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

This commit is contained in:
Joseph Ferano 2026-09-25 11:36:59 +07:00
parent de8e00314b
commit c6f0dd5450
7 changed files with 68 additions and 20 deletions

View File

@ -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]

View File

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

View File

@ -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) {

View File

@ -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;
}

View File

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

View File

@ -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)

View File

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