fix(scripts): await the pack:verify web child's exit before removing its work dir (#1895)
* 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>
This commit is contained in:
parent
694d013f26
commit
8801d0f8bc
4 changed files with 370 additions and 23 deletions
146
scripts/lib/child-cleanup.mjs
Normal file
146
scripts/lib/child-cleanup.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Shared teardown helpers for scripts that spawn a child process into a temp
|
||||
* work dir and then delete that dir (#1801 / #1814, generalized in #1826).
|
||||
*
|
||||
* The bug class both helpers exist for: a script signals its child and then
|
||||
* *synchronously* removes the work dir. `child.kill()` only delivers the
|
||||
* signal — it does not wait — so the removal can race the child's shutdown and
|
||||
* fail with ENOTEMPTY when a file lands after a directory has been read but
|
||||
* before it is removed. That turned a passing smoke red in #1801.
|
||||
*
|
||||
* The two halves are deliberately complementary, and both are needed:
|
||||
*
|
||||
* - `stopChild()` removes the race on the normal path by awaiting the child's
|
||||
* exit (SIGTERM → grace → SIGKILL → proceed anyway with a warning);
|
||||
* - `removeSafe()` makes the residual case harmless — a path that still can't
|
||||
* be removed warns and leaks into tmpdir (which the OS reclaims) instead of
|
||||
* throwing an rmSync stack over the script's own result message.
|
||||
*
|
||||
* Both take an injectable `warn` so they can be unit-tested without capturing
|
||||
* console output (`npm run test:scripts`).
|
||||
*/
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
/** Default wait after SIGTERM before escalating to SIGKILL. */
|
||||
export const DEFAULT_EXIT_GRACE_MS = 5000;
|
||||
|
||||
/**
|
||||
* Coerce a caller-supplied grace period to a usable one.
|
||||
*
|
||||
* Call sites read this from the environment (`Number(process.env.X ?? 5000)`),
|
||||
* so a typo yields `NaN` — which `setTimeout` treats as `0`, silently turning
|
||||
* the whole SIGTERM→SIGKILL escalation into an immediate double-kill and
|
||||
* printing "did not exit within NaNms". Fall back to the default instead.
|
||||
*
|
||||
* @param {unknown} graceMs
|
||||
*/
|
||||
export function normalizeGraceMs(graceMs) {
|
||||
return typeof graceMs === "number" && Number.isFinite(graceMs) && graceMs > 0
|
||||
? graceMs
|
||||
: DEFAULT_EXIT_GRACE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the child process is known to be gone (normal exit or signalled).
|
||||
* Both fields stay `null` while it is alive, and `killed` is NOT a substitute —
|
||||
* it only means a signal was delivered, not that the process is dead yet.
|
||||
*
|
||||
* @param {{ exitCode: number | null, signalCode: string | null }} child
|
||||
*/
|
||||
export function hasExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate `child` and wait for it to actually be gone, so a caller can then
|
||||
* delete the child's work dir without racing its shutdown.
|
||||
*
|
||||
* Escalation: SIGTERM → (after `graceMs`) SIGKILL → (after `graceMs` again)
|
||||
* give up and resolve anyway. Giving up is deliberate: a teardown helper must
|
||||
* never hang a script, and `removeSafe()` below downgrades the consequence of
|
||||
* proceeding early to a warning.
|
||||
*
|
||||
* Waits on `exit` OR `close`, whichever comes first. `exit` alone is wrong for a
|
||||
* *spawn failure* (which emits `error` + `close` and never `exit`, so the wait
|
||||
* would burn both timeouts before proceeding); `close` alone can outlive the
|
||||
* child when a descendant inherited its stdio pipes.
|
||||
*
|
||||
* @param {import("node:child_process").ChildProcess} child
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.label] prefix for warnings, e.g. "pack:verify"
|
||||
* @param {string} [opts.what] what the child is, for warnings
|
||||
* @param {number} [opts.graceMs] non-finite/non-positive falls back to the default
|
||||
* @param {(msg: string) => void} [opts.warn]
|
||||
* @returns {Promise<"already-exited" | "exited" | "gave-up">}
|
||||
*/
|
||||
export function stopChild(
|
||||
child,
|
||||
{
|
||||
label = "cleanup",
|
||||
what = "child process",
|
||||
graceMs: requestedGraceMs = DEFAULT_EXIT_GRACE_MS,
|
||||
warn = console.warn,
|
||||
} = {},
|
||||
) {
|
||||
if (hasExited(child)) return Promise.resolve("already-exited");
|
||||
const graceMs = normalizeGraceMs(requestedGraceMs);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (outcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(forceKill);
|
||||
clearTimeout(deadline);
|
||||
resolve(outcome);
|
||||
};
|
||||
|
||||
const forceKill = setTimeout(() => {
|
||||
warn(
|
||||
`${label} — ${what} did not exit within ${graceMs}ms of SIGTERM; sending SIGKILL`,
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
}, graceMs);
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
warn(
|
||||
`${label} — ${what} did not exit within ${graceMs * 2}ms even after SIGKILL; continuing anyway`,
|
||||
);
|
||||
finish("gave-up");
|
||||
}, graceMs * 2);
|
||||
|
||||
child.once("exit", () => finish("exited"));
|
||||
child.once("close", () => finish("exited"));
|
||||
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `rmSync(path, { recursive, force })` that warns instead of throwing.
|
||||
*
|
||||
* Never let a leftover temp path change a script's verdict: on the success path
|
||||
* a throw here would fail a run that actually passed, and on a failure path it
|
||||
* would bury the real diagnostic under an rmSync stack. The OS reclaims tmpdir.
|
||||
*
|
||||
* @param {string} path
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.label]
|
||||
* @param {(msg: string) => void} [opts.warn]
|
||||
* @returns {boolean} true if the path is gone — which includes a path that was
|
||||
* never there, since `force: true` makes a missing path a no-op success.
|
||||
* False means removal threw and a warning was emitted.
|
||||
*/
|
||||
export function removeSafe(
|
||||
path,
|
||||
{ label = "cleanup", warn = console.warn } = {},
|
||||
) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
warn(`${label} — could not remove ${path}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
174
scripts/lib/child-cleanup.test.mjs
Normal file
174
scripts/lib/child-cleanup.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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: /);
|
||||
});
|
||||
|
|
@ -39,10 +39,11 @@
|
|||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { hasExited, removeSafe, stopChild } from "./lib/child-cleanup.mjs";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..");
|
||||
const testServer = join(
|
||||
|
|
@ -69,13 +70,22 @@ let workDir = null;
|
|||
// own `finally { stop() }` is skipped when fail() calls process.exit()).
|
||||
let webChild = null;
|
||||
|
||||
const LABEL = "pack:verify";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\npack:verify FAILED — ${message}`);
|
||||
if (webChild && webChild.exitCode === null) {
|
||||
// fail() is the single failure-exit point and is called from synchronous
|
||||
// contexts (top-level statements, loop bodies), so it cannot await the
|
||||
// child's exit the way the success path does — it best-effort signals and
|
||||
// moves on. That is why removeSafe() matters most here: this path is already
|
||||
// exiting 1, so an ENOTEMPTY could only bury the real diagnostic under an
|
||||
// rmSync stack (and skip the "tarball retained" hint below), never turn a
|
||||
// green run red.
|
||||
if (webChild && !hasExited(webChild)) {
|
||||
webChild.kill("SIGTERM");
|
||||
}
|
||||
if (workDir) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
removeSafe(workDir, { label: LABEL });
|
||||
// `tarball` is initialized before `workDir` is ever set, so this is safe.
|
||||
console.error(
|
||||
`pack:verify — tarball retained for inspection at ${tarball}`,
|
||||
|
|
@ -327,8 +337,13 @@ try {
|
|||
// Success: clean up both the work dir and the tarball. (This is reached only
|
||||
// on success — every failure path goes through fail() → process.exit(), which
|
||||
// does its own cleanup above and never returns here.)
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
rmSync(tarball, { force: true });
|
||||
//
|
||||
// verifyWeb() has already awaited the `--web` child's exit before returning
|
||||
// (#1826), so nothing of ours is still running inside `work` here. Removal is
|
||||
// still routed through removeSafe(): a leftover temp dir must never fail a run
|
||||
// that actually passed.
|
||||
removeSafe(work, { label: LABEL });
|
||||
removeSafe(tarball, { label: LABEL });
|
||||
|
||||
console.log(
|
||||
"\npack:verify OK — published tarball installs clean and the real bin drives " +
|
||||
|
|
@ -374,10 +389,21 @@ async function verifyWeb(bin, cwd) {
|
|||
exited = true;
|
||||
exitCode = code;
|
||||
});
|
||||
const stop = () => {
|
||||
if (!exited) child.kill("SIGTERM");
|
||||
webChild = null;
|
||||
};
|
||||
|
||||
// Signal the server AND wait for it to be gone before returning: the caller
|
||||
// removes `work` (the installed package the child is running out of, and its
|
||||
// cwd) as its very next statement, and `child.kill()` only delivers the
|
||||
// signal (#1826 — the same kill-then-remove race #1801 hit in smoke:tui).
|
||||
// Escalates to SIGKILL and ultimately proceeds with a warning, so a wedged
|
||||
// server can never hang the verify.
|
||||
const stop = () =>
|
||||
stopChild(child, {
|
||||
label: LABEL,
|
||||
what: "`--web` server",
|
||||
graceMs: Number(process.env.PACK_VERIFY_EXIT_GRACE_MS ?? 5000),
|
||||
}).finally(() => {
|
||||
webChild = null;
|
||||
});
|
||||
|
||||
try {
|
||||
let res = null;
|
||||
|
|
@ -409,6 +435,6 @@ async function verifyWeb(bin, cwd) {
|
|||
fail("`--web` served HTML is missing the injected auth-token value");
|
||||
}
|
||||
} finally {
|
||||
stop();
|
||||
await stop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@
|
|||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { removeSafe } from "./lib/child-cleanup.mjs";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..");
|
||||
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
||||
|
|
@ -126,18 +127,12 @@ const EXIT_GRACE_MS = Number(process.env.SMOKE_TUI_EXIT_GRACE_MS ?? 5000);
|
|||
const DRAIN_MS = 500;
|
||||
|
||||
function cleanup() {
|
||||
try {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Never turn a passing smoke into a failure over a leftover temp dir; the
|
||||
// OS reclaims tmpdir anyway. On the normal path this should not fire, since
|
||||
// removal waits for the child's `close` (see done()) — but the two give-up
|
||||
// branches there call finish() without it, and a process may still hold the
|
||||
// dir, so this is expected rather than anomalous on those.
|
||||
console.warn(
|
||||
`smoke:tui — could not remove temp dir ${work}: ${err.message}`,
|
||||
);
|
||||
}
|
||||
// Never turn a passing smoke into a failure over a leftover temp dir; the OS
|
||||
// reclaims tmpdir anyway. On the normal path a warning should not fire, since
|
||||
// removal waits for the child's `close` (see done()) — but the two give-up
|
||||
// branches there call finish() without it, and a process may still hold the
|
||||
// dir, so it is expected rather than anomalous on those.
|
||||
removeSafe(work, { label: "smoke:tui" });
|
||||
}
|
||||
|
||||
// Tail of the child's output, for quoting in a diagnostic. Slicing an Ink
|
||||
|
|
@ -184,6 +179,12 @@ function done(code, message) {
|
|||
// never `exit`, so an `exit` wait would hang here until the give-up timer and
|
||||
// then blame a SIGTERM the child never received. `close` also guarantees the
|
||||
// stdio pipes are drained, which is what makes the thunked messages complete.
|
||||
//
|
||||
// This deliberately does NOT use lib/child-cleanup.mjs's `stopChild()` (which
|
||||
// pack-and-verify.mjs uses against the same race): that helper resolves on the
|
||||
// FIRST of exit/close, whereas this wait is entangled with the drain phase —
|
||||
// it must keep waiting for `close` after `exit`, on a shorter re-armed
|
||||
// deadline, so the quoted output is complete. Only `removeSafe()` is shared.
|
||||
const forceKill = setTimeout(() => child.kill("SIGKILL"), EXIT_GRACE_MS);
|
||||
|
||||
// Deadline for the wait. Re-armed shorter once the child is gone, since from
|
||||
|
|
|
|||
Loading…
Reference in a new issue