Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

TrackHow it calls RustWho reads the headerDrift shows up as
Swiftclang module over the headerthe compilera compile error
Pythoncffi, fed the real header at runtimethe script, as datacan’t drift — but nothing verifies the header
Dartdart:ffi typedef pairsnobody — hand-transcribed from ita silently wrong answer
KotlinJNA Library interfacenobody — hand-mappeda 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:

  • Pythonif __name__ == "__main__":.
  • Kotlin — a top-level fun main() compiles into a class named after the file: solve.kt becomes SolveKt, first letter capitalized. The recipe runs java … SolveKt; guess SolveKT or Solve and you get ClassNotFoundException from a jar that compiled perfectly.
  • Swift — top-level statements are legal in exactly one file per module. solve.swift gets away with it because it is compiled alone; move it into a package and it must be main.swift, or grow @main on a type.
  • Dartvoid 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):

  1. no such module 'Foundation' — installed in the profile, but swiftc searches only its own toolchain path.
  2. missing required modules: 'CoreFoundation', 'Dispatch' — the package providing them was never installed.
  3. cannot load underlying module for 'Dispatch' — installed, but its C headers live in a separate output of the package that wasn’t.
  4. 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.