Skip to content

streaming memory improvements - #4678

Merged
akshaydeo merged 1 commit into
devfrom
06-24-streaming_memory_improvements
Jun 25, 2026
Merged

akshaydeo merged 1 commit into
devfrom
06-24-streaming_memory_improvements

Conversation

@akshaydeo

@akshaydeo akshaydeo commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes two classes of bugs: a race condition that caused fasthttp's pooled requestStream to be double-released (producing slice bounds out of range panics on concurrent requests), and a stream accumulator memory leak where accumulators for aborted or non-cleanly-terminated streams were not freed until the TTL sweep. It also replaces O(n²) string concatenation in the Responses API stream builder with a strings.Builder-based accumulator pattern.

Changes

  • Atomic stream close ownership (utils.go): Replaced the racy Value-then-SetValue guard on BifrostContextKeyConnectionClosed with a single GetAndSetValue compare-and-swap in SetupStreamCancellation, NewIdleTimeoutReader, and ReleaseStreamingResponse. Previously, the cancellation goroutine, the idle-timeout timer, and ReleaseStreamingResponse could each observe false and all proceed to call CloseWithError, double-releasing the pooled fasthttp requestStream and corrupting a concurrent request's slice bounds. Now exactly one owner wins the claim and performs the close.

  • Removed re-panic in idle timeout reader (utils.go): Dropped the unconditional panic(recovered) in idleTimeoutReader.Read's recover block, which was re-panicking on legitimate post-close reads after the atomic-claim fix moved r.fired.Store(true) to the winning-owner path only.

  • Force-reap stream accumulator backstop (accumulator.go, tracer.go, bifrost.go): Added ForceCleanupStreamAccumulator to the Tracer interface, NoOpTracer, Accumulator, and Tracer implementations. The stream's terminal lifecycle hook (the provider goroutine's postHookSpanFinalizer) now calls this after releasing the plugin pipeline, guaranteeing that accumulators for streams that end without a clean terminal chunk (client abort, broken SSE write, multi-plugin refcount imbalance) are freed immediately rather than waiting for the TTL sweep. The trace ID is captured as an immutable string before the pooled request object is recycled.

  • O(n²) → O(n) Responses stream builder (responses.go): Replaced per-delta *field += delta string concatenation in buildCompleteMessageFromResponsesStreamChunks with strings.Builder accumulators keyed by message and content-block index. Builders are materialized into final strings once after the chunk walk. The five helper methods (appendTextDeltaToResponsesMessage, appendRefusalDeltaToResponsesMessage, appendFunctionArgumentsDeltaToResponsesMessage, appendReasoningDeltaToResponsesMessage, appendReasoningSignatureToResponsesMessage) are removed and their logic is inlined into the walk + materialization pass.

  • Tests (idle_timeout_reader_test.go, responses_test.go): Added TestStreamClose_ExactlyOnceAcrossOwners (races the timer and cancellation goroutine 100 times, asserts CloseWithError fires exactly once), TestConnectionClosedClaim_SerializesOwners (64 concurrent goroutines, asserts exactly one wins), TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed, TestBuildResponsesMessageConcatenatesTextDeltas, TestBuildResponsesMessageRoutesParallelToolArgs, TestBuildResponsesMessageAccumulatesReasoningSummary, TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount, and BenchmarkBuildResponsesMessageTextDeltas.

  • UI formatting (clientLayout.tsx): Reformatted from 2-space to tab indentation and collapsed some multi-line imports; no behavioral changes.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Chore/CI

Affected areas

  • Core (Go)
  • Transports (HTTP)
  • Providers/Integrations
  • Plugins
  • UI (React)
  • Docs

How to test

# Run with race detector to exercise the atomic-claim fix
go test -race ./core/providers/utils/... -run TestStreamClose_ExactlyOnceAcrossOwners -count=10
go test -race ./core/providers/utils/... -run TestConnectionClosedClaim_SerializesOwners
go test -race ./core/providers/utils/... -run TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed

# Force-reap accumulator backstop
go test ./framework/streaming/... -run TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount -v

# Responses stream builder correctness and performance
go test ./framework/streaming/... -run TestBuildResponsesMessage
go test -bench=BenchmarkBuildResponsesMessageTextDeltas -benchmem ./framework/streaming/...

