Skip to content

Commit 5e9b279

Browse files
committed
feat: stabilize websocket lifecycle and terminal initialization
1 parent 573ac1b commit 5e9b279

2 files changed

Lines changed: 155 additions & 45 deletions

File tree

src/components/agents/remotebg/TerminalManager.vue

Lines changed: 143 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@
4444
</template>
4545

4646
<script setup lang="ts">
47-
import { ref, onMounted, onBeforeUnmount, watch, Ref, computed } from "vue";
47+
import {
48+
ref,
49+
onMounted,
50+
onBeforeUnmount,
51+
watch,
52+
Ref,
53+
computed,
54+
type WatchStopHandle,
55+
} from "vue";
4856
import { Terminal } from "@xterm/xterm";
4957
import { FitAddon } from "@xterm/addon-fit";
5058
import { uid, useQuasar } from "quasar";
@@ -63,6 +71,7 @@ interface TerminalWSMessage {
6371
};
6472
error?: string;
6573
}
74+
6675
interface ShellOption {
6776
label: string;
6877
value: string;
@@ -71,6 +80,7 @@ interface ShellOption {
7180
7281
const $q = useQuasar();
7382
const props = defineProps<{ agent_id: string; agentPlatform: string }>();
83+
7484
const loading = ref(false);
7585
const customShellPath = ref<string | null>(null);
7686
const showCustomShellDialog = ref(false);
@@ -104,63 +114,130 @@ const selectedShell = ref<string>("");
104114
105115
let term: Terminal | null = null;
106116
const fit = new FitAddon();
107-
let dataDisposable: { dispose: () => void } | null = null;
117+
118+
let inputDisposable: { dispose: () => void } | null = null;
108119
let stopResizeObserver: (() => void) | null = null;
109-
let wsReadyInterval: number | null = null;
120+
let stopWSDataWatch: WatchStopHandle | null = null;
121+
let wsReadyInterval: ReturnType<typeof setInterval> | null = null;
122+
123+
let startRequested = false;
110124
let started = false;
111125
112-
let wsData: Ref<TerminalWSMessage | null>;
113-
let wsSend: (msg: string) => void;
114-
let wsClose: () => void;
115-
let wsStatus: Ref<string>;
126+
let activeSessionId: string | null = null;
127+
128+
let wsData!: Ref<TerminalWSMessage | null>;
129+
let wsSend!: (msg: string) => void;
130+
let wsClose!: () => void;
131+
let wsStatus!: Ref<string>;
116132
117133
const xtermContainer = ref<HTMLElement | null>(null);
118134
119135
function waitForLayout(): Promise<void> {
120136
return new Promise((resolve) => {
121137
requestAnimationFrame(() => {
122-
requestAnimationFrame(() => {
123-
resolve();
124-
});
138+
requestAnimationFrame(() => resolve());
125139
});
126140
});
127141
}
128142
129-
function initWS(shell: string) {
130-
dataDisposable?.dispose();
131-
dataDisposable = null;
143+
function clearWSReadyInterval() {
144+
if (wsReadyInterval) {
145+
clearInterval(wsReadyInterval);
146+
wsReadyInterval = null;
147+
}
148+
}
149+
150+
function cleanupCurrentSession({
151+
sendKill = true,
152+
}: { sendKill?: boolean } = {}) {
153+
clearWSReadyInterval();
154+
155+
stopWSDataWatch?.();
156+
stopWSDataWatch = null;
157+
158+
inputDisposable?.dispose();
159+
inputDisposable = null;
160+
161+
startRequested = false;
162+
started = false;
163+
164+
if (sendKill) {
165+
try {
166+
wsSend?.(JSON.stringify({ action: "kill" }));
167+
} catch {}
168+
}
169+
132170
try {
133171
wsClose?.();
134172
} catch {}
135173
174+
activeSessionId = null;
175+
}
176+
177+
function markSessionReadyAndResize() {
178+
if (!term || !startRequested || started) return;
179+
180+
started = true;
181+
loading.value = false;
182+
183+
void waitForLayout().then(() => {
184+
if (!term || !started) return;
185+
fit.fit();
186+
wsSend(
187+
JSON.stringify({
188+
action: "resize",
189+
rows: term.rows,
190+
cols: term.cols,
191+
}),
192+
);
193+
});
194+
}
195+
196+
function initWS(shell: string) {
197+
cleanupCurrentSession();
198+
199+
const sessionId = uid();
200+
activeSessionId = sessionId;
201+
136202
({
137203
data: wsData,
138204
send: wsSend,
139205
close: wsClose,
140206
status: wsStatus,
141-
} = useTerminalWSConnection(props.agent_id, uid()) as {
207+
} = useTerminalWSConnection(props.agent_id, sessionId) as {
142208
data: Ref<TerminalWSMessage | null>;
143209
send: (msg: string) => void;
144210
close: () => void;
145211
status: Ref<string>;
146212
});
147213
148-
watch(wsData, (msg) => {
149-
if (!msg?.action || !term) return;
214+
stopWSDataWatch = watch(wsData, (msg) => {
215+
if (!msg || !term) return;
216+
if (activeSessionId !== sessionId) return;
150217
151218
if (msg.action === "terminal_error") {
152219
loading.value = false;
220+
startRequested = false;
221+
started = false;
153222
invalidCustomShell.value = true;
223+
154224
$q.notify({
155225
type: "negative",
156226
message: msg.error || msg.data?.error || "Shell path doesn't exist",
157227
});
228+
158229
showCustomShellDialog.value = true;
159230
pendingCustomShell.value = null;
160231
return;
161232
}
233+
162234
if (msg.data?.output) {
163235
term.write(msg.data.output);
236+
237+
if (!started) {
238+
markSessionReadyAndResize();
239+
}
240+
164241
if (pendingCustomShell.value) {
165242
customShellPath.value = pendingCustomShell.value;
166243
selectedShell.value = "custom";
@@ -169,31 +246,41 @@ function initWS(shell: string) {
169246
invalidCustomShell.value = false;
170247
}
171248
}
249+
172250
if (msg.data?.done) {
251+
started = false;
252+
startRequested = false;
253+
loading.value = false;
173254
term.write("\r\n[Session Ended]\r\n");
174255
}
175256
});
176257
177-
dataDisposable = term!.onData((d) => {
258+
inputDisposable = term!.onData((d) => {
178259
if (!started) return;
260+
if (activeSessionId !== sessionId) return;
261+
179262
wsSend(JSON.stringify({ action: "input", data: d }));
180263
});
181264
182-
const interval = setInterval(async () => {
265+
wsReadyInterval = setInterval(async () => {
266+
if (activeSessionId !== sessionId) {
267+
clearWSReadyInterval();
268+
return;
269+
}
270+
183271
if (wsStatus.value === "OPEN" && term) {
272+
clearWSReadyInterval();
273+
184274
await waitForLayout();
275+
if (!term || activeSessionId !== sessionId) return;
276+
185277
fit.fit();
278+
279+
startRequested = true;
280+
started = false;
281+
loading.value = true;
282+
186283
wsSend(JSON.stringify({ action: "start", shell }));
187-
wsSend(
188-
JSON.stringify({
189-
action: "resize",
190-
rows: term.rows,
191-
cols: term.cols,
192-
}),
193-
);
194-
started = true;
195-
loading.value = false;
196-
clearInterval(interval);
197284
}
198285
}, 50);
199286
}
@@ -211,31 +298,39 @@ function handleCustomEdit() {
211298
212299
async function onShellChange(newShell: string) {
213300
if (!term) return;
301+
214302
if (newShell === "custom") {
215303
if (selectedShell.value !== "custom") {
216304
lastSelectedShell.value = selectedShell.value;
217305
}
306+
218307
if (!customShellPath.value || invalidCustomShell.value) {
219308
showCustomShellDialog.value = true;
220309
customShellInput.value = "";
221310
return;
222311
}
312+
223313
newShell = customShellPath.value;
224314
}
315+
225316
loading.value = true;
317+
startRequested = false;
226318
started = false;
227319
term.reset();
228320
fit.fit();
229321
initWS(newShell);
230322
}
231323
232324
function startCustomShell() {
233-
if (!customShellInput.value) return;
325+
if (!customShellInput.value || !term) return;
326+
234327
loading.value = true;
328+
startRequested = false;
235329
started = false;
236330
invalidCustomShell.value = false;
237331
pendingCustomShell.value = customShellInput.value;
238-
term?.reset();
332+
333+
term.reset();
239334
fit.fit();
240335
initWS(customShellInput.value);
241336
}
@@ -259,27 +354,25 @@ const resizeWindow = useDebounceFn(async () => {
259354
if (!term || !started) return;
260355
261356
await waitForLayout();
357+
if (!term || !started) return;
358+
262359
fit.fit();
263360
wsSend(
264-
JSON.stringify({ action: "resize", rows: term.rows, cols: term.cols }),
361+
JSON.stringify({
362+
action: "resize",
363+
rows: term.rows,
364+
cols: term.cols,
365+
}),
265366
);
266367
}, 200);
267368
268369
function disconnect() {
269-
if (wsReadyInterval) clearInterval(wsReadyInterval);
270-
wsReadyInterval = null;
370+
clearWSReadyInterval();
371+
271372
stopResizeObserver?.();
272373
stopResizeObserver = null;
273374
274-
dataDisposable?.dispose();
275-
dataDisposable = null;
276-
277-
started = false;
278-
279-
try {
280-
wsSend?.(JSON.stringify({ action: "kill" }));
281-
} catch {}
282-
wsClose?.();
375+
cleanupCurrentSession();
283376
284377
term?.dispose();
285378
term = null;
@@ -289,6 +382,7 @@ function cancelCustomShell() {
289382
showCustomShellDialog.value = false;
290383
selectedShell.value = lastSelectedShell.value;
291384
loading.value = true;
385+
startRequested = false;
292386
started = false;
293387
term?.reset();
294388
fit.fit();
@@ -297,18 +391,22 @@ function cancelCustomShell() {
297391
298392
const BUILT_IN_SHELLS = ["cmd", "powershell", "bash"] as const;
299393
type BuiltInShell = (typeof BUILT_IN_SHELLS)[number];
394+
300395
const isBuiltInShell = (shell: string): shell is BuiltInShell => {
301396
return (BUILT_IN_SHELLS as readonly string[]).includes(shell);
302397
};
303398
304399
onMounted(async () => {
305-
setupXTerm();
400+
await setupXTerm();
401+
306402
const { stop } = useResizeObserver(xtermContainer, resizeWindow);
307403
stopResizeObserver = stop;
404+
308405
const data = await fetchAgentShell(props.agent_id);
309406
if (data) {
310407
const { default_shell, effective_default_shell } = data;
311408
const isWindows = props.agentPlatform === "windows";
409+
312410
if (default_shell === "custom") {
313411
if (isBuiltInShell(effective_default_shell)) {
314412
selectedShell.value = effective_default_shell;
@@ -324,15 +422,16 @@ onMounted(async () => {
324422
} else {
325423
selectedShell.value = effective_default_shell;
326424
lastSelectedShell.value = effective_default_shell;
327-
328425
invalidCustomShell.value = false;
329426
customShellPath.value = null;
330427
}
331428
}
429+
332430
const shellToStart =
333431
selectedShell.value === "custom"
334432
? customShellPath.value!
335433
: selectedShell.value;
434+
336435
initWS(shellToStart);
337436
});
338437

src/websocket/websocket.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function getWSUrl(path: string, token: string | null) {
1616
interface WSReturn {
1717
action: string;
1818
data: unknown;
19+
error?: string;
1920
}
2021

2122
let WSConnection: UseWebSocketReturn<string> | undefined = undefined;
@@ -90,7 +91,17 @@ export function useTerminalWSConnection(agentId: string, sessionId: string) {
9091
const { status, data, send, open, close } = connection;
9192
const parsedData = ref<WSReturn>({ action: "", data: {} });
9293
watch(data, (newValue) => {
93-
if (newValue) parsedData.value = JSON.parse(newValue);
94+
if (!newValue) return;
95+
96+
try {
97+
parsedData.value = JSON.parse(newValue);
98+
} catch {
99+
parsedData.value = {
100+
action: "",
101+
data: {},
102+
error: "Invalid websocket payload",
103+
};
104+
}
94105
});
95106

96107
return {

0 commit comments

Comments
 (0)