mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
perf(control-ui): prioritize first connect startup (#89030)
* perf(control-ui): prioritize first connect startup * fix(control-ui): close connect timing gaps * fix(control-ui): default embeds strict before bootstrap * fix(control-ui): keep bootstrap identity deferred * fix(control-ui): gate startup chat on bootstrap * fix(control-ui): restore composer after hello * fix(control-ui): restore drafts before hello
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { connectGateway } from "./app-gateway.ts";
|
||||
import type { GatewayHelloOk } from "./gateway.ts";
|
||||
import type { GatewayConnectTiming, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
|
||||
const refreshActiveTabMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
@@ -19,6 +19,7 @@ const verifyPushMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
type GatewayClientMock = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
emitConnectTiming: (timing?: Partial<GatewayConnectTiming>) => void;
|
||||
emitHello: (hello?: GatewayHelloOk) => void;
|
||||
};
|
||||
|
||||
@@ -42,10 +43,26 @@ vi.mock("./gateway.ts", async (importOriginal) => {
|
||||
readonly stop = vi.fn();
|
||||
readonly request = vi.fn(async () => ({}));
|
||||
|
||||
constructor(private opts: { onHello?: (hello: GatewayHelloOk) => void }) {
|
||||
constructor(
|
||||
private opts: {
|
||||
onConnectTiming?: (timing: GatewayConnectTiming) => void;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
},
|
||||
) {
|
||||
gatewayClients.push({
|
||||
start: this.start,
|
||||
stop: this.stop,
|
||||
emitConnectTiming: (timing) => {
|
||||
this.opts.onConnectTiming?.({
|
||||
generation: 1,
|
||||
phase: "hello",
|
||||
durationMs: 20,
|
||||
phaseDurationMs: 2,
|
||||
hasChallenge: true,
|
||||
usedFallback: false,
|
||||
...timing,
|
||||
});
|
||||
},
|
||||
emitHello: (hello) => {
|
||||
this.opts.onHello?.(
|
||||
hello ?? {
|
||||
@@ -219,6 +236,16 @@ function connectHost(tab: Tab) {
|
||||
return { host, client };
|
||||
}
|
||||
|
||||
function eventPayloads(
|
||||
host: ReturnType<typeof createHost>,
|
||||
event: string,
|
||||
): Record<string, unknown>[] {
|
||||
const buffer = host.eventLogBuffer as { event?: string; payload?: unknown }[];
|
||||
return buffer
|
||||
.filter((entry) => entry.event === event && entry.payload && typeof entry.payload === "object")
|
||||
.map((entry) => entry.payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gatewayClients.length = 0;
|
||||
refreshActiveTabMock.mockClear();
|
||||
@@ -252,6 +279,60 @@ describe("connectGateway chat load startup work", () => {
|
||||
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for startup bootstrap before the first chat refresh", async () => {
|
||||
const bootstrap = createDeferred();
|
||||
const { host, client } = connectHost("chat");
|
||||
(host as typeof host & { controlUiBootstrapReady?: Promise<void> }).controlUiBootstrapReady =
|
||||
bootstrap.promise;
|
||||
|
||||
client.emitHello();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshActiveTabMock).not.toHaveBeenCalled();
|
||||
|
||||
bootstrap.resolve();
|
||||
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
|
||||
});
|
||||
|
||||
it("records connect timing through the Control UI performance buffer", () => {
|
||||
const { host, client } = connectHost("chat");
|
||||
|
||||
client.emitConnectTiming({ phase: "request-sent", hasAuthToken: true });
|
||||
|
||||
expect(eventPayloads(host, "control-ui.connect")).toContainEqual(
|
||||
expect.objectContaining({
|
||||
generation: 1,
|
||||
phase: "request-sent",
|
||||
durationMs: 20,
|
||||
hasChallenge: true,
|
||||
hasAuthToken: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts chat refresh before lower-priority hello work", async () => {
|
||||
const agentsList = createDeferred();
|
||||
loadAgentsMock.mockReturnValueOnce(agentsList.promise);
|
||||
const { host, client } = connectHost("chat");
|
||||
|
||||
client.emitHello();
|
||||
|
||||
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
|
||||
expect(loadAgentsMock).toHaveBeenCalledWith(host);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host, {
|
||||
applyIdentity: false,
|
||||
}),
|
||||
);
|
||||
expect(refreshActiveTabMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loadControlUiBootstrapConfigMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(loadAssistantIdentityMock).toHaveBeenCalledWith(host);
|
||||
expect(loadHealthStateMock).toHaveBeenCalledWith(host);
|
||||
expect(verifyPushMock).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("starts literal global chat refresh before agents.list when hello names the default agent", async () => {
|
||||
const agentsList = createDeferred();
|
||||
loadAgentsMock.mockReturnValueOnce(agentsList.promise);
|
||||
|
||||
@@ -5,8 +5,15 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js";
|
||||
import type { ActivityEntry } from "./activity-model.ts";
|
||||
import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts";
|
||||
import type { GatewayHelloOk } from "./gateway.ts";
|
||||
import type { ChatQueueItem } from "./ui-types.ts";
|
||||
|
||||
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const loadChatComposerSnapshotMock = vi.hoisted(() =>
|
||||
vi.fn<(...args: unknown[]) => { draft: string; queue: ChatQueueItem[] } | null>(() => null),
|
||||
);
|
||||
const restoreChatComposerStateMock = vi.hoisted(() =>
|
||||
vi.fn<(...args: unknown[]) => boolean>(() => false),
|
||||
);
|
||||
const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
type GatewayRequest = (method: string, payload?: unknown) => Promise<unknown>;
|
||||
@@ -118,6 +125,15 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
|
||||
loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./chat/composer-persistence.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./chat/composer-persistence.ts")>();
|
||||
return {
|
||||
...actual,
|
||||
loadChatComposerSnapshot: loadChatComposerSnapshotMock,
|
||||
restoreChatComposerState: restoreChatComposerStateMock,
|
||||
};
|
||||
});
|
||||
|
||||
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
|
||||
chatMessages: unknown[];
|
||||
chatQueue: import("./ui-types.ts").ChatQueueItem[];
|
||||
@@ -172,7 +188,9 @@ function createHost(): TestGatewayHost {
|
||||
updateStatusBanner: null,
|
||||
sessionKey: "main",
|
||||
chatMessages: [],
|
||||
chatMessage: "",
|
||||
chatQueue: [],
|
||||
chatComposerProvisionalRestore: null,
|
||||
chatQueueBySession: {},
|
||||
chatToolMessages: [],
|
||||
activityEntries: [],
|
||||
@@ -245,6 +263,10 @@ describe("connectGateway", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientInstances.length = 0;
|
||||
loadChatHistoryMock.mockClear();
|
||||
loadChatComposerSnapshotMock.mockReset();
|
||||
loadChatComposerSnapshotMock.mockReturnValue(null);
|
||||
restoreChatComposerStateMock.mockReset();
|
||||
restoreChatComposerStateMock.mockReturnValue(false);
|
||||
loadControlUiBootstrapConfigMock.mockClear();
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => callback(Date.now()), 0),
|
||||
@@ -275,6 +297,79 @@ describe("connectGateway", () => {
|
||||
expect(host.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("lets hello-scoped composer state replace an unchanged provisional restore", () => {
|
||||
const { host, client } = connectHostGateway();
|
||||
const provisionalQueue = [{ id: "queued-old", text: "wrong agent", createdAt: 1 }];
|
||||
const scopedQueue = [{ id: "queued-new", text: "right agent", createdAt: 2 }];
|
||||
host.chatMessage = "fallback draft";
|
||||
host.chatQueue = provisionalQueue;
|
||||
host.chatComposerProvisionalRestore = {
|
||||
sessionKey: "main",
|
||||
chatMessage: "fallback draft",
|
||||
chatQueue: provisionalQueue,
|
||||
};
|
||||
loadChatComposerSnapshotMock.mockReturnValueOnce({
|
||||
draft: "scoped draft",
|
||||
queue: scopedQueue,
|
||||
});
|
||||
restoreChatComposerStateMock.mockImplementationOnce((target: unknown) => {
|
||||
const hostTarget = target as typeof host;
|
||||
expect(hostTarget.chatMessage).toBe("");
|
||||
expect(hostTarget.chatQueue).toEqual([]);
|
||||
hostTarget.chatMessage = "scoped draft";
|
||||
hostTarget.chatQueue = scopedQueue;
|
||||
return true;
|
||||
});
|
||||
|
||||
client.emitHello({
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
snapshot: {
|
||||
sessionDefaults: {
|
||||
defaultAgentId: "agent-b",
|
||||
mainKey: "main",
|
||||
mainSessionKey: "agent:agent-b:main",
|
||||
},
|
||||
},
|
||||
auth: { role: "operator", scopes: [] },
|
||||
});
|
||||
|
||||
expect(loadChatComposerSnapshotMock).toHaveBeenCalledWith(host, "agent:agent-b:main");
|
||||
expect(host.sessionKey).toBe("agent:agent-b:main");
|
||||
expect(host.chatMessage).toBe("scoped draft");
|
||||
expect(host.chatQueue).toBe(scopedQueue);
|
||||
expect(host.chatComposerProvisionalRestore).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a provisional composer restore when the user edited before hello", () => {
|
||||
const { host, client } = connectHostGateway();
|
||||
const provisionalQueue = [{ id: "queued-old", text: "offline", createdAt: 1 }];
|
||||
host.chatMessage = "user edit";
|
||||
host.chatQueue = provisionalQueue;
|
||||
host.chatComposerProvisionalRestore = {
|
||||
sessionKey: "main",
|
||||
chatMessage: "fallback draft",
|
||||
chatQueue: provisionalQueue,
|
||||
};
|
||||
loadChatComposerSnapshotMock.mockReturnValueOnce({
|
||||
draft: "scoped draft",
|
||||
queue: [{ id: "queued-new", text: "right agent", createdAt: 2 }],
|
||||
});
|
||||
restoreChatComposerStateMock.mockImplementationOnce((target: unknown) => {
|
||||
const hostTarget = target as typeof host;
|
||||
expect(hostTarget.chatMessage).toBe("user edit");
|
||||
expect(hostTarget.chatQueue).toBe(provisionalQueue);
|
||||
return false;
|
||||
});
|
||||
|
||||
client.emitHello();
|
||||
|
||||
expect(loadChatComposerSnapshotMock).not.toHaveBeenCalled();
|
||||
expect(host.chatMessage).toBe("user edit");
|
||||
expect(host.chatQueue).toBe(provisionalQueue);
|
||||
expect(host.chatComposerProvisionalRestore).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale client onEvent callbacks after reconnect", () => {
|
||||
const host = createHost();
|
||||
|
||||
@@ -863,7 +958,7 @@ describe("connectGateway", () => {
|
||||
expect(host.lastError).toBe("disconnected (1006): no reason");
|
||||
});
|
||||
|
||||
it("refreshes bootstrap config after hello", () => {
|
||||
it("refreshes bootstrap config after hello", async () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
@@ -871,7 +966,7 @@ describe("connectGateway", () => {
|
||||
|
||||
client.emitHello();
|
||||
|
||||
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1));
|
||||
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host, { applyIdentity: false });
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,14 @@ import {
|
||||
type SessionOperationEventPayload,
|
||||
} from "./app-tool-stream.ts";
|
||||
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
|
||||
import { restoreChatComposerState } from "./chat/composer-persistence.ts";
|
||||
import { loadChatComposerSnapshot, restoreChatComposerState } from "./chat/composer-persistence.ts";
|
||||
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
|
||||
import { parseChatSideResult, type ChatSideResult } from "./chat/side-result.ts";
|
||||
import { formatConnectError } from "./connect-error.ts";
|
||||
import { recordControlUiRpcTiming } from "./control-ui-performance.ts";
|
||||
import {
|
||||
recordControlUiConnectTiming,
|
||||
recordControlUiRpcTiming,
|
||||
} from "./control-ui-performance.ts";
|
||||
import { loadAgents, type AgentsState } from "./controllers/agents.ts";
|
||||
import {
|
||||
loadAssistantIdentity,
|
||||
@@ -92,7 +95,7 @@ import type {
|
||||
StatusSummary,
|
||||
UpdateAvailable,
|
||||
} from "./types.ts";
|
||||
import type { ChatSessionRefreshTarget } from "./ui-types.ts";
|
||||
import type { ChatQueueItem, ChatSessionRefreshTarget } from "./ui-types.ts";
|
||||
|
||||
function isGenericBrowserFetchFailure(message: string): boolean {
|
||||
return /^(?:typeerror:\s*)?(?:fetch failed|failed to fetch)$/i.test(message.trim());
|
||||
@@ -134,12 +137,20 @@ type GatewayHost = {
|
||||
pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null;
|
||||
refreshSessionsAfterChat: Map<string, ChatSessionRefreshTarget>;
|
||||
sessionsLoading?: boolean;
|
||||
chatMessage: string;
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatComposerProvisionalRestore?: {
|
||||
sessionKey: string;
|
||||
chatMessage: string;
|
||||
chatQueue: ChatQueueItem[];
|
||||
} | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalBusy: boolean;
|
||||
execApprovalError: string | null;
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
reconcileWebPushState?: () => Promise<void> | void;
|
||||
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
|
||||
controlUiBootstrapReady?: Promise<void> | null;
|
||||
};
|
||||
|
||||
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
|
||||
@@ -600,6 +611,44 @@ function normalizeStartupRefreshError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function chatQueueMatches(left: ChatQueueItem[], right: ChatQueueItem[]): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
function prepareHelloScopedComposerRestore(host: GatewayHost) {
|
||||
const provisional = host.chatComposerProvisionalRestore;
|
||||
host.chatComposerProvisionalRestore = null;
|
||||
const snapshot = host.hello?.snapshot as
|
||||
| { sessionDefaults?: SessionDefaultsSnapshot }
|
||||
| undefined;
|
||||
const provisionalSessionKey = provisional
|
||||
? normalizeSessionKeyForDefaults(provisional.sessionKey, snapshot?.sessionDefaults ?? {})
|
||||
: "";
|
||||
if (!provisional || !areUiSessionKeysEquivalent(provisionalSessionKey, host.sessionKey)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
host.chatMessage !== provisional.chatMessage ||
|
||||
!chatQueueMatches(host.chatQueue, provisional.chatQueue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!loadChatComposerSnapshot(host, host.sessionKey)) {
|
||||
return;
|
||||
}
|
||||
// The pre-hello restore used fallback agent scope for offline recovery.
|
||||
// Once hello resolves the real scope, clear only an untouched provisional
|
||||
// draft so the scoped restore can replace it without clobbering user edits.
|
||||
host.chatMessage = "";
|
||||
host.chatQueue = [];
|
||||
}
|
||||
|
||||
async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
|
||||
let initialRefreshError: Error | undefined;
|
||||
const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host);
|
||||
@@ -629,6 +678,25 @@ async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentsThenRefreshActiveTabAfterBootstrap(
|
||||
host: GatewayHost,
|
||||
client: GatewayBrowserClient,
|
||||
) {
|
||||
await host.controlUiBootstrapReady?.catch(() => undefined);
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
await loadAgentsThenRefreshActiveTab(host);
|
||||
}
|
||||
|
||||
function scheduleDeferredStartupWork(callback: () => void) {
|
||||
if (typeof queueMicrotask === "function") {
|
||||
queueMicrotask(callback);
|
||||
return;
|
||||
}
|
||||
void Promise.resolve().then(callback);
|
||||
}
|
||||
|
||||
export function connectGateway(host: GatewayHost, options?: ConnectGatewayOptions) {
|
||||
const shutdownHost = host as GatewayHostWithShutdownMessage;
|
||||
const reconnectReason = options?.reason ?? "initial";
|
||||
@@ -676,13 +744,10 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
host.chatError = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
prepareHelloScopedComposerRestore(host);
|
||||
restoreChatComposerState(host as unknown as Parameters<typeof restoreChatComposerState>[0], {
|
||||
preserveCurrent: true,
|
||||
});
|
||||
void loadControlUiBootstrapConfig(
|
||||
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0],
|
||||
{ applyIdentity: false },
|
||||
);
|
||||
// Process any pending abort from before the disconnect.
|
||||
if (host.pendingAbort) {
|
||||
const abort = host.pendingAbort;
|
||||
@@ -750,15 +815,24 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
host as unknown as SessionsState & { sessionKey: string },
|
||||
{ force: true },
|
||||
);
|
||||
void loadAssistantIdentity(host as unknown as AssistantIdentityState);
|
||||
if (host.tab !== "chat") {
|
||||
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
|
||||
}
|
||||
void loadHealthState(host as unknown as HealthState);
|
||||
void loadAgentsThenRefreshActiveTab(host);
|
||||
// Re-run push reconciliation now that the gateway client is available.
|
||||
void host.reconcileWebPushState?.();
|
||||
void verifyPendingUpdateVersion(host, client);
|
||||
void loadAgentsThenRefreshActiveTabAfterBootstrap(host, client);
|
||||
scheduleDeferredStartupWork(() => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
void loadControlUiBootstrapConfig(
|
||||
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0],
|
||||
{ applyIdentity: false },
|
||||
);
|
||||
void loadAssistantIdentity(host as unknown as AssistantIdentityState);
|
||||
if (host.tab !== "chat") {
|
||||
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
|
||||
}
|
||||
void loadHealthState(host as unknown as HealthState);
|
||||
// Re-run push reconciliation now that the gateway client is available.
|
||||
void host.reconcileWebPushState?.();
|
||||
void verifyPendingUpdateVersion(host, client);
|
||||
});
|
||||
},
|
||||
onClose: ({ code, reason, error }) => {
|
||||
if (host.client !== client) {
|
||||
@@ -806,6 +880,12 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
}
|
||||
recordControlUiRpcTiming(host, timing);
|
||||
},
|
||||
onConnectTiming: (timing) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
recordControlUiConnectTiming(host, timing);
|
||||
},
|
||||
onGap: ({ expected, received }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatQueueItem } from "./ui-types.ts";
|
||||
|
||||
const { applySettingsFromUrlMock, connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({
|
||||
applySettingsFromUrlMock: vi.fn(),
|
||||
connectGatewayMock: vi.fn(),
|
||||
loadBootstrapMock: vi.fn(),
|
||||
}));
|
||||
const { applySettingsFromUrlMock, connectGatewayMock, loadBootstrapMock, restoreComposerMock } =
|
||||
vi.hoisted(() => ({
|
||||
applySettingsFromUrlMock: vi.fn(),
|
||||
connectGatewayMock: vi.fn(),
|
||||
loadBootstrapMock: vi.fn(),
|
||||
restoreComposerMock: vi.fn<(...args: unknown[]) => boolean>(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("./app-gateway.ts", () => ({
|
||||
connectGateway: connectGatewayMock,
|
||||
@@ -15,6 +18,11 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
|
||||
loadControlUiBootstrapConfig: loadBootstrapMock,
|
||||
}));
|
||||
|
||||
vi.mock("./chat/composer-persistence.ts", () => ({
|
||||
persistChatComposerState: vi.fn(),
|
||||
restoreChatComposerState: restoreComposerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./app-settings.ts", () => ({
|
||||
applySettingsFromUrl: applySettingsFromUrlMock,
|
||||
attachThemeListener: vi.fn(),
|
||||
@@ -70,6 +78,15 @@ function createHost() {
|
||||
serverVersion: null,
|
||||
chatHasAutoScrolled: false,
|
||||
chatManualRefreshInFlight: false,
|
||||
sessionKey: "main",
|
||||
chatMessage: "",
|
||||
chatQueue: [] as ChatQueueItem[],
|
||||
pendingGatewayUrl: null as string | null,
|
||||
chatComposerProvisionalRestore: null as {
|
||||
sessionKey: string;
|
||||
chatMessage: string;
|
||||
chatQueue: ChatQueueItem[];
|
||||
} | null,
|
||||
chatLoading: false,
|
||||
chatMessages: [],
|
||||
chatToolMessages: [],
|
||||
@@ -87,6 +104,8 @@ describe("handleConnected", () => {
|
||||
applySettingsFromUrlMock.mockReset();
|
||||
connectGatewayMock.mockReset();
|
||||
loadBootstrapMock.mockReset();
|
||||
restoreComposerMock.mockReset();
|
||||
restoreComposerMock.mockReturnValue(false);
|
||||
startNodesPollingMock.mockReset();
|
||||
scheduleChatScrollMock.mockReset();
|
||||
vi.stubGlobal("window", {
|
||||
@@ -94,38 +113,39 @@ describe("handleConnected", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for bootstrap load before first gateway connect", async () => {
|
||||
it("starts the first gateway connect without waiting for bootstrap", async () => {
|
||||
const bootstrap = createDeferred();
|
||||
loadBootstrapMock.mockReturnValueOnce(bootstrap.promise);
|
||||
connectGatewayMock.mockReset();
|
||||
const host = createHost();
|
||||
|
||||
handleConnected(host as never);
|
||||
expect(connectGatewayMock).not.toHaveBeenCalled();
|
||||
expect(connectGatewayMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
bootstrap.resolve();
|
||||
await Promise.resolve();
|
||||
expect(connectGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips deferred connect when disconnected before bootstrap resolves", async () => {
|
||||
it("does not start a second gateway connect when bootstrap resolves after disconnect", async () => {
|
||||
const bootstrap = createDeferred();
|
||||
loadBootstrapMock.mockReturnValueOnce(bootstrap.promise);
|
||||
connectGatewayMock.mockReset();
|
||||
const host = createHost();
|
||||
|
||||
handleConnected(host as never);
|
||||
expect(connectGatewayMock).not.toHaveBeenCalled();
|
||||
expect(connectGatewayMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
host.connectGeneration += 1;
|
||||
bootstrap.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(connectGatewayMock).not.toHaveBeenCalled();
|
||||
expect(connectGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scrubs URL settings before starting the bootstrap fetch", () => {
|
||||
loadBootstrapMock.mockResolvedValueOnce(undefined);
|
||||
const bootstrap = Promise.resolve();
|
||||
loadBootstrapMock.mockReturnValueOnce(bootstrap);
|
||||
const host = createHost();
|
||||
|
||||
handleConnected(host as never);
|
||||
@@ -135,6 +155,47 @@ describe("handleConnected", () => {
|
||||
expect(applySettingsFromUrlMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loadBootstrapMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(loadBootstrapMock).toHaveBeenCalledWith(host, { applyIdentity: false });
|
||||
expect(
|
||||
(host as typeof host & { controlUiBootstrapReady?: Promise<void> }).controlUiBootstrapReady,
|
||||
).toBe(bootstrap);
|
||||
});
|
||||
|
||||
it("restores the local composer before starting the gateway connect", () => {
|
||||
loadBootstrapMock.mockResolvedValue(undefined);
|
||||
restoreComposerMock.mockImplementationOnce((target: unknown) => {
|
||||
const hostTarget = target as ReturnType<typeof createHost>;
|
||||
hostTarget.chatMessage = "offline draft";
|
||||
hostTarget.chatQueue = [{ id: "queued-1", text: "retry me", createdAt: 1 }];
|
||||
return true;
|
||||
});
|
||||
const host = createHost();
|
||||
|
||||
handleConnected(host as never);
|
||||
|
||||
expect(restoreComposerMock).toHaveBeenCalledWith(host, { preserveCurrent: true });
|
||||
expect(restoreComposerMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
connectGatewayMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(host.chatComposerProvisionalRestore).toEqual({
|
||||
sessionKey: "main",
|
||||
chatMessage: "offline draft",
|
||||
chatQueue: [{ id: "queued-1", text: "retry me", createdAt: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore old-gateway composer state during a pending gateway switch", () => {
|
||||
loadBootstrapMock.mockResolvedValue(undefined);
|
||||
applySettingsFromUrlMock.mockImplementationOnce((target: ReturnType<typeof createHost>) => {
|
||||
target.pendingGatewayUrl = "ws://new-gateway.test/control";
|
||||
});
|
||||
const host = createHost();
|
||||
|
||||
handleConnected(host as never);
|
||||
|
||||
expect(restoreComposerMock).not.toHaveBeenCalled();
|
||||
expect(host.chatComposerProvisionalRestore).toBeNull();
|
||||
expect(connectGatewayMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("starts Nodes polling only when the Nodes tab is active on connect", () => {
|
||||
|
||||
@@ -56,6 +56,11 @@ type LifecycleHost = {
|
||||
sessionKey: string;
|
||||
chatMessage: string;
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatComposerProvisionalRestore?: {
|
||||
sessionKey: string;
|
||||
chatMessage: string;
|
||||
chatQueue: ChatQueueItem[];
|
||||
} | null;
|
||||
chatComposerPersistTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
|
||||
chatComposerPersistSnapshot?: PendingChatComposerPersistSnapshot | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
@@ -83,6 +88,7 @@ type LifecycleHost = {
|
||||
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
|
||||
controlUiTabPaintSeq?: number;
|
||||
controlUiResponsivenessObserver?: { disconnect: () => void } | null;
|
||||
controlUiBootstrapReady?: Promise<void> | null;
|
||||
popStateHandler: () => void;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
@@ -91,21 +97,27 @@ export function handleConnected(host: LifecycleHost) {
|
||||
const connectGeneration = ++host.connectGeneration;
|
||||
host.basePath = inferBasePath();
|
||||
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
|
||||
const bootstrapReady = loadControlUiBootstrapConfig(
|
||||
host.controlUiBootstrapReady = loadControlUiBootstrapConfig(
|
||||
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0],
|
||||
{ applyIdentity: false },
|
||||
);
|
||||
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
|
||||
const hasPendingGatewaySwitch =
|
||||
typeof host.pendingGatewayUrl === "string" && host.pendingGatewayUrl.trim();
|
||||
if (!hasPendingGatewaySwitch && restoreChatComposerState(host, { preserveCurrent: true })) {
|
||||
host.chatComposerProvisionalRestore = {
|
||||
sessionKey: host.sessionKey,
|
||||
chatMessage: host.chatMessage,
|
||||
chatQueue: [...host.chatQueue],
|
||||
};
|
||||
} else {
|
||||
host.chatComposerProvisionalRestore = null;
|
||||
}
|
||||
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
|
||||
window.addEventListener("popstate", host.popStateHandler);
|
||||
void bootstrapReady.finally(() => {
|
||||
if (host.connectGeneration !== connectGeneration) {
|
||||
return;
|
||||
}
|
||||
if (!host.pendingGatewayUrl) {
|
||||
restoreChatComposerState(host, { preserveCurrent: true });
|
||||
}
|
||||
if (host.connectGeneration === connectGeneration) {
|
||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||
});
|
||||
}
|
||||
if (host.tab === "nodes") {
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import {
|
||||
recordControlUiConnectTiming,
|
||||
recordControlUiPerformanceEvent,
|
||||
recordControlUiRenderTiming,
|
||||
startControlUiResponsivenessObserver,
|
||||
@@ -92,6 +93,49 @@ describe("recordControlUiPerformanceEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordControlUiConnectTiming", () => {
|
||||
it("records safe connect phase payloads without auth material", () => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => undefined);
|
||||
const host = createHost();
|
||||
|
||||
recordControlUiConnectTiming(host, {
|
||||
generation: 1,
|
||||
phase: "request-sent",
|
||||
durationMs: 42.2,
|
||||
phaseDurationMs: 5.8,
|
||||
hasChallenge: true,
|
||||
usedFallback: false,
|
||||
secureContext: true,
|
||||
hasDeviceIdentity: true,
|
||||
hasDevice: true,
|
||||
hasAuthToken: true,
|
||||
hasDeviceToken: false,
|
||||
hasPassword: false,
|
||||
});
|
||||
|
||||
const entry = requireBufferedEvent(host);
|
||||
const payload = payloadOf(entry);
|
||||
expect(entry.event).toBe("control-ui.connect");
|
||||
expect(payload).toEqual({
|
||||
generation: 1,
|
||||
phase: "request-sent",
|
||||
durationMs: 42,
|
||||
phaseDurationMs: 6,
|
||||
slow: false,
|
||||
hasChallenge: true,
|
||||
usedFallback: false,
|
||||
secureContext: true,
|
||||
hasDeviceIdentity: true,
|
||||
hasDevice: true,
|
||||
hasAuthToken: true,
|
||||
hasDeviceToken: false,
|
||||
hasPassword: false,
|
||||
errorCode: undefined,
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toContain("token-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordControlUiRenderTiming", () => {
|
||||
it("records slow render timings after the current render turn", async () => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => undefined);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { GatewayRequestTiming } from "./gateway.ts";
|
||||
import type { GatewayConnectTiming, GatewayRequestTiming } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
|
||||
type ControlUiPerformanceHost = {
|
||||
@@ -21,6 +21,7 @@ export type ControlUiRefreshRun = {
|
||||
|
||||
const EVENT_LOG_LIMIT = 250;
|
||||
const SLOW_RPC_MS = 1_000;
|
||||
const SLOW_CONNECT_MS = 1_000;
|
||||
const SLOW_RENDER_MS = 16;
|
||||
const VERY_SLOW_RENDER_MS = 50;
|
||||
const RESPONSIVENESS_ENTRY_MS = 50;
|
||||
@@ -233,6 +234,36 @@ export function recordControlUiRpcTiming(
|
||||
);
|
||||
}
|
||||
|
||||
export function recordControlUiConnectTiming(
|
||||
host: ControlUiPerformanceHost,
|
||||
timing: GatewayConnectTiming,
|
||||
) {
|
||||
const durationMs = roundedControlUiDurationMs(timing.durationMs);
|
||||
const phaseDurationMs = roundedControlUiDurationMs(timing.phaseDurationMs);
|
||||
const slow = durationMs >= SLOW_CONNECT_MS;
|
||||
recordControlUiPerformanceEvent(
|
||||
host,
|
||||
"control-ui.connect",
|
||||
{
|
||||
generation: timing.generation,
|
||||
phase: timing.phase,
|
||||
durationMs,
|
||||
phaseDurationMs,
|
||||
slow,
|
||||
hasChallenge: timing.hasChallenge,
|
||||
usedFallback: timing.usedFallback,
|
||||
secureContext: timing.secureContext,
|
||||
hasDeviceIdentity: timing.hasDeviceIdentity,
|
||||
hasDevice: timing.hasDevice,
|
||||
hasAuthToken: timing.hasAuthToken,
|
||||
hasDeviceToken: timing.hasDeviceToken,
|
||||
hasPassword: timing.hasPassword,
|
||||
errorCode: timing.errorCode,
|
||||
},
|
||||
{ warn: timing.phase === "failed" || slow, maxBufferedEventsForType: 40 },
|
||||
);
|
||||
}
|
||||
|
||||
export function recordControlUiRenderTiming(
|
||||
host: ControlUiPerformanceHost,
|
||||
surface: string,
|
||||
|
||||
@@ -126,6 +126,22 @@ type RequestTimingPayload = {
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
type ConnectTimingPayload = {
|
||||
generation?: number;
|
||||
phase?: string;
|
||||
durationMs?: number;
|
||||
phaseDurationMs?: number;
|
||||
hasChallenge?: boolean;
|
||||
usedFallback?: boolean;
|
||||
secureContext?: boolean;
|
||||
hasDeviceIdentity?: boolean;
|
||||
hasDevice?: boolean;
|
||||
hasAuthToken?: boolean;
|
||||
hasDeviceToken?: boolean;
|
||||
hasPassword?: boolean;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label}`);
|
||||
@@ -206,6 +222,12 @@ function expectLatestRequestTiming(
|
||||
}
|
||||
}
|
||||
|
||||
function connectTimingPayloads(onConnectTiming: ReturnType<typeof vi.fn>): ConnectTimingPayload[] {
|
||||
return onConnectTiming.mock.calls.map(
|
||||
([payload]) => requireRecord(payload, "connect timing") as ConnectTimingPayload,
|
||||
);
|
||||
}
|
||||
|
||||
function stubWindowGlobals(storage?: ReturnType<typeof createStorageMock>) {
|
||||
vi.stubGlobal("window", {
|
||||
location: { href: "http://127.0.0.1:18789/" },
|
||||
@@ -556,6 +578,110 @@ describe("GatewayBrowserClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports connect phase timing without credentials or nonce values", async () => {
|
||||
const onConnectTiming = vi.fn();
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-auth-token",
|
||||
onConnectTiming,
|
||||
});
|
||||
|
||||
const { ws, connectFrame } = await startConnect(client, "nonce-secret");
|
||||
const sentPayloads = connectTimingPayloads(onConnectTiming);
|
||||
expect(sentPayloads.map((payload) => payload.phase)).toEqual([
|
||||
"socket-open",
|
||||
"challenge",
|
||||
"device-identity-ready",
|
||||
"connect-plan-ready",
|
||||
"request-sent",
|
||||
]);
|
||||
for (const payload of sentPayloads) {
|
||||
expect(payload.generation).toBe(1);
|
||||
expect(payload.durationMs).toBeTypeOf("number");
|
||||
expect(payload.phaseDurationMs).toBeTypeOf("number");
|
||||
expect(payload).not.toHaveProperty("token");
|
||||
expect(payload).not.toHaveProperty("passwordValue");
|
||||
expect(payload).not.toHaveProperty("nonce");
|
||||
expect(JSON.stringify(payload)).not.toContain("shared-auth-token");
|
||||
expect(JSON.stringify(payload)).not.toContain("nonce-secret");
|
||||
}
|
||||
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
auth: { role: "operator", scopes: [] },
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(connectTimingPayloads(onConnectTiming).at(-1)?.phase).toBe("hello");
|
||||
});
|
||||
expect(connectTimingPayloads(onConnectTiming).at(-1)).toMatchObject({
|
||||
generation: 1,
|
||||
phase: "hello",
|
||||
hasChallenge: true,
|
||||
usedFallback: false,
|
||||
secureContext: true,
|
||||
hasDeviceIdentity: true,
|
||||
hasDevice: true,
|
||||
hasAuthToken: true,
|
||||
hasDeviceToken: false,
|
||||
hasPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks fallback connect timing when no challenge arrives", async () => {
|
||||
useNodeFakeTimers();
|
||||
const onConnectTiming = vi.fn();
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-auth-token",
|
||||
onConnectTiming,
|
||||
});
|
||||
|
||||
client.start();
|
||||
const ws = getLatestWebSocket();
|
||||
ws.emitOpen();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(connectTimingPayloads(onConnectTiming).map((payload) => payload.phase)).toContain(
|
||||
"fallback",
|
||||
);
|
||||
expect(connectTimingPayloads(onConnectTiming).at(-1)).toMatchObject({
|
||||
phase: "request-sent",
|
||||
hasChallenge: false,
|
||||
usedFallback: true,
|
||||
});
|
||||
|
||||
client.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reports failed connect timing when the socket closes before hello", async () => {
|
||||
const onConnectTiming = vi.fn();
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-auth-token",
|
||||
onConnectTiming,
|
||||
});
|
||||
|
||||
const { ws } = await startConnect(client);
|
||||
ws.emitClose(1006, "socket lost");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(connectTimingPayloads(onConnectTiming).at(-1)).toMatchObject({
|
||||
phase: "failed",
|
||||
errorCode: "SOCKET_CLOSED",
|
||||
});
|
||||
});
|
||||
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("prefers explicit shared auth over cached device tokens", async () => {
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
|
||||
@@ -283,6 +283,7 @@ export type GatewayBrowserClientOptions = {
|
||||
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
onRequestTiming?: (timing: GatewayRequestTiming) => void;
|
||||
onConnectTiming?: (timing: GatewayConnectTiming) => void;
|
||||
};
|
||||
|
||||
export type GatewayEventListener = (evt: GatewayEventFrame) => void;
|
||||
@@ -297,6 +298,39 @@ export type GatewayRequestTiming = {
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
export type GatewayConnectTimingPhase =
|
||||
| "socket-open"
|
||||
| "challenge"
|
||||
| "fallback"
|
||||
| "device-identity-ready"
|
||||
| "connect-plan-ready"
|
||||
| "request-sent"
|
||||
| "hello"
|
||||
| "failed";
|
||||
|
||||
export type GatewayConnectTiming = {
|
||||
generation: number;
|
||||
phase: GatewayConnectTimingPhase;
|
||||
durationMs: number;
|
||||
phaseDurationMs: number;
|
||||
hasChallenge: boolean;
|
||||
usedFallback: boolean;
|
||||
secureContext?: boolean;
|
||||
hasDeviceIdentity?: boolean;
|
||||
hasDevice?: boolean;
|
||||
hasAuthToken?: boolean;
|
||||
hasDeviceToken?: boolean;
|
||||
hasPassword?: boolean;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
type ConnectTimingState = {
|
||||
startedAtMs: number;
|
||||
lastAtMs: number;
|
||||
hasChallenge: boolean;
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
|
||||
const CONNECT_FAILED_CLOSE_CODE = 4008;
|
||||
const STARTUP_RETRY_CLOSE_CODE = 4013;
|
||||
@@ -450,6 +484,7 @@ export class GatewayBrowserClient {
|
||||
private deviceTokenRetryBudgetUsed = false;
|
||||
private pendingStartupReconnectDelayMs: number | null = null;
|
||||
private eventListeners = new Set<GatewayEventListener>();
|
||||
private connectTiming = new Map<number, ConnectTimingState>();
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {}
|
||||
|
||||
@@ -467,6 +502,7 @@ export class GatewayBrowserClient {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
this.pendingStartupReconnectDelayMs = null;
|
||||
this.connectTiming.clear();
|
||||
this.flushPending(new Error("gateway client stopped"));
|
||||
}
|
||||
|
||||
@@ -500,6 +536,7 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
const generation = ++this.connectGeneration;
|
||||
this.ws = ws;
|
||||
this.startConnectTiming(generation);
|
||||
ws.addEventListener("open", () => this.queueConnect(ws, generation));
|
||||
ws.addEventListener("message", (ev) => {
|
||||
if (!this.isActiveSocket(ws, generation)) {
|
||||
@@ -514,6 +551,9 @@ export class GatewayBrowserClient {
|
||||
const reason = ev.reason ?? "";
|
||||
const connectError = this.pendingConnectError;
|
||||
this.pendingConnectError = undefined;
|
||||
this.emitConnectTiming(generation, "failed", {
|
||||
errorCode: connectError?.code ?? "SOCKET_CLOSED",
|
||||
});
|
||||
this.ws = null;
|
||||
if (this.pendingStartupReconnectDelayMs !== null) {
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
@@ -569,6 +609,57 @@ export class GatewayBrowserClient {
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
private startConnectTiming(generation: number): void {
|
||||
const now = this.nowMs();
|
||||
this.connectTiming.set(generation, {
|
||||
startedAtMs: now,
|
||||
lastAtMs: now,
|
||||
hasChallenge: false,
|
||||
usedFallback: false,
|
||||
});
|
||||
}
|
||||
|
||||
private updateConnectTimingState(
|
||||
generation: number,
|
||||
updates: Partial<Pick<ConnectTimingState, "hasChallenge" | "usedFallback">>,
|
||||
): void {
|
||||
const state = this.connectTiming.get(generation);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
Object.assign(state, updates);
|
||||
}
|
||||
|
||||
private emitConnectTiming(
|
||||
generation: number,
|
||||
phase: GatewayConnectTimingPhase,
|
||||
payload: Partial<GatewayConnectTiming> = {},
|
||||
): void {
|
||||
const state = this.connectTiming.get(generation);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const endedAtMs = this.nowMs();
|
||||
try {
|
||||
this.opts.onConnectTiming?.({
|
||||
generation,
|
||||
phase,
|
||||
durationMs: Math.max(0, endedAtMs - state.startedAtMs),
|
||||
phaseDurationMs: Math.max(0, endedAtMs - state.lastAtMs),
|
||||
hasChallenge: state.hasChallenge,
|
||||
usedFallback: state.usedFallback,
|
||||
...payload,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[gateway] connect timing handler error:", err);
|
||||
} finally {
|
||||
state.lastAtMs = endedAtMs;
|
||||
if (phase === "hello" || phase === "failed") {
|
||||
this.connectTiming.delete(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitRequestTiming(id: string, pending: Pending, ok: boolean, errorCode?: string): void {
|
||||
const endedAtMs = this.nowMs();
|
||||
try {
|
||||
@@ -586,6 +677,19 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
}
|
||||
|
||||
private connectPlanTimingPayload(plan: ConnectPlan): Partial<GatewayConnectTiming> {
|
||||
return {
|
||||
secureContext: Boolean(plan.deviceIdentity),
|
||||
hasDeviceIdentity: Boolean(plan.deviceIdentity),
|
||||
hasDevice: Boolean(plan.device),
|
||||
hasAuthToken: Boolean(plan.selectedAuth.authToken),
|
||||
hasDeviceToken: Boolean(
|
||||
plan.selectedAuth.authDeviceToken ?? plan.selectedAuth.resolvedDeviceToken,
|
||||
),
|
||||
hasPassword: Boolean(plan.selectedAuth.authPassword),
|
||||
};
|
||||
}
|
||||
|
||||
private buildConnectClient(): GatewayConnectClientInfo {
|
||||
return {
|
||||
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
@@ -611,7 +715,10 @@ export class GatewayBrowserClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildConnectPlan(connectNonce: string | null): Promise<ConnectPlan> {
|
||||
private async buildConnectPlan(
|
||||
connectNonce: string | null,
|
||||
generation: number,
|
||||
): Promise<ConnectPlan> {
|
||||
const role = CONTROL_UI_OPERATOR_ROLE;
|
||||
const client = this.buildConnectClient();
|
||||
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
||||
@@ -630,12 +737,32 @@ export class GatewayBrowserClient {
|
||||
|
||||
if (isSecureContext) {
|
||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
this.emitConnectTiming(generation, "device-identity-ready", {
|
||||
secureContext: true,
|
||||
hasDeviceIdentity: true,
|
||||
});
|
||||
selectedAuth = this.selectConnectAuth({
|
||||
role,
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
});
|
||||
}
|
||||
const scopes = resolveControlUiConnectScopes(selectedAuth);
|
||||
const device = await buildGatewayConnectDevice({
|
||||
deviceIdentity,
|
||||
client,
|
||||
role,
|
||||
scopes,
|
||||
authToken: selectedAuth.authToken,
|
||||
connectNonce,
|
||||
});
|
||||
this.emitConnectTiming(generation, "connect-plan-ready", {
|
||||
secureContext: isSecureContext,
|
||||
hasDeviceIdentity: Boolean(deviceIdentity),
|
||||
hasDevice: Boolean(device),
|
||||
hasAuthToken: Boolean(selectedAuth.authToken),
|
||||
hasDeviceToken: Boolean(selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken),
|
||||
hasPassword: Boolean(selectedAuth.authPassword),
|
||||
});
|
||||
|
||||
return {
|
||||
role,
|
||||
@@ -645,14 +772,7 @@ export class GatewayBrowserClient {
|
||||
selectedAuth,
|
||||
auth: buildGatewayConnectAuth(selectedAuth),
|
||||
deviceIdentity,
|
||||
device: await buildGatewayConnectDevice({
|
||||
deviceIdentity,
|
||||
client,
|
||||
role,
|
||||
scopes,
|
||||
authToken: selectedAuth.authToken,
|
||||
connectNonce,
|
||||
}),
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -677,6 +797,7 @@ export class GatewayBrowserClient {
|
||||
});
|
||||
}
|
||||
this.backoffMs = 800;
|
||||
this.emitConnectTiming(generation, "hello", this.connectPlanTimingPayload(plan));
|
||||
this.opts.onHello?.(hello);
|
||||
}
|
||||
|
||||
@@ -720,6 +841,10 @@ export class GatewayBrowserClient {
|
||||
} else {
|
||||
this.pendingConnectError = undefined;
|
||||
}
|
||||
this.emitConnectTiming(generation, "failed", {
|
||||
...this.connectPlanTimingPayload(plan),
|
||||
errorCode: err instanceof GatewayRequestError ? err.gatewayCode : "CLIENT_CONNECT_ERROR",
|
||||
});
|
||||
const usedStoredDeviceToken =
|
||||
Boolean(plan.selectedAuth.storedToken) &&
|
||||
(plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken ||
|
||||
@@ -756,13 +881,14 @@ export class GatewayBrowserClient {
|
||||
this.connectSent = true;
|
||||
this.clearConnectTimer();
|
||||
|
||||
const plan = await this.buildConnectPlan(this.connectNonce);
|
||||
const plan = await this.buildConnectPlan(this.connectNonce, generation);
|
||||
if (!this.isActiveSocket(ws, generation) || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (this.pendingDeviceTokenRetry && plan.selectedAuth.authDeviceToken) {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
}
|
||||
this.emitConnectTiming(generation, "request-sent", this.connectPlanTimingPayload(plan));
|
||||
void this.requestOnSocket<GatewayHelloOk>(ws, "connect", this.buildConnectParams(plan))
|
||||
.then((hello) => this.handleConnectHello(hello, plan, ws, generation))
|
||||
.catch((err: unknown) => this.handleConnectFailure(err, plan, ws, generation));
|
||||
@@ -784,6 +910,8 @@ export class GatewayBrowserClient {
|
||||
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
||||
if (nonce) {
|
||||
this.connectNonce = nonce;
|
||||
this.updateConnectTimingState(generation, { hasChallenge: true });
|
||||
this.emitConnectTiming(generation, "challenge");
|
||||
void this.sendConnect(ws, generation);
|
||||
}
|
||||
return;
|
||||
@@ -904,8 +1032,11 @@ export class GatewayBrowserClient {
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
this.clearConnectTimer();
|
||||
this.emitConnectTiming(generation, "socket-open");
|
||||
this.connectTimer = window.setTimeout(() => {
|
||||
this.connectTimer = null;
|
||||
this.updateConnectTimingState(generation, { usedFallback: true });
|
||||
this.emitConnectTiming(generation, "fallback");
|
||||
void this.sendConnect(ws, generation);
|
||||
}, 750);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user