diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index 01c6ba287e..d77041b50b 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -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); }); diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index ea333113cb..3ffac994c1 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -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 { + const KILL_TIMEOUT_MS = 10000; // 10 seconds max wait + const killPromises = this.state.getRunningTaskIds().map((taskId) => { return new Promise((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); } diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index 8bdbd7fa3e..ffab241ed0 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -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 | 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 { 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'); + } +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 2c5a86f537..52c264329e 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -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(); diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 272dcc4e6b..9381fe517a 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -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, diff --git a/apps/frontend/src/main/platform/__tests__/process-kill.test.ts b/apps/frontend/src/main/platform/__tests__/process-kill.test.ts new file mode 100644 index 0000000000..49f3c847a8 --- /dev/null +++ b/apps/frontend/src/main/platform/__tests__/process-kill.test.ts @@ -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; + + 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).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).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(); + }); + }); +}); diff --git a/apps/frontend/src/main/platform/index.ts b/apps/frontend/src/main/platform/index.ts index 04f19f579d..ea5c14198c 100644 --- a/apps/frontend/src/main/platform/index.ts +++ b/apps/frontend/src/main/platform/index.ts @@ -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(); + } +} diff --git a/apps/frontend/src/main/terminal/pty-daemon-client.ts b/apps/frontend/src/main/terminal/pty-daemon-client.ts index abf1fd8982..df423a70af 100644 --- a/apps/frontend/src/main/terminal/pty-daemon-client.ts +++ b/apps/frontend/src/main/terminal/pty-daemon-client.ts @@ -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();