fix(inference): idle-timeout recovery + guaranteed terminal event on a stalled stream#4763
Conversation
📝 WalkthroughWalkthroughAdds a mid-stream idle-timeout guard to the OpenAI-compatible SSE stream reader, introducing a new ChangesIdle Stream Timeout Handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7d6bb05dc0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…a stalled stream A streaming turn could freeze mid-generation and orphan itself with no chat_done/chat_error. sse_bytes_to_chunks read the upstream with a bare `bytes_stream.next().await` and no idle timeout, so when the backend held the connection open but went silent, the read blocked forever: the loop never exited, the terminal final_chunk never ran, and the consumer blocked with it — no terminal event ever fired (the connection was only dropped ~250s later). Wrap each byte read in an idle timeout (read_next_or_idle). On a stall we emit a terminal StreamError::IdleTimeout and break, so a terminal event is always produced. Default idle window 120s, tunable via OPENHUMAN_INFERENCE_STREAM_IDLE_TIMEOUT_SECS (0 disables). The new variant is classified retryable so the reliable layer retries a fresh connection and, if exhausted, still surfaces a terminal chat_error. Applies to every streaming method since all route through sse_bytes_to_chunks. Closes tinyhumansai#4761
7d6bb05 to
b9992b8
Compare
|
CI guardian: ran |
… success final_chunk after a stall Addresses both Codex review comments on compatible_stream.rs (tinyhumansai#4763): - P2 "Suppress success final chunks after idle timeouts": the idle-timeout branch sent Err(IdleTimeout) then only `break`, so the unconditional `Ok(StreamChunk::final_chunk())` at the end still ran — a stalled generation was reported as a normal `finish_reason: stop` right after the terminal error. Now `return` after sending the idle error so the success final chunk is not emitted on a stall (clean EOF still emits it). - P2 "Bound the idle-timeout env override": the PR added a second resolver for OPENHUMAN_INFERENCE_STREAM_IDLE_TIMEOUT_SECS with a divergent default (120 vs the existing 90), no upper bound (a 99999 typo waited hours), and 0-disables semantics — contradicting the already-shipped `compatible_timeout` resolver and `.env.example` docs (default 90, range 1-3600). Reuse the single shared `compatible_timeout::stream_idle_timeout()` so the env var means the same thing on both the SSE and native stream paths and can never wedge a turn. Drops the now-redundant local const/resolver and its env-override test; the generic `read_next_or_idle` helper and its unit tests are unchanged.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/openhuman/inference/provider/compatible_stream.rs`:
- Around line 78-90: The error-handling in compatible_stream::stream still falls
through to final_chunk() in the String::from_utf8(...) and HTTP Err(e) branches,
which can misreport failures as normal completion. Update those branches in the
stream processing logic to return immediately after sending Err(...) to tx,
matching the IdleRead::IdleTimedOut path, so decoding/transport errors terminate
the stream without emitting a success chunk.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7130f521-dd46-4f47-bb67-04b16b99a37d
📒 Files selected for processing (3)
src/openhuman/inference/provider/compatible_stream.rssrc/openhuman/inference/provider/error_classify.rssrc/openhuman/inference/provider/traits.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- src/openhuman/inference/provider/traits.rs
- src/openhuman/inference/provider/error_classify.rs
…SSE path The review-fix pointed the SSE path at super::compatible_timeout::stream_idle_timeout(), but that module lives under compatible (compatible::compatible_timeout) and the fn was pub(super) — E0433 unresolved/inaccessible. Widen the module + fn to pub(crate) and correct the path so both the SSE and native stream paths resolve the one shared resolver.
|
CI guardian: fixed the E0433 my review-fix introduced — the SSE path referenced |
compatible_stream is loaded as a module under BOTH provider and provider::compatible (dual mod declaration), so any super::-relative path to compatible_timeout resolves in only one of the two inclusions. Use the crate::-absolute path so it compiles in both.
|
CI guardian: root-caused the E0433 — |
|
Verdict: comment-only — the feature is satisfied and CI is green, but one substantive reviewer thread is still open and the description drifted from the merged code. Feature satisfaction: yes. The #4761 invariant ("a terminal event must always fire on a stalled/dropped stream") is delivered. Findings:
Thread status:
|
…just idle timeout Converge on emitting the success final_chunk() only on a clean EOF (IdleRead::Ended). The UTF-8-decode and HTTP-transport error branches previously broke out of the read loop and then fell through to the unconditional final_chunk(), reporting a real failure as a normal finish_reason: stop. Return immediately after sending Err(...) in both, matching the idle-timeout path.
YellowSnnowmann
left a comment
There was a problem hiding this comment.
Approving — the previously-flagged blocker is fixed, CI green, CodeRabbit re-approved, all threads resolved, feature satisfied.
|
Main's #4784 ( Re-applying the fix is therefore a design task, not a rebase: either (a) re-implement idle-timeout + stall-termination in the new crate-native streaming path (first confirming #4761 still reproduces there), or (b) resurrecting Holding the rebase pending a maintainer call on which streaming path #4761's idle-timeout guard should live in. The two surviving one-liners in this PR ( |
|
Closing this PR. It could not be cleanly rebased onto the current |
What & why
A streaming turn with no tools/sub-agents could freeze mid-generation and orphan itself with no terminal event (#4761): the base inference stream streamed some text, went silent, emitted only heartbeats, and the connection was dropped ~250s later with no
chat_doneand nochat_error→ empty final reply.Root cause
sse_bytes_to_chunks(src/openhuman/inference/provider/compatible_stream.rs) read the upstream byte stream with a barewhile let Some(item) = bytes_stream.next().awaitand no idle timeout. When the backend established the SSE stream and then stalled (connection held open, no further bytes),next().awaitblocked forever:final_chunk()never ran;compatible_provider_impl.rs, then the tinyagentsProviderModeladapter that wraps openhuman'sProvider) blocked waiting for a chunk that never came;reqwest had no read/idle timeout configured either, so nothing caught it until the far end dropped the socket much later. The harness lives in tinyagents but the actual backend byte-streaming is openhuman's
Provider, so the gap — and the fix — are here.Fix
compatible_stream.rs— a small generic helperread_next_or_idlewraps each byte read intokio::time::timeout. On a stall it emits a terminalStreamError::IdleTimeoutandreturns — so the successfinal_chunk()is not emitted after the error and a stalled generation can no longer be masked as a normalfinish_reason: stop. The same convergence is applied to the other terminal error branches (UTF-8 decode, HTTP transport, SSE parse): each nowreturns after sending itsErr, sofinal_chunk()is reached only on a clean EOF. A terminal event is now guaranteed on stall/drop. The idle window reuses the single shared per-chunk inactivity watchdog (stream_idle_timeout(), Research agent intermittently hangs mid-run #4269) rather than a second divergent resolver, soOPENHUMAN_INFERENCE_STREAM_IDLE_TIMEOUT_SECSmeans the same thing on both the SSE and native-stream paths: default 90s, bounded1..=3600, with a missing/non-numeric/out-of-range value (e.g.0or99999) falling back to the 90s default — the guard can't be typo'd into disabling or wedging a turn indefinitely. Every streaming method routes throughsse_bytes_to_chunks, so this covers the whole streaming surface.traits.rs— newStreamError::IdleTimeout(Duration)variant.error_classify.rs— classifiesIdleTimeoutas retryable (a stall is a transient upstream condition): the reliable layer can retry a not-yet-committed stall on a fresh connection, and once retries are exhausted it still surfaces as a terminalchat_error— the whole point being that the turn can no longer hang with no terminal event.compatible.rs/compatible_timeout.rs— widen the module +stream_idle_timeout()getter topub(crate)so the shared bounded resolver is reachable from the SSE path (compatible_streamis included as a module under bothproviderandprovider::compatible, so the getter is referenced by itscrate::-absolute path, which resolves in both inclusions).Tests
Four unit tests on the idle helper
read_next_or_idle: stall →IdleTimedOut, data →Item, clean end →Ended, and guard-disabled (None) still yields items. The shared resolver's bounds/parse behaviour is covered by the existingcompatible_timeouttests.The
~19sTTFT noted in the issue is upstream/staging infra (called out there as not the code gap) and is not addressed here.Closes #4761
Summary by CodeRabbit
New Features
Bug Fixes
IdleTimeoutstreaming error and ensured it’s classified as retryable.Tests