diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index f8f62b84..61948121 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -17,7 +17,7 @@ * - Human-readable error messages per phase */ -import { execCli, spawnCli } from '@main/utils/childProcess'; +import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { getHomeDir } from '@main/utils/pathDecoder'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; @@ -467,7 +467,7 @@ export class CliInstallerService { }); const timeout = setTimeout(() => { - child.kill(); + killProcessTree(child); reject( new Error( `Timed out after ${INSTALL_TIMEOUT_MS / 1000}s. ` + diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 53845dd7..402f5d08 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; -import { spawnCli } from '@main/utils/childProcess'; +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { encodePath, extractBaseDir, @@ -1035,7 +1035,7 @@ export class TeamProvisioningService { void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); if (readyOnTimeout) { return; // cleanupRun already called inside tryCompleteAfterTimeout } @@ -1344,7 +1344,7 @@ export class TeamProvisioningService { void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); if (readyOnTimeout) { return; } @@ -1395,7 +1395,7 @@ export class TeamProvisioningService { run.cancelRequested = true; run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); run.onProgress(progress); this.cleanupRun(run); @@ -1824,7 +1824,7 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); @@ -1966,7 +1966,7 @@ export class TeamProvisioningService { // Kill the process on provisioning error run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); this.cleanupRun(run); } else if (run.provisioningComplete) { // Post-provisioning error: process alive, waiting for input @@ -2028,7 +2028,7 @@ export class TeamProvisioningService { run.onProgress(progress); run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); this.cleanupRun(run); return; } @@ -3137,7 +3137,7 @@ export class TeamProvisioningService { const stderrChunks: Buffer[] = []; const timeoutHandle = setTimeout(() => { - child.kill(); + killProcessTree(child); reject(new Error(`Timeout running: claude ${args.join(' ')}`)); }, timeoutMs); diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index a1f49e85..7a420ab2 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -1,4 +1,5 @@ import { + type ChildProcess, exec, execFile, type ExecFileOptions, @@ -6,6 +7,7 @@ import { spawn, type SpawnOptions, } from 'child_process'; +import path from 'path'; /** * Promise wrapper for execFile that always returns { stdout, stderr }. @@ -156,3 +158,41 @@ export function spawnCli( throw err; } } + +/** + * Kill a child process and its entire process tree. + * + * On Windows with `shell: true`, `child.kill()` only kills the intermediate + * `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned. + * `taskkill /T /F /PID` recursively kills the entire process tree. + * + * On macOS/Linux, processes are killed directly (no shell wrapper), so + * the standard `child.kill(signal)` works correctly. + */ +export function killProcessTree( + child: ChildProcess | null | undefined, + signal?: NodeJS.Signals +): void { + if (!child?.pid) { + // Process is null, never started, or already exited + return; + } + + if (process.platform === 'win32') { + try { + const taskkillPath = path.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'taskkill.exe' + ); + execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], () => { + // Best-effort — ignore errors (process may have already exited) + }); + return; + } catch { + // taskkill failed, fall through to standard kill + } + } + + child.kill(signal); +}