import { spawn } from 'child_process'; /** A process, as far as attaching needs it. */ export interface ProcessInfo { pid: number; parentPid: number; name: string; } /** * Every process on the machine, or an empty list if none of the tools work. * * `wmic` is fast and present on Windows 10, but removed from recent Windows 11 builds, * so PowerShell's CIM query is the fallback there. */ export async function listProcesses(): Promise { if (process.platform !== 'win32') { try { return parsePs(await capture('ps', ['-eo', 'pid=,ppid=,comm='])); } catch { return []; } } try { const out = await capture('wmic', ['process', 'get', 'ProcessId,ParentProcessId,Name', '/format:csv']); const parsed = parseWmicCsv(out); if (parsed.length > 0) { return parsed; } } catch { // fall through } try { const out = await capture('powershell', [ '-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation', ]); return parseWmicCsv(out.replace(/"/g, '')); } catch { return []; } } export function parseWmicCsv(text: string): ProcessInfo[] { const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean); const header = lines.findIndex(line => /(^|,)Name(,|$)/i.test(line)); if (header < 0) { return []; } const columns = lines[header].split(',').map(column => column.trim().toLowerCase()); const nameAt = columns.indexOf('name'); const parentAt = columns.indexOf('parentprocessid'); const pidAt = columns.indexOf('processid'); if (nameAt < 0 || parentAt < 0 || pidAt < 0) { return []; } const processes: ProcessInfo[] = []; for (const line of lines.slice(header + 1)) { const cells = line.split(','); const pid = Number(cells[pidAt]); const parentPid = Number(cells[parentAt]); if (Number.isFinite(pid) && Number.isFinite(parentPid)) { processes.push({ pid, parentPid, name: (cells[nameAt] ?? '').trim() }); } } return processes; } export function parsePs(text: string): ProcessInfo[] { const processes: ProcessInfo[] = []; for (const line of text.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line); if (match) { processes.push({ pid: Number(match[1]), parentPid: Number(match[2]), name: match[3] }); } } return processes; } /** * The application process below `rootPid`, identified by assembly name. * * `dotnet watch` sits in the middle: it spawns a build and then the application, so the * target is a descendant rather than a direct child. Matching on the assembly name keeps * us from attaching to MSBuild. The most recently listed match is the newest one, which * matters right after a restart when the old process is still shutting down. */ export function findAppProcess( processes: readonly ProcessInfo[], rootPid: number, assembly: string, avoidPid?: number, ): ProcessInfo | undefined { const byParent = new Map(); for (const info of processes) { const siblings = byParent.get(info.parentPid) ?? []; siblings.push(info); byParent.set(info.parentPid, siblings); } const wanted = assembly.toLowerCase(); const candidates: ProcessInfo[] = []; const queue = [rootPid]; const seen = new Set([rootPid]); while (queue.length > 0) { const pid = queue.shift()!; for (const child of byParent.get(pid) ?? []) { if (seen.has(child.pid)) { continue; } seen.add(child.pid); queue.push(child.pid); const name = child.name.toLowerCase().replace(/\.exe$/, ''); if (name === wanted && child.pid !== avoidPid) { candidates.push(child); } } } return candidates[candidates.length - 1]; } /** Whether a pid is still running. EPERM means it exists but is not ours, which counts. */ export function isAlive(pid: number | undefined): boolean { if (pid === undefined) { return false; } try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } } /** Polls for the application process below the watcher; the build has to finish first. */ export async function waitForApp( rootPid: number, assembly: string, options: { timeoutMs?: number; intervalMs?: number; avoidPid?: number; cancelled?: () => boolean } = {}, ): Promise { const deadline = Date.now() + (options.timeoutMs ?? 90_000); while (Date.now() < deadline && !options.cancelled?.()) { const found = findAppProcess(await listProcesses(), rootPid, assembly, options.avoidPid); if (found) { return found; } await new Promise(resolve => setTimeout(resolve, options.intervalMs ?? 500)); } return undefined; } /** * Kills the watcher and the application it launched. * * `child.kill()` only signals `dotnet watch` itself, leaving the application running and * holding its output files — so the whole tree has to go. */ export async function killTree(pid: number): Promise { try { if (process.platform === 'win32') { await capture('taskkill', ['/PID', String(pid), '/T', '/F']); } else { process.kill(-pid, 'SIGTERM'); } } catch { try { process.kill(pid, 'SIGKILL'); } catch { // already gone } } } function capture(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { windowsHide: true }); let out = ''; child.stdout?.on('data', chunk => (out += String(chunk))); child.on('error', reject); child.on('exit', () => resolve(out)); }); }