The init-once flag moves out of the namespace a program can write

The guard flag was named .init-once.<global>, and . and - are ordinary
symbol constituents, so (defvar .init-once.x i64 7) beside a computed x
emitted the same symbol twice: the dev build died at the assembler on both
backends, and the flag's Bool was registered over the user's global in
Emit.globals so the store came out as an i1. It is .init~once.<global> now;
~ terminates a symbol in the reader, the same trick destructure~N uses.
test/programs/dev-rerun.flan carries such a global and no new printed line.

Three places said a defconst is the linker's image on one backend and a
constructor's stores on the other and a re-run reaches neither. Tast.const_init
splits on the initialiser and not on the form, so that holds only for a
constant initialiser; a computed defconst is guarded like a defvar on x86 and
refused outright by emit.ml's const. The sentences now say that, including
the divergence.

emit.ml also claimed Check.no_transfer_in_init made it impossible to leave the
guarded branch between the store and the flag. It is syntactic over the
written initialiser only: a callee can signal unhandled and take the call's
transfer edge out, leaving the flag false — which is what should happen, since
the next run retries. Read off the emitted IR for such a program.

The x86 float-Rem comment says why the dead movabs before fmod is kept, and
its mid-sentence line break is gone; math3.flan's first float-% line prints
six values, not four.
This commit is contained in:
Joseph Ferano 2026-09-20 13:07:09 +07:00
parent 1829cd43b6
commit 5b39730f07
5 changed files with 75 additions and 23 deletions

21
FIX.org
View File

@ -823,9 +823,14 @@ globals at all.
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.
- [defconst] with a compile-time-constant initialiser is written into the
image — the linker's on one backend, [flan..init-data]'s stores on the
other — and no startup code reaches it, so a re-run reaches neither. The
split is [Tast.const_init]'s and it is over the *initialiser*, not over the
form: a computed [defconst] would be guarded exactly like a computed
[defvar]. The x86 backend does guard one; the LLVM backend refuses the
program instead, because [Emit.const] has nowhere to run a computed value.
That divergence is older than this rule and is noted here rather than fixed.
- 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.
@ -838,6 +843,16 @@ 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.
The flag's name is [.init~once.<global>]. It was [.init-once.<global>] until
2026-09-20, which a program could collide with: [.] and [-] are both ordinary
symbol constituents, so [(defvar .init-once.x i64 7)] beside a computed [x]
emitted the same symbol twice and the dev build died at the assembler on both
backends — and worse, the flag's Bool was registered over the user's global in
[Emit.globals], so the store to it came out as an [i1]. [~] is a terminator in
the reader, so no symbol a program can write contains one; [destructure~N]
uses the same trick. [test/programs/dev-rerun.flan] carries a global named
[.init-once.counter] to keep it pinned.
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

View File

