From da40dc2de26f2812408b07fe3c568e75c6040798 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 11:30:58 +0700 Subject: [PATCH] The rule a re-run follows is written down, and a test fails without it --- FIX.org | 51 +++++++++++++++++++ lib/dev.ml | 9 ++++ test/programs/dev-rerun.flan | 2 +- test/test_dev.ml | 99 ++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/FIX.org b/FIX.org index 1e50077..7cdcf79 100644 --- a/FIX.org +++ b/FIX.org @@ -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. diff --git a/lib/dev.ml b/lib/dev.ml index a3bba1a..12e61fa 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -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 diff --git a/test/programs/dev-rerun.flan b/test/programs/dev-rerun.flan index 94ce033..8ce6091 100644 --- a/test/programs/dev-rerun.flan +++ b/test/programs/dev-rerun.flan @@ -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) diff --git a/test/test_dev.ml b/test/test_dev.ml index 7d8e3f4..efb5260 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -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