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
11 changed files with 121 additions and 233 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) {
@@ -1,72 +0,0 @@
import { expect, it } from "vitest";
import { createHostLogger } from "./host-logger";
it("preserves the original transport cause and tolerates a cyclic cause chain", () => {
const lines: string[] = [];
const capture = (...values: unknown[]) => lines.push(values.map(String).join(" "));
const logger = createHostLogger(() => "test-host", {
log: capture,
warn: capture,
error: capture,
});
const transport = new Error("socket closed");
const wrapper = new Error("Subscription failed", { cause: transport });
Object.defineProperty(transport, "cause", { value: wrapper });
logger.error({ err: wrapper }, "subscription_error");
const record = JSON.parse(lines[0]!.slice("subscription_error ".length));
expect(record.err.cause.message).toBe("socket closed");
expect(record.err.cause.stack).toBe(transport.stack);
expect(record.err.cause.cause).toBe("[Circular]");
});
it("retains host and error details in the desktop's text-only console capture", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "offline-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
logger.warn(
{ err: { name: "Error", message: "malformed ready envelope" } },
"relay_e2ee_channel_error",
);
expect(lines).toEqual([
{
level: "warn",
text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}',
},
]);
});
it("keeps native Error messages and severity without failing on circular diagnostics", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "test-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
const record: { err: Error; self?: object } = { err: new Error("connection lost") };
record.self = record;
logger.error(record, "Connection failed");
expect(lines[0]?.level).toBe("error");
expect(lines[0]?.text).toContain('"message":"connection lost"');
expect(lines[0]?.text).toContain('"serverId":"test-host"');
expect(lines[0]?.text).toContain('"[Circular]"');
logger.info({ status: "offline" }, "Host status");
expect(lines[1]).toEqual({
level: "info",
text: 'Host status {"serverId":"test-host","status":"offline"}',
});
logger.debug({ status: "offline" }, "Host status");
expect(lines).toHaveLength(2);
});
-36
View File
@@ -1,36 +0,0 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
const details = JSON.stringify(
{ serverId: readServerId(), ...record },
(_key, value: unknown) => {
if (value && typeof value === "object") {
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (value instanceof Error) {
return {
...value,
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause,
};
}
}
return value;
},
);
return message ? `${message} ${details}` : details;
};
return {
debug: () => {},
info: (record, message) => output.log(format(record, message)),
warn: (record, message) => output.warn(format(record, message)),
error: (record, message) => output.error(format(record, message)),
};
}
-3
View File
@@ -1,6 +1,5 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostLogger } from "./host-logger";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
@@ -590,7 +589,6 @@ function createDefaultDeps(
runtimeGeneration,
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
logger: createHostLogger(() => host.serverId),
providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}),
@@ -671,7 +669,6 @@ function createDefaultDeps(
null,
),
reconnect: { enabled: false },
logger: createHostLogger(() => host.serverId),
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
@@ -2,11 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
import { createHostLogger } from "@/runtime/host-logger";
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
private connected = false;
constructor(
private readonly probe: FakeDaemonProbe,
@@ -19,11 +17,9 @@ class FakeDaemonClient implements DaemonProbeClient {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
}
this.connected = true;
}
getLastServerInfoMessage() {
if (!this.connected) return null;
return {
serverId: "srv_probe_test",
hostname: "probe-host",
@@ -31,13 +27,11 @@ class FakeDaemonClient implements DaemonProbeClient {
}
async close(): Promise<void> {
this.connected = false;
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
diagnosticLines: string[] = [];
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
@@ -45,12 +39,6 @@ class FakeDaemonProbe {
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
createLogger: (readServerId) => {
const capture = (...values: unknown[]) => {
this.diagnosticLines.push(values.map(String).join(" "));
};
return createHostLogger(readServerId, { log: capture, warn: capture, error: capture });
},
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
@@ -88,34 +76,6 @@ describe("test-daemon-connection connectToDaemon", () => {
probe = new FakeDaemonProbe();
});
it.each([undefined, "srv_probe_test"])(
"logs learned host identity on an adopted probe initially given %s",
async (serverId) => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ id: "direct:test", type: "directTcp", endpoint: "localhost:6767" },
{ serverId },
probe.deps,
);
try {
result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error");
const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length));
expect(record.serverId).toBe("srv_probe_test");
expect(record.err.message).toBe("socket closed");
await result.client.close();
expect(result.client.getLastServerInfoMessage()).toBeNull();
result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error");
const closedRecord = JSON.parse(
probe.diagnosticLines[1]!.slice("connection_error ".length),
);
expect(closedRecord.serverId).toBe("srv_probe_test");
expect(closedRecord.err.message).toBe("disconnected");
} finally {
await result.client.close();
}
},
);
it.each([
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
{
@@ -7,7 +7,6 @@ import {
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
import { createHostLogger } from "@/runtime/host-logger";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import {
@@ -30,7 +29,6 @@ export interface DaemonProbeClient {
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
createLogger: typeof createHostLogger;
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
@@ -39,7 +37,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
createLogger: createHostLogger,
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
@@ -320,9 +317,5 @@ export async function connectToDaemon(
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, options, deps);
let serverId = options?.serverId;
config.logger = deps.createLogger(() => serverId);
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
serverId = result.serverId;
return result;
return connectAndProbe(config, resolveTimeout(connection, options), deps);
}
-30
View File
@@ -9,23 +9,6 @@ async function chooseLanguage(page: Page, language: string) {
await page.getByRole("menuitemradio", { name: language }).click();
}
async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise<void>) {
let releaseScripts!: () => void;
const scriptsReleased = new Promise<void>((resolve) => {
releaseScripts = resolve;
});
await page.route("**/*.js", async (route) => {
await scriptsReleased;
await route.continue();
});
try {
await run(releaseScripts);
} finally {
releaseScripts();
await page.unrouteAll({ behavior: "wait" });
}
}
function describeAlternates(links: Element[]): string[] {
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
}
@@ -131,7 +114,6 @@ test("shows the language as an abbreviation and lists every language by name", a
test("switches language from the keyboard", async ({ page }) => {
await page.goto("/");
await expect(languageButton(page)).toBeEnabled();
await languageButton(page).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
@@ -201,15 +183,3 @@ test("shows the changelog in Chinese", async ({ page }) => {
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
});
test("waits for scripts before enabling the language menu", async ({ page }) => {
await withPageScriptsHeld(page, async (releaseScripts) => {
await page.goto("/docs/cli#running-agents", { waitUntil: "commit" });
await expect(languageButton(page)).toBeVisible();
await expect(languageButton(page)).toBeDisabled();
releaseScripts();
await languageButton(page).click();
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible();
});
});
-32
View File
@@ -181,23 +181,6 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
);
});
test("submits the visible search term when its input event was missed", async ({ page }) => {
await page.goto("/plugins?window=month");
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
await fillSearchWithMissedInputEvent(page, "graphite");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
).toBeVisible();
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
"true",
);
});
test("searches for a term typed before the page finished loading", async ({ page }) => {
const loadScripts = await holdScripts(page);
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
@@ -436,21 +419,6 @@ async function searchPlugins(page: Page, term: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
}
/** Reproduce text entered before hydration's input listener can observe it, without a clock race. */
async function fillSearchWithMissedInputEvent(page: Page, term: string) {
const search = page.getByRole("searchbox", { name: "Search plugins" });
await search.evaluate(() => {
for (const eventType of ["input", "change"]) {
document.addEventListener(eventType, (event) => event.stopImmediatePropagation(), {
capture: true,
once: true,
});
}
});
await search.fill(term);
await expect(search).toHaveValue(term);
}
async function submitSearch(page: Page, term: string) {
await searchPlugins(page, term);
await page.keyboard.press("Enter");
@@ -1,4 +1,4 @@
import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import { Check, ChevronDown, Languages } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
@@ -33,7 +33,6 @@ type OpenFocus = "current" | "first" | "last";
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
*/
export function LanguageSelector() {
const hydrated = useHydrated();
const { t } = useTranslation();
const locale = useLocale();
const location = useLocation();
@@ -126,7 +125,6 @@ export function LanguageSelector() {
<button
ref={triggerRef}
type="button"
disabled={!hydrated}
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
aria-haspopup="menu"
aria-expanded={open}
@@ -68,16 +68,12 @@ export function PluginSearch({
input.current?.focus();
}, [edit]);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
(event: FormEvent) => {
event.preventDefault();
// Live results already show the term.
if (!live) {
// Hydration can attach this handler before state adopts text already in the input.
const submittedTerm = String(new FormData(event.currentTarget).get("q") ?? "");
show(submittedTerm, { replace: false });
}
if (!live) show(term, { replace: false });
},
[live, show],
[live, show, term],
);
return (
<form