@ -3418,9 +3418,17 @@ let startup_sym = fname ".init-globals"
is what the daemon has always promised ("the globals are as it left them")
and what a plain zeroed [defvar] already got for free, since .bss is
untouched by a second call. A computed one used to be the exception, wiped
back to its initial value every re-run. A [defconst] is a constant and the
question does not arise for it: it is the linker's image on one backend and
a constructor's stores on the other, and neither is reached from here.
back to its initial value every re-run. A [defconst] whose initialiser is a
compile-time constant is not reached from here at all: it is the linker's
image on one backend and [.init-data]'s stores on the other, and a re-run
reaches neither.
The split is [Tast.const_init]'s, and it is over the *initialiser* and not
over the form nothing below asks [gconst]. A [defconst] with a computed
initialiser would therefore be guarded here like any [defvar], and on the
x86 backend it is. On this one it never arrives: [const] refuses a computed
[defconst] by name, because a constant has nowhere to run. The two backends
disagree about that program and only about that program.
So each computed initialiser guards itself with a flag of its own. Per
global and not per startup function, because the rule belongs to the form:
@ -3433,10 +3441,23 @@ let startup_sym = fname ".init-globals"
byte. The flag is a global of its own rather than a sentinel value in the
variable, because there is no value a [defvar] cannot hold.
Writing the flag *after* the store is safe rather than merely tidy:
[Check.no_transfer_in_init] refuses a signal or a restart out of an
initialiser, so nothing leaves the guarded branch between the two. *)
let init_flag n = ".init-once." ^ n
Writing the flag *after* the store is what makes a failed initialiser retry
rather than be skipped. [Check.no_transfer_in_init] refuses a [signal] or an
[invoke-restart] written *in* the initialiser, but it is syntactic and over
that expression only: the initialiser is lifted into a function of its own,
and a callee of that function can signal unhandled and transfer. The guarded
branch then leaves through the call's transfer edge with the store not done
and the flag still false which is the behaviour to want, because the next
run will try the initialiser again instead of proceeding with a global that
was never given its value.
The flag's name is mangled with a [~], which the reader treats as a
terminator and so cannot appear in any symbol a program can write the same
trick [destructure~N] uses. A [.]-separated name would not do: [.] is an
ordinary symbol constituent, so [(defvar .init-once.x ...)] beside a
computed [x] used to emit the same symbol twice and the dev build died at
the assembler. *)
let init_flag n = ".init~once." ^ n
(* The computed globals, the flags that guard them, and the body of the
startup function built here so that the two backends cannot disagree

View File

@ -2778,18 +2778,21 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) dst =
globals a folded answer is evidence about the folder.
Calling the same function is what makes the two backends agree by
construction
rather than by a hand-written identity that would have to get every
rounding, every sign of zero and every infinity right on its own.
The prelude already declares both symbols ([fmod-f32],
construction rather than by a hand-written identity that would have
to get every rounding, every sign of zero and every infinity right
on its own. The prelude already declares both symbols ([fmod-f32],
[fmod-f64]) and every link passes -lm, so nothing new has to be
arranged for the call to resolve.
The two operands are already in xmm0 and xmm1, which are exactly
where SysV wants the arguments of [double fmod(double, double)],
and the result comes back in xmm0, which is where the store below
reads it. [rax] carries the count of SSE argument registers, the
same thing [call_c] puts there: a fixed-arity callee ignores it. *)
reads it. The [rax] below carries the count of SSE argument
registers, and [fmod] is fixed-arity and ignores it: it is dead, and
it is kept deliberately so that every call this backend makes into C
is preceded by the same instruction [call_c] emits. Deleting it
would save one [movabs] and make this the one call site that reads
differently in a disassembly. *)
| Tast.Rem ->
imm_into f ~reg:rax 2L;
call_sym f.b (if f64 then "fmod" else "fmodf")

View File

@ -6,8 +6,11 @@
;;;; plain 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
;;;; whatever the last run had left. A [defconst] is a constant and the
;;;; question does not arise.
;;;; whatever the last run had left. A [defconst] whose initialiser is a
;;;; compile-time constant is written into the image and no startup code
;;;; reaches it at all, so the question does not arise for it. The split is
;;;; over the initialiser and not over the form — a computed [defconst] would
;;;; be guarded like a [defvar], and the LLVM backend refuses one outright.
;;;;
;;;; So each line printed below is a claim about one of those cases, and the
;;;; run number is the first of them: [runs] is computed, so before the fix it
@ -33,10 +36,20 @@
(defvar state dyn (table))
;; The guard flags the fix adds are the compiler's own globals, and they used
;; to be spelled [.init-once.<name>] — a name a program can write, since [.]
;; is an ordinary symbol constituent. This one is exactly the old spelling of
;; [counter]'s flag. It compiles only because the flag is mangled with a [~]
;; now; before that the dev build died at the assembler with the symbol
;; defined twice, and the flag's Bool retyped this i64 on the way. It stays
;; unprinted on purpose — the expected output is what it was.
(defvar .init-once.counter i64 7)
(defn main [] i32
(agent/start "/tmp/flan-dev-rerun-fallback.sock")
(set counter (+ counter 1))
(set zeroed (+ zeroed 2))
(set .init-once.counter (+ .init-once.counter 1))
(put state :runs (+ (get state :runs) 1))
(print "counter ") (print counter) (println "")
(print "zeroed ") (print zeroed) (println "")

View File

@ -111,11 +111,11 @@
;; and the folded answer would be evidence about the constant folder and
;; not about the lowering.
;;
;; The four signs are the first line, because that is where a modulo
;; written in place of a remainder disagrees: the sign follows the
;; dividend. The second line is the two answers IEEE defines where an
;; integer % would have died — a zero divisor and a NaN dividend are both
;; NaN, not a signal.
;; Six values on the first line: the four sign combinations, where a modulo
;; written in place of a remainder disagrees — the sign follows the dividend
;; — and then the f32 pair, asking fmodf the same. The second line is the
;; two answers IEEE defines where an integer % would have died: a zero
;; divisor and a NaN dividend are both NaN, not a signal.
(show64 (% rem-a rem-b)) ; 1.5
(show64 (% rem-na rem-b)) ; -1.5
(show64 (% rem-a rem-nb)) ; 1.5