# Full suite
go test ./...

# UI
cd ui
pnpm i
pnpm build

Breaking changes

  • No

The Tracer interface gains ForceCleanupStreamAccumulator. Any external implementations of Tracer must add this method. The NoOpTracer implementation is provided as a reference.

Related issues

Fixes the slice bounds out of range [:-680] crash caused by double-release of fasthttp's pooled requestStream. Fixes stream accumulator leaks on client-aborted or non-cleanly-terminated streams.

Security considerations

No auth, secrets, or PII implications. The fix closes a memory-corruption vector (double-pool-release) that could cause one request to alias another's in-flight data.

Checklist

  • I read docs/contributing/README.md and followed the guidelines
  • I added/updated tests where appropriate
  • I updated documentation where needed
  • I verified builds succeed (Go and UI)
  • I verified the CI pipeline passes locally if applicable

@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 Jun 24, 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: 1a87c403-ff23-4481-9b7f-d36e235c5ad8

📥 Commits

Reviewing files that changed from the base of the PR and between 4304def and 0d3ebf4.

📒 Files selected for processing (10)
  • core/providers/anthropic/anthropic.go
  • core/providers/utils/idle_timeout_reader_test.go
  • core/providers/utils/utils.go
  • framework/streaming/accumulator.go
  • framework/streaming/responses.go
  • framework/streaming/responses_test.go
  • framework/tracing/tracer.go
  • tests/integrations/python/config.json
  • transports/bifrost-http/handlers/middlewares.go
  • ui/app/clientLayout.tsx
✅ Files skipped from review due to trivial changes (1)
  • ui/app/clientLayout.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • framework/tracing/tracer.go
  • framework/streaming/accumulator.go
  • transports/bifrost-http/handlers/middlewares.go
  • framework/streaming/responses_test.go
  • framework/streaming/responses.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a backstop cleanup for streamed responses so unfinished streams are cleaned up more reliably.
    • Improved streaming response assembly to handle text, tool arguments, and reasoning content more consistently.
  • Bug Fixes

    • Reduced the risk of double-closing streamed connections during cancellation and timeout races.
    • Improved streaming request handling to avoid unsafe reuse of connections during Anthropic streaming.
    • Fixed cleanup behavior when a stream ends without a final completion signal.

Walkthrough

Adds atomic stream-close ownership claims, forced accumulator cleanup backstops, response chunk builder refactoring, related tests, and a formatting-only UI update.

Changes

Streaming cleanup and response assembly

Layer / File(s) Summary
Anthropic streaming client setup
core/providers/anthropic/anthropic.go
Sets Connection: close and uses a fresh per-request streaming client before PrepareResponseStreaming in both Anthropic SSE streaming paths.
Atomic stream ownership in utils
core/providers/utils/utils.go
Replaces non-atomic stream-close guards in SetupStreamCancellation, NewIdleTimeoutReader, and ReleaseStreamingResponse with ctx.GetAndSetValue claims, and changes idleTimeoutReader.Read panic recovery to stop re-panicking.
Forced stream-accumulator cleanup
framework/streaming/accumulator.go, framework/tracing/tracer.go, transports/bifrost-http/handlers/middlewares.go
Adds ForceCleanupStreamAccumulator on Accumulator, exposes the tracer wrapper, and calls it from tracing middleware after trace flush completes.
Response chunk builders and coverage
framework/streaming/responses.go, framework/streaming/responses_test.go, core/providers/utils/idle_timeout_reader_test.go
Refactors streamed response assembly to accumulate into builders and materialize once, and adds tests for response concatenation, tool-argument routing, reasoning summaries, force cleanup, and stream-close ownership behavior.

UI formatting

Layer / File(s) Summary
clientLayout.tsx formatting
ui/app/clientLayout.tsx
Reflows imports, component definitions, and JSX structure in clientLayout.tsx while preserving the same logic and provider composition.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • maximhq/bifrost#4616: Modifies the same NewIdleTimeoutReader idle-timeout timer callback in core/providers/utils/utils.go and related CloseWithError behavior.

Suggested reviewers

  • danpiths
  • roroghost17

Poem

