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.
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.