A trial puts the whole context back, and the compiler now insists on it

This commit is contained in:
Joseph Ferano 2026-09-20 19:51:51 +07:00
parent 114ea391aa
commit 0d34831199
3 changed files with 112 additions and 17 deletions

57
FIX.org
View File

@ -2711,13 +2711,56 @@ symptoms, and the second is the serious one:
stored into. An uninitialised stack read, in a program the compiler
accepted, on both backends.
Fixed with ~trial~, which snapshots ~scope~, ~slots~, ~slot_tys~,
~slot_names~, ~defers~ and ~defer_slot~ and puts all six back when the trial
refuses. ~scoped~ itself is untouched — it is shared by every scope-opening
form in the file and this is not its problem to solve. ~trial~ also narrows
the catch to ~Loc.Error~: a timeout or a stack overflow is not a refusal to
reconsider, and continuing past one would turn a resource failure into a wrong
answer. Both symptoms pinned.
Fixed with ~trial~, which snapshots the context and puts it back when the
trial refuses. ~scoped~ itself is untouched — it is shared by every
scope-opening form in the file and this is not its problem to solve. ~trial~
also narrows the catch to ~Loc.Error~: a timeout or a stack overflow is not a
refusal to reconsider, and continuing past one would turn a resource failure
into a wrong answer.
*The first version of that fix restored six chosen fields, and the choice was
wrong.* Review round three found three more, and the worst of them inverts the
symptom: where a leaked binding produces a false *accept*, a leaked window
produces a false *refusal*.
- ~in_frames~. ~check_frames~ sets it, threads the expectation into the body's
last form, and clears it on the way out. A trial abandoned inside that
window leaves the flag stuck, so
: (println (+ i32-x (handler-bind [] i64-y)))
: (return 0)
— which compiled before this lane and compiles again now — was refused with
"return is not allowed inside handler-bind yet", pointing at a line with no
~handler-bind~ within sight of it. A valid program refused for a reason that
is not in the program.
- ~loops~, the same window via ~loop~: a leaked ~Lrecur~ made an invalid
~break~ answer "the nearest loop is a (loop ...), which answers with the
value of its body" instead of "break is only allowed inside a loop". No bad
accept, a thoroughly misleading refusal.
- ~defer_block~, message text only, and leaked with ~loops~.
*So the subset was replaced by the whole record.* ~trial~ now restores every
mutable field of ~ctx~ — the three above, the six from round two, and
~defer_ok~, ~tail~ and ~outer_what~, which would self-heal on their own and
are restored anyway, because "this one cannot currently leak" is precisely the
reasoning that produced two rounds of leaks. The destructuring is closed and
carries ~[@warning "+9"]~, so adding a field to ~ctx~ stops ~trial~ compiling
until somebody decides about it. *Verified that the guard guards*: removing
one field from the pattern by hand fails the build, naming the field.
One thing is deliberately not restored, and it is on ~env~ rather than ~ctx~:
an abandoned trial that lifted a function out of an ~fn~ literal leaves it in
~env.lifted~. That is dead and harmless — the names are ~fn/<owner>/N~ handed
out by count, so the live pass gets fresh ones and nothing refers to the
orphan — and it rides into the module as a function nobody calls. Left because
~env~ is the program's table rather than this form's, and rewinding it would
mean deciding what else on ~env~ a trial may have touched; the one piece of
~env~ state that genuinely needs rewinding, the generic instantiation cache,
already rewinds itself in ~instantiate~.
All five symptoms pinned — the two accepts, the shadow, the unknown name, and
the loop diagnostic.
*2. The trial reconsidered literals.* Written up under the join rule above.
The short version: ~(+ u8-thing 300)~ compiled, at i32, answering 555. The

View File

