Skip to content

TUI: provider/model list blocked behind the SSE handshake (5-12s to first model) #351

Description

@shuv1337

Summary

The TUI's provider and model lists only synced after the SSE event stream reported server.connected. That handshake is bounded by a 2s connectTimeout that is armed before roughly 2s of blocking startup work on the same JS thread, so on a compiled build it almost always aborts a connection that was never unhealthy. Until it recovers, the model switcher renders No items available and the footer shows No provider selected.

Measured 4.6–4.9s to first model on a static transport and 5.4–12.4s on a managed one, against a server that had already published catalog.updated at 450ms.

Fixed in 1cd30412. Filing to record the diagnosis, the measurements, and the parts that are still open.

Evidence: the server is not the bottleneck

Measured against a live, already-running server:

Endpoint Warm Cold location
/api/provider (6 providers) 2–3 ms 15 ms
/api/model (95 models, 70 KB) 9–11 ms
/api/integration (182 entries) 3–7 ms
/api/model/default (has the plugins.flush gate) 2–3 ms

On a full cold-location TUI launch the server logged location services booted durationMs=23 and published catalog.updated 450ms after the CLI process started, while the TUI showed no model until 9798ms. The per-request SELECT * FROM credential that Catalog.provider.available() performs is real but costs single-digit milliseconds.

/api/event TTFB is 1–40 ms and the endpoint is not location-scoped, so it never stalls behind a location build.

Root cause

packages/tui/src/context/client.tsx:26 sets connectTimeout = 2_000. The timer is armed in connect() before api.event.subscribe(), and the TUI then performs ~2s of blocking startup work (renderer creation, waitForThemeMode(1000), plugin load, first render). The handshake reply cannot be processed in time and the timer aborts the request.

Every captured launch shows the same signature:

event stream connecting    attempt=0
   ...exactly 2002 ms later...
event stream disconnected  attempt=1  error=Transport
   ...backoff...
event stream connected                 <- 128 ms on the retry

The retry connects in 128ms. The first attempt "takes" exactly 2002ms because it never completed.

Everything catalog-related was gated behind that handshake:

  • packages/tui/src/context/location.tsx:30set() synced only if (client.connection.status() === "connected")
  • packages/tui/src/context/location.tsx:34 — otherwise it waited for the server.connected event
  • packages/tui/src/context/data.tsx:1355-1358 — while not connected, every sync key is invalidated

On failure, client.tsx:150 re-resolved the transport through props.service.reconnect() → a full ServiceLifecycle.ensure(). That only exists on the implicit managed connection, which is why the normal shuvcode invocation is the erratic one.

Measurements

8 cold launches of the installed binary, fresh git repo per launch:

Mode Time to model resolved First-handshake failures
implicit (normal shuvcode) 5435 / 8827 / 7327 / 12375 ms 1, 1, 1, 2
--server (static) 4928 / 4590 / 4809 / 4600 ms 1, 1, 1, 0

7 of 8 launches burned the full timeout. The --server cluster is tight because the retry path only waits 1s; the implicit spread comes from ServiceLifecycle.ensure(). One run where the first handshake landed finished in 1697ms, which is the achievable number.

Fix (1cd30412)

  1. Decouple the catalog from the stream. Fetch on location set — 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 exactly one fetch.
  2. Retry an unestablished handshake against the endpoint already resolved rather than re-resolving the managed service first. A stream that established and then dropped still re-resolves, since a restarted server may have moved to a new port — that distinction matters and is covered by the existing reconnect tests.
  3. Distinguish an unfetched model list from an empty one, so the dialog shows Loading models… instead of No items available.

600 tests pass, typecheck clean. Two regression tests were added that fail on pristine source and pass with the fix:

  • loads the catalog before the event stream connects (packages/tui/test/cli/tui/data.test.tsx)
  • retries an unestablished handshake before re-resolving the server (packages/tui/test/cli/tui/use-event.test.tsx)

Still open

  • No end-to-end wall-clock validation on a compiled binary. The dev build does not reproduce the bug at all (0 handshake failures, ~3.1s both patched and pristine) because its startup blocks differently. The fix is proven by regression tests, not by a timing measurement on a real build. This should be confirmed with a build + relaunch.
  • The 2s connectTimeout itself is untouched. It still measures wall-clock across a potentially blocked event loop, so a slow start can still spuriously abort the first handshake — it just no longer delays the catalog. Arming it after dispatch, or making the budget reflect server responsiveness rather than local scheduling, is the real repair.
  • Upstream anomalyco/opencode still carries all three defects verbatim on v2 (connectTimeout = 2_000, the props.service.reconnect() on first disconnect, and the connection.status() === "connected" gate). Expect a conflict at the next sync.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions