A restart is not a transaction, and nothing rolls back

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; nothing is
undone. Common Lisp has the same property and offers no help either, so this
is written down rather than fixed.

The discipline is that the author chooses where the retry boundary is: a
restart-case above the mutations re-runs them, one below re-runs only what
follows. 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 the intended use is a game
loop, and a bad index signalling BoundsError rather than ending the process
made abandoning and retrying a frame an ordinary thing to do.

conditions.org and web/index.html already carried the mechanical half as a
one-line gotcha; those are rewritten in place rather than gaining a second
bullet beside them. spec-conditions.md takes it in section 5, which already
enumerates what a transfer does and does not do. No numbered case changed
meaning.
This commit is contained in:
Joseph Ferano 2026-09-13 09:14:46 +07:00
parent 61c41ed27c
commit 042b2ce4d0
5 changed files with 76 additions and 21 deletions

View File

@ -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.

15
NEXT.md
View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -1024,9 +1024,20 @@ a guard after each call.</p>
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.</li>
<li><strong>A restart re-runs whatever sits between it and the target.</strong> Control
resumes at the <code>restart-case</code>, so a <code>retry</code> repeats the side
effects after it. Put the <code>restart-case</code> where re-entry is safe.</li>
<li><strong>A restart is not a transaction.</strong> Control resumes at the
<code>restart-case</code> and runs forward from there, so a <code>retry</code> 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.
<p>So the author chooses where the retry boundary is. A <code>restart-case</code> 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.</p>
<p>This matters more here than in most Lisps because the intended use is a
<em>game loop</em>, where the plan is to skip a frame and carry on rather than die. Now
that a bad index signals <code>BoundsError</code> 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.</p></li>
<li><strong>An unknown restart name is a hard stop</strong> — a located runtime error.
There is no <code>find-restart</code> to test with yet.</li>
<li><strong>No supertype</strong>, so nothing can say "any condition".</li>