🐇 The stream won’t double-close today,
One claimant wins; the rest step away.
Builders gather deltas, neat and bright,
Then clean up traces at the end of flight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and matches the main theme of the PR: streaming-related memory and cleanup improvements.
Description check ✅ Passed The PR description matches the template well with summary, changes, testing, affected areas, security, and checklist sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 06-24-streaming_memory_improvements

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"


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

@akshaydeo
akshaydeo marked this pull request as ready for review June 24, 2026 18:14

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
core/providers/utils/utils.go (1)

2425-2433: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Re-panic unexpected reader panics.

Line 2427 now recovers every panic, but only assigns an error for idle-timeout/closed cases. Any unrelated panic from r.reader.Read is silently converted into (0, nil), which can hide corruption or spin callers.

Restore the previous fail-fast behavior for unexpected panics
 func (r *idleTimeoutReader) Read(p []byte) (n int, err error) {
 	defer func() {
 		if recovered := recover(); recovered != nil {
 			if r.fired.Load() || r.connectionClosed() {
 				n = 0
 				err = r.closedReadError()
 				return
 			}
+			panic(recovered)
 		}
 	}()
🤖 Prompt for 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.

In `@core/providers/utils/utils.go` around lines 2425 - 2433, The
idleTimeoutReader.Read recovery logic is swallowing unrelated panics from
r.reader.Read and turning them into a successful read, so restore fail-fast
behavior for non-idle/closed cases. Update the defer/recover handling in
idleTimeoutReader.Read so it only converts the panic to closedReadError when
r.fired.Load() or r.connectionClosed() is true, and otherwise re-panic the
recovered value to preserve unexpected failures.
framework/streaming/responses.go (1)

624-652: 🎯 Functional Correctness | 🟠 Major

Default ContentIndex before dropping deltas core/providers/bedrock/bedrock.go emits ResponsesStreamResponseTypeOutputTextDelta without ContentIndex, so this branch can discard real text and leave a placeholder empty message. Fall back to 0 when ContentIndex is nil, or defer creating the empty message until a delta is actually applied.

🤖 Prompt for 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.

In `@framework/streaming/responses.go` around lines 624 - 652, The OutputTextDelta
and RefusalDelta handling in responses.go drops real streamed content when
resp.ContentIndex is nil, because it creates an empty message but never applies
the delta; update the delta accumulation path in the
ResponsesStreamResponseTypeOutputTextDelta and
ResponsesStreamResponseTypeRefusalDelta cases to default a missing ContentIndex
to 0, or delay createNewMessage until a delta is actually appended, so
builderFor/ensureContentBlock still writes into the intended block.
🤖 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 `@core/providers/utils/idle_timeout_reader_test.go`:
- Around line 638-646: The race test in
NewIdleTimeoutReader/SetupStreamCancellation is nondeterministic because it
relies on a fixed 3ms sleep before stop() and cleanup(), which can miss both
close paths on slow CI. Replace the sleep with a bounded wait that polls or
waits until closeCount becomes greater than 0 before performing final cleanup
and assertions, using the existing cancel(), stop(), cleanup(), and closeCount
state to keep the test deterministic.

In `@core/schemas/tracer.go`:
- Around line 116-120: The new ForceCleanupStreamAccumulator method should not
be added to the public schemas.Tracer interface because it breaks custom
implementations. Move this behavior into a separate optional secondary interface
near Tracer, keep Tracer unchanged, and update the call site to type-assert for
the optional interface before invoking ForceCleanupStreamAccumulator. Use the
existing Tracer and ForceCleanupStreamAccumulator symbols to locate and refactor
the affected contract.

In `@framework/streaming/responses_test.go`:
- Around line 13-15: `testResponsesAccumulator` creates an `Accumulator` that
starts background work, but callers are not consistently cleaning it up. Change
the helper to accept `testing.TB`, create the accumulator as before, and
register `Cleanup()` on the passed test handle so every test/benchmark using
`testResponsesAccumulator` is covered. Update the existing callers in
`responses_test.go` to pass their `*testing.T`/`*testing.B` values.

---

Outside diff comments:
In `@core/providers/utils/utils.go`:
- Around line 2425-2433: The idleTimeoutReader.Read recovery logic is swallowing
unrelated panics from r.reader.Read and turning them into a successful read, so
restore fail-fast behavior for non-idle/closed cases. Update the defer/recover
handling in idleTimeoutReader.Read so it only converts the panic to
closedReadError when r.fired.Load() or r.connectionClosed() is true, and
otherwise re-panic the recovered value to preserve unexpected failures.

In `@framework/streaming/responses.go`:
- Around line 624-652: The OutputTextDelta and RefusalDelta handling in
responses.go drops real streamed content when resp.ContentIndex is nil, because
it creates an empty message but never applies the delta; update the delta
accumulation path in the ResponsesStreamResponseTypeOutputTextDelta and
ResponsesStreamResponseTypeRefusalDelta cases to default a missing ContentIndex
to 0, or delay createNewMessage until a delta is actually appended, so
builderFor/ensureContentBlock still writes into the intended block.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e13613e-a798-48f4-8387-dbd6574e198e

📥 Commits

Reviewing files that changed from the base of the PR and between b98ba21 and 5dc6090.

📒 Files selected for processing (9)
  • core/bifrost.go
  • core/providers/utils/idle_timeout_reader_test.go
  • core/providers/utils/utils.go
  • core/schemas/tracer.go
  • framework/streaming/accumulator.go
  • framework/streaming/responses.go
  • framework/streaming/responses_test.go
  • framework/tracing/tracer.go
  • ui/app/clientLayout.tsx

Comment thread core/providers/utils/idle_timeout_reader_test.go
Comment thread core/schemas/tracer.go Outdated
Comment thread framework/streaming/responses_test.go Outdated
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

Safe to merge pending the pre-existing open comment about silent panic swallowing in idleTimeoutReader.Read. The CAS-based close ownership, force-reap backstop, and strings.Builder refactor are all correct and well-tested.

The atomic-claim fix is mechanically sound — replacing racy read-then-write with a single locked compare-and-swap in three coordinated sites, backed by targeted race-detector tests. The prior review's concern about the removed panic(recovered) fallback in Read() — where unrelated panics are now silently converted to (0, nil) returns, producing a busy-loop instead of a visible crash — remains unaddressed in this revision.

core/providers/utils/utils.go — the Read() recover block still has no fallback for panics unrelated to idle-timeout or connection-closed state.

Important Files Changed

Filename Overview
core/providers/utils/utils.go Replaces racy Value+SetValue guard with GetAndSetValue CAS across SetupStreamCancellation, NewIdleTimeoutReader, and ReleaseStreamingResponse; removes panic(recovered) fallback — leaving unrelated panics silently swallowed (flagged in prior review).
framework/streaming/accumulator.go Adds ForceCleanupStreamAccumulator that locks the per-accumulator mutex and delegates to cleanupStreamAccumulator with forceEndGate=true; follows the same pattern as Cleanup() and cleanupOldAccumulators(). Idempotent and correct.
framework/streaming/responses.go Replaces O(n²) per-delta string concatenation with strings.Builder accumulators keyed by message and content-block index, materialized once after the chunk walk; removes five helper methods and inlines their logic. Semantically equivalent to old code.
framework/tracing/tracer.go Adds ForceCleanupStreamAccumulator on the concrete *Tracer struct, but the corresponding schemas.Tracer interface and NoOpTracer were not updated — external Tracer implementations cannot call the method through the interface.
transports/bifrost-http/handlers/middlewares.go Adds ForceCleanupStreamAccumulator call in the trace completer callback after CompleteAndFlushTrace, providing the guaranteed end-of-stream backstop for accumulators that outlive their refcount handshake.
core/providers/anthropic/anthropic.go Adds Connection: close header and uses BuildStreamingClient (fresh per-request client) for both chat and Responses streaming paths to prevent fasthttp reader-pool poisoning from non-idempotent stream close; mirrors existing mitigation on other providers.
core/providers/utils/idle_timeout_reader_test.go Adds targeted race-detector tests for atomic close ownership, CAS serialization, and release-skip-when-claimed; verifies the three core invariants introduced by this PR.
framework/streaming/responses_test.go Adds tests for text delta concatenation, parallel tool-arg routing, reasoning summary accumulation, force-reap backstop idempotency, and a benchmark guarding O(n) vs O(n²) allocation behavior.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CG as Cancellation Goroutine
    participant IT as Idle-Timeout Timer
    participant RS as ReleaseStreamingResponse
    participant CAS as GetAndSetValue (CAS)
    participant FS as fasthttp requestStream

    note over CG,FS: All three racers compete for close ownership
    CG->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>CG: "prev=false, wins claim"
    IT->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>IT: "prev=true, skips"
    RS->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>RS: "prev=true, skips"
    CG->>FS: CloseWithError(ctx.Err()) [exactly once]
    note over CG,FS: After stream ends
    participant MC as TracingMiddleware TraceCompleter
    MC->>MC: CompleteAndFlushTrace(traceID)
    MC->>MC: ForceCleanupStreamAccumulator(traceID)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CG as Cancellation Goroutine
    participant IT as Idle-Timeout Timer
    participant RS as ReleaseStreamingResponse
    participant CAS as GetAndSetValue (CAS)
    participant FS as fasthttp requestStream

    note over CG,FS: All three racers compete for close ownership
    CG->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>CG: "prev=false, wins claim"
    IT->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>IT: "prev=true, skips"
    RS->>CAS: GetAndSetValue(ConnectionClosed, true)
    CAS-->>RS: "prev=true, skips"
    CG->>FS: CloseWithError(ctx.Err()) [exactly once]
    note over CG,FS: After stream ends
    participant MC as TracingMiddleware TraceCompleter
    MC->>MC: CompleteAndFlushTrace(traceID)
    MC->>MC: ForceCleanupStreamAccumulator(traceID)
Loading

Reviews (4): Last reviewed commit: "streaming memory improvements" | Re-trigger Greptile

@akshaydeo
akshaydeo force-pushed the 06-24-streaming_memory_improvements branch from 5dc6090 to 4304def Compare June 24, 2026 18:27
@akshaydeo
akshaydeo force-pushed the 06-24-streaming_memory_improvements branch from 4304def to 3fe2b41 Compare June 24, 2026 19:53
@akshaydeo
akshaydeo force-pushed the 06-24-streaming_memory_improvements branch from 3fe2b41 to 0d3ebf4 Compare June 24, 2026 21:45
@coderabbitai
coderabbitai Bot requested a review from roroghost17 June 24, 2026 21:47

akshaydeo commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Jun 25, 6:28 AM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Jun 25, 6:28 AM UTC: @akshaydeo merged this pull request with Graphite.

@akshaydeo
akshaydeo merged commit 35db449 into dev Jun 25, 2026
12 of 15 checks passed
@akshaydeo
akshaydeo deleted the 06-24-streaming_memory_improvements branch June 25, 2026 06:28
akhsaul pushed a commit to akhsaul/bifrost that referenced this pull request Aug 27, 2026
## Summary

This PR fixes two classes of bugs: a race condition that caused fasthttp's pooled `requestStream` to be double-released (producing `slice bounds out of range` panics on concurrent requests), and a stream accumulator memory leak where accumulators for aborted or non-cleanly-terminated streams were not freed until the TTL sweep. It also replaces O(n²) string concatenation in the Responses API stream builder with a `strings.Builder`-based accumulator pattern.

## Changes

- **Atomic stream close ownership (`utils.go`):** Replaced the racy `Value`-then-`SetValue` guard on `BifrostContextKeyConnectionClosed` with a single `GetAndSetValue` compare-and-swap in `SetupStreamCancellation`, `NewIdleTimeoutReader`, and `ReleaseStreamingResponse`. Previously, the cancellation goroutine, the idle-timeout timer, and `ReleaseStreamingResponse` could each observe `false` and all proceed to call `CloseWithError`, double-releasing the pooled fasthttp `requestStream` and corrupting a concurrent request's slice bounds. Now exactly one owner wins the claim and performs the close.

- **Removed re-panic in idle timeout reader (`utils.go`):** Dropped the unconditional `panic(recovered)` in `idleTimeoutReader.Read`'s recover block, which was re-panicking on legitimate post-close reads after the atomic-claim fix moved `r.fired.Store(true)` to the winning-owner path only.

- **Force-reap stream accumulator backstop (`accumulator.go`, `tracer.go`, `bifrost.go`):** Added `ForceCleanupStreamAccumulator` to the `Tracer` interface, `NoOpTracer`, `Accumulator`, and `Tracer` implementations. The stream's terminal lifecycle hook (the provider goroutine's `postHookSpanFinalizer`) now calls this after releasing the plugin pipeline, guaranteeing that accumulators for streams that end without a clean terminal chunk (client abort, broken SSE write, multi-plugin refcount imbalance) are freed immediately rather than waiting for the TTL sweep. The trace ID is captured as an immutable string before the pooled request object is recycled.

