The SIGHUP/SIGTERM handler gob-encoded the live game tree from the signal goroutine while the game goroutine was mid-turn mutating it, and AutoSave removed the save file before encoding — so the failure mode was not a stale save but a deleted one followed by a possibly torn replacement, with a window in which the player had neither. The suite has run under -race since 2026-08-09 and was green because nothing had ever driven the turn loop concurrently with a signal: evidence of untested, not of safe. The handler no longer writes anything. AutoSaveOnSignal posts a request, wakes the input read, and waits up to signalSaveTimeout for the game goroutine to take it; the encode runs on the goroutine that owns the state, at the three points where that goroutine can sit: between turns (command), on waking from a blocked readchar, and while parked in the `!` shell escape (runShellEscape, which now runs the shell on a helper goroutine so a hangup during it still rescues the game). Blocked on input is the case that matters — a dropped connection lands while the player is thinking, so a flag checked only between turns would never be looked at. Terminal.ReadChar therefore returns (byte, bool), with ok false meaning "woken by Interrupt, no key", and term.Tcell posts a tcell.EventInterrupt onto tcell's own event queue to unpark PollEvent. readchar services the request and reads again, so no caller sees it. Running the shell on a helper goroutine would also have moved term.Tcell.ShellEscape's panic on a failed Screen.Resume onto it, and a panic at the top of any goroutine terminates the process without running the deferred calls of the others — including cmd/rogue/main.go's `defer t.Fini()`. The tty would have been left raw on precisely the path where the terminal is already broken, which is issue #12's failure on a path this change created. runShellEscape therefore recovers the helper's panic and re-raises it on the game goroutine, whose stack has the restore in it, so "every path restores the terminal via Terminal.Fini before exiting" stays true. saveFile writes a temporary file in the save's own directory, fsyncs it and renames it over the target instead of truncating in place, so a save that fails — or never happens because the deadline ran out — leaves the player's previous save whole. What the handoff guarantees is stated exactly rather than flatteringly: the encode runs on the state-owning goroutine, so the snapshot is internally consistent and restorable, but it is not necessarily taken between commands. readchar is reached from mid-command prompts (--More--, askOverwrite, getStr, the direction and pack prompts) and the command has already mutated state by then, so a save taken there freezes that command half applied and restoring loses the rest of it. The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering guarantee are untouched. pendingSaver reads the game out from under its mutex rather than delegating with it held, because the delegated call now blocks until the save is taken.
311 lines
11 KiB
Go
311 lines
11 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 {
|
|
// AutoSaveOnSignal asks the game goroutine to write the save file and
|
|
// waits up to timeout for it, reporting whether the save ran (save.c
|
|
// auto_save). The handler never encodes anything itself; see
|
|
// signalSaveTimeout.
|
|
AutoSaveOnSignal(timeout time.Duration) bool
|
|
}
|
|
|
|
// signalSaveTimeout bounds how long the signal handler waits for the game
|
|
// goroutine to take its autosave.
|
|
//
|
|
// The handler cannot encode the game itself — that was issue #24's data
|
|
// race — so it has to hand the work to the goroutine that owns the state
|
|
// and wait. The game answers between turns, while parked waiting for a
|
|
// key, and while parked in the shell escape, which covers everywhere it
|
|
// can sit for any length of time; the deadline is the backstop for a game
|
|
// goroutine wedged somewhere with no service point, so that a signal can
|
|
// never fail to get the process out. It is generous next to the
|
|
// milliseconds a gob encode of one game takes, and invisible to a player
|
|
// whose connection has already dropped.
|
|
//
|
|
// Giving up costs nothing now that saveFile renames over the target
|
|
// (game/save.go): a save that does not happen leaves the previous save
|
|
// whole, where the old remove-then-encode could leave the player with
|
|
// neither.
|
|
const signalSaveTimeout = 3 * time.Second
|
|
|
|
// 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 AutoSaveOnSignal does nothing until set hands over
|
|
// the real game. The mutex is not decoration: set runs on the main
|
|
// goroutine and AutoSaveOnSignal on the signal goroutine.
|
|
type pendingSaver struct {
|
|
mu sync.Mutex
|
|
game saver
|
|
}
|
|
|
|
// AutoSaveOnSignal 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.
|
|
//
|
|
// The lock is held only long enough to read the game, not across the
|
|
// delegated save. That changed with issue #24: the real
|
|
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
|
|
// the deadline expires, and holding the mutex across a wait that long
|
|
// would stall a concurrent set — the case the PR #23 review flagged as
|
|
// safe only for as long as set is called exactly once. This shape does
|
|
// not depend on that.
|
|
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
|
p.mu.Lock()
|
|
g := p.game
|
|
p.mu.Unlock()
|
|
|
|
if g == nil {
|
|
return false
|
|
}
|
|
|
|
return g.AutoSaveOnSignal(timeout)
|
|
}
|
|
|
|
// 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 used to be the third ground, back when the handler
|
|
// gob-encoded live game state from its own goroutine after removing the
|
|
// save file — a data race with a window in which the player had no save
|
|
// at all, accepted on HUP/TERM because the process was dying anyway and
|
|
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
|
|
// living with it: the handler now hands the save to the game goroutine
|
|
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
|
|
// renamed over the target. The split above stands on C and on semantics,
|
|
// which is where it always belonged; INT and QUIT do not save because
|
|
// the player asked to stop, not because saving is dangerous.
|
|
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 save
|
|
// is still being written, 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.
|
|
//
|
|
// AutoSaveOnSignal returns once the game goroutine has finished writing,
|
|
// or once signalSaveTimeout has run out, so the save is complete before
|
|
// the terminal is torn down and the process leaves — and the process
|
|
// leaves either way.
|
|
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
|
|
if savesOnSignal(<-sig) {
|
|
g.AutoSaveOnSignal(signalSaveTimeout)
|
|
}
|
|
|
|
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)
|
|
}
|