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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user