- **O(n²) → O(n) Responses stream builder (`responses.go`):** Replaced per-delta `*field += delta` string concatenation in `buildCompleteMessageFromResponsesStreamChunks` with `strings.Builder` accumulators keyed by message and content-block index. Builders are materialized into final strings once after the chunk walk. The five helper methods (`appendTextDeltaToResponsesMessage`, `appendRefusalDeltaToResponsesMessage`, `appendFunctionArgumentsDeltaToResponsesMessage`, `appendReasoningDeltaToResponsesMessage`, `appendReasoningSignatureToResponsesMessage`) are removed and their logic is inlined into the walk + materialization pass.

- **Tests (`idle_timeout_reader_test.go`, `responses_test.go`):** Added `TestStreamClose_ExactlyOnceAcrossOwners` (races the timer and cancellation goroutine 100 times, asserts `CloseWithError` fires exactly once), `TestConnectionClosedClaim_SerializesOwners` (64 concurrent goroutines, asserts exactly one wins), `TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed`, `TestBuildResponsesMessageConcatenatesTextDeltas`, `TestBuildResponsesMessageRoutesParallelToolArgs`, `TestBuildResponsesMessageAccumulatesReasoningSummary`, `TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount`, and `BenchmarkBuildResponsesMessageTextDeltas`.

