Skip to content

Cursor/Grok 4.6 via OpenCodex can terminate with upstream_stall_timeout after a silent stream #2210

Closed

Description

@diegocantarero

Summary

A long-running Codex App turn routed through OpenCodex to Cursor cursor/grok-4.6 can end with:

stream disconnected before completion:
Incomplete response returned, reason:
upstream_stall_timeout

This is a sanitized reproduction; no credentials, cookies, tokens, local auth files, or private paths are included.

Environment and route

  • Client: Codex App
  • Gateway: OpenCodex local loopback proxy
  • Upstream: Cursor
  • Model: cursor/grok-4.6
  • Failure class: silent/idle upstream stream followed by an incomplete terminal response

Expected behavior

  • Distinguish first-event timeout from an established-stream idle timeout.
  • Emit a deterministic incomplete terminal event with a resumable state.
  • Never replay a request after accepted streamed output or a tool call.
  • Preserve the existing provider/model identity in the failure record.

Reproduction shape

  1. Select the Cursor-backed Grok 4.6 model in a new Codex thread.
  2. Run a long, multi-step turn.
  3. Allow the upstream stream to remain silent long enough to trigger the watchdog.
  4. Observe the incomplete response and upstream_stall_timeout.

The exact elapsed time is intentionally omitted because it varied across runs; the error string and route are stable.

Investigation requested

Please check:

  • Cursor runTurn HTTP/2 keepalive/heartbeat behavior.
  • Separate first-event and established-stream stall timers.
  • Whether the adapter receives a terminal event before the watchdog fires.
  • Mid-stream retry policy: retry only before headers/first event; do not replay accepted output or tool calls.
  • Regression fixtures for a silent stream, a heartbeat-only stream, a tool-call stream, and a clean terminal event.

Related work

No claim is made here that Cursor's upstream model is unavailable; this report is specifically about the OpenCodex adapter/watchdog boundary.

