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" "time" ) // 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{})} } // AutoSaveOnSignal records a save attempt (the saver half). The real one // hands the work to the game goroutine and waits; the recorder stands in // for a game that takes it immediately. func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool { r.record(stepSave) return true } // 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 } // AutoSaveOnSignal delivers the extra signal mid-save, then records the // save. func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool { b.queue <- b.extra return b.rec.AutoSaveOnSignal(timeout) } // 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) if !pending.AutoSaveOnSignal(signalSaveTimeout) { t.Error("after set: the save was not reported as taken") } saved, _ := started.taken() if want := []string{stepSave}; !slices.Equal(saved, want) { t.Errorf("after set: steps = %v, want %v", saved, want) } } // TestPendingSaverDoesNotHoldItsLockAcrossTheSave pins the reason // pendingSaver reads the game out from under the mutex instead of // delegating with it held: since issue #24 the delegated save blocks // until the game goroutine takes it or the deadline expires, so a mutex // held across it would stall whoever calls set. Nothing calls set twice // today, which is why the PR #23 review recorded this as a future-proof // note rather than a bug — this test is what stops it becoming one. func TestPendingSaverDoesNotHoldItsLockAcrossTheSave(t *testing.T) { t.Parallel() pending := &pendingSaver{} stuck := &stuckSaver{entered: make(chan struct{}), release: make(chan struct{})} pending.set(stuck) go pending.AutoSaveOnSignal(signalSaveTimeout) <-stuck.entered // the delegated save is in flight done := make(chan struct{}) go func() { defer close(done) pending.set(newSignalRecorder()) // must not block on the save }() select { case <-done: case <-time.After(time.Second): t.Error("set blocked while a save was in flight: the lock is held across it") } close(stuck.release) } // stuckSaver blocks inside the delegated save until it is released, // standing in for a game goroutine that is slow to answer. type stuckSaver struct { entered chan struct{} release chan struct{} } // AutoSaveOnSignal blocks until the test releases it. func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool { close(s.entered) <-s.release return true } // TestDigsNewDungeon pins which invocations reach C's greeting. In main.c // the printf is the last statement before initscr(), so -s and -d, which // exit earlier, never see it, and neither does a restored game, because // restore() does not return. The saved-game case is the one worth having // a test for: resuming a dungeon must not announce that one is being dug. func TestDigsNewDungeon(t *testing.T) { t.Parallel() cases := []struct { name string deathDemo bool args []string want bool }{ {name: "new game", args: nil, want: true}, {name: "restore a save", args: []string{"rogue.save"}, want: false}, {name: "death demo", deathDemo: true, want: false}, { name: "death demo wins over a save argument", deathDemo: true, args: []string{"rogue.save"}, want: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := digsNewDungeon(tc.deathDemo, tc.args); got != tc.want { t.Errorf("digsNewDungeon(%v, %v) = %v, want %v", tc.deathDemo, tc.args, got, tc.want) } }) } }