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 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 covers the ordering per signal, the save/no-save
split, the mid-save second-signal interleaving, 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 and the window before term.New().

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 would race the drawing goroutine, 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.
This commit is contained in:
2026-08-09 05:45:45 +00:00
parent 4aa4babe40
commit f602ecdbbf
4 changed files with 402 additions and 12 deletions

42
TODO.md
View File

@@ -34,6 +34,48 @@ 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` covers the
ordering for each signal, the save/no-save split against `savesOnSignal`, the
mid-save-second-signal case, 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` and the window before
`term.New()`. 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