Review follow-ups: a new def's image, the keyword that cannot change, and the sweep

Three defects, all from lifting every def initialiser, none of which the
suite caught:

A def typed fresh into a live session came up zero and stayed zero. The
image flan_dev_global copies on the allocation is the only value a new
global ever gets — the host's .init-globals never calls its initialiser —
and both backends chose that image with Tast.const_init, which a def's
lifted Call fails by construction. Emit.initial_image reads the constant
back out of the lifted body; the x86 twin had the same bug.

Changing a global between def and defonce was silently ineffective: the
guard lives in the startup function compiled into the host, which a reload
cannot republish. Session.compatible refuses both directions and says to
restart; editing the value stays allowed.

And global/<n> no longer leaks into the signature refusal when a def is
retyped — the global loop names the same fact in words a reader can act on.

flan check prints def, defonce or defconst off grerun; (defvar) with no
arguments names the shapes rather than offering (defonce ); the docs,
plan.org, runtime comments and valgrind.supp are swept; BUILT.md states
the release-build cost and the uninit caveat.
This commit is contained in:
Joseph Ferano 2026-09-21 07:19:33 +07:00
parent 801c70bd0d
commit a64bee6d96
20 changed files with 288 additions and 57 deletions

33
FIX.org
View File

@ -4409,6 +4409,39 @@ def/defn and def/defonce collisions, the defvar teaching error verbatim), and
in programs/global-init.flan's last four lines (all def spellings start in programs/global-init.flan's last four lines (all def spellings start
identically on both backends and at both -O0 and -O2). identically on both backends and at both -O0 and -O2).
** Review follow-ups
Three real defects, all from the always-lift, all found by review rather than
by the suite:
- *A def typed fresh into a live session came up zero and stayed zero.* The
image [flan_dev_global] copies on the allocation is the only value a
brand-new global ever gets — the host's [.init-globals] was compiled when
the process started and never calls the new name's initialiser — and both
backends decided that image with [Tast.const_init g.ginit], which a def's
lifted [Call] fails by construction. [(def n i64 42)] therefore came up 0
where [(defonce n i64 42)] came up 42, permanently, for that process. Now
[Emit.initial_image] reads the constant back out of the lifted body and
both backends ask it; the x86 twin had the same bug and the same fix.
Pinned in test_session.ml beside the defonce row it is compared against.
- *Changing the keyword on an existing global was silently ineffective.*
Which form declared it is not in the storage, it is in the startup
function's guard, compiled into the host. A redefinition republishes the
initialiser and cannot republish that, so defonce→def kept the guard and
never re-ran, and def→defonce kept re-running. [Session.compatible]
refuses both ways now and says to restart. Editing the *value* is the
workflow and stays allowed, which is the row beside it.
- *[global/<n>] leaked into a user-facing refusal.* Retyping a def hit the
function-signature arm first, which answered about [global/paint] — a name
nothing in the source mentions. The lifted initialiser is skipped there
now; the global loop below says the same fact in the words a reader can
act on.
Also covered, having been reasoned rather than exercised: a def whose type
changes between re-runs (the "changes type" refusal), and a def initialiser
that reads another global at run time rather than only in the static
ordering analysis (an ordinary republish; the read is the running program's
storage).
** Red on this branch, for the merger ** Red on this branch, for the merger
sand.flan spells ~defvar~ at lines 15, 16, 24, 25, 26, 115 and 116 and was sand.flan spells ~defvar~ at lines 15, 16, 24, 25, 26, 115 and 116 and was
not touched — same situation as the raylib keywords above. The three not touched — same situation as the raylib keywords above. The three

View File

@ -351,7 +351,13 @@ let () =
List.iter List.iter
(fun (g : Flan.Tast.global) -> (fun (g : Flan.Tast.global) ->
Printf.printf "%s %s %s\n" Printf.printf "%s %s %s\n"
(if g.gconst then "defconst" else "defvar") (* The three defining forms, told apart the way the compiler
tells them apart: [gconst] is the image, and [grerun] is
what a re-run does to the storage. A listing that called
both mutable forms one name could not answer the question
someone runs [flan check] on a dev file to ask. *)
(if g.gconst then "defconst"
else if g.grerun then "def" else "defonce")
g.gname (Flan.Types.to_string g.gty)) g.gname (Flan.Types.to_string g.gty))
p.globals; p.globals;
List.iter List.iter
@ -739,7 +745,7 @@ let () =
p ~out)) p ~out))
(* The daemon an editor talks to: one session, the program it belongs to (* The daemon an editor talks to: one session, the program it belongs to
running beside it, and a socket. Unlike [flan reload] the session persists, running beside it, and a socket. Unlike [flan reload] the session persists,
so a defvar added by one evaluation is part of what the next one is checked so a defonce added by one evaluation is part of what the next one is checked
against and it owns the build, which is what makes its layout rules against and it owns the build, which is what makes its layout rules
describe the process that is actually running. *) describe the process that is actually running. *)
| _ :: "dev" :: path :: rest -> | _ :: "dev" :: path :: rest ->

View File

