chore(test): add cross-worktree test lock and clean up dev-server test child

- scripts/test-with-lock.mjs: pnpm test:locked acquires an exclusive
  ~/.fusion/test.lock (O_EXLOCK) before running pnpm test, so several
  Claude Code worktrees on the same machine serialize their vitest
  fan-out instead of saturating the box. Prints the holding PID and
  worktree path while waiting.
- dev-server-manager test children now park on stdin instead of
  setInterval so manager.shutdown() can deterministically exit them
  via stdin close, preventing orphaned node processes outliving the
  test run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-02 15:56:46 -07:00
parent 2f13a31e13
commit 1e6714e4df
5 changed files with 206 additions and 31 deletions

View File

@@ -30,7 +30,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 4_000): Promise<voi
} }
function longRunningCommand(previewUrl = "http://127.0.0.1:4173/preview"): DevServerStartOptions { function longRunningCommand(previewUrl = "http://127.0.0.1:4173/preview"): DevServerStartOptions {
const script = `console.log('ready ${previewUrl}'); setInterval(() => {}, 1000);`; const script = `console.log('ready ${previewUrl}');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));`;
return { return {
command: `node -e \"${script}\"`, command: `node -e \"${script}\"`,
scriptName: "dev", scriptName: "dev",

View File

@@ -60,7 +60,7 @@ describe("DevServerManager", () => {
}); });
await manager.start({ await manager.start({
command: "node -e \"console.log('preview at http://localhost:5173/'); setInterval(() => {}, 1000)\"", command: "node -e \"console.log('preview at http://localhost:5173/');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });
@@ -82,7 +82,7 @@ describe("DevServerManager", () => {
await store.updateState({ manualUrl: "https://localhost:9999" }); await store.updateState({ manualUrl: "https://localhost:9999" });
await manager.start({ await manager.start({
command: "node -e \"console.log('ready at http://localhost:4321/'); setInterval(() => {}, 1000)\"", command: "node -e \"console.log('ready at http://localhost:4321/');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });
@@ -106,7 +106,7 @@ describe("DevServerManager", () => {
managers.push(manager); managers.push(manager);
await manager.start({ await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"", command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });
@@ -125,7 +125,7 @@ describe("DevServerManager", () => {
managers.push(manager); managers.push(manager);
await manager.start({ await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"", command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });
@@ -141,7 +141,7 @@ describe("DevServerManager", () => {
managers.push(manager); managers.push(manager);
await manager.start({ await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"", command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });
@@ -160,7 +160,7 @@ describe("DevServerManager", () => {
managers.push(manager); managers.push(manager);
await manager.start({ await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"", command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptName: "dev", scriptName: "dev",
}); });

View File

@@ -60,7 +60,7 @@ describe("DevServerProcessManager", () => {
it("start() spawns child process and updates state to running", async () => { it("start() spawns child process and updates state to running", async () => {
const { root, manager } = await createManager(); const { root, manager } = await createManager();
const state = await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); const state = await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
expect(state.status).toBe("running"); expect(state.status).toBe("running");
expect(typeof state.pid).toBe("number"); expect(typeof state.pid).toBe("number");
@@ -73,14 +73,14 @@ describe("DevServerProcessManager", () => {
manager.once("started", () => resolve()); manager.once("started", () => resolve());
}); });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
await startedEvent; await startedEvent;
}); });
it("start() captures stdout into log buffer", async () => { it("start() captures stdout into log buffer", async () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start("node -e \"console.log('hello from stdout'); setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"console.log('hello from stdout');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
await waitFor(() => store.getState().logHistory.some((line) => line.includes("hello from stdout"))); await waitFor(() => store.getState().logHistory.some((line) => line.includes("hello from stdout")));
@@ -90,8 +90,8 @@ describe("DevServerProcessManager", () => {
it("start() throws if already running", async () => { it("start() throws if already running", async () => {
const { root, manager } = await createManager(); const { root, manager } = await createManager();
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
await expect(manager.start("node -e \"setInterval(() => {}, 1000)\"", root)).rejects.toThrow("already running"); await expect(manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root)).rejects.toThrow("already running");
}); });
it("start() throws if command is empty", async () => { it("start() throws if command is empty", async () => {
@@ -102,7 +102,7 @@ describe("DevServerProcessManager", () => {
it("stop() sends SIGTERM and waits for exit", async () => { it("stop() sends SIGTERM and waits for exit", async () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
const state = await manager.stop(); const state = await manager.stop();
expect(state.status).toBe("stopped"); expect(state.status).toBe("stopped");
@@ -119,7 +119,7 @@ describe("DevServerProcessManager", () => {
const childPidFile = join(root, "managed-child.pid"); const childPidFile = join(root, "managed-child.pid");
await manager.start( await manager.start(
`node -e "require('node:fs').writeFileSync('${childPidFile}', String(process.pid)); setInterval(() => {}, 1000)"`, `node -e "require('node:fs').writeFileSync('${childPidFile}', String(process.pid));process.stdin.resume();process.stdin.on('end',()=>process.exit(0))"`,
root, root,
); );
@@ -143,7 +143,7 @@ describe("DevServerProcessManager", () => {
const { root, store, manager } = await createManager({ stopTimeoutMs: 150 }); const { root, store, manager } = await createManager({ stopTimeoutMs: 150 });
await manager.start( await manager.start(
"node -e \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"", "node -e \"process.on('SIGTERM', () => {});process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
root, root,
); );
@@ -162,7 +162,7 @@ describe("DevServerProcessManager", () => {
it("restart() stops then starts with same command", async () => { it("restart() stops then starts with same command", async () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root, { scriptId: "dev" }); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root, { scriptId: "dev" });
const firstPid = store.getState().pid; const firstPid = store.getState().pid;
const state = await manager.restart(); const state = await manager.restart();
@@ -176,7 +176,7 @@ describe("DevServerProcessManager", () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start( await manager.start(
"node -e \"console.log('Server ready at http://localhost:3000'); setInterval(() => {}, 1000)\"", "node -e \"console.log('Server ready at http://localhost:3000');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
root, root,
); );
@@ -188,7 +188,7 @@ describe("DevServerProcessManager", () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start( await manager.start(
"node -e \"console.log('ready at http://127.0.0.1:4173'); setInterval(() => {}, 1000)\"", "node -e \"console.log('ready at http://127.0.0.1:4173');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
root, root,
); );
@@ -200,7 +200,7 @@ describe("DevServerProcessManager", () => {
const { root, store, manager } = await createManager(); const { root, store, manager } = await createManager();
await manager.start( await manager.start(
"node -e \"console.log('Listening on port 5173'); setInterval(() => {}, 1000)\"", "node -e \"console.log('Listening on port 5173');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
root, root,
); );
@@ -211,7 +211,7 @@ describe("DevServerProcessManager", () => {
it("schedules fallback probing after startup when no URL is announced", async () => { it("schedules fallback probing after startup when no URL is announced", async () => {
const { root, manager } = await createManager({ probeDelayMs: 25, probeTimeoutMs: 5 }); const { root, manager } = await createManager({ probeDelayMs: 25, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
expect(manager.hasPendingProbeTimer()).toBe(true); expect(manager.hasPendingProbeTimer()).toBe(true);
await waitFor(() => manager.hasPendingProbeTimer() === false, 3_000); await waitFor(() => manager.hasPendingProbeTimer() === false, 3_000);
@@ -221,7 +221,7 @@ describe("DevServerProcessManager", () => {
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 }); const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start( await manager.start(
"node -e \"console.log('ready at http://localhost:4321'); setInterval(() => {}, 1000)\"", "node -e \"console.log('ready at http://localhost:4321');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
root, root,
); );
@@ -232,7 +232,7 @@ describe("DevServerProcessManager", () => {
it("clears fallback probe timer on stop", async () => { it("clears fallback probe timer on stop", async () => {
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 }); const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
expect(manager.hasPendingProbeTimer()).toBe(true); expect(manager.hasPendingProbeTimer()).toBe(true);
await manager.stop(); await manager.stop();
@@ -254,7 +254,7 @@ describe("DevServerProcessManager", () => {
it("restarts with a fresh fallback probe timer", async () => { it("restarts with a fresh fallback probe timer", async () => {
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 }); const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root, { scriptId: "dev" }); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root, { scriptId: "dev" });
expect(manager.hasPendingProbeTimer()).toBe(true); expect(manager.hasPendingProbeTimer()).toBe(true);
await manager.restart(); await manager.restart();
@@ -265,7 +265,7 @@ describe("DevServerProcessManager", () => {
it("cleanup() kills process and clears listeners", async () => { it("cleanup() kills process and clears listeners", async () => {
const { root, manager } = await createManager(); const { root, manager } = await createManager();
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root); await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
manager.on("output", () => undefined); manager.on("output", () => undefined);
expect(manager.listenerCount("output")).toBeGreaterThan(0); expect(manager.listenerCount("output")).toBeGreaterThan(0);

View File

@@ -222,7 +222,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"" }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"" }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -238,7 +238,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"", cwd: root }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -258,7 +258,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"", cwd: root }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -266,7 +266,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"", cwd: root }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -282,7 +282,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"", cwd: root }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -311,7 +311,7 @@ describe("createDevServerRouter", () => {
app, app,
"POST", "POST",
"/api/dev-server/start", "/api/dev-server/start",
JSON.stringify({ command: "node -e \"setInterval(() => {}, 1000)\"", cwd: root, scriptId: "dev" }), JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root, scriptId: "dev" }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
); );
@@ -319,7 +319,7 @@ describe("createDevServerRouter", () => {
expect(restartRes.status).toBe(200); expect(restartRes.status).toBe(200);
expect(restartRes.body).toMatchObject({ expect(restartRes.body).toMatchObject({
status: "running", status: "running",
command: "node -e \"setInterval(() => {}, 1000)\"", command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptId: "dev", scriptId: "dev",
}); });
}); });

175
scripts/test-with-lock.mjs Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* test-with-lock.mjs
*
* Serializes `pnpm test` across concurrent git-worktree agent sessions so
* multiple Claude Code instances don't saturate the machine with vitest forks.
*
* Acquires an exclusive lock at ~/.fusion/test.lock (Darwin/Linux, O_EXLOCK)
* before running the underlying test command, then releases it on exit.
* While waiting it prints the PID and worktree path of the lock holder so
* the developer knows who is blocking.
*
* Usage: pnpm test:locked [extra args passed to pnpm test]
* e.g.: pnpm test:locked --filter @fusion/core
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { spawn } from "node:child_process";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LOCK_DIR = path.join(os.homedir(), ".fusion");
const LOCK_FILE = path.join(LOCK_DIR, "test.lock");
const META_FILE = path.join(LOCK_DIR, "test.lock.meta");
const POLL_MS = 1_500;
// O_EXLOCK is a BSD/Darwin extension; value 0x20 on macOS.
// On Linux this flag is silently ignored by glibc — fall back to a best-effort
// advisory lock using a separate meta-file race (good enough for the single
// macOS use-case described in the brief).
const O_EXLOCK = 0x20;
const O_CREAT = fs.constants.O_CREAT;
const O_RDWR = fs.constants.O_RDWR;
const O_NONBLOCK = fs.constants.O_NONBLOCK;
const isMacOS = process.platform === "darwin";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Read PID + worktree from the meta file, or return null on any error. */
function readMeta() {
try {
const raw = fs.readFileSync(META_FILE, "utf8").trim();
const [pidStr, ...rest] = raw.split("\n");
return { pid: Number(pidStr), worktree: rest.join("\n") || "(unknown)" };
} catch {
return null;
}
}
/** Write our PID + CWD into the meta file so waiters can identify us. */
function writeMeta() {
fs.writeFileSync(META_FILE, `${process.pid}\n${process.cwd()}`, "utf8");
}
/** Remove meta file, ignoring errors. */
function cleanMeta() {
try { fs.unlinkSync(META_FILE); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Lock acquisition (macOS O_EXLOCK, non-blocking with busy-wait)
// ---------------------------------------------------------------------------
let lockFd = -1;
function ensureLockDir() {
fs.mkdirSync(LOCK_DIR, { recursive: true });
}
/**
* Try to open the lock file with O_EXLOCK | O_NONBLOCK.
* Returns true on success, false if another process holds the lock.
* Throws on unexpected errors.
*/
function tryAcquire() {
if (!isMacOS) {
// Non-macOS: use a simple existence check (advisory, not atomic, but
// sufficient for the documented single-platform use case).
try {
// O_EXCL + O_CREAT is atomic on POSIX for the create step.
lockFd = fs.openSync(LOCK_FILE, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR);
return true;
} catch (err) {
if (err.code === "EEXIST") return false;
throw err;
}
}
try {
lockFd = fs.openSync(LOCK_FILE, O_CREAT | O_RDWR | O_EXLOCK | O_NONBLOCK);
return true;
} catch (err) {
if (err.code === "EWOULDBLOCK" || err.code === "EAGAIN") return false;
throw err;
}
}
function releaseLock() {
if (lockFd >= 0) {
try { fs.closeSync(lockFd); } catch { /* ignore */ }
lockFd = -1;
}
// Remove the lock file so the next waiter's O_EXCL create succeeds on Linux.
if (!isMacOS) {
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
}
cleanMeta();
}
/** Block until we hold the lock, printing status while waiting. */
async function acquireWithWait() {
ensureLockDir();
let waited = false;
while (!tryAcquire()) {
if (!waited) {
const meta = readMeta();
if (meta) {
console.log(
`[test-with-lock] waiting for test lock held by PID ${meta.pid} (worktree: ${meta.worktree})`,
);
} else {
console.log("[test-with-lock] waiting for test lock…");
}
waited = true;
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
writeMeta();
if (waited) {
console.log("[test-with-lock] lock acquired, starting tests.");
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
// Release on any kind of exit so we don't leave stale locks.
for (const sig of ["exit", "SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(sig, () => {
releaseLock();
if (sig !== "exit") process.exit(1);
});
}
await acquireWithWait();
// Forward all argv after the script name to `pnpm test`.
const extraArgs = process.argv.slice(2);
const child = spawn(
"pnpm",
["test", ...extraArgs],
{ stdio: "inherit", shell: false },
);
child.on("close", (code) => {
releaseLock();
process.exit(code ?? 1);
});
child.on("error", (err) => {
console.error("[test-with-lock] failed to spawn pnpm:", err.message);
releaseLock();
process.exit(1);
});