Surface desktop daemon gate failures instead of silent no-op

The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.

Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
This commit is contained in:
Mohamed Boudra
2026-05-04 01:40:47 +07:00
parent 4338f5b46c
commit 1521ceb381
5 changed files with 62 additions and 9 deletions

View File

@@ -329,6 +329,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -355,9 +356,11 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService: getDaemonStartService({ store: getHostRuntimeStore() }),
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);

View File

@@ -70,6 +70,27 @@ describe("startHostRuntimeBootstrap", () => {
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
const onGateError = vi.fn();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => {
throw new Error("settings file unreadable");
},
onGateError,
});
await vi.waitFor(() => {
expect(onGateError).toHaveBeenCalledTimes(1);
});
expect(daemonStartService.start).not.toHaveBeenCalled();
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;

View File

@@ -17,6 +17,7 @@ export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
@@ -24,26 +25,35 @@ export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput)
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
});
}
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}): void {
if (typeof input.shouldStartDaemon === "boolean") {
if (input.shouldStartDaemon) {
const gate = input.shouldStartDaemon;
if (typeof gate === "boolean") {
if (gate) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve(input.shouldStartDaemon()).then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return;
});
void Promise.resolve()
.then(() => gate())
.then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return null;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
});
}
export const WELCOME_ROUTE: Href = "/welcome";

View File

@@ -188,6 +188,21 @@ describe("DaemonStartService", () => {
expect(service.getLastError()).toBeNull();
});
it("recordError surfaces an external error and notifies subscribers", () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus(),
});
const notifications = vi.fn();
service.subscribe(notifications);
service.recordError("settings file unreadable");
expect(service.getLastError()).toBe("settings file unreadable");
expect(notifications).toHaveBeenCalledTimes(1);
});
it("stops notifying after a subscriber unsubscribes", async () => {
const fake = createFakeStore();
let notifications = 0;

View File

@@ -53,6 +53,10 @@ export class DaemonStartService {
return this.lastError;
}
recordError(message: string): void {
this.setLastError(message);
}
isRunning(): boolean {
return this.inFlightCount > 0;
}