Files
vs-code-dotnet-solution-lau…/src/hotreload/processes.ts
T
maxandClaude Fable 5.1 0cae24a1f0 Hot reload as a third launch mode, rewritten from dotnet-hot-reload
dotnet watch run with the startup project, configuration, platform and
per-project launch options from the status bar. The debugger is no
longer attached automatically: attach on demand, one notification with
Re-attach when the watcher replaces the process. Rude edits follow a
setting (restart / ask / warn). Warns before starting under an optimised
configuration, which SDK 10's dotnet watch cannot hot reload. Solution
build runs first so the Editor's post-build step finds the Builder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
2026-09-08 13:47:37 +02:00

182 lines
6.1 KiB
TypeScript

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<ProcessInfo[]> {
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<number, ProcessInfo[]>();
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<number>([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<ProcessInfo | undefined> {
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<void> {
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<string> {
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));
});
}