diff --git a/BUILT.md b/BUILT.md index 8b7cad6..0ecf9e3 100644 --- a/BUILT.md +++ b/BUILT.md @@ -4445,3 +4445,27 @@ everything else there is arithmetic over a container header. failure — the region a container lived in was released, and there is no frame to go back to that would not read freed memory — and the map path was left alone rather than converted half-way. Written down here so it is a known edge rather than a discovery. + +## A restart is not a transaction + +Written down in three places rather than fixed, because it is a property and not a defect. If a frame mutates a global +and then signals, taking a `retry` **re-runs the mutation**. Control resumes at the `restart-case` and runs forward +from there; nothing is undone. Common Lisp has exactly this property and offers no help either — rollback would mean +journalling every store, which is a different language. + +The discipline is that **the author chooses where the retry boundary is**. A `restart-case` at the top of a frame +re-runs everything including mutations already applied; one placed after the mutations re-runs only what follows them. +So: put the restart before anything mutates, make the retried section idempotent, or snapshot what will be re-applied. +§3 of `spec-conditions.md` places a restart *syntactically* — every clause body and the body share a type — and +nothing places it semantically. + +It matters more here than in most Lisps because the intended use is a **game loop**, where the plan is to skip a frame +and carry on rather than die. That stopped being hypothetical when a failed bounds check began signalling +`BoundsError` instead of ending the process (see "An index out of range is a condition"): a frame can now be abandoned +and retried, which is exactly the case a non-idempotent mutation spoils. + +Both `conditions.org` and `web/index.html` already carried the mechanical half — "a restart re-runs whatever sits +between it and the target" — as a one-line gotcha. Those were rewritten in place into the full statement rather than +having a second bullet added beside them, and the same reasoning went into `spec-conditions.md` §5, which is the +section that already enumerates what a transfer does and does not do: it runs `defer`s, it skips `errdefer`s, and it +does not undo. No numbered case changed meaning, so the freeze holds. diff --git a/NEXT.md b/NEXT.md index d70afce..4b56725 100644 --- a/NEXT.md +++ b/NEXT.md @@ -5,21 +5,6 @@ that is what makes it checkable against the header — and the layer is where a already exist by hand in `vendor/raylib/raylib.flan` and are the shape to copy: `collision-point-poly?` takes a slice and `collision-lines` answers with an `Option`, each wrapping a `-raw` binding of the same name. -## Queued: a restart is not a transaction, and the docs must say so - -Raised by the author, and it is a real sharp edge rather than a gap. **If a frame mutates a global and then signals, -taking a `retry` re-runs the mutation.** Nothing rolls back. Common Lisp has exactly this property and offers no help -either — restarts are explicitly not transactional. - -The discipline is that **the author chooses where the retry boundary is**: a `restart-case` at the top of a frame -re-runs everything including mutations already applied; one placed after the mutations re-runs only what follows. So -either put the restart before anything mutates, make the retried section idempotent, or snapshot what will be -re-applied. - -This matters more here than in most Lisps because the intended use is a **game loop**, where the author's plan is to -skip a frame and carry on rather than die — exactly the case where a non-idempotent mutation bites. Write it into -`conditions.org` and `spec-conditions.md`'s prose, and into `web/index.html` beside the restart documentation. - ## Queued: a dev-build allocation registry — address to type Raised in conversation and wanted. **What it is:** in a dev build only, every allocation records what type it was diff --git a/conditions.org b/conditions.org index 23ce07e..b0fa0a2 100644 --- a/conditions.org +++ b/conditions.org @@ -81,9 +81,22 @@ the agent's socket instead. - *A handler closes over nothing.* It is lifted into its own function. Accumulate into a global, or put the value on the condition. -- *A restart re-runs whatever sits between it and the target.* Control resumes - at the ~restart-case~, so a ~retry~ repeats side effects after it. Put the - ~restart-case~ where re-entry is safe. +- *A restart is not a transaction.* Control resumes at the ~restart-case~ and + runs forward from there, so a ~retry~ repeats every side effect between it + and the target. Nothing rolls back: a global the frame already set stays set, + and gets set again. Common Lisp has exactly this property and offers no help + either — restarts are not transactional there and are not here. + + So *you* choose where the retry boundary is. A ~restart-case~ at the top of a + frame re-runs everything, mutations included; one placed after the mutations + re-runs only what follows. Put the restart before anything mutates, make the + retried section idempotent, or snapshot what will be re-applied. + + This bites harder here than in most Lisps because the point is a *game loop* + — skip the frame, carry on, don't die. Since a bad index signals + ~BoundsError~ rather than ending the process, abandoning a frame and retrying + it is a real thing to do, and a non-idempotent mutation is what makes it go + wrong. - *An unknown restart name is a hard stop.* No ~find-restart~ to test with. - *So are the wrong arguments*, and for the same reason: nothing static can know what a name will find. The message names both signatures. diff --git a/spec-conditions.md b/spec-conditions.md index 250729d..5e20abb 100644 --- a/spec-conditions.md +++ b/spec-conditions.md @@ -117,6 +117,28 @@ Invoking a restart transfers control outward past zero or more frames. the signalling frame dies. Anything a handler keeps must be copied out (conditions are value structs, so `(push errors c)` copies). +**A restart is not a transaction.** The list above is the whole of what a +transfer does: it runs `defer`s and it moves control. It does not undo. Control +resumes at the `restart-case` and runs forward from there, so a `retry` re-runs +every effect between the restart and the target — a global the frame already +assigned stays assigned, and is assigned again. This is not a gap to be closed +later. Common Lisp has exactly this property and offers no help either; rollback +would mean journalling every store, which is a different language. + +What follows is a discipline rather than a mechanism: **the author chooses where +the retry boundary is.** A `restart-case` at the top of a frame re-runs +everything, mutations included; one placed after the mutations re-runs only what +follows them. So either put the restart before anything mutates, make the +retried section idempotent, or snapshot what will be re-applied. + +It matters more here than in most Lisps because of the intended use. A game loop +means to skip a frame and carry on rather than die, and a failed bounds check +signalling `BoundsError` rather than ending the process makes abandoning and +retrying a frame an ordinary thing to do — which is precisely the case where a +non-idempotent mutation bites. §3's rule that every clause body and the body +share a type places the restart syntactically; nothing places it *semantically*, +and that choice is the author's. + ## 6. Crossing compiler-generated frames Transfer is lowered **explicitly** — result propagation plus branch targets — not diff --git a/web/index.html b/web/index.html index 01cc815..9180a1b 100644 --- a/web/index.html +++ b/web/index.html @@ -1024,9 +1024,20 @@ a guard after each call.

of its own, because it runs from wherever the signal was. Accumulate into a global, or put the value on the condition. A reference to an enclosing local is refused for that reason rather than reported as an unknown name. -
  • A restart re-runs whatever sits between it and the target. Control - resumes at the restart-case, so a retry repeats the side - effects after it. Put the restart-case where re-entry is safe.
  • +
  • A restart is not a transaction. Control resumes at the + restart-case and runs forward from there, so a retry repeats + every side effect between it and the target. Nothing rolls back — a global the frame + already set stays set, and is set again. Common Lisp has exactly this property and + offers no help either. +

    So the author chooses where the retry boundary is. A restart-case at the + top of a frame re-runs everything including mutations already applied; one placed after + the mutations re-runs only what follows. Put the restart before anything mutates, make + the retried section idempotent, or snapshot what will be re-applied.

    +

    This matters more here than in most Lisps because the intended use is a + game loop, where the plan is to skip a frame and carry on rather than die. Now + that a bad index signals BoundsError instead of ending the process, + abandoning a frame and retrying it is a real thing to do — and that is exactly the case + a non-idempotent mutation spoils.

  • An unknown restart name is a hard stop — a located runtime error. There is no find-restart to test with yet.
  • No supertype, so nothing can say "any condition".