Compare commits
1 Commits
f602ecdbbf
...
dfb34be1c4
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb34be1c4 |
@@ -1522,9 +1522,18 @@ goroutine that reads exactly one signal, so a second signal can never call
|
|||||||
way out (save.c `auto_save`); SIGINT and SIGQUIT restore the terminal and exit
|
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
|
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
|
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
|
||||||
deliberate interrupt from becoming a free checkpoint. Every path restores the
|
deliberate interrupt from becoming a free checkpoint.
|
||||||
terminal via `Terminal.Fini` before exiting, which is the whole point of C's
|
|
||||||
`leave()`, "leave quickly but curteously".
|
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
|
### 5.4 Messaging
|
||||||
|
|
||||||
@@ -1738,13 +1747,18 @@ clears `ISIG`, which is what makes `VSUSP` live), so Ctrl-Z arrives as key byte
|
|||||||
same effect. Only an explicit `kill -TSTP` can deliver it, which is not a player
|
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
|
action. Handling it properly would mean calling `Screen.Suspend`/`Resume` from
|
||||||
the signal goroutine while the game goroutine may be inside `Render` or
|
the signal goroutine while the game goroutine may be inside `Render` or
|
||||||
`PollEvent` — a data race — so a correct port would have to plumb the signal
|
`PollEvent`. That is not a _data_ race — tcell guards `Suspend`/`Resume` and
|
||||||
through the input loop and handle it synchronously, a design change well beyond
|
`Fini` alike with the screen mutex, which is also why the `Fini` this port does
|
||||||
a signal-safety fix. C's own wiring here is vestigial: `tstp` is armed only by
|
call from the signal goroutine is safe — it is a _logical_ race over the screen
|
||||||
`md_tstpresume()`, which runs after a successful `restore()`, so a freshly
|
state: the game goroutine can redraw into a screen the handler has just
|
||||||
started C game never had a SIGTSTP handler either. `term.Tcell.ShellEscape` (the
|
suspended, or resume under a half-finished frame. Getting it right means
|
||||||
`!` command) already covers getting to a shell and back, doing the same
|
plumbing the signal through the input loop and handling it synchronously, a
|
||||||
suspend/resume dance synchronously on the game goroutine where it is safe.
|
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
|
**SIGINT → `quit()`.** Under `md_onsignal_autosave` C routed SIGINT into the
|
||||||
interactive "really quit?" prompt. The port exits instead (after restoring the
|
interactive "really quit?" prompt. The port exits instead (after restoring the
|
||||||
|
|||||||
55
TODO.md
55
TODO.md
@@ -53,28 +53,39 @@ wizard commands).
|
|||||||
corruption window. The single-reader design closes the window the issue warned
|
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
|
about: a second signal arriving mid-save stays unread in the buffer instead of
|
||||||
exiting out from under the writer (`TestLeaveOnSignalIgnoresLaterSignals`
|
exiting out from under the writer (`TestLeaveOnSignalIgnoresLaterSignals`
|
||||||
reproduces exactly that interleaving). New `cmd/rogue/main_test.go` covers the
|
reproduces exactly that interleaving). New `cmd/rogue/main_test.go` pins the
|
||||||
ordering for each signal, the save/no-save split against `savesOnSignal`, the
|
membership of `handledSignals()` itself (`TestHandledSignalsSet` — without it
|
||||||
mid-save-second-signal case, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered
|
the rest of the file, which iterates that set, would pass against a set that
|
||||||
to the test process through the same `notifySignals` wiring the game uses; the
|
had silently lost SIGINT and SIGQUIT again), and covers the ordering for each
|
||||||
tty leaving raw mode is the one step not checkable headlessly (it needs a
|
signal, the save/no-save split against `savesOnSignal`, the
|
||||||
controlling terminal), and `term.Tcell.Fini` is a direct pass-through to
|
mid-save-second-signal case, the pre-game `pendingSaver` window, and real
|
||||||
tcell's `Screen.Fini` that `myExit` already depends on. Two premises in the
|
SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the test process through the same
|
||||||
issue turned out to be wrong and are recorded in ARCHITECTURE.md: `leave()` is
|
`notifySignals` wiring the game uses; the tty leaving raw mode is the one step
|
||||||
not installed on SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the
|
not checkable headlessly (it needs a controlling terminal), and
|
||||||
shipped build calls `md_onsignal_default()` and installs nothing, and
|
`term.Tcell.Fini` is a direct pass-through to tcell's `Screen.Fini` that
|
||||||
`leave()` appears only in the endgame paths of `rip.c`/`main.c`), and Ctrl-C
|
`myExit` already depends on. Two premises in the issue turned out to be wrong
|
||||||
never generated SIGINT here anyway, since tcell's raw mode clears `ISIG` and
|
and are recorded in ARCHITECTURE.md: `leave()` is not installed on
|
||||||
the key arrives as byte `0x03` — as it did in C, whose `setup()` calls curses
|
SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the shipped build
|
||||||
`raw()`. The real exposure is `kill -INT`/`kill -QUIT` and the window before
|
calls `md_onsignal_default()` and installs nothing, and `leave()` appears only
|
||||||
`term.New()`. ARCHITECTURE.md §9 gained rows for SIGTSTP/`tstp()` (dropped:
|
in the endgame paths of `rip.c`/`main.c`), and Ctrl-C never generated SIGINT
|
||||||
raw mode means Ctrl-Z cannot reach us, a suspend from the signal goroutine
|
here anyway, since tcell's raw mode clears `ISIG` and the key arrives as byte
|
||||||
would race the drawing goroutine, and C armed `tstp` only after a `restore()`;
|
`0x03` — as it did in C, whose `setup()` calls curses `raw()`. The real
|
||||||
the `!` shell escape covers the need), for SIGINT not routing to the
|
exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the process group while the
|
||||||
interactive `quit()` prompt, and for `auto_save` on the fault signals; §5.3's
|
`!` shell escape has the screen suspended, and the window **after**
|
||||||
claim that tcell handles SIGTSTP was false — tcell registers only SIGWINCH —
|
`term.New()`: nothing is raw before it, and the handlers used to be installed
|
||||||
and is corrected. `Next Step` deliberately not rotated: out-of-band issue
|
only once the game existed, leaving the restore path and `-d`'s `DeathDemo()`
|
||||||
work.
|
— 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):
|
- 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
|
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,6 +48,13 @@ func run() int {
|
|||||||
}
|
}
|
||||||
defer t.Fini()
|
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
|
params.Term = t
|
||||||
|
|
||||||
var g *game.RogueGame
|
var g *game.RogueGame
|
||||||
@@ -64,12 +72,15 @@ func run() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *deathDemo {
|
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
|
g.DeathDemo() // does not return: death exits the process
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
installSignalHandlers(g, t)
|
pending.set(g)
|
||||||
|
|
||||||
g.Run() // does not return: the game ends by exiting the process
|
g.Run() // does not return: the game ends by exiting the process
|
||||||
|
|
||||||
@@ -116,6 +127,39 @@ type finisher interface {
|
|||||||
Fini()
|
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
|
// handledSignals returns the signals the game leaves on. They split into
|
||||||
// two groups with deliberately different save behavior; see
|
// two groups with deliberately different save behavior; see
|
||||||
// savesOnSignal.
|
// savesOnSignal.
|
||||||
@@ -137,7 +181,8 @@ func handledSignals() []os.Signal {
|
|||||||
// md_onsignal_default), and the only INT handler it ever installs is
|
// 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
|
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
|
||||||
// discarding pending output. The build that does wire INT during play
|
// discarding pending output. The build that does wire INT during play
|
||||||
// (md_onsignal_autosave, mdport.c, compiled only under DUMP) sends it to
|
// (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
|
// quit(), which confirms, scores, and exits, again without saving, and
|
||||||
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
|
// 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
|
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
|
||||||
@@ -165,8 +210,17 @@ func savesOnSignal(sig os.Signal) bool {
|
|||||||
// usable when it is signalled: C's leave(), "leave quickly but
|
// usable when it is signalled: C's leave(), "leave quickly but
|
||||||
// curteously" (main.c), extended with save.c auto_save on the two
|
// curteously" (main.c), extended with save.c auto_save on the two
|
||||||
// signals that warrant it.
|
// signals that warrant it.
|
||||||
func installSignalHandlers(g saver, t finisher) {
|
//
|
||||||
go leaveOnSignal(notifySignals(), g, t, os.Exit)
|
// 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
|
// notifySignals subscribes to the handled signals and returns the
|
||||||
|
|||||||
@@ -8,12 +8,43 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"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
|
// signalRecorder stands in for the game and the terminal in the signal
|
||||||
// handler, recording the order of the steps the handler takes. The mutex
|
// handler, recording the order of the steps the handler takes. The mutex
|
||||||
// matters: the handler runs on its own goroutine, so an unguarded slice
|
// matters: the handler runs on its own goroutine, so an unguarded slice
|
||||||
@@ -32,12 +63,12 @@ func newSignalRecorder() *signalRecorder {
|
|||||||
|
|
||||||
// AutoSave records a save attempt (the saver half).
|
// AutoSave records a save attempt (the saver half).
|
||||||
func (r *signalRecorder) AutoSave() {
|
func (r *signalRecorder) AutoSave() {
|
||||||
r.record("save")
|
r.record(stepSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fini records a terminal restore (the finisher half).
|
// Fini records a terminal restore (the finisher half).
|
||||||
func (r *signalRecorder) Fini() {
|
func (r *signalRecorder) Fini() {
|
||||||
r.record("fini")
|
r.record(stepFini)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exit records the process exit that ends the handler and releases any
|
// exit records the process exit that ends the handler and releases any
|
||||||
@@ -45,7 +76,7 @@ func (r *signalRecorder) Fini() {
|
|||||||
func (r *signalRecorder) exit(code int) {
|
func (r *signalRecorder) exit(code int) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.code = code
|
r.code = code
|
||||||
r.steps = append(r.steps, "exit")
|
r.steps = append(r.steps, stepExit)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
close(r.done)
|
close(r.done)
|
||||||
@@ -67,6 +98,34 @@ func (r *signalRecorder) taken() ([]string, int) {
|
|||||||
return slices.Clone(r.steps), r.code
|
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:
|
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
|
||||||
// whatever the signal, the terminal is restored before the process ends,
|
// 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
|
// so the player is never dropped into a shell with the tty still in raw
|
||||||
@@ -84,8 +143,8 @@ func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
|||||||
|
|
||||||
steps, code := rec.taken()
|
steps, code := rec.taken()
|
||||||
|
|
||||||
fini := slices.Index(steps, "fini")
|
fini := slices.Index(steps, stepFini)
|
||||||
exit := slices.Index(steps, "exit")
|
exit := slices.Index(steps, stepExit)
|
||||||
|
|
||||||
if fini < 0 || exit < 0 || fini > exit {
|
if fini < 0 || exit < 0 || fini > exit {
|
||||||
t.Errorf("%v: want the terminal restored before exit, got %v",
|
t.Errorf("%v: want the terminal restored before exit, got %v",
|
||||||
@@ -103,17 +162,21 @@ func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
|||||||
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
|
// 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()
|
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
|
||||||
// nor endit() writes a save file.
|
// 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) {
|
func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
want := map[os.Signal][]string{
|
for sig, want := range wantSteps() {
|
||||||
syscall.SIGHUP: {"save", "fini", "exit"},
|
if !slices.Contains(handledSignals(), sig) {
|
||||||
syscall.SIGTERM: {"save", "fini", "exit"},
|
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
|
||||||
syscall.SIGINT: {"fini", "exit"},
|
|
||||||
syscall.SIGQUIT: {"fini", "exit"},
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sig := range handledSignals() {
|
|
||||||
rec := newSignalRecorder()
|
rec := newSignalRecorder()
|
||||||
|
|
||||||
ch := make(chan os.Signal, 1)
|
ch := make(chan os.Signal, 1)
|
||||||
@@ -122,11 +185,11 @@ func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
|||||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||||
|
|
||||||
steps, _ := rec.taken()
|
steps, _ := rec.taken()
|
||||||
if !slices.Equal(steps, want[sig]) {
|
if !slices.Equal(steps, want) {
|
||||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want[sig])
|
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
if saved := slices.Contains(steps, "save"); saved != savesOnSignal(sig) {
|
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
|
||||||
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
||||||
sig, saved, savesOnSignal(sig))
|
sig, saved, savesOnSignal(sig))
|
||||||
}
|
}
|
||||||
@@ -151,7 +214,7 @@ func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
|
|||||||
leaveOnSignal(ch, blocker, rec, rec.exit)
|
leaveOnSignal(ch, blocker, rec, rec.exit)
|
||||||
|
|
||||||
steps, _ := rec.taken()
|
steps, _ := rec.taken()
|
||||||
if !slices.Equal(steps, []string{"save", "fini", "exit"}) {
|
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
|
||||||
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +241,9 @@ func (b *blockingSaver) AutoSave() {
|
|||||||
// TestLeaveOnRealSignal is the deepest headless check available: it
|
// TestLeaveOnRealSignal is the deepest headless check available: it
|
||||||
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
||||||
// os/signal, exactly as notifySignals wires them in the game, and
|
// os/signal, exactly as notifySignals wires them in the game, and
|
||||||
// verifies each one reaches the handler and restores the terminal before
|
// verifies each one reaches the handler and produces the full expected
|
||||||
// exiting.
|
// 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
|
// 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
|
// mode: that needs a controlling terminal and a live tcell screen, which
|
||||||
@@ -210,9 +274,8 @@ func TestLeaveOnRealSignal(t *testing.T) {
|
|||||||
<-rec.done
|
<-rec.done
|
||||||
|
|
||||||
steps, code := rec.taken()
|
steps, code := rec.taken()
|
||||||
if !strings.HasSuffix(strings.Join(steps, ","), "fini,exit") {
|
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
|
||||||
t.Errorf("%v: steps = %v, want the terminal restored before exit",
|
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||||
sig, steps)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
@@ -220,3 +283,41 @@ func TestLeaveOnRealSignal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user