Skip to content

fix: return closed non-nil channel for empty streams instead of (nil, nil) - #5556

Merged
akshaydeo merged 1 commit into
maximhq:devfrom
kharkevich:fix/empty-stream-nil-channel
Jul 26, 2026
Merged

akshaydeo merged 1 commit into
maximhq:devfrom
kharkevich:fix/empty-stream-nil-channel

Conversation

@kharkevich

Copy link
Copy Markdown
Contributor

Summary

Fixes the public streaming API returning (nil, nil) for empty streams. When a provider's chunk channel closes before the first chunk, CheckFirstStreamChunkForError returns a nil channel with a nil error, and executeRequestWithRetries assigned that straight into the public *StreamRequest result — so integrators received a nil channel with no error and blocked forever on the receive (a nil-channel receive never returns).

Closes #5555.

Changes

  • core/bifrost.go (executeRequestWithRetries): when the checked stream comes back nil (empty stream), substitute a closed, non-nil channel as the result. Zero-chunk semantics are preserved — range exits immediately, a receive yields (nil, false) — while the implicit contract "nil error ⇒ usable channel" now holds.
  • The substitution deliberately happens at the call site, not inside CheckFirstStreamChunkForError, and the empty case keeps isStreamChan == false, for two reasons:
    1. Span lifecycle: an empty stream has no surviving provider goroutine to complete a deferred span. Keeping the synchronous span-completion path (pre-existing behavior) avoids stranding a deferred span in the TraceStore or racing the provider goroutine's finalizer.
    2. Large-payload passthrough: SetupStreamingPassthrough providers intentionally return an already-closed placeholder channel; changing the helper's return would have silently moved every passthrough request onto the deferred-span path with no completer.
  • CheckFirstStreamChunkForError itself is unchanged — its nil return is now genuinely consumed by the caller as the empty-stream signal, matching its doc comment.

Behavior note (intended): through the HTTP transport, an empty stream previously hit the stream == nil guard and produced an error response; it now returns a well-formed SSE stream with zero data events, which is the truthful representation of "the provider ended the stream without chunks".

Type of change

  • Bug fix

Affected areas

  • Core (Go)

How to test

cd core
go test -run 'TestExecuteRequestWithRetries_EmptyStreamReturnsClosedChannel' .
go test ./providers/utils/
go build ./...

TestExecuteRequestWithRetries_EmptyStreamReturnsClosedChannel is a caller-level regression test: it fails on the previous behavior (nil channel) and passes with this change. Existing TestCheckFirstStreamChunk_* tests are untouched and still green.

Breaking changes

  • No

Related issues

Closes #5555

Security considerations

None. One extra channel allocation only on the empty-stream path.

… nil)

When a provider's stream closed before the first chunk,
CheckFirstStreamChunkForError returned a nil channel with a nil error and
executeRequestWithRetries assigned it straight into the public
*StreamRequest result. Integrators that range/receive on the returned
channel then block forever, because a receive from a nil channel never
returns.

Substitute a closed, non-nil channel at the call site: range exits
immediately, a receive yields (nil, false), and the implicit contract
'nil error implies a usable channel' holds. The empty case keeps the
synchronous span-completion path (isStreamChan stays false): the provider
goroutine is already gone, so a deferred span would never be completed —
this also preserves the existing span behavior for the large-payload
passthrough placeholder, which is a closed channel by design.

CheckFirstStreamChunkForError is unchanged; its nil return is now
genuinely consumed by the caller as the empty-stream signal.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e8034b35-7383-4023-b2d1-5ee113587db8

📥 Commits

Reviewing files that changed from the base of the PR and between 2952aae and dba0928.

📒 Files selected for processing (2)
  • core/bifrost.go
  • core/bifrost_test.go

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Fixed streaming requests that return no data so they now complete safely with a closed stream instead of hanging indefinitely.
    • Improved completion handling for empty streaming responses.
  • Tests

    • Added coverage to verify empty streams are non-nil, closed, and safe to read or iterate.

Walkthrough

Empty provider streams now return a closed, non-nil channel instead of (nil, nil). Streaming span completion is handled synchronously for empty streams, and a regression test verifies immediate channel consumption.

Changes

Empty stream handling

Layer / File(s) Summary
Normalize empty streaming results
core/bifrost.go, core/bifrost_test.go
Empty streams are replaced with closed non-nil channels, span completion is performed synchronously, and tests verify non-blocking receives and ranging.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • maximhq/bifrost#5491: Updates streaming span handling for streams that fail before producing their first chunk.

Suggested reviewers: tejasghatte, roroghost17, akshaydeo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the bug fix: returning a closed non-nil channel for empty streams instead of nil.
Description check ✅ Passed The description covers summary, changes, type, affected area, testing, breaking changes, related issue, and security notes.
Linked Issues check ✅ Passed The code matches #5555 by turning empty streams into a closed non-nil channel while preserving zero-chunk behavior.
Out of Scope Changes check ✅ Passed The patch stays focused on the empty-stream fix and its regression test, with no unrelated code changes visible.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@akshaydeo
akshaydeo merged commit e7818f8 into maximhq:dev Jul 26, 2026
4 of 5 checks passed
@kharkevich
kharkevich deleted the fix/empty-stream-nil-channel branch July 26, 2026 14:09
akhsaul pushed a commit to akhsaul/bifrost that referenced this pull request Aug 27, 2026
… nil) (maximhq#5556)

When a provider's stream closed before the first chunk,
CheckFirstStreamChunkForError returned a nil channel with a nil error and
executeRequestWithRetries assigned it straight into the public
*StreamRequest result. Integrators that range/receive on the returned
channel then block forever, because a receive from a nil channel never
returns.

Substitute a closed, non-nil channel at the call site: range exits
immediately, a receive yields (nil, false), and the implicit contract
'nil error implies a usable channel' holds. The empty case keeps the
synchronous span-completion path (isStreamChan stays false): the provider
goroutine is already gone, so a deferred span would never be completed —
this also preserves the existing span behavior for the large-payload
passthrough placeholder, which is a closed channel by design.

CheckFirstStreamChunkForError is unchanged; its nil return is now
genuinely consumed by the caller as the empty-stream signal.
occcat pushed a commit to occcat/bifrost that referenced this pull request Sep 2, 2026
… nil) (maximhq#5556)

When a provider's stream closed before the first chunk,
CheckFirstStreamChunkForError returned a nil channel with a nil error and
executeRequestWithRetries assigned it straight into the public
*StreamRequest result. Integrators that range/receive on the returned
channel then block forever, because a receive from a nil channel never
returns.

Substitute a closed, non-nil channel at the call site: range exits
immediately, a receive yields (nil, false), and the implicit contract
'nil error implies a usable channel' holds. The empty case keeps the
synchronous span-completion path (isStreamChan stays false): the provider
goroutine is already gone, so a deferred span would never be completed —
this also preserves the existing span behavior for the large-payload
passthrough placeholder, which is a closed channel by design.

CheckFirstStreamChunkForError is unchanged; its nil return is now
genuinely consumed by the caller as the empty-stream signal.
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.

[Bug]: *StreamRequest returns (nil, nil) for empty streams — consumers hang forever on a nil-channel receive

3 participants