Beskid blog · Runtime
gc-arena: The Runtime Spike That Taught Us to Retreat
Before Beskid had a runtime, it had a research spike. The gc-arena crate asked: can we build a garbage collector out of Rust arena allocators? The answer was 'probably, but not yet' — and that pattern of honest retreat became the project's default stance.
← All postsBefore the project was called Beskid, before the compiler had its own repository, before there was a platform spec or a Book or a CI pipeline — there was Pecan. And in Pecan, there was gc-arena.
What gc-arena was
Section titled “What gc-arena was”The gc-arena crate was a research spike with a single question: can you build a garbage collector out of Rust arena allocators? Rust arenas — bump allocators that free everything at once when the arena is dropped — are fast, simple, and safe. A generational GC built on arenas would inherit that simplicity: allocate into a nursery arena, promote survivors to an older arena, collect by dropping the nursery. No tracing, no mark-sweep, no write barriers. Just arena lifecycle management dressed up as generational collection.
The crate was a workspace member. It had scaffolding. It had documentation. It had tests that sketched the API: Gc<T>, GcCell<T>, allocation in arena scopes, a root set, collection triggers. It looked plausible.
Internally, the crate was structured around a single Arena type that held a bump allocator and a slot array. Allocating a Gc<T> bumped the arena pointer and returned an index into the slot array — not a raw pointer. This indirection was the design’s central insight: because handles were indices, not pointers, the runtime could relocate objects by updating the slot array without invalidating any Gc<T> value held by user code. The nursery collection path was a single call to Arena::reset(), which zeroed the bump pointer and cleared the slot list. The spike didn’t implement promotion — it didn’t need to, because the research question was about the allocation path, not the full generational cycle. What it proved was that arena-based allocation could be both fast (a single bump per allocation) and safe (no use-after-free, because the borrow checker enforced that the arena outlives all handles).
The API sketch was specific, and every piece had a clear job. Gc<T> was the managed heap pointer — an opaque handle the runtime could relocate without invalidating references, because all access went through a layer of indirection the user never saw. GcCell<T> was the mutable variant: interior mutability with a runtime borrow check that would trap on double-mutable-borrow rather than corrupt the heap, exactly the semantics a safe language needs. The root set was the explicit registry of stack and global references the collector treated as live — anything reachable from a root survived; anything unreachable was dead. Collection triggers were the heuristics: nursery fullness crossed a threshold, allocation rate spiked past a watermark, or the user called collect() explicitly. Together these four concepts — allocate, mutate, root, collect — formed a minimal but complete GC surface. The spike had the right shape. It just didn’t have the runtime underneath it.
What we learned
Section titled “What we learned”It was plausible but not ready. The gap between “arenas can approximate nursery collection” and “a correct GC for a language with fibers, channels, and FFI” was larger than the crate could bridge in a spike. Several specific problems emerged:
- Arena lifetimes and GC roots. A GC root can outlive any single arena scope. Consider a concrete case: a fiber spawns, allocates a linked list in its nursery arena, sends the list head through a channel to a global logger, then exits. The fiber’s nursery is collected — the arena is dropped, the bump pointer resets, the memory is gone. But the global logger still holds a reference to the list head, and that reference is a GC root. The arena lifetime ended with the fiber’s stack frame; the root lifetime extends across the entire program. Arena-based collection works when lifetimes are stack-like — strictly nested, parent outliving child. GC-managed heaps require the opposite: any object can point to any other, across any scope boundary, and liveness is determined by reachability from roots, not by lexical scope. The arena model got the allocation path right but the lifetime model wrong.
- Promotion precision. Moving an object from nursery to tenured space requires knowing every pointer to it — every stack slot, every field of every other object that holds a reference. You need a pointer map: a bit-level description of which words in an object are references and which are raw data. Arena bump allocators give you allocation speed but not pointer maps. Building pointer maps means the compiler has to emit type layout information into the binary, and the runtime has to parse it at collection time. The gc-arena spike had no compiler integration — it couldn’t generate pointer maps, so it couldn’t actually promote. It could allocate and it could drop, but the middle step — knowing what to move — was missing.
- Integration surface. The GC has to know about stack maps from Cranelift, about fiber stacks, about what the ABI says is a root. Stack maps tell the collector which stack slots contain live managed pointers at each safepoint. Fiber stacks are individually scheduled — each fiber is a root set, and a suspended fiber’s entire stack is live. The ABI defines which registers hold GC references across calls. An arena crate can’t answer any of these questions alone — they require the compiler to emit metadata, the linker to preserve it, and the runtime to parse it. The gc-arena spike was an island. A real GC is a peninsula connected to the compiler mainland.
The commit message told the truth: “Add gc-arena workspace members and runtime GC scaffolding documentation.” Scaffolding existed. Documentation existed. The claim did not. There was no “we have a GC.” No “experimental GC available.” Just: we looked at this, here is what we found, here is what’s missing, moving on.
That commit — one of the later ones in the Pecan monorepo before the Beskid rename — didn’t delete the crate. It left it in the workspace, tests passing, API documented, with a README.md that opened with “This is a research spike, not a production GC.” The honesty was structural. Anyone cloning the repo would see the crate, read the README, and know exactly where the boundary was between what existed and what was claimed.
The three problems above weren’t bugs. They were gaps — things the spike wasn’t designed to solve. And the decision to name them, write them down, and walk away rather than paper them over with “experimental” labels was the real deliverable. The spike proved that arena-based allocation was fast and ergonomic. It also proved that the compiler wasn’t ready to supply what the GC needed. Both findings were valuable. Most projects only report the first.
The pattern that stuck
Section titled “The pattern that stuck”The decision to retreat from gc-arena was not failure. It was the first instance of a pattern that would define the project:
- Try something. Build the spike, write the code, get it to compile.
- Document what you learned. Not just what worked — what didn’t, and why.
- Retreat if it’s not ready. No “experimental” labels, no half-finished features shipped to production.
- Keep the scaffolding. The design notes and API sketches from
gc-arenafed directly into theabfallGC design two months later.
This is not a pattern you find in startup culture. It is closer to how research labs work: the artifact is not the code, it is the understanding. The code is disposable; the design notes are permanent. gc-arena was the first time the project chose the notes over the code, and it set the cultural expectation that retreat with documentation is a deliverable, not a failure.
This pattern — try, learn, document, retreat — became the default stance for every runtime decision after. It is why the v0.3 GC landing was deferred instead of shipped broken: the runtime team built the collector, ran it against the full test suite, found a data race in fiber root scanning, and pulled the feature from the release notes the day before the tag. It is why the ISLE shift in July 2026 was a boundary change, not a rewrite: the team spent two weeks implementing the same instruction selection logic in both the old pattern-match tier and the new ISLE tier, confirmed ISLE produced identical codegen for the entire corelib test suite, then deleted the old path in a single commit. It is why the project has a Book chapter called Memory without another billion-dollar mistake instead of a wiki page called “GC is hard.”
What survived
Section titled “What survived”gc-arena the crate is gone from the workspace. But when the abfall GC design landed two months later, it reused the gc-arena design notes directly. The Gc<T> / GcCell<T> type distinction survived intact — abfall’s public API exports those same two types with the same contracts. The root set concept became the formal RootSet registry that the Cranelift codegen backend populates from compiled stack maps. The collection trigger heuristics — nursery size threshold, allocation-rate watermark — were parameterized as fields on GcConfig. Even the internal module structure mirrored gc-arena’s layout: collector.rs, arena.rs, root.rs. The spike’s architecture had been correct; it had simply been premature. The retreat wasn’t a rejection — it was a deferral to when the compiler could hold up its end.
This is the quiet achievement of the retreat pattern: it turns spikes from throwaway code into deferred architecture. The gc-arena crate didn’t ship, but its design did — through abfall, through the Book chapter, through the test suite. Every runtime spike that followed (the fiber scheduler spike, the channel buffer spike, the FFI marshalling spike) used the same template: a workspace crate, a README that states the research question, tests that sketch the API, and a documented retreat when the spike had answered its question. The project learned to treat “we tried this and it’s not ready” as a legitimate outcome — not a failed sprint, but a completed investigation.
The Book chapter doesn’t just name-check gc-arena, either. Memory without another billion-dollar mistake Section 10.3 (“The Nursery Model”) describes the arena-based nursery as the default allocation path — the exact mechanism gc-arena prototyped. Section 10.4 (“Root Scanning”) formalizes the root-set concept the spike first articulated into the language specification. And the chapter’s title itself is the retreat pattern in architectural form: don’t ship a GC until you can prove it doesn’t repeat the billion-dollar mistakes of every GC that came before.
The nursery lives in the design. The honesty lives in the process.
And the concrete example? The fiber that sent a linked list through a channel and then exited — that exact case became a test in the abfall test suite. test_fiber_channel_escape: spawn a fiber, allocate in its nursery, send through a channel, let the fiber exit, verify the receiver can still traverse the list. It passes. The arena couldn’t handle it. The GC can. The spike told us what the test should be, two months before we could write it.
There is a deeper point here about language design. The gc-arena spike got the API right — Gc<T>, GcCell<T>, roots, triggers — but the mechanism wrong. Arena lifetimes couldn’t express the reachability semantics the API demanded. That is the normal state of systems programming: the API is the aspiration, the mechanism is the reality, and the gap between them is where you do the work. The project could have compromised the API to fit the mechanism — “GC only works within fiber scopes” — and shipped something. It didn’t. It kept the API and waited for the mechanism to catch up. When abfall landed, the API was already designed. The mechanism just had to implement it.
That is what the retreat pattern really preserves: not just honesty, but architectural coherence across time. The spike defines the target. The retreat preserves the target. The eventual implementation hits the target. The gc-arena crate was the first proof that this works. It wasn’t the last.