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:
@@ -96,6 +96,31 @@ function logFilePath(): string {
|
||||
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 {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
||||
@@ -7,49 +7,72 @@ import {
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||
} 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", () => {
|
||||
it("stops only when quit persistence disables keep-running and the daemon is desktop-managed", () => {
|
||||
expect(
|
||||
shouldStopDesktopManagedDaemonOnQuit({
|
||||
settings: {
|
||||
...DEFAULT_DESKTOP_SETTINGS,
|
||||
daemon: {
|
||||
...DEFAULT_DESKTOP_SETTINGS.daemon,
|
||||
keepRunningAfterQuit: false,
|
||||
},
|
||||
},
|
||||
daemonStatus: {
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
it("only stops when keepRunningAfterQuit is explicitly disabled", () => {
|
||||
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
||||
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
|
||||
});
|
||||
|
||||
it("short-circuits without inspecting the daemon when keep-running is on", async () => {
|
||||
const isDesktopManagedDaemonRunning = vi.fn(() => true);
|
||||
const stopDaemon = vi.fn(async () => undefined);
|
||||
const showShutdownFeedback = vi.fn();
|
||||
|
||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: { get: async () => SETTINGS_KEEP_RUNNING },
|
||||
isDesktopManagedDaemonRunning,
|
||||
stopDaemon,
|
||||
showShutdownFeedback,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const stopDaemon = vi.fn(async () => undefined);
|
||||
const showShutdownFeedback = vi.fn();
|
||||
|
||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: {
|
||||
get: async () => ({
|
||||
...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,
|
||||
}),
|
||||
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
|
||||
isDesktopManagedDaemonRunning: () => false,
|
||||
stopDaemon,
|
||||
showShutdownFeedback,
|
||||
});
|
||||
|
||||
expect(stopped).toBe(false);
|
||||
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 () => {
|
||||
|
||||
@@ -6,11 +6,6 @@ interface QuitLifecycleSettings {
|
||||
};
|
||||
}
|
||||
|
||||
interface QuitLifecycleStatus {
|
||||
status: "starting" | "running" | "stopped" | "errored";
|
||||
desktopManaged: boolean;
|
||||
}
|
||||
|
||||
interface BeforeQuitEvent {
|
||||
preventDefault(): void;
|
||||
}
|
||||
@@ -19,37 +14,31 @@ interface BeforeQuitApp {
|
||||
quit(): void;
|
||||
}
|
||||
|
||||
export function shouldStopDesktopManagedDaemonOnQuit({
|
||||
settings,
|
||||
daemonStatus,
|
||||
}: {
|
||||
settings: QuitLifecycleSettings;
|
||||
daemonStatus: QuitLifecycleStatus;
|
||||
}): boolean {
|
||||
return (
|
||||
settings.daemon.keepRunningAfterQuit === false &&
|
||||
daemonStatus.status === "running" &&
|
||||
daemonStatus.desktopManaged
|
||||
);
|
||||
export interface StopOnQuitDeps {
|
||||
settingsStore: Pick<DesktopSettingsStore, "get">;
|
||||
isDesktopManagedDaemonRunning: () => boolean;
|
||||
stopDaemon: () => Promise<unknown>;
|
||||
showShutdownFeedback: () => void;
|
||||
}
|
||||
|
||||
export async function stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore,
|
||||
resolveStatus,
|
||||
stopDaemon,
|
||||
}: {
|
||||
settingsStore: DesktopSettingsStore;
|
||||
resolveStatus: () => Promise<QuitLifecycleStatus>;
|
||||
stopDaemon: () => Promise<unknown>;
|
||||
}): Promise<boolean> {
|
||||
const settings = await settingsStore.get();
|
||||
const daemonStatus = await resolveStatus();
|
||||
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
|
||||
return settings.daemon.keepRunningAfterQuit === false;
|
||||
}
|
||||
|
||||
if (!shouldStopDesktopManagedDaemonOnQuit({ settings, daemonStatus })) {
|
||||
export async function stopDesktopManagedDaemonOnQuitIfNeeded(
|
||||
deps: StopOnQuitDeps,
|
||||
): Promise<boolean> {
|
||||
const settings = await deps.settingsStore.get();
|
||||
if (!shouldStopDesktopManagedDaemonOnQuit(settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await stopDaemon();
|
||||
if (!deps.isDesktopManagedDaemonRunning()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deps.showShutdownFeedback();
|
||||
await deps.stopDaemon();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user