Activity

  1. github-actions commented on Aug 20, 2026

    @github-actions
    Contributor

    Maintainer decision respected

    A maintainer has reopened this issue. The automated closure has been deactivated.

  2. added
    bugSomething isn't working
    needs-infoWaiting on reporter for a concrete spec or reproduction
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    streamingSSE, WebSocket, terminal stream frames
    on Aug 20, 2026
  3. Ingwannu commented on Aug 20, 2026

    @Ingwannu
    Owner

    Thanks — this report is detailed enough to triage even though the template bot did not recognize its headings, so I am reopening it as needs-info rather than discarding it.

    The reported terminal reason is real, but it is not by itself proof of an OpenCodex defect. Current dev already has two separate Cursor boundaries:

    • a Cursor transport first-frame timeout (CURSOR_FIRST_FRAME_TIMEOUT_MS, 30 seconds), which fails before any response frame;
    • the established Responses bridge inactivity watchdog (stallTimeoutSec, 300 seconds by default), which emits response.incomplete with upstream_stall_timeout only after no adapter event resets the watchdog.

    Cursor progress frames that intentionally produce no visible output are mapped to liveness heartbeats, interaction queries are answered by the headless bridge, and combo failover does not replay after the first meaningful text/tool event. Therefore a genuinely silent upstream for the configured stall window is expected to end this way. The remaining plausible bug is narrower: Cursor may be sending a currently-unrecognized progress frame that does not become an adapter heartbeat, or the transport may stop receiving frames while its HTTP/2 stream remains open.

    Please attach one sanitized reproduction with provider debug enabled:

    ocx debug provider on
    # reproduce once
    ocx debug provider logs

    Include only the Cursor rows for that turn (dial, connected, first-frame, frame, interaction-query, stream-end/stream-error, and turn-failed), plus:

    • exact OpenCodex version/commit;
    • effective stallTimeoutSec;
    • whether any visible text, reasoning, or tool call appeared before the failure;
    • the request-log row's provider/model identity and elapsed time;
    • whether the same prompt/model completes directly in Cursor at approximately the same time.

    Do not include tokens, cookies, request bodies, prompts, or local auth files. With framesReceived, firstFrameMs, and the last sanitized frame case we can determine whether this is expected upstream silence, a missing frame-to-heartbeat mapping, or a transport liveness fault. This remains separate from #1742's image transport work.

  4. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 67 / 80

    에러 문자열이 브릿지 워치독임. src/bridge.ts:1382-1386이 어댑터 이벤트가 일정 시간 없으면 response.incomplete / upstream_stall_timeout을 냄. 기본 창은 src/stall-timeout.ts DEFAULT_STALL_TIMEOUT_SEC = 300. 어댑터 heartbeat가 이걸 리셋함.

    #2156 truncation이랑 다른 선임. openai-chat.ts 주석이 그거 구분함. 이건 Cursor cursor/grok-4.6이 조용히 있는 동안 하트비트가 안 나와서 워치독이 자른 케이스임. 첫 이벤트 타임아웃이랑 established idle을 이슈가 나눠 달라고 함. 지금 코드는 그 둘을 한 카운터로 봄.

    Cursor 쪽은 이미 하트비트 구멍이 있었음. src/adapters/cursor/live-transport.ts:264가 unanswered interactionQuery → heartbeat-only stall → 워치독 → 502 루프를 적음. Grok 4.6 긴 턴이 그 쿼리를 안 보내고 그냥 묵음이면 같은 워치독이 터짐. 재현에 SSE 원본/경과시간이 없음. needs-info 맞음.

    미드스트림 재시도 금지 요구는 맞음. 이미 받은 텍스트/툴콜 뒤에 리플레이하면 중복 실행임. 고치려면 Cursor 어댑터가 침묵 구간에도 하트비트를 내거나, 첫 이벤트/이후 idle 타이머를 나눠야 함.

    해결방안: Cursor/Grok 침묵 구간에 heartbeat를 넣는지부터 확인. 쿼리 미응답이 원인인지 순수 idle인지 로그로 가름. 캡처 오기 전엔 타임아웃 숫자를 흔들지 말 것. #2156이랑 이슈 합치지 말 것.

    이 댓글은 grok-bot이 작성했습니다

  5. lidge-jun commented on Aug 25, 2026

    @lidge-jun
    Owner

    Fixed on dev by 994e5ba87 — fail silent and heartbeat-only streams at the transport instead of the 300s bridge watchdog. Verified as an ancestor of the current head rather than taken on report.

    The cause was not Grok needing more thinking time, which is the tempting reading of a stall timeout. It was genuine established-stream silence, and the generic 300s watchdog was the wrong instrument for it: that watchdog measures upstream silence at the bridge, far above the transport that actually knows what a healthy Cursor stream looks like.

    Cursor now fails at the transport, with clocks that distinguish the cases:

    • first frame: 30s
    • silence after the first frame: 30s
    • heartbeat/checkpoint-only traffic: 90s — this is the one that mattered, because liveness frames kept resetting the bridge watchdog while nothing meaningful arrived
    • meaningful decoded frames reset both clocks; turnEnded disarms them

    That heartbeat-only case is what produced your upstream_stall_timeout → 502 loop: the stream looked alive to the layer doing the timing. Clean Connect END_STREAM terminal ownership was hardened alongside it, so a completed turn no longer waits for HTTP EOF.

    Deliberately not done: raising the timeout or emitting a synthetic keepalive. Both would have made the symptom disappear while leaving genuinely stuck streams to hang longer.

    Covering run on the current head: 252 pass / 0 fail across eleven Cursor transport, framing, EOF and stall-timeout suites.

    Related but distinct: #2600 asked for a semantic no-progress boundary and was closed as a recorded ownership decision — the proxy cannot see your workspace. This one is different in kind, and fixable here, because silence is something the transport genuinely observes.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-infoWaiting on reporter for a concrete spec or reproductionproviderProvider adapters, OpenAI-compat presets, upstream API quirksstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions