fix: address Copilot review round 1 (#2339)
- A non-ELOCKED acquire error with the lock directory still present (a stale lock the library could not remove: ENOTEMPTY, EACCES, EROFS) now waits like a held lock instead of degrading to an unleased run; only a lock that cannot be created at all degrades. - Spawn with a shell on Windows (npm is npm.cmd there) and quote the arguments for cmd.exe via the existing winShellArgs; exported as spawnSpec so the branch is unit-tested from POSIX. - Release the lease on every way out, including a spawn failure, via a finally around the child wait. - Guard every lock removal (release and the library's exit handler) on the directory's identity, mirroring core/auth/node/file-lock.ts, so a holder whose lock was taken over never removes the winner's. - The signal test stops the wrapper in a finally, so a failed setup assertion cannot leak its idle grandchild. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
f99402918d
commit
60bbe7675f
1 changed files with 249 additions and 32 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
|
@ -35,6 +36,7 @@ import {
|
|||
main,
|
||||
readHolder,
|
||||
runUnderLease,
|
||||
spawnSpec,
|
||||
} from "./gate-lease.mjs";
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL("./gate-lease.mjs", import.meta.url));
|
||||
|
|
@ -125,6 +127,24 @@ test("exitCodeFor: the child's code, else 128 + signal", () => {
|
|||
assert.equal(exitCodeFor({ code: null, signal: "SIGNOPE" }), 128);
|
||||
});
|
||||
|
||||
test("spawnSpec: shell-free and grouped on POSIX; cmd.exe-quoted on Windows", () => {
|
||||
// `npm` is `npm.cmd` on Windows and cannot be started without a shell, and
|
||||
// a shell re-parses any argument holding a space — the same pair of rules
|
||||
// `scripts/install-clients.mjs` and `scripts/lib/win-shell-args.mjs` encode.
|
||||
const args = ["run", "local:gate:stages", "a b"];
|
||||
assert.deepEqual(spawnSpec("npm", args, "darwin"), {
|
||||
command: "npm",
|
||||
args,
|
||||
shell: false,
|
||||
detached: true,
|
||||
});
|
||||
assert.deepEqual(spawnSpec("npm", args, "linux").detached, true);
|
||||
const win = spawnSpec("npm", args, "win32");
|
||||
assert.equal(win.shell, true);
|
||||
assert.equal(win.detached, false);
|
||||
assert.deepEqual(win.args, ["run", "local:gate:stages", '"a b"']);
|
||||
});
|
||||
|
||||
test("readHolder/describeHolder: a missing or malformed record is not an error", () => {
|
||||
const dir = freshDir();
|
||||
assert.equal(readHolder(dir), null);
|
||||
|
|
@ -214,6 +234,73 @@ test("a stale lock (its holder died) is taken over, not waited on", async () =>
|
|||
assert.ok(!existsSync(lockPathOf(dir)));
|
||||
});
|
||||
|
||||
test("a stale lock that cannot be removed is waited on, not bypassed", async () => {
|
||||
const dir = freshDir();
|
||||
// Stale by mtime, but with a file inside — the library's takeover `rmdir`
|
||||
// fails ENOTEMPTY, which is not ELOCKED. Something still holds the path,
|
||||
// so degrading to an unleased run here would be the overlap this prevents.
|
||||
mkdirSync(lockPathOf(dir), { recursive: true });
|
||||
writeFileSync(join(lockPathOf(dir), "stray"), "");
|
||||
const dead = new Date(Date.now() - STALE_MS - 1_000);
|
||||
utimesSync(lockPathOf(dir), dead, dead);
|
||||
const { lines, log } = collectLog();
|
||||
await assert.rejects(runNode("", { dir, log, maxWaitMs: 0 }), /gave up/);
|
||||
assert.equal(
|
||||
lines.filter((l) => l.includes("running without")).length,
|
||||
0,
|
||||
lines.join("\n"),
|
||||
);
|
||||
assert.ok(existsSync(lockPathOf(dir)));
|
||||
});
|
||||
|
||||
test("a command that cannot be spawned still releases the lease", async () => {
|
||||
const dir = freshDir();
|
||||
const { log } = collectLog();
|
||||
await assert.rejects(
|
||||
runUnderLease({
|
||||
command: join(dir, "no-such-binary"),
|
||||
args: [],
|
||||
dir,
|
||||
log,
|
||||
stdio: "ignore",
|
||||
pollMs: 25,
|
||||
}),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
assert.ok(
|
||||
!existsSync(lockPathOf(dir)),
|
||||
"the lease must not outlive the failed spawn",
|
||||
);
|
||||
assert.ok(!existsSync(leaseTarget(dir)));
|
||||
});
|
||||
|
||||
test("a lock replaced mid-run (stale takeover) is the winner's, and is left alone", async () => {
|
||||
const dir = freshDir();
|
||||
const { lines, log } = collectLog();
|
||||
const run = runNode("setTimeout(() => {}, 400)", { dir, log });
|
||||
await waitFor(() => existsSync(leaseTarget(dir)));
|
||||
// What a waiter does after our refresh timer was starved past STALE_MS:
|
||||
// remove our directory and create its own. A new inode, so the guard can
|
||||
// tell it is not ours — and must not `rmdir` it on our way out, or a third
|
||||
// gate would run beside the winner.
|
||||
rmSync(lockPathOf(dir), { recursive: true });
|
||||
mkdirSync(lockPathOf(dir));
|
||||
writeFileSync(
|
||||
leaseTarget(dir),
|
||||
JSON.stringify({ pid: 1, cwd: "/winner", startedAt: Date.now() }),
|
||||
);
|
||||
assert.equal(await run, 0);
|
||||
assert.ok(
|
||||
existsSync(lockPathOf(dir)),
|
||||
"the winner's lock survived our release",
|
||||
);
|
||||
assert.equal(readHolder(dir)?.cwd, "/winner", "and so did its record");
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes("taken over by another gate")),
|
||||
lines.join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a live lock that never releases fails the wait loudly, naming the holder", async () => {
|
||||
const dir = freshDir();
|
||||
// A fresh lock directory nobody refreshes is indistinguishable from a live
|
||||
|
|
@ -310,12 +397,17 @@ test("a signal to the wrapper stops the whole tree, releases the lease and exits
|
|||
wrapper.once("exit", (code, signal) => resolve({ code, signal })),
|
||||
);
|
||||
|
||||
await waitFor(() => existsSync(pidFile));
|
||||
const grandchild = Number(readFileSync(pidFile, "utf8"));
|
||||
assert.ok(isAlive(grandchild));
|
||||
assert.ok(existsSync(lockPathOf(dir)), "the wrapper held the lease");
|
||||
|
||||
wrapper.kill("SIGTERM");
|
||||
let grandchild = null;
|
||||
try {
|
||||
await waitFor(() => existsSync(pidFile));
|
||||
grandchild = Number(readFileSync(pidFile, "utf8"));
|
||||
assert.ok(isAlive(grandchild));
|
||||
assert.ok(existsSync(lockPathOf(dir)), "the wrapper held the lease");
|
||||
} finally {
|
||||
// On every path — a failed setup assertion included — the wrapper is
|
||||
// told to stop, so an idle grandchild can never outlive the suite.
|
||||
wrapper.kill("SIGTERM");
|
||||
}
|
||||
const outcome = await exited;
|
||||
assert.deepEqual(outcome, { code: 143, signal: null }, stderr);
|
||||
await waitFor(() => !isAlive(grandchild));
|
||||
|
|
|
|||
Loading…
Reference in a new issue