Skip to content

Commit c0947df

Browse files
authored
fix(terminal): prevent restoring stale terminal sessions (Acode-Foundation#1943)
1 parent 8385dee commit c0947df

File tree

2 files changed

+203
-68
lines changed

2 files changed

+203
-68
lines changed

src/components/terminal/terminal.js

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -657,47 +657,97 @@ 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+
if (!hasOpened) {
739+
clearTimeout(connectionTimeout);
740+
rejectInitialConnect(
741+
`Failed to connect to terminal session ${pid}`,
742+
new Error(`Failed to connect to terminal session ${pid}`),
743+
);
744+
return;
745+
}
746+
747+
console.error("WebSocket error:", error);
748+
this.onError?.(error);
749+
};
750+
});
701751
}
702752

703753
/**

src/components/terminal/terminalManager.js

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

53-
async getPersistedSessions() {
53+
normalizePersistedSessions(stored) {
54+
if (!Array.isArray(stored)) {
55+
return {
56+
sessions: [],
57+
changed: stored != null,
58+
};
59+
}
60+
61+
const sessions = [];
62+
const uniqueSessions = [];
63+
const seenPids = new Set();
64+
let changed = false;
65+
66+
for (const entry of stored) {
67+
if (!entry) {
68+
changed = true;
69+
continue;
70+
}
71+
72+
if (typeof entry === "string") {
73+
sessions.push({
74+
pid: entry,
75+
name: `Terminal ${entry}`,
76+
});
77+
changed = true;
78+
continue;
79+
}
80+
81+
if (typeof entry !== "object" || !entry.pid) {
82+
changed = true;
83+
continue;
84+
}
85+
86+
const pid = String(entry.pid);
87+
const name =
88+
typeof entry.name === "string" && entry.name.trim()
89+
? entry.name.trim()
90+
: `Terminal ${pid}`;
91+
92+
if (entry.pid !== pid || entry.name !== name) {
93+
changed = true;
94+
}
95+
96+
sessions.push({ pid, name });
97+
}
98+
99+
for (const session of sessions) {
100+
const pid = String(session.pid);
101+
if (seenPids.has(pid)) {
102+
changed = true;
103+
continue;
104+
}
105+
seenPids.add(pid);
106+
uniqueSessions.push({
107+
pid,
108+
name:
109+
typeof session.name === "string" && session.name.trim()
110+
? session.name.trim()
111+
: `Terminal ${pid}`,
112+
});
113+
}
114+
115+
if (uniqueSessions.length !== stored.length) {
116+
changed = true;
117+
}
118+
119+
return {
120+
sessions: uniqueSessions,
121+
changed,
122+
};
123+
}
124+
125+
readPersistedSessions() {
54126
try {
55-
const stored = helpers.parseJSON(
56-
localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY),
127+
return this.normalizePersistedSessions(
128+
helpers.parseJSON(localStorage.getItem(TERMINAL_SESSION_STORAGE_KEY)),
57129
);
58-
if (!Array.isArray(stored)) return [];
130+
} catch (error) {
131+
console.error("Failed to read persisted terminal sessions:", error);
132+
return {
133+
sessions: [],
134+
changed: false,
135+
};
136+
}
137+
}
138+
139+
async getPersistedSessions() {
140+
try {
141+
const { sessions, changed } = this.readPersistedSessions();
142+
if (!sessions.length) {
143+
if (changed) {
144+
this.savePersistedSessions([]);
145+
}
146+
return [];
147+
}
148+
59149
if (!(await Terminal.isAxsRunning())) {
150+
// Once the backend is gone, previously persisted PIDs are invalid.
151+
this.savePersistedSessions([]);
60152
return [];
61153
}
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);
154+
155+
if (changed) {
156+
this.savePersistedSessions(sessions);
157+
}
158+
159+
return sessions;
78160
} catch (error) {
79161
console.error("Failed to read persisted terminal sessions:", error);
80162
return [];
@@ -96,7 +178,7 @@ class TerminalManager {
96178
if (!pid) return;
97179

98180
const pidStr = String(pid);
99-
const sessions = await this.getPersistedSessions();
181+
const { sessions } = this.readPersistedSessions();
100182
const existingIndex = sessions.findIndex(
101183
(session) => session.pid === pidStr,
102184
);
@@ -121,7 +203,7 @@ class TerminalManager {
121203
if (!pid) return;
122204

123205
const pidStr = String(pid);
124-
const sessions = await this.getPersistedSessions();
206+
const { sessions } = this.readPersistedSessions();
125207
const nextSessions = sessions.filter((session) => session.pid !== pidStr);
126208

127209
if (nextSessions.length !== sessions.length) {
@@ -156,17 +238,17 @@ class TerminalManager {
156238
error,
157239
);
158240
failedSessions.push(session.name || session.pid);
159-
this.removePersistedSession(session.pid);
241+
await this.removePersistedSession(session.pid);
160242
}
161243
}
162244

163-
// Show alert for failed sessions (don't await to not block UI)
245+
// Stale session entries are expected after force-closes; keep startup quiet.
164246
if (failedSessions.length > 0) {
165247
const message =
166248
failedSessions.length === 1
167-
? `Failed to restore terminal: ${failedSessions[0]}`
168-
: `Failed to restore ${failedSessions.length} terminals: ${failedSessions.join(", ")}`;
169-
alert(strings["error"], message);
249+
? `Skipped unavailable terminal: ${failedSessions[0]}`
250+
: `Skipped ${failedSessions.length} unavailable terminals`;
251+
toast(message);
170252
}
171253

172254
if (activeFileId && manager?.getFile) {
@@ -184,9 +266,10 @@ class TerminalManager {
184266
*/
185267
async createTerminal(options = {}) {
186268
try {
187-
const { render, serverMode, ...terminalOptions } = options;
269+
const { render, serverMode, reconnecting, ...terminalOptions } = options;
188270
const shouldRender = render !== false;
189271
const isServerMode = serverMode !== false;
272+
const isReconnecting = reconnecting === true;
190273

191274
const terminalId = `terminal_${++this.terminalCounter}`;
192275
const providedName =
@@ -305,11 +388,13 @@ class TerminalManager {
305388
}
306389

307390
// 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-
);
391+
if (!isReconnecting) {
392+
const errorMessage = error?.message || "Unknown error";
393+
alert(
394+
strings["error"],
395+
`Failed to create terminal: ${errorMessage}`,
396+
);
397+
}
313398

314399
reject(error);
315400
}

0 commit comments

Comments
 (0)