fix(arena): poison-on-free — a freed block can never pass for a live object (language 44)

- 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 <noreply@anthropic.com>
(cherry picked from commit 78ae3be403ba533db6f0e181bff201717f789a30)
This commit is contained in:
shoney.arickathil 2026-09-09 16:25:50 +02:00
parent 3b1a188d77
commit ed45ac7c06
7 changed files with 151 additions and 2 deletions

View file

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

View file

@ -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) |

View file

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

View file

@ -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;

View file

@ -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;
}

View file

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

View file

@ -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");
}