diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index ef3e38f53..d8123dcf7 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -412,7 +412,7 @@ const TerminalInner = forwardRef( isAttachingSessionRef.current = false; return () => {}; - }, [hostConfig.id]); + }, [hostConfig.id, hostConfig.instanceId]); const connectionAttemptIdRef = useRef(0); const totpTimeoutRef = useRef(null); const connectionTimeoutRef = useRef(null); @@ -2886,40 +2886,34 @@ const TerminalInner = forwardRef( useEffect(() => { isMountedRef.current = true; - const currentHostId = hostConfig.id; return () => { if (!isMountedRef.current) { return; } - if ( - currentHostIdRef.current !== currentHostId && - currentHostIdRef.current !== null - ) { - isUnmountingRef.current = true; - shouldNotReconnectRef.current = true; - isReconnectingRef.current = false; - setIsConnecting(false); - if (reconnectTimeoutRef.current) - clearTimeout(reconnectTimeoutRef.current); - if (connectionTimeoutRef.current) - clearTimeout(connectionTimeoutRef.current); - if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current); - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - if (pongTimeoutRef.current) { - clearTimeout(pongTimeoutRef.current); - pongTimeoutRef.current = null; - } - - if (webSocketRef.current) { - webSocketRef.current.close(); - } - - isMountedRef.current = false; + isUnmountingRef.current = true; + shouldNotReconnectRef.current = true; + isReconnectingRef.current = false; + setIsConnecting(false); + if (reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); + if (connectionTimeoutRef.current) + clearTimeout(connectionTimeoutRef.current); + if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } + if (pongTimeoutRef.current) { + clearTimeout(pongTimeoutRef.current); + pongTimeoutRef.current = null; + } + + if (webSocketRef.current) { + webSocketRef.current.close(); + } + + isMountedRef.current = false; }; }, [hostConfig.id, hostConfig.instanceId]); diff --git a/plugins/ssh-terminal/tests/frontend/terminal/terminal-unmount.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/terminal-unmount.test.ts new file mode 100644 index 000000000..70d44647d --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/terminal-unmount.test.ts @@ -0,0 +1,75 @@ +import ts from "typescript"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw"; + +const source = ts.createSourceFile( + "Terminal.tsx", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +let effect: string | undefined; +function visit(node: ts.Node) { + if ( + ts.isCallExpression(node) && + node.expression.getText(source) === "useEffect" && + node.arguments[0]?.getText(source).includes("isMountedRef.current = true") + ) { + effect = node.arguments[0].getText(source); + } + ts.forEachChild(node, visit); +} +visit(source); +if (!effect) throw new Error("Missing terminal lifecycle effect"); +const mount = new Function( + "state", + ts.transpileModule( + ` + const { isMountedRef, hostConfig, currentHostIdRef, isUnmountingRef, + shouldNotReconnectRef, isReconnectingRef, setIsConnecting, + reconnectTimeoutRef, connectionTimeoutRef, totpTimeoutRef, + pingIntervalRef, pongTimeoutRef, webSocketRef } = state; + return (${effect})(); +`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText, +) as (state: Record) => () => void; + +afterEach(() => vi.useRealTimers()); + +describe("terminal unmount", () => { + it.each([1, 2, null])( + "closes the socket when current host is %s", + (currentHost) => { + vi.useFakeTimers(); + const timeout = () => ({ current: setTimeout(() => {}, 10000) }); + const state = { + isMountedRef: { current: false }, + hostConfig: { id: 1, instanceId: "collab-present-room" }, + currentHostIdRef: { current: currentHost }, + isUnmountingRef: { current: false }, + shouldNotReconnectRef: { current: false }, + isReconnectingRef: { current: true }, + setIsConnecting: vi.fn(), + reconnectTimeoutRef: timeout(), + connectionTimeoutRef: timeout(), + totpTimeoutRef: timeout(), + pongTimeoutRef: timeout(), + pingIntervalRef: { current: setInterval(() => {}, 1000) }, + webSocketRef: { current: { close: vi.fn() } }, + }; + const unmount = mount(state); + expect(state.isMountedRef.current).toBe(true); + unmount(); + expect(state.webSocketRef.current.close).toHaveBeenCalledOnce(); + expect(state.shouldNotReconnectRef.current).toBe(true); + expect(state.isUnmountingRef.current).toBe(true); + expect(state.isReconnectingRef.current).toBe(false); + expect(state.isMountedRef.current).toBe(false); + expect(vi.getTimerCount()).toBe(0); + unmount(); + expect(state.webSocketRef.current.close).toHaveBeenCalledOnce(); + }, + ); +});