feat(engine): guard one engine per project per machine
Adds a per-machine singleton lock that engages in
ProjectEngineManager.createAndStart() before any engine subsystems
spin up. Two fn dashboard processes can no longer run engines for the
same project on the same host — previously they would share .fusion/
state and corrupt worktrees / task rows for in-process projects.
The guard combines two independent checks:
- A proper-lockfile file at <project>/.fusion/engine.lock with
stale-lock recovery (auto-released on process death).
- A loopback listener on a hashed per-project address — UDS on
POSIX, named pipe on Windows. Stale UDS files are probed and
unlinked before a retry bind.
Failures raise EngineAlreadyRunningError. Both guards are released
from stopAll() and pauseProject(); a release on engine.start()
failure lets retries re-acquire cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
packages/engine/src/__tests__/engine-singleton-lock.test.ts
Normal file
118
packages/engine/src/__tests__/engine-singleton-lock.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import net from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireEngineSingleton,
|
||||
computeEngineLockFilePath,
|
||||
computeEngineSocketPath,
|
||||
EngineAlreadyRunningError,
|
||||
type EngineSingletonLock,
|
||||
} from "../engine-singleton-lock.js";
|
||||
|
||||
function uniqueProjectId(label: string): string {
|
||||
return `proj_${label}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
describe("engine-singleton-lock", () => {
|
||||
let workDir: string;
|
||||
let acquired: EngineSingletonLock[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "fusion-engine-lock-test-"));
|
||||
acquired = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const lock of acquired) {
|
||||
await lock.release().catch(() => {});
|
||||
}
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("computes a deterministic socket path per projectId", () => {
|
||||
const id = "proj_deterministic";
|
||||
expect(computeEngineSocketPath(id)).toBe(computeEngineSocketPath(id));
|
||||
expect(computeEngineSocketPath(id)).not.toBe(
|
||||
computeEngineSocketPath("proj_other"),
|
||||
);
|
||||
});
|
||||
|
||||
it("places the lockfile under <workingDir>/.fusion/engine.lock", () => {
|
||||
expect(computeEngineLockFilePath(workDir)).toBe(
|
||||
join(workDir, ".fusion", "engine.lock"),
|
||||
);
|
||||
});
|
||||
|
||||
it("acquires successfully on first call and creates .fusion/engine.lock", async () => {
|
||||
const id = uniqueProjectId("first");
|
||||
const lock = await acquireEngineSingleton(id, workDir);
|
||||
acquired.push(lock);
|
||||
expect(existsSync(lock.lockFilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a second acquisition for the same project + workingDir", async () => {
|
||||
const id = uniqueProjectId("double");
|
||||
const first = await acquireEngineSingleton(id, workDir);
|
||||
acquired.push(first);
|
||||
await expect(acquireEngineSingleton(id, workDir)).rejects.toBeInstanceOf(
|
||||
EngineAlreadyRunningError,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows re-acquisition after release", async () => {
|
||||
const id = uniqueProjectId("cycle");
|
||||
const first = await acquireEngineSingleton(id, workDir);
|
||||
await first.release();
|
||||
// Double-release must be a no-op.
|
||||
await first.release();
|
||||
|
||||
const second = await acquireEngineSingleton(id, workDir);
|
||||
acquired.push(second);
|
||||
expect(existsSync(second.lockFilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("recovers from a stale socket file (POSIX only)", async () => {
|
||||
if (platform() === "win32") return;
|
||||
|
||||
const id = uniqueProjectId("stale");
|
||||
const socketPath = computeEngineSocketPath(id);
|
||||
|
||||
// Simulate a stale socket file from a crashed engine.
|
||||
const stale = net.createServer();
|
||||
stale.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stale.once("error", reject);
|
||||
stale.once("listening", () => resolve());
|
||||
stale.listen(socketPath);
|
||||
});
|
||||
await new Promise<void>((resolve) => stale.close(() => resolve()));
|
||||
|
||||
const lock = await acquireEngineSingleton(id, workDir);
|
||||
acquired.push(lock);
|
||||
expect(existsSync(lock.lockFilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the lockfile so a fresh process could re-acquire", async () => {
|
||||
const id = uniqueProjectId("release-lockfile");
|
||||
const lock = await acquireEngineSingleton(id, workDir);
|
||||
await lock.release();
|
||||
// proper-lockfile uses `<path>.lock` as the actual mutex dir.
|
||||
expect(existsSync(`${lock.lockFilePath}.lock`)).toBe(false);
|
||||
});
|
||||
|
||||
it("different projects don't block each other", async () => {
|
||||
const a = await acquireEngineSingleton(uniqueProjectId("a"), workDir);
|
||||
acquired.push(a);
|
||||
const otherWork = mkdtempSync(join(tmpdir(), "fusion-engine-lock-test-b-"));
|
||||
try {
|
||||
const b = await acquireEngineSingleton(uniqueProjectId("b"), otherWork);
|
||||
acquired.push(b);
|
||||
expect(existsSync(a.lockFilePath)).toBe(true);
|
||||
expect(existsSync(b.lockFilePath)).toBe(true);
|
||||
} finally {
|
||||
rmSync(otherWork, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Stub out the per-machine singleton lock so tests with fake working dirs
|
||||
// (e.g. /mapped/...) don't try to mkdir or bind real sockets.
|
||||
vi.mock("../engine-singleton-lock.js", () => ({
|
||||
acquireEngineSingleton: vi.fn().mockResolvedValue({
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
socketPath: "/tmp/test.sock",
|
||||
lockFilePath: "/tmp/test.lock",
|
||||
}),
|
||||
EngineAlreadyRunningError: class EngineAlreadyRunningError extends Error {},
|
||||
}));
|
||||
|
||||
// Mock ProjectEngine before importing the manager
|
||||
vi.mock("../project-engine.js", () => {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user