a99340c247
* 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>
74 lines
1.9 KiB
Python
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}"
|
|
)
|