From a2137044816218b0d00eaf5c5f469a43c7ddb59a Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Fri, 7 Aug 2026 00:41:18 -0700 Subject: [PATCH] fix(tui): load the catalog without waiting for the event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider and model lists only synced once the SSE stream reported server.connected. That handshake is bounded by a 2s connectTimeout armed before roughly 2s of blocking startup work, so on a compiled build it almost always aborted spuriously: 7 of 8 cold launches burned the full timeout, then paid reconnect backoff before any catalog request went out. Measured time-to-model was 4.6-4.9s on a static transport and 5.4-12.4s on a managed one, against a server that had published catalog.updated at 450ms. Fetch the catalog on location set, since those reads are plain HTTP and never depended on the stream; server.connected still resyncs, and DataProvider already drops the cached completion whenever the stream is down, so a healthy start costs one fetch. Retry an opening handshake that never established against the endpoint just resolved instead of re-resolving the managed service first, which was the source of the multi-second variance. A stream that established and then dropped still re-resolves, since a restarted server may have moved. Distinguish an unfetched model list from an empty one so the dialog shows 'Loading models…' rather than 'No items available'. --- packages/tui/src/component/dialog-model.tsx | 9 +++ packages/tui/src/context/client.tsx | 9 +++ packages/tui/src/context/location.tsx | 8 ++- packages/tui/test/cli/tui/data.test.tsx | 62 ++++++++++++++++++++ packages/tui/test/cli/tui/use-event.test.tsx | 35 ++++++++++- 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 87168f6c29ae..d9855bb02953 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -7,14 +7,18 @@ import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" import { useData } from "../context/data" +import { useTheme } from "../context/theme" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const data = useData() const dialog = useDialog() + const theme = useTheme() const [query, setQuery] = createSignal("") const connected = useConnected() + // An unfetched list is `undefined`; rendering it as empty claims no models exist. + const loading = createMemo(() => data.location.model.list() === undefined) const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item]))) const models = createMemo(() => data.location.model.list() ?? []) @@ -130,6 +134,11 @@ export function DialogModel(props: { providerID?: string }) { return ( [number]["value"]> options={options()} + emptyView={ + + {loading() ? "Loading models…" : "No models available"} + + } actions={[ { command: "model.dialog.provider", diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index 0aa48240f3f8..257bc57a3a07 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -132,9 +132,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( stream = controller void (async () => { let attempt = 0 + let established = false while (!abort.signal.aborted && !controller.signal.aborted) { const result = await connect(controller.signal, attempt) if (abort.signal.aborted || controller.signal.aborted) return + if (result.connectedAt !== undefined) established = true if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0 attempt += 1 const message = errorMessage(result.error) @@ -144,6 +146,13 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( error: message, }) setConnection({ status: "reconnecting", attempt, error: message }) + // An opening handshake that never completed usually means this process lost + // the race against its own startup work, not that the server moved, so retry + // the endpoint already resolved. Re-resolving here instead costs a full + // service ensure before any data can load. A stream that was established and + // then dropped skips this and re-resolves below, since a restarted server may + // now be on a different port. + if (!established && attempt === 1) continue // Re-resolve the transport before retrying: the server may have // moved (service restarted on a new port) or need starting. Static // transports (--server, standalone) resolve to the same address. diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 141d4b89d945..a1ad5b577213 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -28,9 +28,15 @@ export function LocationProvider(props: ParentProps) { function set(location?: LocationRef) { setRef(location) - if (client.connection.status() === "connected") sync(location) + // Catalog reads are plain HTTP and do not depend on the event stream, so fetch + // immediately. Waiting for the handshake left the model and provider lists empty + // for as long as it took to connect, which reads as "no models exist". + sync(location) } + // Resync after a reconnect, which may have missed updates. DataProvider drops the + // cached completion whenever the stream is down, so this is a no-op when the fetch + // above already succeeded and nothing was missed. onCleanup(client.event.on("server.connected", () => sync(ref()))) return ( diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 46a6defbf7a0..f87378b4c180 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -788,6 +788,68 @@ test("reconnects the event stream and resyncs active data", async () => { } }) +test("loads the catalog before the event stream connects", async () => { + const events = createEventStream() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const calls = createFetch((url) => { + // Hold the handshake open so a catalog that waits on it cannot load. + if (url.pathname === "/api/event") return gate.then(() => events.v2()) + if (url.pathname !== "/api/model") return + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [ + { + id: "model-gated", + providerID: "provider", + name: "Gated", + api: { type: "native" }, + capabilities: { tools: false, input: [], output: [] }, + cost: [], + limit: { context: 1, output: 1 }, + request: { headers: {}, body: {} }, + status: "active", + time: { released: 0 }, + variants: [], + }, + ], + }) + }, events) + + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.location.model.list()?.[0]?.id === "model-gated") + expect(client.connection.status()).not.toBe("connected") + release() + await wait(() => client.connection.status() === "connected", 4000) + expect(data.location.model.list()?.[0]?.id).toBe("model-gated") + } finally { + app.renderer.destroy() + } +}) + test("completes exploration when a queued prompt is promoted", async () => { const events = createEventStream() const sessionID = "session-promotion" diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index c26d2c876b5a..60f9307bc87f 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -54,9 +54,10 @@ function update(version: string): OpenCodeEvent { async function mount( reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink, + override?: Parameters[0], ) { const events = createEventStream() - const calls = createFetch(undefined, events) + const calls = createFetch(override, events) const seen: OpenCodeEvent[] = [] const workspaces: Array = [] let client!: ReturnType @@ -216,6 +217,38 @@ describe("useEvent", () => { } }) + test("retries an unestablished handshake before re-resolving the server", async () => { + const attempts: number[] = [] + let opened = 0 + const { app, client } = await mount( + async () => { + attempts.push(attempts.length + 1) + throw new Error("no server") + }, + undefined, + (url, request) => { + if (url.pathname !== "/api/event") return + opened += 1 + // Leave the opening handshake unanswered so `connectTimeout` aborts it. + if (opened > 1) return + return new Promise((_, reject) => { + request.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + }) + }, + ) + + try { + await wait(() => client.connection.status() === "connected", 8000) + // The endpoint was resolved moments earlier, so a handshake that never + // established must retry it directly rather than pay for a service ensure + // before any data can load. + expect(attempts).toEqual([]) + expect(opened).toBe(2) + } finally { + app.renderer.destroy() + } + }) + test("keeps the current client when reconnection fails", async () => { let calls = 0 const { app, events, client, seen } = await mount(async () => {