fix(tui-gateway): close WS disconnect/reconnect session race - #77129
fix(tui-gateway): close WS disconnect/reconnect session race#77129JoaoMarcos44 wants to merge 3 commits into
Conversation
|
Thanks for isolating the disconnect/reconnect race. The production issue is present on current main: Problems
Suggested changes
This is an automated hermes-sweeper review. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Graph note (no action implied — a maintainer has already reviewed this thread).
Our triage graph places this PR in a complex with 1 related issue (#77127). They were checked against each other at the diff level and no consolidation is indicated — they address distinct causes.
Full neighbourhood: https://hermes-triage.gottz.de/?node=77129
This note exists so the relationship stays discoverable from the thread itself.
|
Addressed in 8023685: the test now seeds the session on |
Done sr |
tneemo
left a comment
There was a problem hiding this comment.
Independent verification — #77129 (tui-gateway WS disconnect/reconnect TOCTOU race)
Verified the PR head (80236851, 2 files) on a real checkout:
What the PR does: closes a TOCTOU race in _close_sessions_for_transport() (WS disconnect teardown). The function snapshotted owned sessions under _sessions_lock, released it, then closed/repointed each one without re-checking ownership — so a session.resume reconnect that rebinds session["transport"] in that window could be torn down or stomped back to the detached sentinel by the stale transport's teardown. The fix re-validates each session under _session_resume_lock immediately before acting (same lock session.resume's warm-reuse rebind takes), and moves slow teardown/timer scheduling outside both locks.
Verification results:
- ✅ Head fetched;
server.pyparses clean (13,861 lines) - ✅
test_close_sessions_for_transport_skips_session_reattached_mid_teardownpasses — the new regression test, and it's a good one: uses a_RaceLockstand-in to drive the actual interleaving (reattach strictly between snapshot and per-sid claim), and its docstring confirms it fails against the pre-fix implementation - ✅
test_close_sessions_for_transport_closes_flagged_repoints_restpasses (updated for the new_teardown_popped_sessionhelper, plus the new"a" not in _sessionsclaim assertion) - ✅ Full
test_tui_gateway_server.pysuite: 505 passed; the 9 failures are all session-reaper/ws-orphan/ttl timing tests (async scheduling, fragile on this box) — none touch_close_sessions_for_transport, same category fails without the PR - ✅ Only the two claimed files changed
Design notes:
- Correct pattern: revalidate-then-act under the same lock the other side uses (the fix is symmetric — teardown and resume now serialize on
_session_resume_lock) - Moving slow teardown outside both locks is the right call (matches the module's documented note about keeping slow work off
_session_resume_lock) - The regression test is honest: it explicitly documents why a naive version of the test would exercise nothing (sessions starting on the new transport never enter
owned_sids)
Verdict: Ready to land. Real race with a correct, minimal fix and a regression test that actually reproduces the interleaving.
This was generated by AI, Review is declarative
andrexibiza
left a comment
There was a problem hiding this comment.
Full P1 review — blockers found
Reviewed exact head 8023685121d0e5fbe5ffa1599b1329d6cb9c8e2f against current main at b5455fdd16fe608214f91149233660e1836b067c, including the disconnect/resume lock graph, teardown funnel, queued-prompt finalization, orphan reaper, current tests/CI, prior reviews, and the August 19 field report.
The central fix has the right shape: snapshot only IDs, then serialize the ownership recheck and claim under _session_resume_lock -> _sessions_lock, with slow teardown and timer work after both locks. The repaired close-path test now reaches the actual snapshot-to-claim interleaving. Two merge blockers remain.
1. Preserve current main's canonical teardown claim
The close branch hand-rolls:
del _sessions[sid]
session["_sid"] = sidThat is no longer equivalent to current main's _pop_session_by_id(sid). The canonical helper now sets session["_closing"] = True before removing the record. _drain_queued_prompt() uses that bit as its first lifecycle barrier, while _teardown_popped_session() can wait up to five seconds for the current run thread to settle.
On a rebased branch, the manual delete can therefore claim/remove a session for teardown without marking it closing. The settling run thread can enter _drain_queued_prompt(), claim and dispatch queued work from an already-unregistered session while disconnect teardown is waiting, after which teardown closes the agent underneath that new work. This bypasses a current lifecycle invariant on the exact P1 path being repaired.
Required change: after the transport-identity check, claim the close branch through _pop_session_by_id(sid) while still holding _session_resume_lock (the nested _sessions_lock is safe because it is an RLock), then call _teardown_popped_session() after releasing both locks. Add a regression proving the claim sets _closing and that a queued prompt cannot dispatch after the disconnect close claim.
2. Exercise the detach/orphan half of the reported race
The deterministic regression only creates:
{"transport": old_transport, "close_on_disconnect": True}So it proves the force-close branch, but not the issue's second failure mode: a stale disconnect stomping a resumed session back to _detached_ws_transport and scheduling orphan reap.
Add the same snapshot → reattach → locked-claim interleaving with close_on_disconnect=False, and assert all of the following:
reaped == 0anddetached == 0- the session remains registered
- its transport remains
new_transport _schedule_ws_orphan_reapis not called- teardown is not called
The production revalidation is shared, but for a session-state P1 both branch contracts need executable evidence.
Adjacent field report / topology
The August 19 background-review report should be split and linked rather than silently treated as proven by this patch. Current main tracks the review fork on the parent agent, but WS orphan liveness still checks the gateway session's running bit and the async-delegation registry; this PR only fixes the stale disconnect/resume ownership claim. That separate “orphan reaper while a background review fork is live” path needs its own deterministic repro and issue before #77127 is treated as exhausting the whole dead-session report.
Existing head CI is green, but it ran against the old base and cannot validate the current _closing contract. Rebase, preserve the canonical claim, add both regressions, and rerun the focused WS/session suites plus CI.
8023685 to
ccc0f46
Compare
…race window (NousResearch#77129) - Claim teardown via _pop_session_by_id(sid) under _session_resume_lock to preserve the session['_closing'] = True lifecycle invariant, preventing _drain_queued_prompt from dispatching work while teardown settles. - Add deterministic regression test covering the detach/orphan path (close_on_disconnect=False) under the snapshot -> reattach -> claim interleaving. - Add end-of-flow defense-in-depth safety sweep for straggler sessions and _count_orphaned_ws_sessions helper verification to prevent orphan leaks.
|
Thanks for the thorough P1 review @andrexibiza! Both blockers plus depth coverage at the start and end of the flow have been addressed and rebased on current 1. Preserved canonical teardown claim (
|
Comprehensive Stress & Lifecycle Verification EvidenceTo ensure zero regressions, zero deadlocks, and zero orphan leaks under extreme concurrency, we executed an in-depth stress suite across 6 synthetic scenarios with high contention: 1. High-Concurrency Stress Scenarios Matrix
2. Pytest Lifecycle & Concurrency Suite Results |
…d session.resume reattach _close_sessions_for_transport() snapshotted sessions owned by the disconnecting transport under _sessions_lock, released the lock, then mutated each session (close or repoint to the detached sentinel) without re-checking ownership. session.resume's warm-reuse path (_reuse_live_payload -> _live_session_payload) rebinds session["transport"] under _session_resume_lock independently, so a reconnect landing in that window got silently undone: the old transport's teardown either force-closed the just-reattached session or stomped its new transport back to _detached_ws_transport, making a live reconnect look orphaned and eligible for grace-reap. Revalidate transport ownership under the same resume_lock -> sessions_lock ordering already used by the orphan-reap timer, immediately before claiming (close) or repointing (detach) each session. Slow teardown work still runs after releasing both locks. Fixes #HPA-01
The existing regression test for this fix started both sessions already on new_transport, so they never entered owned_sids (filtered by old_transport) and the revalidation-under-lock logic this PR adds was never exercised. The test passed identically before and after the fix and proved nothing about it. Rewrite it to start the session on old_transport (so the snapshot captures it) and inject the reattach strictly between the snapshot and the per-sid claim under _session_resume_lock, via a thin wrapper around the real lock that performs the reattach on first acquire — modeling session.resume winning the lock race before teardown's revalidation runs. Confirmed this fails against the pre-fix implementation (reaped == 1, not 0) and passes against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…race window (NousResearch#77129) - Claim teardown via _pop_session_by_id(sid) under _session_resume_lock to preserve the session['_closing'] = True lifecycle invariant, preventing _drain_queued_prompt from dispatching work while teardown settles. - Add deterministic regression test covering the detach/orphan path (close_on_disconnect=False) under the snapshot -> reattach -> claim interleaving. - Add end-of-flow defense-in-depth safety sweep for straggler sessions and _count_orphaned_ws_sessions helper verification to prevent orphan leaks.
ccc0f46 to
25e05dc
Compare
Summary
session.resume's warm-reuse reattach:_close_sessions_for_transport()snapshotted owned sessions under_sessions_lock, released the lock, then closed/repointed each one without re-checking ownership — so a reconnect that rebindssession["transport"]in that window got silently undone (session force-closed, or its live transport stomped back to the detached sentinel and left to the grace reaper).session["transport"] is transportunder the same_session_resume_lock -> _sessions_lockordering already used by the grace-reap timer, immediately before claiming (close) or repointing (detach) each session. Slow teardown (_teardown_session) still runs after both locks are released.test_close_sessions_for_transport_skips_session_reattached_mid_teardown) simulating the reconnect winning the race, and updates the existing disconnect test to assert on the new claim path.Fixes #77127, Fixes #77191, Fixes #77192.
Update: consolidated with a duplicate parallel investigation (issues #77191/#77192, originally PR #77212) that found the same root cause independently. That investigation flagged that this PR's original regression test started both sessions already on
new_transport, so they never enteredowned_sids(filtered byold_transport) — the revalidation-under-lock logic added by this PR's own fix was never exercised, and the test passed identically before and after the fix. Replaced it with a version that starts the session onold_transport(so the snapshot captures it) and injects the reattach — via a thin wrapper around the real_session_resume_lock— strictly between the snapshot and the per-sid claim, matching the actual race window. Confirmed it fails against the pre-fix implementation (reaped == 1, not0) and passes against the fix. PR #77212 closed in favor of this one.Infographic:
Test plan
pytest tests/test_tui_gateway_server.py -k close_sessions_for_transport— both the existing disconnect test (updated) and the new race regression test pass.pytest tests/test_tui_gateway_ws.py tests/tui_gateway/test_gateway_owned_session_reap.py— 13 passed, no regressions in transport/orphan-reap lifecycle._schedule_ws_orphan_reap/_reap()convention (resume_lock -> sessions_lock) documented in the module to confirm no new deadlock risk.