mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user