fix: restore the terminal on SIGINT/SIGQUIT (closes #12)
The port installed handlers for SIGHUP and SIGTERM only, so SIGINT and
SIGQUIT killed the process with tcell still holding the tty and dropped
the user into a shell with no echo and a scrambled screen. All four
signals now go to one os/signal channel read by one goroutine, and every
path calls Terminal.Fini before os.Exit(0) -- C's leave(), "leave
quickly but curteously" (main.c).
The handlers are installed immediately after term.New(), the call that
raises raw mode, rather than after the game exists. Everything between
those two points ran raw with no handler at all: the save-restore path,
and -d's DeathDemo(), which never returns -- death() blocks in
waitFor('\n') (game/rip.go) -- so a kill -INT during the death demo left
exactly the scrambled terminal this fixes. The game is handed to the
handler afterwards through pendingSaver, whose AutoSave is a no-op until
then: a signal before the game is built restores the terminal and exits
with nothing to save. SIGHUP/SIGTERM autosave on the play path is
unchanged.
The save decision, written into the savesOnSignal comment: SIGHUP and
SIGTERM keep autosaving; SIGINT and SIGQUIT restore and exit without
saving. No path in C saves on INT or QUIT (leave() is endwin-and-exit,
quit() confirms/scores/exits, endit() goes through fatal(), and
save.c auto_save is reserved for HUP/TERM), the semantics agree
(involuntary teardown is worth rescuing a game from; a deliberate "stop
now" must not become a one-keystroke checkpoint against an anti-save-scum
save discipline), and it is the safe choice, since AutoSave gob-encodes
live state the main goroutine is still mutating after removing the old
file.
One reader of one signal is also what closes the corruption window: a
second signal arriving while a SIGHUP's AutoSave is mid-write stays
unread in the buffer instead of exiting out from under the writer.
cmd/rogue/main_test.go pins the membership of handledSignals() itself --
the rest of the file iterates that set, so without that assertion the
suite would pass against a set that had lost SIGINT and SIGQUIT again,
which is the regression this issue exists to prevent -- and covers the
ordering per signal, the save/no-save split (driven from the expectation
table so every entry is read), the mid-save second-signal interleaving,
the pre-game pendingSaver window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM
delivered to the test process through the same notifySignals wiring the
game uses.
Two premises behind the report were wrong and are recorded rather than
silently fixed: 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 and 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()
described above. Nothing is raw before term.New(), so there was never
anything to cover there.
ARCHITECTURE.md section 9 gains rows for SIGTSTP/tstp() (deliberately
dropped: raw mode means Ctrl-Z cannot reach the process, suspending the
screen from the signal goroutine is a logical race against the drawing
goroutine -- not a data race, since tcell guards Suspend/Resume and Fini
alike -- and C armed tstp only after a successful restore(); the ! shell
escape covers the need), for SIGINT not routing to the interactive
quit() prompt, and for auto_save on the fault signals. Section 5.3's
claim that tcell handles SIGTSTP was false -- tcell registers only
SIGWINCH -- and is corrected, and its "every path restores the terminal"
claim now holds because of the install ordering above.
This commit is contained in:
53
TODO.md
53
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
|
||||
|
||||
Reference in New Issue
Block a user