- **UI formatting (`clientLayout.tsx`):** Reformatted from 2-space to tab indentation and collapsed some multi-line imports; no behavioral changes.

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI

## Affected areas

- [x] Core (Go)
- [x] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs

## How to test

```sh
# Run with race detector to exercise the atomic-claim fix
go test -race ./core/providers/utils/... -run TestStreamClose_ExactlyOnceAcrossOwners -count=10
go test -race ./core/providers/utils/... -run TestConnectionClosedClaim_SerializesOwners
go test -race ./core/providers/utils/... -run TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed

# Force-reap accumulator backstop
go test ./framework/streaming/... -run TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount -v

# Responses stream builder correctness and performance
go test ./framework/streaming/... -run TestBuildResponsesMessage
go test -bench=BenchmarkBuildResponsesMessageTextDeltas -benchmem ./framework/streaming/...

# Full suite
go test ./...

# UI
cd ui
pnpm i
pnpm build
```

## Breaking changes

- [x] No

The `Tracer` interface gains `ForceCleanupStreamAccumulator`. Any external implementations of `Tracer` must add this method. The `NoOpTracer` implementation is provided as a reference.

## Related issues

Fixes the `slice bounds out of range [:-680]` crash caused by double-release of fasthttp's pooled `requestStream`. Fixes stream accumulator leaks on client-aborted or non-cleanly-terminated streams.

