mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(server): reclaim abandoned daemon locks
The supervisor now keeps the pid lock fresh while it owns it. Startup still respects a live PID with a fresh lock, but can reclaim a lock whose owner PID is alive without refreshing it.
This commit is contained in:
@@ -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<void> {
|
||||
}
|
||||
|
||||
let lockReleased = false;
|
||||
const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, {
|
||||
ownerPid: process.pid,
|
||||
});
|
||||
const releaseLock = async (): Promise<void> => {
|
||||
if (lockReleased) {
|
||||
return;
|
||||
}
|
||||
lockReleased = true;
|
||||
stopLockHeartbeat();
|
||||
await releasePidLock(paseoHome, {
|
||||
ownerPid: process.pid,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
const lockStat = await stat(pidPath);
|
||||
return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function touchPidLockFile(pidPath: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await utimes(pidPath, now, now);
|
||||
}
|
||||
|
||||
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
|
||||
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<void> {
|
||||
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<PidLockInfo | null> {
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user