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:
parent
de8e00314b
commit
c6f0dd5450
10
TODO.org
10
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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
19
test/programs/stale-slice-poison.flan
Normal file
19
test/programs/stale-slice-poison.flan
Normal 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)
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user