From 2795ab7dd4a99923c48b6679a9f1b543470d0250 Mon Sep 17 00:00:00 2001 From: rogutkuba Date: Sun, 12 Jul 2026 23:07:26 -0400 Subject: [PATCH] fix: rotate terminal PTY session when shell exits to avoid reconnect loop The terminal was pinned to a single hardcoded 'default' PTY session. When the backing shell exited (e.g. Ctrl-D at the prompt), that session died and the auto-reconnect looped forever re-attaching to it. The client now sends a sessionId and rotates to a fresh one on shell exit, spawning a new shell. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/routes/room/publicRoom.routes.ts | 6 +++++- .../src/components/room/terminal/Terminal.tsx | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/room/publicRoom.routes.ts b/apps/api/src/routes/room/publicRoom.routes.ts index 38d692a..d3e8635 100644 --- a/apps/api/src/routes/room/publicRoom.routes.ts +++ b/apps/api/src/routes/room/publicRoom.routes.ts @@ -124,7 +124,11 @@ export const publicRoomRouter = new Hono() const sandbox = getSandbox(ctx.env.SANDBOX, sandboxId, { normalizeId: true }); const cols = parseInt(ctx.req.query('cols') || '80'); const rows = parseInt(ctx.req.query('rows') || '24'); - return proxyTerminal(sandbox, 'default', ctx.req.raw, { cols, rows }); + // Client picks the PTY session id so it can rotate to a fresh session (and + // thus a fresh shell) after the previous shell exits, instead of endlessly + // reconnecting to a dead session. + const sessionId = ctx.req.query('sessionId') || 'default'; + return proxyTerminal(sandbox, sessionId, ctx.req.raw, { cols, rows }); }) .post( '/preview/start', diff --git a/apps/web/src/components/room/terminal/Terminal.tsx b/apps/web/src/components/room/terminal/Terminal.tsx index a9fa570..985ace9 100644 --- a/apps/web/src/components/room/terminal/Terminal.tsx +++ b/apps/web/src/components/room/terminal/Terminal.tsx @@ -14,6 +14,10 @@ export const Terminal = () => { const terminalRef = useRef(null); const xtermRef = useRef(null); const [connectionState, setConnectionState] = useState('disconnected'); + // PTY session id. Rotated whenever the backing shell exits (e.g. you exit the + // shell with Ctrl-D) so we spawn a fresh shell instead of endlessly + // reconnecting to a now-dead session. + const [sessionId, setSessionId] = useState('default'); useEffect(() => { if (!terminalRef.current) return; @@ -53,17 +57,25 @@ export const Terminal = () => { const sandboxAddon = new SandboxAddon({ getWebSocketUrl: () => { const wsUrl = API_URL.replace(/^http/, 'ws'); - return `${wsUrl}/rooms/${roomId}/public/terminal`; + return `${wsUrl}/rooms/${roomId}/public/terminal?sessionId=${encodeURIComponent(sessionId)}`; }, reconnect: true, - onStateChange: (state) => setConnectionState(state), + onStateChange: (state, error) => { + setConnectionState(state); + // The shell process ended (e.g. you exited the shell with Ctrl-D). + // Reconnecting to the same session just loops, so start a fresh one, + // which remounts this effect and spawns a new shell. + if (error && /exited/i.test(error.message)) { + setSessionId(`default-${crypto.randomUUID()}`); + } + }, }); term.loadAddon(sandboxAddon); term.open(terminalRef.current); fitAddon.fit(); - sandboxAddon.connect({ sandboxId: `s_${roomId}` }); + sandboxAddon.connect({ sandboxId: `s_${roomId}`, sessionId }); // Register programmatic input method for Run button terminalInputRef.current = (cmd: string) => { @@ -82,7 +94,7 @@ export const Terminal = () => { term.dispose(); xtermRef.current = null; }; - }, [roomId, terminalInputRef]); + }, [roomId, terminalInputRef, sessionId]); return (