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

View File

@@ -1513,8 +1513,18 @@ Ported drawing code keeps its structure: `mvaddch(y, x, ch)` →
buffer, preserving the "screen is a data structure" idiom without touching the
real terminal. `md_readchar`'s escape decoding is deleted; tcell's `EventKey`
provides decoded keys and we translate to the byte codes `command()` already
handles (KeyUp → 'k', etc.). SIGTSTP/resume and resize are handled by tcell;
SIGHUP/SIGTERM → `autoSave` via `os/signal`.
handles (KeyUp → 'k', etc.). Resize is handled by tcell, which registers only
SIGWINCH — SIGTSTP is not among the signals it takes, and is dropped (§9).
Signals are handled in `cmd/rogue/main.go`: one `os/signal` channel read by one
goroutine that reads exactly one signal, so a second signal can never call
`os.Exit` out from under an in-flight save. SIGHUP and SIGTERM `AutoSave` on the
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
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
deliberate interrupt from becoming a free checkpoint. Every path restores the
terminal via `Terminal.Fini` before exiting, which is the whole point of C's
`leave()`, "leave quickly but curteously".
### 5.4 Messaging
@@ -1715,6 +1725,39 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
| tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
| curses window save in save file | screen is derivable | redraw on restore |
| `tstp()` SIGTSTP suspend/resume | see below | `!` shell escape |
| SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none |
The three signal rows warrant more than a table cell.
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game
as a signal in the first place: tcell puts the tty in raw mode (`term.MakeRaw`
clears `ISIG`, which is what makes `VSUSP` live), so Ctrl-Z arrives as key byte
`0x1a`, exactly as it did in C, whose `setup()` calls curses `raw()` for the
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
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
through the input loop and handle it synchronously, a 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
interactive "really quit?" prompt. The port exits instead (after restoring the
terminal): the handler runs on another goroutine, and re-entering the message
and input machinery from there would race every screen and state access the main
goroutine makes. The `Q` command reaches the same prompt from inside the turn
loop, which is where the player actually quits.
**Fault signals.** `md_onsignal_autosave` also sent SIGILL, SIGTRAP, SIGFPE,
SIGBUS, SIGSEGV and SIGSYS to `auto_save`. In Go these are runtime panics with
their own diagnostics, and gob-encoding the state that just faulted would risk
replacing a good save file with a corrupt one, so they are left alone. SIGHUP
and SIGTERM still autosave.
## 10. Testing strategy