fix(windows): prevent zombie process accumulation on app close (#1259)

* fix(windows): prevent zombie process accumulation on app close

- Use taskkill /f /t on Windows to properly kill process trees
  (SIGTERM/SIGKILL are ignored on Windows)
- Make killAllProcesses() wait for process exit events with timeout
- Kill PTY daemon process on shutdown
- Clear periodic update check interval on app quit

Fixes process accumulation in Task Manager after closing Auto-Claude.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(windows): extract killProcessGracefully utility with timer cleanup

- Extract shared killProcessGracefully() to platform module
- Fix Issue #1: Move taskkill outside try-catch scope
- Fix Issue #2: Track exit state to skip unnecessary taskkill
- Fix Issue #3: Add GRACEFUL_KILL_TIMEOUT_MS constant
- Fix Issue #4: Use consistent 5000ms timeout everywhere
- Fix Issue #5: Add debug logging for catch blocks
- Fix Issue #6: Log warning when process.once unavailable
- Fix Issue #7: Eliminate code duplication across 3 files
- Fix timer leak: Clear timeout on process exit/error, unref timer

Add comprehensive tests (19 test cases) covering:
- Windows taskkill fallback behavior
- Unix SIGTERM/SIGKILL sequence
- Timer cleanup and memory leak prevention
- Edge cases and error handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
VDT-91
2026-01-17 21:44:20 +01:00
committed by GitHub
parent c13d9a4062
commit 902044695b
8 changed files with 567 additions and 74 deletions
@@ -36,6 +36,9 @@ const mockProcess = Object.assign(new EventEmitter(), {
killed: false,
kill: vi.fn(() => {
mockProcess.killed = true;
// Emit exit event synchronously to simulate process termination
// (needed for killAllProcesses wait - using nextTick for more predictable timing)
process.nextTick(() => mockProcess.emit('exit', 0, null));
return true;
})
});
@@ -329,7 +332,12 @@ describe('Subprocess Spawn Integration', () => {
const result = manager.killTask('task-1');
expect(result).toBe(true);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
// On Windows, kill() is called without arguments; on Unix, kill('SIGTERM') is used
if (process.platform === 'win32') {
expect(mockProcess.kill).toHaveBeenCalled();
} else {
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
}
expect(manager.isRunning('task-1')).toBe(false);
});
+37 -21
View File
@@ -24,6 +24,7 @@ import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars } from './env-utils';
import { getAugmentedEnv } from '../env-utils';
import { getToolInfo } from '../cli-tool-manager';
import { isWindows, killProcessGracefully } from '../platform';
/**
* Type for supported CLI tools
@@ -719,40 +720,55 @@ export class AgentProcessManager {
*/
killProcess(taskId: string): boolean {
const agentProcess = this.state.getProcess(taskId);
if (agentProcess) {
try {
// Mark this specific spawn as killed so its exit handler knows to ignore
this.state.markSpawnAsKilled(agentProcess.spawnId);
if (!agentProcess) return false;
// Send SIGTERM first for graceful shutdown
agentProcess.process.kill('SIGTERM');
// Mark this specific spawn as killed so its exit handler knows to ignore
this.state.markSpawnAsKilled(agentProcess.spawnId);
// Force kill after timeout
setTimeout(() => {
if (!agentProcess.process.killed) {
agentProcess.process.kill('SIGKILL');
}
}, 5000);
// Use shared platform-aware kill utility
killProcessGracefully(agentProcess.process, {
debugPrefix: '[AgentProcess]',
debug: process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'
});
this.state.deleteProcess(taskId);
return true;
} catch {
return false;
}
}
return false;
this.state.deleteProcess(taskId);
return true;
}
/**
* Kill all running processes
* Kill all running processes and wait for them to exit
*/
async killAllProcesses(): Promise<void> {
const KILL_TIMEOUT_MS = 10000; // 10 seconds max wait
const killPromises = this.state.getRunningTaskIds().map((taskId) => {
return new Promise<void>((resolve) => {
const agentProcess = this.state.getProcess(taskId);
if (!agentProcess) {
resolve();
return;
}
// Set up timeout to not block forever
const timeoutId = setTimeout(() => {
resolve();
}, KILL_TIMEOUT_MS);
// Listen for exit event if the process supports it
// (process.once is available on real ChildProcess objects, but may not be in test mocks)
if (typeof agentProcess.process.once === 'function') {
agentProcess.process.once('exit', () => {
clearTimeout(timeoutId);
resolve();
});
}
// Kill the process
this.killProcess(taskId);
resolve();
});
});
await Promise.all(killPromises);
}
+15 -1
View File
@@ -38,6 +38,9 @@ autoUpdater.autoInstallOnAppQuit = true; // Automatically install on app quit
// Update channels: 'latest' for stable, 'beta' for pre-release
type UpdateChannel = 'latest' | 'beta';
// Store interval ID for cleanup during shutdown
let periodicCheckIntervalId: ReturnType<typeof setInterval> | null = null;
/**
* Set the update channel for electron-updater.
* - 'latest': Only receive stable releases (default)
@@ -189,7 +192,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
const FOUR_HOURS = 4 * 60 * 60 * 1000;
console.warn(`[app-updater] Periodic checks scheduled every ${FOUR_HOURS / 1000 / 60 / 60} hours`);
setInterval(() => {
periodicCheckIntervalId = setInterval(() => {
console.warn('[app-updater] Performing periodic update check');
autoUpdater.checkForUpdates().catch((error) => {
console.error('[app-updater] ❌ Periodic update check failed:', error.message);
@@ -492,3 +495,14 @@ export async function downloadStableVersion(): Promise<void> {
autoUpdater.allowDowngrade = false;
}
}
/**
* Stop periodic update checks - called during app shutdown
*/
export function stopPeriodicUpdates(): void {
if (periodicCheckIntervalId) {
clearInterval(periodicCheckIntervalId);
periodicCheckIntervalId = null;
console.warn('[app-updater] Periodic update checks stopped');
}
}
+4 -1
View File
@@ -35,7 +35,7 @@ import { TerminalManager } from './terminal-manager';
import { pythonEnvManager } from './python-env-manager';
import { getUsageMonitor } from './claude-profile/usage-monitor';
import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers';
import { initializeAppUpdater } from './app-updater';
import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater';
import { DEFAULT_APP_SETTINGS } from '../shared/constants';
import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger';
@@ -440,6 +440,9 @@ app.on('window-all-closed', () => {
// Cleanup before quit
app.on('before-quit', async () => {
// Stop periodic update checks
stopPeriodicUpdates();
// Stop usage monitor
const usageMonitor = getUsageMonitor();
usageMonitor.stop();
@@ -20,6 +20,7 @@ import {
findTaskWorktree,
} from '../../worktree-paths';
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
import { killProcessGracefully } from '../../platform';
// Regex pattern for validating git branch names
const GIT_BRANCH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/;
@@ -2006,27 +2007,11 @@ export function registerWorktreeHandlers(
debug('TIMEOUT: Merge process exceeded', MERGE_TIMEOUT_MS, 'ms, killing...');
resolved = true;
// Platform-specific process termination
if (process.platform === 'win32') {
// On Windows, .kill() without signal terminates the process tree
// SIGTERM/SIGKILL are not supported the same way on Windows
try {
mergeProcess.kill();
} catch {
// Process may already be dead
}
} else {
// On Unix-like systems, use SIGTERM first, then SIGKILL as fallback
mergeProcess.kill('SIGTERM');
// Give it a moment to clean up, then force kill
setTimeout(() => {
try {
mergeProcess.kill('SIGKILL');
} catch {
// Process may already be dead
}
}, 5000);
}
// Platform-specific process termination with fallback
killProcessGracefully(mergeProcess, {
debugPrefix: '[MERGE]',
debug: isDebugMode
});
// Check if merge might have succeeded before the hang
// Look for success indicators in the output
@@ -3030,35 +3015,10 @@ export function registerWorktreeHandlers(
resolved = true;
// Platform-specific process termination with fallback
if (process.platform === 'win32') {
try {
createPRProcess.kill();
// Fallback: forcefully kill with taskkill if process ignores initial kill
if (createPRProcess.pid) {
setTimeout(() => {
try {
spawn('taskkill', ['/pid', createPRProcess.pid!.toString(), '/f', '/t'], {
stdio: 'ignore',
detached: true
}).unref();
} catch {
// Process may already be dead
}
}, 5000);
}
} catch {
// Process may already be dead
}
} else {
createPRProcess.kill('SIGTERM');
setTimeout(() => {
try {
createPRProcess.kill('SIGKILL');
} catch {
// Process may already be dead
}
}, 5000);
}
killProcessGracefully(createPRProcess, {
debugPrefix: '[PR_CREATION]',
debug: isDebugMode
});
resolve({
success: false,
@@ -0,0 +1,358 @@
/**
* Process Kill Utility Tests
*
* Tests the killProcessGracefully utility for cross-platform process termination.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import type { ChildProcess } from 'child_process';
// Mock child_process.spawn before importing the module
vi.mock('child_process', async () => {
const actual = await vi.importActual('child_process');
return {
...actual,
spawn: vi.fn(() => ({ unref: vi.fn() }))
};
});
// Import after mocking
import { killProcessGracefully, GRACEFUL_KILL_TIMEOUT_MS } from '../index';
import { spawn } from 'child_process';
// Mock process.platform
const originalPlatform = process.platform;
function mockPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', {
value: platform,
writable: true,
configurable: true
});
}
describe('killProcessGracefully', () => {
let mockProcess: ChildProcess;
const mockSpawn = spawn as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Create a mock ChildProcess with EventEmitter capabilities
mockProcess = Object.assign(new EventEmitter(), {
pid: 12345,
killed: false,
kill: vi.fn(),
stdin: null,
stdout: null,
stderr: null,
stdio: [null, null, null, null, null],
connected: false,
exitCode: null,
signalCode: null,
spawnargs: [],
spawnfile: '',
send: vi.fn(),
disconnect: vi.fn(),
unref: vi.fn(),
ref: vi.fn(),
[Symbol.dispose]: vi.fn()
}) as unknown as ChildProcess;
});
afterEach(() => {
mockPlatform(originalPlatform);
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('GRACEFUL_KILL_TIMEOUT_MS constant', () => {
it('is defined and equals 5000', () => {
expect(GRACEFUL_KILL_TIMEOUT_MS).toBe(5000);
});
});
describe('on Windows', () => {
beforeEach(() => {
mockPlatform('win32');
});
it('calls process.kill() without signal argument', () => {
killProcessGracefully(mockProcess);
expect(mockProcess.kill).toHaveBeenCalledWith();
});
it('schedules taskkill as fallback after timeout', () => {
killProcessGracefully(mockProcess);
// Verify taskkill not called yet
expect(mockSpawn).not.toHaveBeenCalled();
// Advance past the timeout
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// Verify taskkill was called with correct arguments
expect(mockSpawn).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/f', '/t'],
expect.objectContaining({
stdio: 'ignore',
detached: true
})
);
});
it('skips taskkill if process exits before timeout', () => {
killProcessGracefully(mockProcess);
// Simulate process exit before timeout
mockProcess.emit('exit', 0);
// Advance past the timeout
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// Verify taskkill was NOT called
expect(mockSpawn).not.toHaveBeenCalled();
});
it('runs taskkill even if .kill() throws (Issue #1 fix)', () => {
// Make .kill() throw an error
(mockProcess.kill as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('Process already dead');
});
// Should not throw
expect(() => killProcessGracefully(mockProcess)).not.toThrow();
// Advance past the timeout
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// taskkill should still be called - this is the key assertion for Issue #1
expect(mockSpawn).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/f', '/t'],
expect.any(Object)
);
});
it('does not schedule taskkill if pid is undefined', () => {
const noPidProcess = Object.assign(new EventEmitter(), {
pid: undefined,
killed: false,
kill: vi.fn()
}) as unknown as ChildProcess;
killProcessGracefully(noPidProcess);
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe('on Unix (macOS/Linux)', () => {
beforeEach(() => {
mockPlatform('darwin');
});
it('calls process.kill(SIGTERM)', () => {
killProcessGracefully(mockProcess);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
it('sends SIGKILL after timeout if process not killed', () => {
killProcessGracefully(mockProcess);
// First call should be SIGTERM
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
expect(mockProcess.kill).toHaveBeenCalledTimes(1);
// Advance past the timeout
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// Second call should be SIGKILL
expect(mockProcess.kill).toHaveBeenCalledWith('SIGKILL');
expect(mockProcess.kill).toHaveBeenCalledTimes(2);
});
it('skips SIGKILL if process exits before timeout', () => {
killProcessGracefully(mockProcess);
// Simulate process exit before timeout
mockProcess.emit('exit', 0);
// Advance past the timeout
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// Only SIGTERM should have been called
expect(mockProcess.kill).toHaveBeenCalledTimes(1);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
it('skips SIGKILL if process.killed is true', () => {
// Simulate process already killed
Object.defineProperty(mockProcess, 'killed', { value: true });
killProcessGracefully(mockProcess);
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
// Only initial SIGTERM call
expect(mockProcess.kill).toHaveBeenCalledTimes(1);
});
it('handles SIGKILL failure gracefully', () => {
// Make SIGKILL throw
let callCount = 0;
(mockProcess.kill as ReturnType<typeof vi.fn>).mockImplementation(() => {
callCount++;
if (callCount > 1) {
throw new Error('Cannot kill dead process');
}
});
killProcessGracefully(mockProcess);
// Should not throw when SIGKILL fails
expect(() => vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS)).not.toThrow();
});
});
describe('options', () => {
beforeEach(() => {
mockPlatform('win32');
});
it('uses custom timeout when provided', () => {
const customTimeout = 1000;
killProcessGracefully(mockProcess, { timeoutMs: customTimeout });
// Should not trigger at default timeout
vi.advanceTimersByTime(customTimeout - 1);
expect(mockSpawn).not.toHaveBeenCalled();
// Should trigger at custom timeout
vi.advanceTimersByTime(1);
expect(mockSpawn).toHaveBeenCalled();
});
it('logs debug messages when debug is enabled', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
killProcessGracefully(mockProcess, {
debug: true,
debugPrefix: '[TestPrefix]'
});
expect(warnSpy).toHaveBeenCalledWith(
'[TestPrefix]',
'Graceful kill signal sent'
);
warnSpy.mockRestore();
});
it('does not log when debug is disabled', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
killProcessGracefully(mockProcess, { debug: false });
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('logs warning when process.once is unavailable (Issue #6 fix)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Create process without .once method
const processWithoutOnce = {
pid: 12345,
killed: false,
kill: vi.fn()
} as unknown as ChildProcess;
killProcessGracefully(processWithoutOnce, {
debug: true,
debugPrefix: '[Test]'
});
expect(warnSpy).toHaveBeenCalledWith(
'[Test]',
'process.once unavailable, cannot track exit state'
);
warnSpy.mockRestore();
});
});
describe('Linux-specific behavior', () => {
beforeEach(() => {
mockPlatform('linux');
});
it('behaves the same as macOS', () => {
killProcessGracefully(mockProcess);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGKILL');
});
});
describe('timer cleanup (memory leak prevention)', () => {
beforeEach(() => {
mockPlatform('win32');
});
it('clears timeout when process exits before timeout fires', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
killProcessGracefully(mockProcess);
// Simulate process exit before timeout
mockProcess.emit('exit', 0);
// clearTimeout should have been called
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it('clears timeout when process emits error', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
killProcessGracefully(mockProcess);
// Simulate process error before timeout
mockProcess.emit('error', new Error('spawn failed'));
// clearTimeout should have been called
expect(clearTimeoutSpy).toHaveBeenCalled();
// Advance past timeout - should not call taskkill
vi.advanceTimersByTime(GRACEFUL_KILL_TIMEOUT_MS);
expect(mockSpawn).not.toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it('unrefs timer to not block Node.js exit', () => {
// Create a mock timer with unref
const mockUnref = vi.fn();
const originalSetTimeout = global.setTimeout;
vi.spyOn(global, 'setTimeout').mockImplementation((fn, ms) => {
const timer = originalSetTimeout(fn, ms);
timer.unref = mockUnref;
return timer;
});
killProcessGracefully(mockProcess);
// Timer should have been unref'd
expect(mockUnref).toHaveBeenCalled();
vi.restoreAllMocks();
});
});
});
+103
View File
@@ -14,6 +14,7 @@
import * as os from 'os';
import * as path from 'path';
import { existsSync } from 'fs';
import { spawn, ChildProcess } from 'child_process';
import { OS, ShellType, PathConfig, ShellConfig, BinaryDirectories } from './types';
// Re-export from paths.ts for backward compatibility
@@ -399,3 +400,105 @@ export function getPlatformDescription(): string {
const arch = os.arch();
return `${osName} (${arch})`;
}
/**
* Grace period (ms) before force-killing a process after graceful termination.
* Used for SIGTERM->SIGKILL (Unix) and kill()->taskkill (Windows) patterns.
*/
export const GRACEFUL_KILL_TIMEOUT_MS = 5000;
export interface KillProcessOptions {
/** Custom timeout in ms (defaults to GRACEFUL_KILL_TIMEOUT_MS) */
timeoutMs?: number;
/** Debug logging prefix */
debugPrefix?: string;
/** Whether debug logging is enabled */
debug?: boolean;
}
/**
* Platform-aware process termination with graceful shutdown and forced fallback.
*
* Windows: .kill() then taskkill /f /t as fallback
* Unix: SIGTERM then SIGKILL as fallback
*
* IMPORTANT: Taskkill/SIGKILL runs OUTSIDE the .kill() try-catch to ensure
* fallback executes even if graceful kill throws.
*/
export function killProcessGracefully(
childProcess: ChildProcess,
options: KillProcessOptions = {}
): void {
const {
timeoutMs = GRACEFUL_KILL_TIMEOUT_MS,
debugPrefix = '[ProcessKill]',
debug = false
} = options;
const pid = childProcess.pid;
const log = (...args: unknown[]) => {
if (debug) console.warn(debugPrefix, ...args);
};
// Track if process exits before force-kill timeout
let hasExited = false;
let forceKillTimer: NodeJS.Timeout | null = null;
const cleanup = () => {
hasExited = true;
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
};
if (typeof childProcess.once === 'function') {
childProcess.once('exit', cleanup);
childProcess.once('error', cleanup); // Also cleanup on error
} else {
log('process.once unavailable, cannot track exit state');
}
// Attempt graceful termination (may throw if process dead)
try {
if (isWindows()) {
childProcess.kill(); // Windows: no signal argument
} else {
childProcess.kill('SIGTERM');
}
log('Graceful kill signal sent');
} catch (err) {
log('Graceful kill failed (process likely dead):',
err instanceof Error ? err.message : String(err));
}
// ALWAYS schedule force-kill fallback OUTSIDE the try-catch
// This ensures fallback runs even if .kill() threw
if (pid) {
forceKillTimer = setTimeout(() => {
if (hasExited) {
log('Process already exited, skipping force kill');
return;
}
try {
if (isWindows()) {
log('Running taskkill for PID:', pid);
spawn('taskkill', ['/pid', pid.toString(), '/f', '/t'], {
stdio: 'ignore',
detached: true
}).unref();
} else if (!childProcess.killed) {
log('Sending SIGKILL to PID:', pid);
childProcess.kill('SIGKILL');
}
} catch (err) {
log('Force kill failed:',
err instanceof Error ? err.message : String(err));
}
}, timeoutMs);
// Unref timer so it doesn't prevent Node.js from exiting
forceKillTimer.unref();
}
}
@@ -10,6 +10,7 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
@@ -402,6 +403,36 @@ class PtyDaemonClient {
*/
shutdown(): void {
this.isShuttingDown = true;
// Kill the daemon process if we spawned it
if (this.daemonProcess && this.daemonProcess.pid) {
try {
if (isWindows()) {
// Windows: use taskkill to force kill process tree
spawn('taskkill', ['/pid', this.daemonProcess.pid.toString(), '/f', '/t'], {
stdio: 'ignore',
detached: true
}).unref();
} else {
// Unix: SIGTERM then SIGKILL
this.daemonProcess.kill('SIGTERM');
const daemonProc = this.daemonProcess;
setTimeout(() => {
try {
if (daemonProc) {
daemonProc.kill('SIGKILL');
}
} catch {
// Process may already be dead
}
}, GRACEFUL_KILL_TIMEOUT_MS);
}
} catch {
// Process may already be dead
}
this.daemonProcess = null;
}
this.disconnect();
this.pendingRequests.clear();
this.dataHandlers.clear();