fix(terminal): prevent xterm.js crash, websocket disconnect cascade, stale sessions

Three related bugs fixed:

1. Frontend xterm.js crash: TerminalPage rendered ALL sessions with display:none
   for inactive ones. xterm.js crashes when initialized in a hidden container
   (Viewport can't read dimensions). Fix: only render the active session's
   TerminalComponent using conditional rendering.

2. Backend websocket disconnect cascade: When client disconnected (due to #1),
   the server tried to send 'connected' status on dead socket, caught the
   WebSocketDisconnect in a generic except block, then tried to close() again
   causing RuntimeError. Fix: catch WebSocketDisconnect specifically and suppress
   close() errors.

3. Stale DB sessions: After server restart, DB still had old terminal session
   rows but no in-memory sessions. list_terminal_sessions returned these ghosts,
   causing the frontend to render dead tabs. Fix: skip DB-only sessions that
   have no live in-memory counterpart.

Quality gates: pytest (15/15 passed), tsc clean, vitest (7/7 passed)
This commit is contained in:
2026-05-28 19:10:22 +02:00
parent 569c20cf63
commit 9f90624aa6
4 changed files with 45 additions and 41 deletions
+11 -5
View File
@@ -9,6 +9,7 @@ from contextlib import suppress
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel
@@ -207,6 +208,8 @@ async def _handle_terminal_websocket(
for task in pending:
task.cancel()
except WebSocketDisconnect:
logger.debug("WebSocket disconnected for instance %s", instance_id)
except Exception as exc:
logger.error(
"Terminal session error for instance %s: %s",
@@ -214,7 +217,8 @@ async def _handle_terminal_websocket(
str(exc),
exc_info=True,
)
await websocket.close(code=4000, reason=f"Error: {exc}")
with suppress(Exception):
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Detach WebSocket, don't kill session
with suppress(Exception):
@@ -403,18 +407,20 @@ async def list_terminal_sessions(
)
db_rows = result.scalars().all()
# Build response with live has_websockets flag
# Build response with live has_websockets flag.
# Skip DB-only sessions that have no live in-memory counterpart
# (e.g. after server restart).
sessions = []
for row in db_rows:
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
if not live_session:
continue
sessions.append(
{
"id": str(row.id),
"name": row.name,
"status": row.status,
"has_websockets": live_session.has_websockets()
if live_session
else False,
"has_websockets": live_session.has_websockets(),
"created_at": row.created_at.isoformat() if row.created_at else None,
"last_activity_at": row.last_activity_at.isoformat()
if row.last_activity_at