Files
hermes-agent/tests/hermes_cli/test_web_server_pty_idle_backoff.py
T
Gille a99340c247 fix(dashboard): prevent PTY input from blocking event loop (#93565)
* fix(dashboard): prevent PTY input from blocking event loop

* fix(win-pty): don't terminate a healthy ConPTY on write cancellation; log leaked write workers

Review follow-up to the backpressure fix.

CancelledError on WinPtyBridge.write() ran the same path as a timeout and
force-terminated the ConPTY. Cancellation means the owning socket went away
mid-write, which is the keep-alive session's normal reattach case, not a
wedged child; killing the process there defeats the PTY-outlives-socket
design. Give the in-flight write the shutdown grace window and only
terminate if it never lands.

When terminate() fails to unblock pywinpty, the worker stays parked in the
default executor. That was swallowed by a bare except; log it so a slow
thread-pool starvation is diagnosable.

---------

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
2026-09-04 20:24:24 -04:00

74 lines
1.9 KiB
Python

"""Regression: dashboard PTY pump must back off when the terminal is idle."""
from __future__ import annotations
import asyncio
from unittest.mock import patch
import pytest
from hermes_cli.web_server_chat import _legacy_pump
class _FakeBridge:
def __init__(self, reads):
self._reads = list(reads)
self.closed = False
def read(self, timeout):
if self._reads:
return self._reads.pop(0)
return None
async def write(self, data):
return True
def resize(self, cols, rows):
pass
def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_legacy_pump_sleeps_on_idle_pty():
"""An empty PTY read must not spin with ``asyncio.sleep(0)``."""
sleeps: list[float] = []
first_idle_seen = asyncio.Event()
real_sleep = asyncio.sleep
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
first_idle_seen.set()
# Yield with the real sleep so we don't recurse through the patch.
await real_sleep(0)
bridge = _FakeBridge([b"", b"", None])
class _FakeWebSocket:
def __init__(self):
self.sent: list[bytes] = []
async def send_bytes(self, data):
self.sent.append(data)
async def receive(self):
await first_idle_seen.wait()
return {"type": "websocket.disconnect"}
async def close(self):
pass
ws = _FakeWebSocket()
with patch("hermes_cli.web_server.asyncio.sleep", side_effect=fake_sleep):
# _legacy_pump is typed against starlette WebSocket; our fake is an
# intentional behavioral shim. # type: ignore[invalid-argument-type]
await _legacy_pump(ws, bridge)
assert bridge.closed
assert sleeps, "pty pump did not call asyncio.sleep on idle ticks"
assert all(d > 0 for d in sleeps), (
f"idle pump used zero or negative sleeps: {sleeps}"
)