Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andmagiclaw b3b72b1550 test(desktop): cover a daemon stopping during startup
Keep incomplete host registration out of startup recovery when a delayed daemon exits before readiness.

Co-authored-by: magiclaw <930198090@qq.com>
2026-11-07 20:45:45 +00:00
magiclaw f42926cffb fix(desktop): wait for an already-starting daemon to become ready 2026-11-07 20:44:51 +00:00
2 changed files with 116 additions and 2 deletions
@@ -64,6 +64,99 @@ function makeRelayOnlyHost(serverId: string): HostProfile {
}
describe("DaemonStartService", () => {
it("reports a daemon that stops during startup without registering an incomplete host", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () =>
makeStatus({ status: "starting", serverId: "", listen: null }),
getDesktopDaemonStatus: async () =>
makeStatus({ status: "stopped", serverId: "", listen: null, pid: null }),
});
expect(await service.start()).toEqual({
ok: false,
error: "The local daemon stopped before startup completed.",
});
expect(service.isRunning()).toBe(false);
expect(fake.upserts).toEqual([]);
});
it("keeps startup pending until a slow daemon becomes ready without restarting it", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const starting = makeStatus({ status: "starting", serverId: "", listen: null });
const startDesktopDaemon = vi.fn(async () => starting);
const getDesktopDaemonStatus = vi
.fn()
.mockResolvedValueOnce(starting)
.mockResolvedValueOnce(makeStatus());
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon,
getDesktopDaemonStatus,
});
const result = service.start();
await vi.advanceTimersByTimeAsync(1000);
expect(service.isRunning()).toBe(true);
expect(service.getLastError()).toBeNull();
expect(fake.upserts).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
expect(await result).toEqual({ ok: true });
expect(startDesktopDaemon).toHaveBeenCalledTimes(1);
expect(fake.upserts).toEqual([
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("shows the daemon error if it exits while startup is pending", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ status: "starting", serverId: "" }),
getDesktopDaemonStatus: async () =>
makeStatus({ status: "errored", error: "provider startup failed" }),
});
const result = service.start();
await vi.advanceTimersByTimeAsync(1000);
expect(await result).toEqual({ ok: false, error: "provider startup failed" });
expect(fake.upserts).toEqual([]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("bounds the readiness wait and leaves a retryable error", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const starting = makeStatus({ status: "starting", serverId: "" });
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => starting,
getDesktopDaemonStatus: async () => starting,
});
const result = service.start();
await vi.advanceTimersByTimeAsync(120000);
expect(await result).toEqual({
ok: false,
error: "The local daemon is still starting. Wait a moment and retry.",
});
expect(fake.upserts).toEqual([]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("upserts the connection on a successful daemon start", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
@@ -1,4 +1,8 @@
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
getDesktopDaemonStatus,
startDesktopDaemon,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { connectionFromListen } from "@/types/host-connection";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
@@ -14,6 +18,7 @@ type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnecti
export interface DaemonStartServiceDeps {
store: DaemonConnectionStore;
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
getDesktopDaemonStatus?: () => Promise<DesktopDaemonStatus>;
}
export async function upsertDesktopDaemonConnection(
@@ -48,6 +53,7 @@ export async function upsertDesktopDaemonConnection(
export class DaemonStartService {
private readonly store: DaemonConnectionStore;
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
private readonly invokeGetDesktopDaemonStatus: () => Promise<DesktopDaemonStatus>;
private readonly listeners = new Set<() => void>();
private lastError: string | null = null;
private inFlightCount = 0;
@@ -55,6 +61,7 @@ export class DaemonStartService {
constructor(deps: DaemonStartServiceDeps) {
this.store = deps.store;
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
this.invokeGetDesktopDaemonStatus = deps.getDesktopDaemonStatus ?? getDesktopDaemonStatus;
}
async start(): Promise<DaemonStartResult> {
@@ -79,7 +86,21 @@ export class DaemonStartService {
return { ok: true };
}
const daemon = await this.invokeStartDesktopDaemon();
let daemon = await this.invokeStartDesktopDaemon();
// The desktop start command can return after its readiness deadline while
// the owned process keeps initializing. Keep the startup UI pending until
// that process can supply its host identity; do not launch it again.
const readinessDeadline = Date.now() + 120_000;
while (daemon.status === "starting") {
if (Date.now() >= readinessDeadline) {
return this.fail("The local daemon is still starting. Wait a moment and retry.");
}
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
daemon = await this.invokeGetDesktopDaemonStatus();
}
if (daemon.status !== "running") {
return this.fail(daemon.error ?? "The local daemon stopped before startup completed.");
}
const result = await upsertDesktopDaemonConnection(this.store, daemon);
return result.ok ? result : this.fail(result.error);
} catch (error) {