The desktop app could time out connecting to the daemon on first launch because the PID file advertised a listen address before the HTTP server was actually listening. The supervisor now writes the PID lock with listen: null and updates it only after the worker sends a paseo:ready IPC message confirming the server is listening. - Rename daemon-runner.ts to supervisor-entrypoint.ts - PID lock acquired with listen: null, updated via paseo:ready IPC - Worker sends paseo:ready after httpServer.listen() resolves - Desktop polls until listen is non-null before returning to the app - Remove PASEO_PID_LOCK_MODE and external lock mode - Remove unnecessary env overrides (PASEO_HOME, PASEO_CORS_ORIGINS) from desktop daemon spawn - Reduce trace log bloat: inbound/outbound WebSocket messages now log only message type and payload size instead of full payloads - Supervisor restarts worker on SIGKILL (covers OOM)
188 lines
4.7 KiB
TypeScript
188 lines
4.7 KiB
TypeScript
import { open, readFile, unlink, mkdir } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { hostname } from "node:os";
|
|
|
|
export interface PidLockInfo {
|
|
pid: number;
|
|
startedAt: string;
|
|
hostname: string;
|
|
uid: number;
|
|
listen: string | null;
|
|
}
|
|
|
|
export class PidLockError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly existingLock?: PidLockInfo,
|
|
) {
|
|
super(message);
|
|
this.name = "PidLockError";
|
|
}
|
|
}
|
|
|
|
function isPidRunning(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getPidFilePath(paseoHome: string): string {
|
|
return join(paseoHome, "paseo.pid");
|
|
}
|
|
|
|
function resolveOwnerPid(ownerPid?: number): number {
|
|
if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
return ownerPid;
|
|
}
|
|
return process.pid;
|
|
}
|
|
|
|
export async function acquirePidLock(
|
|
paseoHome: string,
|
|
listen: string | null,
|
|
options?: { ownerPid?: number },
|
|
): Promise<void> {
|
|
const pidPath = getPidFilePath(paseoHome);
|
|
|
|
// Ensure paseoHome directory exists
|
|
if (!existsSync(paseoHome)) {
|
|
await mkdir(paseoHome, { recursive: true });
|
|
}
|
|
|
|
// Try to read existing lock
|
|
let existingLock: PidLockInfo | null = null;
|
|
try {
|
|
const content = await readFile(pidPath, "utf-8");
|
|
existingLock = JSON.parse(content) as PidLockInfo;
|
|
} catch {
|
|
// No existing lock or invalid JSON - that's fine
|
|
}
|
|
|
|
// Check if existing lock is stale
|
|
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
|
if (existingLock) {
|
|
if (isPidRunning(existingLock.pid)) {
|
|
if (existingLock.pid === lockOwnerPid) {
|
|
return;
|
|
}
|
|
|
|
throw new PidLockError(
|
|
`Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`,
|
|
existingLock,
|
|
);
|
|
}
|
|
// Stale lock - remove it
|
|
await unlink(pidPath).catch(() => {});
|
|
}
|
|
|
|
// Create new lock with exclusive flag
|
|
const lockInfo: PidLockInfo = {
|
|
pid: lockOwnerPid,
|
|
startedAt: new Date().toISOString(),
|
|
hostname: hostname(),
|
|
uid: process.getuid?.() ?? 0,
|
|
listen,
|
|
};
|
|
|
|
let fd;
|
|
try {
|
|
fd = await open(pidPath, "wx");
|
|
await fd.write(JSON.stringify(lockInfo));
|
|
} catch (err: any) {
|
|
if (err.code === "EEXIST") {
|
|
// Race condition - another process created the file
|
|
// Re-read and check
|
|
try {
|
|
const content = await readFile(pidPath, "utf-8");
|
|
const raceLock = JSON.parse(content) as PidLockInfo;
|
|
throw new PidLockError(
|
|
`Another Paseo daemon is already running (PID ${raceLock.pid})`,
|
|
raceLock,
|
|
);
|
|
} catch (innerErr) {
|
|
if (innerErr instanceof PidLockError) throw innerErr;
|
|
throw new PidLockError("Failed to acquire PID lock due to race condition");
|
|
}
|
|
}
|
|
throw err;
|
|
} finally {
|
|
await fd?.close();
|
|
}
|
|
}
|
|
|
|
export async function updatePidLock(
|
|
paseoHome: string,
|
|
patch: { listen: string },
|
|
options?: { ownerPid?: number },
|
|
): Promise<void> {
|
|
const pidPath = getPidFilePath(paseoHome);
|
|
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
|
const content = await readFile(pidPath, "utf-8");
|
|
const existingLock = JSON.parse(content) as PidLockInfo;
|
|
|
|
if (existingLock.pid !== lockOwnerPid) {
|
|
throw new PidLockError(
|
|
`Cannot update PID lock owned by PID ${existingLock.pid}`,
|
|
existingLock,
|
|
);
|
|
}
|
|
|
|
const updatedLock: PidLockInfo = {
|
|
...existingLock,
|
|
...patch,
|
|
};
|
|
|
|
const fd = await open(pidPath, "r+");
|
|
try {
|
|
await fd.truncate(0);
|
|
await fd.writeFile(JSON.stringify(updatedLock));
|
|
} finally {
|
|
await fd.close();
|
|
}
|
|
}
|
|
|
|
export async function releasePidLock(
|
|
paseoHome: string,
|
|
options?: { ownerPid?: number },
|
|
): Promise<void> {
|
|
const pidPath = getPidFilePath(paseoHome);
|
|
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
|
try {
|
|
// Only remove if it's our lock
|
|
const content = await readFile(pidPath, "utf-8");
|
|
const lock = JSON.parse(content) as PidLockInfo;
|
|
if (lock.pid === lockOwnerPid) {
|
|
await unlink(pidPath);
|
|
}
|
|
} catch {
|
|
// Ignore errors - lock may already be gone
|
|
}
|
|
}
|
|
|
|
export async function getPidLockInfo(paseoHome: string): Promise<PidLockInfo | null> {
|
|
const pidPath = getPidFilePath(paseoHome);
|
|
try {
|
|
const content = await readFile(pidPath, "utf-8");
|
|
return JSON.parse(content) as PidLockInfo;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function isLocked(
|
|
paseoHome: string,
|
|
): Promise<{ locked: boolean; info?: PidLockInfo }> {
|
|
const info = await getPidLockInfo(paseoHome);
|
|
if (!info) {
|
|
return { locked: false };
|
|
}
|
|
if (!isPidRunning(info.pid)) {
|
|
return { locked: false, info };
|
|
}
|
|
return { locked: true, info };
|
|
}
|