From c6f0dd5450bba0b1ed5e87a6a12eef8ea30de216 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 11:36:59 +0700 Subject: [PATCH] A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it, so a stale slice reads a value nobody wrote --- TODO.org | 10 ++++------ docs/BUILT.md | 12 ++++-------- runtime/flan_dev.c | 16 ++++++++++++++++ runtime/flan_rt.c | 12 +++++++++--- spec-memory.md | 9 ++++++--- test/programs/stale-slice-poison.flan | 19 +++++++++++++++++++ test/test_acceptance.ml | 10 ++++++++++ 7 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 test/programs/stale-slice-poison.flan diff --git a/TODO.org b/TODO.org index e46e02ac..0395fa88 100644 --- a/TODO.org +++ b/TODO.org @@ -1296,12 +1296,10 @@ build, because a layout that changes with a build flag can disagree silently across the reload boundary. One addition: a budget, because =retry= needs a handler that can make the same request succeed. -** NEXT The Vec generation word has no reader -Decided 2026-09-25: remove the word, and in a dev build fill a Vec's old buffer with the dead-beef pattern when a push moves it, so a stale slice reads visibly wrong values. No slice layout change; a release build is untouched. Rules out a dev-only word on every slice. -It is bumped on reallocation and read by nothing. The stale-slice trap it exists -for needs a slice that can carry the Vec's identity, and a slice is pointer and -length — so either slices grow a word in a dev build or the trap does not exist. -Today it does not. +** DONE A stale slice reads poison in a dev build +CLOSED: [2026-09-25] +A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it; nothing traps. +Rules out a dev-only word on every slice, which would change the slice layout per build. ** DONE The allocator's budget is not in the spec CLOSED: [2026-09-25] diff --git a/docs/BUILT.md b/docs/BUILT.md index 02803ee5..65d1a0d8 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -2996,14 +2996,10 @@ that counter, and any operation on a container whose recorded epoch has moved tr `test/programs/stale-region.flan` is the case, and the point of it is that `v` is still in scope, still looks fine, and nothing marked it — which is precisely what a static rule cannot see. -**The generation word has no reader.** It is bumped on every reallocation, as specified, and the stale-slice trap it -exists for is not implemented: a slice is ptr+len and has nowhere to carry the Vec's identity or its generation. Said -plainly here rather than implied by the word's presence in the header. - -It is not "not yet", either, and the runtime's own comment now says so. A reader for that word is a third word on every -slice in the language — a layout `spec-memory.md` fixes — so implementing the trap is a spec amendment and an ABI -change, not a runtime patch. The two live options are that amendment, or dropping the word from the header and from the -spec together; neither is a cleanup, and until one is taken the word is carried and trusted by nothing. +**A stale slice is not trapped.** A slice is ptr+len and has nowhere to carry the Vec's identity, and a word on every +slice is a layout `spec-memory.md` fixes. The generation word the header once carried for that trap is gone. What a dev +build does instead is fill a block a resize moved away from with `0xDEADBEEF` words (`flan_dev_poison`), so a slice +taken before the move reads a value nobody wrote instead of the old one. `test/programs/stale-slice-poison.flan`. ### What this leaves for steps 5 to 7 diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index bbd914eb..c84301b0 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -1406,6 +1406,22 @@ void flan_dev_reg_enable(void) { int flan_dev_reg_enabled(void) { return flan_reg_on; } +/* A block a resize moved away from, filled with 0xDEADBEEF words in a dev + * build. A slice is a pointer and a length and carries nothing that could say + * the Vec under it grew, so a slice taken before a push that moved the storage + * still reads the old block — which, unpoisoned, holds exactly the values it + * held, and the stale read looks right. Filled, it reads a value nobody + * wrote. A release build keeps the load and the branch and nothing else. + * Byte-wise at the tail so that an element size that is not a multiple of + * four is still filled to its end. */ +void flan_dev_poison(void *p, int64_t bytes) { + static const uint8_t pat[4] = { 0xEF, 0xBE, 0xAD, 0xDE }; + uint8_t *b = (uint8_t *)p; + int64_t i; + if (!flan_reg_on || p == NULL || bytes <= 0) return; + for (i = 0; i < bytes; i++) b[i] = pat[i & 3]; +} + /* Knuth's multiplicative hash over the address, which is what an allocator * hands out: aligned, and therefore dense in its low bits. */ static size_t flan_reg_slot(uintptr_t a) { diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 51639a60..35e50e56 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -1184,6 +1184,9 @@ void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, const char *type, int64_t typelen); void flan_dev_reg_dead(void *base); void flan_dev_reg_dead_range(void *base, int64_t bytes); +/* flan_dev.c: a dev build fills a block a resize moved away from, so that a + * slice still pointing into it reads visibly wrong values. */ +void flan_dev_poison(void *p, int64_t bytes); /* -- The heap allocator: malloc, realloc, free. ---------------------- */ @@ -1218,6 +1221,7 @@ static void *flan_heap_proc(flan_allocator *a, int32_t mode, void *p, that got here — which is also why a resize needs no note of its own. */ if (p) { flan_dev_reg_dead(p); + flan_dev_poison(p, old_size); free(p); a->live_blocks--; a->live_bytes -= old_size; @@ -1365,8 +1369,10 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p, } q = flan_arena_proc(a, FLAN_ALLOC_ALLOC, NULL, 0, size, align); if (!q) return NULL; - if (p && old_size > 0) + if (p && old_size > 0) { memcpy(q, p, (size_t)(old_size < size ? old_size : size)); + flan_dev_poison(p, old_size); + } return q; /* the old block is not reclaimable */ } case FLAN_ALLOC_FREE: @@ -1817,8 +1823,8 @@ static int8_t flan_vec_grow(flan_vec *v, int64_t want, int64_t size, if (!p) return 0; v->ptr = p; v->cap = cap; - /* Any slice taken before this points at storage that may have moved. The - * word is bumped here and read nowhere yet; see docs/BUILT.md. */ + /* Any slice taken before this points at storage that may have moved; in a + * dev build the allocator has filled the old block (flan_dev_poison). */ return 1; } diff --git a/spec-memory.md b/spec-memory.md index ac5e57d2..86574e44 100644 --- a/spec-memory.md +++ b/spec-memory.md @@ -34,7 +34,7 @@ facility (see plan.org, "Managed classes"). `(clone x)` is the spelling of an independent one. Using a binding after assigning it away is ordinary: the header is still there, and a program that frees through it twice or reads through it after a free misbehaves at run time, - where the allocator and the dev build's generation word are the net. + where the allocator and the dev build's poisoned buffers are the net. ## Maps — first implementation @@ -113,8 +113,11 @@ region" for the rule that stands in its place and for what it costs. wholesale. Same leak as `(length (mk))`, same rule as overwriting a global `Vec`: manual memory management, and the program's business. - **The first implementation follows Zig/Odin's explicit model, not Rust's - borrow checker.** Dev builds carry a generation word on `Vec` and trap on use - of a stale slice. `Ptr` is the explicit lower-level escape hatch and has the + borrow checker.** A slice is pointer and length and carries nothing that + could detect a stale one. A dev build fills a `Vec`'s old buffer with + `0xDEADBEEF` words when a push moves it, so a slice taken before the move + reads visibly wrong values; nothing traps, and a release build does not fill. + `Ptr` is the explicit lower-level escape hatch and has the same lifetime contract. - A future lightweight provenance pass may reject the obvious mistakes (a borrow of a local escaping, use after an owner moves, and reallocation with a diff --git a/test/programs/stale-slice-poison.flan b/test/programs/stale-slice-poison.flan new file mode 100644 index 00000000..660d4711 --- /dev/null +++ b/test/programs/stale-slice-poison.flan @@ -0,0 +1,19 @@ +;;;; A slice taken before a push moved the Vec's storage still points at the +;;;; old block. In a dev build that block is filled with 0xDEADBEEF words when +;;;; the move happens, so the stale read shows a value nobody wrote; a release +;;;; build leaves the old values where they were. +;;;; +;;;; The Vec lives in an arena so the old block stays mapped and the read is of +;;;; live memory. A second arena allocation between the two pushes keeps the +;;;; first block from being grown in place. +(defn main [] i32 + (let [a (arena-new 4096) + v (vec-new i32 a)] + (dotimes [i 4] (push v (+ i 1))) + (let [s (slice v) + other (vec-new i32 a)] + (push other 0) + (push v 5) + (println (at s 0)) + (println (at v 0)))) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 9c4a4397..0d0fdd1f 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -888,6 +888,16 @@ let () = "programs/shim-literal.flan" shim_literal_out; outputs ~x86:true "a literal crosses to C uncopied, --x86" "programs/shim-literal.flan" shim_literal_out; + (* A slice taken before a push moved its Vec reads 0xDEADBEEF in a dev + build and the old value in a release one. *) + let poison = "programs/stale-slice-poison.flan" in + outputs ~dev:true "a dev build poisons a Vec's old buffer" poison + "-559038737\n1\n"; + outputs ~dev:true ~opt:"-O0" "a dev build poisons a Vec's old buffer, -O0" + poison "-559038737\n1\n"; + outputs ~dev:true ~x86:true "a dev build poisons a Vec's old buffer, --x86" + poison "-559038737\n1\n"; + outputs "a release build leaves a Vec's old buffer alone" poison "1\n1\n"; (* Two rendered numbers held at once, which is what one shared buffer in the runtime made impossible: this printed "22 22" and could not have