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:
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user