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.