Repository navigation
Scope a terminal outcome's send-gate invalidation to its own attempt - #730
Conversation
…698) A reattach takes its opening token before it disposes the client whose outcome it is retiring, so an unconditional invalidation there retires the replacement attempt itself: either aborting it at the post-dispose token check, or discarding its Connecting on an already-stale token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoScope terminal send-gate invalidation to the owning attach attempt
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
BeginAttempt runs on whichever thread called ReattachCommand.Execute, so a compare followed by Invalidate's own increment could straddle a claim and advance past it — retiring the replacement attempt the guard exists to spare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate was a flag any attempt could set after its token check passed, so a BeginAttempt landing in between handed a retired attempt an open gate onto the client it was being replaced by. Recording WHICH token opened it removes the window and leaves no invalidation path needing to remember to close it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TrySendText read the client before the token, so a claim landing between them paired a retired client with its replacement's token — which DeliverAsync then reads as current and follows with the submit CR. Bracketing the client read proves the pairing, since a client is only ever swapped after an advance. Retired() now covers every completion that publishes, so teardown rejects one racing its own generation bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An acceptance whose check passed before a claim, and whose write landed after it, set a flag only that acceptance's own delivery would clear — and a delivery declines to clear one it no longer owns, wedging the composer for the tab's whole life. Recording which token is sending settles it on the advance instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The in-flight half of CanAcceptText and the write that satisfies it were two steps, so two acceptances could both pass and the first delivery's clear would release the second while it was still writing. The only production caller is a UI-bound command and cannot overlap; this stops the shape being load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent review (codex, 4 rounds)Ran an adversarial review over four rounds, asked specifically to construct an interleaving that defeats each guard. Summary of what it found and what happened. Confirmed sound, no findings: the owner-scoped invalidation itself ("I could not construct an interleaving that defeats Fixed in response — all four are pre-existing races of the same shape, a check on one thread and the write that satisfies it on another:
Plus the send-gate itself (ae43450) and the atomic send claim (8a8c313). Accepted as won't-fix, with the reviewer agreeing: the unbounded Left open deliberately: the paste in |
Closes #698 — AI-2355
What & why
TerminalTabViewModelholds two attempt identities and takes them at different points in a reattach:BeginAttempt()claims the opening token beforeawait prevClient.DisposeAsync()(it must, so an in-flight composer delivery stops writing to the client about to die), and the generation bump lands after. In that window the retired attempt is still current by generation while the replacement already owns the token — andPublishinvalidated the token on every terminal state, so the retired attempt's own outcome retired its replacement. Symptoms: the reattach aborts at its post-dispose token check (no second client at all), or itsConnectingand laterAttachedare discarded on a stale token and the tab renders Exited over a live, attached client with the send gate shut. Not a fake-only path —AgentAttachClient.RunAsyncreturns the claimed cause, so a daemon exit frame that beats the reattach's cancel comes back asExited(code), not anOperationCanceledException.The fix scopes that invalidation to the attempt that owns it, as a single compare-and-advance. Review then surfaced three more instances of the same shape — a check on one thread and the write that satisfies it on another — so the gate and the in-flight flag are now derived from token ownership rather than flags anyone may set, and send acceptance claims its slot atomically. An advance now settles all of it, with no path left needing to remember to close anything.
Where to look
TerminalSendGateTestsasserted the defect in this exact scenario (Created.Count == 1, "the drained outcome retired the attempt"). Its sibling covering the case that must abort a reattach — an external invalidation, an agent removal, which publishes with anullowner and so still invalidates unconditionally — is unchanged and green. The two now distinguish cases they were conflating.Verification
New
An_outcome_landing_inside_the_reattach_dispose_window_leaves_the_reattach_aloneparks the reattach on its dispose viaDisposeGate, so the ordering is deterministic rather than raced. On the unpatched view model it failsExpected to be 2 but found 1.The derived gate and derived in-flight flag were mutation-tested rather than assumed: breaking each fails existing tests (2 and 1 respectively), so both are load-bearing.
Race stress on macOS arm64: 100+ consecutive clean runs of the Terminal set. A ~1-in-30 SIGSEGV of the Avalonia headless host reproduces identically on
origin/main, so it is pre-existing and unrelated.The CAS and derived-ownership windows are single interleavings across two threads with no seam to park between them; they are closed by construction, not by coverage, and no test claims otherwise.
Known follow-up, pre-existing and untouched here: the reattach path awaits
prevClient.DisposeAsync()unbounded, so a hanging dispose leaves the last terminal state up until it completes. This branch strictly improves that case (the reattach now survives it) but does not bound it.