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
|
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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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;
|
"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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user