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:
parent
3b1a188d77
commit
ed45ac7c06
7 changed files with 151 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
97
docs/stories/language-runtime-database/44-poison-on-free.md
Normal file
97
docs/stories/language-runtime-database/44-poison-on-free.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue