diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 39ede08c1..b8b1b4d84 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -657,47 +657,97 @@ export default class TerminalComponent { const wsUrl = `ws://localhost:${this.options.port}/terminals/${pid}`; - this.websocket = new WebSocket(wsUrl); - - this.websocket.onopen = () => { - this.isConnected = true; - this.onConnect?.(); - - // Load attach addon after connection - this.attachAddon = new AttachAddon(this.websocket); - this.terminal.loadAddon(this.attachAddon); - this.terminal.unicode.activeVersion = "11"; + await new Promise((resolve, reject) => { + const websocket = new WebSocket(wsUrl); + const CONNECT_TIMEOUT = 5000; + let settled = false; + let hasOpened = false; + + this.websocket = websocket; + + const rejectInitialConnect = (message, error) => { + if (settled || hasOpened) return; + settled = true; + this.isConnected = false; + try { + websocket.close(); + } catch {} + reject(error || new Error(message)); + }; - // Focus terminal and ensure it's ready - this.terminal.focus(); - this.fit(); - }; + const connectionTimeout = setTimeout(() => { + rejectInitialConnect( + `Timed out while connecting to terminal session ${pid}`, + ); + }, CONNECT_TIMEOUT); + + websocket.onopen = () => { + clearTimeout(connectionTimeout); + hasOpened = true; + this.isConnected = true; + this.onConnect?.(); + + // Load attach addon after connection + this.attachAddon = new AttachAddon(websocket); + this.terminal.loadAddon(this.attachAddon); + this.terminal.unicode.activeVersion = "11"; + + // Focus terminal and ensure it's ready + this.terminal.focus(); + this.fit(); + + if (!settled) { + settled = true; + resolve(); + } + }; - this.websocket.onmessage = (event) => { - // Handle text messages (exit events) - if (typeof event.data === "string") { - try { - const message = JSON.parse(event.data); - if (message.type === "exit") { - this.onProcessExit?.(message.data); - return; + websocket.onmessage = (event) => { + // Handle text messages (exit events) + if (typeof event.data === "string") { + try { + const message = JSON.parse(event.data); + if (message.type === "exit") { + this.onProcessExit?.(message.data); + return; + } + } catch (error) { + // Not a JSON message, let attachAddon handle it } - } catch (error) { - // Not a JSON message, let attachAddon handle it } - } - // For binary data or non-exit text messages, let attachAddon handle them - }; + // For binary data or non-exit text messages, let attachAddon handle them + }; - this.websocket.onclose = (event) => { - this.isConnected = false; - this.onDisconnect?.(); - }; + websocket.onclose = (event) => { + clearTimeout(connectionTimeout); + this.isConnected = false; + + if (!hasOpened) { + const code = event?.code ? ` (code ${event.code})` : ""; + const reason = event?.reason ? `: ${event.reason}` : ""; + rejectInitialConnect( + `Terminal session ${pid} is unavailable${code}${reason}`, + ); + return; + } - this.websocket.onerror = (error) => { - console.error("WebSocket error:", error); - this.onError?.(error); - }; + this.onDisconnect?.(); + }; + + websocket.onerror = (error) => { + if (!hasOpened) { + clearTimeout(connectionTimeout); + rejectInitialConnect( + `Failed to connect to terminal session ${pid}`, + new Error(`Failed to connect to terminal session ${pid}`), + ); + return; + } + + console.error("WebSocket error:", error); + this.onError?.(error); + }; + }); } /** diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 8dc394948..174fd9b30 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -50,31 +50,113 @@ class TerminalManager { return nextNumber; } - async getPersistedSessions() { + normalizePersistedSessions(stored) { + if (!Array.isArray(stored)) { + return { + sessions: [], + changed: stored != null, + }; + } + + const sessions = []; + const uniqueSessions = []; + const seenPids = new Set(); + let changed = false; + + for (const entry of stored) { + if (!entry) { + changed = true; + continue; + } + + if (typeof entry === "string") { + sessions.push({ + pid: entry, + name: `Terminal ${entry}`, + }); + changed = true; + continue; + } + + if (typeof entry !== "object" || !entry.pid) { + changed = true; + continue; + } + + const pid = String(entry.pid); + const name = + typeof entry.name === "string" && entry.name.trim() + ? entry.name.trim() + : `Terminal ${pid}`; + + if (entry.pid !== pid || entry.name !== name) { + changed = true; + } + + sessions.push({ pid, name }); + } + + for (const session of sessions) { + const pid = String(session.pid); + if (seenPids.has(pid)) { + changed = true; + continue; + } + seenPids.add(pid); + uniqueSessions.push({ + pid, + name: + typeof session.name === "string" && session.name.trim() + ? session.name.trim() + : `Terminal ${pid}`, + }); + } + + if (uniqueSessions.length !== stored.length) { + changed = true; + } + + return { + sessions: uniqueSessions, + changed, + }; + } + + readPersistedSessions() { try { - const stored = helpers.parseJSON( - localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY), + return this.normalizePersistedSessions( + helpers.parseJSON(localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY)), ); - if (!Array.isArray(stored)) return []; + } catch (error) { + console.error("Failed to read persisted terminal sessions:", error); + return { + sessions: [], + changed: false, + }; + } + } + + async getPersistedSessions() { + try { + const { sessions, changed } = this.readPersistedSessions(); + if (!sessions.length) { + if (changed) { + this.savePersistedSessions([]); + } + return []; + } + if (!(await Terminal.isAxsRunning())) { + // Once the backend is gone, previously persisted PIDs are invalid. + this.savePersistedSessions([]); return []; } - return stored - .map((entry) => { - if (!entry) return null; - if (typeof entry === "string") { - return { pid: entry, name: `Terminal ${entry}` }; - } - if (typeof entry === "object" && entry.pid) { - const pid = String(entry.pid); - return { - pid, - name: entry.name || `Terminal ${pid}`, - }; - } - return null; - }) - .filter(Boolean); + + if (changed) { + this.savePersistedSessions(sessions); + } + + return sessions; } catch (error) { console.error("Failed to read persisted terminal sessions:", error); return []; @@ -96,7 +178,7 @@ class TerminalManager { if (!pid) return; const pidStr = String(pid); - const sessions = await this.getPersistedSessions(); + const { sessions } = this.readPersistedSessions(); const existingIndex = sessions.findIndex( (session) => session.pid === pidStr, ); @@ -121,7 +203,7 @@ class TerminalManager { if (!pid) return; const pidStr = String(pid); - const sessions = await this.getPersistedSessions(); + const { sessions } = this.readPersistedSessions(); const nextSessions = sessions.filter((session) => session.pid !== pidStr); if (nextSessions.length !== sessions.length) { @@ -156,17 +238,17 @@ class TerminalManager { error, ); failedSessions.push(session.name || session.pid); - this.removePersistedSession(session.pid); + await this.removePersistedSession(session.pid); } } - // Show alert for failed sessions (don't await to not block UI) + // Stale session entries are expected after force-closes; keep startup quiet. if (failedSessions.length > 0) { const message = failedSessions.length === 1 - ? `Failed to restore terminal: ${failedSessions[0]}` - : `Failed to restore ${failedSessions.length} terminals: ${failedSessions.join(", ")}`; - alert(strings["error"], message); + ? `Skipped unavailable terminal: ${failedSessions[0]}` + : `Skipped ${failedSessions.length} unavailable terminals`; + toast(message); } if (activeFileId && manager?.getFile) { @@ -184,9 +266,10 @@ class TerminalManager { */ async createTerminal(options = {}) { try { - const { render, serverMode, ...terminalOptions } = options; + const { render, serverMode, reconnecting, ...terminalOptions } = options; const shouldRender = render !== false; const isServerMode = serverMode !== false; + const isReconnecting = reconnecting === true; const terminalId = `terminal_${++this.terminalCounter}`; const providedName = @@ -305,11 +388,13 @@ class TerminalManager { } // Show alert for terminal creation failure - const errorMessage = error?.message || "Unknown error"; - alert( - strings["error"], - `Failed to create terminal: ${errorMessage}`, - ); + if (!isReconnecting) { + const errorMessage = error?.message || "Unknown error"; + alert( + strings["error"], + `Failed to create terminal: ${errorMessage}`, + ); + } reject(error); }