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:
gsxdsm
2026-05-19 22:50:39 -07:00
parent 959f7cd4eb
commit 98033bc869
8 changed files with 458 additions and 3 deletions

View 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 });
}
});
});

View File

@@ -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 {