diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4364cd5..e864669 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/TODO.md b/TODO.md index 29feb29..a89a148 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/cmd/rogue/main.go b/cmd/rogue/main.go index 268c9aa..d8ebdcc 100644 --- a/cmd/rogue/main.go +++ b/cmd/rogue/main.go @@ -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) - }() + } + + t.Fini() + exit(0) } // chooseSeed picks the dungeon number: SEED for reproducible dungeons diff --git a/cmd/rogue/main_test.go b/cmd/rogue/main_test.go new file mode 100644 index 0000000..182a2ea --- /dev/null +++ b/cmd/rogue/main_test.go @@ -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) + } +}