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:
Vincent Koc
2026-06-01 11:41:22 +01:00
committed by GitHub
parent 8bee3be90a
commit 8c8c8c8e32
9 changed files with 712 additions and 51 deletions

View File

@@ -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);

View File

@@ -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 });
});

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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]);
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View File

@@ -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);
}