diff --git a/FIX.org b/FIX.org index 7d21695..549d50e 100644 --- a/FIX.org +++ b/FIX.org @@ -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 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/] 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 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 diff --git a/bin/main.ml b/bin/main.ml index 76016f2..fff8199 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -351,7 +351,13 @@ let () = List.iter (fun (g : Flan.Tast.global) -> 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)) p.globals; List.iter @@ -739,7 +745,7 @@ let () = p ~out)) (* 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, - 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 describe the process that is actually running. *) | _ :: "dev" :: path :: rest -> diff --git a/docs/BUILT.md b/docs/BUILT.md index 714cb84..4b1a2ad 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -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 `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. -`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/` 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 every `def` spelling's startup on both, at `-O0` and `-O2`, and `programs/dev-rerun.flan` pins the live loop — a diff --git a/docs/DIAGNOSTICS-AUDIT.md b/docs/DIAGNOSTICS-AUDIT.md index 1c9d7ea..2fe9966 100644 --- a/docs/DIAGNOSTICS-AUDIT.md +++ b/docs/DIAGNOSTICS-AUDIT.md @@ -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. | | 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. | -| 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. | | 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. | @@ -110,11 +110,11 @@ Elm-class. 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 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. **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 banned essay register, and a fix pass should cut it by a third rather than lengthen anything toward it. diff --git a/docs/PORTING.md b/docs/PORTING.md index d47f29d..73ba2a6 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -74,7 +74,7 @@ Three more the same way, none of them blockers, all one line each: | Missing | Used at | Verdict | |---|---|---| | `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 | | `UpdateTexture` | declared in `rl.clj`, called nowhere | 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.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: `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. > **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 > 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. @@ -313,7 +313,7 @@ more callbacks. **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: *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 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 @@ -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. **`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 call fit it exactly. diff --git a/docs/SPIKE-DUPLICITY.md b/docs/SPIKE-DUPLICITY.md index 0ee3e0a..486503c 100644 --- a/docs/SPIKE-DUPLICITY.md +++ b/docs/SPIKE-DUPLICITY.md @@ -215,7 +215,7 @@ still alive, and a dyn in it has to stay rooted across a transfer the root stack ```lisp (defstruct S [x dyn]) -(defvar boxes (Vec S)) +(defonce boxes (Vec S)) (defn stash [] () (push boxes (S {.x (vec-new dyn)}))) ``` diff --git a/docs/SPIKE-INFERENCE.md b/docs/SPIKE-INFERENCE.md index 48a25c0..b32233c 100644 --- a/docs/SPIKE-INFERENCE.md +++ b/docs/SPIKE-INFERENCE.md @@ -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 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."* 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: - **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 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 diff --git a/lib/emit.ml b/lib/emit.ml index f79c09a..a0cf554 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -3701,6 +3701,45 @@ let emit_startup m ?(hidden = false) (globals : Tast.global list) = floc = (List.hd computed).Tast.ginit.Tast.loc }; 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/] 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 ───────────────────────────────────────────────────────── *) 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. *) (* Its declared initial value travels with it, as a constant the runtime copies on the allocation and ignores afterwards. Without - this a new (defonce n i64 42) or a new defconst would silently be - zero — calloc is only the right answer for ZII. - - A *computed* initialiser sends a null instead, and the allocation - keeps calloc's zeroes. That is the reload rule and not a gap in - 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. *) + this a new (defonce n i64 42), a new (def n i64 42) or a new + defconst would silently be zero — calloc is only the right answer + for ZII. [initial_image] is what decides, and it is shared with + the x86 backend: see the note above it for the def's case and for + why a computed initialiser sends a null instead. *) let init = - if not (Tast.const_init g.Tast.ginit) then "null" - else begin + match initial_image p g with + | None -> "null" + | Some v -> let init = Printf.sprintf "@\".init.%d\"" m.nstr in m.nstr <- m.nstr + 1; Buffer.add_string m.strs (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 - end in Buffer.add_string b (Printf.sprintf diff --git a/lib/parse.ml b/lib/parse.ml index 05b0bdd..3501b7b 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -1660,14 +1660,27 @@ let rec decl (f : Form.t) : Ast.decl = through to "unknown function": every program written before the rename spells it, and the message is the migration. *) | List ({ v = Sym "defvar"; _ } :: args) -> - 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 + (* The rest of the form is echoed back inside the two spellings, so the + answer is a line that can be pasted. A form with nothing after the + keyword has nothing to paste, and echoing it would offer + [(defonce )] as the fix for [(defvar )] — a malformed old form + answered with a malformed new one. The names alone then, which is what + there is to say about a form that named nothing. *) + (match args with + | [] -> + 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) -> (match args with diff --git a/lib/session.ml b/lib/session.ml index 9f54f0f..132c618 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -239,7 +239,19 @@ let compatible ?(origin = fun _ -> None) ?(relaxed = []) ~loc 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 as it always was. *) - if not same && not (List.mem f.Tast.name relaxed) then + (* A lifted initialiser, [global/], 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 ────────────── A generic's instantiations are named [sort-i32], [sort-f32] 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 \ that storage out. Restart to change it." 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; List.iter diff --git a/lib/x86.ml b/lib/x86.ml index 3f6a207..107d32c 100644 --- a/lib/x86.ml +++ b/lib/x86.ml @@ -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 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 - 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/] 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 = List.map (fun (g : Tast.global) -> let size, align = Emit.lay md g.Tast.gty in let l = - if not (Tast.const_init g.Tast.ginit) then None - else begin + match Emit.initial_image p g with + | None -> None + | Some v -> 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 - end in (g, l, max 1 size, max 1 align)) new_globals diff --git a/plan.org b/plan.org index d94df05..e45da52 100644 --- a/plan.org +++ b/plan.org @@ -122,7 +122,7 @@ world. 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 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 — ~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 @@ -131,7 +131,7 @@ world. zeroed*, as in Odin — the same rule as a declaration with no initialiser, so ~(Cursor {.src src})~ is complete and means ~pos~ is 0. - *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 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. @@ -900,7 +900,7 @@ marked. *struct* layout with live values is rejected; a managed class layout has an explicit frame-boundary migration path, as described above. Still open: a 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". 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 diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 9c8205a..a1cef0f 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -3,7 +3,7 @@ * A redefinition module reaches the host's functions and globals through * 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 - * 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 * bind to and ELF cannot grow one. * diff --git a/runtime/flan_dyn_stub.c b/runtime/flan_dyn_stub.c index 41eb8b7..3f30161 100644 --- a/runtime/flan_dyn_stub.c +++ b/runtime/flan_dyn_stub.c @@ -321,7 +321,7 @@ int64_t flan_dyn_need_i64(flan_dyn v) { double flan_dyn_need_f64(flan_dyn v) { cell *c = as(v); /* 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. */ 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"); diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 4864ec6..62cb370 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -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 * same program text. */ 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 * program text, which is the thing this trap exists to prevent. */ 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 * 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 @@ -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 * 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 - * 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 * 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 @@ -2472,7 +2472,7 @@ int8_t flan_map_init(flan_map *m, flan_allocator *a, int64_t ksize, m->alloc = a; m->epoch = (int64_t)a->epoch; /* 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; } diff --git a/test/programs/bytes-copy.flan b/test/programs/bytes-copy.flan index d0c74df..44cbcbc 100644 --- a/test/programs/bytes-copy.flan +++ b/test/programs/bytes-copy.flan @@ -4,7 +4,7 @@ ;;;; printing unchanged, where the old (bytes s) either showed the write ;;;; through or trapped, depending on where the string's storage was. -(defvar frame Allocator) +(defonce frame Allocator) (defn main [] i32 ;; 1. The copy is writable and independent. Under the old reinterpret this diff --git a/test/programs/println-variadic.flan b/test/programs/println-variadic.flan index 3cade8b..0ffc86d 100644 --- a/test/programs/println-variadic.flan +++ b/test/programs/println-variadic.flan @@ -10,8 +10,8 @@ ;;;; 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. -(defvar boxed dyn 21) -(defvar dlabel dyn "mid") +(defonce boxed dyn 21) +(defonce dlabel dyn "mid") (defn main [] () ;; println at every arity diff --git a/test/test_flan.ml b/test/test_flan.ml index db9e11a..ac1b82c 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -526,6 +526,13 @@ let () = | _ -> check "the old defvar spelling has a kind" false | exception Loc.Error { Loc.kind; _ } -> 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 | Import ("rl", "vendor:raylib") -> () | _ -> check "import" false); diff --git a/test/test_session.ml b/test/test_session.ml index 1539b99..3fe6e32 100644 --- a/test/test_session.ml +++ b/test/test_session.ml @@ -291,6 +291,79 @@ let () = fail "a new computed global did not start zeroed"; 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/] 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 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 diff --git a/test/valgrind.supp b/test/valgrind.supp index f36217c..2c52391 100644 --- a/test/valgrind.supp +++ b/test/valgrind.supp @@ -36,7 +36,7 @@ # 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. # -# 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 # correctly so. #