diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index d7d23d41b..fced9b0e5 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"; @@ -120,11 +121,15 @@ async function main(): Promise { } let lockReleased = false; + const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, { + ownerPid: process.pid, + }); const releaseLock = async (): Promise => { if (lockReleased) { return; } lockReleased = true; + stopLockHeartbeat(); await releasePidLock(paseoHome, { ownerPid: process.pid, }); diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 527655e1b..9834abe4e 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -49,4 +49,62 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("reclaims a stale lock when the recorded pid is alive but not refreshing it", 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, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }); + + 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 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, + }), + ); + + 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..1d2c116b1 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,4 +1,4 @@ -import { open, readFile, unlink, mkdir } from "node:fs/promises"; +import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { hostname } from "node:os"; @@ -34,6 +34,10 @@ 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; + function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -47,6 +51,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; @@ -67,22 +94,17 @@ 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; - } - + const lockOwnerRunning = isPidRunning(existingLock.pid); + if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { + await touchPidLockFile(pidPath); + return; + } + if (lockOwnerRunning && (await isPidLockFresh(pidPath))) { throw new PidLockError( `Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`, existingLock, @@ -131,6 +153,45 @@ export async function acquirePidLock( } } +export async function refreshPidLock( + paseoHome: string, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); + const lock = await readPidLock(pidPath); + 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); + } + await touchPidLockFile(pidPath); +} + +export function startPidLockHeartbeat( + paseoHome: string, + options?: { ownerPid?: number; intervalMs?: number }, +): () => void { + const intervalMs = options?.intervalMs ?? PID_LOCK_HEARTBEAT_INTERVAL_MS; + let refreshing = false; + + const timer = setInterval(() => { + if (refreshing) { + return; + } + refreshing = true; + refreshPidLock(paseoHome, options) + .catch(() => undefined) + .finally(() => { + refreshing = false; + }); + }, intervalMs); + timer.unref(); + + return () => clearInterval(timer); +} + export async function updatePidLock( paseoHome: string, patch: { listen: string }, @@ -182,12 +243,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( @@ -197,7 +253,8 @@ export async function isLocked( if (!info) { return { locked: false }; } - if (!isPidRunning(info.pid)) { + const pidPath = getPidFilePath(paseoHome); + if (!isPidRunning(info.pid) || !(await isPidLockFresh(pidPath))) { return { locked: false, info }; } return { locked: true, info };