fix(dev): mata a arvore de processos do backend no Windows
O supervisor faz `spawn("uv", ...)` e o uv sobe o uvicorn como filho dele.
Windows não tem sinais: `child.kill()` vira TerminateProcess só no filho
DIRETO, então matar o `uv` deixava o uvicorn neto vivo segurando a porta 3900.
O spawn seguinte falhava com `[Errno 10048]`, o supervisor contava como crash,
e três desses derrubavam a stack inteira de dev — inclusive o Vite, via
`--kill-others-on-fail`.
`killProcessTree` usa `taskkill /T` no win32 e mantém o envio de sinal no
POSIX. Como o kill forçado devolve exit não-zero e sinal nulo, o reload que nós
mesmos pedimos passaria por crash; isso é tratado olhando se o tree-kill de
fato aconteceu, e não a plataforma — um crash de verdade durante um reload
continua indo para a recuperação de crash (coberto por teste que já existia).
This commit is contained in:
+34
-4
@@ -21,7 +21,7 @@
|
||||
// the Windows CreateProcess PATH search — no shell needed).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, watch } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -47,6 +47,29 @@ export const CRASH_RESTART_LIMIT = 3;
|
||||
export const CRASH_RESTART_WINDOW_MS = 60_000;
|
||||
export const SOURCE_RELOAD_DEBOUNCE_MS = 250;
|
||||
|
||||
/**
|
||||
* Stop a spawned backend and everything it started.
|
||||
*
|
||||
* We spawn `uv`, which spawns uvicorn as its own child. Windows has no signals:
|
||||
* `child.kill()` maps to TerminateProcess on the DIRECT child only, so killing
|
||||
* `uv` orphaned the uvicorn grandchild — which kept holding port 3900. The next
|
||||
* spawn then failed to bind ([Errno 10048]), which the supervisor counted as a
|
||||
* crash, and three of those tore the whole dev stack down. `taskkill /T` walks
|
||||
* the tree; POSIX keeps plain signal delivery.
|
||||
*/
|
||||
export function killProcessTree(child, sig, { platform = process.platform, run = spawnSync } = {}) {
|
||||
if (platform !== "win32") {
|
||||
child.kill(sig);
|
||||
return "signal";
|
||||
}
|
||||
const result = run("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
if (!result || result.error || result.status !== 0) {
|
||||
child.kill(sig); // taskkill unavailable or the process already exited
|
||||
return "fallback";
|
||||
}
|
||||
return "taskkill";
|
||||
}
|
||||
|
||||
export function isBackendSourceChange(filename) {
|
||||
return typeof filename === "string" && filename.toLowerCase().endsWith(".py");
|
||||
}
|
||||
@@ -123,6 +146,7 @@ export function createBackendSupervisor({
|
||||
spawnBackend = () => spawn("uv", uvRunArgs(), { stdio: "inherit" }),
|
||||
watchBackend = (onChange) =>
|
||||
watch("backend", { recursive: true }, (_event, filename) => onChange(filename?.toString())),
|
||||
killBackend = (proc, sig) => killProcessTree(proc, sig),
|
||||
schedule = setTimeout,
|
||||
cancelSchedule = clearTimeout,
|
||||
now = Date.now,
|
||||
@@ -135,6 +159,10 @@ export function createBackendSupervisor({
|
||||
let restartTimer = null;
|
||||
let reloadTimer = null;
|
||||
let reloadRequested = false;
|
||||
// True when the reload we asked for was served by a forced tree-kill, which
|
||||
// reports a non-zero exit and no signal. Only then may such an exit be read
|
||||
// as "the reload we asked for" instead of "it crashed mid-reload".
|
||||
let reloadKillForced = false;
|
||||
let watcher = null;
|
||||
let crashTimes = [];
|
||||
let requestedExitCode = null;
|
||||
@@ -165,7 +193,7 @@ export function createBackendSupervisor({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill(sig);
|
||||
killBackend(child, sig);
|
||||
} catch {
|
||||
exit(requestedExitCode ?? 0);
|
||||
}
|
||||
@@ -180,7 +208,7 @@ export function createBackendSupervisor({
|
||||
reloadRequested = true;
|
||||
report(`[dev-backend] ${filename} changed; reloading backend…`);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
reloadKillForced = killBackend(child, "SIGTERM") === "taskkill";
|
||||
} catch {
|
||||
reloadRequested = false;
|
||||
}
|
||||
@@ -236,7 +264,9 @@ export function createBackendSupervisor({
|
||||
|
||||
if (reloadRequested) {
|
||||
reloadRequested = false;
|
||||
const expectedReloadExit = code === 0 || signal === "SIGTERM";
|
||||
const expectedReloadExit =
|
||||
code === 0 || signal === "SIGTERM" || reloadKillForced;
|
||||
reloadKillForced = false;
|
||||
if (expectedReloadExit) {
|
||||
start();
|
||||
return;
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
buildExitBanner,
|
||||
createBackendSupervisor,
|
||||
isBackendSourceChange,
|
||||
killProcessTree,
|
||||
resolveDataDir,
|
||||
tailFile,
|
||||
} from '../../scripts/dev-backend.mjs';
|
||||
|
||||
function supervisorHarness() {
|
||||
function supervisorHarness(overrides = {}) {
|
||||
const children = [];
|
||||
const timers = [];
|
||||
const exits = [];
|
||||
@@ -64,6 +65,7 @@ function supervisorHarness() {
|
||||
exit: (code) => exits.push(code),
|
||||
report: (message) => reports.push(message),
|
||||
dataDir: () => '/missing-test-data',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -282,3 +284,45 @@ test('failure to spawn uv exits immediately instead of looping', () => {
|
||||
assert.deepEqual(exits, [1]);
|
||||
assert.match(reports[0], /could not start uv: uv is missing/);
|
||||
});
|
||||
|
||||
// Windows has no signals: killing the spawned `uv` left the uvicorn grandchild
|
||||
// alive on port 3900, so every restart failed to bind and the supervisor tore
|
||||
// the stack down after three "crashes".
|
||||
test('a backend is stopped by process tree on windows and by signal elsewhere', () => {
|
||||
const child = { pid: 4242, killedWith: null, kill(sig) { this.killedWith = sig; } };
|
||||
const calls = [];
|
||||
const run = (exe, args) => {
|
||||
calls.push([exe, ...args].join(' '));
|
||||
return { status: 0 };
|
||||
};
|
||||
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'win32', run }), 'taskkill');
|
||||
assert.deepEqual(calls, ['taskkill /pid 4242 /T /F']);
|
||||
assert.equal(child.killedWith, null, 'must not also signal the direct child');
|
||||
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'linux', run }), 'signal');
|
||||
assert.equal(calls.length, 1, 'POSIX must not shell out');
|
||||
assert.equal(child.killedWith, 'SIGTERM');
|
||||
});
|
||||
|
||||
test('a failed taskkill falls back to signalling the direct child', () => {
|
||||
const child = { pid: 4242, killedWith: null, kill(sig) { this.killedWith = sig; } };
|
||||
const run = () => ({ status: 128 });
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'win32', run }), 'fallback');
|
||||
assert.equal(child.killedWith, 'SIGTERM');
|
||||
});
|
||||
|
||||
test('a forced tree-kill reload restarts instead of reporting a crash', () => {
|
||||
const harness = supervisorHarness({ killBackend: () => 'taskkill' });
|
||||
harness.supervisor.start();
|
||||
harness.watchers[0].onChange('main.py');
|
||||
harness.runNextTimer();
|
||||
|
||||
// taskkill /F reports a non-zero exit and no signal — that is the reload we
|
||||
// asked for, not a crash.
|
||||
harness.children[0].emit('exit', 1, null);
|
||||
|
||||
assert.doesNotMatch(harness.reports.join('\n'), /BACKEND DIED/);
|
||||
assert.equal(harness.children.length, 2);
|
||||
assert.deepEqual(harness.exits, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user