writeonce/docs/stories/language-runtime-database/44-poison-on-free.md
shoney.arickathil ed45ac7c06 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)
2026-09-15 01:16:13 +02:00

5.3 KiB

track iteration status readiness review_pending
language-runtime-database 44 done ready 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 (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.