* 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>
280 lines
8.3 KiB
Python
280 lines
8.3 KiB
Python
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.pty_session import RingBuffer
|
|
|
|
|
|
def test_ringbuffer_keeps_everything_under_capacity():
|
|
rb = RingBuffer(10)
|
|
rb.append(b"abc")
|
|
rb.append(b"def")
|
|
assert rb.snapshot() == b"abcdef"
|
|
assert rb.truncated is False
|
|
|
|
|
|
def test_ringbuffer_drops_oldest_over_capacity():
|
|
rb = RingBuffer(4)
|
|
rb.append(b"abcdef") # 6 bytes into a 4-byte buffer
|
|
assert rb.snapshot() == b"cdef"
|
|
assert rb.truncated is True
|
|
|
|
|
|
|
|
|
|
class FakeBridge:
|
|
"""Implements the bridge contract PtySession depends on."""
|
|
|
|
def __init__(self, chunks, *, write_result=True):
|
|
self._chunks = list(chunks) # bytes; b"" = idle tick; None = EOF
|
|
self.written = bytearray()
|
|
self.write_result = write_result
|
|
self.closed = False
|
|
self.resized = None
|
|
|
|
def read(self, timeout):
|
|
if not self._chunks:
|
|
return b"" # idle
|
|
return self._chunks.pop(0)
|
|
|
|
async def write(self, data):
|
|
if self.write_result:
|
|
self.written.extend(data)
|
|
return self.write_result
|
|
|
|
def resize(self, cols, rows):
|
|
self.resized = (cols, rows)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class FakeWS:
|
|
def __init__(self):
|
|
self.sent = [] # list of ("bytes"|"text", payload)
|
|
self.close_code = None
|
|
|
|
async def send_bytes(self, data):
|
|
self.sent.append(("bytes", bytes(data)))
|
|
|
|
async def send_text(self, text):
|
|
self.sent.append(("text", text))
|
|
|
|
async def close(self, code=1000, reason=""):
|
|
self.close_code = code
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_replays_buffer_then_streams_live():
|
|
from hermes_cli.pty_session import PtySession
|
|
bridge = FakeBridge([b"hello ", b"world", None])
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
await asyncio.sleep(0.05) # drain consumes "hello world"
|
|
ws = FakeWS()
|
|
await s.attach(ws)
|
|
replay = b"".join(p for kind, p in ws.sent if kind == "bytes")
|
|
assert replay == b"hello world"
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reattach_can_force_complete_tui_redraw_after_replay():
|
|
"""A fresh terminal cannot reconstruct a differential ANSI tail alone."""
|
|
from hermes_cli.pty_session import PtySession
|
|
|
|
bridge = FakeBridge([b"partial differential frame", b""])
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
await asyncio.sleep(0.05)
|
|
|
|
ws = FakeWS()
|
|
assert await s.attach(ws, force_redraw=True) is True
|
|
|
|
replay = b"".join(p for kind, p in ws.sent if kind == "bytes")
|
|
assert replay == b"partial differential frame"
|
|
assert bytes(bridge.written) == b"\x0c"
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_redraw_marks_session_dead_for_replacement():
|
|
from hermes_cli.pty_session import PtySession
|
|
|
|
bridge = FakeBridge([b""], write_result=False)
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
ws = FakeWS()
|
|
|
|
assert await s.attach(ws, force_redraw=True) is False
|
|
assert s.alive is False
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_serializes_input_across_socket_tasks():
|
|
from hermes_cli.pty_session import PtySession
|
|
|
|
class OrderedBridge(FakeBridge):
|
|
def __init__(self):
|
|
super().__init__([b""])
|
|
self.first_started = asyncio.Event()
|
|
self.release_first = asyncio.Event()
|
|
|
|
async def write(self, data):
|
|
if not self.written:
|
|
self.first_started.set()
|
|
await self.release_first.wait()
|
|
self.written.extend(data)
|
|
return True
|
|
|
|
bridge = OrderedBridge()
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
ws = FakeWS()
|
|
await s.attach(ws)
|
|
|
|
first = asyncio.create_task(s.write(ws, b"first"))
|
|
await bridge.first_started.wait()
|
|
second = asyncio.create_task(s.write(ws, b"second"))
|
|
await asyncio.sleep(0)
|
|
assert bytes(bridge.written) == b""
|
|
|
|
bridge.release_first.set()
|
|
assert await first is True
|
|
assert await second is True
|
|
assert bytes(bridge.written) == b"firstsecond"
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_superseded_failed_write_does_not_kill_replacement_session():
|
|
from hermes_cli.pty_session import PtySession
|
|
|
|
class SupersededBridge(FakeBridge):
|
|
def __init__(self):
|
|
super().__init__([b""])
|
|
self.old_write_started = asyncio.Event()
|
|
self.release_old_write = asyncio.Event()
|
|
self.calls = 0
|
|
|
|
async def write(self, data):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
self.old_write_started.set()
|
|
await self.release_old_write.wait()
|
|
return False
|
|
self.written.extend(data)
|
|
return True
|
|
|
|
bridge = SupersededBridge()
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
old_ws = FakeWS()
|
|
new_ws = FakeWS()
|
|
await s.attach(old_ws)
|
|
|
|
old_write = asyncio.create_task(s.write(old_ws, b"old input"))
|
|
await bridge.old_write_started.wait()
|
|
new_attach = asyncio.create_task(s.attach(new_ws, force_redraw=True))
|
|
for _ in range(10):
|
|
if s._ws is new_ws:
|
|
break
|
|
await asyncio.sleep(0)
|
|
assert s._ws is new_ws
|
|
|
|
bridge.release_old_write.set()
|
|
assert await old_write is False
|
|
assert await new_attach is True
|
|
assert s.alive is True
|
|
assert await s.write(new_ws, b"new input") is True
|
|
assert bytes(bridge.written) == b"\x0cnew input"
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detach_keeps_draining_into_buffer():
|
|
from hermes_cli.pty_session import PtySession
|
|
bridge = FakeBridge([b"one", b"", b"two"])
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
ws = FakeWS()
|
|
await s.attach(ws)
|
|
s.detach(ws)
|
|
assert s.attached is False
|
|
assert s.last_detached_at is not None
|
|
await asyncio.sleep(0.05) # "two" drains while detached
|
|
ws2 = FakeWS()
|
|
await s.attach(ws2)
|
|
replay = b"".join(p for kind, p in ws2.sent if kind == "bytes")
|
|
assert replay == b"onetwo"
|
|
await s.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eof_marks_dead_and_closes_socket_4410():
|
|
from hermes_cli.pty_session import PtySession
|
|
bridge = FakeBridge([b"bye", None])
|
|
s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
|
|
await s.start()
|
|
ws = FakeWS()
|
|
await s.attach(ws)
|
|
await asyncio.sleep(0.05) # drain hits None (EOF)
|
|
assert s.alive is False
|
|
assert ws.close_code == 4410
|
|
await s.close()
|
|
|
|
|
|
from hermes_cli.pty_session import PtySessionRegistry, RegistryFull
|
|
|
|
|
|
def make_registry(ttl=1800.0, max_sessions=16):
|
|
return PtySessionRegistry(ttl=ttl, max_sessions=max_sessions,
|
|
buffer_cap=1024, read_timeout=0.01)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_key_reattaches_same_session():
|
|
reg = make_registry()
|
|
b1 = FakeBridge([b"", b"", b""])
|
|
s1, created1 = await reg.attach_or_spawn("tok", spawn=lambda: b1)
|
|
s2, created2 = await reg.attach_or_spawn("tok", spawn=lambda: FakeBridge([]))
|
|
assert created1 is True and created2 is False
|
|
assert s1 is s2
|
|
assert s2.bridge is b1 # second spawn callable was NOT used
|
|
await reg.close_all()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_key_at_capacity_raises_when_none_reapable():
|
|
reg = make_registry(max_sessions=1)
|
|
b = FakeBridge([b"", b""])
|
|
s, _ = await reg.attach_or_spawn("a", spawn=lambda: b)
|
|
await s.attach(FakeWS()) # attached → not reapable
|
|
with pytest.raises(RegistryFull):
|
|
await reg.attach_or_spawn("b", spawn=lambda: FakeBridge([]))
|
|
await reg.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reaper_loop_invokes_reap(monkeypatch):
|
|
from hermes_cli.pty_session import run_reaper
|
|
reg = make_registry()
|
|
calls = {"n": 0}
|
|
|
|
async def fake_reap(now=None):
|
|
calls["n"] += 1
|
|
|
|
monkeypatch.setattr(reg, "reap_idle", fake_reap)
|
|
task = asyncio.create_task(run_reaper(reg, interval=0.01))
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
assert calls["n"] >= 2
|