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:
+20
-5
@@ -202,11 +202,27 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
|
||||
_real_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
|
||||
class _DropTransport:
|
||||
"""Detached WS sink: keep sessions resumable without writing stale frames."""
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# Module-level stdio transport — fallback sink when no transport is bound via
|
||||
# contextvar or session. Stream resolved through a lambda so runtime monkey-
|
||||
# patches of `_real_stdout` (used extensively in tests) still land correctly.
|
||||
_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
|
||||
|
||||
# Detached websocket sessions use a drop sink instead of stdio. Desktop embeds
|
||||
# the gateway in-process and captures stdout into logs, so stale JSON-RPC frames
|
||||
# must not fall through there while the session waits for resume or reap.
|
||||
_detached_ws_transport = _DropTransport()
|
||||
|
||||
|
||||
class _SlashWorker:
|
||||
"""Persistent HermesCLI subprocess for slash commands."""
|
||||
@@ -394,16 +410,15 @@ def _teardown_session(session: dict | None) -> None:
|
||||
def _ws_session_is_orphaned(session: dict | None) -> bool:
|
||||
"""True if a WS session has no live transport and no in-flight turn.
|
||||
|
||||
After ``handle_ws`` detaches a disconnected client it points the session
|
||||
at ``_stdio_transport``. In the dashboard's in-process gateway there is no
|
||||
real stdio peer reading those frames, so a session left on the stdio
|
||||
transport (and not mid-turn) is genuinely orphaned and safe to reap.
|
||||
After ``handle_ws`` detaches a disconnected client it points the session at
|
||||
``_detached_ws_transport``. A session left on that transport (and not
|
||||
mid-turn) is genuinely orphaned and safe to reap.
|
||||
"""
|
||||
if not session or session.get("_finalized"):
|
||||
return False
|
||||
if session.get("running"):
|
||||
return False
|
||||
return session.get("transport") is _stdio_transport
|
||||
return session.get("transport") is _detached_ws_transport
|
||||
|
||||
|
||||
def _schedule_ws_orphan_reap(sid: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user