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 across the reload boundary. One addition: a budget, because =retry= needs a
handler that can make the same request succeed. handler that can make the same request succeed.
** NEXT The Vec generation word has no reader ** DONE A stale slice reads poison in a dev build
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. CLOSED: [2026-09-25]
It is bumped on reallocation and read by nothing. The stale-slice trap it exists A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it; nothing traps.
for needs a slice that can carry the Vec's identity, and a slice is pointer and Rules out a dev-only word on every slice, which would change the slice layout per build.
length — so either slices grow a word in a dev build or the trap does not exist.
Today it does not.
** DONE The allocator's budget is not in the spec ** DONE The allocator's budget is not in the spec
CLOSED: [2026-09-25] 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 `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. 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 **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
exists for is not implemented: a slice is ptr+len and has nowhere to carry the Vec's identity or its generation. Said slice is a layout `spec-memory.md` fixes. The generation word the header once carried for that trap is gone. What a dev
plainly here rather than implied by the word's presence in the header. 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`.
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.
### What this leaves for steps 5 to 7 ### 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; } 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 /* Knuth's multiplicative hash over the address, which is what an allocator
* hands out: aligned, and therefore dense in its low bits. */ * hands out: aligned, and therefore dense in its low bits. */
static size_t flan_reg_slot(uintptr_t a) { 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); const char *type, int64_t typelen);
void flan_dev_reg_dead(void *base); void flan_dev_reg_dead(void *base);
void flan_dev_reg_dead_range(void *base, int64_t bytes); 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. ---------------------- */ /* -- 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. */ that got here — which is also why a resize needs no note of its own. */
if (p) { if (p) {
flan_dev_reg_dead(p); flan_dev_reg_dead(p);
flan_dev_poison(p, old_size);
free(p); free(p);
a->live_blocks--; a->live_blocks--;
a->live_bytes -= old_size; 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); q = flan_arena_proc(a, FLAN_ALLOC_ALLOC, NULL, 0, size, align);
if (!q) return NULL; 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)); 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 */ return q; /* the old block is not reclaimable */
} }
case FLAN_ALLOC_FREE: 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; if (!p) return 0;
v->ptr = p; v->ptr = p;
v->cap = cap; v->cap = cap;
/* Any slice taken before this points at storage that may have moved. The /* Any slice taken before this points at storage that may have moved; in a
* word is bumped here and read nowhere yet; see docs/BUILT.md. */ * dev build the allocator has filled the old block (flan_dev_poison). */
return 1; 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 `(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 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, 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 ## 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 wholesale. Same leak as `(length (mk))`, same rule as overwriting a global
`Vec`: manual memory management, and the program's business. `Vec`: manual memory management, and the program's business.
- **The first implementation follows Zig/Odin's explicit model, not Rust's - **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 borrow checker.** A slice is pointer and length and carries nothing that
of a stale slice. `Ptr` is the explicit lower-level escape hatch and has the 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. same lifetime contract.
- A future lightweight provenance pass may reject the obvious mistakes (a - 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 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; "programs/shim-literal.flan" shim_literal_out;
outputs ~x86:true "a literal crosses to C uncopied, --x86" outputs ~x86:true "a literal crosses to C uncopied, --x86"
"programs/shim-literal.flan" shim_literal_out; "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 (* 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 the runtime made impossible: this printed "22 22" and could not have