## Security considerations

No auth, secrets, or PII implications. The fix closes a memory-corruption vector (double-pool-release) that could cause one request to alias another's in-flight data.

## Checklist

- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
occcat pushed a commit to occcat/bifrost that referenced this pull request Sep 2, 2026
## Summary

This PR fixes two classes of bugs: a race condition that caused fasthttp's pooled `requestStream` to be double-released (producing `slice bounds out of range` panics on concurrent requests), and a stream accumulator memory leak where accumulators for aborted or non-cleanly-terminated streams were not freed until the TTL sweep. It also replaces O(n²) string concatenation in the Responses API stream builder with a `strings.Builder`-based accumulator pattern.

## Changes

- **Atomic stream close ownership (`utils.go`):** Replaced the racy `Value`-then-`SetValue` guard on `BifrostContextKeyConnectionClosed` with a single `GetAndSetValue` compare-and-swap in `SetupStreamCancellation`, `NewIdleTimeoutReader`, and `ReleaseStreamingResponse`. Previously, the cancellation goroutine, the idle-timeout timer, and `ReleaseStreamingResponse` could each observe `false` and all proceed to call `CloseWithError`, double-releasing the pooled fasthttp `requestStream` and corrupting a concurrent request's slice bounds. Now exactly one owner wins the claim and performs the close.

