fix(cli): concurrent PTY reaps skip already-popped keys

reap_idle() is reached concurrently from attach_or_spawn() and the
background run_reaper loop, and each pop is followed by an awaited
close(); while one reap is parked inside close(), a second reap reaching
the same doomed key hit dict.pop(key) without a default and raised
KeyError straight into the websocket handler. close_all() has the same
overlap window against an in-flight reap.

Pop with a default and skip keys a concurrent reap already removed, so
overlapping reaps are idempotent. Which sessions get reaped is
unchanged.

Fixes #106400

(cherry picked from commit 8ea63a845424263551487886a7526ca2f4bbd1ee)
This commit is contained in:
liuhao1024
2026-09-09 17:25:20 +08:00
committed by Teknium
parent 6c89cb032e
commit 5ddc5b6b0e
2 changed files with 112 additions and 2 deletions

View File

@@ -191,7 +191,12 @@ class PtySessionRegistry:
if not s.alive or (not s.attached and s.last_detached_at is not None and (now - s.last_detached_at) > self._ttl)
]
for key in doomed:
await self._sessions.pop(key).close()
# Reaps overlap (attach_or_spawn and the background reaper) and close()
# awaits, so a concurrent reap can have popped this key already — skip
# it instead of raising KeyError into the websocket handler.
session = self._sessions.pop(key, None)
if session is not None:
await session.close()
def _reap_one_idle_or_raise(self) -> None:
idle = [s for s in self._sessions.values() if not s.attached and s.last_detached_at is not None]
@@ -203,4 +208,8 @@ class PtySessionRegistry:
async def close_all(self) -> None:
for key in list(self._sessions):
await self._sessions.pop(key).close()
# Same overlap window as reap_idle: an in-flight reap may have popped
# a snapshot key while we awaited an earlier close().
session = self._sessions.pop(key, None)
if session is not None:
await session.close()

View File

@@ -277,3 +277,104 @@ async def test_reaper_loop_invokes_reap(monkeypatch):
except asyncio.CancelledError:
pass
assert calls["n"] >= 2
def _register_idle_session(reg, key):
from hermes_cli.pty_session import PtySession
bridge = FakeBridge([b""])
s = PtySession(key, bridge, buffer_cap=1024, read_timeout=0.01)
await_start = s.start()
return bridge, s, await_start
@pytest.mark.asyncio
async def test_concurrent_reap_idle_is_idempotent():
"""reap_idle is reached from attach_or_spawn and the run_reaper loop; both
may doom the same keys, so one reap can pop a key the other already took
while awaiting its close(). The second pop must skip, not raise."""
reg = make_registry(ttl=60.0)
bridges = []
sessions = []
for i in range(2):
bridge, s, start = _register_idle_session(reg, "k%d" % i)
await start
s.detach(None) # unattached, last_detached_at set
reg._sessions[s.key] = s
bridges.append(bridge)
sessions.append(s)
entered, release = asyncio.Event(), asyncio.Event()
original_close = sessions[0].close
async def gated_close():
entered.set()
await release.wait()
await original_close()
sessions[0].close = gated_close
far_future = time.monotonic() + 10_000 # both idle past ttl → doomed
first = asyncio.create_task(reg.reap_idle(now=far_future))
await entered.wait() # k0 popped; first reap parked in close()
await reg.reap_idle(now=far_future) # second reap hits the taken k0
release.set()
await first
assert not reg._sessions
assert all(b.closed for b in bridges)
@pytest.mark.asyncio
async def test_close_all_survives_key_popped_by_concurrent_reap():
"""close_all snapshots keys, then awaits each close(); a reap that runs
during that await can remove a later key from the snapshot."""
reg = make_registry(ttl=60.0)
bridges = []
sessions = []
for i in range(2):
bridge, s, start = _register_idle_session(reg, "k%d" % i)
await start
s.detach(None)
reg._sessions[s.key] = s
bridges.append(bridge)
sessions.append(s)
entered, release = asyncio.Event(), asyncio.Event()
original_close = sessions[0].close
async def gated_close():
entered.set()
await release.wait()
await original_close()
sessions[0].close = gated_close
closer = asyncio.create_task(reg.close_all())
await entered.wait() # close_all popped k0, parked in close()
await reg.reap_idle(now=time.monotonic() + 10_000) # pops k1 meanwhile
release.set()
await closer # k1 of the snapshot is already gone
assert not reg._sessions
assert all(b.closed for b in bridges)
@pytest.mark.asyncio
async def test_reap_idle_closes_doomed_and_keeps_attached():
reg = make_registry(ttl=60.0)
live_bridge = FakeBridge([b""])
s_live, _ = await reg.attach_or_spawn("live", spawn=lambda: live_bridge)
await s_live.attach(FakeWS()) # attached → survives the reap
dead_bridge = FakeBridge([b""])
s_dead, _ = await reg.attach_or_spawn("dead", spawn=lambda: dead_bridge)
s_dead.alive = False # dead remnant → reaped
idle_bridge = FakeBridge([b""])
s_idle, _ = await reg.attach_or_spawn("idle", spawn=lambda: idle_bridge)
s_idle.detach(None) # idle past ttl → reaped
await reg.reap_idle(now=time.monotonic() + 10_000)
assert list(reg._sessions) == ["live"]
assert dead_bridge.closed and idle_bridge.closed and not live_bridge.closed
await reg.close_all()