feat(desktop): simplify quit lifecycle with sync PID check and shutdown dialog
Replace the async daemon status resolution during quit with a synchronous PID file check, avoiding races when the event loop is shutting down. Show a blocking dialog so users know the daemon is stopping. Add --kill-timeout to the CLI stop command for the desktop app to control SIGKILL wait time.
This commit is contained in:
@@ -27,6 +27,7 @@ export function createDaemonCommand(): Command {
|
|||||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||||
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
|
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
|
||||||
.option("--force", "Send SIGKILL if graceful stop times out")
|
.option("--force", "Send SIGKILL if graceful stop times out")
|
||||||
|
.option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)")
|
||||||
.action(withOutput(runStopCommand));
|
.action(withOutput(runStopCommand));
|
||||||
|
|
||||||
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
|
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface DetachedStartResult {
|
|||||||
export interface StopLocalDaemonOptions {
|
export interface StopLocalDaemonOptions {
|
||||||
home?: string;
|
home?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
killTimeoutMs?: number;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +65,11 @@ type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & P
|
|||||||
|
|
||||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||||
const PID_POLL_INTERVAL_MS = 100;
|
const PID_POLL_INTERVAL_MS = 100;
|
||||||
const KILL_TIMEOUT_MS = 3000;
|
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||||
|
|
||||||
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
|
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
|
||||||
|
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -494,6 +495,7 @@ export async function stopLocalDaemon(
|
|||||||
options: StopLocalDaemonOptions = {},
|
options: StopLocalDaemonOptions = {},
|
||||||
): Promise<StopLocalDaemonResult> {
|
): Promise<StopLocalDaemonResult> {
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
||||||
|
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||||
const state = resolveLocalDaemonState({ home: options.home });
|
const state = resolveLocalDaemonState({ home: options.home });
|
||||||
|
|
||||||
if (!state.pidInfo || !state.running) {
|
if (!state.pidInfo || !state.running) {
|
||||||
@@ -530,7 +532,7 @@ export async function stopLocalDaemon(
|
|||||||
if (!stopped && options.force) {
|
if (!stopped && options.force) {
|
||||||
forced = true;
|
forced = true;
|
||||||
signalProcessGroupSafely(pid, "SIGKILL");
|
signalProcessGroupSafely(pid, "SIGKILL");
|
||||||
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
|
stopped = await waitForPidExit(pid, killTimeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stopped) {
|
if (!stopped) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from "./local-daemon.js";
|
import {
|
||||||
|
stopLocalDaemon,
|
||||||
|
DEFAULT_STOP_TIMEOUT_MS,
|
||||||
|
DEFAULT_KILL_TIMEOUT_MS,
|
||||||
|
} from "./local-daemon.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
SingleResult,
|
SingleResult,
|
||||||
@@ -30,17 +34,17 @@ const stopResultSchema: OutputSchema<StopResult> = {
|
|||||||
|
|
||||||
export type StopCommandResult = SingleResult<StopResult>;
|
export type StopCommandResult = SingleResult<StopResult>;
|
||||||
|
|
||||||
function parseTimeoutMs(raw: unknown): number {
|
function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number {
|
||||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||||
return DEFAULT_STOP_TIMEOUT_MS;
|
return fallbackMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seconds = Number(raw);
|
const seconds = Number(raw);
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: "INVALID_TIMEOUT",
|
code: "INVALID_TIMEOUT",
|
||||||
message: `Invalid timeout value: ${raw}`,
|
message: `Invalid ${label} value: ${raw}`,
|
||||||
details: "Timeout must be a positive number of seconds",
|
details: `${label} must be a positive number of seconds`,
|
||||||
};
|
};
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -54,10 +58,15 @@ export async function runStopCommand(
|
|||||||
): Promise<StopCommandResult> {
|
): Promise<StopCommandResult> {
|
||||||
const home = typeof options.home === "string" ? options.home : undefined;
|
const home = typeof options.home === "string" ? options.home : undefined;
|
||||||
const force = options.force === true;
|
const force = options.force === true;
|
||||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout");
|
||||||
|
const killTimeoutMs = parseSecondsOption(
|
||||||
|
options.killTimeout,
|
||||||
|
DEFAULT_KILL_TIMEOUT_MS,
|
||||||
|
"kill-timeout",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await stopLocalDaemon({ home, force, timeoutMs });
|
const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs });
|
||||||
return {
|
return {
|
||||||
type: "single",
|
type: "single",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -96,6 +96,31 @@ function logFilePath(): string {
|
|||||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
|
||||||
|
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
|
||||||
|
if (lock.desktopManaged !== true) return false;
|
||||||
|
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||||
|
return isProcessRunning(lock.pid);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDesktopDaemonViaCli(): Promise<void> {
|
||||||
|
await runCliJsonCommand([
|
||||||
|
"daemon",
|
||||||
|
"stop",
|
||||||
|
"--json",
|
||||||
|
"--timeout",
|
||||||
|
"5",
|
||||||
|
"--force",
|
||||||
|
"--kill-timeout",
|
||||||
|
"5",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function isProcessRunning(pid: number): boolean {
|
function isProcessRunning(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
|
|||||||
@@ -7,49 +7,72 @@ import {
|
|||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./quit-lifecycle";
|
} from "./quit-lifecycle";
|
||||||
|
|
||||||
|
const SETTINGS_KEEP_RUNNING = DEFAULT_DESKTOP_SETTINGS;
|
||||||
|
const SETTINGS_STOP_ON_QUIT = {
|
||||||
|
...DEFAULT_DESKTOP_SETTINGS,
|
||||||
|
daemon: {
|
||||||
|
...DEFAULT_DESKTOP_SETTINGS.daemon,
|
||||||
|
keepRunningAfterQuit: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe("quit-lifecycle", () => {
|
describe("quit-lifecycle", () => {
|
||||||
it("stops only when quit persistence disables keep-running and the daemon is desktop-managed", () => {
|
it("only stops when keepRunningAfterQuit is explicitly disabled", () => {
|
||||||
expect(
|
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
||||||
shouldStopDesktopManagedDaemonOnQuit({
|
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
|
||||||
settings: {
|
});
|
||||||
...DEFAULT_DESKTOP_SETTINGS,
|
|
||||||
daemon: {
|
it("short-circuits without inspecting the daemon when keep-running is on", async () => {
|
||||||
...DEFAULT_DESKTOP_SETTINGS.daemon,
|
const isDesktopManagedDaemonRunning = vi.fn(() => true);
|
||||||
keepRunningAfterQuit: false,
|
const stopDaemon = vi.fn(async () => undefined);
|
||||||
},
|
const showShutdownFeedback = vi.fn();
|
||||||
},
|
|
||||||
daemonStatus: {
|
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||||
status: "running",
|
settingsStore: { get: async () => SETTINGS_KEEP_RUNNING },
|
||||||
desktopManaged: true,
|
isDesktopManagedDaemonRunning,
|
||||||
},
|
stopDaemon,
|
||||||
}),
|
showShutdownFeedback,
|
||||||
).toBe(true);
|
});
|
||||||
|
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled();
|
||||||
|
expect(stopDaemon).not.toHaveBeenCalled();
|
||||||
|
expect(showShutdownFeedback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not stop a manually started daemon on quit", async () => {
|
it("does not stop a manually started daemon on quit", async () => {
|
||||||
const stopDaemon = vi.fn(async () => undefined);
|
const stopDaemon = vi.fn(async () => undefined);
|
||||||
|
const showShutdownFeedback = vi.fn();
|
||||||
|
|
||||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||||
settingsStore: {
|
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
|
||||||
get: async () => ({
|
isDesktopManagedDaemonRunning: () => false,
|
||||||
...DEFAULT_DESKTOP_SETTINGS,
|
|
||||||
daemon: {
|
|
||||||
...DEFAULT_DESKTOP_SETTINGS.daemon,
|
|
||||||
keepRunningAfterQuit: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
patch: async () => DEFAULT_DESKTOP_SETTINGS,
|
|
||||||
migrateLegacyRendererSettings: async () => DEFAULT_DESKTOP_SETTINGS,
|
|
||||||
},
|
|
||||||
resolveStatus: async () => ({
|
|
||||||
status: "running",
|
|
||||||
desktopManaged: false,
|
|
||||||
}),
|
|
||||||
stopDaemon,
|
stopDaemon,
|
||||||
|
showShutdownFeedback,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stopped).toBe(false);
|
expect(stopped).toBe(false);
|
||||||
expect(stopDaemon).not.toHaveBeenCalled();
|
expect(stopDaemon).not.toHaveBeenCalled();
|
||||||
|
expect(showShutdownFeedback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows feedback then stops a desktop-managed daemon", async () => {
|
||||||
|
const stopDaemon = vi.fn(async () => undefined);
|
||||||
|
const showShutdownFeedback = vi.fn();
|
||||||
|
|
||||||
|
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||||
|
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
|
||||||
|
isDesktopManagedDaemonRunning: () => true,
|
||||||
|
stopDaemon,
|
||||||
|
showShutdownFeedback,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stopped).toBe(true);
|
||||||
|
expect(showShutdownFeedback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stopDaemon).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
stopDaemon.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gates quit until the stop decision completes, then lets the next quit pass through", async () => {
|
it("gates quit until the stop decision completes, then lets the next quit pass through", async () => {
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ interface QuitLifecycleSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuitLifecycleStatus {
|
|
||||||
status: "starting" | "running" | "stopped" | "errored";
|
|
||||||
desktopManaged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BeforeQuitEvent {
|
interface BeforeQuitEvent {
|
||||||
preventDefault(): void;
|
preventDefault(): void;
|
||||||
}
|
}
|
||||||
@@ -19,37 +14,31 @@ interface BeforeQuitApp {
|
|||||||
quit(): void;
|
quit(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldStopDesktopManagedDaemonOnQuit({
|
export interface StopOnQuitDeps {
|
||||||
settings,
|
settingsStore: Pick<DesktopSettingsStore, "get">;
|
||||||
daemonStatus,
|
isDesktopManagedDaemonRunning: () => boolean;
|
||||||
}: {
|
stopDaemon: () => Promise<unknown>;
|
||||||
settings: QuitLifecycleSettings;
|
showShutdownFeedback: () => void;
|
||||||
daemonStatus: QuitLifecycleStatus;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
settings.daemon.keepRunningAfterQuit === false &&
|
|
||||||
daemonStatus.status === "running" &&
|
|
||||||
daemonStatus.desktopManaged
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopDesktopManagedDaemonOnQuitIfNeeded({
|
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
|
||||||
settingsStore,
|
return settings.daemon.keepRunningAfterQuit === false;
|
||||||
resolveStatus,
|
}
|
||||||
stopDaemon,
|
|
||||||
}: {
|
|
||||||
settingsStore: DesktopSettingsStore;
|
|
||||||
resolveStatus: () => Promise<QuitLifecycleStatus>;
|
|
||||||
stopDaemon: () => Promise<unknown>;
|
|
||||||
}): Promise<boolean> {
|
|
||||||
const settings = await settingsStore.get();
|
|
||||||
const daemonStatus = await resolveStatus();
|
|
||||||
|
|
||||||
if (!shouldStopDesktopManagedDaemonOnQuit({ settings, daemonStatus })) {
|
export async function stopDesktopManagedDaemonOnQuitIfNeeded(
|
||||||
|
deps: StopOnQuitDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = await deps.settingsStore.get();
|
||||||
|
if (!shouldStopDesktopManagedDaemonOnQuit(settings)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopDaemon();
|
if (!deps.isDesktopManagedDaemonRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.showShutdownFeedback();
|
||||||
|
await deps.stopDaemon();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import path from "node:path";
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron";
|
||||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
parseCliPassthroughArgsFromArgv,
|
parseCliPassthroughArgsFromArgv,
|
||||||
@@ -35,7 +35,10 @@ import { registerOpenerHandlers } from "./features/opener.js";
|
|||||||
import { setupApplicationMenu } from "./features/menu.js";
|
import { setupApplicationMenu } from "./features/menu.js";
|
||||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||||
import { resolveDesktopDaemonStatus, stopDesktopDaemon } from "./daemon/daemon-manager.js";
|
import {
|
||||||
|
isDesktopManagedDaemonRunningSync,
|
||||||
|
stopDesktopDaemonViaCli,
|
||||||
|
} from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
createBeforeQuitHandler,
|
createBeforeQuitHandler,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
@@ -346,6 +349,16 @@ void bootstrap().catch((error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function showDaemonShutdownDialog(): void {
|
||||||
|
void dialog.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
message: "Shutting down Paseo daemon…",
|
||||||
|
detail: "Paseo will quit once the local daemon has stopped.",
|
||||||
|
buttons: [],
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.on(
|
app.on(
|
||||||
"before-quit",
|
"before-quit",
|
||||||
createBeforeQuitHandler({
|
createBeforeQuitHandler({
|
||||||
@@ -354,8 +367,9 @@ app.on(
|
|||||||
stopDesktopManagedDaemonIfNeeded: () =>
|
stopDesktopManagedDaemonIfNeeded: () =>
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded({
|
stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||||
settingsStore: getDesktopSettingsStore(),
|
settingsStore: getDesktopSettingsStore(),
|
||||||
resolveStatus: resolveDesktopDaemonStatus,
|
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
|
||||||
stopDaemon: stopDesktopDaemon,
|
stopDaemon: stopDesktopDaemonViaCli,
|
||||||
|
showShutdownFeedback: showDaemonShutdownDialog,
|
||||||
}),
|
}),
|
||||||
onStopError: (error) => {
|
onStopError: (error) => {
|
||||||
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
|
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user