The Race the Types Allowed

The server episode ended with a debt: a shared mutable map had crossed a thread boundary with the type checker's blessing and lost writes under load. Paying that debt meant two fixes, and the second was found only by insisting the first hadn't worked. Mutable containers are now neither Send nor Sync — and the compile path, it turned out, had never been running the safety analyses at all: programs the interpreter rejected sailed through the compilers untouched.

meresoundnessconcurrencysend-synclanguage-design

The rule this project holds itself to is that a measured soundness hole outranks every other kind of work, so the debt from the server episode came due before any new feature: the type system had allowed a mutable map to be shared across threads, and the map had raced. The design was never in question. The language’s concurrency discipline classifies every captured value as Sync (share it), Send (move it), or neither (reject the capture), and region-bound mutable containers were always meant to be thread-local — the documentation said so in as many words. The classifier just didn’t implement the intention. Its generic rule judged a constructor by its type arguments, a container’s first argument is its region marker, a region marker is an unresolved type variable, and the rule treated unresolved as safe. Optimism in exactly the wrong place: the one kind of value whose runtime is a lock-free array being mutated in place.

The fix that changed nothing

The repair looked like three lines: Map, Vec, and the string builder are now explicitly neither Send nor Sync, before any generic rule can look at their arguments. The owned-vector type keeps its move semantics — it is a drop type, single-owner by construction, and crossing a thread by moving is exactly what it is for. Channels stay Send and Sync, because they are the blessed pattern. The satisfying part should have been re-running the server’s naive draft and watching it fail to compile. It compiled. The classifier was right, the move checker was right, and the program still built — which meant the problem was no longer what the analyses concluded but whether anyone asked them.

The checks that never ran

The answer was embarrassing in the way only infrastructure bugs are. The interpreter’s pipeline ran the full sequence: type inference, then the channel-element Send obligations, the borrow-conflict check, and the spawn-capture move analysis. The compile entry point — shared by the C, LLVM, and WebAssembly backends — ran type inference and went straight to code generation. Every safety analysis the language had built for concurrency and regions was simply skipped when producing a binary. A program that captured a region borrow in a spawned thread was rejected by mere run and built without comment by mere -c — and had been, for as long as both paths existed. The fix was to make the compile path run the same three analyses before any backend sees the program. With that, the naive server draft finally failed to compile, with the error it should always have had: cannot capture a Map across a thread boundary, it is neither Send nor Sync.

The server as proof

Two details close the episode properly. First, the actor-model server — the workaround from three episodes ago — compiles unchanged under the stricter rules, because channels are Send and the store never crosses a thread: the workaround was, all along, the design the types now enforce. It sits in the test suite as the living proof that the blessed pattern survives. Second, the honest asymmetry: this hole was found not by an audit but by a program that raced, which is the dogfood method working exactly as intended — and also a reminder that it only finds what you build. A soundness claim is a claim about all programs; a dogfood only ever checks one more. With the type system now saying no in the right places, the last debt standing was the big one — the memory that never came back — and the next two episodes pay it.

← Back to Mere: Building a Language