@ -6207,7 +6207,31 @@ place — same storage, no reallocation, every reference sees the new bytes. And
startup calls the initialiser through its function cell, so a re-evaluated `def` swaps the cell (`Session`'s startup calls the initialiser through its function cell, so a re-evaluated `def` swaps the cell (`Session`'s
`def_inits`, plus `Emit.redefinition` declaring the cell for a target whose `fparent` is a global) and the next re-run `def_inits`, plus `Emit.redefinition` declaring the cell for a target whose `fparent` is a global) and the next re-run
stores the edited value. A constant left inline would have baked the stale number into the host's startup for ever. stores the edited value. A constant left inline would have baked the stale number into the host's startup for ever.
`uninit` opts out on both forms — nothing to run, nothing repaints.
Two consequences of the always-lift that are worth stating, because both were bugs first. A global the *process was
never built with* — a `def` typed fresh into a live session — gets its initial value from the image
`flan_dev_global` copies on the allocation, and nothing else: the host's `.init-globals` was compiled when the process
started and never calls the new name's initialiser, at this run or any re-run. Asking `Tast.const_init` about a def's
`ginit` there answers no every time, because the lift made it a call, so a new `(def n i64 42)` came up zero and
stayed zero while the `defonce` beside it came up 42. `Emit.initial_image` reads the constant back out of the lifted
body instead, and both backends ask it. And *changing the keyword* on an existing global is refused: which form
declared it lives in the startup function's guard, which was compiled into the host, so a reload can replace the
initialiser but not how often it is called — swapping `def` for `defonce` would load cleanly and go on doing what the
old keyword said. `Session.compatible` names it and says to restart. Editing the *value* is the workflow and stays
allowed.
**`uninit` is the one spelling where `def` does not do what its name promises.** `(def buf [8 u8] uninit)` keeps its
bytes across a re-run, because there is nothing to run: the initialiser that would repaint it is the absence of one.
That is deliberate — repainting would mean storing garbage on purpose, and the whole point of `uninit` is that nobody
has said what the bytes are — but it is the one place where reading the keyword does not tell you the answer. Write
`(def buf [8 u8])` if the re-run should zero it.
**What a `def` costs a release build.** A `defonce` with a constant initialiser is written into the image and starts
for free; a `def` with the same initialiser is not, because its initialiser is lifted into `global/<n>` whether or not
a linker could have written it. So the storage leaves `.data` for `.bss`, and startup gains one call and one store for
it — once per program, not per frame, and no guard flag either way in a release build. The x86 backend tracks LLVM
`-O0`, so that call is definitely emitted there rather than being something an optimiser might fold. A `defonce` is
unchanged in a release build, byte for byte, which is what keeps the cost something you opt into by choosing the form.
One shared plan feeds both backends, so x86 and LLVM cannot disagree; `programs/global-init.flan`'s last four lines pin One shared plan feeds both backends, so x86 and LLVM cannot disagree; `programs/global-init.flan`'s last four lines pin
every `def` spelling's startup on both, at `-O0` and `-O2`, and `programs/dev-rerun.flan` pins the live loop — a every `def` spelling's startup on both, at `-O0` and `-O2`, and `programs/dev-rerun.flan` pins the live loop — a

View File

@ -57,7 +57,7 @@ runtime has only preformatted loc strings, no access to source text).
| 12 | `unknown type i — did you mean i8? A parameter with no type is dyn, so this would otherwise be read as a second parameter called i` | check.ml:850 | `(defn idx [v i] dyn …)` | A | B | C | C | Locates and explains well, but the did-you-mean fires on a *lowercase* name the user plainly meant as a parameter, so the suggestion is a false accusation. Suppress `near_miss` when the name is lowercase and in a parameter vector; lead with the dyn-parameter reading instead. | | 12 | `unknown type i — did you mean i8? A parameter with no type is dyn, so this would otherwise be read as a second parameter called i` | check.ml:850 | `(defn idx [v i] dyn …)` | A | B | C | C | Locates and explains well, but the did-you-mean fires on a *lowercase* name the user plainly meant as a parameter, so the suggestion is a false accusation. Suppress `near_miss` when the name is lowercase and in a parameter vector; lead with the dyn-parameter reading instead. |
| 13 | `expected a type, found 1. This is the return type, which every defn states -- a function that returns nothing writes ()` | parse.ml:1156 | `(defn f [x i32] (+ x 1))` | C | B | B | C | Says the fix, which is good. Two warts: the caret lands on the `1` deep inside the body rather than on the position where the return type belongs; and the literal `--` where the house uses `—` everywhere else. | | 13 | `expected a type, found 1. This is the return type, which every defn states -- a function that returns nothing writes ()` | parse.ml:1156 | `(defn f [x i32] (+ x 1))` | C | B | B | C | Says the fix, which is good. Two warts: the caret lands on the `1` deep inside the body rather than on the position where the return type belongs; and the literal `--` where the house uses `—` everywhere else. |
| 14 | `f64 is not a struct, so it has no fields` | check.ml:4028 | `(match s (Circle c) (.r c))` — single-field case binds the payload directly | B | C | D | A | The user wrote what looks like a destructuring pattern and got a type fact. Say what the pattern bound (`c` is the payload, an `f64`) and that the field is already in hand. | | 14 | `f64 is not a struct, so it has no fields` | check.ml:4028 | `(match s (Circle c) (.r c))` — single-field case binds the payload directly | B | C | D | A | The user wrote what looks like a destructuring pattern and got a type fact. Say what the pattern bound (`c` is the payload, an `f64`) and that the field is already in hand. |
| 15 | `% is a constant` | check.ml:4043 | `(defconst k 3)` + `(set k 4)` | A | C | D | A | Four words. Needs a `Loc.note` at the `defconst` and the named fix (`defvar`). `no_container_defconst` (7447) already shows how the house writes this well — imitate it. | | 15 | `% is a constant` | check.ml:4043 | `(defconst k 3)` + `(set k 4)` | A | C | D | A | Four words. Needs a `Loc.note` at the `defconst` and the named fix (`defonce`). `no_container_defconst` (7447) already shows how the house writes this well — imitate it. |
| 16 | `% is a parameter, and parameters are not assignable places (spec-memory.md) — bind a local with let` | check.ml:4037 | `(defn f [x i32] i32 (set x 1) x)` | A | B | B | C | Names the fix. Register wart: a diagnostic should not cite a spec filename at the user; put the rule in words and drop `(spec-memory.md)`. Same for `(plan.org, Types)` at 4609/4614 and `(see plan.org)` at 423. | | 16 | `% is a parameter, and parameters are not assignable places (spec-memory.md) — bind a local with let` | check.ml:4037 | `(defn f [x i32] i32 (set x 1) x)` | A | B | B | C | Names the fix. Register wart: a diagnostic should not cite a spec filename at the user; put the rule in words and drop `(spec-memory.md)`. Same for `(plan.org, Types)` at 4609/4614 and `(see plan.org)` at 423. |
| 17 | `% is not implemented yet — milestone %d (see plan.org)` | check.ml:423 (`unimplemented`), used widely | `(Result i32 string)` in a type position | B | B | D | D | Sends the user to a planning document. Say what is missing in one clause and what to write in the meantime, or nothing. | | 17 | `% is not implemented yet — milestone %d (see plan.org)` | check.ml:423 (`unimplemented`), used widely | `(Result i32 string)` in a type position | B | B | D | D | Sends the user to a planning document. Say what is missing in one clause and what to write in the meantime, or nothing. |
| 18 | `% takes numbers, found string` | check.ml:4219 (`binary`) | `(+ "a" "b")` | C | B | C | A | Caret covers the whole form rather than the offending operand — the exact "whole form vs operand" regression class this repo already fixed once in `check_truthy`. The operand's `Tast.eloc` is right there. Also: no mention of `str-cat`/the concatenation route, which is what the user wanted. | | 18 | `% takes numbers, found string` | check.ml:4219 (`binary`) | `(+ "a" "b")` | C | B | C | A | Caret covers the whole form rather than the offending operand — the exact "whole form vs operand" regression class this repo already fixed once in `check_truthy`. The operand's `Tast.eloc` is right there. Also: no mention of `str-cat`/the concatenation route, which is what the user wanted. |
@ -110,11 +110,11 @@ Elm-class.
and `no_container_defconst` (7447). No secondary span, but the prose does the and `no_container_defconst` (7447). No secondary span, but the prose does the
whole contract: what was understood ("the constant `n` is computed"), what it whole contract: what was understood ("the constant `n` is computed"), what it
conflicts with ("a defconst is what the linker writes into the image and has conflicts with ("a defconst is what the linker writes into the image and has
nowhere to run"), and two named ways out (`defvar`, or a folded literal). The nowhere to run"), and two named ways out (`defonce`, or a folded literal). The
best *prose-only* message in the tree. best *prose-only* message in the tree.
**4. The dyn view-lifetime refusal.** `view_not_permanent` (1570). Explains **4. The dyn view-lifetime refusal.** `view_not_permanent` (1570). Explains
the rule, gives the one shape that does work (`defvar g …`), and enumerates the rule, gives the one shape that does work (`defonce g …`), and enumerates
what is refused. Borderline long — it is the closest thing in the tree to the what is refused. Borderline long — it is the closest thing in the tree to the
banned essay register, and a fix pass should cut it by a third rather than banned essay register, and a fix pass should cut it by a third rather than
lengthen anything toward it. lengthen anything toward it.

