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
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user