fix(dashboard): a resume target is (profile, session), not the session id alone

Session ids are only unique within a profile's store, and the keep-alive
registry is process-wide, so matching stranded PTYs on the key's resume
segment alone would let a resume in one profile close a detached PTY on the
same session id in another profile — somebody's reconnect window, with
whatever turn it had in flight. The key already carries the profile
segment; the resume arm now requires both to match the requested chat.
The lease-holder arm is unchanged: it reads the lease from the chat's own
home, so it already names the right process.

Found by Copilot's review.
This commit is contained in:
Austin Pickett
2026-10-06 09:54:50 -04:00
parent f139d661c7
commit 85db7c3a68
2 changed files with 41 additions and 13 deletions

View File

@@ -52,14 +52,16 @@ def _process_ancestors(pid: int) -> set[int]:
return set()
def _resume_target(key: str) -> str:
"""The resume target a keep-alive key was registered under.
def _key_segments(key: str) -> tuple[str, str]:
"""``(profile, resume)`` a keep-alive key was registered under.
Registry keys read ``token\\0profile\\0resume``; a key without a resume
target — a chat that was never resumed from — yields ``""``.
Registry keys read ``token\\0profile\\0resume``; a key without those
segments — a chat that was never resumed from — yields ``("", "")``.
Session ids are only unique within a profile's store, so a resume target
is identified by both.
"""
parts = key.split("\0")
return parts[2] if len(parts) >= 3 else ""
return (parts[1], parts[2]) if len(parts) >= 3 else ("", "")
class PtySession:
@@ -283,11 +285,14 @@ class PtySessionRegistry:
"""
if not resume:
return
# The requested chat is (profile, session): the same session id in
# another profile's store is a different chat whose terminal stays.
target = (_key_segments(keep_key)[0], resume)
async with self._attach_lock:
doomed = [
key for key, session in self._sessions.items()
if key != keep_key and not session.attached
and (_resume_target(key) == resume or session.hosts_pid(holder_pid))
and (_key_segments(key) == target or session.hosts_pid(holder_pid))
]
sessions = [self._sessions.pop(key) for key in doomed]
# Close outside the registry lock — a close can wait out its helpers' SIGHUP grace and

View File

@@ -556,16 +556,39 @@ async def test_close_orphaned_sessions_without_resume_target_touches_nothing():
@pytest.mark.parametrize(
"key,expected",
[
("token\0profile\0session-a", "session-a"),
("token\0\0session-b", "session-b"),
("token\0profile\0", ""),
("token", ""),
("token\0profile\0session-a", ("profile", "session-a")),
("token\0\0session-b", ("", "session-b")),
("token\0profile\0", ("profile", "")),
("token", ("", "")),
],
)
def test_resume_target_reads_the_last_key_segment(key, expected):
from hermes_cli.pty_session import _resume_target
def test_key_segments_read_profile_and_resume(key, expected):
from hermes_cli.pty_session import _key_segments
assert _resume_target(key) == expected
assert _key_segments(key) == expected
@pytest.mark.asyncio
async def test_close_orphaned_sessions_leaves_the_same_session_id_in_another_profile():
"""Session ids are per profile store: alpha's ``session-a`` is not beta's."""
from hermes_cli.pty_session import PtySession
reg = make_registry()
other_bridge = FakeBridge([b""])
current_bridge = FakeBridge([b""])
other = PtySession("old-token\0beta\0session-a", other_bridge, buffer_cap=1024, read_timeout=0.01)
current = PtySession("new-token\0alpha\0session-a", current_bridge, buffer_cap=1024, read_timeout=0.01)
await other.start()
await current.start()
other.detach(None) # beta's tab is mid-reconnect, not stranded
reg._sessions[other.key] = other
reg._sessions[current.key] = current
await reg.close_orphaned_sessions("session-a", keep_key=current.key)
assert not other_bridge.closed
assert reg._sessions[other.key] is other
await reg.close_all()
class PidBridge(FakeBridge):