perf(chat): resume before shared resource hydration

This commit is contained in:
murashit 2026-07-21 16:48:52 +09:00
parent d0965d19b1
commit 6b2a20cf0d
5 changed files with 101 additions and 29 deletions

View file

@ -53,6 +53,7 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
}
export interface ChatConnectionActions {
ensureInitialized(): Promise<void>;
ensureConnected(): Promise<void>;
invalidate(): void;
handleExit(): void;
@ -63,29 +64,40 @@ export interface ChatConnectionActions {
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
let generation = 0;
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
let activeConnection: { generation: number; initialization: Promise<void>; hydration: Promise<void> } | null = null;
const invalidate = (): void => {
generation += 1;
activeConnection = null;
};
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
const startConnection = (): NonNullable<typeof activeConnection> => {
const connectionGeneration = generation;
const connectionIsStale = (): boolean => isStale(connectionGeneration);
const initialization = initializeConnection(host, connectionIsStale);
const hydration = initialization.then(async () => {
if (connectionIsStale() || !host.connection.isConnected()) return;
await hydrateConnectedResources(host, connectionIsStale);
});
const active = { generation: connectionGeneration, initialization, hydration };
activeConnection = active;
const clear = (): void => {
if (activeConnection === active) activeConnection = null;
};
void hydration.then(clear, clear);
return active;
};
const actions: ChatConnectionActions = {
ensureInitialized: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.initialization;
if (host.connection.isConnected()) return;
await startConnection().initialization;
},
ensureConnected: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.promise;
if (activeConnection) return activeConnection.hydration;
if (host.connection.isConnected()) return;
const connectionGeneration = generation;
const promise = initializeConnection(host, () => isStale(connectionGeneration));
const active = { generation: connectionGeneration, promise };
activeConnection = active;
try {
await promise;
} finally {
if (activeConnection === active) {
activeConnection = null;
}
}
await startConnection().hydration;
},
invalidate,
handleExit: () => {
@ -150,8 +162,6 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
host.notifyConnectionFailed();
return;
}
await hydrateConnectedResources(host, isStale);
}
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {

View file

@ -79,6 +79,7 @@ interface ChatPanelThreadLifecycleInput {
appServer: ChatAppServerGateway;
localItemIds: LocalIdSource;
ensureConnected: () => Promise<void>;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
threadStart: ThreadStartActions;
foundation: ChatPanelThreadFoundation;
@ -191,9 +192,19 @@ export function createThreadLifecycleBundle(
host: ChatPanelThreadHost,
input: ChatPanelThreadLifecycleInput,
): ChatPanelThreadLifecycleBundle {
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input;
const {
appServer,
localItemIds,
ensureConnected,
ensureInitialized,
status,
threadStart,
foundation,
notifyActiveThreadIdentityChanged,
} = input;
const lifecycle = createSessionThreadLifecycle(host, {
appServer,
ensureInitialized,
status,
goalSync: foundation.goalSync,
autoTitleCoordinator: foundation.autoTitleCoordinator,
@ -298,6 +309,7 @@ function createSessionThreadLifecycle(
host: ChatPanelThreadHost,
input: {
appServer: ChatAppServerGateway;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
goalSync: ChatPanelGoalSyncActions;
autoTitleCoordinator: AutoTitleCoordinator;
@ -306,7 +318,16 @@ function createSessionThreadLifecycle(
notifyActiveThreadIdentityChanged: () => void;
},
): ChatPanelThreadLifecycle {
const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input;
const {
appServer,
ensureInitialized,
status,
goalSync,
autoTitleCoordinator,
history,
invalidateThreadWork,
notifyActiveThreadIdentityChanged,
} = input;
const restoration = new RestorationController({
stateStore: host.stateStore,
});
@ -315,7 +336,13 @@ function createSessionThreadLifecycle(
};
const resume = createResumeActions({
stateStore: host.stateStore,
resumeTransport: appServer.threadResume,
resumeTransport: {
ensureConnected: async () => {
await ensureInitialized();
return appServer.connectionAvailable();
},
resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId),
},
resumeWork: host.resumeWork,
history,
closing: host.getClosing,

View file

@ -187,6 +187,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
appServer,
localItemIds,
ensureConnected,
ensureInitialized: () => connectionActions.ensureInitialized(),
status,
threadStart,
foundation: threadFoundation,

View file

@ -123,6 +123,32 @@ describe("ChatConnectionActions", () => {
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
});
it("publishes initialization readiness before shared resources finish hydrating", async () => {
const { actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness();
const metadata = deferred<void>();
refreshAppServerMetadata.mockReturnValueOnce(metadata.promise);
await actions.ensureInitialized();
expect(stateStore.getState().connection.initializeResponse).toMatchObject({ codexHome: "/codex" });
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
expect(host.refreshSharedThreads).not.toHaveBeenCalled();
expect(host.scheduleDeferredDiagnostics).not.toHaveBeenCalled();
let connected = false;
const fullyConnected = actions.ensureConnected().then(() => {
connected = true;
});
await Promise.resolve();
expect(connected).toBe(false);
metadata.resolve(undefined);
await fullyConnected;
expect(host.refreshSharedThreads).toHaveBeenCalledOnce();
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
});
it("keeps the initialized connection usable when metadata hydration fails", async () => {
const { actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness();
refreshAppServerMetadata.mockRejectedValueOnce(new Error("config unavailable"));

View file

@ -39,7 +39,7 @@ describe("CodexChatView connection lifecycle", () => {
expectRequestTimes(client, "thread/list", 1);
});
it("keeps connect calls joined while metadata is still loading", async () => {
it("resumes while metadata is still loading without reconnecting", async () => {
const config = deferred<unknown>();
const client = connectedClient({
"config/read": vi.fn(() => config.promise),
@ -47,25 +47,31 @@ describe("CodexChatView connection lifecycle", () => {
connectionMockState().client = client;
const view = await chatView();
const firstConnect = view.surface.connect();
const opening = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expectRequestTimes(client, "config/read", 1);
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
});
let secondResolved = false;
const secondConnect = view.surface.connect().then(() => {
secondResolved = true;
await opening;
let connected = false;
const fullyConnected = view.surface.connect().then(() => {
connected = true;
});
await Promise.resolve();
expect(connectionMockState().connected).toBe(true);
expect(secondResolved).toBe(false);
expect(connectionMockState().connectCalls).toBe(1);
expect(connected).toBe(false);
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" });
expectRequestTimes(client, "thread/list", 0);
config.resolve({});
await Promise.all([firstConnect, secondConnect]);
await fullyConnected;
await waitForAsyncWork(() => {
expectRequestTimes(client, "thread/list", 1);
});
expectRequestTimes(client, "config/read", 1);
expectRequestTimes(client, "thread/list", 1);
});
it("loads app-server metadata after connecting", async () => {
@ -209,7 +215,9 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
const connecting = view.surface.connect();
await Promise.resolve();
await waitForAsyncWork(() => {
expectRequestTimes(client, "config/read", 1);
});
await view.onClose();
resolveConfig({});
await connecting;