Skip to content

Web client: 2s event-stream connect timeout with fixed 1s retry causes minutes-long loading behind slow/buffering proxies #53498

Open

Description

@LeonMueller-OneAndOnly

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 1s

The 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.connected immediately. We verified this with curl, and the response headers are content-type: text/event-stream, cache-control: no-cache, no-transform and x-accel-buffering: no. A : heartbeat comment follows every 15s.
  • For one user on a slow, buffering network path, the server log showed a /api/event request 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

  1. Make the connect deadline more tolerant (e.g. 10–15s) and/or configurable, or count any received bytes (including SSE comments, via the existing onActivity hook) as progress instead of requiring the parsed server.connected event within 2s.
  2. Use exponential backoff with jitter for reconnects (e.g. 1s → 2s → 4s …, capped at ~30s), and reset it after a successful connection.
  3. After N failed attempts, show a visible "cannot establish live connection" state instead of loading indefinitely.
  4. Optional, server side: send an initial padding comment so that proxies which buffer by response size flush the first event.

Related

Plugins

none

OpenCode version

2.0.23 (code unchanged in 2.0.24 and on v2)

Steps to reproduce

  1. Run opencode serve and 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 of text/event-stream responses for 3s).
  2. The UI stays on loading. The server log shows /api/event requests every ~3s, each aborted after ~2.0s.

Operating System

Server: Linux. Client: browser behind a corporate network.

Terminal

N/A (web UI)

Activity

  1. github-actions commented on Oct 6, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #48771: V2 event streams hit the same 2-second initial connection deadline when security software delays the response.
    • #52271: Desktop event-stream retries every few seconds and leaves the UI unusable for several minutes.
  2. LeonMueller-OneAndOnly commented on Oct 14, 2026

    @LeonMueller-OneAndOnly
    ContributorAuthor

    One more cause of the 2 s timeout / retry loop: in Firefox, /api/event is answered with Cache-Control: no-cache, so the stream lands in the HTTP cache and any concurrent /api/event request (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. Recommend Cache-Control: no-store on event streams and/or cache: "no-store" on the client fetch. Details: #55369

  3. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    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/event responses for 3s and then forwards the stream unchanged. A plain fetch through the proxy receives server.connected after about 3s. The same event-stream client the web UI uses (createClientConnection, with no reconnect resolver) never connects through it:

    • every attempt is aborted after about 2.0s with Timed out connecting to server
    • a new attempt starts 1s later, with no backoff
    • after 20s it had made 7 attempts and still hadn't connected

    That 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 latest dev (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 Accept header), #52271 (desktop, a similar ~3s reconnect loop) and #53458 (raises the timeout as part of a broader PR).

    Reproduction script (repro.ts)
    // Reproduction for anomalyco/opencode#53498.
    // 1. Start `opencode serve --port 4096 --hostname 127.0.0.1` and set OPENCODE_PASSWORD to the printed password.
    // 2. Run from the opencode repo root (so solid-js resolves):
    //      OPENCODE_PASSWORD=... bun --conditions=browser /workspace/repro/repro.ts
    // It starts a proxy on :4097 that holds back the first bytes of /api/event responses for DELAY_MS
    // (simulating a buffering corporate proxy / TLS-inspecting AV), then runs the same
    // createClientConnection the web UI uses (no `reconnect` resolver) through it for RUN_MS.
    import { createRoot } from "solid-js"
    import { OpenCode } from "/workspace/repo/packages/client/src/promise/index.ts"
    import { createClientConnection } from "/workspace/repo/packages/client/src/solid/connection.ts"
    
    const UPSTREAM = process.env.UPSTREAM ?? "http://127.0.0.1:4096"
    const DELAY_MS = Number(process.env.DELAY_MS ?? 3000)
    const RUN_MS = Number(process.env.RUN_MS ?? 20000)
    const password = process.env.OPENCODE_PASSWORD ?? ""
    const t0 = Date.now()
    const ts = () => `+${((Date.now() - t0) / 1000).toFixed(2)}s`
    
    let proxied = 0
    Bun.serve({
      port: 4097,
      idleTimeout: 0,
      async fetch(req) {
        const url = new URL(req.url)
        const upstream = await fetch(UPSTREAM + url.pathname + url.search, { headers: req.headers, method: req.method })
        if (!url.pathname.startsWith("/api/event")) return upstream
        const n = ++proxied
        const started = Date.now()
        console.log(`${ts()} proxy: /api/event request #${n} (holding first bytes ${DELAY_MS}ms)`)
        const reader = upstream.body!.getReader()
        const body = new ReadableStream({
          async start(c) {
            await Bun.sleep(DELAY_MS)
            c.enqueue(new TextEncoder().encode(": padding\n\n"))
          },
          async pull(c) {
            const r = await reader.read()
            if (r.done) return c.close()
            c.enqueue(r.value)
          },
          cancel() {
            console.log(`${ts()} proxy: /api/event request #${n} aborted by client after ${Date.now() - started}ms`)
            reader.cancel()
          },
        })
        return new Response(body, { status: upstream.status, headers: upstream.headers })
      },
    })
    
    // Sanity check: the delayed stream itself works and delivers server.connected after ~DELAY_MS.
    {
      const s = Date.now()
      const res = await fetch("http://127.0.0.1:4097/api/event", {
        headers: { authorization: "Basic " + btoa("opencode:" + password), accept: "text/event-stream" },
      })
      const reader = res.body!.getReader()
      let text = ""
      while (!text.includes("server.connected")) text += new TextDecoder().decode((await reader.read()).value)
      console.log(`${ts()} sanity: plain fetch got server.connected through proxy after ${Date.now() - s}ms`)
      reader.cancel()
    }
    
    const api = OpenCode.make({
      baseUrl: "http://127.0.0.1:4097",
      headers: { authorization: "Basic " + btoa("opencode:" + password) },
    })
    let connected = false
    createRoot(() => {
      const connection = createClientConnection(api, {
        onEvent(e) {
          if (e.type === "server.connected") connected = true
        },
        log: { info: (m, d) => console.log(`${ts()} client: ${m}`, JSON.stringify(d ?? {})) },
      })
      connection.start?.()
    })
    
    await Bun.sleep(RUN_MS)
    console.log(`\nRESULT after ${RUN_MS / 1000}s: connected=${connected}, /api/event attempts=${proxied - 1}`)
    process.exit(0)

    Run it: bun repro.ts

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions