From ed45ac7c06ae9752b8a12e297f7ec4410c1fe2ce Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 9 Sep 2026 16:25:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(arena):=20poison-on-free=20=E2=80=94=20a=20?= =?UTF-8?q?freed=20block=20can=20never=20pass=20for=20a=20live=20object=20?= =?UTF-8?q?(language=2044)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wo_arena_free stamps the header: class_id = WO_CLS_FREED (0xFFFFFFFF), shard_id = 0xFFFF, flags/pad = 0 - freelist link moves from offset 0 to offset 8 so the poison survives on the list; wo_arena_alloc pops from offset 8 - wo_drop_obj aborts first on a poisoned header: a double free is a diagnostic, not a catchable state - WO_CLS_FREED defined in wob.h beside the builtin id space - test_arena: test_poison_on_free (poison stamped, LIFO chain through the relocated link, class drains to a fresh bump) — 17/0 - full suite SUITE_ALL_ZERO, wovm + wovm_asan rebuilt, just db-actor 10/0 (lang-41 5x marshal gate unchanged) - story 44 status: done; board row + dependency graph L44 (41 -.follow-up.-> 44) Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 78ae3be403ba533db6f0e181bff201717f789a30) --- docs/00-dependency-graph.md | 2 + docs/stories/00-status.md | 1 + .../44-poison-on-free.md | 97 +++++++++++++++++++ runtime/src/gc.c | 9 ++ runtime/src/obj.c | 16 ++- runtime/src/wob.h | 3 + runtime/test/test_arena.c | 25 +++++ 7 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 docs/stories/language-runtime-database/44-poison-on-free.md diff --git a/docs/00-dependency-graph.md b/docs/00-dependency-graph.md index dc2f09a..e412984 100644 --- a/docs/00-dependency-graph.md +++ b/docs/00-dependency-graph.md @@ -304,6 +304,8 @@ flowchart TD P8["porch 8 static files + lifecycle"]:::ready P9["porch 9 idempotent replay (ready — unblocked 2026-09-09)"]:::ready L41["language 41 actor-arena double free ✅ fixed 63065ff (cross-shard marshal)"]:::done + L44["language 44 poison-on-free ✅ (41's decision 3: a freed header can never pass for live; double free aborts)"]:::done + L41 -.follow-up.-> L44 RB --> P2 P2 --> P3 diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md index 9c10c44..674b357 100644 --- a/docs/stories/00-status.md +++ b/docs/stories/00-status.md @@ -1668,6 +1668,7 @@ state replays after a server restart, which tmux loses by design. | 27 | Query grammar from real embedded-DB corpora — whole-query count + correlated exists, driven by the skillhost SQL catalogue; add only what a corpus uses | **no spec yet** — three forks; may collapse to "confirm len(query) + add exists" | | 14 | skillhost host workload — port skillhost (MCP host + confined script runner) to writeonce; drives the missing host capabilities into the open (bounded subprocess, stdin/stdout transport, fs metadata, FFI-vs-out-of-process) | **no spec yet** — gaps recorded in the iteration; each gap brainstormed on demand, bounded-subprocess first | | 42 | [Bounded subprocess](language-runtime-database/42-bounded-subprocess.md) — `proc.run` bounded in place (deadline, output caps, per-shard ceiling, owner-bound reaping via pidfd, fiber parked) + `proc.run_dl`; streaming form deferred by name | ✅ **DONE 2026-09-01, on `master`** — [spec](../superpowers/specs/2026-09-01-bounded-subprocess-design.md) · [plan](../superpowers/plans/2026-09-01-bounded-subprocess.md); test_proc 128/0, `just subprocess` 12/0; see NEXT PLAN | +| 44 | [Poison-on-free](language-runtime-database/44-poison-on-free.md) — language 41's decision 3 as its own iteration: `wo_arena_free` stamps `class_id = WO_CLS_FREED` (0xFFFFFFFF) + `shard_id = 0xFFFF` and keeps the freelist link at offset 8, so a dead block can never read as a live class-0 object; `wo_drop_obj` aborts loudly on a poisoned header (a double free is UB, not a catchable state) | ✅ **DONE 2026-09-09** — test_arena 17/0 (poison stamped, LIFO chain through the relocated link intact), full battery + `just db-actor` unchanged: no live object is ever poisoned | | 17 | library projects + dependency privacy — `wo.toml` kind = "library" (checkable without entry, dual lib+bin) + Go-style `internal/` at the [deps] boundary; framework reorg demonstrates both | ✅ **landed 2026-08-20** — [spec](../superpowers/specs/2026-08-20-library-kind-internal-design.md) · [plan](../superpowers/plans/2026-08-20-library-kind-internal.md) | | 10 | HTTP service layer | [plan 6](../superpowers/plans/2026-08-01-http-service-layer.md) | | 11 | Fibers | vision §3, [blue-green exploration](../plan/exploration/blue-green-vm/00-vision.md) | diff --git a/docs/stories/language-runtime-database/44-poison-on-free.md b/docs/stories/language-runtime-database/44-poison-on-free.md new file mode 100644 index 0000000..f780851 --- /dev/null +++ b/docs/stories/language-runtime-database/44-poison-on-free.md @@ -0,0 +1,97 @@ +--- +track: language-runtime-database +iteration: "44" +status: done +readiness: ready +review_pending: "forks auto-approved 2026-09-09 for autonomous execution — developer second review; design is language 41's decision 3, lifted into its own iteration; landed 2026-09-09 (obj.c poison + offset-8 link, gc.c double-free abort, test_arena 17/0, full suite + db-actor gate green)" +--- + +# 44 — poison-on-free: a freed block can never pass for a live object + +> Split out of [language 41](41-actor-arena-crash.md) (its decision 3), where it +> was named and deferred. Brainstormed to `ready` 2026-09-09; the design is the +> one 41 recorded, made concrete against `runtime/src/obj.c`. + +## Why this exists + +Language 41's hang was a double free that the runtime could not *see*. +`wo_arena_free` pushes a block onto its size class's freelist by writing the +next-pointer over the block's first 8 bytes — exactly the `wo_hdr` fields +`class_id` (0..3), `shard_id` (4..5), `flags`, `pad`. When the block is the +tail of its class that pointer is NULL, so the dead header reads back +`class_id 0, shard_id 0` — a **valid class index on the primary shard**. A stale +drop then ran `class_free` with class 0's field kinds over dead memory, forged +further headers out of freelist links, and self-routed forever. Every one of +those steps would have been an immediate, named trap if a freed block simply +could not look alive. + +That is the whole iteration: stamp a poison on free, and make the drop path +refuse it. It is defensive — 41's marshal fix removed the double free at its +source — but it turns any *future* ownership bug into a one-line diagnostic at +the first stale drop instead of a silent corruption or a spin. + +## Decisions locked + +1. **The freelist link moves to offset 8.** Every arena block is at least 16 + bytes (`round16`; `sizeof(wo_hdr) == 16`, statically asserted), and on a dead + block the header's second 8 bytes — the `borrow`/`gclink` union — carry no + meaning. The link lives there; the first 8 bytes are free for a poison. Both + sites move together: the push in `wo_arena_free` and the pop in + `wo_arena_alloc`. Nothing else reads the link (checked: `obj.c` only). +2. **The poison is a reserved class id plus an impossible shard.** + `class_id = WO_CLS_FREED` (`0xFFFFFFFF`, the one sentinel value the + `WO_CLS_*` space does not use), `shard_id = 0xFFFF` (no shard; also trips + `wo_route_free`'s `>= nshards` guard if a freed block ever reaches it), + `flags = pad = 0`. Stamped on every arena free; a fresh allocation overwrites + the header as it always did, so a *live* object never carries the poison. +3. **`wo_drop_obj` refuses a poisoned header, loudly.** Before the home/route + decision: `class_id == WO_CLS_FREED` is a double free — print the class/shard + and abort. Every drop path (`class_free`, `multi_free`, `map_free`, the + settle loop, teardown) funnels through `wo_drop_obj`, so one check covers all. + It is a fatal, not a catchable trap: continuing past a double free is + undefined behaviour, exactly as language 41's evidence showed. +4. **Malloc-backed blocks are untouched.** Sizes above `WO_ARENA_MAX_CLASS` go to + `free()` and never sit on a freelist; there is nothing to poison and glibc's + own checks apply. +5. **Same-shard and every existing workload are byte-unchanged.** The only + observable change is that a double free now aborts with a message instead of + corrupting. + +## Phases + +- **A — poison + relocate.** `WO_CLS_FREED` in `wob.h`; `wo_arena_free` stamps + the poison and stores the link at offset 8; `wo_arena_alloc` pops from + offset 8. Verify: `test_arena` — a freed block reads `WO_CLS_FREED`/`0xFFFF`; + a same-class allocation hands the same block back; two frees chain in LIFO + order through the relocated link; the malloc path is untouched. +- **B — the refusal.** `wo_drop_obj` aborts on `WO_CLS_FREED`. Verify: the full + runtime battery and the lang-41 fixtures (`just db-actor`) are unchanged — no + live object is ever poisoned. + +## Acceptance Criteria + +- **Given** any arena block, **when** it is freed, **then** its header reads + `class_id == WO_CLS_FREED` and `shard_id == 0xFFFF` until it is reallocated. +- **Given** a freed block, **when** the same size class is allocated, **then** + the freed block is returned and its header is rewritten by the allocator. +- **Given** two freed blocks of one class, **when** the class is allocated + twice, **then** they return in LIFO order — the relocated link chains. +- **Given** a poisoned header reaching `wo_drop_obj`, **when** dropped, + **then** the runtime aborts with a message naming the double free. +- **Given** every existing test binary, corpus fixture and gate, **when** run, + **then** results are unchanged (no live object carries the poison). + +## Out Of Scope + +- **Poisoning the block body** (beyond the header) or guard pages — the header + check is what catches the class of bug 41 exposed; body poisoning is a + debug-build luxury with a real cost. +- **A catchable trap** instead of an abort — a double free is not a recoverable + program state. +- **The remaining language-41 follow-ups** (the `try…catch nil` Int-0 ambiguity, + the `json.decode as T` cross-return corruption) — each its own fixture and fix. + +## Info + +Pure `runtime/src` (obj.c, gc.c, wob.h) plus a `test_arena` KAT. A few lines; +the value is that the next ownership bug announces itself. diff --git a/runtime/src/gc.c b/runtime/src/gc.c index d0372a0..99d1996 100644 --- a/runtime/src/gc.c +++ b/runtime/src/gc.c @@ -74,6 +74,15 @@ static void class_free(wo_rt *rt, wo_hdr *o) { void wo_route_free(wo_hdr *h); void wo_drop_obj(wo_rt *rt, wo_hdr *o) { + /* language 44: a poisoned header is a block already on a freelist — this + * drop is a double free. Continuing is undefined behaviour (language 41's + * hang), so abort with the facts rather than free, route or spin. */ + if (o && o->class_id == WO_CLS_FREED) { + fprintf(stderr, "wovm: FATAL double free — dropping an already-freed " + "object (header poisoned: class_id=0x%x shard_id=%u)\n", + (unsigned)o->class_id, (unsigned)o->shard_id); + abort(); + } if (o && o->shard_id != rt->shard_id && !(o->flags & WO_F_CONST)) { wo_route_free(o); return; diff --git a/runtime/src/obj.c b/runtime/src/obj.c index 45dd283..1de50aa 100644 --- a/runtime/src/obj.c +++ b/runtime/src/obj.c @@ -27,7 +27,10 @@ void *wo_arena_alloc(wo_arena *a, size_t size) { size_t cls = size / 16u - 1u; if (a->freelist[cls]) { void *p = a->freelist[cls]; - memcpy(&a->freelist[cls], p, sizeof(void *)); + /* language 44: the link lives at offset 8 (the dead block's + * borrow/gclink slot); bytes 0..7 carry the WO_CLS_FREED poison, which + * the caller's fresh header write erases. */ + memcpy(&a->freelist[cls], (char *)p + 8, sizeof(void *)); return p; } if (a->used + size > a->cap) return NULL; /* region OOM -> trap upstream */ @@ -44,7 +47,16 @@ void wo_arena_free(wo_arena *a, void *p, size_t size) { return; } size_t cls = size / 16u - 1u; - memcpy(p, &a->freelist[cls], sizeof(void *)); + /* language 44: poison the header so a dead block can never read as a live + * object (a NULL freelist link over class_id/shard_id forged a valid class-0 + * header in language 41's double free). class_id = WO_CLS_FREED, shard_id = + * 0xFFFF (no shard); the freelist link goes at offset 8 instead. */ + wo_hdr *h = (wo_hdr *)p; + h->class_id = WO_CLS_FREED; + h->shard_id = 0xFFFFu; + h->flags = 0; + h->pad = 0; + memcpy((char *)p + 8, &a->freelist[cls], sizeof(void *)); a->freelist[cls] = p; } diff --git a/runtime/src/wob.h b/runtime/src/wob.h index eb5e090..dee48d8 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -163,6 +163,9 @@ _Static_assert(sizeof(wo_hdr) == 16, "object header must be exactly 16 bytes"); #define WO_CLS_STR 0xFFFFFFFCu #define WO_CLS_MAP 0xFFFFFFFDu #define WO_CLS_MULTI 0xFFFFFFFEu +/* language 44: stamped into a block's class_id by wo_arena_free so a dead block + * can never pass for a live object (wo_drop_obj aborts on it). Never allocated. */ +#define WO_CLS_FREED 0xFFFFFFFFu /* iteration 19: Bytes reuses the wo_str object layout byte for byte (header, * len, bytes) and differs ONLY in this header class_id. That is deliberate: * every allocation, drop, and copy path already handles the shape, while the diff --git a/runtime/test/test_arena.c b/runtime/test/test_arena.c index 20e6f2f..2c8a7ab 100644 --- a/runtime/test/test_arena.c +++ b/runtime/test/test_arena.c @@ -39,9 +39,34 @@ static void test_large_bypasses_region(void) { wo_arena_destroy(&a); } +/* language 44: a freed block's header is poisoned (class WO_CLS_FREED, shard + * 0xFFFF) so it can never pass for a live object; the freelist link lives at + * offset 8 and must still chain in LIFO order. */ +static void test_poison_on_free(void) { + wo_arena a; + T_EQ(wo_arena_init(&a, 4096), 0); + wo_hdr *p = wo_arena_alloc(&a, 48); + wo_hdr *q = wo_arena_alloc(&a, 48); + T_CHECK(p != NULL && q != NULL && p != q); + p->class_id = 7; p->shard_id = 3; p->flags = 0; p->pad = 0; /* "live" */ + wo_arena_free(&a, p, 48); + T_CHECK(p->class_id == WO_CLS_FREED); /* poisoned on free */ + T_CHECK(p->shard_id == 0xFFFFu); + wo_arena_free(&a, q, 48); /* q now heads the list, links to p */ + T_CHECK(q->class_id == WO_CLS_FREED); + /* LIFO through the relocated (offset-8) link: q first, then p */ + T_CHECK(wo_arena_alloc(&a, 48) == q); + T_CHECK(wo_arena_alloc(&a, 48) == p); + /* the class is drained: the next allocation is a fresh bump, not a stale block */ + void *r = wo_arena_alloc(&a, 48); + T_CHECK(r != NULL && r != p && r != q); + wo_arena_destroy(&a); +} + int main(void) { test_size_class_reuse(); test_region_oom_returns_null(); test_large_bypasses_region(); + test_poison_on_free(); return t_report("test_arena"); }