Skip to content

chunkTimeout resets on SSE comment heartbeats — keepalive-warm stalled streams never time out #43519

Description

@bpross

chunkTimeout resets on SSE comment heartbeats — keepalive-warm stalled streams never time out

Version: 1.18.18 (behavior also observed on 1.17.5 before the option existed)

Summary

provider.<id>.options.chunkTimeout arms a timer per SSE reader.read() and
aborts with the typed, retryable APIError: SSE read timed out
(ProviderResponseStreamError). That works for a fully silent stream — but the
timer resets on any chunk, including SSE comment lines (: keepalive) that
carry no data: event. SSE comments are explicitly allowed by the SSE spec for
connection keep-alive, so a provider or load balancer that holds a stalled
generation open with comment heartbeats keeps the connection "warm" forever:
the chunk timer never fires, and opencode run hangs indefinitely with no
stdout events, no log lines, and no error.

Impact

In long agentic sessions we see provider streams stall mid-generation (e.g. a
tool-call part is created, zero argument bytes ever arrive, the stream then
sits open). With a comment heartbeat interval shorter than chunkTimeout, the
session hangs until an external watchdog kills the process. The stall is
invisible to every in-process signal: no stdout JSON event, no native log
line, no session-DB write, no error.

Deterministic repro (no provider, no network)

Save as stall_server.py and run with STALL_MODE=keepalive KEEPALIVE_SECS=10:

#!/usr/bin/env python3
"""OpenAI-compatible SSE stall server. Modes:
  silent    - role delta + partial tool-call args, then hold the body open
              with zero further bytes.
  keepalive - same prefix, then an SSE comment (": keepalive") every
              KEEPALIVE_SECS forever. Chunks keep arriving but no data event
              ever completes.
"""
import http.server, json, os, sys, time

MODE = os.environ.get("STALL_MODE", "silent")
KEEPALIVE_SECS = float(os.environ.get("KEEPALIVE_SECS", "20"))
PORT = int(os.environ.get("PORT", "8123"))

def sse(obj):
    return ("data: " + json.dumps(obj) + "\n\n").encode()

class Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def log_message(self, fmt, *args):
        sys.stderr.write("[stall-server] %s\n" % (fmt % args)); sys.stderr.flush()
    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.end_headers()
        base = {"id": "chatcmpl-stall", "object": "chat.completion.chunk",
                "created": int(time.time()), "model": "stall"}
        self.wfile.write(sse({**base, "choices": [{"index": 0,
            "delta": {"role": "assistant"}, "finish_reason": None}]}))
        self.wfile.flush()
        self.wfile.write(sse({**base, "choices": [{"index": 0, "delta":
            {"tool_calls": [{"index": 0, "id": "chatcmpl-tool-stall",
            "type": "function", "function": {"name": "edit",
            "arguments": "{\"filePa"}}]}, "finish_reason": None}]}))
        self.wfile.flush()
        try:
            if MODE == "keepalive":
                while True:
                    time.sleep(KEEPALIVE_SECS)
                    self.wfile.write(b": keepalive\n\n")
                    self.wfile.flush()
                    self.log_message("keepalive sent")
            else:
                while True:
                    time.sleep(3600)
        except (BrokenPipeError, ConnectionResetError):
            self.log_message("client closed connection")
    do_GET = do_POST

if __name__ == "__main__":
    http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

Config (test-config.json) pointing any OpenAI-compatible provider at it, with
a 15s chunk timeout:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "fireworks-ai": {
      "options": {
        "chunkTimeout": 15000,
        "baseURL": "http://127.0.0.1:8123/v1"
      }
    }
  }
}

Run:

python3 stall_server.py &  # STALL_MODE=keepalive KEEPALIVE_SECS=10
OPENCODE_CONFIG=test-config.json timeout 300 opencode run \
  --model "fireworks-ai/<any-model-id>" --format json \
  --dangerously-skip-permissions "say hi"

Observed

  • STALL_MODE=silent: chunkTimeout fires — retries, then terminal typed
    APIError: SSE read timed out (isRetryable: true). Correct.
  • STALL_MODE=keepalive with KEEPALIVE_SECS=10 (heartbeats inside the 15s
    chunk window): one step_start event, then permanent silence. No timeout
    after 10x the chunk window; the external 300s bound is the only thing that
    ends the process.
  • Control with KEEPALIVE_SECS=20 (gap longer than the 15s chunk window):
    the timeout fires on every gap — opencode retries with exponential backoff
    and exits 1 with the typed error at ~5 minutes. So the timer itself works;
    it is specifically reset by non-data chunks.

Expected

chunkTimeout should bound event progress, not transport bytes: ignore
non-data: chunks (comments/heartbeats) for the timer, or add a separate
event-level timeout (no complete SSE data: event for N seconds) so a
heartbeat-warm but generation-dead stream still fails with the typed,
retryable error.

Possibly related to #28729 (silent mid-session stream drops); this issue is
the complementary case where the connection is kept alive by non-data bytes.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions