The rule a re-run follows is written down, and a test fails without it

This commit is contained in:
Joseph Ferano 2026-09-20 11:30:58 +07:00
parent 931cf860c3
commit da40dc2de2
4 changed files with 160 additions and 1 deletions

51
FIX.org
View File

@ -39,6 +39,13 @@ whether it accepts one.
Globals are NOT reset between runs. That is the CL/Clojure semantics asked for:
the process never died, so a second (main) sees what the first one left.
Held for a zeroed global from the day this merged, and did not hold for a
computed one until 2026-09-20: re-entering main re-entered the startup function
that runs the computed initialisers, so every [defvar] with a call in it was
stored back over what the last run had left. Fixed by giving each computed
initialiser a guard of its own rather than by changing what a re-run does —
see "Per-form initialisation semantics on re-run" below.
** 2. C-x C-e on a top-level form — QUEUED behind 1
Same file as 1 (emacs/flan.el), so it waits rather than merging by hand.
No design questions; the note specifies it.
@ -656,3 +663,47 @@ immutability, and the discipline of planning a shape ahead of time that
comes with it. The plan is to write imperative Flan as it stands and see
whether the parens still grate once that variable is gone. Revisit this once
that evidence exists.
* Per-form initialisation semantics on re-run, decided 2026-09-20
The defining form is the contract, and the daemon does not have a policy about
globals at all.
- [defvar] is Common Lisp's [defvar]: its initialiser runs only if the variable
is not already initialised. Its value therefore survives a re-run, which is
what the daemon has always promised in its own words — "the globals are as
the last run left them" — and what a zeroed one already got for free, since
.bss is untouched by a second entry into main.
- [defconst] is a constant and the question does not arise: it is the linker's
image on one backend and a constructor's stores on the other, and a re-run
reaches neither.
- If the language grows a [def]-style form that re-evaluates, that form
recomputes on every run. None exists today and none was invented for this;
the rule is written so that adding one is a new case and not a revision.
A re-run may therefore re-enter the startup function as freely as it re-enters
anything else. Each initialiser guards itself: [Emit.startup_plan] gives every
computed global a flag of its own — zeroed in .bss, set after the store — and
wraps the store in a test of it. Per global rather than per startup function,
because the rule belongs to the form; dev builds only, so a release build's
.ll and .s are byte for byte what they were, which was measured on both
backends rather than argued.
Verified against a live daemon on both backends with
[test/programs/dev-rerun.flan]: a computed i64 counts 41, 42, 43, 44 across
four runs where it counted 41, 41, 41, 41 before; a computed dyn map keeps the
mutations every run made to it; a zeroed [defvar] still accumulates; a
[defconst] is untouched. The block in test_dev.ml that pins it fails on the
pre-fix compiler in exactly the two computed cases and in neither of the other
two, which is the other half of the claim.
** The interaction with the park's root reset, not fixed here
[flan_merged_park] calls [flan_dyn_root_reset], which clears the collector's
whole root stack, the dyn globals' permanent roots included — and the emitted
main re-pushes them on the way in. So push-exactly-once holds today *because*
of the reset, and this lane deliberately leaves both halves alone.
The lane fixing the reset to preserve the globals' roots has to guard the
pushes in the same change: preserved roots plus an unguarded main is a
duplicate root per re-run. Guarding the pushes here instead would have been
strictly worse — a guarded push against today's reset leaves the dyn globals
unrooted for the whole of the second run.

View File

@ -2555,6 +2555,15 @@ let abort t =
is a thing you ask for by hand, in one evaluation, and it cannot be had back
the other way round if this zeroed by default.
Nothing here does the keeping, and that is deliberate: a re-run re-enters
[flan_program_main] from the top, the startup function that runs the
computed initialisers included, and what each of those does the second time
is decided by the form that defined the global. A [defvar] is CL's, so its
initialiser runs only if the variable is not already initialised
[Emit.startup_plan] emits the flag that makes that true, and FIX.org's entry
of 2026-09-20 is the rule. This op says what it sees rather than arranging
for it.
The state is asked twice here, to say something useful about [Gone], and
again inside [Program.rerun], which is the answer that counts. The C does
its test and its signal under one lock, so the window between them that this

View File

