//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) package game // Tests for the signal-triggered autosave handoff (issue #24): the signal // goroutine must never encode game state itself, and the game goroutine // must answer wherever it is parked. import ( "io" "os" "path/filepath" "testing" "time" ) // autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they // expect the save to be taken. It is long enough that a loaded machine // cannot turn a working handoff into a spurious failure, and it is never // actually waited out on a passing run. It is also what bounds // driveUntilDone, by way of the saving goroutine it waits for — see // there for what that bound comes to. const autoSaveWait = 10 * time.Second // TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it // drives the real turn loop on one goroutine while another asks for a // signal-triggered autosave over and over, which is the interleaving no // test in the suite used to produce. `make test` runs with -race, so a // save that encodes the live game tree from the asking goroutine — what // the old AutoSave did straight from the signal handler — is reported as // a data race and fails this test. // // Non-vacuity: with AutoSaveOnSignal's body replaced by a direct // g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails // under -race with the encoder reading state that command() is writing. func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) { t.Parallel() // Same mix as TestTurnLoopCrashSweep — the spaces answer any --More-- // prompt — on a driveTerm, so the drive can run for as long as the // saves take rather than for as long as a script lasts. The '.' and // the 's' are what make an unbounded drive safe, and at least one of // the two has to stay in the cycle: see driveTerm. term := &driveTerm{script: []byte("h j k l y u b n s . ")} g := New(Params{Seed: 20260809, Term: term}) g.FileName = filepath.Join(t.TempDir(), "rogue.save") g.startLevel() g.prePlay() const wantSaves = 25 var taken int done := make(chan struct{}) go func() { defer close(done) for range wantSaves { if g.AutoSaveOnSignal(autoSaveWait) { taken++ } } }() driveUntilDone(t, g, done) // The close of done orders that goroutine's writes before this read. if taken != wantSaves { t.Errorf("saves taken = %d, want %d", taken, wantSaves) } // Every request was answered by the turn loop, so the file is the // work of the game goroutine and must be a whole save. assertRestorable(t, g.FileName) } // driveUntilDone runs turns until the saving goroutine is finished, // fortifying the hero each turn so no death exits the test binary. The // condition it waits on is that goroutine finishing — nothing else. // // It used to stop after a fixed 1000 turns and fail, and that cap was a // load-sensitive assumption wearing a counter's clothes (issue #36). The // turns this loop spends between one save request being answered and the // next arriving are not work; they are the saving goroutine's scheduling // latency, so the turn count 25 saves costs is a function of how // contended the machine is rather than of anything the code under test // does. Measured here on a 48-core host at load ~57: about 60-120 turns // with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut // from 4 to 1, and past 1000 under the doubled load of the verbose // rerun, which is the flake this replaces. A budget that has to be // guessed cannot be guessed right, so there is no budget. // // Dropping it costs no termination guarantee, because the bound belongs // to the code under test and not to this loop: each AutoSaveOnSignal // call returns within the timeout the caller hands it, so the saving // goroutine always finishes and done always closes. That bound is worth // stating exactly, because it is not one autoSaveWait. // // A handoff that has stopped answering altogether costs one, in total, // however many saves were asked for. g.sigSave // is one deep, so the unserviced request stays in the channel and every // later call finds it full and reports failure immediately — measured // at 10.0s for 25 saves with the service point deleted from command(). // What fails is then the caller's own assertion, the count of saves // actually taken, which says far more than "out of turns" ever did. // // A handoff that still drains every request but takes longer than // autoSaveWait to do it is the worst case, and costs one timeout per // save: wantSaves * autoSaveWait, 250s at these constants, which would // run past the package timeout rather than reach the assertion. It // takes about ten seconds of scheduler starvation per save to get // there, against a regime measured at 0.12s per 1000 turns, so it is // remote — and the 1000-turn cap did not bound it either, a turn count // being no kind of time bound. `go test -timeout 30s` is the backstop // under all of it. // // The one thing the caller does have to supply is a terminal that can // feed an unbounded drive: see driveTerm. func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) { t.Helper() for { select { case <-done: return default: } fortify(g) g.command() } } // TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really // for: the connection drops while the player is staring at the screen, // so the game goroutine is parked in ReadChar and will not reach the // between-turns check on its own. A flag checked only between turns would // never be looked at here. func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) { t.Parallel() bt := newBlockingTerm() g := mkBlockedGame(t, bt) read := make(chan byte) go func() { read <- g.readchar() }() // The wake is buffered, so this is correct whether or not the reader // has reached ReadChar yet. if !g.AutoSaveOnSignal(autoSaveWait) { t.Fatal("the save was not taken while the game was blocked on input") } assertRestorable(t, g.FileName) // The interrupt must not have been mistaken for a keystroke: the // reader is still waiting, and still returns the real key. bt.keys <- 'x' if ch := <-read; ch != 'x' { t.Errorf("readchar() = %q, want 'x'", ch) } } // TestAutoSaveOnSignalWhileInShellEscape covers the other place the game // goroutine parks for an unbounded time: the `!` shell escape, where it // used to sit inside the shell call with no way to answer. A dropped line // while the player is off in a shell is as much a hangup as any other. func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) { t.Parallel() st := &shellTerm{ blockingTerm: newBlockingTerm(), entered: make(chan struct{}), release: make(chan struct{}), } g := mkBlockedGame(t, st) left := make(chan struct{}) go func() { defer close(left) g.shell() }() <-st.entered if !g.AutoSaveOnSignal(autoSaveWait) { t.Fatal("the save was not taken while the game was in the shell escape") } assertRestorable(t, g.FileName) close(st.release) <-left } // TestShellEscapePanicUnwindsTheGameGoroutine pins the reason // runShellEscape recovers its helper's panic. // // term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell // now runs on a helper goroutine. A panic reaching the top of that helper // would kill the process without running the deferred calls of any other // goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is // the only thing that takes the tty back out of raw mode. That is issue // #12's failure, and it would land on the one path where the terminal is // already broken. // // So the panic has to arrive on the goroutine that runs the game, with // that goroutine's deferred restore still on the stack. This test stands // in for main: a Fini deferred around the g.shell() call, and the panic // caught after it, asserting both that the restore ran and that the // original value came through. Against the unrecovered version there is // nothing to assert — the panic escapes a helper goroutine and takes the // whole test binary down, which is the failure being prevented. func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) { t.Parallel() pt := &panickingShellTerm{blockingTerm: newBlockingTerm()} g := mkBlockedGame(t, pt) caught := make(chan any, 1) go func() { // Registered first, so it runs last: it sees the terminal // already restored, exactly as the runtime would have printed // the trace after main's Fini. defer func() { caught <- recover() }() // Stands in for cmd/rogue/main.go's `defer t.Fini()`. defer pt.Fini() g.shell() }() got := <-caught if got == nil { t.Fatal("the resume failure did not reach the game goroutine") } if msg, ok := got.(string); !ok || msg != errShellResume { t.Errorf("recovered %v, want %q", got, errShellResume) } if !pt.restored { t.Error("the terminal was not restored on the way out") } // shell() must not have resumed into its InShell reset and refresh: // there is no screen left to draw into. if !g.InShell { t.Error("shell() carried on drawing after the resume failed") } } // TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game // goroutine that never reaches a service point must not hold the process // open, and giving up must cost the player nothing. The old save is still // there, byte for byte — which is the whole point of renaming over the // target instead of removing it first. func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) { t.Parallel() g := mkGame(t, 77) g.FileName = filepath.Join(t.TempDir(), "rogue.save") const old = "an older save nobody is allowed to destroy" writeErr := os.WriteFile(g.FileName, []byte(old), 0o600) if writeErr != nil { t.Fatal(writeErr) } // Nothing drives the turn loop, so nothing will ever answer. start := time.Now() if g.AutoSaveOnSignal(100 * time.Millisecond) { t.Error("AutoSaveOnSignal reported a save that nobody took") } if waited := time.Since(start); waited > time.Second { t.Errorf("waited %v for an unanswered save, want the deadline to bound it", waited) } got, readErr := os.ReadFile(g.FileName) if readErr != nil { t.Fatalf("the previous save was destroyed: %v", readErr) } if string(got) != old { t.Error("the previous save was overwritten by a save that never ran") } } // TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal // case: a game with no file name has nothing to write, and must say so // rather than reporting a save that did not happen. func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) { t.Parallel() g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}}) g.FileName = "" g.startLevel() g.prePlay() var answered bool done := make(chan struct{}) go func() { defer close(done) answered = g.AutoSaveOnSignal(autoSaveWait) }() driveUntilDone(t, g, done) if answered { t.Error("AutoSaveOnSignal = true with no save file name") } } // TestSaveFileReplacesTargetAtomically pins the write discipline: the new // save arrives by rename, so the file the player already had is never // written into, and the temporary file it came from is not left lying in // the save directory. // // The load-bearing assertion is the handle opened before the save. A // rename leaves the old file whole and merely stops it being reachable by // name, so that handle still reads the old save; the truncate-in-place // write this replaced would empty it under the reader — the same // in-place write that, interrupted, left the player with a file that // could no longer be restored. func TestSaveFileReplacesTargetAtomically(t *testing.T) { t.Parallel() g := mkGame(t, 11) dir := t.TempDir() path := filepath.Join(dir, "rogue.save") const old = "an older save" writeErr := os.WriteFile(path, []byte(old), 0o600) if writeErr != nil { t.Fatal(writeErr) } held, openErr := os.Open(path) //nolint:gosec // G304: test temp path if openErr != nil { t.Fatal(openErr) } defer func() { _ = held.Close() }() saveErr := g.saveFile(path) if saveErr != nil { t.Fatalf("saveFile: %v", saveErr) } kept, readErr := io.ReadAll(held) if readErr != nil { t.Fatalf("reading the file that was there before the save: %v", readErr) } if string(kept) != old { t.Errorf("the previous save was written into rather than replaced: %q", string(kept)) } entries, readErr := os.ReadDir(dir) if readErr != nil { t.Fatal(readErr) } if len(entries) != 1 || entries[0].Name() != "rogue.save" { t.Errorf("save directory = %v, want just the save file", names(entries)) } info, statErr := os.Stat(path) if statErr != nil { t.Fatal(statErr) } if perm := info.Mode().Perm(); perm != 0o400 { t.Errorf("save file mode = %v, want 0400", perm) } assertRestorable(t, path) } // TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the // same discipline: a save that cannot be completed must leave what the // player already had. The target here is a non-empty directory, which no // rename can replace — the one write failure that can be forced without // depending on file permissions, and therefore on not being root. func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) { t.Parallel() g := mkGame(t, 12) dir := t.TempDir() path := filepath.Join(dir, "rogue.save") mkErr := os.Mkdir(path, 0o700) if mkErr != nil { t.Fatal(mkErr) } keep := filepath.Join(path, "keep") writeErr := os.WriteFile(keep, []byte("still here"), 0o600) if writeErr != nil { t.Fatal(writeErr) } saveErr := g.saveFile(path) if saveErr == nil { t.Error("saveFile over an unreplaceable target reported success") } _, statErr := os.Stat(keep) if statErr != nil { t.Errorf("the target was damaged by a failed save: %v", statErr) } entries, readErr := os.ReadDir(dir) if readErr != nil { t.Fatal(readErr) } if len(entries) != 1 { t.Errorf("save directory = %v, want no temporary file left behind", names(entries)) } } // names lists directory entry names for a failure message. func names(entries []os.DirEntry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Name()) } return out } // assertRestorable checks that path holds a save this program can load, // which is what "the save was taken" has to mean: a file of the right // size proves nothing about a torn encode. func assertRestorable(t *testing.T, path string) { t.Helper() _, err := Restore(path, Params{Term: &testTerm{}}) if err != nil { t.Errorf("the saved file does not restore: %v", err) } } // mkBlockedGame builds a game with a save file name and a terminal whose // reads block, for the tests that park the game goroutine. func mkBlockedGame(t *testing.T, term Terminal) *RogueGame { t.Helper() g := New(Params{Seed: 4242, Term: term}) g.NewLevel() g.FileName = filepath.Join(t.TempDir(), "rogue.save") return g } // driveTerm is a headless Terminal whose script repeats instead of // running out, for the tests that drive the turn loop until something // else finishes rather than for a set number of turns. // // testTerm cannot do that job. Once its script is exhausted it answers // space and newline for ever, and neither takes a turn, so command() — // which loops until the player does something that consumes one, the // `if !g.After { ntimes++ }` in command.c — never returns. A drive with // a turn cap sized to its script never notices; a drive that runs until // the saves are taken wedges inside a single command() call, which is // what a first attempt at issue #36 did. // // Repeating the script is necessary but nowhere near sufficient, and // the difference is what anyone editing one of these scripts has to // know. Most keys take a turn only conditionally. ' ' is the "legal // illegal command" and clears After outright (tables.go). All eight // movement keys clear it whenever the step is refused: a wall or the // map edge (move.go moveResolve), an illegal diagonal (moveTarget), or // a confused step that lands back in place (moveHero). A script of // nothing but those keys wedges exactly the way testTerm's tail does, // repetition or no repetition — with the script set to just " " this // drive hits the 30s package timeout inside command(). // // What actually makes the wedge impossible is that the cycle always // contains at least one *unconditional* turn-taker, and the scripts // here carry two: '.', the rest command, whose handler is empty, and // 's', search, which writes After on no path. Nothing refuses either // one — not being blocked in all eight directions, not Held, not stuck // in a bear trap, and not NoCommand > 0, where playTurn skips // executeCommand altogether and After is simply left true. Trim both // out and the wedge this test exists to remove comes straight back. // // One further precondition, from what this fake does not supply: // testTerm's tail answered a newline every other read and this does // not. Nothing reachable from these scripts asks for one — waitFor('\n') // sits on the death and score paths (rip.go, score.go), which fortify // prevents from ever being reached — but a script that could reach them // would park in waitFor for ever. type driveTerm struct { script []byte pos int } func (t *driveTerm) Render(*Window) {} func (t *driveTerm) Repaint() {} func (t *driveTerm) Fini() {} // Interrupt has nothing to wake: this terminal's ReadChar never blocks. func (t *driveTerm) Interrupt() {} // ReadChar hands out the next scripted key, wrapping at the end. func (t *driveTerm) ReadChar() (byte, bool) { ch := t.script[t.pos] t.pos = (t.pos + 1) % len(t.script) return ch, true } // blockingTerm is a Terminal that genuinely blocks in ReadChar until a // key is pushed or Interrupt wakes it — which testTerm, whose reads never // block, cannot reproduce. type blockingTerm struct { keys chan byte wake chan struct{} } func newBlockingTerm() *blockingTerm { return &blockingTerm{ keys: make(chan byte), // Buffered by one and posted to without blocking, the same // contract term.Tcell.Interrupt has with tcell's event queue: an // interrupt that arrives before the read still wakes it. wake: make(chan struct{}, 1), } } func (t *blockingTerm) Render(*Window) {} // Repaint has nothing to redraw: this terminal exists for its input // behaviour, and no autosave test types CTRL-R. func (t *blockingTerm) Repaint() {} func (t *blockingTerm) Fini() {} // Interrupt wakes a blocked ReadChar; called from the saving goroutine. func (t *blockingTerm) Interrupt() { select { case t.wake <- struct{}{}: default: } } // ReadChar blocks until a key arrives or Interrupt wakes it. func (t *blockingTerm) ReadChar() (byte, bool) { select { case ch := <-t.keys: return ch, true case <-t.wake: return 0, false } } // shellTerm is a blockingTerm that also offers a shell escape which stays // in the shell until the test lets it out. type shellTerm struct { *blockingTerm entered chan struct{} release chan struct{} } // ShellEscape parks the caller in the "shell" until released. func (t *shellTerm) ShellEscape() { close(t.entered) <-t.release } // errShellResume is what panickingShellTerm panics with, standing in for // the value term.Tcell.ShellEscape raises when Screen.Resume fails. const errShellResume = "resume failed" // panickingShellTerm is a blockingTerm whose shell escape panics on the // way out, the way term.Tcell.ShellEscape does when the screen cannot be // resumed. It records whether Fini ran, which is the thing that must // still happen. type panickingShellTerm struct { *blockingTerm restored bool } func (t *panickingShellTerm) Fini() { t.restored = true } func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }