From 33b482ce9138f079e4c9ff127bdde6bdc4e7f967 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 20:57:33 +0200 Subject: [PATCH] fix: terminal reconnect typing and reset functionality Frontend: - Fix term.onData to use wsRef.current instead of captured ws variable - Fix fitTerminal to use wsRef.current for resize messages - Fix sendData callback to use wsRef.current - This fixes 'cannot type' after WebSocket reconnect Backend: - Add SessionRef class for mutable session reference - Update _read_loop and _write_loop to use SessionRef - Reset now updates session_ref.session instead of returning - This keeps the WebSocket alive after reset instead of closing it --- apps/api/src/api/terminal.py | 39 +++++++++++++++++++++------- apps/web/src/components/terminal.tsx | 17 +++++++----- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index c582e33..22541ea 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -15,6 +15,13 @@ router = APIRouter() logger = logging.getLogger(__name__) +class SessionRef: + """Mutable reference to a terminal session, allowing updates during reset.""" + + def __init__(self, session): + self.session = session + + @router.websocket( "/ws/tool-instances/{instance_id}/terminal", ) @@ -86,9 +93,12 @@ async def terminal_websocket( # Send connected status await websocket.send_json({"type": "status", "status": "connected"}) + # Use mutable session reference so loops can survive reset + session_ref = SessionRef(session) + # Start I/O loops and heartbeat - read_task = asyncio.create_task(_read_loop(session, websocket)) - write_task = asyncio.create_task(_write_loop(session, websocket, instance_id)) + read_task = asyncio.create_task(_read_loop(session_ref, websocket)) + write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id)) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) # Wait for either task to complete (indicating disconnect or error) @@ -114,10 +124,14 @@ async def terminal_websocket( pass -async def _read_loop(session, websocket) -> None: +async def _read_loop(session_ref: SessionRef, websocket) -> None: """Read output from the container and send to WebSocket.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue data = await session.read_output() if data: try: @@ -130,10 +144,14 @@ async def _read_loop(session, websocket) -> None: pass -async def _write_loop(session, websocket, instance_id: str) -> None: +async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: """Read input from WebSocket and send to container.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue message = await websocket.receive() if message["type"] == "websocket.receive": if "bytes" in message: @@ -163,14 +181,15 @@ async def _write_loop(session, websocket, instance_id: str) -> None: session.container_id, ) + # Update the mutable session reference so read_loop uses the new session + session_ref.session = new_session + # Attach to new session await terminal_manager.attach_websocket(new_session, websocket) await websocket.send_json({"type": "status", "status": "connected"}) - # Update session reference and restart loops - # Note: This will cause the current loops to exit - # The WebSocket handler will create new ones - return + # Continue the loop with the new session + continue except json.JSONDecodeError: pass diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index dc441aa..e3ae4ff 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -225,8 +225,9 @@ export const TerminalComponent: React.FC = ({ if (cols !== oldCols || rows !== oldRows) { termRef.current.refresh(0, rows - 1); } - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } }; @@ -244,20 +245,21 @@ export const TerminalComponent: React.FC = ({ // Handle terminal input term.onData((data) => { - if (ws.readyState !== WebSocket.OPEN) return; + const currentWs = wsRef.current; + if (currentWs?.readyState !== WebSocket.OPEN) return; // Apply active modifier to single-character input const modifier = activeModifierRef.current; if (modifier && data.length === 1) { const modified = applyModifierToChar(data, modifier); if (modified) { - ws.send(modified); + currentWs.send(modified); onModifierChange?.(null); return; } } - ws.send(data); + currentWs.send(data); }); // Handle container resize with ResizeObserver for accurate dimension tracking @@ -302,8 +304,9 @@ export const TerminalComponent: React.FC = ({ // Notify parent about terminal readiness if (onTerminalReadyRef.current) { const sendData = (data: string) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(data); } }; const focusInput = () => {