View File

@ -74,7 +74,7 @@ Three more the same way, none of them blockers, all one line each:
| Missing | Used at | Verdict | | Missing | Used at | Verdict |
|---|---|---| |---|---|---|
| `ImageFromImage` | `sprite_atlas.clj` `auto-select-tiles`; `tilemap-blob.lisp` `tile-subimages` | Annoyance — see §3 | | `ImageFromImage` | `sprite_atlas.clj` `auto-select-tiles`; `tilemap-blob.lisp` `tile-subimages` | Annoyance — see §3 |
| `IsWindowReady` | `engine.clj` `run-game!`, `engine.lisp` `run-game` — the "window already open" guard | Annoyance; a `defvar bool` does the same thing | | `IsWindowReady` | `engine.clj` `run-game!`, `engine.lisp` `run-game` — the "window already open" guard | Annoyance; a `defonce bool` does the same thing |
| `SetTextureFilter` | declared in `rl.clj`, called nowhere | Not needed. Point is raylib's default | | `SetTextureFilter` | declared in `rl.clj`, called nowhere | Not needed. Point is raylib's default |
| `UpdateTexture` | declared in `rl.clj`, called nowhere | Not needed | | `UpdateTexture` | declared in `rl.clj`, called nowhere | Not needed |
| `SetClipboardText` | `sprite_atlas.clj`, in a `comment` block only | Not needed | | `SetClipboardText` | `sprite_atlas.clj`, in a `comment` block only | Not needed |
@ -245,7 +245,7 @@ Ordered by how much of the game it touches.
(`engine.clj`'s `texture-cache` atom, `engine.lisp`'s `*texture-cache*` hash table), and (`engine.clj`'s `texture-cache` atom, `engine.lisp`'s `*texture-cache*` hash table), and
`engine.lisp` keeps `*buffers*` there too. `engine.lisp` keeps `*buffers*` there too.
`sand.flan`'s idiom — everything in `defvar` fixed arrays, the loop functions taking no `sand.flan`'s idiom — everything in `defonce` fixed arrays, the loop functions taking no
state — works because nothing it holds is move-only. This game's state is not like that: state — works because nothing it holds is move-only. This game's state is not like that:
`tilesets`, `src-rects`, `dst-rects` and the bitmask table all want a `Vec` or a `Map`. `tilesets`, `src-rects`, `dst-rects` and the bitmask table all want a `Vec` or a `Map`.
@ -302,7 +302,7 @@ piece that needs to touch the state, and the game can supply `snapshot`/`restore
more callbacks. more callbacks.
> **Settled by the author, 2026-09-13: fixed arrays with counts.** The first branch below. So the > **Settled by the author, 2026-09-13: fixed arrays with counts.** The first branch below. So the
> conditional verdict in this section is now unconditional: the state fits in `defvar` globals, the > conditional verdict in this section is now unconditional: the state fits in `defonce` globals, the
> engine takes only `(Fn [] ())` callbacks and never names a state type, generics stays off this > engine takes only `(Fn [] ())` callbacks and never names a state type, generics stays off this
> game's critical path, and `drop` is not needed for this version. Nothing in the language had to be > game's critical path, and `drop` is not needed for this version. Nothing in the language had to be
> built for it — fixed arrays, counts and slices all already work. What is left is writing the game. > built for it — fixed arrays, counts and slices all already work. What is left is writing the game.
@ -313,7 +313,7 @@ more callbacks.
**This verdict is conditional, and the condition is one decision.** Both it and the **This verdict is conditional, and the condition is one decision.** Both it and the
globals workaround above hang on the same pivot, so it is worth stating once, flatly: globals workaround above hang on the same pivot, so it is worth stating once, flatly:
*nothing in this game's state needs to be move-only.* Make the inner collections fixed *nothing in this game's state needs to be move-only.* Make the inner collections fixed
arrays with counts, and then the state fits in `defvar` globals, the engine takes only arrays with counts, and then the state fits in `defonce` globals, the engine takes only
`(Fn [] Unit)` callbacks and never names a state type, and generics stays off the critical `(Fn [] Unit)` callbacks and never names a state type, and generics stays off the critical
path. Keep `Vec`s in a let-bound `Game` struct passed as `(Ptr Game)` instead, and path. Keep `Vec`s in a let-bound `Game` struct passed as `(Ptr Game)` instead, and
`run-game` names `Game`, two programs with two state types need two engines, and generics `run-game` names `Game`, two programs with two state types need two engines, and generics
@ -844,7 +844,7 @@ is the C's `char *ptr` and compiles to nothing: a `string` and a `[u8]` are the
words. Every forward-reading `const char *` entry point is reachable that way. words. Every forward-reading `const char *` entry point is reachable that way.
**`load-font-ex` needed nothing.** It takes a `[i32]` of codepoints, takes the **`load-font-ex` needed nothing.** It takes a `[i32]` of codepoints, takes the
pointer-and-count apart itself, and uses a zeroed `defvar` as the null pointer that means pointer-and-count apart itself, and uses a zeroed `defonce` as the null pointer that means
"the default ASCII set". It was written for this call before anything called it, and the "the default ASCII set". It was written for this call before anything called it, and the
call fit it exactly. call fit it exactly.

View File

@ -215,7 +215,7 @@ still alive, and a dyn in it has to stay rooted across a transfer the root stack
```lisp ```lisp
(defstruct S [x dyn]) (defstruct S [x dyn])
(defvar boxes (Vec S)) (defonce boxes (Vec S))
(defn stash [] () (push boxes (S {.x (vec-new dyn)}))) (defn stash [] () (push boxes (S {.x (vec-new dyn)})))
``` ```

View File

@ -362,7 +362,7 @@ A parameter vector is a flat list of pairs. Probed:
So an odd count is a parse error and an even count is a silently different program. There is no count to So an odd count is a parse error and an even count is a silently different program. There is no count to
disambiguate by, which is precisely the finding NEXT.md item 4 records for `let`: *"`let` is a flat list of disambiguate by, which is precisely the finding NEXT.md item 4 records for `let`: *"`let` is a flat list of
pairs, so it cannot disambiguate by count the way `defvar` and `defconst` do — those read `[n t v]` as three pairs, so it cannot disambiguate by count the way `defonce` and `defconst` do — those read `[n t v]` as three
arguments to a form, and there is no such boundary between one pair and the next."* arguments to a form, and there is no such boundary between one pair and the next."*
And the meaning that would have to be given to `[row col]` is exactly the class of change this parser was And the meaning that would have to be given to `[row col]` is exactly the class of change this parser was
@ -398,7 +398,7 @@ The cheap first step below is the one that needs **no** grammar change at all.
It is worth taking seriously because everything expensive above evaporates: It is worth taking seriously because everything expensive above evaporates:
- **One grammar decision instead of two, and it needs a marker.** Omitting the return slot cannot be decided by - **One grammar decision instead of two, and it needs a marker.** Omitting the return slot cannot be decided by
count, and the temptation to say otherwise should be resisted: `defvar` and `defconst` disambiguate by count count, and the temptation to say otherwise should be resisted: `defonce` and `defconst` disambiguate by count
because they have a *fixed maximum arity*`[n t v]` is three slots and there is no fourth. A `defn` body is because they have a *fixed maximum arity*`[n t v]` is three slots and there is no fourth. A `defn` body is
an unbounded list of forms, so `(defn f [] (a) (b))` is `ret=(a), body=(b)` or `infer-ret, body=(a) (b)` and an unbounded list of forms, so `(defn f [] (a) (b))` is `ret=(a), body=(b)` or `infer-ret, body=(a) (b)` and
nothing in the shape decides which. That is the same structural fact NEXT.md states about `let` — *"there is nothing in the shape decides which. That is the same structural fact NEXT.md states about `let` — *"there is

View File

@ -3701,6 +3701,45 @@ let emit_startup m ?(hidden = false) (globals : Tast.global list) =
floc = (List.hd computed).Tast.ginit.Tast.loc }; floc = (List.hd computed).Tast.ginit.Tast.loc };
true true
(* ── What a name the process has never had starts with ─────────────────
A global a redefinition module introduces is allocated by
[flan_dev_global], which copies an image on the allocation and ignores it
for ever after. That image is the only chance the value gets: the host's
[.init-globals] was fixed when the process was built, so nothing in it
calls the new global's initialiser, now or at any re-run.
For most globals the image is the initialiser itself, when the linker
could have written it. A *computed* one has none and the allocation keeps
calloc's zeroes the reload rule, not a gap in it: an initialiser runs at
startup, once, a reload does not run initialisers, and "edit the code,
keep the sand" is the whole demo.
A [def] needs the second arm, and it is the whole reason this is a
function rather than a call to [Tast.const_init]. Every def's initialiser
is lifted into [global/<n>] so that a re-evaluation can swap it through
the function cell, which means a def's own [ginit] is a [Call] and never a
constant and a brand-new [(def n i64 42)] typed into a live session
would come up zero and stay zero for the life of the process, where the
[defonce] beside it correctly comes up 42. So the constant is read back
out of the lifted body, which is where it went.
Both backends ask this, because a rule only one of them follows is not a
rule. *)
let initial_image (p : Tast.program) (g : Tast.global) : Tast.expr option =
if Tast.const_init g.Tast.ginit then Some g.Tast.ginit
else
(* Only a lifted body that is exactly one expression, which is what a
constant initialiser lifts to. Anything else a [let], a defer
counter ahead of the value is computed by definition and has no
image, so the conservative shape is also the correct one. *)
match
List.find_opt
(fun (f : Tast.fn) -> String.equal f.Tast.name ("global/" ^ g.Tast.gname))
p.Tast.fns
with
| Some { Tast.body = [ v ]; _ } when Tast.const_init v -> Some v
| _ -> None
(* ── Program ───────────────────────────────────────────────────────── *) (* ── Program ───────────────────────────────────────────────────────── *)
let header = {|; Generated by flan. The layout is C's: no object headers anywhere, let header = {|; Generated by flan. The layout is C's: no object headers anywhere,
@ -4575,28 +4614,21 @@ let redefinition ?(checks = true) ?(dev = false) ?(debug = false)
would have to agree with LLVM's on every target. *) would have to agree with LLVM's on every target. *)
(* Its declared initial value travels with it, as a constant the (* Its declared initial value travels with it, as a constant the
runtime copies on the allocation and ignores afterwards. Without runtime copies on the allocation and ignores afterwards. Without
this a new (defonce n i64 42) or a new defconst would silently be this a new (defonce n i64 42), a new (def n i64 42) or a new
zero calloc is only the right answer for ZII. defconst would silently be zero calloc is only the right answer
for ZII. [initial_image] is what decides, and it is shared with
A *computed* initialiser sends a null instead, and the allocation the x86 backend: see the note above it for the def's case and for
keeps calloc's zeroes. That is the reload rule and not a gap in why a computed initialiser sends a null instead. *)
this one: a global's initialiser runs at startup, once, and a
reload does not run initialisers sand's grid is a global and
"edit the code, keep the sand" is the whole demo. A name the
program is meeting for the first time has no startup to have
missed, so it starts as ZII and the function that loads it loads
it. Both backends answer the same way, which is the only answer
that makes it a rule. *)
let init = let init =
if not (Tast.const_init g.Tast.ginit) then "null" match initial_image p g with
else begin | None -> "null"
| Some v ->
let init = Printf.sprintf "@\".init.%d\"" m.nstr in let init = Printf.sprintf "@\".init.%d\"" m.nstr in
m.nstr <- m.nstr + 1; m.nstr <- m.nstr + 1;
Buffer.add_string m.strs Buffer.add_string m.strs
(Printf.sprintf "%s = private constant %s %s\n" init (Printf.sprintf "%s = private constant %s %s\n" init
(ll g.Tast.gty) (const m g.Tast.ginit)); (ll g.Tast.gty) (const m v));
init init
end
in in
Buffer.add_string b Buffer.add_string b
(Printf.sprintf (Printf.sprintf

View File

@ -1660,14 +1660,27 @@ let rec decl (f : Form.t) : Ast.decl =
through to "unknown function": every program written before the rename through to "unknown function": every program written before the rename
spells it, and the message is the migration. *) spells it, and the message is the migration. *)
| List ({ v = Sym "defvar"; _ } :: args) -> | List ({ v = Sym "defvar"; _ } :: args) ->
let rest = (* The rest of the form is echoed back inside the two spellings, so the
String.concat " " (List.map Form.to_string args) answer is a line that can be pasted. A form with nothing after the
in keyword has nothing to paste, and echoing it would offer
Loc.failk "parse/defvar-renamed" f.loc [(defonce )] as the fix for [(defvar )] a malformed old form
"defvar is now called defonce — the name says what it does: it \ answered with a malformed new one. The names alone then, which is what
initialises once and keeps its value across re-runs. Write (defonce \ there is to say about a form that named nothing. *)
%s), or (def %s) if the value should follow the source on every re-run" (match args with
rest rest | [] ->
Loc.failk "parse/defvar-renamed" f.loc
"defvar is now called defonce — the name says what it does: it \
initialises once and keeps its value across re-runs. It is \
(defonce name Type value?), or (def name Type value?) if the value \
should follow the source on every re-run"
| _ ->
let rest = String.concat " " (List.map Form.to_string args) in
Loc.failk "parse/defvar-renamed" f.loc
"defvar is now called defonce — the name says what it does: it \
initialises once and keeps its value across re-runs. Write (defonce \
%s), or (def %s) if the value should follow the source on every \
re-run"
rest rest)
| List ({ v = Sym "defconst"; _ } :: args) -> | List ({ v = Sym "defconst"; _ } :: args) ->
(match args with (match args with

View File

@ -239,7 +239,19 @@ let compatible ?(origin = fun _ -> None) ?(relaxed = []) ~loc
callers and makes the refusal itself, naming the ones in the way. callers and makes the refusal itself, naming the ones in the way.
Nothing else sets it, and a name that is not in it is refused here Nothing else sets it, and a name that is not in it is refused here
as it always was. *) as it always was. *)
if not same && not (List.mem f.Tast.name relaxed) then (* A lifted initialiser, [global/<n>], is not a function anybody
wrote: its signature is its global's type, so the only way it can
change is the global being retyped and the loop below says that
in the words a reader can act on, naming the global and its two
types. Left in, this arm gets there first and answers a question
about [global/paint] that nothing in the source mentions. So the
fact is refused exactly once, by the pass that can name it. *)
let lifted_init =
match f.Tast.fparent with
| Some parent -> String.equal f.Tast.name ("global/" ^ parent)
| None -> false
in
if not same && not lifted_init && not (List.mem f.Tast.name relaxed) then
(* ── When the name is not one the programmer wrote ────────────── (* ── When the name is not one the programmer wrote ──────────────
A generic's instantiations are named [sort-i32], [sort-f32] A generic's instantiations are named [sort-i32], [sort-f32]
and so on, and the mangling carries only the *type variables* and so on, and the mangling carries only the *type variables*
@ -318,6 +330,30 @@ let compatible ?(origin = fun _ -> None) ?(relaxed = []) ~loc
"%s changes type, from %s to %s; the running program already laid \ "%s changes type, from %s to %s; the running program already laid \
that storage out. Restart to change it." that storage out. Restart to change it."
g.Tast.gname (Types.to_string h.Tast.gty) (Types.to_string g.Tast.gty) g.Tast.gname (Types.to_string h.Tast.gty) (Types.to_string g.Tast.gty)
(* Which of the two mutable forms declared it is not in the storage,
it is in the *startup function*: [Emit.startup_plan] wrote the
[.init~once.] guard around a defonce's store and left a def's bare,
and that function was compiled into the host when the process was
built. A redefinition republishes the initialiser and cannot
republish the thing that decides how often it is called, so
swapping the keyword would load cleanly and then do exactly what
the old keyword said a def that never re-runs, or a defonce that
keeps being overwritten with nothing anywhere saying so. That is
the silent-wrongness class the house rule is about, so it is a
refusal with the reason. A defconst on either side is the arm
above's business and never reaches here: the type check catches a
retype, and a constant's own arm catches the rest. *)
| Some h
when (not h.Tast.gconst) && (not g.Tast.gconst)
&& h.Tast.grerun <> g.Tast.grerun ->
let word b = if b then "def" else "defonce" in
fail loc
"%s changes from %s to %s. The running program decides when an \
initialiser runs in its startup function, which was compiled \
when it started a reload can replace the initialiser but not \
that. Restart to change it, or keep %s and edit the value."
g.Tast.gname (word h.Tast.grerun) (word g.Tast.grerun)
(word h.Tast.grerun)
| _ -> ()) | _ -> ())
new_.Tast.globals; new_.Tast.globals;
List.iter List.iter

View File

@ -4825,18 +4825,25 @@ let redefinition ~checks ?(dev = true) ?(known = fun _ -> true)
and the allocation keeps calloc's zeroes. It runs at startup, and this and the allocation keeps calloc's zeroes. It runs at startup, and this
module is loaded rather than started -- there is no startup here to have module is loaded rather than started -- there is no startup here to have
missed. [emit.ml] answers a new computed global the same way and for the missed. [emit.ml] answers a new computed global the same way and for the
same reason; the value it would compute is not what a reload is for. *) same reason; the value it would compute is not what a reload is for.
Which value that is, when there is one, is [Emit.initial_image]'s answer
and not this file's. A [def] is the case that needs the shared answer: its
initialiser is lifted into [global/<n>] so a re-evaluation can swap it, so
its own [ginit] is a call and never a constant, and asking [const_init]
here would give a new [(def n i64 42)] calloc's zero for the life of the
process while the [defonce] beside it came up 42. *)
let images = let images =
List.map List.map
(fun (g : Tast.global) -> (fun (g : Tast.global) ->
let size, align = Emit.lay md g.Tast.gty in let size, align = Emit.lay md g.Tast.gty in
let l = let l =
if not (Tast.const_init g.Tast.ginit) then None match Emit.initial_image p g with
else begin | None -> None
| Some v ->
let l = rodata_label f in let l = rodata_label f in
scoped f (fun () -> lower f g.Tast.ginit (Lg (l, 0))); scoped f (fun () -> lower f v (Lg (l, 0)));
Some l Some l
end
in in
(g, l, max 1 size, max 1 align)) (g, l, max 1 size, max 1 align))
new_globals new_globals

View File

@ -122,7 +122,7 @@ world.
startup, by a function ~main~ calls before a line of the program's own code — startup, by a function ~main~ calls before a line of the program's own code —
Odin's ~__$startup_runtime~ shape rather than a constructor, so the runtime is Odin's ~__$startup_runtime~ shape rather than a constructor, so the runtime is
up and the order is the compiler's to choose. The computed ones are sorted by up and the order is the compiler's to choose. The computed ones are sorted by
what they read, so ~(defvar b i64 (+ a 1))~ works above ~a~; a cycle between two what they read, so ~(defonce b i64 (+ a 1))~ works above ~a~; a cycle between two
of them is a compile error naming both. A transfer out of an initialiser — of them is a compile error naming both. A transfer out of an initialiser —
~signal~, ~restart-case~ — is refused: nothing has established a handler that ~signal~, ~restart-case~ — is refused: nothing has established a handler that
early. A reload never re-runs an initialiser, which is what keeps the live early. A reload never re-runs an initialiser, which is what keeps the live
@ -131,7 +131,7 @@ world.
zeroed*, as in Odin — the same rule as a declaration with no initialiser, so zeroed*, as in Odin — the same rule as a declaration with no initialiser, so
~(Cursor {.src src})~ is complete and means ~pos~ is 0. ~(Cursor {.src src})~ is complete and means ~pos~ is 0.
- *Zero is initialisation (ZII), with an opt-out.* No initialiser means - *Zero is initialisation (ZII), with an opt-out.* No initialiser means
all-bytes-zero. ~(defvar buf [65536 u8] uninit)~ skips it, exactly as Odin's all-bytes-zero. ~(defonce buf [65536 u8] uninit)~ skips it, exactly as Odin's
~---~ does, for a large buffer that is about to be overwritten. ~uninit~ is ~---~ does, for a large buffer that is about to be overwritten. ~uninit~ is
greppable and rare by design; reading an ~uninit~ value before writing it is greppable and rare by design; reading an ~uninit~ value before writing it is
undefined, and dev builds poison the memory so the bug is loud. undefined, and dev builds poison the memory so the bug is loud.
@ -900,7 +900,7 @@ marked.
*struct* layout with live values is rejected; a managed class layout has an *struct* layout with live values is rejected; a managed class layout has an
explicit frame-boundary migration path, as described above. Still open: a explicit frame-boundary migration path, as described above. Still open: a
function pointer already handed to C, a captured environment, and redefining function pointer already handed to C, a captured environment, and redefining
a ~defvar~. Each needs an answer of the form "rejected", "accepted with a a ~defonce~. Each needs an answer of the form "rejected", "accepted with a
migration", or "accepted and the old code keeps running". migration", or "accepted and the old code keeps running".
7. Does the interpreter survive milestone 3, or is the compiled path the only 7. Does the interpreter survive milestone 3, or is the compiled path the only
backend? /Settled: the compiled path is the only one, and there is no backend? /Settled: the compiled path is the only one, and there is no

View File

@ -3,7 +3,7 @@
* A redefinition module reaches the host's functions and globals through * A redefinition module reaches the host's functions and globals through
* symbols the host already exports: a cell for each function, the storage for * symbols the host already exports: a cell for each function, the storage for
* each global. That covers everything the program was *built* with. It does * each global. That covers everything the program was *built* with. It does
* not cover a name the module introduces a defn or a defvar typed into the * not cover a name the module introduces a defn or a defonce typed into the
* REPL after the process started because there is no symbol in the host to * REPL after the process started because there is no symbol in the host to
* bind to and ELF cannot grow one. * bind to and ELF cannot grow one.
* *

View File

@ -321,7 +321,7 @@ int64_t flan_dyn_need_i64(flan_dyn v) {
double flan_dyn_need_f64(flan_dyn v) { double flan_dyn_need_f64(flan_dyn v) {
cell *c = as(v); cell *c = as(v);
/* An i64 satisfies an f64 slot, because a dyn integer literal is an i64 by /* An i64 satisfies an f64 slot, because a dyn integer literal is an i64 by
* the header's rule and (defvar x f64 (f 1)) would otherwise be unwritable * the header's rule and (defonce x f64 (f 1)) would otherwise be unwritable
* for any f returning dyn. The reverse is not true: f64 to i64 loses. */ * for any f returning dyn. The reverse is not true: f64 to i64 loses. */
if (c->tag == T_I64) return (double)c->u.i; if (c->tag == T_I64) return (double)c->u.i;
if (c->tag != T_F64) dyn_trap("DynExpectedF64", "this value was required to be an f64 and is not"); if (c->tag != T_F64) dyn_trap("DynExpectedF64", "this value was required to be an f64 and is not");

View File

@ -1403,7 +1403,7 @@ _Noreturn void flan_null_alloc_fail(const uint8_t *loc, int64_t loclen);
* no-op: "I released the region" and "I leaked the region" must not be the * no-op: "I released the region" and "I leaked the region" must not be the
* same program text. */ * same program text. */
void flan_alloc_free_all(flan_allocator *a, const uint8_t *loc, int64_t loclen) { void flan_alloc_free_all(flan_allocator *a, const uint8_t *loc, int64_t loclen) {
/* A null allocator is a zeroed [defvar] nobody assigned yet. Silently doing /* A null allocator is a zeroed [defonce] nobody assigned yet. Silently doing
* nothing would make "I released the region" and "I never made one" the same * nothing would make "I released the region" and "I never made one" the same
* program text, which is the thing this trap exists to prevent. */ * program text, which is the thing this trap exists to prevent. */
if (!a) flan_null_alloc_fail(loc, loclen); if (!a) flan_null_alloc_fail(loc, loclen);
@ -1599,7 +1599,7 @@ static void flan_vec_check(flan_vec *v, const uint8_t *loc, int64_t loclen) {
} }
} }
/* A zeroed Vec — a struct field nobody assigned, or a (defvar xs (Vec i32)) — /* A zeroed Vec — a struct field nobody assigned, or a (defonce xs (Vec i32)) —
* has a null allocator, and the first operation that needs storage adopts the * has a null allocator, and the first operation that needs storage adopts the
* context allocator. That is Odin's behaviour, and the alternative was to * context allocator. That is Odin's behaviour, and the alternative was to
* refuse a Vec-typed struct field outright until step 5. Shipping the null * refuse a Vec-typed struct field outright until step 5. Shipping the null
@ -1615,7 +1615,7 @@ static flan_allocator *flan_vec_adopt(flan_vec *v) {
/* The region requirement asked of a container rather than of a named /* The region requirement asked of a container rather than of a named
* allocator, which is the form every *growth* site needs. A Vec that was built * allocator, which is the form every *growth* site needs. A Vec that was built
* by (vec-new) has its allocator already and answers from it; one that was * by (vec-new) has its allocator already and answers from it; one that was
* zeroed a data type case's field left out of a literal, a (defvar xs (Vec * zeroed a data type case's field left out of a literal, a (defonce xs (Vec
* Value)) that a global starts as has none yet, and the allocator it is * Value)) that a global starts as has none yet, and the allocator it is
* about to adopt is the context. Asking the context in that case is not a * about to adopt is the context. Asking the context in that case is not a
* guess: [flan_vec_adopt], three lines up, is the code that will take it, and * guess: [flan_vec_adopt], three lines up, is the code that will take it, and
@ -2472,7 +2472,7 @@ int8_t flan_map_init(flan_map *m, flan_allocator *a, int64_t ksize,
m->alloc = a; m->alloc = a;
m->epoch = (int64_t)a->epoch; m->epoch = (int64_t)a->epoch;
/* No block until something is put in it: an empty map that is never written /* No block until something is put in it: an empty map that is never written
* costs nothing, which is what makes a (defvar m (Map string i32)) free. */ * costs nothing, which is what makes a (defonce m (Map string i32)) free. */
return 1; return 1;
} }

View File

@ -4,7 +4,7 @@
;;;; printing unchanged, where the old (bytes s) either showed the write ;;;; printing unchanged, where the old (bytes s) either showed the write
;;;; through or trapped, depending on where the string's storage was. ;;;; through or trapped, depending on where the string's storage was.
(defvar frame Allocator) (defonce frame Allocator)
(defn main [] i32 (defn main [] i32
;; 1. The copy is writable and independent. Under the old reinterpret this ;; 1. The copy is writable and independent. Under the old reinterpret this

View File

@ -10,8 +10,8 @@
;;;; ones selected at check time and the dyn ones handed to the runtime, so ;;;; ones selected at check time and the dyn ones handed to the runtime, so
;;;; the interleaving on one line is what proves the two sinks share a buffer. ;;;; the interleaving on one line is what proves the two sinks share a buffer.
(defvar boxed dyn 21) (defonce boxed dyn 21)
(defvar dlabel dyn "mid") (defonce dlabel dyn "mid")
(defn main [] () (defn main [] ()
;; println at every arity ;; println at every arity

View File

@ -526,6 +526,13 @@ let () =
| _ -> check "the old defvar spelling has a kind" false | _ -> check "the old defvar spelling has a kind" false
| exception Loc.Error { Loc.kind; _ } -> | exception Loc.Error { Loc.kind; _ } ->
check "the old defvar spelling has a kind" (kind = "parse/defvar-renamed")); check "the old defvar spelling has a kind" (kind = "parse/defvar-renamed"));
(* A form with nothing after the keyword has nothing to echo, and the
answer must not be "(defonce )" a malformed old form getting a
malformed new one as its fix. *)
parse_rejects "the old spelling with no arguments names the shapes"
"(defvar)"
~needle:"It is (defonce name Type value?), or (def name Type value?) if \
the value should follow the source on every re-run";
(match (parse_decl "(import rl \"vendor:raylib\")").d with (match (parse_decl "(import rl \"vendor:raylib\")").d with
| Import ("rl", "vendor:raylib") -> () | _ -> check "import" false); | Import ("rl", "vendor:raylib") -> () | _ -> check "import" false);

View File

@ -291,6 +291,79 @@ let () =
fail "a new computed global did not start zeroed"; fail "a new computed global did not start zeroed";
if not c.Session.installs then fail "adding a computed global had nothing to install"; if not c.Session.installs then fail "adding a computed global had nothing to install";
(* And the same claim for the form that makes it hard. Every [def]
initialiser is lifted into [global/<n>] so a re-evaluation can swap it
through the function cell, so a def's own [ginit] is a call and never a
constant and asking [Tast.const_init] about it would hand a brand-new
(def n i64 42) calloc's zero for the life of the process, with no
startup in the host to ever put 42 there. [Emit.initial_image] reads the
constant back out of the lifted body, which is where it went. The
defonce two rows above is what this is being compared against. *)
let c = Session.eval t "(def started-def i64 42) (defn read-sd [] i64 started-def)" in
if not (has c.Session.ir "@\".init.") then
fail "a new def's initial value was dropped";
if has c.Session.ir "@\"flan.started-def\"), i64 ptrtoint (ptr getelementptr (i64, ptr null, i32 1) to i64), ptr null)"
then fail "a new def was allocated with a null image";
(* The def's own half of the computed rule, which answers the same way the
defonce's does: nothing to write, so the storage is ZII. *)
let c =
Session.eval t
"(defn seed-def [] i64 22) (def computed-def i64 (seed-def)) \
(defn read-cd [] i64 computed-def)"
in
if not (has c.Session.ir "to i64), ptr null)") then
fail "a new computed def did not start zeroed";
(* Which of the two mutable forms declared a global is in the *startup
function*, compiled when the process started the guard a defonce has
and a def does not. A reload republishes the initialiser and cannot
republish that, so swapping the keyword would load cleanly and then keep
doing what the old keyword said. Refused, both ways.
The host's own globals, both of them read by nothing, so the checker has
no complaint and the session is the only thing that can refuse: [spare]
is a defonce and [paint] is a def. *)
(match Session.eval t "(def spare i64 0)" with
| _ -> fail "defonce became def without a word"
| exception Loc.Error { Loc.dmsg = m; _ } ->
if not (has m "spare changes from defonce to def") then
fail "the defonce-to-def refusal says: %s" m;
if not (has m "Restart to change it") then
fail "the form-change refusal does not say what to do: %s" m);
(match Session.eval t "(defonce paint i64 7)" with
| _ -> fail "def became defonce without a word"
| exception Loc.Error { Loc.dmsg = m; _ } ->
if not (has m "paint changes from def to defonce") then
fail "the def-to-defonce refusal says: %s" m);
(* Editing the *value* of a def is the workflow and stays allowed: same
form, same type, a new initialiser, and the lifted [global/paint]
republished through its cell so the next re-run stores the edited value.
This is the one the whole form exists for. *)
(match Session.eval t "(def paint i64 9)" with
| c ->
if not (List.mem "global/paint" c.Session.fns) then
fail "editing a def's value did not republish its initialiser: %s"
(String.concat " " c.Session.fns)
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "editing a def's value was refused: %s" m);
(* A def is storage like any other, so retyping one is the same refusal a
defonce gets reasoned when the form landed, exercised here. *)
(match Session.eval t "(def paint i32 9)" with
| _ -> fail "a def was retyped without a word"
| exception Loc.Error { Loc.dmsg = m; _ } ->
if not (has m "paint changes type") then
fail "retyping a def says: %s" m);
(* A def initialiser that reads another global at run time, which is a
different claim from the static ordering the checker sorts on: the
lifted function loads [counter] when it runs, so re-evaluating it is an
ordinary republish and the read is the running program's storage. *)
(match Session.eval t "(def paint i64 (+ counter 1))" with
| c ->
if not (List.mem "global/paint" c.Session.fns) then
fail "a def initialiser reading a global was not republished"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "a def initialiser reading a global was refused: %s" m);
(* Names the process was never built with go through the registry instead of (* Names the process was never built with go through the registry instead of
binding to a symbol, and adding one is allowed where retyping one is not. *) binding to a symbol, and adding one is allowed where retyping one is not. *)
let c = Session.eval t "(defonce fresh i64) (defn use-fresh [] i64 (set fresh 3) fresh)" in let c = Session.eval t "(defonce fresh i64) (defn use-fresh [] i64 (set fresh 3) fresh)" in

View File

@ -36,7 +36,7 @@
# sees a frontend attribute, and cannot tell Emit's output from clang's. # sees a frontend attribute, and cannot tell Emit's output from clang's.
# This is the whole reason it was reachable here when MSan was not. # This is the whole reason it was reachable here when MSan was not.
# #
# 3. `zeroed` storage. A zeroed defvar is in .bss, which memcheck treats as # 3. `zeroed` storage. A zeroed defonce is in .bss, which memcheck treats as
# defined because it is — the kernel supplies zeroes. No complaint, and # defined because it is — the kernel supplies zeroes. No complaint, and
# correctly so. # correctly so.
# #