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>
147 lines
4.5 KiB
Python
147 lines
4.5 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import web_server
|
|
import hermes_cli.web_server_chat as _web_server_chat
|
|
|
|
|
|
class FakeBridge:
|
|
def __init__(self):
|
|
self.alive = True
|
|
self.accept_input = True
|
|
self.written = bytearray()
|
|
|
|
def read(self, timeout):
|
|
return b"" # idle forever
|
|
|
|
async def write(self, data):
|
|
if not self.accept_input:
|
|
return False
|
|
self.written.extend(data)
|
|
return True
|
|
|
|
def resize(self, cols, rows):
|
|
pass
|
|
|
|
def close(self):
|
|
self.alive = False
|
|
|
|
|
|
@pytest.fixture
|
|
def pty_keepalive_harness(monkeypatch):
|
|
class Spawned(list):
|
|
pass
|
|
|
|
spawned = Spawned()
|
|
spawned.bridges = []
|
|
|
|
def fake_spawn(argv, cwd=None, env=None):
|
|
b = FakeBridge()
|
|
spawned.append(argv)
|
|
spawned.bridges.append(b)
|
|
return b
|
|
|
|
monkeypatch.setattr(_web_server_chat.PtyBridge, "spawn", staticmethod(fake_spawn))
|
|
monkeypatch.setattr(_web_server_chat, "_ws_auth_reason", lambda ws: (None, "test"))
|
|
monkeypatch.setattr(_web_server_chat, "_ws_host_origin_reason", lambda ws: None)
|
|
monkeypatch.setattr(_web_server_chat, "_ws_client_reason", lambda ws: None)
|
|
|
|
async def fake_argv(**kw):
|
|
resume = "child" if kw.get("resume") == "parent" else kw.get("resume")
|
|
env = {"HERMES_TUI_RESUME": resume} if resume else {}
|
|
return (["x", resume or "fresh"], "/tmp", env)
|
|
|
|
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv_async", fake_argv)
|
|
|
|
try:
|
|
yield spawned
|
|
finally:
|
|
_web_server_chat.PTY_REGISTRY._sessions.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_token_reuses_same_session(pty_keepalive_harness):
|
|
"""Two connects with the same ?attach= token hit one spawned bridge."""
|
|
from starlette.testclient import TestClient
|
|
|
|
client = TestClient(web_server.app)
|
|
with client.websocket_connect("/api/pty?attach=TOK1") as ws1:
|
|
ws1.send_bytes(b"hi")
|
|
with client.websocket_connect("/api/pty?attach=TOK1") as ws2:
|
|
ws2.send_bytes(b"again")
|
|
assert len(pty_keepalive_harness) == 1 # reattached, did not respawn
|
|
assert bytes(pty_keepalive_harness.bridges[0].written) == b"hi\x0cagain"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stalled_input_closes_only_the_keepalive_socket(
|
|
pty_keepalive_harness,
|
|
):
|
|
from starlette.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
client = TestClient(web_server.app)
|
|
with client.websocket_connect("/api/pty?attach=TOK1") as ws:
|
|
bridge = pty_keepalive_harness.bridges[0]
|
|
bridge.accept_input = False
|
|
ws.send_bytes(b"input")
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
ws.receive_bytes()
|
|
|
|
assert exc_info.value.code == 1013
|
|
assert web_server.PTY_REGISTRY._sessions["TOK1"].alive is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_token_reuses_same_resume(pty_keepalive_harness):
|
|
from starlette.testclient import TestClient
|
|
|
|
client = TestClient(web_server.app)
|
|
with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws1:
|
|
ws1.send_bytes(b"hi")
|
|
with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws2:
|
|
ws2.send_bytes(b"again")
|
|
assert pty_keepalive_harness == [["x", "same"]]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness):
|
|
from starlette.testclient import TestClient
|
|
|
|
client = TestClient(web_server.app)
|
|
with client.websocket_connect("/api/pty?attach=TOK1&resume=parent") as ws1:
|
|
ws1.send_bytes(b"hi")
|
|
with client.websocket_connect("/api/pty?attach=TOK1&resume=child") as ws2:
|
|
ws2.send_bytes(b"again")
|
|
assert pty_keepalive_harness == [["x", "child"]]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_token_reuses_default_chat_after_active_session_fallback(
|
|
pty_keepalive_harness, tmp_path, monkeypatch
|
|
):
|
|
from starlette.testclient import TestClient
|
|
|
|
active_session_file = tmp_path / "active-session.json"
|
|
monkeypatch.setattr(
|
|
_web_server_chat,
|
|
"_active_session_file_for_channel",
|
|
lambda app, channel: active_session_file,
|
|
)
|
|
|
|
client = TestClient(web_server.app)
|
|
with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws1:
|
|
ws1.send_bytes(b"hi")
|
|
|
|
active_session_file.write_text(json.dumps({"session_id": "existing"}))
|
|
|
|
with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws2:
|
|
ws2.send_bytes(b"again")
|
|
|
|
assert pty_keepalive_harness == [["x", "fresh"]]
|