A function that returns or stores into a global an address or slice of its own frame is refused, a dev free-all poisons any arena's released bytes and ASan sees them released, and @sanitize checks stack use after return

This commit is contained in:
Joseph Ferano 2026-09-25 20:33:11 +07:00
parent 89e4f53895
commit c6a3259a62
8 changed files with 314 additions and 18 deletions

View File

@ -868,13 +868,12 @@ correct code, so any per-push flag is a false positive by the language's own
semantics; the refined version needs liveness across control flow, which is the
flow tracking that was repealed.
** NEXT Catching a use-after-release statically
Decided 2026-09-25 (91): build (A) Odin's unsafe-return refusal — returning (addr local), (slice local-array …) or (addr (at local-array i)); (B) the same test on a set into a global; (C) dev fills a fixed arena's freed bytes with poison on free-all; (D) detect_stack_use_after_return=1 for @sanitize. Rules out a with-allocator escape check: the runtime epoch check catches it and a static rule flags building into the caller's arena. Probes: p1-p16 of the study.
Decided 2026-09-25: a study, not a build — how arena memory escapes in real Flan code, and whether a sound lexical check would catch most of it. The result goes in docs/BUILT.md; nothing is built on it without the author.
Open, and for the first time with evidence available: the epoch trap is built, and
there is a =Vec= to write real arena programs with, so whether the escapes that
actually occur are lexical can now be answered. The next thing to look at, not the
next thing to build.
** DONE Catching a use-after-release statically
CLOSED: [2026-09-25]
Studied (p1-p16); the compile-time checks that fit are returning, or storing into a global, an address or slice of the function's own frame. Rules out lexical tracking of arena and heap releases: the rest of the escapes the study found are the runtime epoch check's and the dev poison's.
** CANCELLED A with-allocator escape check
The runtime epoch check already catches it, and a static rule flags the normal idiom of building into the caller's arena.
** DONE (vec-new [u8]) is refused
CLOSED: [2026-09-25]
@ -1177,6 +1176,9 @@ dev build wipes it at a top-level agent poll; an expression run at a stop gets a
* Runtime
** TODO Heap quarantine in dev
A dev build could hold freed heap blocks back from reuse for a while (Zig's debug allocator), so a read after a free sees poison rather than a new owner's data.
** DONE An index out of range is a condition
CLOSED: [2026-09-13]
A failed bounds check signals =BoundsError=; a handler can answer it, a

View File

@ -13415,6 +13415,187 @@ let escaping_names ~returns (body : Ast.expr list) : string list =
if returns then (match List.rev body with x :: _ -> tails x | [] -> ());
!names
(* A value handed out of [f] that points into [f]'s own frame: returned (the
last form's tails, or a [return]), or stored into a global or a field or
array element of one. Both read a dead frame the moment [f] returns, so
both are refused.
Deliberately narrow — the exact spellings that can only be wrong, so
nothing that could be valid is ever refused:
- [(addr p)] where [p] is a local, a field of one, or an element of a
local *array* (an array's elements are the frame's bytes; a Vec's or a
slice's are not);
- [(slice a …)] where [a] is such a place of array type;
- a local bound by [let] to one of those and never assigned or addressed
afterwards, which is also how a [return] under a [defer] arrives here.
A parameter is a local: its value is copied into the frame (an array
parameter too), so its address dies with the frame as well. Anything
reached through a [Ptr] is not the frame's, and a struct literal holding
such an address is not looked into.
The store is let through in two cases where it can be right. In [main],
whose frame outlives everything the program runs. And where the function
sets that same global again elsewhere — the stash-then-restore shape, the
global pointed at the frame only while the frame was live. *)
let refuse_frame_escapes (f : Tast.fn) =
(* Keyed by slot alone: [fresh_slot] never reuses one, so a slot has at
most one binding [Let] in the function. *)
let binds = Hashtbl.create 16 in
let unstable = Hashtbl.create 16 in
let global_sets = Hashtbl.create 8 in
let rec place_global = function
| Tast.Pglobal g -> Some g
| Tast.Pfield (t, _) -> expr_global t
| Tast.Pindex (t, idx) when all_array t.Tast.ty idx -> expr_global t
| _ -> None
and expr_global (e : Tast.expr) =
match e.Tast.e with
| Tast.Global g -> Some g
| Tast.Field (t, _) -> expr_global t
| Tast.Prim (Tast.At, t :: idx) when all_array t.Tast.ty idx -> expr_global t
| _ -> None
(* [(at a i j)] is one node carrying every index; each level stepped must
be an array for the element to be inside [a]'s own bytes. *)
and all_array ty = function
| [] -> true
| _ :: rest ->
(match ty with Types.Array (_, el) -> all_array el rest | _ -> false)
in
List.iter
(Tast.walk (fun (e : Tast.expr) ->
match e.Tast.e with
| Tast.Let (bs, _) -> List.iter (fun (s, v) -> Hashtbl.replace binds s v) bs
| Tast.Set (Tast.Plocal s, _) | Tast.Addr (Tast.Plocal s) ->
Hashtbl.replace unstable s ()
| Tast.Set (p, _) ->
Option.iter
(fun g ->
Hashtbl.replace global_sets g
(1 + Option.value ~default:0 (Hashtbl.find_opt global_sets g)))
(place_global p)
| _ -> ()))
f.Tast.body;
(* The local at the root of a place inside this frame, if it is one. *)
let rec root_expr (e : Tast.expr) =
match e.Tast.e with
| Tast.Local s -> Some s
| Tast.Field (t, _) -> root_expr t
| Tast.Prim (Tast.At, t :: idx) when all_array t.Tast.ty idx -> root_expr t
| _ -> None
in
let root_place = function
| Tast.Plocal s -> Some s
| Tast.Pfield (t, _) -> root_expr t
| Tast.Pindex (t, idx) when all_array t.Tast.ty idx -> root_expr t
| _ -> None
in
(* What escapes: the root local, whether it is a slice (else an address),
and whether the place is the bare local rather than a path into it —
only then can the fix be spelled with its name alone. *)
let rec escapes depth (e : Tast.expr) =
match e.Tast.e with
| Tast.Addr p ->
Option.map
(fun s ->
(e, (s, `Addr, (match p with Tast.Plocal _ -> true | _ -> false))))
(root_place p)
| Tast.Prim (Tast.Slice, [ t; _; _ ]) ->
(match t.Tast.ty with
| Types.Array _ ->
Option.map
(fun s ->
( e,
(s, `Slice,
(match t.Tast.e with Tast.Local _ -> true | _ -> false)) ))
(root_expr t)
| _ -> None)
| Tast.Local s when depth < 32 && not (Hashtbl.mem unstable s) ->
Option.bind (Hashtbl.find_opt binds s) (escapes (depth + 1))
| _ -> None
in
(* A slot with no name is a value the function made for itself, such as
the array a literal like [(slice [7 8 9])] is stored in. *)
let what (s, kind, exact) =
match f.Tast.snames.(s), kind, exact with
| Some n, `Slice, true -> "a slice of " ^ n
| Some n, `Slice, false -> "a slice of an array inside " ^ n
| Some n, `Addr, true -> "the address of " ^ n
| Some n, `Addr, false -> "an address inside " ^ n
| None, `Slice, _ -> "a slice of a temporary array"
| None, `Addr, _ -> "the address of a temporary"
in
(* Reported at the addr or slice itself: a [return] under a [defer], or a
local bound to one, reaches here as a read of a slot, and the caret
belongs on the form that took the address. *)
let fail ~verb ~target ~fix_slice ~fix_addr
((e : Tast.expr), ((s, kind, exact) as hit)) =
let fix =
match f.Tast.snames.(s), kind, exact with
| Some n, `Slice, true ->
fix_slice ("wrap the slice in clone, as in (clone (slice " ^ n ^ "))")
| _, `Slice, _ -> fix_slice "wrap the slice in (clone ...)"
| _, `Addr, _ ->
let pointee =
match e.Tast.ty with
| Types.Ptr (_, t) -> Types.to_string t
| t -> Types.to_string t
in
fix_addr pointee
in
let whose =
match f.Tast.snames.(s) with
| Some n -> Printf.sprintf "%s is a local of %s" n f.Tast.name
| None -> "That value lives in " ^ f.Tast.name ^ "'s frame"
in
Loc.failk "check/frame-escape" e.Tast.loc
"%s %s %s%s. %s, and its storage is gone once %s returns, so every \
later read through it reads whatever the next call leaves there. %s"
f.Tast.name verb (what hit) target whose f.Tast.name fix
in
let rec tails (e : Tast.expr) =
match e.Tast.e with
| Tast.Do es | Tast.Let (_, es) | Tast.WithAlloc (_, es) ->
(match List.rev es with x :: _ -> tails x | [] -> ())
| Tast.If (_, a, b) -> tails a; tails b
| Tast.Match (_, arms) ->
List.iter
(fun (a : Tast.arm) ->
match List.rev a.Tast.abody with x :: _ -> tails x | [] -> ())
arms
| _ ->
Option.iter
(fail ~verb:"returns" ~target:""
~fix_slice:(fun c ->
"Return a copy the caller owns: " ^ c
^ ", which puts the elements in the context allocator")
~fix_addr:(fun t ->
"Return the value instead: declare " ^ f.Tast.name
^ " to return " ^ t ^ " and drop the addr"))
(escapes 0 e)
in
let returns = not (Types.equal f.Tast.ret Types.Unit) in
List.iter
(Tast.walk (fun (e : Tast.expr) ->
match e.Tast.e with
| Tast.Return (Some v) when returns -> tails v
| Tast.Set (p, v) when not (String.equal f.Tast.name "main") ->
(match place_global p with
| Some g when Hashtbl.find_opt global_sets g = Some 1 ->
Option.iter
(fail ~verb:"stores" ~target:(" into the global " ^ g)
~fix_slice:(fun c ->
"Store a copy that outlives the frame: " ^ c
^ ", which puts the elements in the context allocator")
~fix_addr:(fun t ->
"Store the value instead: declare " ^ g ^ " as " ^ t
^ " and drop the addr"))
(escapes 0 v)
| _ -> ())
| _ -> ()))
f.Tast.body;
if returns then
match List.rev f.Tast.body with x :: _ -> tails x | [] -> ()
let rec check_fn env (fn : Ast.fn) : Tast.fn =
let params, ret = Hashtbl.find env.fns fn.Ast.name in
let ctx = { (invented_ctx env ret) with owner = fn.Ast.name } in
@ -13545,6 +13726,7 @@ let rec check_fn env (fn : Ast.fn) : Tast.fn =
| None -> body
| Some s -> defer_counter_zero s fn.Ast.nloc :: body
in
let checked =
{ Tast.name = fn.Ast.name; params;
slots = Array.of_list (List.rev ctx.slot_tys);
snames = Array.of_list (List.rev ctx.slot_names);
@ -13558,6 +13740,9 @@ let rec check_fn env (fn : Ast.fn) : Tast.fn =
| None -> ctx.defers
| Some s -> guarded_defers s ctx.defers);
fenv = None; fparent = None; floc = fn.Ast.nloc }
in
refuse_frame_escapes checked;
checked
(* The generic body, checked once with its variables abstract. Nothing is kept
— the [Tast.fn] it produces is thrown away, and so is anything it lifted —

View File

@ -1858,6 +1858,30 @@ static flan_allocator flan_heap = {
#define FLAN_VG_MAKE_MEM_UNDEFINED(p, n) ((void)(p), (void)(n))
#endif
/* The same fact told to AddressSanitizer: an arena's released bytes are
* poisoned at free-all, so a read through a slice kept past it reports under
* --sanitize instead of printing what is there. Every path that hands arena
* bytes back out — the bump, a resize in place, the temp arena's text fast
* path — unpoisons exactly what it hands out, and every path that writes
* over released bytes itself (the dev fill, a chunk dropped or freed)
* unpoisons first. */
#if defined(__SANITIZE_ADDRESS__)
#define FLAN_ASAN 1
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define FLAN_ASAN 1
#endif
#endif
#ifdef FLAN_ASAN
void __asan_poison_memory_region(void const volatile *addr, size_t size);
void __asan_unpoison_memory_region(void const volatile *addr, size_t size);
#define FLAN_ASAN_POISON(p, n) __asan_poison_memory_region((p), (size_t)(n))
#define FLAN_ASAN_UNPOISON(p, n) __asan_unpoison_memory_region((p), (size_t)(n))
#else
#define FLAN_ASAN_POISON(p, n) ((void)(p), (void)(n))
#define FLAN_ASAN_UNPOISON(p, n) ((void)(p), (void)(n))
#endif
/* -- The arena: one fixed backing buffer and a bump offset. ----------
*
* `free-all` is retain-capacity: offset = 0, the pages stay. That is an
@ -1923,6 +1947,7 @@ static void flan_arena_drop_old(flan_arena *ar) {
flan_chunk *c = ar->old;
ar->old = c->next;
flan_dev_reg_dead_range(c->base, c->cap);
FLAN_ASAN_UNPOISON(c->base, c->cap);
flan_dev_poison(c->base, c->cap);
free(c->base);
free(c);
@ -1955,6 +1980,7 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p,
if (end > ar->peak) ar->peak = end;
a->live_blocks++;
a->live_bytes += size;
FLAN_ASAN_UNPOISON(ar->base + start, size);
return ar->base + start;
}
case FLAN_ALLOC_RESIZE: {
@ -1974,6 +2000,7 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p,
ar->offset = end;
if (end > ar->peak) ar->peak = end;
a->live_bytes += size - old_size;
FLAN_ASAN_UNPOISON(p, size);
return p;
}
q = flan_arena_proc(a, FLAN_ALLOC_ALLOC, NULL, 0, size, align);
@ -2005,11 +2032,14 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p,
cheaper if the second could be skipped. */
flan_arena_drop_old(ar);
flan_dev_reg_dead_range(ar->base, ar->cap);
/* The temp arena's wipe, in a dev build, fills what was handed out with
the pattern a moved Vec's old buffer gets, so text kept past its frame
without a clone reads as garbage rather than as last frame's value. */
if (ar->grow) flan_dev_poison(ar->base, ar->offset);
/* A dev build fills what was handed out with the pattern a moved Vec's
old buffer gets, so a slice or text kept past the free-all reads as
garbage rather than as the last round's values — the temp arena and a
program's own arena alike. A release build leaves the bytes. */
FLAN_ASAN_UNPOISON(ar->base, ar->offset);
flan_dev_poison(ar->base, ar->offset);
FLAN_VG_MAKE_MEM_UNDEFINED(ar->base, ar->cap);
FLAN_ASAN_POISON(ar->base, ar->cap);
ar->offset = 0;
a->live_blocks = 0;
a->live_bytes = 0;
@ -2259,6 +2289,7 @@ void flan_arena_destroy(flan_allocator *a) {
a->epoch++;
flan_arena_drop_old(ar);
flan_dev_reg_dead_range(ar->base, ar->cap);
FLAN_ASAN_UNPOISON(ar->base, ar->cap);
free(ar->base);
free(ar);
flan_header_retire(a);
@ -2519,7 +2550,10 @@ static int8_t flan_temp_text(flan_render render, const void *x,
ar = (flan_arena *)a->data;
if (a->budget <= 0 && ar->cap - ar->offset >= FLAN_NUM_BYTES) {
q = ar->base + ar->offset;
FLAN_ASAN_UNPOISON(q, FLAN_NUM_BYTES);
len = fit(render(x, (char *)q, FLAN_NUM_BYTES));
/* The tail the text did not use goes back to released. */
FLAN_ASAN_POISON(q + len, FLAN_NUM_BYTES - len);
ar->offset += len;
if (ar->offset > ar->peak) ar->peak = ar->offset;
a->live_blocks++;
@ -2752,9 +2786,9 @@ void flan_vec_as_slice(flan_vec *v, void *out, int32_t lo, int32_t hi,
}
/* spec-memory.md's first release point. The Vec is left zeroed rather than
* dangling — the checker has already made using it afterwards a compile error,
* and zeroing costs nothing and makes a bug that slips past the checker a null
* deref rather than a use-after-free. An allocator without can-free keeps the
* dangling: a later use of it is then a null deref rather than a
* use-after-free, and a slice taken of it before the free is the dev
* registry's to catch. An allocator without can-free keeps the
* block: releasing it is free-all's job, and pretending otherwise here is the
* silent-no-op this file refuses elsewhere. */
void flan_vec_free(flan_vec *v, int64_t size, int64_t align,

View File

@ -134,7 +134,8 @@
(deps
(alias corpus)
test_sanitize.exe
; ASAN_OPTIONS=detect_leaks=1 asks the leak question; see test_sanitize.ml.
; ASAN_OPTIONS=detect_leaks=1:detect_stack_use_after_return=1 asks the leak
; question and keeps the frame check; see test_sanitize.ml.
(env_var ASAN_OPTIONS)
; The dyn runtime's C main, which is the one thing in this sweep that is not
; a Flan program: flan_dyn.c has no Flan spelling yet. It is also the one

View File

@ -0,0 +1,8 @@
;;;; A slice kept past its arena's free-all. A dev build fills the released
;;;; bytes with 0xDEADBEEF, so the element reads -559038737; a release build
;;;; leaves the old value, 7.
(defn main [] i32
(let [a (arena-new 4096) v (vec-new i32 a)]
(push v 7)
(let [s (slice v)] (free-all a) (println (at s 0))))
0)

View File

@ -1087,6 +1087,14 @@ let () =
outputs ~dev:true "a dev free-temp poisons the wiped text" tg (tg_out "239");
outputs ~dev:true ~x86:true "a dev free-temp poisons the wiped text, --x86"
tg (tg_out "239");
let fp = "programs/arena-free-all-poison.flan" in
outputs "a release free-all leaves the arena's bytes" fp "7\n";
outputs ~dev:true "a dev free-all poisons a program's own arena" fp
"-559038737\n";
outputs ~dev:true ~opt:"-O0" "a dev free-all poisons a program's own arena, -O0"
fp "-559038737\n";
outputs ~dev:true ~x86:true
"a dev free-all poisons a program's own arena, --x86" fp "-559038737\n";
(* A dev build's agent poll is a frame boundary and wipes the temp
allocator itself: a loop that polls and never calls free-temp does not
fill the registry. *)

View File

@ -2203,7 +2203,10 @@ let () =
"(defn mk [] [3 i32] [7 8 9]) (defn f [] [i32] (slice (mk) 0 3))"
~needle:"a temporary the slice would outlive";
accepts "slice of an array literal"
"(defn f [] [i32] (slice [7 8 9]))";
"(defn f [] i32 (at (slice [7 8 9]) 0))";
rejects_check "returning a slice of an array literal"
"(defn f [] [i32] (slice [7 8 9]))"
~needle:"f returns a slice of a temporary array";
(* One builtin, one answer about a bound. A slice bound is a subscript and
goes through [index_expr] like every other one, so a u32 is admitted on
an array exactly as it always was on a Vec, and an i64 is refused by name
@ -3356,6 +3359,57 @@ let () =
rejects_check "a dead-beef pattern out of u32 range"
"(defn f [] () (let [a (array 4 u8)] (set a (dead-beef 0x1DEADBEEF))))"
~needle:"does not fit in u32";
(* A pointer or slice into the frame, handed out of it. *)
rejects_check "returning the address of a local"
"(defn mk [] (Ptr i32) (let [x (i32 42)] (addr x)))"
~needle:"mk returns the address of x";
rejects_check "returning the address of a local with return"
"(defn mk [] (Ptr i32) (let [x (i32 42)] (return (addr x))))"
~needle:"mk returns the address of x";
rejects_check "returning the address of a local under a defer"
"(defn mk [] (Ptr i32) (let [x (i32 42)] (defer (println 1)) \
(return (addr x))))"
~needle:"mk returns the address of x";
rejects_check "returning a slice of a local array"
"(defn mk [] [i32] (let [a [1 2 3 4]] (slice a)))"
~needle:"mk returns a slice of a";
rejects_check "returning a local bound to a slice of a local array"
"(defn mk [] [i32] (let [a [1 2 3 4] s (slice a 1 3)] s))"
~needle:"(clone (slice a))";
rejects_check "returning a slice of an array parameter"
"(defn mk [a [4 i32]] [i32] (slice a))"
~needle:"mk returns a slice of a";
rejects_check "returning the address of a local array's element"
"(defn mk [] (Ptr i32) (let [a [1 2 3 4]] (addr (at a 2))))"
~needle:"mk returns an address inside a";
rejects_check "returning the address of a local from one if arm"
"(defn mk [c bool] (Ptr i32) (let [x (i32 1)] (if c (addr x) (addr x))))"
~needle:"declare mk to return i32";
rejects_check "storing a slice of a local array into a global"
"(defonce g [i32]) (defn stash [] () (let [a [5 6 7]] (set g (slice a))))"
~needle:"stash stores a slice of a into the global g";
rejects_check "storing a local field's address into a global"
"(defstruct P [x i32 y i32]) (defonce g (Ptr i32)) \
(defn stash [] () (let [p (P 1 2)] (set g (addr (.y p)))))"
~needle:"declare g as i32";
(* And what is left alone: storage that outlives the frame, a slice of a
Vec, a pointer passed in, a stash the function restores, and main. *)
accepts "a slice of a Vec is returned"
"(defn mk [] [i32] (let [v (vec-new i32)] (push v 1) (slice v)))";
accepts "an element of a slice parameter is returned"
"(defn mk [s [i32]] (Ptr i32) (addr (at s 0)))";
accepts "a slice of a global array is returned"
"(defonce k [3 i32]) (defn mk [] [i32] (slice k))";
accepts "a local reassigned before it is returned"
"(defonce k [3 i32]) \
(defn mk [] [i32] (let [a [1 2 3] s (slice a)] (set s (slice k)) s))";
accepts "a global stashed and restored"
"(defonce g [i32]) (defonce k [3 i32]) \
(defn f [] () (let [a [1 2 3]] (set g (slice a)) (set g (slice k))))";
accepts "main stores its own local into a global"
"(defonce g (Ptr i32)) (defn main [] i32 (let [x (i32 5)] (set g (addr x))) 0)";
accepts "a unit function's last form is not returned"
"(defn f [] () (let [x (i32 4)] (addr x)))";
(* A fill is never a value the linker can write into the image, so a
defconst of one is refused by the constant rule rather than by anything
of this feature's own. A defonce is fine: its initialiser runs at

View File

@ -43,10 +43,13 @@ let scratch = Test_support.scratch
by design — [rt_args] says so in its own comment — so LeakSanitizer here
produces a suppression list and no information. Set in the environment
rather than baked in, so a session asking the leak question can ask it.
[detect_stack_use_after_return] moves frames to a fake stack that is
poisoned when the function returns, so a pointer or slice into a frame that
has returned reports rather than reading whatever the next call left.
[print_stacktrace] is what turns a UBSan report from a source line into
something with a caller in it, and it is off by default. *)
let env =
"ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0} \
"ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0:detect_stack_use_after_return=1} \
UBSAN_OPTIONS=${UBSAN_OPTIONS:-print_stacktrace=1} "
let run exe args =
@ -486,7 +489,8 @@ let dev_session () =
let fd = Unix.openfile log [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600 in
let env =
Array.append (Unix.environment ())
[| "ASAN_OPTIONS=detect_leaks=0"; "UBSAN_OPTIONS=print_stacktrace=1" |]
[| "ASAN_OPTIONS=detect_leaks=0:detect_stack_use_after_return=1";
"UBSAN_OPTIONS=print_stacktrace=1" |]
in
let pid =
Unix.create_process_env flan