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:30 — set() 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)
- 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.
- 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.
- 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.
Summary
The TUI's provider and model lists only synced after the SSE event stream reported
server.connected. That handshake is bounded by a 2sconnectTimeoutthat 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 rendersNo items availableand the footer showsNo 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.updatedat 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:
/api/provider(6 providers)/api/model(95 models, 70 KB)/api/integration(182 entries)/api/model/default(has theplugins.flushgate)On a full cold-location TUI launch the server logged
location services booted durationMs=23and publishedcatalog.updated450ms after the CLI process started, while the TUI showed no model until 9798ms. The per-requestSELECT * FROM credentialthatCatalog.provider.available()performs is real but costs single-digit milliseconds./api/eventTTFB 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:26setsconnectTimeout = 2_000. The timer is armed inconnect()beforeapi.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:
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:30—set()synced onlyif (client.connection.status() === "connected")packages/tui/src/context/location.tsx:34— otherwise it waited for theserver.connectedeventpackages/tui/src/context/data.tsx:1355-1358— while not connected, every sync key is invalidatedOn failure,
client.tsx:150re-resolved the transport throughprops.service.reconnect()→ a fullServiceLifecycle.ensure(). That only exists on the implicit managed connection, which is why the normalshuvcodeinvocation is the erratic one.Measurements
8 cold launches of the installed binary, fresh git repo per launch:
shuvcode)--server(static)7 of 8 launches burned the full timeout. The
--servercluster is tight because the retry path only waits 1s; the implicit spread comes fromServiceLifecycle.ensure(). One run where the first handshake landed finished in 1697ms, which is the achievable number.Fix (1cd30412)
server.connectedstill resyncs, andDataProvideralready drops the cached completion whenever the stream is down, so a healthy start costs exactly one fetch.Loading models…instead ofNo 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
connectTimeoutitself 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.anomalyco/opencodestill carries all three defects verbatim onv2(connectTimeout = 2_000, theprops.service.reconnect()on first disconnect, and theconnection.status() === "connected"gate). Expect a conflict at the next sync.