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

97 lines
5.3 KiB
Markdown

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