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.
218 lines
6.5 KiB
Go
218 lines
6.5 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"
|
|
"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()
|
|
|
|
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 {
|
|
g.DeathDemo() // does not return: death exits the process
|
|
|
|
return 0
|
|
}
|
|
|
|
installSignalHandlers(g, t)
|
|
|
|
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()
|
|
}
|
|
|
|
// 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, compiled only under 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.
|
|
func installSignalHandlers(g saver, t finisher) {
|
|
go leaveOnSignal(notifySignals(), g, t, os.Exit)
|
|
}
|
|
|
|
// 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)
|
|
}
|