diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index c8d70f9..510e73e 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -153,13 +153,36 @@ async def _handle_terminal_websocket( target_session_id, ) if session is None: - logger.warning( - "Session %s not found for instance %s", - target_session_id, - instance_id, + # Session not in memory — may have been lost on server restart. + # Try to restore from the DB row. + db_row = await db_session.get( + TerminalSessionModel, uuid.UUID(target_session_id) ) - await websocket.close(code=4004, reason="Session not found") - return + if ( + db_row is not None + and db_row.instance_id == instance_uuid + and db_row.status != "closed" + ): + logger.info( + "Restoring terminal session %s for instance %s from DB", + target_session_id, + instance_id, + ) + session = await terminal_manager.create_session( + instance_uuid, + instance.container_id, + startup_command=startup_command, + name=db_row.name, + session_id=target_session_id, + ) + else: + logger.warning( + "Session %s not found for instance %s", + target_session_id, + instance_id, + ) + await websocket.close(code=4004, reason="Session not found") + return # Determine slot key for reset scoping key = terminal_manager._find_key_by_internal_id( instance_id, session.session_id @@ -231,6 +254,7 @@ async def _handle_terminal_websocket( async def _read_loop(session_ref: SessionRef, websocket) -> None: """Read output from the container and send to WebSocket.""" + send_failures = 0 try: while True: session = session_ref.session @@ -241,8 +265,15 @@ async def _read_loop(session_ref: SessionRef, websocket) -> None: if data: try: await websocket.send_bytes(data) + send_failures = 0 except Exception: - break + # Send failed — client may have disconnected. + # Allow a few retries before giving up so transient + # errors don't kill the whole session. + send_failures += 1 + if send_failures >= 3: + break + await asyncio.sleep(0.1) else: await asyncio.sleep(0.01) except Exception: @@ -408,19 +439,19 @@ async def list_terminal_sessions( db_rows = result.scalars().all() # Build response with live has_websockets flag. - # Skip DB-only sessions that have no live in-memory counterpart - # (e.g. after server restart). + # Include DB rows even without in-memory counterparts (e.g. after + # server restart) so the frontend can display tabs and reconnect. 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(), + "has_websockets": live_session.has_websockets() + if live_session + else False, "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 diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index 5ee12a4..dfe7ff5 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -131,6 +131,7 @@ class TerminalManager: container_id: str, startup_command: str | None = None, name: str | None = None, + session_id: str | None = None, ) -> TerminalSession: """Create a new terminal session for an instance. @@ -159,7 +160,8 @@ class TerminalManager: instance_id_str, self.MAX_SESSIONS_PER_INSTANCE ) - session_id = str(uuid.uuid4()) + if session_id is None: + session_id = str(uuid.uuid4()) session = TerminalSession( session_id=session_id, instance_id=instance_id, @@ -172,7 +174,7 @@ class TerminalManager: key = (instance_id_str, session_id) self._sessions[key] = session - # Fire-and-forget DB insert + # Fire-and-forget DB insert (skip if row already exists) asyncio.create_task( self._insert_db_session_row(session_id, instance_id, session.name) ) diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 04438fb..4fc6d4a 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -26,6 +26,7 @@ export const TerminalPage: React.FC = () => { const [isFullscreen, setIsFullscreen] = useState(false); const terminalRefs = useRef>>({}); const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); + const hasAutoCreated = useRef(false); const { sessions, @@ -39,9 +40,16 @@ export const TerminalPage: React.FC = () => { error, } = useTerminalSessions(instanceId ?? ""); - // Auto-create default session if none exist + // Auto-create default session if none exist (guard against double-fire) useEffect(() => { - if (!loading && sessions.length === 0 && !error && instanceId) { + if ( + !loading && + sessions.length === 0 && + !error && + instanceId && + !hasAutoCreated.current + ) { + hasAutoCreated.current = true; void createSession("Session 1"); } }, [loading, sessions.length, error, instanceId, createSession]); @@ -232,16 +240,13 @@ export const TerminalPage: React.FC = () => { {sessions .filter((session) => session.id === activeSessionId) .map((session) => ( -
+
handleClose(session.id)} - isMobile={true} + ref={terminalRefs.current[session.id]} + instanceId={instanceId} + sessionId={session.id} + onClose={() => handleClose(session.id)} + isMobile={true} />
))} @@ -291,10 +296,7 @@ export const TerminalPage: React.FC = () => { {sessions .filter((session) => session.id === activeSessionId) .map((session) => ( -
+