fix: take the signal-time autosave on the game goroutine (closes #24)

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. Only the check at the top of command is; the other two
service points both sit inside a command call already under way. readchar
is reached from mid-command prompts (--More--, askOverwrite, getStr, the
direction and pack prompts) with the command's mutations already applied,
and runShellEscape is reached from shell, an ordinary '!' command handler
dispatched inside command, with that turn's DoDaemons(Before) and
DoFuses(Before) already fired and its AFTER pass not yet. Restoring
re-enters playit at the top of command, so either way the rest of that
command is lost and a fresh BEFORE pass runs on top of the one in the
snapshot.

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.
This commit is contained in:
clawbot
2026-08-09 06:47:40 +00:00
committed by sneak
parent e1bf46b241
commit 3bc2e09e24
13 changed files with 1156 additions and 98 deletions

511
game/autosave_test.go Normal file
View File

@@ -0,0 +1,511 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
// Tests for the signal-triggered autosave handoff (issue #24): the signal
// goroutine must never encode game state itself, and the game goroutine
// must answer wherever it is parked.
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
// expect the save to be taken. It is long enough that a loaded machine
// cannot turn a working handoff into a spurious failure, and it is never
// actually waited out on a passing run.
const autoSaveWait = 10 * time.Second
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
// drives the real turn loop on one goroutine while another asks for a
// signal-triggered autosave over and over, which is the interleaving no
// test in the suite used to produce. `make test` runs with -race, so a
// save that encodes the live game tree from the asking goroutine — what
// the old AutoSave did straight from the signal handler — is reported as
// a data race and fails this test.
//
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
// under -race with the encoder reading state that command() is writing.
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
t.Parallel()
// Same mix as TestTurnLoopCrashSweep: the spaces answer any --More--
// prompt, and the script is long enough that the drive never runs it
// out.
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
g := New(Params{Seed: 20260809, Term: &testTerm{input: script}})
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
g.startLevel()
g.prePlay()
const wantSaves = 25
var taken int
done := make(chan struct{})
go func() {
defer close(done)
for range wantSaves {
if g.AutoSaveOnSignal(autoSaveWait) {
taken++
}
}
}()
driveUntilDone(t, g, done)
// The close of done orders that goroutine's writes before this read.
if taken != wantSaves {
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
}
// Every request was answered by the turn loop, so the file is the
// work of the game goroutine and must be a whole save.
assertRestorable(t, g.FileName)
}
// driveUntilDone runs turns until the saving goroutine is finished,
// fortifying the hero each turn so no death exits the test binary. The
// turn cap keeps a broken handoff from hanging the suite instead of
// failing it.
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
t.Helper()
const maxTurns = 1000
for range maxTurns {
select {
case <-done:
return
default:
}
fortify(g)
g.command()
}
t.Fatal("the turn loop ran out of turns before the saves were taken")
}
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
// for: the connection drops while the player is staring at the screen,
// so the game goroutine is parked in ReadChar and will not reach the
// between-turns check on its own. A flag checked only between turns would
// never be looked at here.
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
t.Parallel()
bt := newBlockingTerm()
g := mkBlockedGame(t, bt)
read := make(chan byte)
go func() { read <- g.readchar() }()
// The wake is buffered, so this is correct whether or not the reader
// has reached ReadChar yet.
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Fatal("the save was not taken while the game was blocked on input")
}
assertRestorable(t, g.FileName)
// The interrupt must not have been mistaken for a keystroke: the
// reader is still waiting, and still returns the real key.
bt.keys <- 'x'
if ch := <-read; ch != 'x' {
t.Errorf("readchar() = %q, want 'x'", ch)
}
}
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
// goroutine parks for an unbounded time: the `!` shell escape, where it
// used to sit inside the shell call with no way to answer. A dropped line
// while the player is off in a shell is as much a hangup as any other.
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
t.Parallel()
st := &shellTerm{
blockingTerm: newBlockingTerm(),
entered: make(chan struct{}),
release: make(chan struct{}),
}
g := mkBlockedGame(t, st)
left := make(chan struct{})
go func() {
defer close(left)
g.shell()
}()
<-st.entered
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Error("the save was not taken while the game was in the shell escape")
}
assertRestorable(t, g.FileName)
close(st.release)
<-left
}
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
// runShellEscape recovers its helper's panic.
//
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
// now runs on a helper goroutine. A panic reaching the top of that helper
// would kill the process without running the deferred calls of any other
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
// the only thing that takes the tty back out of raw mode. That is issue
// #12's failure, and it would land on the one path where the terminal is
// already broken.
//
// So the panic has to arrive on the goroutine that runs the game, with
// that goroutine's deferred restore still on the stack. This test stands
// in for main: a Fini deferred around the g.shell() call, and the panic
// caught after it, asserting both that the restore ran and that the
// original value came through. Against the unrecovered version there is
// nothing to assert — the panic escapes a helper goroutine and takes the
// whole test binary down, which is the failure being prevented.
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
t.Parallel()
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
g := mkBlockedGame(t, pt)
caught := make(chan any, 1)
go func() {
// Registered first, so it runs last: it sees the terminal
// already restored, exactly as the runtime would have printed
// the trace after main's Fini.
defer func() { caught <- recover() }()
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
defer pt.Fini()
g.shell()
}()
got := <-caught
if got == nil {
t.Fatal("the resume failure did not reach the game goroutine")
}
if msg, ok := got.(string); !ok || msg != errShellResume {
t.Errorf("recovered %v, want %q", got, errShellResume)
}
if !pt.restored {
t.Error("the terminal was not restored on the way out")
}
// shell() must not have resumed into its InShell reset and refresh:
// there is no screen left to draw into.
if !g.InShell {
t.Error("shell() carried on drawing after the resume failed")
}
}
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
// goroutine that never reaches a service point must not hold the process
// open, and giving up must cost the player nothing. The old save is still
// there, byte for byte — which is the whole point of renaming over the
// target instead of removing it first.
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
t.Parallel()
g := mkGame(t, 77)
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
const old = "an older save nobody is allowed to destroy"
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
// Nothing drives the turn loop, so nothing will ever answer.
start := time.Now()
if g.AutoSaveOnSignal(100 * time.Millisecond) {
t.Error("AutoSaveOnSignal reported a save that nobody took")
}
if waited := time.Since(start); waited > time.Second {
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
waited)
}
got, readErr := os.ReadFile(g.FileName)
if readErr != nil {
t.Fatalf("the previous save was destroyed: %v", readErr)
}
if string(got) != old {
t.Error("the previous save was overwritten by a save that never ran")
}
}
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
// case: a game with no file name has nothing to write, and must say so
// rather than reporting a save that did not happen.
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 5, Term: &testTerm{
input: []byte(strings.Repeat("s . ", 200)),
}})
g.FileName = ""
g.startLevel()
g.prePlay()
var answered bool
done := make(chan struct{})
go func() {
defer close(done)
answered = g.AutoSaveOnSignal(autoSaveWait)
}()
driveUntilDone(t, g, done)
if answered {
t.Error("AutoSaveOnSignal = true with no save file name")
}
}
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
// save arrives by rename, so the file the player already had is never
// written into, and the temporary file it came from is not left lying in
// the save directory.
//
// The load-bearing assertion is the handle opened before the save. A
// rename leaves the old file whole and merely stops it being reachable by
// name, so that handle still reads the old save; the truncate-in-place
// write this replaced would empty it under the reader — the same
// in-place write that, interrupted, left the player with a file that
// could no longer be restored.
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
t.Parallel()
g := mkGame(t, 11)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
const old = "an older save"
writeErr := os.WriteFile(path, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
if openErr != nil {
t.Fatal(openErr)
}
defer func() { _ = held.Close() }()
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
kept, readErr := io.ReadAll(held)
if readErr != nil {
t.Fatalf("reading the file that was there before the save: %v", readErr)
}
if string(kept) != old {
t.Errorf("the previous save was written into rather than replaced: %q",
string(kept))
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
t.Errorf("save directory = %v, want just the save file", names(entries))
}
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatal(statErr)
}
if perm := info.Mode().Perm(); perm != 0o400 {
t.Errorf("save file mode = %v, want 0400", perm)
}
assertRestorable(t, path)
}
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
// same discipline: a save that cannot be completed must leave what the
// player already had. The target here is a non-empty directory, which no
// rename can replace — the one write failure that can be forced without
// depending on file permissions, and therefore on not being root.
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
t.Parallel()
g := mkGame(t, 12)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
mkErr := os.Mkdir(path, 0o700)
if mkErr != nil {
t.Fatal(mkErr)
}
keep := filepath.Join(path, "keep")
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
saveErr := g.saveFile(path)
if saveErr == nil {
t.Error("saveFile over an unreplaceable target reported success")
}
_, statErr := os.Stat(keep)
if statErr != nil {
t.Errorf("the target was damaged by a failed save: %v", statErr)
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 {
t.Errorf("save directory = %v, want no temporary file left behind",
names(entries))
}
}
// names lists directory entry names for a failure message.
func names(entries []os.DirEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// assertRestorable checks that path holds a save this program can load,
// which is what "the save was taken" has to mean: a file of the right
// size proves nothing about a torn encode.
func assertRestorable(t *testing.T, path string) {
t.Helper()
_, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Errorf("the saved file does not restore: %v", err)
}
}
// mkBlockedGame builds a game with a save file name and a terminal whose
// reads block, for the tests that park the game goroutine.
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
t.Helper()
g := New(Params{Seed: 4242, Term: term})
g.NewLevel()
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
return g
}
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
// block, cannot reproduce.
type blockingTerm struct {
keys chan byte
wake chan struct{}
}
func newBlockingTerm() *blockingTerm {
return &blockingTerm{
keys: make(chan byte),
// Buffered by one and posted to without blocking, the same
// contract term.Tcell.Interrupt has with tcell's event queue: an
// interrupt that arrives before the read still wakes it.
wake: make(chan struct{}, 1),
}
}
func (t *blockingTerm) Render(*Window) {}
func (t *blockingTerm) Fini() {}
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.
func (t *blockingTerm) Interrupt() {
select {
case t.wake <- struct{}{}:
default:
}
}
// ReadChar blocks until a key arrives or Interrupt wakes it.
func (t *blockingTerm) ReadChar() (byte, bool) {
select {
case ch := <-t.keys:
return ch, true
case <-t.wake:
return 0, false
}
}
// shellTerm is a blockingTerm that also offers a shell escape which stays
// in the shell until the test lets it out.
type shellTerm struct {
*blockingTerm
entered chan struct{}
release chan struct{}
}
// ShellEscape parks the caller in the "shell" until released.
func (t *shellTerm) ShellEscape() {
close(t.entered)
<-t.release
}
// errShellResume is what panickingShellTerm panics with, standing in for
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
const errShellResume = "resume failed"
// panickingShellTerm is a blockingTerm whose shell escape panics on the
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
// resumed. It records whether Fini ran, which is the thing that must
// still happen.
type panickingShellTerm struct {
*blockingTerm
restored bool
}
func (t *panickingShellTerm) Fini() { t.restored = true }
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }

View File

@@ -8,6 +8,13 @@ package game
func (g *RogueGame) command() {
p := &g.Player
// Between turns is the one point in the loop where the game state is
// whole, so it is where a signal-triggered autosave is answered when
// the game goroutine is busy rather than waiting for a key (issue
// #24). The other service points are readchar (io.c) and
// runShellEscape, covering the two ways this goroutine can be parked.
g.serviceAutoSaveRequest()
ntimes := 1 // number of player moves
if p.On(Hasted) {
ntimes++
@@ -890,7 +897,7 @@ func (g *RogueGame) shell() {
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
g.InShell = true
se.ShellEscape()
g.runShellEscape(se)
g.InShell = false
g.refresh()
@@ -898,3 +905,58 @@ func (g *RogueGame) shell() {
g.msg("shell escape is not available")
}
}
// runShellEscape runs the shell and returns when it exits, answering
// signal-triggered autosave requests in the meantime (issue #24).
//
// The shell blocks for as long as the player is away — minutes, or until
// they forget — and a line dropping while they are in it is exactly the
// case SIGHUP autosave exists for, so this goroutine cannot simply sit
// inside the call. The shell runs on a helper goroutine and the game
// goroutine waits here, still the only one that ever encodes game state.
// It draws nothing while it waits, so the suspend/resume dance is as
// undisturbed as it was when it ran inline (ARCHITECTURE.md section 9).
//
// A panic out of ShellEscape must not be allowed to unwind on the helper
// goroutine. term.Tcell.ShellEscape panics when Screen.Resume fails, and
// a panic reaching the top of any goroutine kills the process without
// running any *other* goroutine's deferred calls — which is where
// cmd/rogue/main.go's `defer t.Fini()` lives. Running the shell off the
// game goroutine would therefore have left the tty raw on exactly the
// path where the terminal is already broken, reintroducing issue #12 on a
// path this change created. So the helper recovers, and the value is
// re-raised below on the game goroutine, whose stack does have Fini in
// it. The recover deferral is registered after `defer close(done)` and so
// runs before it, which is what publishes panicVal to the reader.
func (g *RogueGame) runShellEscape(se interface{ ShellEscape() }) {
done := make(chan struct{})
var panicVal any
go func() {
defer close(done)
defer func() {
panicVal = recover()
}()
se.ShellEscape()
}()
for {
select {
case <-done:
if panicVal != nil {
// Re-raised here so the unwind passes through the game
// goroutine's deferred Fini. shell()'s InShell reset and
// refresh are skipped deliberately: there is no screen
// left to draw into.
panic(panicVal)
}
return
case req := <-g.sigSave:
g.runAutoSaveRequest(req)
}
}
}

View File

@@ -140,6 +140,13 @@ type RogueGame struct {
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
restored bool // game came from a save file; Run skips setup
// sigSave carries signal-triggered autosave requests from the signal
// goroutine to the game goroutine, which is the only one allowed to
// touch the state above (issue #24). Buffered by one: the handler
// reads exactly one signal, so there is never more than one request.
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
sigSave chan *autoSaveRequest
// data is the game's copy of the static tables (extern.c and friends).
data *gameData
}
@@ -161,6 +168,7 @@ func New(params Params) *RogueGame {
Depth: 1,
ScorePath: params.ScorePath,
LastScore: -1,
sigSave: make(chan *autoSaveRequest, 1),
}
g.Options = Options{
SeeFloor: true,

View File

@@ -166,15 +166,37 @@ func stepOk(ch byte) bool {
// readchar reads and returns a character, checking for gross input errors
// (io.c readchar).
//
// Waiting for a key is where the game spends nearly all of its wall
// clock, so it is also where a signal-triggered autosave usually finds
// it: a dropped connection lands while the player is thinking, not
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
// save runs here, on the game goroutine, before reading again.
//
// What that buys is a snapshot taken by the goroutine that owns the
// state, so it is internally consistent and restorable. It is not
// necessarily a between-commands snapshot: readchar is also reached from
// prompts raised part-way through a command — --More--, askOverwrite,
// getStr, the direction and pack prompts — and mutation has already
// happened by then. See serviceAutoSaveRequest (save.go) for what that
// costs the player.
func (g *RogueGame) readchar() byte {
ch := g.scr.term.ReadChar()
if ch == 3 { // ^C
g.quit(0)
for {
ch, ok := g.scr.term.ReadChar()
if !ok {
g.serviceAutoSaveRequest()
return 27
continue
}
if ch == 3 { // ^C
g.quit(0)
return 27
}
return ch
}
return ch
}
// statusCache is the set of static shadow variables in io.c status() that

View File

@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
@@ -643,38 +645,196 @@ func (g *RogueGame) askOverwrite() saveAnswer {
}
}
// saveFile writes the saved game (save.c save_file). A failed write means
// a corrupt save, so the file is removed before reporting the error.
// saveFile writes the saved game (save.c save_file).
//
// The snapshot goes to a temporary file in the target's own directory and
// is renamed over the target, so there is no instant at which the player
// has no save file: until the rename the old file is whole, and after it
// the new one is. C wrote straight over the target, and this port did the
// same with a remove in front of it (AutoSave), so a write that failed —
// or a signal-time save cut short by the process dying — could leave the
// player with neither the old save nor a usable new one (issue #24).
//
// The temporary file is fsynced before the rename so its contents reach
// the disk ahead of the directory entry that will point at it. The
// directory itself is not fsynced: that would only matter for a machine
// that loses power in the same instant, and the old save survives that
// case anyway. A process killed mid-encode leaves its temporary file
// behind, which is litter next to a destroyed save file, and the dot
// prefix keeps it out of the way.
func (g *RogueGame) saveFile(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
if err != nil {
return err
}
encErr := gob.NewEncoder(f).Encode(g.snapshot())
closeErr := f.Close()
tmp := f.Name()
if encErr != nil || closeErr != nil {
_ = os.Remove(path) // don't leave a corrupt save behind
writeErr := encodeSnapshot(f, g.snapshot())
if writeErr != nil {
_ = os.Remove(tmp) // never leave a half-written file behind
if encErr != nil {
return encErr
}
return closeErr
return writeErr
}
return os.Chmod(path, 0o400)
renErr := os.Rename(tmp, path)
if renErr != nil {
_ = os.Remove(tmp)
return renErr
}
return nil
}
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
// (save.c auto_save). Best effort by design: it runs on the way out of a
// dying process.
func (g *RogueGame) AutoSave() {
if g.FileName != "" {
_ = os.Remove(g.FileName)
_ = g.saveFile(g.FileName)
// encodeSnapshot encodes the snapshot into an open temporary file and
// closes it, leaving it read-only as the C game's saves were (save.c
// save_file). It never removes the file: its caller owns the cleanup, so
// that one place decides what happens to a failed write.
func encodeSnapshot(f *os.File, st *SaveState) error {
encErr := gob.NewEncoder(f).Encode(st)
if encErr == nil {
encErr = f.Sync()
}
if encErr == nil {
encErr = f.Chmod(0o400)
}
closeErr := f.Close()
if encErr != nil {
return encErr
}
return closeErr
}
// autoSaveRequest is one signal-triggered autosave in flight: the signal
// goroutine posts it and waits, the game goroutine performs the save and
// closes done. ok is written before done is closed and read only after,
// so the close is the happens-before edge that publishes it.
type autoSaveRequest struct {
done chan struct{}
ok bool
}
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
// timeout for it to finish, reporting whether the save actually ran
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
// point the signal goroutine may use, and it deliberately touches no game
// state: the gob encoder used to walk the live game tree from the signal
// goroutine while the game goroutine was mid-turn mutating it (issue
// #24).
//
// Blocked on input is the case that matters, since a dropped connection
// is the whole reason the handler exists: the request is posted first and
// the input read is then interrupted, so a game goroutine parked in
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
// that is running turns instead picks the request up between turns, in
// command; one parked in the `!` shell escape picks it up in
// runShellEscape.
//
// The wait is bounded because the signal goroutine's job is to get the
// process out. If the game goroutine is somewhere with no service point
// at all, the deadline expires, this reports false, and the caller
// restores the terminal and exits — leaving the player's previous save
// file exactly as it was, which is the point of the rename in saveFile.
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
req := &autoSaveRequest{done: make(chan struct{})}
select {
case g.sigSave <- req:
default:
// A request is already queued and unserviced, or there is no
// game loop to service one; either way this one would not be
// answered either.
return false
}
g.scr.Interrupt()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-req.done:
return req.ok
case <-timer.C:
return false
}
}
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
// one is waiting, and otherwise returns at once. It runs on the game
// goroutine — that is the whole design — so it must only be called where
// that goroutine is not itself inside the encode: between turns, or while
// parked waiting for input or for the shell escape.
//
// What is guaranteed, exactly: the encode runs on the one goroutine that
// owns the state, so the snapshot is internally consistent and always
// restorable. It is *not* guaranteed to be a between-commands snapshot.
// Only one of the three service points gives that: the check at the top
// of command, which runs after the previous command returned and before
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
// reached from inside a command call already under way, and both cost
// the same on restore.
//
// readchar is reached from prompts raised part-way through a command —
// --More-- on the second message of a turn, askOverwrite, getStr, the
// direction and pack prompts — and by then the command has already
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
// any message, revealXeroc writes tp.Disguise before emitting one. The
// ordinary top-of-turn key read in readCommand is inside command too,
// after that turn's BEFORE daemons and turnUpkeep.
//
// runShellEscape is no safer. shell is an ordinary command handler ('!'
// in the tables.go dispatch table), reached through executeCommand, so a
// goroutine parked in the shell escape has already run this turn's
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
// ringTurnEffects.
//
// The cost, at both: restoring re-enters playit at the top of command,
// so the rest of that command never runs — its AFTER daemons and fuses
// and its ring effects are lost — and the restored game opens with a
// fresh BEFORE pass on top of the one already in the snapshot. That
// second BEFORE pass is not free: rollwand, a live Before daemon once
// swander has fired, ticks again and draws from the RNG every fourth
// tick, and any Before fuse is decremented again. The result is still a
// coherent game state, one turn's worth of effects off — strictly better
// than the torn encode this replaced, and the cost of being able to save
// a player whose line dropped mid-prompt, or who is away in a shell, at
// all.
func (g *RogueGame) serviceAutoSaveRequest() {
select {
case req := <-g.sigSave:
g.runAutoSaveRequest(req)
default:
}
}
// runAutoSaveRequest answers one request: save, then release the waiter.
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
req.ok = g.autoSave()
close(req.done)
}
// autoSave silently saves to the current file name (save.c auto_save),
// reporting whether it wrote a save. Game-goroutine only — reach it
// through AutoSaveOnSignal from anywhere else.
//
// The error is not surfaced: there is no player to tell, since the
// terminal is on its way out, and nothing sensible to do about it. It is
// reported to the waiting signal goroutine as a failed save rather than
// discarded outright, which is what the old `_ =` here used to do.
func (g *RogueGame) autoSave() bool {
if g.FileName == "" {
return false
}
return g.saveFile(g.FileName) == nil
}
// ErrSaveOutOfDate reports a save file from an incompatible version.
@@ -746,6 +906,7 @@ func Restore(path string, params Params) (*RogueGame, error) {
FileName: path,
rogueOpts: params.RogueOpts,
restored: true,
sigSave: make(chan *autoSaveRequest, 1),
}
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)

View File

@@ -14,8 +14,15 @@ type Terminal interface {
// Render blits the window to the device.
Render(w *Window)
// ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes).
ReadChar() byte
// (arrows become hjkl, control keys their C0 codes). ok is false when
// the read was woken by Interrupt instead of by a key, which is how a
// signal-triggered autosave reaches a game parked on input; the byte
// is meaningless then.
ReadChar() (ch byte, ok bool)
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
// the one Terminal method called from another goroutine, so an
// implementation must be safe to call concurrently with ReadChar.
Interrupt()
// Fini restores the device to its pre-game state (curses endwin). The
// game calls it on its way out, since one game run is one process.
Fini()
@@ -223,6 +230,15 @@ func (s *Screen) Fini() {
}
}
// Interrupt wakes a device read that is blocked waiting for a key, if
// there is a device. Called from the signal goroutine; everything else on
// Screen belongs to the game goroutine.
func (s *Screen) Interrupt() {
if s.term != nil {
s.term.Interrupt()
}
}
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
func (s *Screen) RefreshWin(w *Window) {
if s.term != nil {

View File

@@ -14,18 +14,22 @@ func (t *testTerm) Render(*Window) {}
func (t *testTerm) Fini() {}
func (t *testTerm) ReadChar() byte {
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
// The blocking case has its own fake, blockingTerm in autosave_test.go.
func (t *testTerm) Interrupt() {}
func (t *testTerm) ReadChar() (byte, bool) {
if t.pos < len(t.input) {
c := t.input[t.pos]
t.pos++
return c
return c, true
}
t.tick++
if t.tick%2 == 0 {
return '\n'
return '\n', true
}
return ' '
return ' ', true
}