| git.druid.rocks | index | druid520 | kaboom | docs/ | superpowers/ | specs/ | 2026-09-30-colosseums-design.md |
docs/superpowers/specs/2026-09-30-colosseums-design.md
# colosseums: nested arenas with kernel-driven lazy reclaim
Date: 2026-09-30
Status: implemented, verified live (boot + real exec/fd exercise)
## Motivation
`alloc.nsc` today is a single parent `heap_arena` with one named child,
`fs_arena`, carved out of it once at boot. Both are plain bump
allocators - `arena_alloc` hands out the next `size` bytes and never
gives any of it back. This is already a deliberate, documented choice
(`alloc.nsc`'s own header: "nothing ever frees"), and the same
philosophy shows up independently in `kfs`'s own block/inode
allocators (`kfs_next_free_block`/`kfs_next_free_inode`, rescanned on
mount rather than freed) and even in directory chains
(`kfs_rmdir`'s comment: "the same bump-allocator-never-frees
philosophy as every other allocator in this kernel").
That's fine for things allocated once and kept forever (the heap
itself, the fs buffer region as a whole). It's a real, growing problem
for `fd.nsc`'s five per-slot file-cache buffers specifically: each one
only ever grows (never shrinks, even across a close+reopen of that
same slot), so a slot that once cached one unusually large file stays
that large forever, wasting `fs_arena` space permanently for every
future use of that slot no matter how small. Enough
`cat`/`cp`/`mv` calls in one boot session already exhausts real,
reclaimable-in-principle memory for good (the exact bug `alloc.nsc`'s
own comment documents finding).
Colosseums generalize the existing one-level parent/child arena shape
into arbitrary nesting, plus a real (but still simple, coarse-grained)
reclaim path: mark a sub-arena "done" when its owner is finished with
it, and the next allocation that would otherwise fail against that
same vector sweeps for anything marked done, wipes it, and reuses the
slot - instead of failing, and instead of ever calling a real `free()`
with all the bookkeeping that implies.
## Goals
- One recursive structure, used at every nesting level - the whole
heap, a subsystem's own region, an individual per-fd buffer slot -
no separate "container" vs "leaf" types.
- A real reclaim path for the one concrete, motivating case above (fd
file-cache buffers), without introducing a real `free()`, a
moving/compacting allocator, or any background task/scheduler this
kernel doesn't otherwise have.
- Fully replace `kalloc`/`fs_alloc` and their `_used`/`_total`/
`heap_arena_limit` accessors - one real way to allocate anywhere in
the kernel, not two names for the same thing living side by side.
## Non-goals
- No true `free()` of an arbitrary pointer at an arbitrary offset -
reclaim always operates on a whole child slot at once.
- No compaction/coalescing/moving of live data. A reclaimed slot is
reused in place; nothing about a still-live arena's address ever
changes.
- No growable vectors. A vector's child count is fixed forever at
creation, matching every other fixed-size structure in this kernel
(the heap's own size, the inode table, buffer caps raised only by
hand when something outgrows them).
- No new generic process/fd lifecycle system. Auto-marking hooks into
the *existing* teardown paths in `exec.nsc`/`fd.nsc` where one
already exists; it does not invent a new one.
## Design
### The struct
One type, `struct arena`, used for every role - the root of a tree
(called a *colosseum* when used that way, e.g. `heap_colosseum`,
`fs_colosseum` - naming/role convention only, not a distinct type) and
every nested arena beneath it (`arena`, `sub-arena`, however deep):
```nsc
struct arena
{
u64 start;
u64 current;
u64 limit;
u64 issued; /* 0 never handed out by arena_child, 1 has been */
u64 done; /* 0 in use (or not yet issued), 1 marked done -
* available for reuse regardless of `issued` */
u64 child_size; /* 0 if this arena has no children (pure leaf) */
u64 child_count; /* 0 if no children */
ptr children; /* -> child_count structs, 0 if no children */
};
```
An arena with `child_count == 0` behaves exactly like today's plain
bump arena. An arena that's been turned into a vector (`child_count >
0`) additionally owns `child_count` further `struct arena`s, each
`child_size` bytes of its own bump-allocatable space - both the child
bookkeeping array and the child data region are carved out of the
*parent* via the same `arena_alloc` everything else uses, so nothing
new is needed to bootstrap a vector's own storage.
**`issued`, added mid-implementation, not in the original draft**: a
child slot's own `current == start` alone can't tell "never handed
out" apart from "handed out and genuinely in use" for a child used as
a flat buffer rather than sub-allocated from - which is exactly how
`fd.nsc`'s own file-cache slots work (the caller never calls
`arena_alloc` on what `arena_child` gives it, so `current` never moves
away from `start` the whole time it's legitimately in use). Caught by
tracing `fd.nsc`'s real usage before trusting the simpler-looking
check, which would otherwise have hidden a real bug: handing the same
still-in-use slot to two different open files at once.
### Core functions
```nsc
void arena_init(ptr a, u64 start, u64 size);
ptr arena_alloc(ptr a, u64 size);
ptr arena_vector(ptr parent, u64 child_size, u64 child_count);
ptr arena_child(ptr vector);
void arena_mark_done(ptr a);
```
- `arena_init` - as today (zero `current` to `start`, set `limit`),
plus zeroing `issued`/`done`/`child_size`/`child_count`/`children`.
- `arena_alloc(a, size)` - unchanged bump-pointer behavior: round up
to 16 bytes, fail cleanly (return 0) if it would exceed `limit`.
This is also the one function `arena_vector` uses internally to
carve out its own child storage - no separate carving path.
- `arena_vector(parent, child_size, child_count)` - carves
`child_count * sizeof(struct arena)` from `parent` for the child
bookkeeping array, then `child_count * child_size` for the children's
own data region, `arena_init`s each child against its own slice of
that region, and returns a `struct arena` (also carved from
`parent`) whose own `child_size`/`child_count`/`children` describe
the new vector. Returns 0 if `parent` doesn't have room for either
carve - same clean-failure contract as `arena_alloc`.
- `arena_child(vector)` - a single scan for a child that's either
`issued == 0` (never handed out at all) or `done == 1` (handed out
once, since marked done) - either counts as available. The first
one found gets `arena_init`'d back to its own start/limit (a no-op
for a genuinely fresh slot, a real wipe for a reclaimed one - same
call either way, no need to tell the two apart), `issued` set to 1,
and is returned. **This scan is the entire "lazy sweep"** - scoped
to exactly this one vector's own direct children, nothing global.
Returns 0 if every slot is currently issued and live (none marked
done) - a clean, visible failure exactly like today's "heap
genuinely full" case.
- `arena_mark_done(a)` - sets `a->done = 1`. Callable on any arena,
leaf or vector. Marking a vector-node done does **not** need to
recurse into its own children first - the whole slot (and whatever
tree was under it) is discarded wholesale the moment its *own*
parent's `arena_child` reclaims it; nothing underneath it is ever
touched again after that point regardless of its own state.
No registry of colosseums, no background sweep task: reclaim only
ever happens synchronously inside `arena_child`'s own scan, scoped to
the one vector being asked for a slot.
### Auto-marking
`arena_mark_done` stays the one real mechanism; it gets called from
the *existing* teardown point that already runs today, not a new
lifecycle system: `fd.nsc`'s close path, on that fd's own file-cache
arena. `exec_filebuf` needs no mark_done call at all - it isn't a
vector, see below.
### Colosseums this introduces
- `heap_colosseum` - replaces `heap_arena`. Same 16MiB region at
0x2000000.
- `fs_colosseum` - replaces `fs_arena`. Same 4MiB carve-out, but now
holds one real vector (fd-cache) plus one single permanent arena
(`exec_filebuf`), instead of being one flat bump region:
- `exec_filebuf`: **not a vector at all** - a single, permanent
65536-byte arena, carved once at `alloc_init` time and reused
forever, no `arena_child`/`arena_mark_done` cycle involved. traced
`exec.nsc`'s real recursion logic first, not assumed: a shebang
chain re-execs through the *same* global `exec_filebuf`, safely,
because the outer call has already parsed everything it needs
(`interp_path`, a stack local) *before* recursing and never
touches `exec_filebuf`'s own content again once the inner call
returns - proven correct by the code that already ships today,
confirmed against `exec_shebang_depth`'s own real 8-level limit
(line 467) too: a vector would need at least 8 concurrent slots to
avoid *regressing* that already-tested bound, when the real fix
for the actual leak (growing a single buffer in place, abandoning
the old one on every size increase) is simpler and needs none of
that - fix the size once, up front, so growth essentially never
happens for any real file. 65536 covers the same "minimum 63488,
rounded" floor `exec.nsc` already enforces today for every
realistic coreutils-sized binary (checked against every real
binary this disk actually ships - the largest, `4c.elf`, is 31391
bytes).
a file that needs *more* than 65536 bytes (the dynamite bootloader
blob is the one documented real case) falls through to a direct,
uncounted `arena_alloc` against `fs_colosseum` itself, exactly
like today - rare and deliberate enough not to need fixed-vector
machinery built around it, matching this project's own "extend
when something actually needs it" precedent (`fs.btft`'s own
tier-format history says the same thing about raising ceilings).
this one case keeps today's exact "abandoned forever" behavior,
undisguised - not every rough edge needs solving in the same
change.
- an fd-file-cache vector: **5 slots of 65536 bytes each** -
`fd.nsc`'s own real, current fd table is exactly 5 hand-declared
slots (`fd_inuse0..4`/`fd_data0..4`/`fd_cap0..4`, fd 3..7, not an
array), each independently grown-in-place with the identical
`fd_buf_need` floor (63488, rounded) `exec_filebuf` uses - so the
same 65536 decision applies here too, for the same reason. a file
needing more than that falls through to the same direct,
uncounted `arena_alloc` escape hatch as `exec_filebuf`'s oversized
case.
worth being honest about what this migration actually buys: since
each fd slot already keeps its own `fd_capN`/`fd_dataN` alive
across a close+reopen of that *same* slot number today, reopening
the same fd index already doesn't leak - the real win here is
replacing 5x hand-duplicated near-identical global-variable
tracking with one real indexed vector, plus finally being able to
reclaim a slot that grew large once back down to normal size
(mark it done on close, get a fresh, minimum-size wipe on next
use) - something today's per-slot globals have no way to do at
all.
## Migration (every real caller today)
Found by direct grep, not assumed:
| file | call site | becomes |
|---|---|---|
| `alloc.nsc` | `arena_init`/`arena_alloc`/`arena_carve` definitions, `alloc_init`, `kalloc`/`fs_alloc`/`kalloc_used`/`kalloc_total`/`fs_alloc_used`/`fs_alloc_total`/`heap_arena_limit` | rewritten per this design; `kalloc`/`fs_alloc` and all `_used`/`_total` accessors removed entirely, not kept as wrappers |
| `idt.nsc:585` | `kalloc(arg1)` (the `sys_alloc` syscall handler) | `arena_alloc(&heap_colosseum, arg1)` |
| `exec.nsc:409` | `fs_alloc(need)` growing `exec_filebuf`/`exec_filebuf_cap` in place | the common case (`need <= 65536`) uses the single, permanent `exec_filebuf` arena directly, no allocation call at all; the rare oversized case falls through to a direct `arena_alloc(&fs_colosseum, need)`, same as today. `exec_filebuf_cap` is removed entirely - the buffer's size is fixed, not tracked. |
| `fd.nsc:192,210,228,246,264` | `fs_alloc(need)` per fd slot, plus the 5x hand-duplicated `fd_inuseN`/`fd_dataN`/`fd_capN` globals backing them | `arena_child()` against the fd-file-cache vector (index = fd - 3, replacing the duplicated globals with one real indexed lookup); mark done on close |
| `paging.nsc:67` | `heap_arena_limit()` | equivalent accessor over `heap_colosseum.limit` |
| `virtfs.nsc:249,251,253,255` | `kalloc_used`/`kalloc_total`/`fs_alloc_used`/`fs_alloc_total` for `/int/mem` | equivalent accessors computed from `heap_colosseum`/`fs_colosseum`'s own fields |
| `kfs.nsc:55` | `extern ptr fs_alloc(u64 size);` | dead declaration, never actually called anywhere in this file - dropped, not migrated |
## Testing/verification approach
This is kernel code with no unit-test harness; verification is real
boot + real exercise, matching how every other change to this kernel
has been verified this project:
- Boot in qemu, confirm `alloc_init` still produces a working heap
(nothing regresses for code paths that don't touch vectors at all).
- Exercise `exec` repeatedly (run several different programs back to
back in one boot session, and a real shebang chain) and confirm the
single `exec_filebuf` arena is reused correctly every time with no
new allocation - `fs_colosseum` usage should not grow at all across
repeated normal execs.
- Exercise fd open/close repeatedly past the fd-cache vector's own
slot count in one boot session (more opens-then-closes than there
are slots) and confirm reuse works instead of failing once every
slot has been touched once.
- Confirm a genuinely-exhausted vector (every slot live, none marked
done) still fails cleanly (returns 0), not silently corrupting
something - the same property `alloc.nsc`'s own original comment
called out ("a clean failure every caller can see, instead of
silently handing out live memory").
- `/int/mem` (virtfs) continues to report sane, real numbers against
the new struct's fields.
## Open questions for the implementation plan
- Whether `heap_colosseum` itself ever needs to become a vector too,
or stays a plain bump arena with `fs_colosseum` as its one
child, as it is today - nothing in the one motivating case (fd
cache) needs the top-level heap itself to be a vector, so the
default is: no, unless something else needs it.
## Implementation finding: a real boot hang, root-caused before shipping
Status: resolved. Recorded here because it changed `alloc.nsh`'s own
shape, not just a footnote.
The first full build of every file in the Migration table booted into
a genuine, reproducible triple-fault loop (`B` printed once per real
boot repeating forever) the moment `sys_exec("sh")` actually ran the
loaded program - every value involved (the file's real bytes read
into `exec_filebuf`, a real ELF magic number, a plausible `entry`
point, real-looking code at both the load address and `entry`) was
individually correct, which is what made this take real, methodical
bisection rather than a quick read-the-diff fix:
1. A from-scratch git worktree checkout of the pre-colosseums commit,
rebuilt and booted in this exact same environment, confirmed
cleanly first - ruling out the environment/qemu itself before
suspecting the migration.
2. Reintroducing the real (not test-only) migrated files one at a
time - `alloc.nsc` alone, then `+exec.nsc`, each with temporary
compatibility shims (`kalloc`/`fs_alloc` as thin wrappers) so the
still-unmigrated callers kept linking - each booted clean on its
own, narrowing the fault to one of the remaining files.
3. Reverting `fd.nsc` alone (the most-restructured file, the most
likely suspect) did *not* fix it. Reverting `idt.nsc` alone did -
despite `idt.nsc`'s own change being the most trivial one-line
rename in the entire migration (`kalloc(arg1)` ->
`arena_alloc(&heap_colosseum, arg1)`), and despite `idt.nsc`'s own
`sys_alloc` handler never even being reached yet (confirmed with a
raw serial beacon placed directly inside it) at the point of crash.
4. Isolating further: the crash reproduced with the *functional*
change reverted back to the original `kalloc(arg1)` call, as long
as `include "alloc.nsh"` itself was still added - the bug was
triggered by the mere presence of certain declarations pulled in by
the include, not by anything `idt.nsc`'s own code actually did with
them.
5. Bisecting `alloc.nsh`'s own contents line by line (struct
definition alone: fine; struct + every function declaration: fine;
`+ global struct arena heap_colosseum`: fine) found the exact
trigger: adding `global struct arena fs_colosseum` - **on its own**,
with no `heap_colosseum` present at all - reproduced the hang.
`heap_colosseum` declared the identical way, alone, never did.
The generated assembly for the two single-global test cases was
**byte-identical apart from the symbol name** (`heap_colosseum` vs
`fs_colosseum`), which rules out a logic bug in `idt.nsc` itself -
this is a real, narrow toolchain-level quirk around redundant
multi-file `.comm` (common symbol) declarations of this specific
struct, triggered by some property of `fs_colosseum` specifically
(name, or declaration count/position across the object files actually
linked) that didn't reproduce for the other three globals in every
combination tried. It was not fully root-caused to the exact
assembler/linker mechanism - past a certain point that stopped being
the efficient use of time, once a clean, principled fix was in hand.
The fix doesn't work around the quirk - it removes the pattern that
exposes it, and is arguably the better design anyway: `alloc.nsh` no
longer blanket-declares all four colosseum globals for every caller
regardless of whether that caller touches them. Each file now declares
only the specific global(s) it actually uses, inline:
- `idt.nsc`: `heap_colosseum` only (`sys_alloc`'s own handler).
- `exec.nsc`: `fs_colosseum` + `exec_filebuf`.
- `fd.nsc`: `fs_colosseum` + `fd_cache_vec`.
- `paging.nsc`/`virtfs.nsc`: neither - they only ever call the
accessor *functions*, which stay in the shared header unchanged.
Re-verified after the fix: a full clean rebuild of every file boots
correctly, and the real functional tests above all pass - repeated
different-program execs hold `fs_colosseum` usage at a constant 393600
bytes (65536 `exec_filebuf` + 320 bytes of vector bookkeeping + 327680
bytes of fd-cache data, matching the design exactly), and 6+ sequential
file opens through a 5-slot fd-cache vector in one boot session hold
that same number steady too, confirming real reuse rather than growth.