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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user