Repository navigation
Description
Description
When the web UI is served by opencode serve, its event stream client gives each /api/event attempt a hard 2s deadline to receive and parse the first server.connected event. After a failure it retries on a fixed 1s delay with no backoff and no attempt limit. Behind a network path that delays the start of streamed responses (likely a corporate proxy or TLS-inspecting AV), the connection works but is slow to start, and the UI stays in its loading state for minutes: the session list shows "loading" and no agents can be selected.
Source: packages/client/src/solid/connection.ts (same code in v2.0.23, v2.0.24 and the current v2 head):
const connectTimeout = 2_000
const reconnectDelay = 1_000
// connect(): the deadline is cleared only after the first parsed event
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
const first = await iterator.next()
if (first.value.type !== "server.connected") return { error: new Error("Event stream did not start with server.connected"), ... }
clearTimeout(timeout)
// runStream(): after every failure
await wait(reconnectDelay, controller.signal) // fixed 1sThe web client passes no reconnect resolver, so this fixed loop is the only recovery path. The onActivity/touch() hook already sees incoming bytes, including keepalive comments, but it only feeds the idle watchdog after the connection is established. It does not count toward the connect deadline.
Observed
- The server sits behind a reverse proxy and sends
server.connectedimmediately. We verified this with curl, and the response headers arecontent-type: text/event-stream,cache-control: no-cache, no-transformandx-accel-buffering: no. A: heartbeatcomment follows every 15s. - For one user on a slow, buffering network path, the server log showed a
/api/eventrequest every ~3s, each lasting almost exactly 2.0s before being interrupted (InterruptError, status 200). There were about 140 such attempts over about 7 minutes before one succeeded and the UI loaded. - Another user on the same host produced more than 3000 of these 2s aborts over several days.
The 2s deadline turns a slow but working network into minutes of loading, and the fixed 1s retry keeps hitting the server the whole time.
Proposed fix
- Make the connect deadline more tolerant (e.g. 10–15s) and/or configurable, or count any received bytes (including SSE comments, via the existing
onActivityhook) as progress instead of requiring the parsedserver.connectedevent within 2s. - Use exponential backoff with jitter for reconnects (e.g. 1s → 2s → 4s …, capped at ~30s), and reset it after a successful connection.
- After N failed attempts, show a visible "cannot establish live connection" state instead of loading indefinitely.
- Optional, server side: send an initial padding comment so that proxies which buffer by response size flush the first event.
Related
- V2 event streams can stall with Kaspersky without an SSE Accept header #48771 / fix(client): default SSE Accept headers #48779: the same 2s deadline is hit with Kaspersky. fix(client): default SSE Accept headers #48779 fixes it with the
Accept: text/event-streamheader. That may help for some AV setups, but the deadline and retry behaviour stay the same for other slow paths. - Desktop: ~5 min startup delay retrying background service (probed: false, event stream disconnects every ~3s) #52271: desktop, similar pattern (event stream disconnects every ~3s for ~5 min).
- Global event stream reconnects on a fixed 250ms timer with no backoff #48014 / fix(app): back off event stream reconnects after repeated failures #48015 / fix(app): recover stale event streams — stall watchdog, foreground resync, reconnect backoff #51871: reconnect backoff in
packages/app/src/context/server-sdk.tsx, not increateClientConnection. - fix(app): eliminate submission starvation and timeouts #53458 raises
connectTimeoutto 10s as part of an unrelated, broader PR.
Plugins
none
OpenCode version
2.0.23 (code unchanged in 2.0.24 and on v2)
Steps to reproduce
- Run
opencode serveand open the web UI through a network path that holds back the start of streamed responses for more than 2s (e.g. a TLS-inspecting proxy/AV, or simulate with a proxy that holds the first bytes oftext/event-streamresponses for 3s). - The UI stays on loading. The server log shows
/api/eventrequests every ~3s, each aborted after ~2.0s.
Operating System
Server: Linux. Client: browser behind a corporate network.
Terminal
N/A (web UI)
Activity
github-actions commentedon Oct 6, 2026
This issue might be a duplicate of existing issues. Please check:
LeonMueller-OneAndOnly commentedon Oct 14, 2026
One more cause of the 2 s timeout / retry loop: in Firefox,
/api/eventis answered withCache-Control: no-cache, so the stream lands in the HTTP cache and any concurrent/api/eventrequest (e.g. from a second tab) queues behind the never-finishing cache write - it is never sent, times out after 2 s, and retries forever. RecommendCache-Control: no-storeon event streams and/orcache: "no-store"on the client fetch. Details: #55369opencodejr commentedon Oct 27, 2026
Thanks for the detailed report, I was able to reproduce this.
I ran
opencode serve(2.0.23) behind a small proxy that holds back the first bytes of/api/eventresponses for 3s and then forwards the stream unchanged. A plain fetch through the proxy receivesserver.connectedafter about 3s. The same event-stream client the web UI uses (createClientConnection, with noreconnectresolver) never connects through it:Timed out connecting to serverThat matches the ~3s request cadence and ~2.0s aborts you saw in your server log. The client code (
packages/client/src/solid/connection.ts) is unchanged on the latestdev(f7d0ce1), so this still happens there. I couldn't drive the web UI itself here, so this covers the client module it uses, not the UI.The script below reproduces it. Related: #48771 (the same 2s deadline under Kaspersky, caused by a missing
Acceptheader), #52271 (desktop, a similar ~3s reconnect loop) and #53458 (raises the timeout as part of a broader PR).Reproduction script (repro.ts)
Run it:
bun repro.ts