diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index c5bc67926..805899e94 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ }, runExternalCliJsonCommand: vi.fn(), runExternalCliTextCommand: vi.fn(), + createNodeEntrypointInvocation: vi.fn(() => ({ + command: "node", + args: [], + env: {}, + })), spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), @@ -59,11 +64,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ })); vi.mock("./runtime-paths.js", () => ({ - createNodeEntrypointInvocation: vi.fn(() => ({ - command: "node", - args: [], - env: {}, - })), + createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ entryPath: "/tmp/daemon.js", execArgv: [], @@ -112,6 +113,8 @@ describe("daemon-manager commands", () => { mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); + mocks.createNodeEntrypointInvocation.mockReset(); + mocks.createNodeEntrypointInvocation.mockReturnValue({ command: "node", args: [], env: {} }); mocks.spawnProcess.mockReset(); mocks.logInfo.mockReset(); mocks.logError.mockReset(); @@ -439,6 +442,9 @@ describe("daemon-manager commands", () => { expect(message).toContain("Daemon failed to start: exit code 1"); expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); expect(message).toContain("recent daemon failure"); + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); expect(mocks.spawnProcess).toHaveBeenCalledWith( "node", [], @@ -450,6 +456,47 @@ describe("daemon-manager commands", () => { ); }); + it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { + mocks.runExternalCliJsonCommand.mockResolvedValue({ + localDaemon: "unresponsive", + connectedDaemon: "unreachable", + serverId: "", + pid: 7675, + listen: "127.0.0.1:6767", + desktopManaged: true, + }); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), + ); + }); + + it("does not pass stale lock reclaim when the status command fails", async () => { + mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); + }); + it("returns the Electron main-process log tail from electron-log", () => { writeFileSync( mocks.appLogPath, diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 331454e45..6393a5bb5 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -385,10 +385,12 @@ async function startDaemon(): Promise { } const daemonRunner = resolveDaemonRunnerEntrypoint(); + const reclaimStalePidLock = + current.status === "errored" && current.desktopManaged && current.error === null; const invocation = createNodeEntrypointInvocation({ entrypoint: daemonRunner, argvMode: "node-script", - args: [], + args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], baseEnv: process.env, }); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index d7d23d41b..b379e6179 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -5,6 +5,7 @@ import { acquirePidLock, PidLockError, releasePidLock, + startPidLockHeartbeat, updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; @@ -17,11 +18,13 @@ process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; + reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; + let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -29,10 +32,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { devMode = true; continue; } + if (arg === "--reclaim-stale-pid-lock") { + reclaimStalePidLock = true; + continue; + } workerArgs.push(arg); } - return { devMode, workerArgs }; + return { devMode, reclaimStalePidLock, workerArgs }; } function resolveWorkerEntry(): string { @@ -109,6 +116,7 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, + reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -120,17 +128,29 @@ async function main(): Promise { } let lockReleased = false; + let requestSupervisorShutdown: ((reason: string) => void) | null = null; + const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, { + ownerPid: process.pid, + onError: (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + if (error instanceof PidLockError) { + requestSupervisorShutdown?.("pid_lock_ownership_lost"); + } + }, + }); const releaseLock = async (): Promise => { if (lockReleased) { return; } lockReleased = true; + stopLockHeartbeat(); await releasePidLock(paseoHome, { ownerPid: process.pid, }); }; - runSupervisor({ + const supervisor = runSupervisor({ name: "DaemonRunner", startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)", resolveWorkerEntry: () => workerEntry, @@ -159,6 +179,7 @@ async function main(): Promise { }, onSupervisorExit: releaseLock, }); + requestSupervisorShutdown = supervisor.requestShutdown; } void main().catch((error) => { diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 6e7c1b032..4e56c9612 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -47,6 +47,10 @@ interface SupervisorOptions { logFile?: SupervisorLogFileOptions; } +export interface SupervisorController { + requestShutdown(reason: string): void; +} + function describeExit(code: number | null, signal: NodeJS.Signals | null): string { return signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); } @@ -105,7 +109,7 @@ function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined }); } -export function runSupervisor(options: SupervisorOptions): void { +export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); const workerEnv = options.workerEnv ?? process.env; @@ -331,4 +335,6 @@ export function runSupervisor(options: SupervisorOptions): void { process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); spawnWorker(); + + return { requestShutdown }; } diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 527655e1b..1dc6c260b 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,9 +1,17 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js"; +import { + acquirePidLock, + getPidLockInfo, + isLocked, + PidLockError, + refreshPidLock, + releasePidLock, + updatePidLock, +} from "./pid-lock.js"; describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { @@ -22,6 +30,7 @@ describe("pid-lock ownership", () => { const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); + expect(lock?.heartbeat).toBe(true); await ( updatePidLock as unknown as ( @@ -49,4 +58,200 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("keeps a stale heartbeat lock when the recorded pid is alive without a reachability check", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect(isLocked(paseoHome)).resolves.toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.listen).toBeNull(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a stale live lock written by a pre-heartbeat daemon", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-live-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); + const replacementOwnerPid = process.pid + 10_000; + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.heartbeat).toBe(true); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("rejects a heartbeat refresh after another supervisor takes ownership", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-owner-")); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }); + + await expect(refreshPidLock(paseoHome, { ownerPid: process.pid })).rejects.toBeInstanceOf( + PidLockError, + ); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("retries a heartbeat refresh while its owner is rewriting the lock", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-rewrite-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + const lock = await getPidLockInfo(paseoHome); + expect(lock).not.toBeNull(); + + const rewriteHandle = await open(pidPath, "r+"); + await rewriteHandle.truncate(0); + + const refresh = refreshPidLock(paseoHome, { ownerPid: process.pid }); + await new Promise((resolve) => setTimeout(resolve, 250)); + await rewriteHandle.writeFile(JSON.stringify(lock)); + await rewriteHandle.close(); + + await expect(refresh).resolves.toBeUndefined(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a fresh lock when the recorded pid is alive", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-fresh-heartbeat-")); + + try { + await writeFile( + join(paseoHome, "paseo.pid"), + JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: "current-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 96d663e89..9202f49df 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,4 +1,5 @@ -import { open, readFile, unlink, mkdir } from "node:fs/promises"; +import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { hostname } from "node:os"; @@ -11,6 +12,7 @@ export const pidLockInfoSchema = z.object({ uid: z.number(), listen: z.string().nullable(), desktopManaged: z.boolean().optional(), + heartbeat: z.literal(true).optional(), }); export interface PidLockInfo extends z.infer {} @@ -34,6 +36,12 @@ export class PidLockError extends Error { } } +// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. +const PID_LOCK_STALE_MS = 5 * 60_000; +const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; +const PID_LOCK_READ_RETRY_ATTEMPTS = 10; +const PID_LOCK_READ_RETRY_DELAY_MS = 50; + function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -47,6 +55,29 @@ function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } +async function isPidLockFresh(pidPath: string): Promise { + try { + const lockStat = await stat(pidPath); + return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; + } catch { + return false; + } +} + +async function touchPidLockFile(pidPath: string): Promise { + const now = new Date(); + await utimes(pidPath, now, now); +} + +async function readPidLock(pidPath: string): Promise { + try { + const content = await readFile(pidPath, "utf-8"); + return parsePidLockInfo(JSON.parse(content)); + } catch { + return null; + } +} + function resolveOwnerPid(ownerPid?: number): number { if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) { return ownerPid; @@ -54,10 +85,91 @@ function resolveOwnerPid(ownerPid?: number): number { return process.pid; } +interface AcquirePidLockOptions { + ownerPid?: number; + reclaimStaleDesktopLock?: boolean; +} + +function canReclaimLiveLock( + lock: PidLockInfo, + options: AcquirePidLockOptions | undefined, +): boolean { + // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is + // unreachable before it launches the supervisor. Remove after 2027-01-15. + return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; +} + +function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { + return left.pid === right.pid && left.startedAt === right.startedAt; +} + +function createLockHeldError(lock: PidLockInfo): PidLockError { + return new PidLockError( + `Another Paseo daemon is already running (PID ${lock.pid}, started ${lock.startedAt})`, + lock, + ); +} + +async function clearExistingPidLock( + pidPath: string, + existingLock: PidLockInfo, + lockOwnerPid: number, + options: AcquirePidLockOptions | undefined, +): Promise<"already_owned" | "cleared"> { + const lockOwnerRunning = isPidRunning(existingLock.pid); + if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { + await touchPidLockFile(pidPath); + return "already_owned"; + } + + if (lockOwnerRunning) { + const reclaimable = canReclaimLiveLock(existingLock, options); + if (!reclaimable || (await isPidLockFresh(pidPath))) { + throw createLockHeldError(existingLock); + } + + // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + (await isPidLockFresh(pidPath)) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); + } + } + + await unlink(pidPath).catch(() => {}); + return "cleared"; +} + +async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise { + let fd; + try { + fd = await open(pidPath, "wx"); + await fd.write(JSON.stringify(lockInfo)); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") { + throw error; + } + + const raceLock = await readPidLock(pidPath); + if (raceLock) { + throw new PidLockError( + `Another Paseo daemon is already running (PID ${raceLock.pid})`, + raceLock, + ); + } + throw new PidLockError("Failed to acquire PID lock due to race condition"); + } finally { + await fd?.close(); + } +} + export async function acquirePidLock( paseoHome: string, listen: string | null, - options?: { ownerPid?: number }, + options?: AcquirePidLockOptions, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -67,29 +179,15 @@ export async function acquirePidLock( } // Try to read existing lock - let existingLock: PidLockInfo | null = null; - try { - const content = await readFile(pidPath, "utf-8"); - existingLock = parsePidLockInfo(JSON.parse(content)); - } catch { - // No existing lock or invalid JSON - that's fine - } + const existingLock = await readPidLock(pidPath); // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - if (isPidRunning(existingLock.pid)) { - if (existingLock.pid === lockOwnerPid) { - return; - } - - throw new PidLockError( - `Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`, - existingLock, - ); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + if (result === "already_owned") { + return; } - // Stale lock - remove it - await unlink(pidPath).catch(() => {}); } // Create new lock with exclusive flag @@ -99,36 +197,103 @@ export async function acquirePidLock( hostname: hostname(), uid: process.getuid?.() ?? 0, listen, + heartbeat: true, ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; + await writeNewPidLock(pidPath, lockInfo); +} + +export async function refreshPidLock( + paseoHome: string, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); let fd; try { - fd = await open(pidPath, "wx"); - await fd.write(JSON.stringify(lockInfo)); - } catch (err) { - if (isErrnoException(err) && err.code === "EEXIST") { - // Race condition - another process created the file - // Re-read and check - try { - const content = await readFile(pidPath, "utf-8"); - const raceLock = parsePidLockInfo(JSON.parse(content)); - if (raceLock) { - throw new PidLockError( - `Another Paseo daemon is already running (PID ${raceLock.pid})`, - raceLock, - ); - } - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } catch (innerErr) { - if (innerErr instanceof PidLockError) throw innerErr; - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } + fd = await open(pidPath, "r+"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + throw new PidLockError("Cannot refresh PID lock: lock file is missing"); } - throw err; - } finally { - await fd?.close(); + throw error; } + + try { + const lock = await readPidLockFromHandleWithRetry(fd); + if (!lock) { + throw new PidLockError("Cannot refresh PID lock: invalid lock file"); + } + if (lock.pid !== lockOwnerPid) { + throw new PidLockError(`Cannot refresh PID lock owned by PID ${lock.pid}`, lock); + } + const now = new Date(); + await fd.utimes(now, now); + } finally { + await fd.close(); + } +} + +async function readPidLockFromHandle(fd: FileHandle): Promise { + try { + const { size } = await fd.stat(); + if (size === 0) { + return null; + } + const content = Buffer.alloc(size); + const { bytesRead } = await fd.read(content, 0, size, 0); + return parsePidLockInfo(JSON.parse(content.subarray(0, bytesRead).toString("utf-8"))); + } catch { + return null; + } +} + +async function readPidLockFromHandleWithRetry(fd: FileHandle): Promise { + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt += 1) { + const lock = await readPidLockFromHandle(fd); + if (lock) { + return lock; + } + if (attempt < PID_LOCK_READ_RETRY_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); + } + } + return null; +} + +export function startPidLockHeartbeat( + paseoHome: string, + options?: { + ownerPid?: number; + intervalMs?: number; + onError?: (error: unknown) => void; + }, +): () => void { + const intervalMs = options?.intervalMs ?? PID_LOCK_HEARTBEAT_INTERVAL_MS; + let refreshing = false; + + const timer = setInterval(() => { + if (refreshing) { + return; + } + refreshing = true; + refreshPidLock(paseoHome, { ownerPid: options?.ownerPid }) + .catch((error) => { + if (options?.onError) { + options.onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + }) + .finally(() => { + refreshing = false; + }); + }, intervalMs); + timer.unref(); + + return () => clearInterval(timer); } export async function updatePidLock( @@ -138,23 +303,23 @@ export async function updatePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); - const content = await readFile(pidPath, "utf-8"); - const existingLock = parsePidLockInfo(JSON.parse(content)); - if (!existingLock) { - throw new PidLockError("Cannot update PID lock: invalid lock file"); - } - - if (existingLock.pid !== lockOwnerPid) { - throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock); - } - - const updatedLock: PidLockInfo = { - ...existingLock, - ...patch, - }; - const fd = await open(pidPath, "r+"); try { + const existingLock = await readPidLockFromHandleWithRetry(fd); + if (!existingLock) { + throw new PidLockError("Cannot update PID lock: invalid lock file"); + } + if (existingLock.pid !== lockOwnerPid) { + throw new PidLockError( + `Cannot update PID lock owned by PID ${existingLock.pid}`, + existingLock, + ); + } + + const updatedLock: PidLockInfo = { + ...existingLock, + ...patch, + }; await fd.truncate(0); await fd.writeFile(JSON.stringify(updatedLock)); } finally { @@ -182,12 +347,7 @@ export async function releasePidLock( export async function getPidLockInfo(paseoHome: string): Promise { const pidPath = getPidFilePath(paseoHome); - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; - } + return readPidLock(pidPath); } export async function isLocked(