mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
730 lines
19 KiB
TypeScript
730 lines
19 KiB
TypeScript
import { spawnSync, type ChildProcess } from "node:child_process";
|
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
|
import treeKill from "tree-kill";
|
|
import { tryConnectToDaemon } from "../../utils/client.js";
|
|
|
|
export interface DaemonStartOptions {
|
|
port?: string;
|
|
listen?: string;
|
|
home?: string;
|
|
foreground?: boolean;
|
|
relay?: boolean;
|
|
relayUseTls?: boolean;
|
|
mcp?: boolean;
|
|
injectMcp?: boolean;
|
|
hostnames?: string;
|
|
}
|
|
|
|
export interface LocalDaemonPidInfo {
|
|
pid: number;
|
|
startedAt?: string;
|
|
hostname?: string;
|
|
uid?: number;
|
|
listen?: string;
|
|
desktopManaged?: boolean;
|
|
}
|
|
|
|
export interface LocalDaemonState {
|
|
home: string;
|
|
listen: string;
|
|
relayEnabled: boolean;
|
|
relayEndpoint: string;
|
|
relayUseTls: boolean;
|
|
relayPublicUseTls: boolean;
|
|
logPath: string;
|
|
pidPath: string;
|
|
pidInfo: LocalDaemonPidInfo | null;
|
|
running: boolean;
|
|
stalePidFile: boolean;
|
|
}
|
|
|
|
export interface DetachedStartResult {
|
|
pid: number | null;
|
|
logPath: string;
|
|
}
|
|
|
|
export interface StopLocalDaemonOptions {
|
|
home?: string;
|
|
timeoutMs?: number;
|
|
killTimeoutMs?: number;
|
|
force?: boolean;
|
|
}
|
|
|
|
export interface StopLocalDaemonResult {
|
|
action: "stopped" | "not_running";
|
|
home: string;
|
|
pid: number | null;
|
|
forced: boolean;
|
|
message: string;
|
|
}
|
|
|
|
interface ProcessExitDetails {
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
error?: Error;
|
|
}
|
|
|
|
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
|
|
|
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
|
|
|
|
export interface ForegroundDaemonProcessResult {
|
|
status: number | null;
|
|
error?: Error;
|
|
}
|
|
|
|
export interface DaemonLaunchRuntime {
|
|
resolveRunnerEntry(): string;
|
|
resolveHome(env: NodeJS.ProcessEnv): string;
|
|
spawnDetached(
|
|
command: string,
|
|
args: string[],
|
|
options: Parameters<typeof spawnProcess>[2],
|
|
): DetachedDaemonProcess;
|
|
spawnForeground(
|
|
command: string,
|
|
args: string[],
|
|
options: Parameters<typeof spawnSync>[2],
|
|
): ForegroundDaemonProcessResult;
|
|
}
|
|
|
|
const DETACHED_STARTUP_GRACE_MS = 1200;
|
|
const PID_POLL_INTERVAL_MS = 100;
|
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
|
const DAEMON_PID_FILENAME = "paseo.pid";
|
|
|
|
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
|
|
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
|
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
|
resolveHome: resolvePaseoHome,
|
|
spawnDetached: spawnProcess,
|
|
spawnForeground: spawnSync,
|
|
};
|
|
|
|
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
|
|
|
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
|
exitedEarly: true,
|
|
...details,
|
|
});
|
|
|
|
function envWithHome(home?: string): NodeJS.ProcessEnv {
|
|
if (!home) {
|
|
return process.env;
|
|
}
|
|
|
|
return { ...process.env, PASEO_HOME: home };
|
|
}
|
|
|
|
function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
|
const args: string[] = [];
|
|
if (options.relay === false) {
|
|
args.push("--no-relay");
|
|
}
|
|
if (options.relayUseTls === true) {
|
|
args.push("--relay-use-tls");
|
|
}
|
|
|
|
if (options.mcp === false) {
|
|
args.push("--no-mcp");
|
|
}
|
|
if (options.injectMcp === false) {
|
|
args.push("--no-inject-mcp");
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
|
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
if (options.home) {
|
|
childEnv.PASEO_HOME = options.home;
|
|
}
|
|
if (options.listen) {
|
|
childEnv.PASEO_LISTEN = options.listen;
|
|
} else if (options.port) {
|
|
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
|
}
|
|
if (options.hostnames) {
|
|
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
|
}
|
|
if (options.relayUseTls === true) {
|
|
childEnv.PASEO_RELAY_USE_TLS = "true";
|
|
}
|
|
return childEnv;
|
|
}
|
|
|
|
function resolveServerRunnerFromDir(currentDir: string): string | null {
|
|
const packageJsonPath = path.join(currentDir, "package.json");
|
|
if (!existsSync(packageJsonPath)) return null;
|
|
try {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
|
|
if (packageJson.name !== "@getpaseo/server") return null;
|
|
const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js");
|
|
if (existsSync(distRunner)) {
|
|
return distRunner;
|
|
}
|
|
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveDaemonRunnerEntry(): string {
|
|
const serverExportPath = require.resolve("@getpaseo/server");
|
|
let currentDir = path.dirname(serverExportPath);
|
|
|
|
while (true) {
|
|
const entry = resolveServerRunnerFromDir(currentDir);
|
|
if (entry) {
|
|
return entry;
|
|
}
|
|
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
break;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
|
|
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
|
|
}
|
|
|
|
function pidFilePath(paseoHome: string): string {
|
|
return path.join(paseoHome, DAEMON_PID_FILENAME);
|
|
}
|
|
|
|
function resolveListenField(listen: unknown, sockPath: unknown): string | undefined {
|
|
if (typeof listen === "string") return listen;
|
|
if (typeof sockPath === "string") return sockPath;
|
|
return undefined;
|
|
}
|
|
|
|
function resolveStopMessage(
|
|
forced: boolean,
|
|
lifecycleRequested: boolean,
|
|
fallbackMessage: string | null | undefined,
|
|
): string {
|
|
if (forced) return "Daemon owner process was force-stopped";
|
|
if (lifecycleRequested) return "Daemon stopped gracefully";
|
|
return fallbackMessage ?? "Daemon stopped via owner PID signal";
|
|
}
|
|
|
|
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
|
const pidValue = parsed.pid;
|
|
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
pid: pidValue,
|
|
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
|
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
|
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
|
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
|
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tailFile(filePath: string, lines = 30): string | null {
|
|
try {
|
|
const content = readFileSync(filePath, "utf-8");
|
|
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function readNodeErrnoCode(error: unknown): string | undefined {
|
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
return undefined;
|
|
}
|
|
|
|
return typeof error.code === "string" ? error.code : undefined;
|
|
}
|
|
|
|
function isProcessRunning(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
const code = readNodeErrnoCode(err);
|
|
if (code === "EPERM") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
|
try {
|
|
process.kill(pid, signal);
|
|
return true;
|
|
} catch (err) {
|
|
const code = readNodeErrnoCode(err);
|
|
if (code === "ESRCH") {
|
|
return false;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return signalProcess(pid, signal);
|
|
} catch (err) {
|
|
const code = readNodeErrnoCode(err);
|
|
if (code === "EPERM") {
|
|
return true;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> {
|
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
|
return false;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
treeKill(pid, signal, (err) => {
|
|
if (!err) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
|
|
const code = readNodeErrnoCode(err);
|
|
if (code === "ESRCH") {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
if (code === "EPERM") {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function signalProcessTreeOrOwnerSafely(
|
|
pid: number,
|
|
signal: NodeJS.Signals,
|
|
): Promise<boolean> {
|
|
try {
|
|
return await signalProcessTreeSafely(pid, signal);
|
|
} catch {
|
|
return signalProcessSafely(pid, signal);
|
|
}
|
|
}
|
|
|
|
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
async function poll(): Promise<boolean> {
|
|
if (!isProcessRunning(pid)) return true;
|
|
if (Date.now() >= deadline) return !isProcessRunning(pid);
|
|
await sleep(PID_POLL_INTERVAL_MS);
|
|
return poll();
|
|
}
|
|
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 {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
export function resolveLocalPaseoHome(home?: string): string {
|
|
return resolvePaseoHome(envWithHome(home));
|
|
}
|
|
|
|
export function resolveTcpHostFromListen(listen: string): string | null {
|
|
const normalized = listen.trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
normalized.startsWith("/") ||
|
|
normalized.startsWith("unix://") ||
|
|
normalized.startsWith("pipe://") ||
|
|
normalized.startsWith("\\\\.\\pipe\\") ||
|
|
/^[A-Za-z]:[/\\]/.test(normalized)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (/^\d+$/.test(normalized)) {
|
|
return `127.0.0.1:${normalized}`;
|
|
}
|
|
|
|
if (normalized.includes(":")) {
|
|
return normalized;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...envWithHome(options.home),
|
|
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
|
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
|
|
PASEO_LISTEN: undefined,
|
|
PASEO_HOSTNAMES: undefined,
|
|
PASEO_ALLOWED_HOSTS: undefined,
|
|
PASEO_RELAY_ENABLED: undefined,
|
|
PASEO_RELAY_ENDPOINT: undefined,
|
|
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
|
|
PASEO_RELAY_USE_TLS: undefined,
|
|
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
|
|
};
|
|
const home = resolvePaseoHome(env);
|
|
const config = loadConfig(home, { env });
|
|
const pidPath = pidFilePath(home);
|
|
const logPath = path.join(home, DAEMON_LOG_FILENAME);
|
|
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
|
|
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
|
|
const listen = pidInfo?.listen ?? config.listen;
|
|
|
|
return {
|
|
home,
|
|
listen,
|
|
relayEnabled: config.relayEnabled ?? true,
|
|
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
|
|
relayUseTls: config.relayUseTls ?? false,
|
|
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
|
|
logPath,
|
|
pidPath,
|
|
pidInfo,
|
|
running,
|
|
stalePidFile: Boolean(pidInfo) && !running,
|
|
};
|
|
}
|
|
|
|
export function tailDaemonLog(home?: string, lines = 30): string | null {
|
|
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
|
|
return tailFile(logPath, lines);
|
|
}
|
|
|
|
export async function startLocalDaemonDetached(
|
|
options: DaemonStartOptions,
|
|
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
|
): Promise<DetachedStartResult> {
|
|
if (options.listen && options.port) {
|
|
throw new Error("Cannot use --listen and --port together");
|
|
}
|
|
|
|
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
|
const childEnv = buildChildEnv(options);
|
|
|
|
const paseoHome = runtime.resolveHome(childEnv);
|
|
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
|
const child = runtime.spawnDetached(
|
|
process.execPath,
|
|
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
|
{
|
|
detached: true,
|
|
envMode: "internal",
|
|
env: childEnv,
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
},
|
|
);
|
|
|
|
child.unref();
|
|
|
|
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
|
let settled = false;
|
|
|
|
const finish = (value: DetachedStartupResult) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(value);
|
|
};
|
|
|
|
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
|
|
|
|
child.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
finish(startupExited({ code: null, signal: null, error }));
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
clearTimeout(timer);
|
|
finish(startupExited({ code, signal }));
|
|
});
|
|
});
|
|
|
|
if (startup.exitedEarly) {
|
|
const reason = startup.error
|
|
? startup.error.message
|
|
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
|
|
const recentLogs = tailFile(logPath);
|
|
throw new Error(
|
|
[
|
|
`Daemon failed to start in background (${reason}).`,
|
|
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n"),
|
|
);
|
|
}
|
|
|
|
return {
|
|
pid: child.pid ?? null,
|
|
logPath,
|
|
};
|
|
}
|
|
|
|
export function startLocalDaemonForeground(
|
|
options: DaemonStartOptions,
|
|
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
|
): number {
|
|
if (options.listen && options.port) {
|
|
throw new Error("Cannot use --listen and --port together");
|
|
}
|
|
|
|
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
|
const childEnv = buildChildEnv(options);
|
|
const result = runtime.spawnForeground(
|
|
process.execPath,
|
|
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
|
{
|
|
env: childEnv,
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
return result.status ?? 1;
|
|
}
|
|
|
|
async function requestLifecycleShutdown(
|
|
state: LocalDaemonState,
|
|
timeoutMs: number,
|
|
): Promise<LifecycleShutdownAttempt> {
|
|
const host = resolveTcpHostFromListen(state.listen);
|
|
if (!host) {
|
|
return {
|
|
requested: false,
|
|
reason: "daemon listen target is not TCP, falling back to owner PID signal",
|
|
};
|
|
}
|
|
|
|
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
|
|
if (!client) {
|
|
return {
|
|
requested: false,
|
|
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
await client.shutdownServer();
|
|
return { requested: true };
|
|
} catch (error) {
|
|
return {
|
|
requested: false,
|
|
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
|
|
error,
|
|
)}), falling back to owner PID signal`,
|
|
};
|
|
} finally {
|
|
await client.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function stopLocalDaemon(
|
|
options: StopLocalDaemonOptions = {},
|
|
): Promise<StopLocalDaemonResult> {
|
|
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
|
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
|
const state = resolveLocalDaemonState({ home: options.home });
|
|
|
|
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
|
|
const lifecycleRequested = shutdownAttempt.requested;
|
|
|
|
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,
|
|
pid,
|
|
forced,
|
|
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
|
|
};
|
|
}
|