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
|
P8["porch 8 static files + lifecycle"]:::ready
|
||||||
P9["porch 9 idempotent replay (ready — unblocked 2026-09-09)"]:::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
|
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
|
RB --> P2
|
||||||
P2 --> P3
|
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" |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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_route_free(wo_hdr *h);
|
||||||
|
|
||||||
void wo_drop_obj(wo_rt *rt, wo_hdr *o) {
|
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)) {
|
if (o && o->shard_id != rt->shard_id && !(o->flags & WO_F_CONST)) {
|
||||||
wo_route_free(o);
|
wo_route_free(o);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ void *wo_arena_alloc(wo_arena *a, size_t size) {
|
||||||
size_t cls = size / 16u - 1u;
|
size_t cls = size / 16u - 1u;
|
||||||
if (a->freelist[cls]) {
|
if (a->freelist[cls]) {
|
||||||
void *p = 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;
|
return p;
|
||||||
}
|
}
|
||||||
if (a->used + size > a->cap) return NULL; /* region OOM -> trap upstream */
|
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;
|
return;
|
||||||
}
|
}
|
||||||
size_t cls = size / 16u - 1u;
|
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;
|
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_STR 0xFFFFFFFCu
|
||||||
#define WO_CLS_MAP 0xFFFFFFFDu
|
#define WO_CLS_MAP 0xFFFFFFFDu
|
||||||
#define WO_CLS_MULTI 0xFFFFFFFEu
|
#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,
|
/* 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:
|
* 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
|
* 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);
|
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) {
|
int main(void) {
|
||||||
test_size_class_reuse();
|
test_size_class_reuse();
|
||||||
test_region_oom_returns_null();
|
test_region_oom_returns_null();
|
||||||
test_large_bypasses_region();
|
test_large_bypasses_region();
|
||||||
|
test_poison_on_free();
|
||||||
return t_report("test_arena");
|
return t_report("test_arena");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue