Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions apps/frontend/src/main/terminal/pty-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,43 @@ import type { SupportedTerminal } from '../../shared/types/settings';

// Windows shell paths are now imported from the platform module via getWindowsShellPaths()

/**
* Track pending exit promises for terminals being destroyed.
* Used to wait for PTY process exit on Windows where termination is async.
*/
const pendingExitPromises = new Map<string, {
resolve: () => void;
timeoutId: NodeJS.Timeout;
}>();

/**
* Default timeouts for waiting for PTY exit (in milliseconds).
* Windows needs longer timeout due to slower process termination.
*/
const PTY_EXIT_TIMEOUT_WINDOWS = 2000;
const PTY_EXIT_TIMEOUT_UNIX = 500;

/**
* Wait for a PTY process to exit.
* Returns a promise that resolves when the PTY's onExit event fires.
* Has a timeout fallback in case the exit event never fires.
*/
export function waitForPtyExit(terminalId: string, timeoutMs?: number): Promise<void> {
const timeout = timeoutMs ?? (isWindows() ? PTY_EXIT_TIMEOUT_WINDOWS : PTY_EXIT_TIMEOUT_UNIX);

return new Promise<void>((resolve) => {
// Set up timeout fallback
const timeoutId = setTimeout(() => {
debugLog('[PtyManager] PTY exit timeout for terminal:', terminalId);
pendingExitPromises.delete(terminalId);
resolve();
}, timeout);

// Store the promise resolver
pendingExitPromises.set(terminalId, { resolve, timeoutId });

This comment was marked as outdated.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent destroy with same ID causes wrong promise resolution

Medium Severity

The pendingExitPromises map is keyed only by terminal ID. If terminal A is being destroyed and a new terminal B is created with the same ID during the await, then B is also destroyed, B's promise resolver overwrites A's. When A's PTY exits, it resolves B's promise instead of A's - causing destroyTerminal(B) to return before B's PTY has actually exited. This recreates the race condition the PR is trying to fix. The terminals map handles this with an object identity check, but pendingExitPromises lacks equivalent protection.

Additional Locations (1)

Fix in Cursor Fix in Web

});
}
Comment on lines +40 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If this function is called multiple times for the same terminalId before the PTY exits, it could lead to a resource leak. Each call creates a new promise and a new timeout, but only the latest one is tracked, orphaning previous promises and their timeouts. To make this more robust, it's good practice to clean up any existing waiters for the same terminal ID before creating a new one.

export function waitForPtyExit(terminalId: string, timeoutMs?: number): Promise<void> {
  const timeout = timeoutMs ?? (isWindows() ? PTY_EXIT_TIMEOUT_WINDOWS : PTY_EXIT_TIMEOUT_UNIX);

  // If we are already waiting for this terminal, clear the old timeout to prevent leaks.
  const existing = pendingExitPromises.get(terminalId);
  if (existing) {
    debugLog('[PtyManager] PTY exit already being awaited, overwriting for terminal:', terminalId);
    clearTimeout(existing.timeoutId);
  }

  return new Promise<void>((resolve) => {
    // Set up timeout fallback
    const timeoutId = setTimeout(() => {
      debugLog('[PtyManager] PTY exit timeout for terminal:', terminalId);
      pendingExitPromises.delete(terminalId);
      resolve();
    }, timeout);

    // Store the promise resolver
    pendingExitPromises.set(terminalId, { resolve, timeoutId });
  });
}


/**
* Get the Windows shell executable based on preferred terminal setting
*/
Expand Down Expand Up @@ -115,6 +152,14 @@ export function setupPtyHandlers(
ptyProcess.onExit(({ exitCode }) => {
debugLog('[PtyManager] Terminal exited:', id, 'code:', exitCode);

// Resolve any pending exit promise FIRST (before other cleanup)
const pendingExit = pendingExitPromises.get(id);
if (pendingExit) {
clearTimeout(pendingExit.timeoutId);
pendingExitPromises.delete(id);
pendingExit.resolve();
}

const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
Expand All @@ -123,7 +168,10 @@ export function setupPtyHandlers(
// Call custom exit handler
onExitCallback(terminal);

terminals.delete(id);
// Only delete if still in map (destroyTerminal may have already removed it)
if (terminals.has(id)) {
terminals.delete(id);
}
});
}

Expand Down Expand Up @@ -228,9 +276,17 @@ export function resizePty(terminal: TerminalProcess, cols: number, rows: number)
}

/**
* Kill a PTY process
* Kill a PTY process.
* @param terminal The terminal process to kill
* @param waitForExit If true, returns a promise that resolves when the PTY exits.
* Used on Windows where PTY termination is async.
*/
export function killPty(terminal: TerminalProcess): void {
export function killPty(terminal: TerminalProcess, waitForExit?: boolean): Promise<void> | void {
if (waitForExit) {
const exitPromise = waitForPtyExit(terminal.id);
terminal.pty.kill();
return exitPromise;
}
terminal.pty.kill();
}

Expand Down
17 changes: 15 additions & 2 deletions apps/frontend/src/main/terminal/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
WindowGetter,
TerminalOperationResult
} from './types';
import { isWindows } from '../platform';
import { debugLog, debugError } from '../../shared/utils/debug-logger';

/**
Expand Down Expand Up @@ -228,7 +229,9 @@ export async function restoreTerminal(
}

/**
* Destroy a terminal process
* Destroy a terminal process.
* On Windows, waits for the PTY to actually exit before returning to prevent
* race conditions when recreating terminals (e.g., worktree switching).
*/
export async function destroyTerminal(
id: string,
Expand All @@ -245,8 +248,18 @@ export async function destroyTerminal(
// Release any claimed session ID for this terminal
SessionHandler.releaseSessionId(id);
onCleanup(id);
PtyManager.killPty(terminal);

// Delete from map BEFORE killing to prevent race with onExit handler
terminals.delete(id);

// On Windows, wait for PTY to actually exit before returning
// This prevents race conditions when recreating terminals
if (isWindows()) {
await PtyManager.killPty(terminal, true);
} else {
PtyManager.killPty(terminal);
}

return { success: true };
} catch (error) {
return {
Expand Down
Loading