fix(control): refuse NewSession while a turn is running#6077
Conversation
6640ea3 to
544a60e
Compare
ClearSession and Compact both guard against rotating the session out from under a running turn, but NewSession did not. Both the Submit "/new" path (go c.NewSession()) and the bot gateway invoke it asynchronously, so a mid-turn /new could swap the executor session while the run loop reads it directly and race the turn-end snapshot onto the fresh path. Mirror ClearSession's running guard in NewSession. The bot gateway cancels the in-flight turn before rotating, and that cancel is asynchronous — give it a bounded window to unwind (same 5s/10ms shape as the desktop's waitControllerStopped) and report the failure to the chat instead of claiming a new session started.
544a60e to
395aa3d
Compare
The running guard checked c.running once at entry, released c.mu, and only swapped the executor session after Snapshot(). A turn could start during that snapshot window (running was false at the check) via runGuarded/RunTurn and then have its live session replaced by SetSession — the exact race the guard meant to prevent, just moved later. Replace the point-in-time check with a rotation gate: beginRotation atomically refuses when a turn is running or another rotation holds the gate, and stays held (c.rotating) from before Snapshot() through the swap. runGuarded and RunTurn now also refuse to start while c.rotating, so turn-start and session-rotation are mutually exclusive in both directions and the run loop's session reference cannot change under it. Regression test forces the interleaving deterministically by parking NewSession inside Snapshot()'s recovery callback, then asserting a concurrent RunTurn is refused, running stays false, and the session is not mutated until the rotation completes. Verified it fails without the c.rotating checks.
|
Confirmed the P1 and fixed it in a6f96dc. The reviewer is right: the entry-time Fix: a rotation gate instead of a point-in-time read.
Regression test Verification:
|
NewSession/ClearSession closed their turn↔swap TOCTOU window with the rotation gate, but the same bare-Running()-then-mutate-later shape lived in every other interactive session-mutating entry point: - forkNamed: Running() check, then Snapshot(), then SetSession - Branch: read running, then Snapshot(), then SetSession - SwitchBranch: read running, then Branches()/LoadSession, then SetSession - Rewind: Running() check, then Session().Replace + SnapshotRewrite - Compact: Running() check, then CompactNow rewrites the live session All are dispatched from submit()'s synchronous slash-command path (or Fork wrappers), so a user can trigger them while a turn streams — the guard existed for exactly that reason, but a turn starting after the check and before the mutation slipped through, replacing/rewriting the session under the run loop. Route all five through beginRotation()/endRotation(), same as NewSession/ClearSession: the gate is held from before the snapshot/load through the swap, and runGuarded/RunTurn already refuse to start while c.rotating. Each keeps its original "while a turn is running" message on the running path and reports errRotationInProgress for the newly-closed window. None of these methods is called from inside a turn body, so the gate is never re-entered (no deadlock). TestSessionMutationsRefuseWhileRotating asserts all five (plus RunTurn) refuse while a rotation holds the gate and the live session is untouched, and that the gate cannot be claimed while a turn runs. Verified it fails if the rotating check is removed.
|
Extended the fix to close the same TOCTOU window in every other session-mutating entry point (252ff75). Auditing all live-session mutations (
All five now go through the same Safety checks I verified:
Regression test Verification: |
SummarizeFrom/SummarizeUpTo still used the bare Running() check before rewriting the live session — and their window is the widest of the class: the summarizer performs a provider round-trip before Session.Replace, leaving seconds for a turn to start and then have the log replaced under it. Reachable from desktop summarize actions, the TUI rewind menu, and serve /summarize. Route summarizeAt through beginRotation()/endRotation() from the boundary read through the post-rewrite snapshot, keeping the original "cannot summarize while a turn is running" message on the running path. Also strengthen TestSessionMutationsRefuseWhileRotating: every gated method now asserts the errRotationInProgress sentinel instead of any non-nil error, so each method's wiring is independently regression-proof (a bare-Running() revert previously slipped past the weaker checks by failing later for an unrelated reason). Verified the summarize assertions fail with the gate removed.
|
Confirmed and fixed in 203ab34. The finding is correct — and it was the worst instance of the class: Why my previous audit missed it: I enumerated direct mutation sites in controller.go (
So the gated set is now closed over all user-triggerable live-session mutations: NewSession, ClearSession, forkNamed, Branch, SwitchBranch, Rewind, Compact, SummarizeFrom/UpTo. Fix mirrors the others: Test hardening: your finding also exposed that my Verification: |
Summary
ClearSession(andCompact) refuse to run while a turn is in flight, butNewSessionhad no such guard. Both theSubmit/newpath (go c.NewSession()) and the bot gateway invoke it asynchronously, so a mid-turn/newcould swap the executor session out from under the run loop's direct session reads and race the turn-end snapshot onto the fresh path.ClearSession's running guard inController.NewSession("cannot start a new session while a turn is running"). Callers already surface errors: TUI noticesSlashNewFailed, Submit notices "new session failed", serve returns the error, desktop propagates to the frontend.waitControllerStopped) for the turn to unwind, and on failure replies 新会话创建失败 instead of claiming a new session started while silently keeping the old one.Verification
TestNewSessionRefusesWhileTurnRunning: refusal while running (path unrotated, executor session untouched), successful rotation once the turn stops.go test ./internal/control ./internal/cli ./internal/serve ./internal/bot— all green.Cache impact
Cache-impact: none — session lifecycle guard only; no provider request, system prompt, or tool schema changes.
Cache-guard: not applicable; existing NewSession/ClearSession controller tests plus the new guard test cover the change.
System-prompt-review: N/A
Follow-up to the /new triage around #5699.