fix(gateway): re-bind session transport to a surviving window on pop-out close

Live sessions hold one transport; a pop-out window's session.resume
rebinds it, and on pop-out close the disconnect path parked the session
on the drop sentinel — the original window never received stream events
again until a manual re-resume (#83716).

Sessions now track every transport that has shown them (viewers, stamped
in _live_session_payload). _close_sessions_for_transport re-binds to the
most recent surviving viewer instead of detaching when one exists; dead
viewers are filtered; the drop sentinel + grace reap remain the path for
the last viewer. Root cause and repro by CharlesR-sudo on #83716.
This commit is contained in:
Ayush Nangia
2026-08-14 16:47:27 +05:30
committed by Teknium
parent 3dd0ed1d38
commit f2dbd37ef9
2 changed files with 107 additions and 0 deletions
+84
View File
@@ -5110,6 +5110,90 @@ def test_finalize_session_closes_slash_worker(monkeypatch):
assert closed["count"] == 1
def test_close_transport_rebinds_session_to_remaining_viewer(monkeypatch):
"""Closing a pop-out window's transport must re-bind the session to a
still-open window instead of stranding it on the drop sentinel (#83716)."""
reap_calls = []
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
class _LiveTransport:
def write(self, *a, **k):
return True
main = _LiveTransport()
popout = _LiveTransport()
session = _session(transport=popout, running=False)
session["viewers"] = {main: 100.0, popout: 200.0}
server._sessions["multi-sid"] = session
reaped, detached = server._close_sessions_for_transport(popout)
assert reaped == 0 and detached == 0
assert session["transport"] is main
assert "multi-sid" not in reap_calls
assert server._ws_session_is_orphaned(session) is False
def test_close_transport_detaches_when_no_viewers_remain(monkeypatch):
"""The last viewer closing still lands the session on the drop sentinel
and schedules the grace reap (unchanged single-window behavior)."""
reap_calls = []
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
class _LiveTransport:
def write(self, *a, **k):
return True
only = _LiveTransport()
session = _session(transport=only, running=False)
session["viewers"] = {only: 100.0}
server._sessions["solo-sid"] = session
reaped, detached = server._close_sessions_for_transport(only)
assert reaped == 0 and detached == 1
assert session["transport"] is server._detached_ws_transport
assert reap_calls == ["solo-sid"]
def test_close_transport_skips_dead_remaining_viewers(monkeypatch):
"""A viewer whose socket is already dead must not win the re-bind."""
reap_calls = []
monkeypatch.setattr(server, "_schedule_ws_orphan_reap", lambda sid: reap_calls.append(sid))
class _LiveTransport:
def write(self, *a, **k):
return True
dead = _LiveTransport()
dead._closed = True
owner = _LiveTransport()
session = _session(transport=owner, running=False)
session["viewers"] = {dead: 100.0, owner: 200.0}
server._sessions["dead-viewer-sid"] = session
reaped, detached = server._close_sessions_for_transport(owner)
assert detached == 1
assert session["transport"] is server._detached_ws_transport
assert reap_calls == ["dead-viewer-sid"]
def test_live_session_payload_registers_transport_as_viewer():
"""Resume/activate through _live_session_payload must register the caller
as a viewer so the disconnect path has something to re-bind to (#83716)."""
class _LiveTransport:
def write(self, *a, **k):
return True
t = _LiveTransport()
session = _session(transport=server._detached_ws_transport, running=False)
server._live_session_payload("viewer-sid", session, transport=t)
assert session["transport"] is t
assert t in session.get("viewers", {})
def test_ws_orphan_reap_spares_reattached_session(monkeypatch):
"""A session that rebinds a live transport is NOT considered orphaned."""
+23
View File
@@ -1329,6 +1329,22 @@ def _close_sessions_for_transport(
# Point detached sessions at the drop sentinel (NOT real stdio) so
# _ws_session_is_orphaned recognizes them and the grace-reap can
# actually fire; a standalone `hermes --tui` keeps real _stdio.
# UNLESS another window still shows the session: multi-window
# pop-outs all register as viewers, so on disconnect re-bind the
# session to the most recent surviving viewer instead of
# stranding the original window on the sentinel (#83716).
viewers = session.get("viewers")
if viewers:
viewers.pop(transport, None)
remaining = [
(ts, v)
for v, ts in (viewers or {}).items()
if v is not transport and not _transport_is_dead(v)
]
if remaining:
remaining.sort(key=lambda kv: kv[0])
session["transport"] = remaining[-1][1]
continue
session["transport"] = _detached_ws_transport
session.pop("_client_gone_interrupt_requested", None)
detached += 1
@@ -9160,6 +9176,13 @@ def _live_session_payload(
session["cols"] = cols
if transport is not None:
session["transport"] = transport
# Track every transport that has shown this session (multi-window:
# pop-out windows each resume the same sid). The last viewer
# becomes the transport on the disconnect path so closing a
# pop-out re-binds the session to a still-open window instead of
# stranding it on the drop sentinel (#83716).
viewers = session.setdefault("viewers", {})
viewers[transport] = time.time()
if touch:
session["last_active"] = time.time()
in_memory_history = list(session.get("display_history_prefix") or []) + list(