Incomplete checkpoint, preserved after the implementing session hit a capacity limit partway through mutation verification. NOT reviewed and NOT gate-clean: make fmt has not been run, so fmt-check currently fails on ARCHITECTURE.md and TODO.md. Work still outstanding before this is fit for review: - run make fmt and fold the result in - finish mutation-proving each of the three behaviors - verify every message string byte-for-byte against the C sources - confirm TestSeedCompatItemTables passes with its golden untouched Refs #13.
420 lines
12 KiB
Go
420 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|