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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user