fix: terminal EOF detection and dead session cleanup

When a tool container stops, the docker exec PTY reaches EOF. Previously,
the event-driven reader silently returned on EOF, leaving websockets
attached to a dead session. Input writes then failed silently.

Changes:
- _on_fd_readable: detect EOF (empty read) and call _handle_eof()
- _handle_eof: stop reading, mark process dead, close all websockets
  with code 4001 to force frontend reconnection
- write_input: detect write errors and trigger EOF cleanup

Quality gates: pytest (19 passed, 1 skipped)
This commit is contained in:
Alex Blank
2026-06-02 15:23:16 +02:00
parent c6b804bf0a
commit 37134b8c18
+34 -3
View File
@@ -186,10 +186,17 @@ class TerminalSession:
try: try:
data = os.read(self._master_fd, 4096) data = os.read(self._master_fd, 4096)
except (OSError, IOError): except (OSError, IOError) as exc:
logger.debug(
"PTY read error for session %s: %s", self.session_id, exc
)
self._handle_eof()
return return
if not data: if not data:
# EOF: docker exec process exited
logger.debug("PTY EOF for session %s", self.session_id)
self._handle_eof()
return return
self._add_to_buffer(data) self._add_to_buffer(data)
@@ -297,6 +304,27 @@ class TerminalSession:
"""Get buffered output for replay.""" """Get buffered output for replay."""
return b"".join(self._output_buffer) return b"".join(self._output_buffer)
def _handle_eof(self) -> None:
"""Handle PTY EOF: process died, close websockets to force reconnect."""
self._stop_reading()
# Mark process as done so is_alive() returns False
if self.process is not None and self.process.returncode is None:
# Force returncode to a non-None value since the process is dead
# but asyncio.subprocess may not have set it yet
try:
self.process._transport.close() # type: ignore[attr-defined]
except Exception:
pass
# Close all websockets to force frontend reconnection
dead_sockets = set(self._websockets)
self._websockets.clear()
for ws in dead_sockets:
try:
asyncio.create_task(ws.close(code=4001, reason="Session process exited"))
except Exception:
pass
logger.info("Session %s EOF handled, websockets closed", self.session_id)
async def write_input(self, data: bytes) -> None: async def write_input(self, data: bytes) -> None:
"""Write input to the PTY master.""" """Write input to the PTY master."""
if self._master_fd is None or self._closed: if self._master_fd is None or self._closed:
@@ -304,8 +332,11 @@ class TerminalSession:
try: try:
os.write(self._master_fd, data) os.write(self._master_fd, data)
self.last_activity = time.time() self.last_activity = time.time()
except (OSError, IOError): except (OSError, IOError) as exc:
pass logger.debug(
"PTY write error for session %s: %s", self.session_id, exc
)
self._handle_eof()
def _set_terminal_size(self, cols: int, rows: int) -> None: def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ.""" """Set the terminal size using TIOCSWINSZ."""