@ -44,6 +44,6 @@
(print "base ") (print base) (println "")
;; Long enough for a client to be served, short enough to park well inside
;; any watchdog — dev-macro.flan's clock, for its reason.
(dotimes [i 200]
(dotimes [i 100]
(agent/wait 5))
0)

View File

@ -4644,6 +4644,105 @@ let () =
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ())
[ xsock2; xout2; msock; mout ];
(* ── What a re-run does to a global ───────────────────────────────── *)
(* The rule is the defining form's: a [defvar] is Common Lisp's, so its
initialiser runs only if the variable is not already initialised and its
value survives a re-run. A zeroed one always did .bss is untouched by
a second entry into [main] and a computed one did not, because the
startup function [main] calls ran again from the top and stored the
initial value back over what the last run had left.
[Emit.startup_plan] guards each computed initialiser with a flag of its
own; this is the claim that guard exists for.
On the default backend, which is x86 and merged, because that is what
the dev loop takes unasked and the guard is emitted by the two backends
from one shared body. Three re-runs and not one: a guard that ran the
initialiser every *other* time would pass a single re-run.
[programs/dev-rerun.flan] prints one line per case per run, and the
whole assertion is the fourth run's four lines: [counter] computed and
incremented four times, [zeroed] uncomputed and incremented four times,
a computed dyn map whose contents were mutated four times, and a
[defconst] that no run can have changed. *)
let rsock = tmp "rerun.sock" and rout = tmp "rerun.out" in
(try Sys.remove rsock with Sys_error _ -> ());
let rfd =
Unix.openfile rout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600
in
let rpid =
Unix.create_process flan
[| flan; "dev"; "programs/dev-rerun.flan"; "-s"; rsock |]
Unix.stdin rfd Unix.stderr
in
Unix.close rfd;
if not (listening ~pid:rpid rsock) then begin
fail "the re-run daemon %s (%S)" !listen_why
(In_channel.with_open_bin rout In_channel.input_all);
(try Unix.kill rpid Sys.sigkill with Unix.Unix_error _ -> ())
end
else begin
let c = connect rsock in
let said r = Option.value ~default:"" (Wire.string_field r "message") in
(* [describe] is what drains the program's stdout into [output], so the
park is asked for rather than slept through and the lines are there
to read the moment it is parked. *)
let parked () =
match Wire.field (request c "(:op \"describe\")") "parked" with
| Some { Form.v = Form.Sym "t"; _ } -> true
| _ -> false
in
let printed s = contains_sub (Buffer.contents output) s in
if not (await ~ms:20000 parked) then
fail "the re-run fixture never parked (%S)"
(In_channel.with_open_bin rout In_channel.input_all);
if not (printed "counter 41") then
fail "the first run printed %S" (Buffer.contents output);
for _ = 1 to 3 do
let r = request c "(:op \"rerun\")" in
if status r <> "ok" then fail "rerun: %s" (said r);
if not (await ~ms:20000 parked) then
fail "a re-run never parked (%S)"
(In_channel.with_open_bin rout In_channel.input_all)
done;
let want =
[ (* A computed [defvar], which is the whole bug: 41 on the first run
and one more on each of the three after it. *)
"counter 44";
(* An uncomputed one, which survived before this and still does. *)
"zeroed 8";
(* A computed dyn global, mutated by every run: its value survives
and so does the mutation, which is the map still being the map the
first run built. *)
"runs 4";
(* And a [defconst], which no run can have changed. *)
"base 40" ]
in
List.iter
(fun s ->
if not (printed s) then
fail "after three re-runs the program never printed %S: %S" s
(Buffer.contents output))
want;
(* Read back rather than only printed, because the two can differ: a
printed line is what the run computed, and this is what the global
holds now. *)
let r =
request c
"(:op \"eval-expr\" :code \"counter\" :file \"programs/dev-rerun.flan\")"
in
(match Wire.string_field r "value" with
| Some "44" -> ()
| v ->
fail "counter reads back as %S after three re-runs"
(Option.value ~default:(status r) v));
ignore (request c "(:op \"close\")");
(try Unix.close c with Unix.Unix_error _ -> ());
(try ignore (Unix.waitpid [] rpid) with Unix.Unix_error _ -> ())
end;
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ())
[ rsock; rout ];
(* ── A daemon whose editor was killed ─────────────────────────────── *)
(* The defect FIX.org recorded and PDEATHSIG does not reach: an editor that