Serialize session recovery snapshots / 串行化会话恢复快照#6074
Merged
SivanCola merged 3 commits intoJul 6, 2026
Merged
Conversation
snapshotMu closed the snapshot-vs-snapshot recovery race, but snapshot still reads sessionPath and the executor session as two separate steps, and every session/path swap mutated that state without the lock. A swap landing between the two reads pairs the old path with the new session: best case it manufactures the same spurious conflict/recovery cascade, worst case an append-shaped mismatch writes one transcript's messages into another transcript's file (the loaded session has no persisted baseline for the old path, so the revision CAS is disarmed). Take snapshotMu across the swap sections of NewSession, ClearSession, forkNamed, Branch, SwitchBranch, Resume, and SetSessionPath. ClearSession holds it from artifact removal onward so a racing save cannot resurrect the just-removed transcript. Resume releases it before recoverInterruptedTurn/maybeColdResumePrune, which snapshot on their own (the lock is not reentrant). replaceSessionAfterCancel was a second save/recovery handoff outside the lock: its post-cancel flush calls SaveRewrite and recoverSnapshotConflict directly. Serialize it the same way and read the path under the lock so an in-flight recovery retarget cannot leave it stale. Regression test blocks the first recovery callback and races SetSessionPath, Resume, and the cancel flush against the in-flight handoff: none may complete until the handoff commits, and only one recovery transcript may exist afterwards.
replaceSessionAfterCancel took snapshotMu only around the SaveRewrite/ recovery flush, but the in-memory Replace ran before it. A mid-turn autosave already holding the lock could then capture the truncated transcript mid-save, read the longer partial autosave on disk as a stale-prefix conflict, and adopt it back into the executor — silently undoing the cancel cleanup before the flush could persist it, and pointing the later SaveRewrite at the resurrected session. Hold snapshotMu from the top of the cleanup so the truncation, todo rebuild, path read, and flush are one atomic handoff. The CancelFlush regression case now performs a real truncation (drop the assistant reply, the shape stripTurnMessagesAfter produces) instead of a same-content no-op, asserts the in-memory transcript is untouched while the blocked handoff is still in flight, and verifies the truncation lands on the recovery transcript afterwards. The blocked-phase assertion fails against the previous code.
This was referenced Jul 6, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
NewSession,ClearSession,forkNamed,Branch,SwitchBranch,Resume,SetSessionPath) and the post-cancel transcript flush (replaceSessionAfterCancel) behind the same mutex, so a swap or cancel-flush can never interleave with an in-flight save/recovery handoff.SetSessionPath,Resume, and the cancel flush against a blocked handoff and verifies none of them move controller state until the handoff commits, with only one recovery transcript created.Root Cause
The session file layer already serialized individual file writes per path, but controller snapshot recovery also updates controller-owned state outside that file lock: the active session path, guardian path, checkpoints, and rewrite baseline. If another snapshot entered while the first recovery handoff was still in progress, it could observe the stale path or baseline and treat the same in-memory transcript as a new snapshot conflict. In desktop autosave/action-save overlap, that produced repeated recovery branches and duplicate recovery notices for one conversation.
The same tear exists on the swap side:
snapshotreadssessionPathand the executor session as two separate steps, and the session-swap paths mutated both without any coordination. A swap landing between the two reads pairs the old path with the new session — best case that manufactures the same spurious conflict/recovery cascade (includingadoptDiskSessionsilently reverting a just-completed switch); worst case an append-shaped content overlap writes one transcript's messages into another transcript's file, because the freshly loaded session has no persisted baseline for the old path and the revision CAS is disarmed.replaceSessionAfterCancelwas a second save/recovery handoff running entirely outside the lock, andClearSessionhad a window where a racing save could resurrect the just-removed transcript.Solution
Make
Controller.snapshotserialize the full save/recovery/meta-update sequence with a controller-localsnapshotMu. The lock is scoped to the controller and does not holdApp.mu, so recovery callbacks can still update desktop tab state without a lock-order inversion. Once the first recovery commits the new path and baseline, later snapshots see the updated controller state and save normally instead of deriving another recovery branch.Extend the same mutex over the swap sections of the session lifecycle paths and over the whole cancel-flush cleanup — including the in-memory truncation, so a save already holding the lock can never capture a half-cleaned transcript and adopt the longer on-disk partial back over it.
ClearSessionholds it from artifact removal through the swap;Resumereleases it beforerecoverInterruptedTurn/maybeColdResumePrune, which snapshot on their own (the lock is not reentrant, and the field comment now documents that constraint).Verification
go test ./internal/control -run 'TestConcurrentSnapshotsShareSingleRecoveryHandoff|TestSessionSwapWaitsForRecoveryHandoff' -count=1go test -race ./internal/control -run 'TestConcurrentSnapshotsShareSingleRecoveryHandoff|TestSessionSwapWaitsForRecoveryHandoff' -count=1go test ./internal/control -count=1andgo test -race ./internal/control -count=1go test ./...cd desktop && go test ./...