Skip to content

Missing Content-Type bypasses model response body timeout and leaves sessions busy indefinitely #47605

Closed as not planned

Description

@totalolage

Missing Content-Type bypasses model response body timeout and leaves sessions busy indefinitely

Summary

OpenCode 1.18.28 on Linux x86_64 can wait indefinitely for a model response body when an HTTP 200 response omits or mislabels Content-Type. This is a confirmed client timeout defect that reproduces deterministically with synthetic responses.

Cause

In wrapSSE, OpenCode returns the response unchanged unless its Content-Type contains text/event-stream (source). In resolveSDK, the header timer defaults to 300000 ms and is cleared as soon as fetch returns headers. Bun's native fetch timeout is set to false, and OpenCode creates a total AbortSignal.timeout only when the provider timeout option is explicitly set (source).

Therefore, a response that supplies headers but lacks or mislabels Content-Type bypasses the body-read watchdog. With the total timeout unset, the body has no deadline. SessionProcessor awaits Stream.runDrain, so the session remains busy until the stream finishes, fails, or is cancelled (source). The same guard is present in v1.18.29.

The OpenCode 1.18.28 binary reproduces this behavior against a synthetic localhost Responses server using @ai-sdk/openai. No real model calls are needed.

Reproduction

The following standalone fixture was verified against OpenCode 1.18.28 on Linux x86_64. Verification substituted an available ephemeral localhost port for 8765 to avoid conflicts. All assertions passed with no fixture errors and no real model calls. It follows the Responses event shapes in OpenCode's test fixture (llm-server.ts). Save it as stall_server.py and run it in the first terminal.

import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from itertools import count

SEND_CONTENT_TYPE = False  # Set True for the positive control.
requests = count(1)

def sse(value):
    data = b"data: " + json.dumps(value, separators=(",", ":")).encode() + b"\n\n"
    return data

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *_args):
        pass

    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        self.rfile.read(length)
        print('request', next(requests), flush=True)
        self.send_response(200)
        if SEND_CONTENT_TYPE:
            self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Transfer-Encoding", "chunked")
        self.send_header("Connection", "keep-alive")
        self.end_headers()
        self.wfile.flush()
        events = [
            {"type": "response.created", "sequence_number": 1,
             "response": {"id": "resp_test", "created_at": 1,
                           "model": "probe", "service_tier": None}},
            {"type": "response.output_item.added", "sequence_number": 2,
             "output_index": 0,
             "item": {"type": "reasoning", "id": "rs_test",
                       "encrypted_content": None}},
            {"type": "response.reasoning_summary_part.added",
             "sequence_number": 3, "item_id": "rs_test", "summary_index": 0},
        ]
        for value in events:
            data = sse(value)
            self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
            self.wfile.flush()
        threading.Event().wait()  # Stall until the server process is interrupted.

class Server(ThreadingHTTPServer):
    daemon_threads = True

try:
    Server(("127.0.0.1", 8765), Handler).serve_forever()
except KeyboardInterrupt:
    pass

In a second terminal, run the client with temporary directories and an inline config. OPENCODE_DISABLE_PROJECT_CONFIG=true is required to prevent ancestor project config discovery.

(
tmp=$(mktemp -d)
mkdir -p "$tmp"/{home,config,data,cache,state,tmp,cwd,managed}
cd "$tmp/cwd"
env -i \
  PATH="$PATH" \
  HOME="$tmp/home" \
  XDG_CONFIG_HOME="$tmp/config" \
  XDG_DATA_HOME="$tmp/data" \
  XDG_CACHE_HOME="$tmp/cache" \
  XDG_STATE_HOME="$tmp/state" \
  TMPDIR="$tmp/tmp" \
  OPENCODE_TEST_MANAGED_CONFIG_DIR="$tmp/managed" \
  OPENCODE_DISABLE_PROJECT_CONFIG=true \
  OPENCODE_DISABLE_MODELS_FETCH=true \
  OPENCODE_DISABLE_EXTERNAL_SKILLS=true \
  OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=true \
  OPENCODE_PURE=1 \
  NO_PROXY=localhost,127.0.0.1 \
  no_proxy=localhost,127.0.0.1 \
  OPENCODE_CONFIG_CONTENT='{"provider":{"stallprobe":{"npm":"@ai-sdk/openai","name":"Stall Probe","options":{"apiKey":"synthetic-not-a-secret","baseURL":"http://127.0.0.1:8765/v1","headerTimeout":1000,"chunkTimeout":1000,"timeout":false},"models":{"probe":{"name":"probe","limit":{"context":32000,"output":1000},"tool_call":false}}}},"model":"stallprobe/probe","small_model":"stallprobe/probe","enabled_providers":["stallprobe"],"permission":"deny","snapshot":false,"agent":{"build":{"mode":"primary","prompt":"Reply briefly only"}}}' \
  timeout 20s opencode run --agent build --format json --model stallprobe/probe 'Reply briefly.'
)