@ -7560,24 +7560,59 @@ and numeric_want want =
Rule 2 above is not a description of the old language kept for continuity;
it is what the author decided, and the kind is how the trial obeys it. *)
and trial ctx f =
(* Everything a check writes into the context that is not the expression it
answers. [defers] and [defer_slot] are on the list even though a [defer]
inside an operand is already refused [defer_ok] is cleared on entry to
[check] because "already impossible elsewhere" is the kind of reason
that stops being true, and putting a field back costs nothing.
(* Everything a check writes into the context, put back if the check is
abandoned and it is *everything* on purpose, not a chosen subset.
Picking the fields that looked like they mattered was tried twice and was
wrong twice. [scope] and the slot fields were the first round, found as an
uninitialised read. The second round was worse, because the symptom was
the other way up: a form that opens a window and closes it on the way out
[check_frames] setting [in_frames], [loop] pushing onto [loops] leaves
that window *open* when a trial inside it is abandoned, and then refuses
a perfectly good program.
(println (+ i32-x (handler-bind [] i64-y)))
(return 0)
compiled before this lane and was refused after it, with "return is not
allowed inside handler-bind yet" pointing at a line with no handler-bind
anywhere near it. A false refusal is not a lesser bug than a false accept;
it is just quieter about being one.
So the rule here is not judgement, it is the whole record. Three fields
would have self-healed anyway [defer_ok] and [tail] are read and cleared
on entry to [check], [outer_what] is never written after the context is
built and they are restored regardless, because "this one cannot
currently leak" is exactly the reasoning that produced two rounds of
leaks. The destructuring below is closed and warning 9 is turned on for
it, so a new field on [ctx] stops this function compiling until somebody
decides about it, rather than joining the list of things nobody noticed.
What is *not* restored, once, deliberately: [env.lifted] keeps whatever
function an abandoned trial lifted out of an [fn] literal. It is dead
the names are [fn/<owner>/N] handed out by count, so the live pass gets
fresh ones and nothing refers to the orphan and it rides along into the
module as a function nobody calls. Left alone because [env] is the
program's table and not this form's, and rewinding it would mean deciding
what else on [env] a trial may have touched; the generic instantiation
cache already rolls itself back, in [instantiate].
Only [Loc.Error] is caught. A timeout or a stack overflow is not a
refusal to reconsider, and silently continuing past one would turn a
resource failure into a wrong answer. *)
let scope = ctx.scope and slots = ctx.slots in
let slot_tys = ctx.slot_tys and slot_names = ctx.slot_names in
let defers = ctx.defers and defer_slot = ctx.defer_slot in
let[@warning "+9"] { env = _; ret = _; slots; slot_tys; slot_names; scope;
defers; defer_slot; defer_ok; defer_block; outer = _;
outer_what; in_frames; loops; tail; in_defer;
owner = _ } = ctx in
match f () with
| r -> Ok r
| exception Loc.Error d ->
ctx.scope <- scope; ctx.slots <- slots;
ctx.slot_tys <- slot_tys; ctx.slot_names <- slot_names;
ctx.slots <- slots; ctx.slot_tys <- slot_tys;
ctx.slot_names <- slot_names; ctx.scope <- scope;
ctx.defers <- defers; ctx.defer_slot <- defer_slot;
ctx.defer_ok <- defer_ok; ctx.defer_block <- defer_block;
ctx.outer_what <- outer_what; ctx.in_frames <- in_frames;
ctx.loops <- loops; ctx.tail <- tail; ctx.in_defer <- in_defer;
Error d
(* Whether the trial's refusal is one worth reconsidering. A literal that did

View File

@ -1031,6 +1031,23 @@ let () =
restores around each trial. Both symptoms of not doing it: a binding that
outlives the pass that made it, and the same binding *shadowing* a live
one, which is an uninitialised read in a program the compiler accepted. *)
(* The other half, and the one that bites harder: a form that opens a window
and closes it on the way out leaves it *open* when a trial inside it is
abandoned, and then refuses a program that is fine. Both windows the
frames [handler-bind] establishes, and the loop [loop] pushes with the
refusal each would wrongly produce written as the second half of the
test, so a regression shows up as the message coming back rather than as
a silent accept. *)
accepts "an abandoned trial inside handler-bind does not leave its frames up"
"(defvar n i32) (defvar w i64) \
(defn f [] i32 (println (+ n (handler-bind [] w))) (return 0))";
accepts "nor does one inside a loop leave the loop up"
"(defvar n i32) (defvar w i64) \
(defn f [] i32 (println (+ n (loop [i 0] w))) (defer (println 1)) 0)";
rejects_check "and a break outside every loop still says so plainly"
"(defvar n i32) (defvar w i64) \
(defn f [] i32 (println (+ n (loop [i 0] w))) (break) 0)"
~needle:"break is only allowed inside a loop";
rejects_check "an abandoned trial leaves no binding behind"
"(defvar n i32) (defvar w i64) \
(defn f [] i32 (println (+ n (let [q w] q))) (println q) 0)"