/** * Unit tests for the per-HOME advisory lock. * See change: single-dashboard-per-home. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { canonicalHomedir, getLockPath, getMetaPath, readMetadata, writeMetadataAtomic, removeMetadata, acquireOrAttach, isLockHolderResponsive, isLockDisabled, InstanceLockMismatchError, type LockMetadata, } from "../home-lock.js"; // Fresh tmp dir per test → real FS (proper-lockfile needs real FS semantics). let tmpHome: string; let lockPath: string; let metaPath: string; beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-home-lock-test-")); lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock"); metaPath = `${lockPath}.meta.json`; }); afterEach(() => { try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } }); function baseConfig(overrides: Partial[0]> = {}) { return { httpPort: 8000, piPort: 9999, version: "0.0.0-test", hooks: { lockPath, metaPath, staleMs: 500, probeHealth: async () => ({ running: false }), isProcessAlive: () => false, ...(overrides.hooks ?? {}), }, ...overrides, }; } describe("canonicalHomedir + paths", () => { it("returns a path containing .pi/dashboard/server.lock", () => { const p = getLockPath(); expect(p.endsWith(path.join(".pi", "dashboard", "server.lock"))).toBe(true); }); it("getMetaPath appends .meta.json", () => { expect(getMetaPath("/x/y/server.lock")).toBe("/x/y/server.lock.meta.json"); }); it("canonicalHomedir survives even when homedir is unreadable (tolerant)", () => { expect(typeof canonicalHomedir()).toBe("string"); }); it("ignores $HOME env override — lock path always derives from os.homedir()", () => { // The design (§4) explicitly states $HOME must NOT influence the lock // path: Git Bash sets $HOME=/c/Users/R while os.homedir()=C:\Users\R, // which would otherwise produce two divergent canonical locks. Here we // prove the invariant by construction: mutate process.env.HOME and // verify getLockPath() doesn't change. const original = process.env.HOME; const before = getLockPath(); try { process.env.HOME = "/garbage/not/a/real/path/" + Math.random(); const after = getLockPath(); expect(after).toBe(before); } finally { if (original === undefined) delete process.env.HOME; else process.env.HOME = original; } }); it("symlinked homedir canonicalizes to the same lock path on repeated calls", () => { const real = fs.mkdtempSync(path.join(os.tmpdir(), "pi-real-")); const link = path.join(os.tmpdir(), `pi-link-${Date.now()}-${Math.random()}`); fs.symlinkSync(real, link); try { const a = fs.realpathSync(link); const b = fs.realpathSync(link); expect(a).toBe(b); expect(a).toBe(fs.realpathSync(real)); } finally { try { fs.unlinkSync(link); } catch { /* ignore */ } fs.rmSync(real, { recursive: true, force: true }); } }); }); describe("writeMetadataAtomic + readMetadata", () => { it("round-trips a metadata object", () => { const meta: LockMetadata = { pid: 1, ppid: 0, httpPort: 8000, piPort: 9999, startedAt: 1, identity: "i", version: "v", url: "http://localhost:8000", hostname: "h", }; writeMetadataAtomic(meta, metaPath); expect(readMetadata(metaPath)).toEqual(meta); }); it("readMetadata returns null when file is missing", () => { expect(readMetadata(metaPath)).toBeNull(); }); it("readMetadata returns null when JSON is corrupt", () => { fs.mkdirSync(path.dirname(metaPath), { recursive: true }); fs.writeFileSync(metaPath, "{not json"); expect(readMetadata(metaPath)).toBeNull(); }); it("readMetadata returns null for shape-mismatched JSON", () => { fs.mkdirSync(path.dirname(metaPath), { recursive: true }); fs.writeFileSync(metaPath, JSON.stringify({ foo: "bar" })); expect(readMetadata(metaPath)).toBeNull(); }); it("removeMetadata is silent on missing file", () => { expect(() => removeMetadata(metaPath)).not.toThrow(); }); }); describe("isLockHolderResponsive", () => { const meta: LockMetadata = { pid: 12345, ppid: 0, httpPort: 8000, piPort: 9999, startedAt: 0, identity: "id-A", version: "v", url: "http://localhost:8000", hostname: "h", }; it("returns 'dead' when PID is gone", async () => { const result = await isLockHolderResponsive(meta, { isProcessAlive: () => false }); expect(result).toBe("dead"); }); it("returns 'dead' when port is not responding", async () => { const result = await isLockHolderResponsive(meta, { isProcessAlive: () => true, probeHealth: async () => ({ running: false }), }); expect(result).toBe("dead"); }); it("returns 'alive-match' when identity matches", async () => { const result = await isLockHolderResponsive(meta, { isProcessAlive: () => true, probeHealth: async () => ({ running: true, identity: "id-A", pid: 12345 }), }); expect(result).toBe("alive-match"); }); it("returns 'alive-mismatch' when identity differs", async () => { const result = await isLockHolderResponsive(meta, { isProcessAlive: () => true, probeHealth: async () => ({ running: true, identity: "id-B", pid: 99999 }), }); expect(result).toBe("alive-mismatch"); }); it("falls back to PID match when identity missing", async () => { const matchByPid = await isLockHolderResponsive(meta, { isProcessAlive: () => true, probeHealth: async () => ({ running: true, pid: 12345 }), }); expect(matchByPid).toBe("alive-match"); const misMatchByPid = await isLockHolderResponsive(meta, { isProcessAlive: () => true, probeHealth: async () => ({ running: true, pid: 99999 }), }); expect(misMatchByPid).toBe("alive-mismatch"); }); }); describe("acquireOrAttach", () => { it("acquires a fresh lock and writes metadata", async () => { const result = await acquireOrAttach(baseConfig()); expect(result.mode).toBe("acquired"); const meta = readMetadata(metaPath); expect(meta).not.toBeNull(); expect(meta?.pid).toBe(process.pid); expect(meta?.httpPort).toBe(8000); if (result.mode === "acquired") await result.release(); }); it("release() removes the metadata sidecar", async () => { const result = await acquireOrAttach(baseConfig()); expect(result.mode).toBe("acquired"); if (result.mode === "acquired") { await result.release(); expect(readMetadata(metaPath)).toBeNull(); } }); it("release() is idempotent", async () => { const result = await acquireOrAttach(baseConfig()); if (result.mode === "acquired") { await result.release(); await expect(result.release()).resolves.toBeUndefined(); } }); it("attaches when a live dashboard already holds the lock", async () => { // Acquire as "another process" first. const first = await acquireOrAttach(baseConfig({ identity: "first-instance", })); expect(first.mode).toBe("acquired"); // Now mount a probe that says the first is alive + matches. const second = await acquireOrAttach(baseConfig({ hooks: { lockPath, metaPath, staleMs: 500, isProcessAlive: () => true, probeHealth: async () => ({ running: true, identity: "first-instance", pid: process.pid }), }, })); expect(second.mode).toBe("attach"); if (second.mode === "attach") { expect(second.meta.identity).toBe("first-instance"); } if (first.mode === "acquired") await first.release(); }); it("throws InstanceLockMismatchError on identity mismatch", async () => { const first = await acquireOrAttach(baseConfig({ identity: "mine" })); expect(first.mode).toBe("acquired"); await expect( acquireOrAttach(baseConfig({ hooks: { lockPath, metaPath, staleMs: 500, isProcessAlive: () => true, probeHealth: async () => ({ running: true, identity: "someone-else", pid: 99999 }), }, })), ).rejects.toBeInstanceOf(InstanceLockMismatchError); if (first.mode === "acquired") await first.release(); }); it("steals a stale lock (process dead)", async () => { const first = await acquireOrAttach(baseConfig({ identity: "stale-holder" })); expect(first.mode).toBe("acquired"); // Don't release — simulate a crash. Then attempt to reacquire with // isProcessAlive=false → steal path. // proper-lockfile's `stale` option needs the staleMs to have elapsed. // We pass a 1ms stale threshold in baseConfig via the hooks override. await new Promise(r => setTimeout(r, 50)); const second = await acquireOrAttach(baseConfig({ hooks: { lockPath, metaPath, staleMs: 1, isProcessAlive: () => false, probeHealth: async () => ({ running: false }), }, })); expect(second.mode).toBe("acquired"); if (second.mode === "acquired") await second.release(); }); it("steals lock when metadata is corrupt", async () => { const first = await acquireOrAttach(baseConfig()); expect(first.mode).toBe("acquired"); // Corrupt metadata but leave proper-lockfile in place. fs.writeFileSync(metaPath, "{not json"); await new Promise(r => setTimeout(r, 50)); const second = await acquireOrAttach(baseConfig({ hooks: { lockPath, metaPath, staleMs: 1, isProcessAlive: () => false, probeHealth: async () => ({ running: false }), }, })); expect(second.mode).toBe("acquired"); if (second.mode === "acquired") await second.release(); }); }); describe("isLockDisabled", () => { it("returns true for PI_DASHBOARD_ALLOW_MULTIPLE=1", () => { expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "1" })).toBe(true); }); it("returns true for PI_DASHBOARD_ALLOW_MULTIPLE=true", () => { expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "true" })).toBe(true); }); it("returns false when unset", () => { expect(isLockDisabled({})).toBe(false); }); it("returns false for other values", () => { expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "0" })).toBe(false); expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "yes" })).toBe(false); }); });