Skip to content

v2: WebSocket idle-timeout bypasses the 3-timeout retry cap, so a dead stream can hold a step for hours #57127

Open

Description

@carson2222

Description

A WebSocket stream that goes silent fails with transport code idle-timeout after 30 minutes (packages/core/src/session/model-transport.ts:418-424, IDLE_TIMEOUT = "30 minutes"). The retry policy only counts code === "Timeout" toward MAX_TIMEOUT_RETRIES = 3 (packages/core/src/model-retry.ts:53). So an idle-timeout falls under the general 10-retry allowance, and one dead step can hold a session or subagent for hours.

This shows up after a laptop sleeps or the network drops with agents mid-run. On a v2.0.32 install over one week, 264 model requests failed on transport and took 54.8 request-hours. One review subagent spent 243 of its 256 minutes in 9 failed requests ("Timed out waiting for WebSocket data", "WebSocket closed with code 1006"), each about 15-40 min. The agent also sees these as failed tool calls with empty input (patch {}), so it changes its own behavior instead of recognising a provider stall.

Expected: an idle-timeout counts toward the timeout cap like an HTTP Timeout, the same reasoning as the comment above MAX_TIMEOUT_RETRIES.

Possible fix: in model-retry.ts, count reason.code === "idle-timeout" (WebSocket) along with "Timeout", plus a policy test next to the existing HTTP timeout test.

Plugins

Local plugins only (cache TTL, cost); none touch transport or retry.

OpenCode version

2.0.32 (code references from dev at ce15cd8)

Steps to reproduce

  1. Start a long task on an OpenAI model over the Responses WebSocket transport.
  2. Put the machine to sleep, or block network egress, mid-stream for a few minutes, then restore it.
  3. The step waits 30 min for idle-timeout, then retries under the general allowance instead of the 3-timeout cap.

Operating System

macOS 27

Terminal

OpenCode TUI

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the pointers into the code. I can confirm this.

    I tested the session retry policy directly. I fed it repeated failures with the same idle-timeout error that model-transport.ts raises when the WebSocket stream goes silent, and compared that with an HTTP Timeout:

    • HTTP Timeout: retried 3 times, then stopped (MAX_TIMEOUT_RETRIES).
    • WebSocket idle-timeout: retried 10 times under the general allowance, then stopped.

    Since each idle-timeout attempt waits 30 minutes before failing, one step can be held for hours. That goes against the reason the comment above the cap gives for having it. The results were the same on v2.0.32 and on the latest dev (ce15cd8). The test script below reproduces it.

    Related:

    Reproduction script (idle-timeout-retry.test.ts)
    // Copy into packages/core/test/ of an opencode checkout and run: bun test test/idle-timeout-retry.test.ts (from packages/core)
    import { describe, expect } from "bun:test"
    import { AIError, TransportError } from "@opencode/ai"
    import { Agent } from "@opencode/schema/agent"
    import { Model } from "@opencode/schema/model"
    import { Provider } from "@opencode/core/provider"
    import { SessionSchema } from "@opencode/core/session/schema"
    import { SessionRunnerRetry } from "@opencode/core/session/runner/retry"
    import { toSessionError } from "@opencode/core/session/to-session-error"
    import { Effect } from "effect"
    import { it } from "./lib/effect"
    
    const httpTimeout = new AIError({
      reason: new TransportError({ message: "Timed out", transport: "http", operation: "request", code: "Timeout" }),
    })
    // Same shape model-transport.ts produces after the 30-minute WebSocket IDLE_TIMEOUT.
    const wsIdle = new AIError({
      reason: new TransportError({
        message: "Timed out waiting for WebSocket data",
        transport: "websocket",
        operation: "read",
        code: "idle-timeout",
        phase: "receive",
        delivery: "accepted",
      } as any),
    })
    
    const input = (cause: AIError) => ({
      cause,
      error: toSessionError(cause),
      agent: Agent.ID.make("build"),
      model: Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
      hook: () => Effect.void,
      retry: SessionRunnerRetry.isRetryable(cause),
    })
    
    const decisions = (cause: AIError, count: number) =>
      Effect.gen(function* () {
        const policy = yield* SessionRunnerRetry.policy(SessionSchema.ID.make("ses_retry"))
        const results: boolean[] = []
        for (let i = 0; i < count; i++) results.push((yield* policy(input(cause))).retry)
        return results
      })
    
    describe("timeout cap", () => {
      it.effect("http Timeout is capped at 3 retries", () =>
        Effect.gen(function* () {
          const r = yield* decisions(httpTimeout, 11)
          console.log("http Timeout   :", JSON.stringify(r))
          expect(r.slice(0, 4)).toEqual([true, true, true, false])
        }),
      )
      it.effect("websocket idle-timeout should be capped at 3 retries too", () =>
        Effect.gen(function* () {
          const r = yield* decisions(wsIdle, 11)
          console.log("ws idle-timeout:", JSON.stringify(r), "retryable:", SessionRunnerRetry.isRetryable(wsIdle))
          expect(r.slice(0, 4)).toEqual([true, true, true, false])
        }),
      )
    })

    Run it: bun idle-timeout-retry.test.ts

  2. HootRock commented on Oct 19, 2026

    @HootRock

    I reproduced this on dev 367c8fb and pushed a scoped fix: two-file diff, commit a2162bf.

    idle-timeout now shares the existing three-timeout counter with HTTP Timeout. Regressions cover repeated idle timeouts, mixed transport timeouts and the unchanged connection-failure allowance. The two regressions fail before the fix; 50 repeated policy tests and 46 related tests pass afterward. Independent review found no actionable issues, and lint/all 34 typecheck tasks pass. The Windows full-suite comparison has the same 151 failures on both versions, plus the two expected regression failures before the fix.

    GitHub currently blocks my new PR with this repository's three-open-PR limit. The tested branch is available for review; no PR has been created, and I will retain my existing PRs.

    AI-assisted; reviewed and tested locally.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions