SIGHUP/SIGTERM autosave is a data race: gob-encodes live state from a second goroutine #24
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The SIGHUP/SIGTERM handler calls
AutoSavefrom a signal goroutine while thegame goroutine is still mutating state. The gob encoder walks the live game
tree with no synchronisation, so the save can be written from a torn,
half-updated snapshot.
This is pre-existing, not introduced by PR #23. The implementer of #12
found it, correctly left it exactly as found, and confined their change to
making sure the new SIGINT/SIGQUIT handling does not make it worse.
Worse,
AutoSaveremoves the save file before encoding. So the failuremode is not "a slightly stale save" — it is: delete the player's existing
save, then write a possibly-corrupt replacement. There is a window where the
player has neither.
Why
-racehas not caught itThe race detector only reports races it actually observes, and no test drives
the turn loop concurrently with a signal.
make testruns with-raceand isgreen — that is evidence of untested, not of safe. Any fix here must come
with a test that genuinely provokes the interleaving, or it proves nothing.
Definition of done
goroutine. The expected shape (open to a better design if you find one):
the signal handler sets a flag; the command loop checks it between turns
and performs the save synchronously on the game goroutine.
which is the common case for a dropped connection, and the whole reason
the handler exists. A between-turns flag check that never runs because the
process is parked in
PollEventwould be a regression, not a fix. Sayexplicitly how you handle this.
-raceacross repeated runs (GOFLAGS=-count=1). Demonstrate the test isnon-vacuous: show it failing against the current unsynchronised code.
rename over the target, so a crash mid-encode cannot leave the player with
no save at all.
MEMORY.md's "signal-time autosave" note (currently listed among thedeliberate best-effort
_ =discard paths) is updated to match whateverthe new discipline is.
make checkfully green.TODO.mdupdated in the same commit — Completed Steps entry, and do notrotate "Next Step".
(closes #N).Implementation requirements
cmd/rogue/main.go,game/command.go, andgame/save.go.NOT weaken its single-signal-read ordering guarantee. Read that PR's
savesOnSignaldoc comment before touching the handler.maketargets only. Do NOT modify.golangci.yml. No Dockerfile/CI/script/.c-masterandmodern-roguealone; read C only viagit show.game/testdata/.Depends on
PR #23 (#12) — land that first so the handler shape is settled.
Priority
Medium-high. It is a real correctness bug that can destroy a save file, but
it needs an involuntary signal to trigger, so it is rarer than a crash.
Implementation plan
Read first: the issue,
TODO.md,MEMORY.md,ARCHITECTURE.md§5.3/§9, and thewhole PR #23 thread (all six comments) including the
savesOnSignal/pendingSaver/leaveOnSignalcode and doc comments.Design: request-and-wait handoff, save on the game goroutine
The signal goroutine stops writing anything. It posts a request and waits.
RogueGamegains an unexportedsigSave chan *autoSaveRequest(buffered 1),initialised in
NewandRestore. A request carries adone chan struct{}and an
okfield written beforedoneis closed.AutoSaveOnSignal(timeout time.Duration) bool— posts the request, wakes ablocked input read, waits for
doneor the deadline, reports whether the saveran. It never touches game state itself.
serviceAutoSaveRequest(non-blockingreceive) and performs the encode synchronously, at two points:
command()(game/command.go) — between turns, which also coversresting/
NoCommandturns and running;readchar()(game/io.go) whenever the terminal read is woken by aninterrupt rather than a key.
AutoSave()(exported, callable from anywhere) goes away as the signal entrypoint; the actual write becomes unexported
autoSave(), reachable only fromthe game goroutine.
The blocked-on-input case (definition of done #2), explicitly
A between-turns flag check alone is exactly the regression the issue names, so
the input read has to be interruptible. Mechanism:
TerminalgainsInterrupt()andReadCharbecomesReadChar() (byte, bool),where
ok == falsemeans "the read was woken byInterrupt, no key".term.Tcell.Interruptpoststcell.NewEventInterrupt(nil)viaScreen.PostEvent, which is exactly what tcell provides to wake a goroutineparked in
PollEvent.Tcell.ReadChar's existing event loop gains an*tcell.EventInterruptcase that returns(0, false).readchar()loops: onok == falseit services the pending autosave and readsagain, so no caller sees the wake-up.
Saving from inside a nested prompt (
really quit?, an inventory prompt) isdeliberate and safe: the game goroutine is parked, nothing is mid-mutation at
that instant, and the snapshot is the state as of the start of that command —
the same as the player never having answered the prompt.
Second blocking case, the
!shell escape (game goroutine parked incmd.Run, screen suspended): today a SIGHUP there does save, so leaving ituncovered would be a regression.
shell()will runShellEscapeon a helpergoroutine and
selecton {shell finished, save request}, so the encode stillruns on the goroutine that owns game state while that goroutine is otherwise
idle. No concurrent
Render— it is parked in the select — so §9'ssuspend/resume safety argument still holds; §9 gets updated to match.
Bounded wait:
AutoSaveOnSignalgives up after a deadline (const incmd/rogue/main.go) and reportsfalse, so a game goroutine that is wedgedsomewhere with no service point can never hang the process on its way out. On
that path nothing is written and the previous save file is left intact — which
is only acceptable because of the atomic-write change below.
Atomic write (definition of done #4)
saveFilestops truncating the target in place andautoSavestops removing it:os.CreateTempin the same directory, encode,Sync,Close,Chmod 0400,os.Renameover the target, with the temp removed on every failure path. Thereis then no instant at which the player has no save file, and a crash mid-encode
leaves the previous save untouched.
pendingSaver(stated reason for the one change)Locking otherwise untouched, but the delegated call now blocks for up to the save
deadline, so it will read the saver under
p.muinto a local and call it afterunlocking. Holding the mutex across a blocking delegate would stall a concurrent
set— the future-proofing note N3 from the PR #23 review, now load-bearingrather than hypothetical.
savesOnSignal's decision andleaveOnSignal'ssingle-signal-read ordering guarantee are not touched.
Tests (definition of done #3, non-vacuous)
In
game/, all witht.Parallel():TestAutoSaveOnSignalRacesTurnLoop— drivescommand()in a loop on the gamegoroutine (with
fortify()so no death exits the test binary) while anothergoroutine calls
AutoSaveOnSignalrepeatedly. This is the one that must failfirst: I will demonstrate it under
-raceagainst anAutoSaveOnSignalthatencodes on the calling goroutine (i.e. today's
AutoSave), and report itrather than claim success if I cannot make it fail.
TestAutoSaveOnSignalWhileBlockedOnInput— a terminal fake whoseReadChargenuinely blocks until
Interruptis called; the game goroutine sits inreadchar()and the save still lands. This is the DoD #2 test.TestAutoSaveOnSignalWhileInShellEscape— same, for the shell window.TestAutoSaveOnSignalTimesOut— no service point; returnsfalseinside thedeadline and the existing save file is byte-for-byte untouched.
saveFiletests: replaces an existing file atomically, leaves no temp behind,final mode
0400; and a forced-failure case leaving the previous target inplace.
cmd/rogue/main_test.goupdated for the renamed saver method; the existingordering/split/
pendingSavertests keep their meaning.Docs
MEMORY.md's error-handling note stops listing signal-time autosave among the_ =best-effort discards and describes the new discipline.ARCHITECTURE.md§5.3 and the §9 signal rows are corrected (including the mapping-table row that
already claims "channel checked in ReadChar", which is only true after this
change).
TODO.mdgets a Completed Steps entry in the same commit;Next Stepis not rotated.
Out of scope, untouched
chooseSeed/SEED(#25), the SIGINT/SIGQUIT no-save decision,.golangci.yml,game/testdata/goldens, Dockerfile/CI/script/.Verification will be
make fmtthenmake checkunder the retry protocol —discarding any run that says
parallel golangci-lint is runningor names pathsoutside my worktree — plus repeated
GOFLAGS=-count=1 make testruns for therace tests.