The env -i launch drops inherited environment variables and uses the empty managed directory, so managed or inherited configuration cannot override this test. Only the host PATH is carried into the client. The outer timeout 20s is a test-harness limit, not an OpenCode product timeout. The subshell keeps the temporary HOME, XDG variables, and working directory out of the caller's terminal after the command exits. Set SEND_CONTENT_TYPE = True, restart the server, and rerun for the valid-SSE control. To test an explicit total deadline, change only the provider option "timeout": false to "timeout": 2000 and rerun with the missing header.

For the no-first-event variant, replace the events list with []. Before threading.Event().wait(), send exactly one HTTP chunk:

data = b": ready\n\n"
self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
self.wfile.flush()

The emitted fixture above remains the after-reasoning variant.

The server prints only a request number. The baseline is usually two requests, one for auto-title and one for the main run; repeated request numbers expose retries. This logging does not include payloads or headers and does not change response bytes. It also lets the no-first-event variant be observed when JSON output has no step_start event.

Observed results

Case Result with the actual binary
SEND_CONTENT_TYPE=True Valid SSE shows repeated step_start events for the same assistant and retries after the stalled body. Observed: exit 124, 20.09s, requests=4, step_start=3, error_events=0.
SEND_CONTENT_TYPE=False A missing header stalls before the first event or, in the emitted fixture, after the reasoning events. The after-reasoning case shows one initial step_start and no retry within 20 seconds. Observed: exit 124, 20.09s, requests=2, step_start=1, error_events=0.
Missing header plus timeout=2000 The explicit total deadline produces the expected product timeout failure instead of waiting for the outer limit. Observed: exit 1, 4.02s, requests=2, step_start=1, error_events=1.

A complete Responses control succeeded. Both stall cases may reach the outer limit and return status 124, so status 124 alone is not a diagnostic criterion. A visible timeout message is not required for the missing-header case. CLI auto-title can add a request, so request counts are not root-call counts; counts above the usual two requests indicate retries.

Expected behavior

Enforce a model-response body-read deadline independently of an absent or incorrect Content-Type. In these tests, timeout=false disables only the optional total deadline; it does not disable chunkTimeout. Treat chunkTimeout=false as the explicit body-watchdog opt-out. Preserve these semantics and existing cancellation behavior. Add regression coverage for absent and wrong headers, stalls before the first event and during reasoning, proper SSE with timeout=false, and the explicit chunkTimeout=false opt-out.

No source fix has been made yet. The 2000 ms total timeout is a synthetic control, not a production recommendation. Any default must allow legitimate long-running requests.

Activity

  1. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or closely related to existing issues. Please check:

    If your issue is distinct (specifically the Content-Type-guard bypass path), please confirm that after reviewing #37580.

  2. dcadenas commented on Sep 8, 2026

    @dcadenas

    Confirmed on live traffic with 1.18.29, where the ?? 300_000 default is present and does not fire.

    Provider openai, model gpt-6-astra, streamed subagent request.

    Timeline of one stall:

    • Request sent. Response headers received about 3 seconds later.
    • No further bytes on that connection for 39 minutes.
    • The session's last log line is the stream start. No later step, no error, no retry.
    • The parent session stayed blocked behind the subagent for the whole period. The process was otherwise alive and still logging unrelated background work.

    Socket state during the stall, the only established connection for that process:

    ESTAB  ...:443   lastsnd:2355046  lastrcv:2352202
    

    lastrcv is about 2.8 seconds more recent than lastsnd, so the headers arrived and the body then went silent.
    No chunkTimeout was configured, so the request used the 300000 ms default.
    It never fired, which matches the content-type condition described in this issue.
    Recovery required killing the session by hand.

    There is a second copy of the same guard

    packages/core/src/aisdk.ts has its own wrapSSE with the same content-type check, and it is also missing the default:

    const chunkTimeout = options.chunkTimeout
    ...
    if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res

    On that path nothing arms at all without explicit config, before the content-type check is even reached.
    It special-cases @ai-sdk/openai, so openai traffic can reach it.
    #47621 changes only packages/opencode/src/provider/provider.ts, so this copy keeps the gate.
    That overlaps #46692.

    I could not determine from the logs which of the two paths served the stalled request.
    Both are consistent with what I observed.

    What I have tested

    Locally I removed the content-type check in both copies and gave the aisdk.ts path the same 300000 ms default.
    bun test test/provider/header-timeout.test.ts test/provider/provider.test.ts passes, 111 tests.

    I have not yet observed a real stall under the patched build, so I cannot report that it catches this case in practice.

  3. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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

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