From 37134b8c1859a95cddecbfa81ccb81b240849106 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 2 Jun 2026 15:23:16 +0200 Subject: [PATCH] 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) --- apps/api/src/services/terminal_session.py | 37 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index e2609c0..a824407 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -186,10 +186,17 @@ class TerminalSession: try: 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 if not data: + # EOF: docker exec process exited + logger.debug("PTY EOF for session %s", self.session_id) + self._handle_eof() return self._add_to_buffer(data) @@ -297,6 +304,27 @@ class TerminalSession: """Get buffered output for replay.""" 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: """Write input to the PTY master.""" if self._master_fd is None or self._closed: @@ -304,8 +332,11 @@ class TerminalSession: try: os.write(self._master_fd, data) self.last_activity = time.time() - except (OSError, IOError): - pass + except (OSError, IOError) as exc: + 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: """Set the terminal size using TIOCSWINSZ."""