The rule a re-run follows is written down, and a test fails without it
This commit is contained in:
parent
931cf860c3
commit
da40dc2de2
51
FIX.org
51
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user