diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 583b83b0410..f489d43bcc6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12210,6 +12210,16 @@ async def pty_ws(ws: WebSocket) -> None: # handler never reaches its ``finally`` and the PTY's fds leak. # With dashboard auto-reconnect (#52962) every dropped socket then # stacks a fresh PTY on top of the orphaned one, exhausting fds. + # + # Reap the bridge here too (close() is idempotent): on child EOF the + # writer loop's ``finally`` is the usual closer, but if the handler + # task is cancelled the instant we close the WS, that ``finally`` + # can be skipped, leaking the PTY. Closing from the EOF path makes + # the reap independent of that cancellation race (#54028). + try: + await asyncio.to_thread(bridge.close) + except Exception: + pass try: await ws.close() except Exception: diff --git a/tests/hermes_cli/test_web_server_pty_reconnect.py b/tests/hermes_cli/test_web_server_pty_reconnect.py index 5a399407084..a4d12687383 100644 --- a/tests/hermes_cli/test_web_server_pty_reconnect.py +++ b/tests/hermes_cli/test_web_server_pty_reconnect.py @@ -165,4 +165,12 @@ def test_child_eof_closes_socket_and_bridge(pty_client, monkeypatch): conn.receive_bytes() assert len(bridges) == 1 + # bridge.close() runs in the handler's `finally` via asyncio.to_thread, + # which can lag the client-side context exit by a tick or two. Poll briefly + # instead of asserting immediately so the teardown isn't a race. + import time + + deadline = time.monotonic() + 5.0 + while not bridges[0].closed and time.monotonic() < deadline: + time.sleep(0.01) assert bridges[0].closed is True