Fix built-in daemon management toggle

This commit is contained in:
Mohamed Boudra
2026-05-03 22:02:40 +07:00
parent f8d4758e6b
commit f0d96f8e5a
7 changed files with 262 additions and 15 deletions

View File

@@ -50,11 +50,12 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
@@ -319,7 +328,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldUseDesktopDaemon(),
shouldStartDaemon: shouldStartBuiltInDaemon,
});
}, []);
@@ -346,7 +355,10 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
startDaemonIfGateAllows({
daemonStartService: getDaemonStartService({ store: getHostRuntimeStore() }),
shouldStartDaemon: shouldStartBuiltInDaemon,
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;

View File

@@ -55,6 +55,21 @@ describe("startHostRuntimeBootstrap", () => {
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("skips daemon-start when the startup gate resolves false", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => false,
});
await Promise.resolve();
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;

View File

@@ -11,17 +11,39 @@ export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: boolean;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
});
}
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
}): void {
if (typeof input.shouldStartDaemon === "boolean") {
if (input.shouldStartDaemon) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve(input.shouldStartDaemon()).then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return;
});
}
export const WELCOME_ROUTE: Href = "/welcome";

View File

@@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({
const daemonCommandMocks = vi.hoisted(() => ({
getCliDaemonStatusMock: vi.fn(),
startDesktopDaemonMock: vi.fn(),
stopDesktopDaemonMock: vi.fn(),
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
shouldUseDesktopDaemon: vi.fn(() => true),
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
}));
@@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => {
settingsState.updateSettings.mockReset();
settingsState.updateSettings.mockResolvedValue();
daemonStatusState.data.status.status = "running";
daemonStatusState.data.status.desktopManaged = true;
daemonStatusState.setStatus.mockReset();
daemonStatusState.refetch.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
status: "running",
desktopManaged: true,
});
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
@@ -297,4 +306,57 @@ describe("LocalDaemonSection", () => {
},
});
});
it("persists paused management before stopping the desktop-managed daemon", async () => {
confirmDialogMock.mockResolvedValue(true);
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
);
});
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
confirmDialogMock.mockResolvedValue(true);
daemonStatusState.data.status.desktopManaged = false;
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: false,
},
});
});
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
});
it("starts the built-in daemon when management is re-enabled", async () => {
settingsState.settings.daemon.manageBuiltInDaemon = false;
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: true,
},
});
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
...daemonStatusState.data.status,
status: "running",
desktopManaged: true,
});
});
});

View File

@@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
@@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: {
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => startDesktopDaemon())
.then((newStatus) => {
setStatus(newStatus);
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
@@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: {
setIsUpdatingDaemonManagement(true);
const stopPromise =
daemonStatus?.status === "running"
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null);
void stopPromise
void updateSettings({ manageBuiltInDaemon: false })
.then(() => {
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
return stopDesktopDaemon();
}
return daemonStatus ?? null;
})
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
return;
})
.then(() => {
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
console.error("[Settings] Failed to stop built-in daemon", error);
Alert.alert(
"Error",
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
);
})
.finally(() => {
setIsUpdatingDaemonManagement(false);

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
settings: {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
},
runCliJsonCommand: vi.fn(),
runCliTextCommand: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: false,
},
ipcMain: { handle: vi.fn() },
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
}));
vi.mock("electron-log/main", () => ({
default: { info: vi.fn(), error: vi.fn() },
}));
vi.mock("@getpaseo/server", () => ({
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
spawnProcess: mocks.spawnProcess,
}));
vi.mock("../settings/desktop-settings-electron.js", () => ({
getDesktopSettingsStore: () => ({
get: async () => mocks.settings,
patch: vi.fn(),
migrateLegacyRendererSettings: vi.fn(),
}),
}));
vi.mock("./runtime-paths.js", () => ({
createNodeEntrypointInvocation: vi.fn(() => ({
command: "node",
args: [],
env: {},
})),
resolveDaemonRunnerEntrypoint: vi.fn(() => ({
entryPath: "/tmp/daemon.js",
execArgv: [],
})),
runCliJsonCommand: mocks.runCliJsonCommand,
runCliTextCommand: mocks.runCliTextCommand,
}));
function desktopSettingsWithManagement(enabled: boolean) {
return {
...DEFAULT_DESKTOP_SETTINGS,
daemon: {
...DEFAULT_DESKTOP_SETTINGS.daemon,
manageBuiltInDaemon: enabled,
},
};
}
describe("daemon-manager commands", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
mocks.runCliJsonCommand.mockReset();
mocks.runCliTextCommand.mockReset();
mocks.spawnProcess.mockReset();
});
it("refuses start and restart while built-in daemon management is disabled", async () => {
mocks.settings = desktopSettingsWithManagement(false);
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).rejects.toThrow(
"Built-in daemon management is disabled.",
);
await expect(handlers.restart_desktop_daemon()).rejects.toThrow(
"Built-in daemon management is disabled.",
);
expect(mocks.runCliJsonCommand).not.toHaveBeenCalled();
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("keeps stop callable while built-in daemon management is disabled", async () => {
mocks.settings = desktopSettingsWithManagement(false);
mocks.runCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
serverId: "",
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
serverId: "",
status: "stopped",
listen: null,
hostname: null,
pid: null,
home: "/tmp/paseo-home",
version: null,
desktopManaged: false,
error: null,
});
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
});

View File

@@ -38,6 +38,7 @@ import {
createDesktopSettingsCommandHandlers,
type DesktopCommandHandler,
} from "../settings/desktop-settings-commands.js";
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
@@ -285,6 +286,12 @@ function shouldRestartForVersion(current: DesktopDaemonStatus): boolean {
return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion);
}
function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
if (!settings.daemon.manageBuiltInDaemon) {
throw new Error("Built-in daemon management is disabled.");
}
}
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: string,
@@ -322,6 +329,8 @@ async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
const current = await resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("initial status check before start", {
status: current.status,
@@ -457,6 +466,7 @@ export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
await stopDesktopDaemon();
return startDaemon();
}