- **Removed re-panic in idle timeout reader (`utils.go`):** Dropped the unconditional `panic(recovered)` in `idleTimeoutReader.Read`'s recover block, which was re-panicking on legitimate post-close reads after the atomic-claim fix moved `r.fired.Store(true)` to the winning-owner path only.

- **Force-reap stream accumulator backstop (`accumulator.go`, `tracer.go`, `bifrost.go`):** Added `ForceCleanupStreamAccumulator` to the `Tracer` interface, `NoOpTracer`, `Accumulator`, and `Tracer` implementations. The stream's terminal lifecycle hook (the provider goroutine's `postHookSpanFinalizer`) now calls this after releasing the plugin pipeline, guaranteeing that accumulators for streams that end without a clean terminal chunk (client abort, broken SSE write, multi-plugin refcount imbalance) are freed immediately rather than waiting for the TTL sweep. The trace ID is captured as an immutable string before the pooled request object is recycled.

- **O(n²) → O(n) Responses stream builder (`responses.go`):** Replaced per-delta `*field += delta` string concatenation in `buildCompleteMessageFromResponsesStreamChunks` with `strings.Builder` accumulators keyed by message and content-block index. Builders are materialized into final strings once after the chunk walk. The five helper methods (`appendTextDeltaToResponsesMessage`, `appendRefusalDeltaToResponsesMessage`, `appendFunctionArgumentsDeltaToResponsesMessage`, `appendReasoningDeltaToResponsesMessage`, `appendReasoningSignatureToResponsesMessage`) are removed and their logic is inlined into the walk + materialization pass.

- **Tests (`idle_timeout_reader_test.go`, `responses_test.go`):** Added `TestStreamClose_ExactlyOnceAcrossOwners` (races the timer and cancellation goroutine 100 times, asserts `CloseWithError` fires exactly once), `TestConnectionClosedClaim_SerializesOwners` (64 concurrent goroutines, asserts exactly one wins), `TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed`, `TestBuildResponsesMessageConcatenatesTextDeltas`, `TestBuildResponsesMessageRoutesParallelToolArgs`, `TestBuildResponsesMessageAccumulatesReasoningSummary`, `TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount`, and `BenchmarkBuildResponsesMessageTextDeltas`.

- **UI formatting (`clientLayout.tsx`):** Reformatted from 2-space to tab indentation and collapsed some multi-line imports; no behavioral changes.

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI

## Affected areas

- [x] Core (Go)
- [x] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs

## How to test

```sh
# Run with race detector to exercise the atomic-claim fix
go test -race ./core/providers/utils/... -run TestStreamClose_ExactlyOnceAcrossOwners -count=10
go test -race ./core/providers/utils/... -run TestConnectionClosedClaim_SerializesOwners
go test -race ./core/providers/utils/... -run TestReleaseStreamingResponse_SkipsWhenAlreadyClaimed

# Force-reap accumulator backstop
go test ./framework/streaming/... -run TestForceCleanupStreamAccumulatorReapsRegardlessOfRefcount -v

# Responses stream builder correctness and performance
go test ./framework/streaming/... -run TestBuildResponsesMessage
go test -bench=BenchmarkBuildResponsesMessageTextDeltas -benchmem ./framework/streaming/...

# Full suite
go test ./...

# UI
cd ui
pnpm i
pnpm build
```

## Breaking changes

- [x] No

The `Tracer` interface gains `ForceCleanupStreamAccumulator`. Any external implementations of `Tracer` must add this method. The `NoOpTracer` implementation is provided as a reference.

## Related issues

Fixes the `slice bounds out of range [:-680]` crash caused by double-release of fasthttp's pooled `requestStream`. Fixes stream accumulator leaks on client-aborted or non-cleanly-terminated streams.

## Security considerations

No auth, secrets, or PII implications. The fix closes a memory-corruption vector (double-pool-release) that could cause one request to alias another's in-flight data.

## Checklist

- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
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.

2 participants