Skip to content

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

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

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