Skip to content

Long reasoning streams abort after 300s: treat timeoutMs as chunk idle timeout instead of total wall-clock limit #87

Description

@leon-zym

Problem

When using reasoning models that generate extensive thinking traces (such as GLM-5.3-flash with max thinking or DeepSeek models), long generations that exceed 5 minutes are consistently aborted mid-stream with:
Error: Command Code API request timed out after 300000ms / Operation aborted

This happens even though the server is actively and continuously streaming tokens without any pauses or network drops.

Evidence & Observation

Looking at the session timestamps, the abort occurs at exactly the 300-second mark:

  • Turn start to abort: 300.010s (while actively streaming thinking tokens)
  • Next retry to abort: 300.009s

The 300,000 ms duration originates from Pi's default httpIdleTimeoutMs setting (300_000), which is forwarded to the provider as options.timeoutMs.

Analysis

In src/core.ts (around lines 617-622), attemptTimeoutId is started when the request attempt begins:

if (timeoutMs !== undefined) {
  attemptTimeoutId = setTimeout(() => {
    attemptTimedOut = true
    attemptController.abort()
  }, timeoutMs)
}

In the stream reading loop (readLoop: for (;;) around lines 707-730):

const { done, value } = await raceAbort(reader.read(), attemptController.signal)

The timer is not refreshed when reader.read() yields new chunks. It is only cleared in the finally block once the entire response has completed. As a result, timeoutMs currently acts as a hard wall-clock cap on the total duration of the attempt, rather than an idle gap timeout between received chunks.

Comparison with Pi Core Semantics

In Pi core (@earendil-works/pi-coding-agent), httpIdleTimeoutMs is documented and implemented as an idle timeout:

  • For HTTP/SSE via Undici (http-dispatcher.ts), bodyTimeout monitors the time between bytes received over the socket, resetting on every chunk.
  • For WebSocket streams (e.g., in openai-codex-responses.ts), the idle timer is rescheduled on each message.

The safeguard introduced in PR #13 to ensure the stream does not hang indefinitely mid-consumption is completely valid and necessary. However, resetting the timer whenever active data is received would preserve that protection while allowing legitimate, long-running reasoning streams to finish.

Suggested Improvement

One possible approach is to refresh attemptTimeoutId inside readLoop upon receiving each chunk:

const { done, value } = await raceAbort(reader.read(), attemptController.signal)
if (timeoutMs !== undefined) {
  clearAttemptTimeout()
  attemptTimeoutId = setTimeout(() => {
    attemptTimedOut = true
    attemptController.abort()
  }, timeoutMs)
}

With this adjustment:

  1. If the server hangs mid-stream without sending bytes for timeoutMs, the attempt still aborts as intended by PR feat(core): add retry for transient HTTP and stream-level errors #13.
  2. If the server is actively streaming tokens (e.g. 5-10 minutes of continuous reasoning), the generation proceeds without being prematurely terminated.

Would you be open to a PR implementing this idle timeout reset, along with a test case for continuous long streams?

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions