Merge sig-leave (restore the terminal on INT/QUIT)

This commit was merged in pull request #23.
This commit is contained in:
2026-08-09 08:19:01 +02:00
4 changed files with 582 additions and 12 deletions

View File

@@ -1513,8 +1513,27 @@ Ported drawing code keeps its structure: `mvaddch(y, x, ch)` →
buffer, preserving the "screen is a data structure" idiom without touching the
real terminal. `md_readchar`'s escape decoding is deleted; tcell's `EventKey`
provides decoded keys and we translate to the byte codes `command()` already
handles (KeyUp → 'k', etc.). SIGTSTP/resume and resize are handled by tcell;
SIGHUP/SIGTERM → `autoSave` via `os/signal`.
handles (KeyUp → 'k', etc.). Resize is handled by tcell, which registers only
SIGWINCH — SIGTSTP is not among the signals it takes, and is dropped (§9).
Signals are handled in `cmd/rogue/main.go`: one `os/signal` channel read by one
goroutine that reads exactly one signal, so a second signal can never call
`os.Exit` out from under an in-flight save. SIGHUP and SIGTERM `AutoSave` on the
way out (save.c `auto_save`); SIGINT and SIGQUIT restore the terminal and exit
without saving, matching C — where `auto_save` is reserved for HUP/TERM and
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
deliberate interrupt from becoming a free checkpoint.
The handlers are installed immediately after `term.New()`, which is the call
that puts the tty in raw mode, and before the game exists — the saver is handed
over afterwards through `pendingSaver`. That ordering is what makes "every path
restores the terminal via `Terminal.Fini` before exiting" actually true: both
the save-restoring path and `-d`'s `DeathDemo()` run with the tty already raw,
and `DeathDemo()` never returns (it blocks in `waitFor` inside `death()`), so
handlers installed after them would leave that whole stretch unarmed. Restoring
the terminal is the part that must be armed the instant the tty goes raw, and it
needs no game; a signal arriving before the game is built restores and exits
with nothing to save. That is C's `leave()`, "leave quickly but curteously".
### 5.4 Messaging
@@ -1715,6 +1734,44 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
| tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
| curses window save in save file | screen is derivable | redraw on restore |
| `tstp()` SIGTSTP suspend/resume | see below | `!` shell escape |
| SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none |
The three signal rows warrant more than a table cell.
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game
as a signal in the first place: tcell puts the tty in raw mode (`term.MakeRaw`
clears `ISIG`, which is what makes `VSUSP` live), so Ctrl-Z arrives as key byte
`0x1a`, exactly as it did in C, whose `setup()` calls curses `raw()` for the
same effect. Only an explicit `kill -TSTP` can deliver it, which is not a player
action. Handling it properly would mean calling `Screen.Suspend`/`Resume` from
the signal goroutine while the game goroutine may be inside `Render` or
`PollEvent`. That is not a _data_ race — tcell guards `Suspend`/`Resume` and
`Fini` alike with the screen mutex, which is also why the `Fini` this port does
call from the signal goroutine is safe — it is a _logical_ race over the screen
state: the game goroutine can redraw into a screen the handler has just
suspended, or resume under a half-finished frame. Getting it right means
plumbing the signal through the input loop and handling it synchronously, a
design change well beyond a signal-safety fix. C's own wiring here is vestigial:
`tstp` is armed only by `md_tstpresume()`, which runs after a successful
`restore()`, so a freshly started C game never had a SIGTSTP handler either.
`term.Tcell.ShellEscape` (the `!` command) already covers getting to a shell and
back, doing the same suspend/resume dance synchronously on the game goroutine
where it is safe.
**SIGINT → `quit()`.** Under `md_onsignal_autosave` C routed SIGINT into the
interactive "really quit?" prompt. The port exits instead (after restoring the
terminal): the handler runs on another goroutine, and re-entering the message
and input machinery from there would race every screen and state access the main
goroutine makes. The `Q` command reaches the same prompt from inside the turn
loop, which is where the player actually quits.
**Fault signals.** `md_onsignal_autosave` also sent SIGILL, SIGTRAP, SIGFPE,
SIGBUS, SIGSEGV and SIGSYS to `auto_save`. In Go these are runtime panics with
their own diagnostics, and gob-encoding the state that just faulted would risk
replacing a good save file with a corrupt one, so they are left alone. SIGHUP
and SIGTERM still autosave.
## 10. Testing strategy

