Skip to content

Commit 92068d3

Browse files
committed
fix(terminal): prevent restoring stale terminal sessions
1 parent 8385dee commit 92068d3

File tree

2 files changed

+164
-67
lines changed

2 files changed

+164
-67
lines changed

src/components/terminal/terminal.js

Lines changed: 86 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -657,47 +657,98 @@ export default class TerminalComponent {
657657

658658
const wsUrl = `ws://localhost:${this.options.port}/terminals/${pid}`;
659659

660-
this.websocket = new WebSocket(wsUrl);
661-
662-
this.websocket.onopen = () => {
663-
this.isConnected = true;
664-
this.onConnect?.();
665-
666-
// Load attach addon after connection
667-
this.attachAddon = new AttachAddon(this.websocket);
668-
this.terminal.loadAddon(this.attachAddon);
669-
this.terminal.unicode.activeVersion = "11";
660+
await new Promise((resolve, reject) => {
661+
const websocket = new WebSocket(wsUrl);
662+
const CONNECT_TIMEOUT = 5000;
663+
let settled = false;
664+
let hasOpened = false;
665+
666+
this.websocket = websocket;
667+
668+
const rejectInitialConnect = (message, error) => {
669+
if (settled || hasOpened) return;
670+
settled = true;
671+
this.isConnected = false;
672+
try {
673+
websocket.close();
674+
} catch {}
675+
reject(error || new Error(message));
676+
};
670677

671-
// Focus terminal and ensure it's ready
672-
this.terminal.focus();
673-
this.fit();
674-
};
678+
const connectionTimeout = setTimeout(() => {
679+
rejectInitialConnect(
680+
`Timed out while connecting to terminal session ${pid}`,
681+
);
682+
}, CONNECT_TIMEOUT);
683+
684+
websocket.onopen = () => {
685+
clearTimeout(connectionTimeout);
686+
hasOpened = true;
687+
this.isConnected = true;
688+
this.onConnect?.();
689+
690+
// Load attach addon after connection
691+
this.attachAddon = new AttachAddon(websocket);
692+
this.terminal.loadAddon(this.attachAddon);
693+
this.terminal.unicode.activeVersion = "11";
694+
695+
// Focus terminal and ensure it's ready
696+
this.terminal.focus();
697+
this.fit();
698+
699+
if (!settled) {
700+
settled = true;
701+
resolve();
702+
}
703+
};
675704

676-
this.websocket.onmessage = (event) => {
677-
// Handle text messages (exit events)
678-
if (typeof event.data === "string") {
679-
try {
680-
const message = JSON.parse(event.data);
681-
if (message.type === "exit") {
682-
this.onProcessExit?.(message.data);
683-
return;
705+
websocket.onmessage = (event) => {
706+
// Handle text messages (exit events)
707+
if (typeof event.data === "string") {
708+
try {
709+
const message = JSON.parse(event.data);
710+
if (message.type === "exit") {
711+
this.onProcessExit?.(message.data);
712+
return;
713+
}
714+
} catch (error) {
715+
// Not a JSON message, let attachAddon handle it
684716
}
685-
} catch (error) {
686-
// Not a JSON message, let attachAddon handle it
687717
}
688-
}
689-
// For binary data or non-exit text messages, let attachAddon handle them
690-
};
718+
// For binary data or non-exit text messages, let attachAddon handle them
719+
};
691720

692-
this.websocket.onclose = (event) => {
693-
this.isConnected = false;
694-
this.onDisconnect?.();
695-
};
721+
websocket.onclose = (event) => {
722+
clearTimeout(connectionTimeout);
723+
this.isConnected = false;
724+
725+
if (!hasOpened) {
726+
const code = event?.code ? ` (code ${event.code})` : "";
727+
const reason = event?.reason ? `: ${event.reason}` : "";
728+
rejectInitialConnect(
729+
`Terminal session ${pid} is unavailable${code}${reason}`,
730+
);
731+
return;
732+
}
696733

697-
this.websocket.onerror = (error) => {
698-
console.error("WebSocket error:", error);
699-
this.onError?.(error);
700-
};
734+
this.onDisconnect?.();
735+
};
736+
737+
websocket.onerror = (error) => {
738+
console.error("WebSocket error:", error);
739+
740+
if (!hasOpened) {
741+
clearTimeout(connectionTimeout);
742+
rejectInitialConnect(
743+
`Failed to connect to terminal session ${pid}`,
744+
new Error(`Failed to connect to terminal session ${pid}`),
745+
);
746+
return;
747+
}
748+
749+
this.onError?.(error);
750+
};
751+
});
701752
}
702753

703754
/**

src/components/terminal/terminalManager.js

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,31 +50,74 @@ class TerminalManager {
5050
return nextNumber;
5151
}
5252

53+
normalizePersistedSessions(stored) {
54+
if (!Array.isArray(stored)) return [];
55+
56+
const sessions = stored
57+
.map((entry) => {
58+
if (!entry) return null;
59+
if (typeof entry === "string") {
60+
return { pid: entry, name: `Terminal ${entry}` };
61+
}
62+
if (typeof entry === "object" && entry.pid) {
63+
const pid = String(entry.pid);
64+
return {
65+
pid,
66+
name: entry.name || `Terminal ${pid}`,
67+
};
68+
}
69+
return null;
70+
})
71+
.filter(Boolean);
72+
const uniqueSessions = [];
73+
const seenPids = new Set();
74+
75+
for (const session of sessions) {
76+
const pid = String(session.pid);
77+
if (seenPids.has(pid)) continue;
78+
seenPids.add(pid);
79+
uniqueSessions.push({
80+
pid,
81+
name:
82+
typeof session.name === "string" && session.name.trim()
83+
? session.name.trim()
84+
: `Terminal ${pid}`,
85+
});
86+
}
87+
88+
return uniqueSessions;
89+
}
90+
91+
readPersistedSessions() {
92+
try {
93+
return this.normalizePersistedSessions(
94+
helpers.parseJSON(localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY)),
95+
);
96+
} catch (error) {
97+
console.error("Failed to read persisted terminal sessions:", error);
98+
return [];
99+
}
100+
}
101+
53102
async getPersistedSessions() {
54103
try {
104+
const sessions = this.readPersistedSessions();
105+
if (!sessions.length) return [];
106+
107+
if (!(await Terminal.isAxsRunning())) {
108+
// Once the backend is gone, previously persisted PIDs are invalid.
109+
this.savePersistedSessions([]);
110+
return [];
111+
}
112+
55113
const stored = helpers.parseJSON(
56114
localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY),
57115
);
58-
if (!Array.isArray(stored)) return [];
59-
if (!(await Terminal.isAxsRunning())) {
60-
return [];
116+
if (Array.isArray(stored) && sessions.length !== stored.length) {
117+
this.savePersistedSessions(sessions);
61118
}
62-
return stored
63-
.map((entry) => {
64-
if (!entry) return null;
65-
if (typeof entry === "string") {
66-
return { pid: entry, name: `Terminal ${entry}` };
67-
}
68-
if (typeof entry === "object" && entry.pid) {
69-
const pid = String(entry.pid);
70-
return {
71-
pid,
72-
name: entry.name || `Terminal ${pid}`,
73-
};
74-
}
75-
return null;
76-
})
77-
.filter(Boolean);
119+
120+
return sessions;
78121
} catch (error) {
79122
console.error("Failed to read persisted terminal sessions:", error);
80123
return [];
@@ -96,7 +139,7 @@ class TerminalManager {
96139
if (!pid) return;
97140

98141
const pidStr = String(pid);
99-
const sessions = await this.getPersistedSessions();
142+
const sessions = this.readPersistedSessions();
100143
const existingIndex = sessions.findIndex(
101144
(session) => session.pid === pidStr,
102145
);
@@ -121,7 +164,7 @@ class TerminalManager {
121164
if (!pid) return;
122165

123166
const pidStr = String(pid);
124-
const sessions = await this.getPersistedSessions();
167+
const sessions = this.readPersistedSessions();
125168
const nextSessions = sessions.filter((session) => session.pid !== pidStr);
126169

127170
if (nextSessions.length !== sessions.length) {
@@ -156,17 +199,17 @@ class TerminalManager {
156199
error,
157200
);
158201
failedSessions.push(session.name || session.pid);
159-
this.removePersistedSession(session.pid);
202+
await this.removePersistedSession(session.pid);
160203
}
161204
}
162205

163-
// Show alert for failed sessions (don't await to not block UI)
206+
// Stale session entries are expected after force-closes; keep startup quiet.
164207
if (failedSessions.length > 0) {
165208
const message =
166209
failedSessions.length === 1
167-
? `Failed to restore terminal: ${failedSessions[0]}`
168-
: `Failed to restore ${failedSessions.length} terminals: ${failedSessions.join(", ")}`;
169-
alert(strings["error"], message);
210+
? `Skipped unavailable terminal: ${failedSessions[0]}`
211+
: `Skipped ${failedSessions.length} unavailable terminals`;
212+
toast(message);
170213
}
171214

172215
if (activeFileId && manager?.getFile) {
@@ -184,9 +227,10 @@ class TerminalManager {
184227
*/
185228
async createTerminal(options = {}) {
186229
try {
187-
const { render, serverMode, ...terminalOptions } = options;
230+
const { render, serverMode, reconnecting, ...terminalOptions } = options;
188231
const shouldRender = render !== false;
189232
const isServerMode = serverMode !== false;
233+
const isReconnecting = reconnecting === true;
190234

191235
const terminalId = `terminal_${++this.terminalCounter}`;
192236
const providedName =
@@ -305,11 +349,13 @@ class TerminalManager {
305349
}
306350

307351
// Show alert for terminal creation failure
308-
const errorMessage = error?.message || "Unknown error";
309-
alert(
310-
strings["error"],
311-
`Failed to create terminal: ${errorMessage}`,
312-
);
352+
if (!isReconnecting) {
353+
const errorMessage = error?.message || "Unknown error";
354+
alert(
355+
strings["error"],
356+
`Failed to create terminal: ${errorMessage}`,
357+
);
358+
}
313359

314360
reject(error);
315361
}

0 commit comments

Comments
 (0)