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
182 lines
6.1 KiB
TypeScript
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));
|
|
});
|
|
}
|