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.
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
// Command rogue is the Go port of Rogue 5.4.4: Exploring the Dungeons of
|
|
// Doom. It is a faithful function-by-function port of the classic C game;
|
|
// see ARCHITECTURE.md at the repository root.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"os/user"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/rgoue/game"
|
|
"git.eeqj.de/sneak/rgoue/term"
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(run())
|
|
}
|
|
|
|
// run does the real work and returns an exit code. It only returns on a
|
|
// startup error; once the game starts, it ends by exiting the process
|
|
// from within (game.myExit restores the terminal first). The deferred
|
|
// Fini covers the early-return paths.
|
|
func run() int {
|
|
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
|
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
|
|
|
flag.Parse()
|
|
|
|
params := loadParams()
|
|
|
|
if *scores {
|
|
game.New(params).ShowScores()
|
|
|
|
return 0
|
|
}
|
|
|
|
t, err := term.New()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
|
|
return 1
|
|
}
|
|
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
|
|
|
|
var g *game.RogueGame
|
|
|
|
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
|
// restore a saved game
|
|
g, err = game.Restore(args[0], params)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
|
|
|
|
return 1
|
|
}
|
|
} else {
|
|
g = game.New(params)
|
|
}
|
|
|
|
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
|
|
|
|
return 0
|
|
}
|
|
|
|
pending.set(g)
|
|
|
|
g.Run() // does not return: the game ends by exiting the process
|
|
|
|
return 0
|
|
}
|
|
|
|
// loadParams gathers the game parameters from the environment: home
|
|
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
|
// (main.c's startup).
|
|
func loadParams() game.Params {
|
|
home, _ := os.UserHomeDir()
|
|
|
|
name := ""
|
|
|
|
u, userErr := user.Current()
|
|
if userErr == nil {
|
|
name = u.Username
|
|
}
|
|
|
|
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
|
|
|
return game.Params{
|
|
Seed: chooseSeed(wizard),
|
|
Name: name,
|
|
RogueOpts: os.Getenv("ROGUEOPTS"),
|
|
Home: home,
|
|
ScorePath: home + "/.rogue.scores",
|
|
Wizard: wizard,
|
|
}
|
|
}
|
|
|
|
// saver is the autosave half of *game.RogueGame that the signal handler
|
|
// needs; an interface so the handler is testable headlessly.
|
|
type saver interface {
|
|
// AutoSave writes the game to its save file, best effort (save.c
|
|
// auto_save).
|
|
AutoSave()
|
|
}
|
|
|
|
// finisher is the terminal-restoring half of game.Terminal that the
|
|
// signal handler needs (curses endwin).
|
|
type finisher interface {
|
|
// Fini restores the terminal to its pre-game state.
|
|
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
|
|
// two groups with deliberately different save behavior; see
|
|
// savesOnSignal.
|
|
func handledSignals() []os.Signal {
|
|
return []os.Signal{
|
|
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
|
|
}
|
|
}
|
|
|
|
// savesOnSignal reports whether the game should autosave on its way out
|
|
// for this signal.
|
|
//
|
|
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
|
|
// restore the terminal and exit WITHOUT saving. This is deliberate, not
|
|
// an oversight, on three grounds.
|
|
//
|
|
// C: no path in the C game saves on INT or QUIT. The shipped build
|
|
// installs no handler at all during play (mach_dep.c setup calls
|
|
// 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
|
|
// discarding pending output. The build that does wire INT during play
|
|
// (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
|
|
// 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
|
|
// therefore exactly C's split.
|
|
//
|
|
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
|
|
// or the machine is going down — so rescuing the game is right. INT and
|
|
// QUIT are the player deliberately saying "stop now". Rogue scores a
|
|
// deliberate quit, and its save discipline is anti-save-scum by design
|
|
// (restoring consumes the file), so making Ctrl-C a free checkpoint
|
|
// would turn it into a one-keystroke undo for a bad turn: a gameplay
|
|
// change, not a robustness fix.
|
|
//
|
|
// Safety: this runs on a goroutine while the main goroutine is mid-turn
|
|
// mutating game state, and AutoSave removes the save file before
|
|
// gob-encoding that live state. On HUP/TERM that risk is accepted
|
|
// because the process is about to die regardless and a best-effort save
|
|
// beats none. On INT/QUIT there is nothing to rescue, so the right
|
|
// choice is the one with no corruption window at all.
|
|
func savesOnSignal(sig os.Signal) bool {
|
|
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
|
|
}
|
|
|
|
// installSignalHandlers arranges for the game to leave the terminal
|
|
// usable when it is signalled: C's leave(), "leave quickly but
|
|
// curteously" (main.c), extended with save.c auto_save on the two
|
|
// signals that warrant it.
|
|
//
|
|
// 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
|
|
// channel they arrive on. Split out from installSignalHandlers so tests
|
|
// can drive leaveOnSignal with real signal delivery.
|
|
func notifySignals() chan os.Signal {
|
|
// Buffered so signal delivery never blocks, and deliberately never
|
|
// drained past the first signal: see leaveOnSignal.
|
|
sig := make(chan os.Signal, 1)
|
|
|
|
signal.Notify(sig, handledSignals()...)
|
|
|
|
return sig
|
|
}
|
|
|
|
// leaveOnSignal waits for one signal and takes the game out.
|
|
//
|
|
// Exactly one goroutine reads exactly one signal, which is what makes
|
|
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's
|
|
// AutoSave is still writing, say) stays in the buffer unread and can
|
|
// never call exit out from under an in-flight save. The order within is
|
|
// the same one myExit uses (game/rip.go): save if this signal saves,
|
|
// then restore the terminal, then exit.
|
|
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
|
|
if savesOnSignal(<-sig) {
|
|
g.AutoSave()
|
|
}
|
|
|
|
t.Fini()
|
|
exit(0)
|
|
}
|
|
|
|
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
|
|
// (wizard mode, as in the C game), else time+pid (main.c).
|
|
func chooseSeed(wizard bool) int32 {
|
|
if env := os.Getenv("SEED"); env != "" && wizard {
|
|
n, err := strconv.ParseInt(env, 10, 32)
|
|
if err == nil {
|
|
return int32(n)
|
|
}
|
|
}
|
|
|
|
// The C game computed `lowtime + getpid()` in int; the truncation to
|
|
// 32 bits is the same wraparound the C int arithmetic performed.
|
|
//nolint:mnd // C-faithful: the C int wraparound mask
|
|
return int32(time.Now().Unix()&0x7fffffff) +
|
|
int32(os.Getpid()&0x7fffffff)
|
|
}
|