From 7869ec046acd49a4b06da6b8c5a65a3862b87c88 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 18:03:42 -0500 Subject: [PATCH] feat: enhance terminal session management and cleanup - Added functionality to collect and kill all terminal sessions on the server before clearing terminal state to prevent orphaned processes. - Implemented cleanup of terminal sessions during page unload using sendBeacon for reliable delivery. - Refactored terminal state clearing logic to ensure server sessions are terminated before switching projects. - Improved handling of search decorations to prevent visual artifacts during terminal disposal and content restoration. --- .../ui/src/components/views/terminal-view.tsx | 98 ++++++++++++++++++- .../views/terminal-view/terminal-panel.tsx | 11 ++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 36e0054d..9cefa582 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -269,6 +269,49 @@ export function TerminalView() { const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript); const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + + // Helper to collect all session IDs from all tabs + const collectAllSessionIds = useCallback((): string[] => { + const sessionIds: string[] = []; + const collectFromLayout = (node: TerminalPanelContent | null): void => { + if (!node) return; + if (node.type === "terminal") { + sessionIds.push(node.sessionId); + } else { + node.panels.forEach(collectFromLayout); + } + }; + terminalState.tabs.forEach(tab => collectFromLayout(tab.layout)); + return sessionIds; + }, [terminalState.tabs]); + + // Kill all terminal sessions on the server + // This should be called before clearTerminalState() to prevent orphaned server sessions + const killAllSessions = useCallback(async () => { + const sessionIds = collectAllSessionIds(); + if (sessionIds.length === 0) return; + + const headers: Record = {}; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`); + + // Kill all sessions in parallel + await Promise.allSettled( + sessionIds.map(async (sessionId) => { + try { + await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { + method: "DELETE", + headers, + }); + } catch (err) { + console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); + } + }) + ); + }, [collectAllSessionIds, terminalState.authToken, serverUrl]); const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation // Helper to check if terminal creation should be debounced @@ -426,6 +469,47 @@ export function TerminalView() { fetchStatus(); }, [fetchStatus]); + // Clean up all terminal sessions when the page/app is about to close + // This prevents orphaned PTY processes on the server + useEffect(() => { + const handleBeforeUnload = () => { + // Use sendBeacon for reliable delivery during page unload + // Fall back to sync fetch if sendBeacon is not available + const sessionIds = collectAllSessionIds(); + if (sessionIds.length === 0) return; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + // Try to use the bulk delete endpoint if available, otherwise delete individually + // Using sendBeacon for reliability during page unload + sessionIds.forEach((sessionId) => { + const url = `${serverUrl}/api/terminal/sessions/${sessionId}`; + // sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest + // which is more reliable during page unload than fetch + try { + const xhr = new XMLHttpRequest(); + xhr.open("DELETE", url, false); // synchronous + if (terminalState.authToken) { + xhr.setRequestHeader("X-Terminal-Token", terminalState.authToken); + } + xhr.send(); + } catch { + // Ignore errors during unload - best effort cleanup + } + }); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [collectAllSessionIds, terminalState.authToken, serverUrl]); + // Fetch server settings when terminal is unlocked useEffect(() => { if (terminalState.isUnlocked) { @@ -455,15 +539,23 @@ export function TerminalView() { // Update the previous project ref prevProjectPathRef.current = currentPath; + // Helper to kill sessions and clear state + const killAndClear = async () => { + // Kill all server-side sessions first to prevent orphaned processes + await killAllSessions(); + clearTerminalState(); + }; + // If no current project, just clear terminals if (!currentPath) { - clearTerminalState(); + killAndClear(); return; } // ALWAYS clear existing terminals when switching projects // This is critical - prevents old project's terminals from "bleeding" into new project - clearTerminalState(); + // We need to kill server sessions first to prevent orphans + killAndClear(); // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); @@ -650,7 +742,7 @@ export function TerminalView() { }; restoreLayout(); - }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); + }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl, killAllSessions]); // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 95520ef7..102e07a4 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -896,12 +896,17 @@ export function TerminalPanel({ resizeDebounceRef.current = null; } + // Clear search decorations before disposing to prevent visual artifacts + if (searchAddonRef.current) { + searchAddonRef.current.clearDecorations(); + searchAddonRef.current = null; + } + if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; - searchAddonRef.current = null; setIsTerminalReady(false); }; }, []); // No dependencies - only run once on mount @@ -950,6 +955,8 @@ export function TerminalPanel({ // Only process scrollback if there's actual data // Don't clear if empty - prevents blank terminal issue if (msg.data && msg.data.length > 0) { + // Clear any stale search decorations before restoring content + searchAddonRef.current?.clearDecorations(); // Use reset() which is more reliable than clear() or escape sequences terminal.reset(); terminal.write(msg.data); @@ -1257,6 +1264,8 @@ export function TerminalPanel({ // Update terminal theme when app theme changes (including system preference) useEffect(() => { if (xtermRef.current && isTerminalReady) { + // Clear any search decorations first to prevent stale color artifacts + searchAddonRef.current?.clearDecorations(); const terminalTheme = getTerminalTheme(resolvedTheme); xtermRef.current.options.theme = terminalTheme; }