mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
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.
This commit is contained in:
@@ -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<string, string> = {};
|
||||
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<string, string> = {
|
||||
"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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user