Recover stale supervised daemons

Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.

Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.

Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
This commit is contained in:
Mohamed Boudra
2026-06-07 23:18:48 +07:00
parent 7c8b290e2f
commit 893e3376b0
7 changed files with 769 additions and 115 deletions
+147 -38
View File
@@ -1,5 +1,5 @@
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
@@ -351,6 +351,123 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
return poll();
}
async function waitForDaemonUnreachable(
state: LocalDaemonState,
timeoutMs: number,
): Promise<boolean> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return true;
}
const reachableHost = host;
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
if (!client) {
return true;
}
await client.close().catch(() => undefined);
if (Date.now() >= deadline) {
const finalClient = await tryConnectToDaemon({
host: reachableHost,
timeout: PID_POLL_INTERVAL_MS,
});
if (!finalClient) {
return true;
}
await finalClient.close().catch(() => undefined);
return false;
}
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
function removeStalePidFile(state: LocalDaemonState): void {
if (!state.stalePidFile) {
return;
}
try {
unlinkSync(state.pidPath);
} catch {
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
}
}
function createNotRunningStopResult(
state: LocalDaemonState,
pid: number | null,
message: string,
): StopLocalDaemonResult {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message,
};
}
function createStopTimeoutError(
state: LocalDaemonState,
pid: number | null,
timeoutMs: number,
): Error {
if (!state.running) {
const host = resolveTcpHostFromListen(state.listen);
return new Error(
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
timeoutMs / 1000,
)}s`,
);
}
return new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
async function signalDaemonOwnerForStop(
state: LocalDaemonState,
pid: number | null,
): Promise<StopLocalDaemonResult | null> {
if (pid === null) {
return createNotRunningStopResult(state, null, "Daemon is not running");
}
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (signaled) {
return null;
}
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
}
async function waitForStopAfterRequest(args: {
state: LocalDaemonState;
pid: number | null;
timeoutMs: number;
killTimeoutMs: number;
force?: boolean;
}): Promise<{ stopped: boolean; forced: boolean }> {
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
let stopped =
state.running && pid !== null
? await waitForPidExit(pid, timeoutMs)
: await waitForDaemonUnreachable(state, timeoutMs);
if (!stopped && force && state.running && pid !== null) {
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
return { stopped, forced: true };
}
return { stopped, forced: false };
}
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
@@ -567,49 +684,41 @@ export async function stopLocalDaemon(
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return {
action: "not_running",
home: state.home,
pid: state.pidInfo?.pid ?? null,
forced: false,
message: `Daemon is not running${staleSuffix}`,
};
}
const pid = state.pidInfo.pid;
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const lifecycleRequested = shutdownAttempt.requested;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
let forced = false;
if (!lifecycleRequested) {
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (!signaled) {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message: "Daemon process was already stopped",
};
}
}
let stopped = await waitForPidExit(pid, timeoutMs);
if (!stopped && options.force) {
forced = true;
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
}
if (!stopped) {
throw new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return createNotRunningStopResult(
state,
state.pidInfo?.pid ?? null,
`Daemon is not running${staleSuffix}`,
);
}
const pid = state.pidInfo?.pid ?? null;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
if (!lifecycleRequested) {
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
if (notRunningResult) return notRunningResult;
}
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs,
killTimeoutMs,
force: options.force,
});
if (!stopped) {
throw createStopTimeoutError(state, pid, timeoutMs);
}
if (lifecycleRequested) {
removeStalePidFile(state);
}
return {
action: "stopped",
home: state.home,