diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts new file mode 100644 index 000000000..8f93bface --- /dev/null +++ b/src/agents/session-write-lock.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { acquireSessionWriteLock } from "./session-write-lock.js"; + +describe("acquireSessionWriteLock", () => { + it("reuses locks across symlinked session paths", async () => { + if (process.platform === "win32") { + expect(true).toBe(true); + return; + } + + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + try { + const realDir = path.join(root, "real"); + const linkDir = path.join(root, "link"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, linkDir); + + const sessionReal = path.join(realDir, "sessions.json"); + const sessionLink = path.join(linkDir, "sessions.json"); + + const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 }); + const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 }); + + await lockB.release(); + await lockA.release(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 99478c2cd..54e61d965 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -45,20 +45,28 @@ export async function acquireSessionWriteLock(params: { }> { const timeoutMs = params.timeoutMs ?? 10_000; const staleMs = params.staleMs ?? 30 * 60 * 1000; - const sessionFile = params.sessionFile; - const lockPath = `${sessionFile}.lock`; - await fs.mkdir(path.dirname(lockPath), { recursive: true }); + const sessionFile = path.resolve(params.sessionFile); + const sessionDir = path.dirname(sessionFile); + await fs.mkdir(sessionDir, { recursive: true }); + let normalizedDir = sessionDir; + try { + normalizedDir = await fs.realpath(sessionDir); + } catch { + // Fall back to the resolved path if realpath fails (permissions, transient FS). + } + const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile)); + const lockPath = `${normalizedSessionFile}.lock`; - const held = HELD_LOCKS.get(sessionFile); + const held = HELD_LOCKS.get(normalizedSessionFile); if (held) { held.count += 1; return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); }, @@ -75,14 +83,14 @@ export async function acquireSessionWriteLock(params: { JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), "utf8", ); - HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath }); + HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath }); return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); },