fix(app): keep reload splash continuous
Treat desktop settings evaluation as part of daemon startup so restored app chrome cannot render between bootstrap phases.
This commit is contained in:
@@ -60,7 +60,6 @@ import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
@@ -337,7 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -376,11 +374,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
}, []);
|
||||
|
||||
const splashError =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
@@ -7,115 +7,37 @@ import {
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
} from "./host-runtime-bootstrap";
|
||||
|
||||
function createFakeStore() {
|
||||
return { boot: vi.fn() };
|
||||
}
|
||||
|
||||
function createFakeDaemonStartService() {
|
||||
return {
|
||||
start: vi.fn(async () => ({ ok: true as const })),
|
||||
};
|
||||
}
|
||||
import type {
|
||||
DaemonStartCondition,
|
||||
StartDaemonIfEnabledInput,
|
||||
} from "@/runtime/daemon-start-service";
|
||||
|
||||
describe("startHostRuntimeBootstrap", () => {
|
||||
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
|
||||
it("boots the host registry and starts the managed-daemon decision as one operation", () => {
|
||||
const events: string[] = [];
|
||||
const shouldStartDaemon = async () => true;
|
||||
const store = {
|
||||
boot: vi.fn(() => {
|
||||
boot: () => {
|
||||
events.push("boot");
|
||||
}),
|
||||
};
|
||||
const daemonStartService = {
|
||||
start: vi.fn(async () => {
|
||||
events.push("daemon-start");
|
||||
return { ok: true as const };
|
||||
}),
|
||||
};
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: true,
|
||||
});
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["boot", "daemon-start"]);
|
||||
});
|
||||
|
||||
it("skips daemon-start when shouldStartDaemon is false", () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: false,
|
||||
});
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
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("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;
|
||||
};
|
||||
let receivedCondition: DaemonStartCondition | null = null;
|
||||
const daemonStartService = {
|
||||
start: vi.fn(
|
||||
() =>
|
||||
new Promise<{ ok: true }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
),
|
||||
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
|
||||
receivedCondition = input.shouldStart;
|
||||
events.push("daemon-start-decision");
|
||||
return { ok: true as const };
|
||||
},
|
||||
};
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: true,
|
||||
shouldStartDaemon,
|
||||
});
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveStart?.({ ok: true });
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
expect(receivedCondition).toBe(shouldStartDaemon);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||
import type {
|
||||
DaemonStartCondition,
|
||||
DaemonStartResult,
|
||||
StartDaemonIfEnabledInput,
|
||||
} from "@/runtime/daemon-start-service";
|
||||
import type { Href } from "expo-router";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -12,54 +16,22 @@ export interface HostRuntimeBootstrapStore {
|
||||
}
|
||||
|
||||
export interface HostRuntimeBootstrapDaemonStartService {
|
||||
start: () => Promise<DaemonStartResult>;
|
||||
startIfEnabled: (input: StartDaemonIfEnabledInput) => Promise<DaemonStartResult>;
|
||||
}
|
||||
|
||||
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
|
||||
|
||||
export interface StartHostRuntimeBootstrapInput {
|
||||
store: HostRuntimeBootstrapStore;
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
shouldStartDaemon: DaemonStartCondition;
|
||||
}
|
||||
|
||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||
input.store.boot();
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService: input.daemonStartService,
|
||||
shouldStartDaemon: input.shouldStartDaemon,
|
||||
onGateError: input.onGateError,
|
||||
void input.daemonStartService.startIfEnabled({
|
||||
shouldStart: input.shouldStartDaemon,
|
||||
});
|
||||
}
|
||||
|
||||
export function startDaemonIfGateAllows(input: {
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}): void {
|
||||
const gate = input.shouldStartDaemon;
|
||||
if (typeof gate === "boolean") {
|
||||
if (gate) {
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
export type StartupBlocker =
|
||||
|
||||
@@ -164,6 +164,53 @@ describe("DaemonStartService", () => {
|
||||
expect(runningSnapshots).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("stays running while deciding whether the managed daemon should start", async () => {
|
||||
const fake = createFakeStore();
|
||||
let resolveCondition: ((value: boolean) => void) | undefined;
|
||||
let daemonStartCount = 0;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => {
|
||||
daemonStartCount += 1;
|
||||
return makeStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const startPromise = service.startIfEnabled({
|
||||
shouldStart: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveCondition = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
expect(service.isRunning()).toBe(true);
|
||||
expect(daemonStartCount).toBe(0);
|
||||
|
||||
resolveCondition?.(true);
|
||||
await startPromise;
|
||||
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(daemonStartCount).toBe(1);
|
||||
});
|
||||
|
||||
it("finishes without starting the daemon when management is disabled", async () => {
|
||||
const fake = createFakeStore();
|
||||
let daemonStartCount = 0;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => {
|
||||
daemonStartCount += 1;
|
||||
return makeStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.startIfEnabled({ shouldStart: false });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(daemonStartCount).toBe(0);
|
||||
});
|
||||
|
||||
it("clears the error and notifies subscribers when retry begins", async () => {
|
||||
const fake = createFakeStore();
|
||||
const startMock = vi
|
||||
@@ -188,19 +235,27 @@ describe("DaemonStartService", () => {
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordError surfaces an external error and notifies subscribers", () => {
|
||||
it("surfaces settings errors through the daemon startup state", async () => {
|
||||
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");
|
||||
const result = await service.startIfEnabled({
|
||||
shouldStart: async () => {
|
||||
throw new Error("settings file unreadable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getLastError()).toBe("settings file unreadable");
|
||||
expect(notifications).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "Failed to evaluate desktop daemon settings: settings file unreadable",
|
||||
});
|
||||
expect(service.getLastError()).toBe(
|
||||
"Failed to evaluate desktop daemon settings: settings file unreadable",
|
||||
);
|
||||
expect(service.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { connectionFromListen } from "@/types/host-connection";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||
export type DaemonStartCondition = boolean | (() => boolean | Promise<boolean>);
|
||||
|
||||
export interface StartDaemonIfEnabledInput {
|
||||
shouldStart: DaemonStartCondition;
|
||||
}
|
||||
|
||||
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
|
||||
@@ -50,8 +55,27 @@ export class DaemonStartService {
|
||||
}
|
||||
|
||||
async start(): Promise<DaemonStartResult> {
|
||||
return this.startIfEnabled({ shouldStart: true });
|
||||
}
|
||||
|
||||
async startIfEnabled(input: StartDaemonIfEnabledInput): Promise<DaemonStartResult> {
|
||||
// Settings evaluation is part of startup. Publish the running state before
|
||||
// its first await so restored app chrome cannot appear between these phases.
|
||||
this.beginRequest();
|
||||
try {
|
||||
let shouldStart: boolean;
|
||||
try {
|
||||
shouldStart =
|
||||
typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return this.fail(`Failed to evaluate desktop daemon settings: ${message}`);
|
||||
}
|
||||
|
||||
if (!shouldStart) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const daemon = await this.invokeStartDesktopDaemon();
|
||||
const result = await upsertDesktopDaemonConnection(this.store, daemon);
|
||||
return result.ok ? result : this.fail(result.error);
|
||||
@@ -66,10 +90,6 @@ export class DaemonStartService {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
recordError(message: string): void {
|
||||
this.setLastError(message);
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.inFlightCount > 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user