mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +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 defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript);
|
||||||
|
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
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
|
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
|
||||||
|
|
||||||
// Helper to check if terminal creation should be debounced
|
// Helper to check if terminal creation should be debounced
|
||||||
@@ -426,6 +469,47 @@ export function TerminalView() {
|
|||||||
fetchStatus();
|
fetchStatus();
|
||||||
}, [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
|
// Fetch server settings when terminal is unlocked
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminalState.isUnlocked) {
|
if (terminalState.isUnlocked) {
|
||||||
@@ -455,15 +539,23 @@ export function TerminalView() {
|
|||||||
// Update the previous project ref
|
// Update the previous project ref
|
||||||
prevProjectPathRef.current = currentPath;
|
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 no current project, just clear terminals
|
||||||
if (!currentPath) {
|
if (!currentPath) {
|
||||||
clearTerminalState();
|
killAndClear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALWAYS clear existing terminals when switching projects
|
// ALWAYS clear existing terminals when switching projects
|
||||||
// This is critical - prevents old project's terminals from "bleeding" into new project
|
// 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
|
// Check for saved layout for this project
|
||||||
const savedLayout = getPersistedTerminalLayout(currentPath);
|
const savedLayout = getPersistedTerminalLayout(currentPath);
|
||||||
@@ -650,7 +742,7 @@ export function TerminalView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restoreLayout();
|
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)
|
// Save terminal layout whenever it changes (debounced to prevent excessive writes)
|
||||||
// Also save when tabs become empty so closed terminals stay closed on refresh
|
// Also save when tabs become empty so closed terminals stay closed on refresh
|
||||||
|
|||||||
@@ -896,12 +896,17 @@ export function TerminalPanel({
|
|||||||
resizeDebounceRef.current = null;
|
resizeDebounceRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear search decorations before disposing to prevent visual artifacts
|
||||||
|
if (searchAddonRef.current) {
|
||||||
|
searchAddonRef.current.clearDecorations();
|
||||||
|
searchAddonRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (xtermRef.current) {
|
if (xtermRef.current) {
|
||||||
xtermRef.current.dispose();
|
xtermRef.current.dispose();
|
||||||
xtermRef.current = null;
|
xtermRef.current = null;
|
||||||
}
|
}
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
searchAddonRef.current = null;
|
|
||||||
setIsTerminalReady(false);
|
setIsTerminalReady(false);
|
||||||
};
|
};
|
||||||
}, []); // No dependencies - only run once on mount
|
}, []); // No dependencies - only run once on mount
|
||||||
@@ -950,6 +955,8 @@ export function TerminalPanel({
|
|||||||
// Only process scrollback if there's actual data
|
// Only process scrollback if there's actual data
|
||||||
// Don't clear if empty - prevents blank terminal issue
|
// Don't clear if empty - prevents blank terminal issue
|
||||||
if (msg.data && msg.data.length > 0) {
|
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
|
// Use reset() which is more reliable than clear() or escape sequences
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
terminal.write(msg.data);
|
terminal.write(msg.data);
|
||||||
@@ -1257,6 +1264,8 @@ export function TerminalPanel({
|
|||||||
// Update terminal theme when app theme changes (including system preference)
|
// Update terminal theme when app theme changes (including system preference)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (xtermRef.current && isTerminalReady) {
|
if (xtermRef.current && isTerminalReady) {
|
||||||
|
// Clear any search decorations first to prevent stale color artifacts
|
||||||
|
searchAddonRef.current?.clearDecorations();
|
||||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||||
xtermRef.current.options.theme = terminalTheme;
|
xtermRef.current.options.theme = terminalTheme;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user