fix(tui): handle Windows PTY stdin and detached WS frames (#41953)

Two narrow Windows desktop fixes:

1. tools/process_registry.py — PTY stdin writes are now platform-aware.
   pywinpty (Windows) expects str; ptyprocess (POSIX) expects bytes.
   Previously bytes was unconditionally passed, producing a TypeError on
   Windows ("'bytes' object cannot be converted to 'PyString'").

2. tui_gateway/server.py + ws.py — Detached WebSocket sessions now park on
   a _DropTransport sink instead of _stdio_transport. In the desktop the
   gateway runs in-process and stdout is captured by Electron into
   desktop.log, so falling back to stdio leaked raw JSON-RPC frames into
   the desktop log after WS disconnects. Orphan-reap semantics are
   preserved via _ws_session_is_orphaned.

Verified on a Windows desktop install:
- pywinpty 2.0.15 rejects bytes / accepts str — reproduced exactly
- Focused suite green (write_stdin × 2, write_json_drops_detached_ws_frames,
  ws_orphan_reap × 2)
- All 6 CI test shards green, e2e green, nix (ubuntu/macos) green

Salvage commit (21be7ca) fixes the new test referencing an undefined
_ThreadUnsafeStdout — uses the existing _ChunkyStdout helper.
This commit is contained in:
qWait
2026-06-08 09:41:20 -07:00
committed by GitHub
parent 74744795af
commit cef00ae602
5 changed files with 91 additions and 19 deletions
+22 -3
View File
@@ -152,6 +152,21 @@ def test_write_json_returns_false_on_broken_pipe(monkeypatch):
assert server.write_json({"ok": True}) is False
def test_write_json_drops_detached_ws_frames(monkeypatch):
out = _ChunkyStdout()
monkeypatch.setattr(server, "_real_stdout", out)
server._sessions["detached-sid"] = {"transport": server._detached_ws_transport}
try:
assert server.write_json({
"jsonrpc": "2.0",
"method": "event",
"params": {"session_id": "detached-sid", "type": "message.delta"},
}) is False
assert out.parts == []
finally:
server._sessions.pop("detached-sid", None)
def test_tui_verbose_tool_details_fail_closed_when_redaction_fails(monkeypatch):
redact_module = types.ModuleType("agent.redact")
@@ -933,7 +948,7 @@ def test_ws_orphan_reap_closes_worker_when_session_stays_detached(monkeypatch):
closed["worker"] = True
server._sessions["orphan-sid"] = _session(
transport=server._stdio_transport,
transport=server._detached_ws_transport,
slash_worker=_FakeWorker(),
running=False,
)
@@ -992,11 +1007,15 @@ def test_ws_orphan_reap_spares_reattached_session(monkeypatch):
assert server._ws_session_is_orphaned(reattached) is False
# Mid-turn sessions are also spared even if detached.
mid_turn = _session(transport=server._stdio_transport, running=True)
mid_turn = _session(transport=server._detached_ws_transport, running=True)
assert server._ws_session_is_orphaned(mid_turn) is False
# Already finalized sessions are spared (idempotency).
done = _session(transport=server._stdio_transport, running=False, _finalized=True)
done = _session(
transport=server._detached_ws_transport,
running=False,
_finalized=True,
)
assert server._ws_session_is_orphaned(done) is False
+38
View File
@@ -63,6 +63,44 @@ def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.05) -> bool
return False
def test_write_stdin_uses_str_for_windows_pty(monkeypatch, registry):
"""pywinpty expects str input; bytes raises a PyString conversion error."""
written = []
class _FakePty:
def write(self, value):
written.append(value)
session = _make_session(sid="pty-win")
session._pty = _FakePty()
registry._running[session.id] = session
monkeypatch.setattr("tools.process_registry._IS_WINDOWS", True)
result = registry.write_stdin(session.id, "hello\n")
assert result == {"status": "ok", "bytes_written": 6}
assert written == ["hello\n"]
assert isinstance(written[0], str)
def test_write_stdin_uses_bytes_for_posix_pty(monkeypatch, registry):
written = []
class _FakePty:
def write(self, value):
written.append(value)
session = _make_session(sid="pty-posix")
session._pty = _FakePty()
registry._running[session.id] = session
monkeypatch.setattr("tools.process_registry._IS_WINDOWS", False)
result = registry.write_stdin(session.id, "hello\n")
assert result == {"status": "ok", "bytes_written": 6}
assert written == [b"hello\n"]
# =========================================================================
# Get / Poll
# =========================================================================