Skip to content

fix(kosong): fail stalled provider streams instead of hanging forever - #2797

Open
matthiasgoergens wants to merge 1 commit into
MoonshotAI:mainfrom
matthiasgoergens:fix/stream-stall-watchdog
Open

fix(kosong): fail stalled provider streams instead of hanging forever#2797
matthiasgoergens wants to merge 1 commit into
MoonshotAI:mainfrom
matthiasgoergens:fix/stream-stall-watchdog

Conversation

@matthiasgoergens

@matthiasgoergens matthiasgoergens commented Aug 11, 2026

Copy link
Copy Markdown

Related Issue

Resolve #1050

Problem

See linked issue. In short: a response that goes silent — before headers or mid-stream, connection open, no bytes (e.g. an overloaded or half-dead gateway) — blocks generate() forever. No timeout fires, cancel only takes effect when the next part arrives, and the turn never produces a terminal event. Hosts wedge with it: in the VSCode extension the session then rejects every follow-up message for the rest of the window's life (surfaced as "Internal error occurred.", see #2796).

Verified from a real session log: the step's llm request is logged, the wire log ends mid-thinking-token, and no llm response ever follows.

What changed

  • generate() (kosong, and the mirrored agent-core-v2 kosong contract) races both the response-headers wait and every iterator.next() against:
    • a resettable inactivity timer — new GenerateOptions.streamStallTimeoutMs (default DEFAULT_STREAM_STALL_TIMEOUT_MS = 300_000, 0 disables), and
    • the caller's abort signal, so cancel-during-stall now aborts promptly too (previously throwIfAborted only ran when a part arrived).
  • On stall, an internal AbortController linked into the request signal via AbortSignal.any tears down the provider's HTTP connection (providers forward the signal to their HTTP clients), and APITimeoutError is thrown. isRetryableGenerateError already classifies that as retryable, so chatWithRetry recovers transient stalls and persistent ones end the turn with a real error instead of a wedge.
  • All watchdog-path teardown (stream cancel()/return(), iterator return(), late-resolving provider.generate()) is fire-and-forget — a faulty provider whose teardown never settles cannot hang the stall path; a stream acquired just as the watchdog fires is still cancelled.
  • agent-core hosts can tune the budget via KIMI_STREAM_STALL_TIMEOUT_MS (wired in KosongLLM, following the existing KIMI_* env-override pattern). The v2 copy takes the option programmatically only — no raw process.env read in v2 domains per the v2 config rule.
  • The manual iterator loop preserves for await semantics on exceptional exit: a throwing callback/merge still closes the iterator and cancels the stream (best-effort, never blocking the original error).
  • Decode-phase accounting (serverDecodeMs/clientConsumeMs) semantics preserved; Promise.race keeps every abandoned promise observed, so no unhandled rejections.

Tests: unit tests for stall mid-stream / stall-before-first-part / stall-before-headers / never-settling teardown / budget reset per part / abort-during-stall / disable-via-0 (both copies), plus a live-HTTP e2e driving the real KimiChatProvider against a local server that sends one SSE chunk and then holds the socket open forever.

Reproduced end-to-end with a mock OpenAI-compatible server that stalls mid-stream: pre-fix the CLI hangs indefinitely; post-fix the stall is detected after the budget and retried (llm request failed ... errorName=APITimeoutError).

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my fix works.
  • Ran gen-changesets skill, or this PR needs no changeset. (changeset included: @moonshot-ai/kimi-code + @moonshot-ai/kimi-code-sdk patch)
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 12963a6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@moonshot-ai/kimi-code Patch
@moonshot-ai/kimi-code-sdk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd3e642448

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/kosong/src/generate.ts Outdated
});

try {
const outcome = await Promise.race([iterator.next(), watchdog]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize iterator failures after caller cancellation

When a provider or its transport has already subscribed to the linked signal and the pending iterator.next() rejects before the watchdog promise wins, this race rejects directly and bypasses the 'aborted' branch. A custom ChatProvider can therefore surface its provider-specific cancellation error instead of the documented AbortError, and its explicit teardown hooks are skipped. Catch failures from this race and, when the caller signal is aborted, cancel the stream and throw the standard abort error; the mirrored v2 implementation has the same race.

Useful? React with 👍 / 👎.

Comment thread packages/kosong/src/generate.ts Outdated
// Abort first: providers forward the signal to their HTTP client, so
// this kills the dead connection and settles the in-flight iteration.
stallAbort.abort();
await cancelStream(stream);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid awaiting cancellation that can itself remain stalled

If a StreamedMessage is also its own async iterator, or exposes a cancel()/return() that waits for the currently pending next(), this await never settles when that iterator ignores the abort signal. The watchdog then reproduces the original indefinite hang instead of returning 'stalled' and throwing APITimeoutError; async-generator return() calls are queued behind an outstanding next(), so this is a valid provider shape. Make cancellation on the stall path fire-and-forget or otherwise bounded, as teardownIterator already intends; the mirrored v2 implementation has the same issue.

Useful? React with 👍 / 👎.

@matthiasgoergens
matthiasgoergens force-pushed the fix/stream-stall-watchdog branch from bd3e642 to 12963a6 Compare August 11, 2026 04:06
A response that goes silent — before headers or mid-stream, connection
open, no bytes (e.g. an overloaded or half-dead gateway) — blocked
generate() forever: no timeout fired, cancel only took effect when the
next part arrived, and the turn never produced a terminal event. Hosts
wedged with it — VSCode sessions then rejected every follow-up message
for the rest of the window's life.

generate() now races both the header wait and each iterator.next()
against a resettable inactivity timer (streamStallTimeoutMs, default
300s, 0 disables) and an internal AbortController linked into the
request signal via AbortSignal.any. On stall the linked abort tears
down the provider HTTP connection and an APITimeoutError is thrown;
isRetryableGenerateError already classifies it as retryable, so
chatWithRetry recovers transient stalls and persistent ones end the
turn with a real error. All watchdog teardown is fire-and-forget, so a
faulty provider whose cancel()/return() never settles cannot hang the
stall path. The same race makes cancel-during-stall abort promptly.

Mirrored in agent-core-v2's kosong contract copy. agent-core hosts can
tune the budget via KIMI_STREAM_STALL_TIMEOUT_MS; the v2 copy takes the
option programmatically only (no raw process.env read in v2 domains).

Fixes MoonshotAI#1050
@matthiasgoergens
matthiasgoergens force-pushed the fix/stream-stall-watchdog branch from 12963a6 to eaa464d Compare August 11, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kosong: streaming response hangs forever when no chunks arrive (no idle timeout)

1 participant