53
TODO.md
View File

@@ -34,6 +34,59 @@ wizard commands).
# Completed Steps
- 2026-08-09 Signal-time terminal restore (`sig-leave`, closes #12): the port
handled only SIGHUP and SIGTERM, so SIGINT and SIGQUIT killed the process with
tcell still holding the tty, leaving the user at a shell with no echo. All
four signals now go to one `os/signal` channel read by one goroutine in
`cmd/rogue/main.go`, and every path calls `Terminal.Fini` before `os.Exit(0)`
— C's `leave()`, "leave quickly but curteously". **The decision** (written
into the `savesOnSignal` comment): SIGHUP/SIGTERM keep autosaving,
SIGINT/SIGQUIT restore and exit **without** saving. C never saves on INT or
QUIT anywhere — `leave()` is endwin-and-exit, `quit()` confirms/scores/exits,
`endit()` goes through `fatal()`, and `save.c auto_save` is reserved for
HUP/TERM — and the semantics agree: HUP/TERM are involuntary teardown worth
rescuing a game from, while INT/QUIT are a deliberate "stop now" that must not
become a one-keystroke checkpoint against a save discipline built to be
anti-save-scum. It is also the safe choice: `AutoSave` gob-encodes live state
that the main goroutine is still mutating, after removing the old file, so on
the signals with nothing to rescue the port takes the option with no
corruption window. The single-reader design closes the window the issue warned
about: a second signal arriving mid-save stays unread in the buffer instead of
exiting out from under the writer (`TestLeaveOnSignalIgnoresLaterSignals`
reproduces exactly that interleaving). New `cmd/rogue/main_test.go` pins the
membership of `handledSignals()` itself (`TestHandledSignalsSet` — without it
the rest of the file, which iterates that set, would pass against a set that
had silently lost SIGINT and SIGQUIT again), and covers the ordering for each
signal, the save/no-save split against `savesOnSignal`, the
mid-save-second-signal case, the pre-game `pendingSaver` window, and real
SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the test process through the same
`notifySignals` wiring the game uses; the tty leaving raw mode is the one step
not checkable headlessly (it needs a controlling terminal), and
`term.Tcell.Fini` is a direct pass-through to tcell's `Screen.Fini` that
`myExit` already depends on. Two premises in the issue turned out to be wrong
and are recorded in ARCHITECTURE.md: `leave()` is not installed on
SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the shipped build
calls `md_onsignal_default()` and installs nothing, and `leave()` appears only
in the endgame paths of `rip.c`/`main.c`), and Ctrl-C never generated SIGINT
here anyway, since tcell's raw mode clears `ISIG` and the key arrives as byte
`0x03` — as it did in C, whose `setup()` calls curses `raw()`. The real
exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the process group while the
`!` shell escape has the screen suspended, and the window **after**
`term.New()`: nothing is raw before it, and the handlers used to be installed
only once the game existed, leaving the restore path and `-d`'s `DeathDemo()`
— which never returns, blocking in `waitFor` inside `death()` — running raw
with no handler at all. The handlers are therefore installed immediately after
`term.New()`, with the game handed to them afterwards via `pendingSaver`; a
signal before the game exists restores the terminal and exits with nothing to
save, and the SIGHUP/SIGTERM autosave behavior on the play path is unchanged.
ARCHITECTURE.md §9 gained rows for SIGTSTP/`tstp()` (dropped: raw mode means
Ctrl-Z cannot reach us, a suspend from the signal goroutine would race the
drawing goroutine, and C armed `tstp` only after a `restore()`; the `!` shell
escape covers the need), for SIGINT not routing to the interactive `quit()`
prompt, and for `auto_save` on the fault signals; §5.3's claim that tcell
handles SIGTSTP was false — tcell registers only SIGWINCH — and is corrected.
`Next Step` deliberately not rotated: out-of-band issue work.
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against

View File

