Skip to content

V2 event streams can stall with Kaspersky without an SSE Accept header #48771

Open

Description

@DEAN-Cherry

Description

On Windows with Kaspersky 21.26 enabled, the V2 CLI can remain on Restarting service... for minutes even though /api/health responds normally. The generated Promise SSE client does not set Accept: text/event-stream, and the initial /api/event response can be delayed beyond the CLI's two-second connection deadline.

I compared the same authenticated requests against one running 2.0.3 backend, without restarting it or changing its configuration:

Kaspersky state Unspecified / wildcard Accept (6 requests) Explicit SSE Accept (3 requests)
Enabled 4 timed out at 2.5 s; 2 took 1.3-1.4 s 3-6 ms
Disabled for the comparison All succeeded in 3-40 ms 3-5 ms
Protection restored 3 timed out at 2.5 s; 3 took 1.1-1.4 s 3-4 ms

The request-header-dependent delay also reproduces with a separate Node HTTP server/client, without OpenCode plugins. This identifies a compatibility problem with the enabled security software, not a particular internal Kaspersky module.

The EventSource specification says user agents may set this header, not that it is mandatory. Setting it by default for Promise SSE requests would improve interoperability; explicit caller headers should remain respected. SvelteKit has a matching Kaspersky report.

OpenCode version

Observed in 2.0.2 and 2.0.3. The default is still missing on v2 at 7a31b5c0f76ce1c06befacc09b47b4fc3c71c408, in packages/httpapi-codegen/src/index.ts and packages/client/src/promise/generated/client.ts.

Steps to reproduce

  1. On an affected Windows/Kaspersky setup, start the V2 CLI and observe repeated connection timeouts while the backend remains healthy.
  2. Compare authenticated streaming GET requests to the same /api/event endpoint with the default Accept and with Accept: text/event-stream.
  3. The explicitly identified SSE request receives server.connected promptly; the default request can stall until later traffic arrives.

Operating System

Windows x64

