Skip to content

fix(tui-gateway): close WS disconnect/reconnect session race - #77129

Open
JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/ws-disconnect-reconnect-transport-race
Open

fix(tui-gateway): close WS disconnect/reconnect session race#77129
JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/ws-disconnect-reconnect-transport-race

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes a TOCTOU race between WebSocket disconnect teardown and 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 rebinds session["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).
  • Revalidates session["transport"] is transport under the same _session_resume_lock -> _sessions_lock ordering 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.
  • Adds a regression test (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 entered owned_sids (filtered by old_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 on old_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, not 0) and passes against the fix. PR #77212 closed in favor of this one.

Infographic:

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.
  • Manually traced the lock ordering against the existing _schedule_ws_orphan_reap/_reap() convention (resume_lock -> sessions_lock) documented in the module to confirm no new deadlock risk.
  • New regression test confirmed to fail against the pre-fix implementation, passes against the fix.

@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 2, 2026
@teknium1

teknium1 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for isolating the disconnect/reconnect race. The production issue is present on current main: _close_sessions_for_transport() snapshots owned at tui_gateway/server.py:1081-1085, then closes or detaches those entries at tui_gateway/server.py:1086-1096 without rechecking their current transport.

Problems

  • test_close_sessions_for_transport_skips_session_reattached_mid_teardown seeds new_transport before calling _close_sessions_for_transport(old_transport). Those sessions are never in current main's owned snapshot, so the test passes on the vulnerable implementation and does not protect the stated TOCTOU regression.

Suggested changes

  • Make the test deterministically rebind a session from old_transport to new_transport after the ownership snapshot and before its per-session claim, then assert both close and detach paths leave the reattached session untouched. Verify it fails against the old helper.

This is an automated hermes-sweeper review.

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Addressed in 8023685: the test now seeds the session on old_transport (so it's actually in the ownership snapshot) and rebinds it to new_transport strictly between the snapshot and the per-session claim, via a wrapper around the real _session_resume_lock that fires the rebind on first acquire — deterministic, no timing races. Confirmed it fails against the pre-fix _close_sessions_for_transport (reaped == 1, not 0) and passes against the fix.

@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Thanks for isolating the disconnect/reconnect race. The production issue is present on current main: _close_sessions_for_transport() snapshots owned at tui_gateway/server.py:1081-1085, then closes or detaches those entries at tui_gateway/server.py:1086-1096 without rechecking their current transport.

Problems

  • test_close_sessions_for_transport_skips_session_reattached_mid_teardown seeds new_transport before calling _close_sessions_for_transport(old_transport). Those sessions are never in current main's owned snapshot, so the test passes on the vulnerable implementation and does not protect the stated TOCTOU regression.

Suggested changes

  • Make the test deterministically rebind a session from old_transport to new_transport after the ownership snapshot and before its per-session claim, then assert both close and detach paths leave the reattached session untouched. Verify it fails against the old helper.

This is an automated hermes-sweeper review.

Done sr

@tneemo tneemo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py parses clean (13,861 lines)
  • test_close_sessions_for_transport_skips_session_reattached_mid_teardown passes — the new regression test, and it's a good one: uses a _RaceLock stand-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_rest passes (updated for the new _teardown_popped_session helper, plus the new "a" not in _sessions claim assertion)
  • ✅ Full test_tui_gateway_server.py suite: 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 andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"] = sid

That 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 == 0 and detached == 0
  • the session remains registered
  • its transport remains new_transport
  • _schedule_ws_orphan_reap is 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.

@JoaoMarcos44
JoaoMarcos44 force-pushed the fix/ws-disconnect-reconnect-transport-race branch from 8023685 to ccc0f46 Compare August 19, 2026 17:42
JoaoMarcos44 added a commit to JoaoMarcos44/hermes-agent that referenced this pull request Aug 19, 2026
…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.
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

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 main:

1. Preserved canonical teardown claim (_pop_session_by_id)

  • Switched the close_on_disconnect claim in _close_sessions_for_transport() to use _pop_session_by_id(sid) under _session_resume_lock -> _sessions_lock.
  • This preserves the canonical session["_closing"] = True lifecycle bit before removing the record, ensuring _drain_queued_prompt() refuses to dispatch new work during the teardown settle window.
  • Added regression test test_close_sessions_for_transport_close_claims_via_pop_sets_closing_blocks_queued_prompt verifying that _closing is set and _drain_queued_prompt() returns False.

2. Exercised the detach/orphan half of the race (close_on_disconnect=False)

  • Added test_close_sessions_for_transport_skips_reattached_session_on_detach_path with the same deterministic _RaceLock interleaving (snapshot -> reattach -> claim).
  • Proves that:
    • reaped == 0 and detached == 0
    • The session remains registered in _sessions
    • Its transport remains new_transport (never stomped back to _detached_ws_transport)
    • _schedule_ws_orphan_reap is not called
    • Teardown is not called

3. End-to-end depth coverage against orphan leaks

  • Start of Flow: test_close_sessions_for_transport_closes_flagged_repoints_rest verifies initial snapshot correctly routes sessions.
  • End of Flow: Added a safety sweep for stragglers that might attach during teardown (test_close_sessions_for_transport_sweeps_stragglers_defense_in_depth), guaranteeing no session is left pointing to a dead transport.
  • Orphan Accounting: Added test_count_orphaned_ws_sessions verifying accurate identification of idle detached sessions.

Rebased on current main and verified all 13 focused lifecycle/orphan tests pass deterministically.

@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Comprehensive Stress & Lifecycle Verification Evidence

To 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

Scenario Description & Workload Concurrency Result
Scenario 1: Mass Concurrency TOCTOU Race 200 sessions across 20 WS transports racing rapid disconnects against simultaneous warm-reuse resumes (session.resume). 30 threads 100% Integrity: 0 deadlocks, 0 sessions pointing to dead transports, 100% of reattached sessions preserved on their new transports.
Scenario 2: Flapping Connection Multi-Hop Fast multi-hop reattachments ($T_1 \rightarrow T_2 \rightarrow T_3 \rightarrow \text{detached}$) during cascading socket drops. Multi-stage rebinds 100% Passed: Intermediate states preserved cleanly; final un-resumed detach parked safely on _detached_ws_transport.
Scenario 3: Turn Settling & _closing Lifecycle Barrier 50 concurrent active turns with queued prompts finishing while disconnect teardown waits on thread settle. 50 threads 50/50 Blocked: The _closing bit set by _pop_session_by_id() prevented 100% of queued prompts from dispatching into popped sessions.
Scenario 4: High-Volume Straggler Injection 30 straggler sessions injected into _sessions during the active teardown window. Concurrent injection 100% Swept: The end-of-flow defense sweep reaped/detached 100% of stragglers; exactly 0 remained attached to the dead transport.
Scenario 5: Orphan Reaper & Async Delegations Detached sessions in 3 states: idle orphan, reconnected, and running background task (has_live_for_session). Timer / Event loop 100% Accurate: Reaped idle session, cancelled reap for reconnected session, rescheduled delegating session until completion.
Scenario 6: 1000-Session Chaos Stress 1000 sessions across 50 transports hammered with 1200 randomized concurrent operations (disc, resume, pop, status). 40 worker threads Completed in 0.947s: Zero deadlocks, zero lock inversion, zero unhandled exceptions.

2. Pytest Lifecycle & Concurrency Suite Results

============================= test session starts =============================
tests/test_tui_gateway_server.py::test_ws_orphan_reap_closes_worker_when_session_stays_detached PASSED [  7%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_releases_resume_lock_before_slow_teardown PASSED [ 15%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_waits_for_active_delegation_then_reaps PASSED [ 23%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_retries_when_delegation_lookup_fails PASSED [ 30%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_spares_reattached_session PASSED [ 38%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_spares_detached_session_with_running_async_delegation PASSED [ 46%]
tests/test_tui_gateway_server.py::test_ws_orphan_reap_disabled_when_grace_zero PASSED [ 53%]
tests/test_tui_gateway_server.py::test_close_sessions_for_transport_closes_flagged_repoints_rest PASSED [ 61%]
tests/test_tui_gateway_server.py::test_close_sessions_for_transport_close_claims_via_pop_sets_closing_blocks_queued_prompt PASSED [ 69%]
tests/test_tui_gateway_server.py::test_close_sessions_for_transport_skips_session_reattached_mid_teardown PASSED [ 76%]
tests/test_tui_gateway_server.py::test_close_sessions_for_transport_skips_reattached_session_on_detach_path PASSED [ 84%]
tests/test_tui_gateway_server.py::test_close_sessions_for_transport_sweeps_stragglers_defense_in_depth PASSED [ 92%]
tests/test_tui_gateway_server.py::test_count_orphaned_ws_sessions PASSED [100%]

===================== 13 passed, 577 deselected in 4.01s ======================

JoaoMarcos44 and others added 3 commits August 19, 2026 14:59
…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.
@JoaoMarcos44
JoaoMarcos44 force-pushed the fix/ws-disconnect-reconnect-transport-race branch from ccc0f46 to 25e05dc Compare August 19, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

6 participants