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:
marreiradigital
2026-09-08 22:45:44 -04:00
parent 0d3fb07c1f
commit 2127aa7716
2 changed files with 79 additions and 5 deletions
+34 -4
View File
@@ -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;
+45 -1
View File
@@ -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, []);
});