Activity

  1. LeonMueller-OneAndOnly commented on Oct 14, 2026

    @LeonMueller-OneAndOnly
    Contributor

    Related browser-side cause with the same symptom: in Firefox, /api/event responses carry Cache-Control: no-cache, which still lets Firefox store the stream in its HTTP cache. A second concurrent /api/event request (e.g. a second tab) then waits for the never-ending cache write and is never sent. Recommend serving event streams with Cache-Control: no-store (and/or fetching them with cache: "no-store") alongside the SSE Accept header. Details: #55369

  2. Upitep commented on Oct 26, 2026

    @Upitep

    I can confirm that PR #48779 fixes this issue on Windows with Kaspersky Endpoint Security enabled.

    After upgrading from OpenCode 2.0.6 to 2.1.4, both CLI/TUI and Desktop became practically unusable due to repeated SSE disconnections.

    We rebuilt both CLI and Desktop 2.1.4 with the patch from #48779 and verified:

    • First SSE event: 33 ms, compared to the previous ~2001 ms timeout.
    • SSE connection remained stable for 65 seconds, including heartbeats.
    • 140 tests passed, along with type checks.
    • Three consecutive TUI launches completed without disconnections.
    • Desktop successfully loaded all 10 models, including our custom provider.
    • Both patched applications are now working with our restored user profile and credentials.

    No Kaspersky exclusions or security policy changes were required.

    Unfortunately, PR #48779 was closed by automated cleanup rather than technical review.

    Could a maintainer please reconsider reopening or merging the fix, or porting it into the current release?

    This is a reproducible issue with a tested, working solution. It would be unfortunate to lose the fix because of automated PR cleanup.

    Thanks to @DEAN-Cherry for the patch!

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed measurements. I reproduced this without Kaspersky. A small local proxy stood in for it: it holds back /api/event responses for 5 s unless the request carries Accept: text/event-stream, and it logs the Accept header of each request.

    With opencode serve behind that proxy and the TUI started with opencode --server <proxy>, on 2.0.3:

    • Every /api/event request from the CLI was sent with Accept: */*.
    • Each attempt was aborted at the 2 s deadline (Timed out connecting to server) and retried about every 3 s, while the TUI showed "Connection lost / Reconnecting".
    • /api/health and a curl request to /api/event with Accept: text/event-stream went through the same proxy and got server.connected straight away.

    The same thing happens on the latest dev (ecf1d36e7), run from source. No default SSE Accept header has been added to the Promise client or its generator since.

    The script below reproduces it (proxy included). There is no recording, because our TUI test harness can't be routed through a proxy; the results come from the TUI's own output. Related: #55741 (same timeout loop, traced to the same Kaspersky behaviour), #53498 (the 2 s connect deadline behind slow proxies), and the closed PR #48779.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #48771 without Kaspersky: a local proxy stands in for the security software and
    # holds back /api/event responses for 5 s unless the request has `Accept: text/event-stream`.
    # Usage: OC="opencode" ./repro.sh   (or OC="bun --conditions=browser --preload=... packages/cli/src/index.ts")
    set -u
    OC=${OC:-opencode}
    DIR=$(cd "$(dirname "$0")" && pwd)
    mkdir -p "$DIR/proj" && cd "$DIR/proj"
    cat > "$DIR/proxy.mjs" <<'JS'
    // Simulates the reported security-software behaviour: /api/event responses are held back
    // for DELAY ms unless the request carries `Accept: text/event-stream`. Logs each request's Accept.
    import http from "node:http"
    const [listen, target, delay] = [Number(process.argv[2]), Number(process.argv[3]), Number(process.argv[4] ?? 5000)]
    http.createServer((req, res) => {
      const sse = (req.headers.accept ?? "").includes("text/event-stream")
      if (req.url.startsWith("/api/event")) console.log(new Date().toISOString(), req.method, req.url, "accept=", JSON.stringify(req.headers.accept ?? null), sse ? "pass-through" : `held ${delay}ms`)
      const up = http.request({ host: "127.0.0.1", port: target, path: req.url, method: req.method, headers: req.headers }, (r) => {
        const go = () => { if (res.destroyed) return; res.writeHead(r.statusCode, r.headers); r.pipe(res) }
        if (req.url.startsWith("/api/event") && !sse) setTimeout(go, delay); else go()
      })
      up.on("error", () => res.destroy()); res.on("close", () => up.destroy())
      req.pipe(up)
    }).listen(listen, "127.0.0.1", () => console.log("proxy", listen, "->", target))
    JS
    export OPENCODE_SERVER_PASSWORD=repro-password
    $OC serve --hostname 127.0.0.1 --port 4601 > /tmp/serve.log 2>&1 &  SERVE=$!
    node "$DIR/proxy.mjs" 4602 4601 5000 > /tmp/proxy.log 2>&1 &        PROXY=$!
    sleep 5
    echo "health via proxy: $(curl -s -m 3 -u opencode:$OPENCODE_SERVER_PASSWORD http://127.0.0.1:4602/api/health)"
    echo "/api/event, SSE Accept -> first bytes:"
    curl -s -N -m 2 -u opencode:$OPENCODE_SERVER_PASSWORD -H 'Accept: text/event-stream' http://127.0.0.1:4602/api/event | head -c 200; echo
    # Run the TUI against the proxy for 20 s in a pseudo-terminal.
    timeout 20 script -qfc "$OC --server http://127.0.0.1:4602 --log-level debug --print-logs" /tmp/tui.txt >/dev/null 2>&1
    echo "--- proxy log (Accept header the CLI sent on /api/event) ---"; cat /tmp/proxy.log
    echo "--- TUI/log messages ---"
    sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/tui.txt | grep -a -o -i "timed out connecting to server\|restarting service\|connection lost\|reconnecting" | sort | uniq -c
    kill $SERVE $PROXY 2>/dev/null

    Run it: bash repro.sh

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