@@ -10,6 +10,7 @@ import (
"os/signal"
"os/user"
"strconv"
"sync"
"syscall"
"time"
@@ -47,6 +48,13 @@ func run() int {
}
defer t.Fini()
// Armed here, the instant the tty goes raw, not after the game is
// built: everything below this line — the restore, the death demo
// (which never returns), the game itself — would otherwise run raw
// with no handler installed. There is no game to save yet, so the
// saver is filled in below once there is one.
pending := installSignalHandlers(t)
params.Term = t
var g *game.RogueGame
@@ -64,12 +72,15 @@ func run() int {
}
if *deathDemo {
// The demo is left without a saver on purpose: a signal still
// restores the terminal, but a throwaway demo game is not worth
// writing over the player's save file.
g.DeathDemo() // does not return: death exits the process
return 0
}
installAutosave(g, t)
pending.set(g)
g.Run() // does not return: the game ends by exiting the process
@@ -101,19 +112,145 @@ func loadParams() game.Params {
}
}
// installAutosave saves the game and exits on SIGHUP/SIGTERM (save.c
// auto_save).
func installAutosave(g *game.RogueGame, t *term.Tcell) {
// saver is the autosave half of *game.RogueGame that the signal handler
// needs; an interface so the handler is testable headlessly.
type saver interface {
// AutoSave writes the game to its save file, best effort (save.c
// auto_save).
AutoSave()
}
// finisher is the terminal-restoring half of game.Terminal that the
// signal handler needs (curses endwin).
type finisher interface {
// Fini restores the terminal to its pre-game state.
Fini()
}
// pendingSaver is the saver the signal handler holds from the moment the
// terminal goes raw. The handler has to be armed before there is a game
// to save — restoring a save file and the death demo both run with the
// tty already raw — so AutoSave does nothing until set hands over the
// real game. The mutex is not decoration: set runs on the main goroutine
// and AutoSave on the signal goroutine.
type pendingSaver struct {
mu sync.Mutex
game saver
}
// AutoSave saves the game if there is one yet, and otherwise does
// nothing: a signal arriving before the game is built still restores the
// terminal, which is the part that matters.
func (p *pendingSaver) AutoSave() {
p.mu.Lock()
defer p.mu.Unlock()
if p.game == nil {
return
}
p.game.AutoSave()
}
// set hands the signal handler the game to autosave, once one exists.
func (p *pendingSaver) set(g saver) {
p.mu.Lock()
defer p.mu.Unlock()
p.game = g
}
// handledSignals returns the signals the game leaves on. They split into
// two groups with deliberately different save behavior; see
// savesOnSignal.
func handledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// savesOnSignal reports whether the game should autosave on its way out
// for this signal.
//
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
// restore the terminal and exit WITHOUT saving. This is deliberate, not
// an oversight, on three grounds.
//
// C: no path in the C game saves on INT or QUIT. The shipped build
// installs no handler at all during play (mach_dep.c setup calls
// md_onsignal_default), and the only INT handler it ever installs is
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
// discarding pending output. The build that does wire INT during play
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
// quit(), which confirms, scores, and exits, again without saving, and
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
// therefore exactly C's split.
//
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
// or the machine is going down — so rescuing the game is right. INT and
// QUIT are the player deliberately saying "stop now". Rogue scores a
// deliberate quit, and its save discipline is anti-save-scum by design
// (restoring consumes the file), so making Ctrl-C a free checkpoint
// would turn it into a one-keystroke undo for a bad turn: a gameplay
// change, not a robustness fix.
//
// Safety: this runs on a goroutine while the main goroutine is mid-turn
// mutating game state, and AutoSave removes the save file before
// gob-encoding that live state. On HUP/TERM that risk is accepted
// because the process is about to die regardless and a best-effort save
// beats none. On INT/QUIT there is nothing to rescue, so the right
// choice is the one with no corruption window at all.
func savesOnSignal(sig os.Signal) bool {
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
}
// installSignalHandlers arranges for the game to leave the terminal
// usable when it is signalled: C's leave(), "leave quickly but
// curteously" (main.c), extended with save.c auto_save on the two
// signals that warrant it.
//
// Call it the instant the terminal goes raw, which is earlier than the
// game exists; the returned pendingSaver takes the game once it does.
// Restoring the terminal is what has to be armed the moment the tty
// stops being usable, and it does not need a game.
func installSignalHandlers(t finisher) *pendingSaver {
pending := &pendingSaver{}
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
return pending
}
// notifySignals subscribes to the handled signals and returns the
// channel they arrive on. Split out from installSignalHandlers so tests
// can drive leaveOnSignal with real signal delivery.
func notifySignals() chan os.Signal {
// Buffered so signal delivery never blocks, and deliberately never
// drained past the first signal: see leaveOnSignal.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM)
signal.Notify(sig, handledSignals()...)
go func() {
<-sig
return sig
}
// leaveOnSignal waits for one signal and takes the game out.
//
// Exactly one goroutine reads exactly one signal, which is what makes
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's
// AutoSave is still writing, say) stays in the buffer unread and can
// never call exit out from under an in-flight save. The order within is
// the same one myExit uses (game/rip.go): save if this signal saves,
// then restore the terminal, then exit.
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
if savesOnSignal(<-sig) {
g.AutoSave()
}
t.Fini()
os.Exit(0)
}()
exit(0)
}
// chooseSeed picks the dungeon number: SEED for reproducible dungeons

323
cmd/rogue/main_test.go Normal file
View File

@@ -0,0 +1,323 @@
package main
// White-box tests for the signal plumbing. Unlike the game package's test
// files this one carries no //nolint:testpackage directive: testpackage
// exempts package main, so nolintlint rejects the directive as unused.
import (
"os"
"os/signal"
"slices"
"sync"
"syscall"
"testing"
)
// The steps the signal handler can take, in the order signalRecorder
// records them.
const (
stepSave = "save"
stepFini = "fini"
stepExit = "exit"
)
// wantHandledSignals is the exact set of signals the game must leave on.
// This is the subject of issue #12: SIGHUP and SIGTERM were handled and
// SIGINT and SIGQUIT were not, so the latter two killed the process with
// the tty still raw. Every other test here iterates handledSignals(), so
// without this one the whole file would pass against a set that had
// silently lost SIGINT and SIGQUIT again.
func wantHandledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// wantSteps is the expected handler step sequence for each handled
// signal, and the single source of truth for the tests that check the
// save/no-save split.
func wantSteps() map[os.Signal][]string {
return map[os.Signal][]string{
syscall.SIGHUP: {stepSave, stepFini, stepExit},
syscall.SIGTERM: {stepSave, stepFini, stepExit},
syscall.SIGINT: {stepFini, stepExit},
syscall.SIGQUIT: {stepFini, stepExit},
}
}
// signalRecorder stands in for the game and the terminal in the signal
// handler, recording the order of the steps the handler takes. The mutex
// matters: the handler runs on its own goroutine, so an unguarded slice
// would be a data race under -race, which is exactly what these tests
// are meant to rule out.
type signalRecorder struct {
mu sync.Mutex
steps []string
code int
done chan struct{}
}
func newSignalRecorder() *signalRecorder {
return &signalRecorder{done: make(chan struct{})}
}
// AutoSave records a save attempt (the saver half).
func (r *signalRecorder) AutoSave() {
r.record(stepSave)
}
// Fini records a terminal restore (the finisher half).
func (r *signalRecorder) Fini() {
r.record(stepFini)
}
// exit records the process exit that ends the handler and releases any
// waiter. It stands in for os.Exit, which cannot be called in a test.
func (r *signalRecorder) exit(code int) {
r.mu.Lock()
r.code = code
r.steps = append(r.steps, stepExit)
r.mu.Unlock()
close(r.done)
}
// record appends one step.
func (r *signalRecorder) record(step string) {
r.mu.Lock()
defer r.mu.Unlock()
r.steps = append(r.steps, step)
}
// taken returns the recorded steps and the exit code.
func (r *signalRecorder) taken() ([]string, int) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.steps), r.code
}
// TestHandledSignalsSet pins the membership of handledSignals() itself.
// The regression issue #12 exists to prevent is a signal dropping out of
// that set — SIGINT and SIGQUIT reaching the process at SIG_DFL and
// killing it with the tty raw — and every other test in this file is
// driven by the set, so only this test can fail on it.
func TestHandledSignalsSet(t *testing.T) {
t.Parallel()
got := handledSignals()
want := wantHandledSignals()
if len(got) != len(want) {
t.Errorf("handledSignals() = %v, want exactly %v", got, want)
}
for _, sig := range want {
if !slices.Contains(got, sig) {
t.Errorf("handledSignals() = %v, missing %v", got, sig)
}
}
for _, sig := range got {
if !slices.Contains(want, sig) {
t.Errorf("handledSignals() = %v, unexpected %v", got, sig)
}
}
}
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
// whatever the signal, the terminal is restored before the process ends,
// so the player is never dropped into a shell with the tty still in raw
// mode.
func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
t.Parallel()
for _, sig := range handledSignals() {
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, code := rec.taken()
fini := slices.Index(steps, stepFini)
exit := slices.Index(steps, stepExit)
if fini < 0 || exit < 0 || fini > exit {
t.Errorf("%v: want the terminal restored before exit, got %v",
sig, steps)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestLeaveOnSignalSaveSplit pins the decision recorded on savesOnSignal:
// SIGHUP/SIGTERM (involuntary teardown) save on the way out, SIGINT and
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
// nor endit() writes a save file.
//
// It is driven by the expectation table rather than by
// handledSignals(), so that every entry — including the SIGINT and
// SIGQUIT ones — is actually read, and a signal dropped from the handled
// set fails here as well as in TestHandledSignalsSet.
func TestLeaveOnSignalSaveSplit(t *testing.T) {
t.Parallel()
for sig, want := range wantSteps() {
if !slices.Contains(handledSignals(), sig) {
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
continue
}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
t.Errorf("%v: saved = %v, savesOnSignal = %v",
sig, saved, savesOnSignal(sig))
}
}
}
// TestLeaveOnSignalIgnoresLaterSignals covers the ordering guarantee in
// leaveOnSignal's comment: only the first signal is read, so a second one
// arriving mid-save cannot exit out from under the save and truncate the
// player's file. The saver here blocks until a second signal has been
// queued, reproducing that window.
func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
t.Parallel()
rec := newSignalRecorder()
ch := make(chan os.Signal, 2)
ch <- syscall.SIGHUP
blocker := &blockingSaver{rec: rec, queue: ch, extra: syscall.SIGINT}
leaveOnSignal(ch, blocker, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
}
if len(ch) != 1 {
t.Errorf("queued signals left unread = %d, want 1", len(ch))
}
}
// blockingSaver queues another signal while the save is in flight, the
// race window leaveOnSignal is built to close.
type blockingSaver struct {
rec *signalRecorder
queue chan os.Signal
extra os.Signal
}
// AutoSave delivers the extra signal mid-save, then records the save.
func (b *blockingSaver) AutoSave() {
b.queue <- b.extra
b.rec.AutoSave()
}
// TestLeaveOnRealSignal is the deepest headless check available: it
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
// os/signal, exactly as notifySignals wires them in the game, and
// verifies each one reaches the handler and produces the full expected
// step sequence — including the save/no-save split, which this test is
// the best placed to check end to end.
//
// What cannot be checked here is the tty itself coming back out of raw
// mode: that needs a controlling terminal and a live tcell screen, which
// a headless test run does not have. This test covers everything up to
// the Terminal.Fini call; term.Tcell.Fini is a direct pass-through to
// tcell's Screen.Fini, which is the same call myExit already relies on.
func TestLeaveOnRealSignal(t *testing.T) {
t.Parallel()
ch := notifySignals()
defer signal.Stop(ch)
for _, sig := range handledSignals() {
rec := newSignalRecorder()
go leaveOnSignal(ch, rec, rec, rec.exit)
unix, ok := sig.(syscall.Signal)
if !ok {
t.Fatalf("%v is not a unix signal", sig)
}
err := syscall.Kill(os.Getpid(), unix)
if err != nil {
t.Fatalf("kill(%v): %v", sig, err)
}
<-rec.done
steps, code := rec.taken()
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestPendingSaverArmsBeforeTheGameExists covers what lets the handlers
// be installed the instant the terminal goes raw rather than after the
// game is built: a signal arriving before there is a game must still
// reach Fini, and must not save anything, while one arriving after the
// game is handed over saves it.
func TestPendingSaverArmsBeforeTheGameExists(t *testing.T) {
t.Parallel()
pending := &pendingSaver{}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- syscall.SIGHUP
// No game yet: the SIGHUP still restores the terminal and exits, it
// just has nothing to write.
leaveOnSignal(ch, pending, rec, rec.exit)
steps, code := rec.taken()
if want := []string{stepFini, stepExit}; !slices.Equal(steps, want) {
t.Errorf("before the game exists: steps = %v, want %v", steps, want)
}
if code != 0 {
t.Errorf("before the game exists: exit code = %d, want 0", code)
}
// Once the game is handed over, the same saver writes it.
started := newSignalRecorder()
pending.set(started)
pending.AutoSave()
saved, _ := started.taken()
if want := []string{stepSave}; !slices.Equal(saved, want) {
t.Errorf("after set: steps = %v, want %v", saved, want)
}
}