NousResearch/hermes-agent

Dashboard PTY WebSocket file descriptor leak reports and fixes

Cluster 54028 · dashboard. Data as of

Nothing open on GitHub. Fix merged in #54123 on 28 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #54123. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #54123. That is separate from the fix event.
Still open
Nothing open on GitHub.

The members concern a file descriptor leak in the dashboard's PTY/WebSocket handling: two pull requests propose changes (racing the PTY reader against WS receive, and closing the WebSocket on child EOF), and one bug report describes an FD leak regression tied to earlier PTY reconnect PRs. The titles alone do not show whether the changes address the reported issue.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #54123 fix(dashboard): close PTY WebSocket on child EOF to stop FD leak (#54028)

    merged to main

    files: hermes_cli/web_server.py, infographic/pr-54028-pty-fd-leak/infographic.png, tests/hermes_cli/test_web_server_pty_reconnect.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -12188,26 +12188,44 @@ async def pty_ws(ws: WebSocket) -> None:
       
           # --- reader task: PTY master → WebSocket ----------------------------
           async def pump_pty_to_ws() -> None:
      -        while True:
      -            chunk = await loop.run_in_executor(
      -                None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
      -            )
      -            if chunk is None:  # EOF
      -                return
      -            if not chunk:  # no data this tick; yield control and retry
      -                await asyncio.sleep(0)
      -                continue
      +        try:
      +            while True:
      +                chunk = await loop.run_in_executor(
      +                    None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
      +                )
      +                if chunk is None:  # EOF
      +                    return
      +                if not chunk:  # no data this tick; yield control and retry
      +                    await asyncio.sleep(0)
      +                    continue
      +                try:
      +                    await ws.send_bytes(chunk)
      +                except Exception:
      +                    return
      +        finally:
      +            # The child has exited (EOF) or the send side broke.  Close the
      +            # WebSocket so the writer loop's ``ws.receive()`` returns instead
      +            # of blocking forever — otherwise, when the browser's socket is
      +            # half-open (no FIN delivered, common on macOS/launchd) the
      +            # 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.
                   try:
      -                await ws.send_bytes(chunk)
      +                await ws.close()
                   except Exception:
      -                return
      +                pass
       
           reader_task = asyncio.create_task(pump_pty_to_ws())
       
           # --- writer loop: WebSocket → PTY master ----------------------------
           try:
               while True:
      -            msg = await ws.receive()
      +            try:
      +                msg = await ws.receive()
      +            except RuntimeError:
      +                # Raised when ws.receive() is called after the socket is
      +                # already disconnected (e.g. closed by the reader task above).
      +                break
                   msg_type = msg.get("type")
                   if msg_type == "websocket.disconnect":
                       break
    • tests/hermes_cli/test_web_server_pty_reconnect.py
      @@ -16,6 +16,7 @@
       class _OneFrameBridge:
           def __init__(self):
               self._sent = False
      +        self.closed = False
       
           @classmethod
           def spawn(cls, *args, **kwargs):
      @@ -34,7 +35,7 @@ def write(self, raw):
               pass
       
           def close(self):
      -        pass
      +        self.closed = True
       
       
       @pytest.fixture
      @@ -128,3 +129,40 @@ def fake_resolve(resume=None, sidecar_url=None, profile=None, active_session_fil
           assert captured["resume"] is None
           assert captured["active_session_file"] == str(active_file)
           assert not active_file.exists()
      +
      +
      +def test_child_eof_closes_socket_and_bridge(pty_client, monkeypatch):
      +    """Child EOF must close the WS server-side and reap the PTY.
      +
      +    Regression for the FD leak (#54028): the reader task hits EOF when the
      +    PTY child exits, but if the browser's socket is half-open (no FIN), the
      +    writer loop's ``ws.receive()`` would block forever and the PTY fds would
      +    never be closed. The reader now closes the WebSocket on EOF so the
      +    handler's ``finally`` runs ``bridge.close()``.
      +    """
      +    ws, client, token = [redacted:secret-assignment]
      +    bridges = []
      +
      +    class _RecordingBridge(_OneFrameBridge):
      +        @classmethod
      +        def spawn(cls, *args, **kwargs):
      +            b = cls()
      +            bridges.append(b)
      +            return b
      +
      +    monkeypatch.setattr(ws.PtyBridge, "spawn", _RecordingBridge.spawn)
      +    monkeypatch.setattr(
      +        ws, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
      +    )
      +
      +    # The client never sends a disconnect of its own — it only reads the one
      +    # frame then the server side must tear everything down on child EOF.
      +    with client.websocket_connect(_url(token, channel="eof-chan")) as conn:
      +        assert conn.receive_bytes() == b"ready"
      +        # Server closes the socket after the child EOFs; receiving again
      +        # surfaces the close rather than hanging.
      +        with pytest.raises(Exception):
      +            conn.receive_bytes()
      +
      +    assert len(bridges) == 1
      +    assert bridges[0].closed is True

1 PR closed without merging

From the close comments: done elsewhere 1

  • #54047 fix(dashboard): race PTY reader against WS receive to prevent FD leak (#54028) · done elsewhere → 6d879d4, #54123, #54028
Closed issues · 1
  • Closed#54028[Bug]: FD leak regression after PTY reconnect PRs (#52962 #53227)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#54123
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.