* fix(scripts): await the pack:verify web child's exit before removing its work dir `pack-and-verify.mjs` had the same kill-then-remove shape that made `smoke:tui` flake with ENOTEMPTY in #1801: `verifyWeb()`'s `finally { stop() }` only *delivered* SIGTERM, then the main flow `rmSync`'d the work dir immediately, and `fail()` did the same back to back. Applies the #1814 treatment, factored into a shared `scripts/lib/child-cleanup.mjs` so the two scripts can't drift: - `stopChild()` — SIGTERM, await the child's `exit`/`close`, escalate to SIGKILL after a grace period, and ultimately proceed with a warning so a wedged child can never hang the script. `verifyWeb()`'s `stop()` now goes through it and is awaited, so the child is gone before the caller removes `work`. - `removeSafe()` — `rmSync` that warns instead of throwing. Used on both success-path removals (a leftover temp dir must never fail a run that passed) and in `fail()`, where an ENOTEMPTY could otherwise bury the real diagnostic under an rmSync stack and skip the "tarball retained at ..." hint. `fail()` stays synchronous — it is called from sync contexts and ends in `process.exit()` — so it best-effort signals the child; `removeSafe()` is what makes that safe. `smoke-tui.mjs` adopts `removeSafe()` for its `cleanup()`. Its `done()` keeps its bespoke wait (documented inline): that one must keep waiting for `close` *after* `exit` on a shorter re-armed deadline so the quoted output is complete, which `stopChild()`'s first-of-exit/close contract deliberately does not do. Adds `scripts/lib/child-cleanup.test.mjs` (10 cases, `node --test` via `npm run test:scripts`) covering the SIGTERM/SIGKILL escalation, the `close`-only spawn-failure path, the never-hang give-up, late events after giving up, and that `removeSafe` warns rather than throws. `npm run ci` passes, and `npm run pack:verify` (not in the gate — needs network) was run end to end and passes with no cleanup warnings. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): normalize stopChild's graceMs and correct removeSafe's JSDoc Copilot review round 1 on #1895. - `stopChild()` trusted `graceMs` as given, but call sites build it as `Number(process.env.X ?? 5000)` — a typo yields `NaN`, which `setTimeout` treats as `0`. That would fire SIGTERM and SIGKILL in the same tick and print "did not exit within NaNms", collapsing the whole escalation silently. New exported `normalizeGraceMs()` falls back to `DEFAULT_EXIT_GRACE_MS` for anything non-finite or non-positive, with two tests: the table of bad values, and an end-to-end one asserting a `NaN` grace period still leaves only SIGTERM sent a turn later. - `removeSafe()`'s `@returns` said "true if the path was removed", which is inaccurate for a missing path (`force: true` makes it a no-op success). Reworded to say what it actually reports. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
6.6 KiB
JavaScript
174 lines
6.6 KiB
JavaScript
// Tests for the shared child-teardown helpers (#1826). Each case pins one rule
|
|
// the kill-then-remove race (#1801) turned out to need. Run via
|
|
// `npm run test:scripts` (node:test; the root has no vitest harness).
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
DEFAULT_EXIT_GRACE_MS,
|
|
hasExited,
|
|
normalizeGraceMs,
|
|
removeSafe,
|
|
stopChild,
|
|
} from "./child-cleanup.mjs";
|
|
|
|
/**
|
|
* Minimal stand-in for a ChildProcess: records the signals it was sent and lets
|
|
* a test decide when (or whether) it emits exit/close.
|
|
*/
|
|
function fakeChild({ exitCode = null, signalCode = null } = {}) {
|
|
const child = new EventEmitter();
|
|
child.exitCode = exitCode;
|
|
child.signalCode = signalCode;
|
|
child.signals = [];
|
|
child.kill = (signal) => {
|
|
child.signals.push(signal);
|
|
return true;
|
|
};
|
|
return child;
|
|
}
|
|
|
|
function collectWarnings() {
|
|
const warnings = [];
|
|
return { warnings, warn: (msg) => warnings.push(msg) };
|
|
}
|
|
|
|
test("hasExited: only exitCode/signalCode count, not `killed`", () => {
|
|
assert.equal(hasExited(fakeChild()), false);
|
|
assert.equal(hasExited(fakeChild({ exitCode: 0 })), true);
|
|
assert.equal(hasExited(fakeChild({ exitCode: 1 })), true);
|
|
assert.equal(hasExited(fakeChild({ signalCode: "SIGTERM" })), true);
|
|
// `killed` means "a signal was delivered", NOT "the process is dead" — a
|
|
// helper that treated it as exited would reintroduce the very race.
|
|
const killed = fakeChild();
|
|
killed.killed = true;
|
|
assert.equal(hasExited(killed), false);
|
|
});
|
|
|
|
test("normalizeGraceMs: a bad env-derived value can't collapse the escalation", () => {
|
|
// `Number(process.env.X ?? 5000)` is how call sites build this, so a typo
|
|
// yields NaN — which setTimeout treats as 0, firing SIGTERM and SIGKILL in
|
|
// the same tick and printing "within NaNms".
|
|
for (const bad of [NaN, 0, -1, Infinity, undefined, null, "3000"])
|
|
assert.equal(normalizeGraceMs(bad), DEFAULT_EXIT_GRACE_MS, String(bad));
|
|
assert.equal(normalizeGraceMs(25), 25);
|
|
});
|
|
|
|
test("stopChild: a NaN graceMs falls back rather than double-killing at once", async () => {
|
|
const child = fakeChild();
|
|
const { warnings, warn } = collectWarnings();
|
|
const stopped = stopChild(child, { graceMs: NaN, warn });
|
|
// With NaN passed through, both timers would already have been scheduled at
|
|
// 0ms; a macrotask turn is enough to observe that.
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.deepEqual(child.signals, ["SIGTERM"]);
|
|
assert.deepEqual(warnings, []);
|
|
child.emit("exit", 0);
|
|
assert.equal(await stopped, "exited");
|
|
});
|
|
|
|
test("stopChild: an already-exited child is not signalled again", async () => {
|
|
const child = fakeChild({ exitCode: 0 });
|
|
const { warnings, warn } = collectWarnings();
|
|
assert.equal(await stopChild(child, { warn }), "already-exited");
|
|
assert.deepEqual(child.signals, []);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("stopChild: SIGTERMs a live child and resolves on `exit`", async () => {
|
|
const child = fakeChild();
|
|
const { warnings, warn } = collectWarnings();
|
|
const stopped = stopChild(child, { graceMs: 50, warn });
|
|
// The signal goes out synchronously, so the caller's wait is genuinely a wait
|
|
// for the child rather than a wait to signal it.
|
|
assert.deepEqual(child.signals, ["SIGTERM"]);
|
|
child.exitCode = 0;
|
|
child.emit("exit", 0);
|
|
assert.equal(await stopped, "exited");
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("stopChild: resolves on `close` alone (spawn failure emits no `exit`)", async () => {
|
|
const child = fakeChild();
|
|
const { warnings, warn } = collectWarnings();
|
|
const stopped = stopChild(child, { graceMs: 50, warn });
|
|
child.emit("close", null, "SIGTERM");
|
|
assert.equal(await stopped, "exited");
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("stopChild: escalates to SIGKILL after the grace period", async () => {
|
|
const child = fakeChild();
|
|
const { warnings, warn } = collectWarnings();
|
|
const stopped = stopChild(child, {
|
|
graceMs: 20,
|
|
label: "pack:verify",
|
|
what: "`--web` server",
|
|
warn,
|
|
});
|
|
// Ignores SIGTERM; dies only once SIGKILLed.
|
|
child.once("kill:SIGKILL", () => child.emit("exit", null));
|
|
const originalKill = child.kill;
|
|
child.kill = (signal) => {
|
|
originalKill(signal);
|
|
if (signal === "SIGKILL") child.emit("kill:SIGKILL");
|
|
return true;
|
|
};
|
|
assert.equal(await stopped, "exited");
|
|
assert.deepEqual(child.signals, ["SIGTERM", "SIGKILL"]);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(warnings[0], /pack:verify — `--web` server did not exit/);
|
|
assert.match(warnings[0], /sending SIGKILL/);
|
|
});
|
|
|
|
test("stopChild: gives up (never hangs) when even SIGKILL is not observed", async () => {
|
|
const child = fakeChild();
|
|
const { warnings, warn } = collectWarnings();
|
|
// Emits nothing ever: the helper must still resolve, since a teardown path
|
|
// that can hang is worse than one that proceeds with a warning.
|
|
assert.equal(await stopChild(child, { graceMs: 10, warn }), "gave-up");
|
|
assert.deepEqual(child.signals, ["SIGTERM", "SIGKILL"]);
|
|
assert.equal(warnings.length, 2);
|
|
assert.match(warnings[1], /continuing anyway/);
|
|
});
|
|
|
|
test("stopChild: a late exit after giving up does not re-resolve or throw", async () => {
|
|
const child = fakeChild();
|
|
const { warn } = collectWarnings();
|
|
assert.equal(await stopChild(child, { graceMs: 10, warn }), "gave-up");
|
|
child.emit("exit", null);
|
|
child.emit("close", null);
|
|
});
|
|
|
|
test("removeSafe: removes a populated directory and reports true", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "child-cleanup-test-"));
|
|
writeFileSync(join(dir, "file.txt"), "x");
|
|
const { warnings, warn } = collectWarnings();
|
|
assert.equal(removeSafe(dir, { warn }), true);
|
|
assert.equal(existsSync(dir), false);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("removeSafe: a missing path is a no-op success (force)", () => {
|
|
const { warnings, warn } = collectWarnings();
|
|
assert.equal(
|
|
removeSafe(join(tmpdir(), "child-cleanup-does-not-exist-1826"), { warn }),
|
|
true,
|
|
);
|
|
assert.deepEqual(warnings, []);
|
|
});
|
|
|
|
test("removeSafe: warns instead of throwing when removal fails", () => {
|
|
const { warnings, warn } = collectWarnings();
|
|
// A non-string path makes rmSync throw synchronously, standing in for the
|
|
// ENOTEMPTY this helper exists to swallow. The contract under test is that
|
|
// NOTHING escapes — a leftover temp dir must never change a script's verdict.
|
|
assert.equal(removeSafe(42, { label: "pack:verify", warn }), false);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(warnings[0], /^pack:verify — could not remove 42: /);
|
|
});
|