Step -1: Machine Provisioning
Before anything else, your machine has to be able to run the workshop.
Everything lives in a single file at the repo root: shell.nix — but only
five tools are required: rustc, cargo, cbindgen, a C compiler, and
just. The one extra in the box, cheat, is cheatsheets for the FFI
patterns we’ll hit — useful, and safe to ignore. How the required
five get onto your machine is your choice; the
repo README walks each
path in detail. In brief:
The four paths
-
macOS / Linux — Nix. Install Nix (the Determinate installer is the least fuss), then
nix-shellin the repo root. Optionally add direnv + nix-direnv so the environment loads automatically oncd— that’s what the repo’s.envrcis for. -
Windows — WSL2. Nix doesn’t run natively on Windows, but WSL2 is Linux.
wsl --install, then follow the Nix path inside your distro. Clone the repo inside WSL, not on/mnt/c. -
Docker — VS Code devcontainer. Nothing on your machine but Docker. “Reopen in Container” builds Nix + home-manager inside; first build takes minutes, later opens are fast.
-
💀 Entirely manual. Install every tool yourself with your package manager of choice. It works, but versions are on you.
Why a self-check exists
The point of this workshop is FFI, not fighting your linker. The most common
way an environment looks fine but isn’t: the compiler binary exists, but
the SDK paths behind it are broken (a macOS upgrade is the classic cause).
command -v cc passes; actually compiling fails.
So verification isn’t “is the tool installed” — it’s “does the toolchain do its job”:
just check
This runs scripts/self-check.sh, which verifies every required tool and
compiles and links a real C executable before declaring your machine ready. Red
rows come with the fix command inline.
Once the required rows are green, step -1 is done. The ○ rows belong to
the next step.
Version Control
Workshop note: this step is already handled for you — we bypass it. The repo’s ignore rules cover every language track (Rust, C, Python, Swift, Kotlin/JNA, Dart) plus OS noise, even though Exercise 3 only needs one track. Spare ignore rules cost nothing, and trying a second language is an extension exercise — so nothing you build during the workshop will ever show up as git noise. This chapter stays as the why.
Git needs no introduction—it’s the ubiquitous version control system you already know. This chapter isn’t about teaching Git itself, but about why the very first commit matters more than you might think.
The Initial Commit
The template this repository was cloned from started with a single commit containing only a .gitignore:
# Ignore build outputs from performing a nix-build or `nix build` command
result
result-*
# Ignore automatically generated direnv output
.direnv
That’s it. No code, no configuration—just ignore rules.
Why This Matters
This template is a Nix-based development environment, but the principle applies universally: get your .gitignore right from the very beginning.
Step Zero, Not Step One
Most tutorials treat .gitignore as an afterthought—something you add when you notice unwanted files sneaking into your commits. This is a mistake, and the bill comes due the first time you need to rewrite history.
The Cost Shows Up Later
Every interesting Git operation replays old commits: rebase reapplies your work onto a new base, cherry-pick lifts one commit somewhere else, bisect checks out arbitrary points in the past to find where a bug appeared.
If your early commits contain files that should have been ignored—build artifacts, editor configs, generated files—each of those operations drags them along. You get conflicts in files nobody edited on purpose, because two branches both regenerated the same target/ directory differently. You get diffs where three real lines hide inside three hundred generated ones. And bisect starts checking out revisions whose committed build output doesn’t match the source, so the build you’re testing isn’t the build you think it is.
Removing the files later doesn’t undo this. A .gitignore added in commit fifty doesn’t retroactively clean commits one through forty-nine—the objects are still in history, still replayed by every rebase, still cloned by everyone. Getting them out means git filter-repo and a force-push that rewrites every hash, which is a bad afternoon and a worse conversation with your collaborators.
By establishing ignore patterns in the literal first commit, you ensure that:
- No garbage ever enters the repository - Build outputs, cache directories, and environment-specific files are excluded from day one
- History stays clean - Rebasing, cherry-picking, and bisecting work smoothly
- The pattern is established - Contributors see immediately that this project takes repository hygiene seriously
For Any Project
Use https://github.com/github/gitignore as a starting point.
While this template uses Nix, the same principle applies everywhere:
- Node.js: Ignore
node_modules/,.next/,dist/ - Python: Ignore
__pycache__/,*.pyc,.venv/,*.egg-info/ - Rust: Ignore
target/
The specific patterns vary; the principle doesn’t. Start clean, stay clean.
- https://git-scm.com/docs/gitignore
- https://www.kernel.org/pub/software/scm/git/docs/gitignore.html
The Day Library
Your toolchain works and your repo is clean. Now you need something to put across an FFI boundary.
That something is a day: one Advent of Code puzzle, solved in pure Rust, one
crate per day under days/. The workshop’s subject is the pipeline — Rust → C
glue → bindings — not any particular puzzle, so the days are plug-in content.
You pick one and carry it through the stages.
Pick from the menu, not from the directory listing
The menu lives in
days/README.md,
next to the crates it indexes. Read it there rather than browsing the tree: the
column that matters is not the puzzle, it’s the boundary shape — what has
to cross the FFI boundary once the Rust part is done.
A scalar in and a scalar out is one exercise. A list of strings is a different one. A fixed-size top-three array is different again, and the interesting question arrives before you write any C: does it come back as a returned struct, or do you hand the callee a pointer to fill in?
Two days on the menu are marked golden. A golden day has the whole pipeline worked through, in every language track, so when you get stuck there is always a finished pattern to mirror rather than a blank file. There are two so that no track depends on a single example.
Running one
The days recipes are a just module, so they run from the repo root:
just days test 2023-12-01 # one day, against the puzzle's published examples
just days run 2023-12-01 # one day, against your own input
just days verify # every day: tests, formatting, lints
just days verify is the one to run before you push. The per-day recipes take
one day; the day crates share one lockfile, so a change to a shared dependency
can break a day you never touched.
Your own puzzle input
Puzzle inputs are yours, not the repo’s: they are tied to your AoC account, and Advent of Code asks that they not be redistributed. So no real input and no puzzle text is ever committed here — only the small examples printed in the problem statements, which is what the tests assert against.
Download yours and drop it at days/inputs/<YYYY-MM-DD>.txt. .gitignore
already covers that directory, and each day reads its file at run time from a
path anchored to the crate, so just days run works and a day with no input
still builds and tests.
What a day looks like inside
Three files, and only three things to write:
impl FromStr for Day— parse the input into whatever shape the puzzle actually wants. The scaffold starts line-based, which is a fine default and rarely the final answer.part1andpart2on theSolutiontrait.- The tests — paste the example from the problem statement and delete the
#[ignore].
just days new 2019-12-04 scaffolds those from days/_template. Nothing needs
registering afterwards: membership is a glob, so cargo, rust-analyzer and CI all
pick the new day up by its existing.
And feel free to ignore my opinionated setup and use your own! The scaffold, the recipes, the editor config, even the crate layout are how I like to solve these — none of it is what the workshop is teaching. If you already have a way you enjoy writing Rust, bring it. All the FFI steps need from you is a crate that builds and a function worth calling from another language.
Read them critically
These are solved days, written by hand over several years, and they do not all
look alike. Some parse with split_once and some with nom; most return errors
and a couple reach for .expect(); two of them are older solutions carried in
from another repo and still carry the seams — a todo!() here, an
unimplemented!() there, a half-finished second implementation kept because it
is interesting.
That unevenness is on purpose. The interesting question at an FFI boundary is rarely “is this idiomatic Rust” — it is “what happens to this particular shape when a caller in another language holds it, and what does a panic look like from over there?” A tidy day and a scruffy day answer that differently. Notice which is which as you read.
CI: Verifying on Borrowed Machines
Step -1 made you responsible for your machine. This page
is about the machines nobody is responsible for: CI runners. The repo’s
Verify workflow runs the same scripts/self-check.sh you ran at home,
on GitHub’s stock images — and the interesting part is everything those
images already contain that we never asked for.
The dirty environment
A GitHub runner is not a clean machine. It arrives with rustc and cargo
(current stable, on all three OSes), working C toolchains (gcc, Apple
clang, even MinGW on Windows), a JDK and kotlinc, Swift on two of the
three images, and a Python that — on macOS — can already import cffi.
None of that was provisioned by this repo. It is convenience, shipped by
GitHub for the median workflow, and it quietly lifts our provisioning
responsibility — right up until an image update takes something away.
That’s the deal, and it’s worth stating plainly: preinstalled toolchains
are a courtesy, not a contract. Nothing upstream promises kotlinc
will be there next month, and nothing on our side pins it.
This has a recognizable failure signature. When a matrix cell that has been green for months goes red and our diff is empty, we didn’t break it — the borrowed machine changed underneath us. Knowing that signature in advance is most of the diagnosis.
Mapping before trusting
Before the workflow asserted anything, it ran the self-check bare on every OS × track cell, no setup steps at all, to map what the images actually cover. Findings, as of the mapping run:
| linux | macos | windows | |
|---|---|---|---|
| rustc/cargo (1.85 floor) | ✅ | ✅ | ✅ |
| C compiler + linker | ✅ | ✅ | ✅ MinGW |
| cbindgen | ❌ | ❌ | ❌ |
| Kotlin/JNA | ✅ | ✅ | ✅ |
| Swift | ✅ | ✅ | ○ |
| Python (cffi) | ○ | ✅ | ○ |
| Dart | ○ | ○ | ○ |
Two things the map taught us that guessing would not have: cbindgen is
the single required tool no image ships — and native Windows is far
closer to workshop-ready than our own “use WSL2” answer assumes (the
script never even reached its Windows failure hint; MinGW compiles and
links).
The provisioning policy that falls out
- Provision what no image ships.
cbindgen, installed explicitly in every cell, prebuilt and pinned. This is the one install that turns the required-toolchain rows green everywhere. - Provision tracks the way the workshop does. The Python cell builds
the same repo-local
.venvthatjust setup-pythonbuilds — CI should exercise the attendee’s recipe, not a shortcut that only works on runners. Dart gets the officialsetup-dartaction, because the workshop’s own answer is written for humans with package managers. - Don’t provision what the image ships — but keep the fix on ice. Kotlin and Swift are green for free today. The workflow carries their full provisioning steps commented out, checksummed and (for Kotlin) already validated, so the day an image update drops them, the fix is uncommenting a block — not archaeology under time pressure.
- Never bend the self-check to flatter the runner. The script’s exit
code is the attendee’s contract: missing
cbindgenfails, because at the workshop it would fail you. CI meets the contract by provisioning; a red cell on an unprovisioned runner is the map working, not a bug.
Why the matrix is unusually small Today
Billed minutes round up per job, and macOS bills at 10×, Windows at 2× —
so the full 18-cell grid costs ~78 billed minutes per push while a
linux-only column costs ~6. The full map only needs taking when the
question is “what do the images ship?”; day-to-day pushes only ask “did
we break something?”, and linux answers that at 1×. The grid widens
back to three OSes for pre-workshop sweeps, one os: line away.
Failure Is Not an Option — It’s Mandatory
Not required reading. The book is not the workshop — not yet, anyway. It is supporting material, gathered in advance for the kind of person who likes to research a thing before turning up. I am that kind of person, which is why it exists. If you arrive having read none of it, you are not behind.
This chapter in particular is a record of things going wrong, written after they went wrong. Reading it first will not stop them happening to you, and it is not meant to. Take it now if that is how you prepare, or leave it and come back when something breaks and you want the diagnosis.
Every error in this chapter is one we hit, in the order we hit it. None of them are here as warnings to help you avoid them. They’re here because meeting them is the exercise.
The day is the easy part. You solved it in Rust, the tests are green, and the answer is right. Now something written in another language has to call it, and the interesting problems start — almost none of them in your puzzle logic.
This chapter is the record of one boundary shape — a C API generated by
cbindgen, status codes and out-parameters — crossed from four languages:
Python over cffi, Kotlin over JNA, Swift through a clang module, and Dart
through dart:ffi. Everything below actually happened, in that order, on the
machines this repo ships.
One header, four treaties
Every track builds against the same generated header. What they differ in is who actually reads it:
| Track | How it calls Rust | Who reads the header | Drift shows up as |
|---|---|---|---|
| Swift | clang module over the header | the compiler | a compile error |
| Python | cffi, fed the real header at runtime | the script, as data | can’t drift — but nothing verifies the header |
| Dart | dart:ffi typedef pairs | nobody — hand-transcribed from it | a silently wrong answer |
| Kotlin | JNA Library interface | nobody — hand-mapped | a wrong answer, or a runtime lookup error |
Enforced, consumed, copied, remembered. Only Swift keeps the guarantee the
Exercise 2 C harness has — #include, then a compiler — and the other three
trade it away for runtime convenience, each in a different way. Kotlin’s own
harness says it plainest, in its own comment: of the four tracks, it is the
least checked.
Errors: C has no Result
The boundary reports failure as a status code and writes the answer through an out-parameter — never as a magic value folded into the answer itself, because any value a sentinel could use is a value some input could legitimately produce. Two kinds of code, because these are different problems with different owners:
-1— the boundary refused your input: a NULL pointer, or bytes that are not valid UTF-8.- a day-specific code (
-2,-3) — the input was fine and the solver failed on it: no digits on a line, an overflowing product.
Collapsing them would tell a caller to check their encoding when the real answer is “your input is corrupt in a way this code can’t survive.”
The day that earned this section is 2021-12-02, which promises -3 when the
product overflows an i32 — and shipped a version that only kept that promise
in debug builds. The guard was “let the overflow panic, catch the unwind”:
overflow only panics where overflow-checks is on, which is the dev profile’s
default and not release’s, so the build most likely to be shipped wrote a
wrapped number and returned 0. A guard can look present and not be — the fix
was checked_* arithmetic, which cannot care what profile it runs in. The
next day’s C API (2023-12-01) was built with checked arithmetic from the
start, which is the only part of that lesson that transfers for free.
And note what a status code does not do: force anyone to look at it. Every
caller in every track can ignore a -1 and use whatever the out-parameter
holds. That is the C ABI’s actual offer, which is why each track’s harness
checks the status and exits nonzero — the discipline lives in the caller,
because nothing else will hold it.
Panics don’t cross — they detonate
A Rust panic unwinding across an extern "C" frame is undefined behavior.
The caller doesn’t get an exception, it gets a corpse — or worse, it gets
nothing and keeps running.
This repo’s answer is not “catch it.” It is: the C surface must not panic,
by construction — every fallible step returns Result, every overflow is
checked — and on the days whose solvers can fail at all, catch_unwind sits
behind that as a seatbelt, because this is a frame where being wrong about
“cannot panic” costs undefined behavior rather than a bad answer.
The receipt that this is worth the trouble: 2023-12-01’s solver scanned lines
by byte offset and sliced at every position. On a line containing any
multi-byte character — é1 is enough — the slice lands inside a character and
panics. No Rust caller could ever reach it: puzzle inputs are ASCII, both
statement examples are ASCII, the suite was green. Exercise 2 hands the same
function a const char * that has been checked for valid UTF-8 — which
é1 is — and passes it straight in. The bug was found and fixed before the C
API landed on that day, and the moral survives it: valid UTF-8 and
panic-free are different promises, and the boundary is where the difference
stops being theoretical.
Every language has its own front door
Four harnesses, four unrelated ways to say “start here”, none of them FFI:
- Python —
if __name__ == "__main__":. - Kotlin — a top-level
fun main()compiles into a class named after the file:solve.ktbecomesSolveKt, first letter capitalized. The recipe runsjava … SolveKt; guessSolveKTorSolveand you getClassNotFoundExceptionfrom a jar that compiled perfectly. - Swift — top-level statements are legal in exactly one file per module.
solve.swiftgets away with it because it is compiled alone; move it into a package and it must bemain.swift, or grow@mainon a type. - Dart —
void main(), like C. The familiar-looking one, right up until the library path.
You will meet all four in one afternoon, and none of them will be the thing you thought you were learning.
The toolchain is the other half of the exercise
Kotlin is JNA, not JNI. The track calls the cdylib through JNA, so
jna.jar must be on the classpath at compile and run time. There is no
pip or pub on this track to fetch it, so the recipe pulls it from Maven
Central — pinned and checksummed, because an unpinned jar downloaded over the
network and then executed is a different kind of thing from a pinned one.
A module resolving is not the same as its library loading. Getting Swift
to work in this repo’s Nix container took four failures that all named
Foundation or Dispatch, and each was a different problem
(.devcontainer/swift/README.md has the full ladder):
no such module 'Foundation'— installed in the profile, butswiftcsearches only its own toolchain path.missing required modules: 'CoreFoundation', 'Dispatch'— the package providing them was never installed.cannot load underlying module for 'Dispatch'— installed, but its C headers live in a separate output of the package that wasn’t.cannot find -ldispatch— headers found; the shared objects live in a different directory of the same package than the Swift module does.
The lesson generalizes past Nix: compiling and linking want different directories, and the error text will name the same module for both. Failure 4 reads like failure 3 not being fixed. It isn’t.
Where the library is at run time is a third question again. Python, Dart
and Kotlin each compute the cdylib’s path themselves — debug first, then
release — and load it at runtime. Swift asks the linker instead: two
-L/-rpath pairs, which is the compiled-language spelling of exactly the
same search.
Failure is the curriculum
A workshop where everything works teaches you that everything works. You would leave able to follow a recipe, and stuck the first time your input has a trailing newline.
So the failures are not obstacles between you and the exercise — they are the
exercise. The C boundary hands back -1 and lets you ignore it. A Dart
typedef with one wrong type compiles and corrupts silently — the treaty,
unenforced. A C-library variant whose build probe leaks out of its feature
gate turns every machine without that library red, which is why the variants
ship off by default and the gate is checked before anything is pushed. Every
one of those is a question you now know to ask, and you only know it because
it broke in front of you.
If you get to the end of a track and nothing went wrong, you were lucky, not
finished. Break it on purpose: feed the C entry point a NULL and check you get
-1, not a crash. Hand 2021-12-02 a course big enough to overflow and check
you get -3 — in a release build, where the shipped version once didn’t.
Mistype one Dart typedef and watch it compile anyway. Then you have actually
seen the boundary.
Your Rust being correct buys you less than you’d think. The boundary is where the ownership rules, the error conventions, the encoding assumptions and the toolchain’s opinions all have to be made explicit — and every one of them is a decision someone makes, not a fact you discover.
That is why the days are deliberately uneven. A tidy day and a scruffy day teach different things at the boundary, and the scruffy one teaches more.