From 1bf531229763331804fc42b9ea66a407a5138327 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:53:13 +0200 Subject: [PATCH] Start the daemon when an empty paseo.pid was left behind (#5306) A supervisor killed between creating paseo.pid and writing its contents leaves an empty file. Every later start, status, and stop then failed with "Cannot read daemon state", so the daemon stayed down until someone deleted the file by hand. An empty lock names no owner, so after the existing read retries it now reads as no lock, and acquiring the lock removes it before creating a new one. A lock with unparseable contents still refuses, since it may belong to a supervisor this version cannot read. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/src/server/pid-lock.test.ts | 36 ++++++++++++++++++++- packages/server/src/server/pid-lock.ts | 26 ++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index e54093f177..70e2eddae3 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, open, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -256,4 +256,38 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("starts over an empty lock file left by a supervisor killed before writing it", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-")); + const ownerPid = process.pid + 10_000; + + try { + await writeFile(join(paseoHome, "paseo.pid"), ""); + + await expect(getPidLockInfo(paseoHome)).resolves.toBeNull(); + await acquirePidLock(paseoHome, null, { ownerPid }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(ownerPid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a lock file whose contents cannot be read as a lock", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile(pidPath, JSON.stringify({ pid: "unknown" })); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Cannot read daemon state"); + + await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" })); + } 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 bffab63063..3005dce9d0 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, utimes } from "node:fs/promises"; +import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; import { join } from "node:path"; @@ -84,18 +84,26 @@ async function touchPidLockFile(pidPath: string): Promise { async function readPidLock(pidPath: string): Promise { let lastError: unknown; + let empty = false; for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) { try { const content = await readFile(pidPath, "utf-8"); - const lock = parsePidLockInfo(JSON.parse(content)); - if (lock) return lock; - lastError = new Error("Invalid lock shape"); + empty = content === ""; + if (!empty) { + const lock = parsePidLockInfo(JSON.parse(content)); + if (lock) return lock; + lastError = new Error("Invalid lock shape"); + } } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") return null; + empty = false; lastError = error; } await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); } + // A supervisor writes its lock right after creating the file, so a file still empty + // after the retries was abandoned in between and names no owner. + if (empty) return null; throw Object.assign( new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`), { code: "DAEMON_STATE_READ_FAILED" }, @@ -149,6 +157,14 @@ async function clearExistingPidLock( return "cleared"; } +async function removeEmptyPidLock(pidPath: string): Promise { + try { + if ((await stat(pidPath)).size === 0) await unlink(pidPath); + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } +} + async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise { let fd; try { @@ -191,6 +207,8 @@ export async function acquirePidLock( if (result === "already_owned") { return; } + } else { + await removeEmptyPidLock(pidPath); } // Create new lock with exclusive flag