Four dyn values in a defer the collector had never been told about

A defer is in the typed IR twice -- spliced into the body for the normal path,
and again in fdefers for the path a transfer leaves through -- so a dyn
temporary inside one is emitted twice. dyn_roots counted only the body's, and
the second copy went into slots nothing had rooted.

Nothing failed, and that is the whole reason this is worth a commit of its own.
dyn_tmp falls back to a plain slot rather than unbalancing the stack, so the
pushes and the pops still matched, the program ran and printed the right answer,
and the values were simply invisible. Against a stub that never collects there is
no symptom to find -- no leak, no crash, no wrong number. It would have become a
symptom the week the real collector landed, in a defer reached only on a handled
condition, which is close to the worst place to start looking.

What found it was the IR: a rooted slot is spelled %dr and the fallback %dx, and
the assertion is that no dyn program in the corpus emits one of the latter. That
is now a test over all five dyn programs, and it is the only check in the lane
that can see a missing root while there is still nothing to lose one by. When
the collector arrives it is the thing to extend rather than replace.

Also checked, both clean: flan dev --llvm builds and runs a dyn program, which
is the route the x86 refusal sends people to and would have been a link error in
the worst possible place; and the daemon's own refusal already names the flag.
This commit is contained in:
Joseph Ferano 2026-09-19 06:40:53 +07:00
parent 4aa24c0e33
commit bd981ff87a
5 changed files with 92 additions and 4 deletions

View File

@ -111,9 +111,23 @@ that count, and `dyn_tmp` only hands them out.
**The stub verifies none of this.** `flan_dyn_stub.c` mallocs and never frees,
so a program with entirely wrong root discipline passes every test that runs
against it. What is checked instead is the IR: an early-return function pops on
both paths, and a function with a defer pops on the transfer path. When the real
collector lands, that is the area to re-examine first.
against it. What is checked instead is the IR, and that check earned its keep —
it found a real hole. A defer appears twice in the typed IR, spliced into `body`
for the normal path and again in `fdefers` for the path a transfer leaves
through, so a dyn temporary inside one is emitted twice; `dyn_roots` counted
only the body's, and the second copy went into slots the collector had never
been told about.
Nothing failed, which is the point. `dyn_tmp` falls back to a plain unrooted
slot rather than unbalancing the stack, so the pushes and the pops still
matched, the program ran and printed the right answer, and four dyn values were
simply invisible. Under a stub that never collects there is no symptom at all.
The assertion that caught it is in `test_acceptance.ml`: a rooted slot is
spelled `%dr` and the fallback `%dx`, and no dyn program in the corpus may emit
the latter. When the real collector lands, that is the check to extend rather
than replace — it is the only one that can see a missing root before there is a
collector to lose one by.
Cost: a rooted alloca has its address escape through `flan_dyn_root_push`, so
mem2reg cannot promote it. Every dyn local and every dyn temporary is a real

View File

@ -719,6 +719,15 @@ let dyn_roots (fn : Tast.fn) =
| _ -> ()
in
List.iter (Tast.walk count) fn.Tast.body;
(* And the transfer path's copy of the defers, which is a second list of the
same expressions and is emitted as well so it mints a second set of
temporaries, and counting only [body] left every one of them in a slot the
collector never heard of. The fallback in [dyn_tmp] meant that was silent:
the pushes and the pops still balanced, and four dyn values in a defer
reached on a handled condition were simply invisible. Found by emitting
one and counting [%dx] in the IR, which is the only thing that can see it
while the runtime is a stub that never collects. *)
List.iter (Tast.walk count) fn.Tast.fdefers;
slots + !temps
(* The next pre-made root slot for a dyn temporary. They are all minted, zeroed

View File

@ -85,7 +85,7 @@ forever="dev-loop dev-watch dev-chatty"
# which is a regression, and this is the opposite -- a lane that has not
# started. Take a name off this list when the backend grows the lowering, and
# the survey will say whether it works.
llvmonly="dyn-basic dyn-vec dyn-global dyn-boundary"
llvmonly="dyn-basic dyn-vec dyn-global dyn-boundary dyn-defer"
TIMEOUT=${TIMEOUT:-20}

View File

@ -0,0 +1,28 @@
;;;; A dyn value produced inside a defer, on a function a transfer leaves
;;;; through rather than returns from.
;;;;
;;;; This is here for the root count and not for the arithmetic. A defer appears
;;;; twice in the typed IR — spliced into the body for the normal path, and
;;;; again in fdefers for the path a handled condition unwinds along — so the
;;;; emitter produces two copies of every dyn temporary inside one. Counting
;;;; only the body left the second copy's temporaries in slots the collector had
;;;; never been told about: the pushes and the pops still balanced, so nothing
;;;; failed, and the values were simply invisible.
;;;;
;;;; Nothing the stub does can show that, because it never collects. What shows
;;;; it is the emitted IR — a fallback slot is spelled %dx and a rooted one %dr,
;;;; and the fix is the absence of the former.
(defstruct Boom [n i64])
(defn inner [x] dyn
(defer (print (+ x 1000)) (print "\n"))
(restart-case
(error (Boom {.n 1}))
(give [] 0))
(+ x 1))
(defn main [] ()
(handler-bind [(Boom [b] (invoke-restart 'give))]
(print (inner 5))
(print "\n")))

View File

@ -3035,6 +3035,43 @@ level "1"
dyn_boundary ();
dyn_boundary ~opt:"-O0" ();
(* The root count, which is the one part of this feature no run can check:
the stub never collects, so a program whose roots are entirely wrong
passes every test above. What can be checked is the IR, and this is the
assertion that found a real hole a defer appears twice in the typed IR,
spliced into the body for the normal path and again in [fdefers] for the
path a transfer leaves through, so a dyn temporary inside one is emitted
twice. Counting only the body left the second copy unrooted, silently:
the pushes and the pops balanced because [dyn_tmp] falls back to a plain
slot rather than unbalancing them, and four dyn values were invisible.
[%dr] is a rooted slot and [%dx] is the fallback, so the claim is that
the emitted IR contains none of the latter. It is worth stating as a
property of the whole corpus and not only of this file: any dyn program
that mints one has a temporary the collector cannot see. *)
let no_fallback_slots path =
let l = Load.program ~file:path (Reader.read_file path) in
let ir = Emit.program (Check.program_all l.Load.decls) in
if contains ir "%dx" then begin
incr failures;
Printf.printf
"FAIL %s emits an unrooted dyn temporary (%%dx) — dyn_roots counted \
fewer than the emission minted\n"
path
end
in
List.iter no_fallback_slots
[ "programs/dyn-basic.flan"; "programs/dyn-vec.flan";
"programs/dyn-global.flan"; "programs/dyn-boundary.flan";
"programs/dyn-defer.flan" ];
(* And that the defer program still runs and still runs its defer: the
count being right is not much use if the transfer path broke getting
there. 1005 is the defer, 6 is the value the restart produced. *)
outputs "dyn: a defer on the transfer path" "programs/dyn-defer.flan"
"1005\n6\n";
outputs ~opt:"-O0" "dyn: a defer on the transfer path, -O0"
"programs/dyn-defer.flan" "1005\n6\n";
(* ── --no-gc ─────────────────────────────────────────────────────
The flag is a pass between checking and emission that answers unit or
refuses, and these are its two halves.