diff --git a/src/services/infrastructure/ProcessManager.ts b/src/services/infrastructure/ProcessManager.ts index 7013892e..65f7f792 100644 --- a/src/services/infrastructure/ProcessManager.ts +++ b/src/services/infrastructure/ProcessManager.ts @@ -33,6 +33,93 @@ const ORPHAN_PROCESS_PATTERNS = [ // Only kill processes older than this to avoid killing the current session const ORPHAN_MAX_AGE_MINUTES = 30; +interface RuntimeResolverOptions { + platform?: NodeJS.Platform; + execPath?: string; + env?: NodeJS.ProcessEnv; + homeDirectory?: string; + pathExists?: (candidatePath: string) => boolean; + lookupInPath?: (binaryName: string, platform: NodeJS.Platform) => string | null; +} + +function isBunExecutablePath(executablePath: string | undefined | null): boolean { + if (!executablePath) return false; + + return /(^|[\\/])bun(\.exe)?$/i.test(executablePath.trim()); +} + +function lookupBinaryInPath(binaryName: string, platform: NodeJS.Platform): string | null { + const command = platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`; + + try { + const output = execSync(command, { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8' + }); + + const firstMatch = output + .split(/\r?\n/) + .map(line => line.trim()) + .find(line => line.length > 0); + + return firstMatch || null; + } catch { + return null; + } +} + +/** + * Resolve the runtime executable for spawning the worker daemon. + * + * Windows must prefer Bun because worker-service.cjs imports bun:sqlite, + * which is unavailable in Node.js. + */ +export function resolveWorkerRuntimePath(options: RuntimeResolverOptions = {}): string | null { + const platform = options.platform ?? process.platform; + const execPath = options.execPath ?? process.execPath; + + // Non-Windows currently relies on the runtime that launched worker-service. + if (platform !== 'win32') { + return execPath; + } + + // If already running under Bun, reuse it directly. + if (isBunExecutablePath(execPath)) { + return execPath; + } + + const env = options.env ?? process.env; + const homeDirectory = options.homeDirectory ?? homedir(); + const pathExists = options.pathExists ?? existsSync; + const lookupInPath = options.lookupInPath ?? lookupBinaryInPath; + + const candidatePaths = [ + env.BUN, + env.BUN_PATH, + path.join(homeDirectory, '.bun', 'bin', 'bun.exe'), + path.join(homeDirectory, '.bun', 'bin', 'bun'), + env.USERPROFILE ? path.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe') : undefined, + env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bun.exe') : undefined, + env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bin', 'bun.exe') : undefined, + ]; + + for (const candidate of candidatePaths) { + const normalized = candidate?.trim(); + if (!normalized) continue; + + if (isBunExecutablePath(normalized) && pathExists(normalized)) { + return normalized; + } + + // Allow command-style values from env (e.g. BUN=bun) + if (normalized.toLowerCase() === 'bun') { + return normalized; + } + } + + return lookupInPath('bun', platform); +} + export interface PidInfo { pid: number; port: number; @@ -368,9 +455,16 @@ export function spawnDaemon( // Use PowerShell Start-Process to spawn a hidden, independent process // Unlike WMIC, PowerShell inherits environment variables from parent // -WindowStyle Hidden prevents console popup - const execPath = process.execPath; - const script = scriptPath; - const psCommand = `Start-Process -FilePath '${execPath}' -ArgumentList '${script}','--daemon' -WindowStyle Hidden`; + const runtimePath = resolveWorkerRuntimePath(); + + if (!runtimePath) { + logger.error('SYSTEM', 'Failed to locate Bun runtime for Windows worker spawn'); + return undefined; + } + + const escapedRuntimePath = runtimePath.replace(/'/g, "''"); + const escapedScriptPath = scriptPath.replace(/'/g, "''"); + const psCommand = `Start-Process -FilePath '${escapedRuntimePath}' -ArgumentList '${escapedScriptPath}','--daemon' -WindowStyle Hidden`; try { execSync(`powershell -NoProfile -Command "${psCommand}"`, { @@ -379,7 +473,8 @@ export function spawnDaemon( env }); return 0; - } catch { + } catch (error) { + logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error as Error); return undefined; } } diff --git a/tests/infrastructure/process-manager.test.ts b/tests/infrastructure/process-manager.test.ts index 5a5b28ff..9a354ab9 100644 --- a/tests/infrastructure/process-manager.test.ts +++ b/tests/infrastructure/process-manager.test.ts @@ -11,6 +11,7 @@ import { isProcessAlive, cleanStalePidFile, spawnDaemon, + resolveWorkerRuntimePath, type PidInfo } from '../../src/services/infrastructure/index.js'; @@ -225,6 +226,62 @@ describe('ProcessManager', () => { }); }); + describe('resolveWorkerRuntimePath', () => { + it('should return current runtime on non-Windows platforms', () => { + const resolved = resolveWorkerRuntimePath({ + platform: 'linux', + execPath: '/usr/bin/node' + }); + + expect(resolved).toBe('/usr/bin/node'); + }); + + it('should reuse execPath when already running under Bun on Windows', () => { + const resolved = resolveWorkerRuntimePath({ + platform: 'win32', + execPath: 'C:\\Users\\alice\\.bun\\bin\\bun.exe' + }); + + expect(resolved).toBe('C:\\Users\\alice\\.bun\\bin\\bun.exe'); + }); + + it('should prefer configured Bun path from environment when available', () => { + const resolved = resolveWorkerRuntimePath({ + platform: 'win32', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { BUN: 'C:\\tools\\bun.exe' } as NodeJS.ProcessEnv, + pathExists: candidatePath => candidatePath === 'C:\\tools\\bun.exe', + lookupInPath: () => null + }); + + expect(resolved).toBe('C:\\tools\\bun.exe'); + }); + + it('should fall back to PATH lookup when no Bun candidate exists', () => { + const resolved = resolveWorkerRuntimePath({ + platform: 'win32', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: {} as NodeJS.ProcessEnv, + pathExists: () => false, + lookupInPath: () => 'C:\\Program Files\\Bun\\bun.exe' + }); + + expect(resolved).toBe('C:\\Program Files\\Bun\\bun.exe'); + }); + + it('should return null when Bun cannot be resolved on Windows', () => { + const resolved = resolveWorkerRuntimePath({ + platform: 'win32', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: {} as NodeJS.ProcessEnv, + pathExists: () => false, + lookupInPath: () => null + }); + + expect(resolved).toBeNull(); + }); + }); + describe('isProcessAlive', () => { it('should return true for the current process', () => { expect(isProcessAlive(process.pid)).toBe(true);