From 191dcc12464209ad1e40f2366fcf77d07e5187ee Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 12:41:13 +0700 Subject: [PATCH] An abandoned frame rolls back, and restore goes after the defers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PORTING.md Tier 1 item 6, the last one on that list. Not a language feature and nothing was added to the language: restart-case, struct assignment and fixed-arrays-as-values were already built, and what was missing was the worked example. test/programs/frame-rollback.flan is it — snapshot at the top of the frame, restore in the `continue` clause, over one fixed array and one struct, which is engine.clj's grids plus engine.lisp's shallow copy of the state object. Two `set`s each way, because both are values; there is no IntGrid walk and no sb-mop:class-slots walk to write. The decision in it is the ordering against defers, and both orderings compile. An answered bounds failure runs the abandoned function's defers, innermost-first, before the restart clause body starts. Restore in the clause is therefore the last write on the abandoned path and needs no agreement with what any defer did on the way out. The rejected alternative — restore in a defer inside the frame function — also runs on the ordinary return path, so it rolls back the frames that succeeded, and nothing reports that as an error. Pinned with numbers rather than prose: a tick counter inside the snapshot, written by the frame's defer, reads its pre-frame value, while a counter outside the snapshot shows the defer ran. And there is a negative control, the same bad frame with a `continue` that only counts, because "state equals snapshot" passes trivially on a program that never wrote anything. Three acceptance rows beside bounds-condition.flan's, for the same reasons: -O0, and the dev build where every call goes through a cell. --- NEXT.md | 34 +++-- PORTING.md | 40 +++++- test/programs/frame-rollback.flan | 207 ++++++++++++++++++++++++++++++ test/test_acceptance.ml | 40 ++++++ 4 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 test/programs/frame-rollback.flan diff --git a/NEXT.md b/NEXT.md index 2225e6c..70f2136 100644 --- a/NEXT.md +++ b/NEXT.md @@ -74,18 +74,23 @@ path is hand-written and header-checked, not left to the opt-in `FLAN_RAYLIB_H` has no `FLAN_RAYLIB_H` and still has to draw. An enum value has to be added by hand whatever happens — the importer generates functions and only functions. -### Still not started: frame rollback, `PORTING.md` Tier 1 item 6 +### Landed: frame rollback, `PORTING.md` Tier 1 item 6 -The last item on that list, and the only one of Tier 1 that has not moved. Not a language feature: `restart-case`, -struct assignment and fixed-arrays-as-values are all built. What is missing is the worked example — -`snapshot`/`restore` callbacks beside the `continue` restart, the way `sand.flan` is the worked example for the -loop — plus a headless case on the `dune test` path. `bounds-condition.flan` shows an abandoned frame leaving -half-written state behind; this is what finishes that thought, and a bad index has landed in `continue` rather than -`exit(134)` since this morning, so it is worth more than it was. +`test/programs/frame-rollback.flan`, with three acceptance rows beside `bounds-condition.flan`'s (plain, `-O0`, +dev). Nothing was added to the language, which was the premise: `snapshot` is `(set grid-backup grid)` and +`(set world-backup world)`, `restore` is the same two the other way, and that is the whole of what `engine.clj` +spends an `IntGrid` walk on and `engine.lisp` spends `sb-mop:class-slots` on. -One thing to get right rather than discover: an *answered* bounds failure runs the function's defers, because it -leaves through the same unwind path a `return` does. A restore has to be ordered against them deliberately, and -getting it wrong is silent rather than loud. +**The ordering against defers was the decision.** Restore lives in the `continue` clause, so it is the last write +on the abandoned path and needs no agreement with any defer that ran on the way out. The rejected alternative — +restore in a `defer` inside the frame function — is the silent one: a defer runs on the ordinary return path too, +so it rolls back the frames that *succeeded*, and nothing reports that as an error. The test pins the ordering with +a counter inside the snapshot and a counter outside it rather than in prose, and carries a negative control (the +same bad frame, a `continue` that only counts) because "state equals snapshot" passes trivially on a program that +wrote nothing. + +One edge left in the file's header comment rather than here: the snapshot covers plain values only. A defer that +frees a resource the snapshot holds a pointer to means restore resurrects a dangling one. ### Landed: sand.flan is the game the other ports are @@ -223,10 +228,11 @@ Tier 0 is finished, and so is item 5. **The watch for a running program is done* built (a pushed table, the watch buffer, inline ghost text) and the `spy-num` half landed 2026-09-13: a hot-loop slot keeps count/min/max/last/mean, the write path does no formatting, and the window is since the editor's last tick rather than cumulative, which is a deliberate divergence from `watch.clj` argued in `BUILT.md`, "A hot loop keeps -five numbers, and the window is the editor's". So of Tier 1 **item 6 is the next one**: **frame rollback as a worked -example** — `snapshot`/`restore` callbacks beside the `continue` restart, which is -now genuinely reachable from a bad index and so is worth more than it was yesterday. `bounds-condition.flan` shows an -abandoned frame leaving half-written state behind; rollback is what finishes that thought. +five numbers, and the window is the editor's". **Item 6 landed the same day**: +`test/programs/frame-rollback.flan` is the worked example — `snapshot` at the top of the frame, `restore` in the +`continue` clause — and `bounds-condition.flan`'s half-written abandoned frame is the thought it finishes. That was +the last Tier 1 item anyone was going to move. Items 7 (`drop`), 8 (generics) and 9 (`(read-edn T bytes)`) are still +on that list and still deferred with reasons written beside each; none is a blocker for this game. **What `PORTING.md` says NOT to build, with evidence:** escaping closures (one capture site, fixed by one parameter), `Handle`/pools, `Result`/`try`, `handler-case`, `loop`/`recur` and tail calls, user allocators, structural typing — diff --git a/PORTING.md b/PORTING.md index 1bfcd48..bf1158e 100644 --- a/PORTING.md +++ b/PORTING.md @@ -307,6 +307,13 @@ another name. Two things are missing behind it: arrays are values, so `(set backup grid)` is the whole of `snapshot!` and there is no `sb-mop:class-slots` walk to write. It just has to be written. + > **Done, 2026-09-13.** Written, in `test/programs/frame-rollback.flan`: + > `snapshot` at the top of the frame, `restore` in the `continue` clause, + > over one fixed array and one struct — `engine.clj`'s grids plus + > `engine.lisp`'s shallow copy of the state object, two `set`s here because + > both are values. The ordering against defers is the decision in it, and + > item 6 below records it. Three acceptance rows, plain, `-O0` and dev. + 2. **A bounds failure is not a condition.** `runtime/flan_rt.c` `flan_bounds_fail` prints and calls `rt_die`, which is `exit(134)`. No handler runs, no restart is offered, and `lib/dev.ml` answers every subsequent request with "the program exited; restart flan @@ -543,11 +550,34 @@ not compete for the same slot. accumulator is reached by the same plain `declare-c` the scalars use, so the `(watch "hp" hp)` form is still unbuilt and still only wanted for composites. -6. **Frame rollback in the engine pattern.** Not a language feature — the pieces are all - there (`restart-case`, struct assignment, fixed arrays as values). What is missing is - the worked example showing `snapshot`/`restore` callbacks alongside the `continue` - restart, the way `sand.flan` is the worked example for the loop. A page of code and a - test program, and the "never restarting" claim gets materially stronger. +6. ~~**Frame rollback in the engine pattern.**~~ **Done, 2026-09-13.** Not a language + feature and nothing was added to the language: `test/programs/frame-rollback.flan` is + the worked example, `snapshot` at the top of the frame and `restore` in the `continue` + clause, over one fixed array and one struct. Two `set`s each way. That is the whole + claim about values — `engine.clj` walks every `IntGrid` and `engine.lisp` walks + `sb-mop:class-slots`, and here there is nothing to walk. + + **The ordering against defers was the decision, and both orderings compile.** An + *answered* bounds failure runs the abandoned function's defers, innermost-first, + **before** the restart clause body starts (item 4 above, `spec-conditions.md` §5). So: + + - **`restore` in the `continue` clause** — chosen. It is the last write on the + abandoned path and therefore needs no agreement with what any defer did on the way + out. A defer that writes into the snapshotted state is simply overwritten, which is + what "the frame did not happen" means. + - `restore` in a `defer` inside the frame function — rejected, **and it is the silent + one**. A defer runs on the ordinary return path too, so that version rolls back the + frames that *succeeded*. Nothing errors; the game stops advancing. + + The test pins the ordering with two numbers rather than asserting it in prose: a + counter inside the snapshot, written by the frame's defer, reads its pre-frame value, + while a counter outside the snapshot proves the defer ran. And there is a negative + control — the same bad frame with a `continue` that only counts — because "state + equals snapshot" passes trivially on a program that wrote nothing. + + One edge worth carrying: **the snapshot covers plain values only.** If a defer frees a + resource and the snapshot holds a pointer or handle to it, restore resurrects a + dangling one. Value state in the snapshot, resources in the defers, no overlap. 7. **`drop`, or recursive teardown.** Unblocks `(Vec T)` where `T` owns a `Vec`, which is `game.lisp`'s tileset shape exactly. Fixed arrays sidestep it at this size, so this is diff --git a/test/programs/frame-rollback.flan b/test/programs/frame-rollback.flan new file mode 100644 index 0000000..5985ab3 --- /dev/null +++ b/test/programs/frame-rollback.flan @@ -0,0 +1,207 @@ +;;;; Frame rollback: the half of the frame loop that `continue` does not do. +;;;; +;;;; PORTING.md Tier 1 item 6. Not a language feature — restart-case, struct +;;;; assignment and fixed-arrays-as-values are all built. This is the worked +;;;; example, the way sand.flan is the worked example for the loop itself. +;;;; +;;;; Both reference hosts survive a bad frame and keep the window open, and +;;;; both do it the same way underneath: engine.clj's `run-game!` snapshots +;;;; every IntGrid at the top of the frame, catches Throwable and restores; +;;;; engine.lisp's `run-frame` wraps update and draw in a restart-case whose +;;;; `retry-frame`, `skip-frame` and `reinit` each roll the grids *and* a MOP +;;;; shallow copy of the state object back. Flan already had the restart-case +;;;; and, since a bad index signals BoundsError rather than calling exit(134), +;;;; already lands in `continue` instead of ending the session. What it did +;;;; not have was the rollback — so an abandoned frame left the grid +;;;; half-written, which is bounds-condition.flan's last line (`10 99 12 13`) +;;;; read as a bug rather than as a result. +;;;; +;;;; **A restart is not a transaction** (spec-conditions.md §5). A transfer +;;;; runs defers and moves control; it does not undo. Nothing here is a +;;;; mechanism the compiler provides, and that is the point: rollback is a +;;;; discipline the author writes, and in Flan it is two lines, because a +;;;; fixed array and a struct are values. `(set backup grid)` is the whole of +;;;; snapshot! — no walk, no sb-mop:class-slots, no per-cell copy loop. +;;;; +;;;; ── The ordering decision, which is the reason to read this file ── +;;;; +;;;; An *answered* bounds failure runs the abandoned function's defers, because +;;;; it leaves through the same unwind path a `return` does, and those defers +;;;; run innermost-first **before** the restart clause body starts. So a +;;;; snapshot has to be ordered against them deliberately, and both orderings +;;;; compile: +;;;; +;;;; - **restore in the restart clause** — chosen. It is the last write on +;;;; the abandoned path, so it needs no agreement with what any defer did +;;;; on the way out. A defer that writes into the snapshotted state is +;;;; simply overwritten, which is what "the frame did not happen" means. +;;;; - restore in a `defer` inside the frame function — **rejected, and it is +;;;; the silent one.** A defer runs on the ordinary return path too, so +;;;; that version rolls back the frames that *succeeded*. Nothing errors; +;;;; the game just stops advancing. +;;;; +;;;; `tick` below is the discriminator. It is written by update-frame's defer, +;;;; it lives inside the snapshot, and after an abandoned frame it reads as its +;;;; pre-frame value while `tails` — which lives outside the snapshot — proves +;;;; the defer ran. Only the chosen ordering produces that pair. +;;;; +;;;; **The snapshot covers plain values only.** If a defer frees a resource and +;;;; the snapshot holds a pointer or a handle to it, restore resurrects a +;;;; dangling one. Value state in the snapshot, resources in the defers, and +;;;; no overlap between them. +;;;; +;;;; ── What is asserted ── +;;;; +;;;; Frame 2 is the negative control and it is what makes frame 3 mean +;;;; anything: the same bad frame, the same snapshot taken, and a `continue` +;;;; clause that only counts. It leaves drift 3. Frame 3 restores and leaves +;;;; drift 0. Without the control, "state equals snapshot" would pass on a +;;;; program that never wrote anything and never restored anything. + +;;; The state the engine owns: one grid of cells, and one struct of scalars. +;;; Two shapes on purpose — engine.clj rolls back grids, engine.lisp rolls +;;; back grids *and* a copy of the state object, and in Flan each is one `set`. +(defstruct World [placed i32 brush i32]) + +(defvar grid [8 i32]) +(defvar world World) + +;;; The snapshot. Same types, same declarations; there is nothing else to it. +(defvar grid-backup [8 i32]) +(defvar world-backup World) + +;;; Evidence, and it has to live *outside* the snapshot or restore rolls back +;;; the proof along with the state. `writes` counts the mutations a frame made +;;; before it failed, `tails` counts the defers that ran on the way out. +(defvar frames i64) +(defvar skipped i64) +(defvar writes i64) +(defvar tails i64) + +;;; Handlers cannot see the locals of the function that established them — +;;; check.ml refuses a capture by name and says to use a global — so the +;;; condition's numbers land up here too. +(defvar low i64) +(defvar length i64) + +;;; ── snapshot and restore ────────────────────────────────────────────── +;;; The whole of it. A fixed array copies on assignment and so does a struct +;;; (values.flan), so these are two stores each and the cost is the size of +;;; the state, not the shape of it. + +(defn snapshot [] () + (set grid-backup grid) + (set world-backup world)) + +(defn restore [] () + (set grid grid-backup) + (set world world-backup)) + +;;; How far the live state has drifted from the snapshot: the assertion this +;;; program exists to make. Counted rather than compared with `=`, because +;;; `=` is numeric and enum only — there is no structural equality in the +;;; surface language, and a hand-written walk is what a game would write too. +(defn drift [] i64 + (let [d (i64 0)] + (dotimes [i 8] + (when (!= (at grid i) (at grid-backup i)) (set d (+ d 1)))) + (when (!= (.placed world) (.placed world-backup)) (set d (+ d 1))) + (when (!= (.brush world) (.brush world-backup)) (set d (+ d 1))) + d)) + +(defn show [name string n i64] () + (print name) (print " ") (print n) (println "")) + +(defn dump [] () + (print (at grid 0)) (print " ") (print (at grid 3)) (print " ") + (print (at grid 7)) (print " ") (print (.placed world)) (println "")) + +;;; ── the frame ───────────────────────────────────────────────────────── + +;;; The engine's tail write. It runs on every exit path — the good one and the +;;; abandoned one — and it writes into the snapshotted state deliberately, so +;;; that cell 7 discriminates the two orderings. `tails` does not, which is why +;;; it is a separate counter. +(defn tick [] () + (set (at grid 7) (+ (at grid 7) 1)) + (set tails (+ tails 1))) + +;;; Mutate first, fail second: the half-written frame this whole file is about. +;;; `col` is a mouse column in the real thing — game.clj computes it straight +;;; from the pointer with no check anywhere, which is how an ordinary session +;;; walks into this by moving one pixel outside the window. +(defn update-frame [col i32] () + (defer (tick)) + (set (.placed world) (+ (.placed world) 1)) + (set (at grid 0) (+ (at grid 0) 1)) + (set writes (+ writes 1)) + (set (at grid col) 5)) + +;;; The frame loop, without the rollback. sand.flan's shape exactly, and the +;;; negative control: the snapshot is taken and then ignored. +(defn frame-no-rollback [col i32] () + (snapshot) + (restart-case + (do (update-frame col) + (set frames (+ frames 1))) + (continue [] (set skipped (+ skipped 1))))) + +;;; And with it. One line more, in one place, and it is the *last* thing on the +;;; abandoned path rather than the first. +(defn frame [col i32] () + (snapshot) + (restart-case + (do (update-frame col) + (set frames (+ frames 1))) + (continue [] (restore) + (set skipped (+ skipped 1))))) + +(defn main [] i32 + (set (.brush world) 1) + + (handler-bind + [(BoundsError [c] + (set low (.low c)) + (set length (.length c)) + ;; Abandon the frame. The transfer crosses update-frame, running its + ;; defer, and lands in the clause of the restart-case one frame out. + (invoke-restart 'continue))] + + ;; Frame 1: in bounds. The handler never runs, the frame finishes, and the + ;; defer runs on the ordinary return path — which is exactly why restore + ;; must not live in a defer: this frame's work has to survive. + (frame 3) + (dump) ; 1 5 1 1 + (show "drift" (drift)) ; 1 5 1 1 vs the zeroed snapshot: 4 + + ;; Frame 2: out of bounds, and nothing rolls back. The frame is abandoned + ;; and the session survives — that much `continue` already gave us — but + ;; the state is the frame's leftovers: cell 0 bumped, placed bumped, the + ;; defer's tick landed, and the write that failed never happened. + (frame-no-rollback 9) + (dump) ; 2 5 2 2 + (show "drift" (drift)) ; 3 — and this is the bug + + ;; Frame 3: the same bad index, the same snapshot, restore in the clause. + (frame 9) + (dump) ; 2 5 2 2 — frame 2's state, intact + (show "drift" (drift)) ; 0 + + ;; The ordering, in two numbers. `tails` is 3, so update-frame's defer ran + ;; on the abandoned path as well as the good one; cell 7 is back at 2, so + ;; the restore happened *after* it. Had restore run first — in a defer + ;; registered *below* `(defer (tick))`, since defers run innermost-first + ;; and the later registration is the inner one — cell 7 would read 3 and + ;; drift above would have read 1, which is the quiet kind of wrong. + (show "tails" tails) ; 3 + (show "tick" (i64 (at grid 7))) ; 2 + + ;; Three frames, two of them bad; one finished, two were abandoned; three + ;; mutations were made before the failures, of which only frame 1's + ;; survives. And the condition carried the real numbers both times. + (show "frames" frames) ; 1 + (show "skipped" skipped) ; 2 + (show "writes" writes) ; 3 + (show "low" low) ; 9 + (show "length" length)) ; 8 + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 1f3b52b..3d7e17e 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1237,6 +1237,46 @@ let () = outputs ~dev:true "a bad index is a condition, dev" "programs/bounds-condition.flan" bounds_cond_out; + (* And the half that finishes that thought. bounds-condition.flan's last + line is `10 99 12 13` — an abandoned frame's leftovers — and a restart + undoes none of it, because a restart is not a transaction + (spec-conditions.md §5). So rollback is written rather than provided, + and frame-rollback.flan is the worked example: snapshot at the top of + the frame, restore in the `continue` clause, over one fixed array and + one struct, which is engine.clj's grids plus engine.lisp's shallow copy + of the state object and is two `set`s here because both are values. + + The rows to read are the three drifts. Frame 1 is in bounds and leaves + drift 4 against a zeroed snapshot — work that must survive. Frame 2 is + the negative control: the same bad index, the same snapshot taken, and + a `continue` that only counts, leaving drift 3. Without it "state equals + snapshot" would pass on a program that wrote nothing. Frame 3 restores + and leaves drift 0. + + `tails 3` and `tick 2` are the ordering, and they are the pair no other + ordering produces. An answered bounds failure runs the abandoned + function's defers — innermost-first, before the clause body — so + update-frame's defer ran on all three frames (tails 3) and wrote into + the snapshotted cell 7, and cell 7 still reads its pre-frame 2 because + restore is the last write on that path. Restore in a defer instead + would read tick 3 here, and would also roll back frame 1, which nothing + reports as an error. + + Three rows for the reason the bounds rows above have three: -O0 pins + that the transfer does not depend on optimisation, and the dev build + pins that a call through a cell and a shadow-stack frame per call does + not change where it lands. *) + let rollback_out = + "1 5 1 1\ndrift 4\n2 5 2 2\ndrift 3\n2 5 2 2\ndrift 0\n\ + tails 3\ntick 2\nframes 1\nskipped 2\nwrites 3\nlow 9\nlength 8\n" + in + outputs "an abandoned frame rolls back" "programs/frame-rollback.flan" + rollback_out; + outputs ~opt:"-O0" "an abandoned frame rolls back, -O0" + "programs/frame-rollback.flan" rollback_out; + outputs ~dev:true "an abandoned frame rolls back, dev" + "programs/frame-rollback.flan" rollback_out; + (* ── Packages: the link follows the program ──────────────────────── A package's C and linker arguments used to come with the import, whatever [main] did — which is what made sand's two halves two files