Boot-smoke's post-verdict temp-dir cleanup could throw ENOTEMPTY on macOS when async writers (fsevents/Spotlight, the just-killed child) still touched the throwaway HOME/project dirs, turning an already-decided PASS into a pnpm verify:fast failure. - Add removeTempDir() helper in scripts/boot-smoke.mjs: wraps rmSync with maxRetries/retryDelay and swallows any residual error (never throws) since cleanup always runs after the smoke verdict is decided/printed - Replace both isolatedHome/isolatedProject rmSync call sites (exit handler and EADDRINUSE retry-port branch) with removeTempDir() - Guard main() behind an import.meta.url === pathToFileURL(process.argv[1]) check so requiring boot-smoke.mjs from tests doesn't spawn a real server - Add scripts/__tests__/boot-smoke.test.mjs covering ENOTEMPTY tolerance, always-failing rm, retry/backoff params, and the no-boot-on-import guard Files changed: scripts/__tests__/boot-smoke.test.mjs | 77 +++++++++++++++++++++++++++++++++++ scripts/boot-smoke.mjs | 44 +++++++++++++++++--- 2 files changed, 115 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7662 Fusion-Task-Lineage: 3288d055-4f70-4487-8462-933563214ce5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
78 lines
2.5 KiB
JavaScript
78 lines
2.5 KiB
JavaScript
// FNXC:BootSmoke 2026-07-07-00:00: Regression for the macOS ENOTEMPTY temp-dir
|
|
// cleanup race (FN-7662). Drives an injected `rm` to throw ENOTEMPTY and
|
|
// asserts `removeTempDir` tolerates it (retries/succeeds, or swallows a
|
|
// persistently-throwing remover) so a post-PASS cleanup can never fail the gate.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { removeTempDir } from "../boot-smoke.mjs";
|
|
|
|
function enotempty() {
|
|
const err = new Error("ENOTEMPTY: directory not empty");
|
|
err.code = "ENOTEMPTY";
|
|
return err;
|
|
}
|
|
|
|
test("removeTempDir tolerates ENOTEMPTY on the first call then succeeds", () => {
|
|
let calls = 0;
|
|
const rm = (dir, opts) => {
|
|
calls += 1;
|
|
assert.equal(dir, "/tmp/fusion-boot-smoke-home-xyz");
|
|
assert.equal(opts.recursive, true);
|
|
assert.equal(opts.force, true);
|
|
if (calls === 1) throw enotempty();
|
|
// second call (simulating rmSync's own internal retry succeeding): no throw
|
|
};
|
|
|
|
assert.doesNotThrow(() => {
|
|
removeTempDir("/tmp/fusion-boot-smoke-home-xyz", { rm });
|
|
});
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("removeTempDir never throws even when rm always fails with ENOTEMPTY", () => {
|
|
let calls = 0;
|
|
const rm = () => {
|
|
calls += 1;
|
|
throw enotempty();
|
|
};
|
|
|
|
assert.doesNotThrow(() => {
|
|
removeTempDir("/tmp/fusion-boot-smoke-project-abc", { rm });
|
|
});
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("removeTempDir calls rm with recursive/force and a positive maxRetries", () => {
|
|
let seenOpts;
|
|
const rm = (dir, opts) => {
|
|
seenOpts = opts;
|
|
};
|
|
|
|
removeTempDir("/tmp/fusion-boot-smoke-home-happy", { rm });
|
|
|
|
assert.equal(seenOpts.recursive, true);
|
|
assert.equal(seenOpts.force, true);
|
|
assert.ok(seenOpts.maxRetries > 0, "maxRetries should be positive");
|
|
assert.ok(typeof seenOpts.retryDelay === "number");
|
|
});
|
|
|
|
test("removeTempDir uses the provided maxRetries/retryDelayMs overrides", () => {
|
|
let seenOpts;
|
|
const rm = (dir, opts) => {
|
|
seenOpts = opts;
|
|
};
|
|
|
|
removeTempDir("/tmp/fusion-boot-smoke-project-custom", { rm, maxRetries: 9, retryDelayMs: 250 });
|
|
|
|
assert.equal(seenOpts.maxRetries, 9);
|
|
assert.equal(seenOpts.retryDelay, 250);
|
|
});
|
|
|
|
test("importing boot-smoke.mjs does not boot a server (main() guard holds)", async () => {
|
|
// The module was already imported at the top of this file for `removeTempDir`.
|
|
// If the main()/bootAndVerify() top-level invocation ran unguarded, this test
|
|
// file would hang waiting on a spawned `fn serve` child. Reaching this
|
|
// assertion at all proves the import completed without side effects.
|
|
assert.ok(true);
|
|
});
|