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 (