fix(terminal): prevent double session creation, restore sessions on reload

Three fixes for multi-session terminal bugs:

1. Race-condition double creation: The auto-create effect fired twice because
   loadSessions returned 0 while an earlier createSession was still in flight.
   Added hasAutoCreated guard ref to ensure only one auto-create happens.

2. Page reload spawns new sessions: After server restart, list_terminal_sessions
   filtered out DB-only sessions (no in-memory counterpart), so the frontend
   thought no sessions existed and auto-created new ones. Reverted the filter
   so DB rows are always returned. The WebSocket handler now restores the
   in-memory session from the DB row on demand when connecting.

3. No input after connection: The backend _read_loop would break on any send
   error, causing asyncio.wait to cancel the _write_loop. Made _read_loop
   retry up to 3 times before giving up, preventing transient send errors
   from killing input handling.

Quality gates: pytest (15/15 passed), tsc clean
This commit is contained in:
2026-05-28 19:45:59 +02:00
parent 9ccaae04db
commit b6e71e32f5
3 changed files with 64 additions and 29 deletions
+43 -12
View File
@@ -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
+4 -2
View File
@@ -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)
)
+17 -15
View File
@@ -26,6 +26,7 @@ export const TerminalPage: React.FC = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
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) => (
<div
key={session.id}
className="terminal-instance active"
>
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
/>
</div>
))}
@@ -291,10 +296,7 @@ export const TerminalPage: React.FC = () => {
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div
key={session.id}
className="terminal-instance active"
>
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}