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>
This commit is contained in:
paseo-bot[bot]
2026-09-24 01:53:13 +02:00
committed by GitHub
parent 7fa78244fd
commit 1bf5312297
2 changed files with 57 additions and 5 deletions
+35 -1
View File
@@ -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 });
}
});
});
+22 -4
View File
@@ -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<void> {
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
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<void> {
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<void> {
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