Memory That Comes Back

The finale of the long-lived-programs arc: making region blocks actually reclaim what their bodies allocate. Stage one taught containers to own their contents by copying on store. Stage two pointed every value allocation at a thread-local current region, copied block results out, and routed channel payloads through per-message regions. In between, three separate ambushes tried to kill tail calls — a stack struct's escaping address, an inlined asprintf, a thread-local cache with no destructor. At the end, the idiomatic line counter runs in constant memory below wc, and the server that started this part holds flat under four hundred thousand commands.

merememory-modelregionsserverlanguage-design

The problem had been named three episodes ago: per-command values die logically the moment a reply is written, but they are interleaved in one immortal region with the few values that must survive. Any repair has to answer the survivors first — if a scoped region reclaims everything its body allocated, what happens to the string that was stored into a long-lived map, or sent to another thread? Answer carelessly and reclamation becomes a use-after-free generator. So the repair shipped in two deliberate stages, survivors first.

Stage one: containers own their contents

The first release changed no observable behaviour at all, and said so in its own changelog. Stores now copy: map_set deep-copies the key and value into the map’s own region, vector pushes copy the element. The copies go through per-type copy functions specialized exactly the way the derive family always has been — strings copy their bytes, tuples and records and variant nodes copy structurally, scalars and closures pass through, and a nested container copies as a pointer, because a mutable container is an identity, and aliasing it is the point. Strings are immutable, so none of this can be observed by a program; the entire release was a promise about lifetime — whatever you store outlives whoever stored it — made in advance of the machinery that would test it. Boring on purpose, and the precondition for everything after.

Stage two: the scope takes it back

The second release was the payoff. Value allocations — strings, cons cells, variant nodes — stopped hardcoding the immortal region and started targeting a thread-local current region. A region block now makes itself current for its body, so everything the body allocates lands in the block; at exit, the block deep-copies its result out into the enclosing region using stage one’s machinery, and releases everything else. The escape routes enumerated in the design note each got their answer: results copy out; container stores were already safe by stage one; channel sends deep-copy the payload into a per-message region that the receiver drains into its own current region and frees, so a sender’s scratch can die while the message is in flight; closure environments deliberately stay immortal, trading a small known leak for never dangling; and a container cannot be a block’s result — the typer’s existing region-escape check fires first, with a codegen guard behind it. Even the failure path was accounted for: a fail that longjmps past a block restores the current region rather than leaving it pointing at freed memory.

Three ambushes on the way to a tail call

The design worked immediately and the implementation tried to die three times, all in the same place: deep loops. A per-iteration region block in a tail-recursive loop overflowed the stack at ninety thousand iterations — the block’s stack-allocated struct had its address escape into the thread-local, and the C compiler quietly refused to turn the self-call into a jump. Heap-allocating block regions, with a one-deep per-thread cache so a hot loop pays a pointer swap instead of a malloc, removed the escape. The loop still died. Bisecting the emitted C found the second ambush: the integer-formatting helper, inlined into the loop, passes a local’s address to asprintf — same inhibition, new culprit; the formatting family is noinline now. The third ambush waited for the server: memory grew per connection, because a thread’s cached region has no destructor, and a spawn-per-connection server retires threads constantly. The spawn trampoline now frees the cache as each thread finishes. Three different costumes, one lesson: in a language that compiles through C, tail calls are a promise you have to keep on purpose, against the optimizer’s conservatism, one escaped address at a time.

The receipts

Two numbers close the arc. The idiomatic string-dialect line counter — the probe that measured thirty permanent bytes per line — wrapped each line in a region block and ran eight million lines in one and a half megabytes, constant, below wc itself; the same counter had needed a quarter gigabyte two episodes ago. And the key-value server that opened this part adopted a per-command region in its handler and its store owner, then took five rounds of eighty thousand commands over fresh connections: resident memory oscillated around ten megabytes and ended where it began. From abort at eight thousand, to unbounded growth, to flat — with the remaining micro-edge written down as always (an overwrite-heavy store still grows slowly, since a bump region cannot free a replaced value’s old copy). The language that began this part unable to keep a server alive ends it able to run one indefinitely. What a long-lived program demands turned out to be most of what a language is: sound types, honest allocators, and promises kept across threads.

← Back to Mere: Building a Language