Skip to content

fix(transports): drop trailing blank line from SSE heartbeat frame - #5883

Merged
akshaydeo merged 1 commit into
maximhq:devfrom
jeremym-tanium:fix/sse-heartbeat-frame
Aug 6, 2026
Merged

fix(transports): drop trailing blank line from SSE heartbeat frame#5883
akshaydeo merged 1 commit into
maximhq:devfrom
jeremym-tanium:fix/sse-heartbeat-frame

Conversation

@jeremym-tanium

@jeremym-tanium jeremym-tanium commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

The SSE heartbeat introduced in #5850 is emitted as ": heartbeat\n\n". The trailing blank line is the SSE event-dispatch signal, and some widely-deployed decoders dispatch an empty event on it: openai-go's ssestream (before v3.43.0, fixed in openai/openai-go#621) falls into its untyped branch, calls json.Unmarshal on the empty payload, and aborts the stream with unexpected end of JSON input. Net effect: for those consumers, any streaming request outliving the 1s heartbeat interval dies at the first heartbeat. Fast streams are unaffected, which makes this easy to miss.

Changes

  • lib/streamreader.go: sseHeartbeatFrame drops its trailing blank line (": heartbeat\n"). A bare comment line accumulates nothing and dispatches nothing in every decoder — conforming or not — while still being a real write on the socket, which is all the disconnect probe (and intermediary idle timers, per [Feature]: Server-side SSE keepalive (comment heartbeat) to keep long-idle streams alive through intermediaries #5010's original goal) needs. Doc comment explains why the blank line must not come back.
  • lib/streamreader_test.go: TestSSEStreamReaderSendHeartbeat now pins the frame as a single newline-terminated comment line with no blank line.
  • integrations/router_heartbeat_test.go: asserts the heartbeat appears during idle gaps and never carries a blank line.
  • transports/changelog.md: entry under Fixed + closed-issue link.

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

cd transports
go test ./bifrost-http/lib/... -count=1
go test ./bifrost-http/integrations/ -run 'Heartbeat|heartbeat' -count=1

Expected: all pass. TestSSEStreamReaderSendHeartbeat fails on the old frame; Test_handleStreamingSSESendsHeartbeatDuringIdleGap confirms heartbeats still flow during idle gaps.

To reproduce the original failure end-to-end: run bifrost-http at transports/v1.6.8 with any streaming provider and issue a streaming request lasting more than ~1s using openai-go < v3.43.0 (e.g. v3.15.0); the stream aborts at the first heartbeat with unexpected end of JSON input. With this fix, the same client streams to completion (verified with both openai-go v3.15.0 and v3.43.0).

No new configs or environment variables.

Breaking changes

  • Yes
  • No

Related issues

Fixes #5874

Security considerations

None — one byte removed from a server-emitted SSE comment frame.

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

@coderabbitai

coderabbitai Bot commented Aug 5, 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: fa6b5082-9302-4faa-a813-3bd81751398a

📥 Commits

Reviewing files that changed from the base of the PR and between 041817a and 1f89af6.

📒 Files selected for processing (4)
  • transports/bifrost-http/integrations/router_heartbeat_test.go
  • transports/bifrost-http/lib/streamreader.go
  • transports/bifrost-http/lib/streamreader_test.go
  • transports/changelog.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • transports/bifrost-http/integrations/router_heartbeat_test.go
  • transports/changelog.md
  • transports/bifrost-http/lib/streamreader_test.go
  • transports/bifrost-http/lib/streamreader.go

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved SSE heartbeat compatibility with older clients.
    • Prevented heartbeat frames from being interpreted as empty events or causing streams to abort.
  • Documentation

    • Added a changelog entry describing the SSE compatibility fix.

Walkthrough

The SSE heartbeat frame changed from ": heartbeat\n\n" to ": heartbeat\n". Tests enforce the exact format, and the changelog documents compatibility with older openai-go SSE decoders.

Changes

SSE heartbeat compatibility

Layer / File(s) Summary
Update heartbeat frame
transports/bifrost-http/lib/streamreader.go
The stream reader emits a single newline-terminated SSE comment without a trailing blank line.
Validate heartbeat framing
transports/bifrost-http/integrations/router_heartbeat_test.go, transports/bifrost-http/lib/streamreader_test.go, transports/changelog.md
Tests require the revised heartbeat format. The changelog records the decoder compatibility issue and reference.

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

Possibly related issues

  • maximhq/bifrost issue 5874 — Tracks the SSE heartbeat framing change implemented here.

Possibly related PRs

Suggested reviewers: akshaydeo, tejasghatte, roroghost17

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary SSE heartbeat fix.
Description check ✅ Passed The description covers the problem, changes, testing, affected areas, breaking changes, issue, security, and checklist requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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.

@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: 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 `@transports/bifrost-http/lib/streamreader_test.go`:
- Line 6: Update the heartbeat assertion in the stream reader tests to require
the exact frame value ": heartbeat\n" rather than accepting strings with
additional content. Remove the strings import if it is no longer used elsewhere
in the test file.
🪄 Autofix

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: aace9043-9aeb-4e52-8fdf-ab6c56651096

📥 Commits

Reviewing files that changed from the base of the PR and between dafc587 and b5fb10e.

📒 Files selected for processing (4)
  • transports/bifrost-http/integrations/router_heartbeat_test.go
  • transports/bifrost-http/lib/streamreader.go
  • transports/bifrost-http/lib/streamreader_test.go
  • transports/changelog.md

Comment thread transports/bifrost-http/lib/streamreader_test.go Outdated
The heartbeat's trailing blank line is the SSE event-dispatch signal.
Non-conforming decoders (e.g. openai-go ssestream before v3.43.0,
fixed in openai/openai-go#621) dispatch an empty event on it, fail to
unmarshal the empty payload, and abort the stream with "unexpected end
of JSON input" -- so any stream outliving the 1s heartbeat interval
died at the first heartbeat for those consumers.

A bare comment line accumulates nothing and dispatches nothing in every
decoder, while still being a real write on the socket, which is all the
disconnect probe and intermediary idle timers need.

Fixes maximhq#5874
@jeremym-tanium
jeremym-tanium force-pushed the fix/sse-heartbeat-frame branch from b5fb10e to 1f89af6 Compare August 6, 2026 14:34
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@akshaydeo
akshaydeo merged commit f3f3b8e into maximhq:dev Aug 6, 2026
5 checks passed
akshaydeo added a commit that referenced this pull request Aug 7, 2026
## Summary

Fixes #5905: SSE heartbeat frames injected mid-line corrupt the `data:` payload on passthrough streams. When Bifrost forwards raw upstream bytes (e.g. via `StreamPassthrough`, which reads into a fixed 4096-byte buffer), chunks are arbitrary TCP slices and are not guaranteed to end on an SSE line boundary. A heartbeat tick landing between two such chunks splices `: heartbeat\n` into the middle of a half-written `data:` line. Per the SSE spec, a comment is only a comment when the colon is the **first character of a line**; spliced mid-line, the `\n` terminates the `data:` line early and the JSON remainder becomes an unrecognized field that decoders silently discard, producing truncated JSON and a `JSONDecodeError` at the client.

No heartbeat frame shape can fix this — the reader must refuse to emit a heartbeat unless it is at a line boundary.

closes #5905

## Changes

- **`SSEStreamReader`** **now tracks** **`atLineBoundary`**: a mutex-guarded boolean, initialized `true`, updated on every `Send` call based on whether the last byte written was `\n`. The mutex makes the "check position, then write" pair atomic against the concurrent heartbeat goroutine — an atomic flag alone is insufficient because the producer can enqueue a partial line between the check and the write.
- **`Send`** **is split into** **`Send`** **(acquires lock) and** **`sendLocked`** **(body)**: `SendHeartbeat` calls `sendLocked` without releasing the lock between the boundary check and the write, closing the race window that caused #5905.
- **`SendHeartbeat`** **skips emission when mid-line, but still returns** **`true`**: a skipped heartbeat is not a disconnect. Returning `false` would cause `StartSSEHeartbeat` to invoke `onDisconnect` and cancel a healthy stream. A real disconnect (`closeCh` closed) returns `false` even when mid-line, preserving the proactive disconnect detection from #5010.
- **`mcpserver.go`** **switches from a hand-rolled** **`": ping\n\n"`** **byte slice to** **`reader.SendHeartbeat()`**: the old local frame bypassed the line-boundary gate and carried the trailing blank line that #5883 removed (some decoders dispatch it as an empty event).
- **Investigate-issue skill updated**: regression tests are now written and confirmed red _before_ the plan is presented to the user, rather than after approval. The checklist, approval gate wording, and Step 7 flow are updated accordingly. A new "Regression Rerun Scope" section (Step 5e) requires coverage-attributed test tiers rather than guessed reruns.

## Type of change

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

## Affected areas

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

## How to test

```sh
cd transports/bifrost-http
go test ./lib/... -run TestSSEStreamReaderHeartbeat -race -v
go test ./lib/... -run TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits -race -count=5
go test ./lib/... -race
```

Key tests added:

- `TestSSEStreamReaderHeartbeatNeverSplitsDataLine` — deterministic interleaving: heartbeat tick lands exactly between two chunks of a split `data:` line; asserts structural integrity, byte-for-byte forwarding, and JSON parseability.
- `TestSSEStreamReaderHeartbeatStillSentAtLineBoundary` — guards against over-correction by verifying heartbeats are still emitted on the typed paths (where every `Send` ends in `\n\n`).
- `TestSSEStreamReaderHeartbeatBoundaryMatrix` — exhaustive matrix of write endings vs. expected `atLineBoundary` state.
- `TestSSEStreamReaderWrapperMethodsLeaveBoundary` — all high-level wrapper methods (`SendEvent`, `SendError`, `SendDone`, `SendHeartbeat`) must leave the stream at a boundary.
- `TestSSEStreamReaderClosedMidLineReportsDisconnect` — disconnect outranks mid-line skip.
- `TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits` — full-fidelity race test with a live `StartSSEHeartbeat` goroutine and co-prime chunk sizes; run under `-race`.
- `TestSSEStreamReaderConcurrentProducersWithHeartbeat` — multiple concurrent producers plus a live heartbeat goroutine; asserts no mid-line splice and no deadlock under `-race`.
- `TestSSEStreamReaderSkippedHeartbeatIsNotDisconnect` — mid-line skip must return `true`.

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

Closes #5905. Complements #5883 (trailing blank line removal) and #5010 (proactive disconnect detection).

## Security considerations

None. This change affects SSE framing only; no auth, secrets, or PII are involved.

## Checklist

- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
akshaydeo pushed a commit that referenced this pull request Aug 7, 2026
…5883)

The heartbeat's trailing blank line is the SSE event-dispatch signal.
Non-conforming decoders (e.g. openai-go ssestream before v3.43.0,
fixed in openai/openai-go#621) dispatch an empty event on it, fail to
unmarshal the empty payload, and abort the stream with "unexpected end
of JSON input" -- so any stream outliving the 1s heartbeat interval
died at the first heartbeat for those consumers.

A bare comment line accumulates nothing and dispatches nothing in every
decoder, while still being a real write on the socket, which is all the
disconnect probe and intermediary idle timers need.

Fixes #5874
akshaydeo added a commit that referenced this pull request Aug 7, 2026
## Summary

Fixes #5905: SSE heartbeat frames injected mid-line corrupt the `data:` payload on passthrough streams. When Bifrost forwards raw upstream bytes (e.g. via `StreamPassthrough`, which reads into a fixed 4096-byte buffer), chunks are arbitrary TCP slices and are not guaranteed to end on an SSE line boundary. A heartbeat tick landing between two such chunks splices `: heartbeat\n` into the middle of a half-written `data:` line. Per the SSE spec, a comment is only a comment when the colon is the **first character of a line**; spliced mid-line, the `\n` terminates the `data:` line early and the JSON remainder becomes an unrecognized field that decoders silently discard, producing truncated JSON and a `JSONDecodeError` at the client.

No heartbeat frame shape can fix this — the reader must refuse to emit a heartbeat unless it is at a line boundary.

closes #5905

## Changes

- **`SSEStreamReader`** **now tracks** **`atLineBoundary`**: a mutex-guarded boolean, initialized `true`, updated on every `Send` call based on whether the last byte written was `\n`. The mutex makes the "check position, then write" pair atomic against the concurrent heartbeat goroutine — an atomic flag alone is insufficient because the producer can enqueue a partial line between the check and the write.
- **`Send`** **is split into** **`Send`** **(acquires lock) and** **`sendLocked`** **(body)**: `SendHeartbeat` calls `sendLocked` without releasing the lock between the boundary check and the write, closing the race window that caused #5905.
- **`SendHeartbeat`** **skips emission when mid-line, but still returns** **`true`**: a skipped heartbeat is not a disconnect. Returning `false` would cause `StartSSEHeartbeat` to invoke `onDisconnect` and cancel a healthy stream. A real disconnect (`closeCh` closed) returns `false` even when mid-line, preserving the proactive disconnect detection from #5010.
- **`mcpserver.go`** **switches from a hand-rolled** **`": ping\n\n"`** **byte slice to** **`reader.SendHeartbeat()`**: the old local frame bypassed the line-boundary gate and carried the trailing blank line that #5883 removed (some decoders dispatch it as an empty event).
- **Investigate-issue skill updated**: regression tests are now written and confirmed red _before_ the plan is presented to the user, rather than after approval. The checklist, approval gate wording, and Step 7 flow are updated accordingly. A new "Regression Rerun Scope" section (Step 5e) requires coverage-attributed test tiers rather than guessed reruns.

## Type of change

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

## Affected areas

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

## How to test

```sh
cd transports/bifrost-http
go test ./lib/... -run TestSSEStreamReaderHeartbeat -race -v
go test ./lib/... -run TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits -race -count=5
go test ./lib/... -race
```

Key tests added:

- `TestSSEStreamReaderHeartbeatNeverSplitsDataLine` — deterministic interleaving: heartbeat tick lands exactly between two chunks of a split `data:` line; asserts structural integrity, byte-for-byte forwarding, and JSON parseability.
- `TestSSEStreamReaderHeartbeatStillSentAtLineBoundary` — guards against over-correction by verifying heartbeats are still emitted on the typed paths (where every `Send` ends in `\n\n`).
- `TestSSEStreamReaderHeartbeatBoundaryMatrix` — exhaustive matrix of write endings vs. expected `atLineBoundary` state.
- `TestSSEStreamReaderWrapperMethodsLeaveBoundary` — all high-level wrapper methods (`SendEvent`, `SendError`, `SendDone`, `SendHeartbeat`) must leave the stream at a boundary.
- `TestSSEStreamReaderClosedMidLineReportsDisconnect` — disconnect outranks mid-line skip.
- `TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits` — full-fidelity race test with a live `StartSSEHeartbeat` goroutine and co-prime chunk sizes; run under `-race`.
- `TestSSEStreamReaderConcurrentProducersWithHeartbeat` — multiple concurrent producers plus a live heartbeat goroutine; asserts no mid-line splice and no deadlock under `-race`.
- `TestSSEStreamReaderSkippedHeartbeatIsNotDisconnect` — mid-line skip must return `true`.

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

Closes #5905. Complements #5883 (trailing blank line removal) and #5010 (proactive disconnect detection).

## Security considerations

None. This change affects SSE framing only; no auth, secrets, or PII are involved.

## Checklist

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

The heartbeat's trailing blank line is the SSE event-dispatch signal.
Non-conforming decoders (e.g. openai-go ssestream before v3.43.0,
fixed in openai/openai-go#621) dispatch an empty event on it, fail to
unmarshal the empty payload, and abort the stream with "unexpected end
of JSON input" -- so any stream outliving the 1s heartbeat interval
died at the first heartbeat for those consumers.

A bare comment line accumulates nothing and dispatches nothing in every
decoder, while still being a real write on the socket, which is all the
disconnect probe and intermediary idle timers need.

Fixes maximhq#5874
atharvamhaske pushed a commit to atharvamhaske/bifrost that referenced this pull request Aug 13, 2026
## Summary

Fixes maximhq#5905: SSE heartbeat frames injected mid-line corrupt the `data:` payload on passthrough streams. When Bifrost forwards raw upstream bytes (e.g. via `StreamPassthrough`, which reads into a fixed 4096-byte buffer), chunks are arbitrary TCP slices and are not guaranteed to end on an SSE line boundary. A heartbeat tick landing between two such chunks splices `: heartbeat\n` into the middle of a half-written `data:` line. Per the SSE spec, a comment is only a comment when the colon is the **first character of a line**; spliced mid-line, the `\n` terminates the `data:` line early and the JSON remainder becomes an unrecognized field that decoders silently discard, producing truncated JSON and a `JSONDecodeError` at the client.

No heartbeat frame shape can fix this — the reader must refuse to emit a heartbeat unless it is at a line boundary.

closes maximhq#5905

## Changes

- **`SSEStreamReader`** **now tracks** **`atLineBoundary`**: a mutex-guarded boolean, initialized `true`, updated on every `Send` call based on whether the last byte written was `\n`. The mutex makes the "check position, then write" pair atomic against the concurrent heartbeat goroutine — an atomic flag alone is insufficient because the producer can enqueue a partial line between the check and the write.
- **`Send`** **is split into** **`Send`** **(acquires lock) and** **`sendLocked`** **(body)**: `SendHeartbeat` calls `sendLocked` without releasing the lock between the boundary check and the write, closing the race window that caused maximhq#5905.
- **`SendHeartbeat`** **skips emission when mid-line, but still returns** **`true`**: a skipped heartbeat is not a disconnect. Returning `false` would cause `StartSSEHeartbeat` to invoke `onDisconnect` and cancel a healthy stream. A real disconnect (`closeCh` closed) returns `false` even when mid-line, preserving the proactive disconnect detection from maximhq#5010.
- **`mcpserver.go`** **switches from a hand-rolled** **`": ping\n\n"`** **byte slice to** **`reader.SendHeartbeat()`**: the old local frame bypassed the line-boundary gate and carried the trailing blank line that maximhq#5883 removed (some decoders dispatch it as an empty event).
- **Investigate-issue skill updated**: regression tests are now written and confirmed red _before_ the plan is presented to the user, rather than after approval. The checklist, approval gate wording, and Step 7 flow are updated accordingly. A new "Regression Rerun Scope" section (Step 5e) requires coverage-attributed test tiers rather than guessed reruns.

## Type of change

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

## Affected areas

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

## How to test

```sh
cd transports/bifrost-http
go test ./lib/... -run TestSSEStreamReaderHeartbeat -race -v
go test ./lib/... -run TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits -race -count=5
go test ./lib/... -race
```

Key tests added:

- `TestSSEStreamReaderHeartbeatNeverSplitsDataLine` — deterministic interleaving: heartbeat tick lands exactly between two chunks of a split `data:` line; asserts structural integrity, byte-for-byte forwarding, and JSON parseability.
- `TestSSEStreamReaderHeartbeatStillSentAtLineBoundary` — guards against over-correction by verifying heartbeats are still emitted on the typed paths (where every `Send` ends in `\n\n`).
- `TestSSEStreamReaderHeartbeatBoundaryMatrix` — exhaustive matrix of write endings vs. expected `atLineBoundary` state.
- `TestSSEStreamReaderWrapperMethodsLeaveBoundary` — all high-level wrapper methods (`SendEvent`, `SendError`, `SendDone`, `SendHeartbeat`) must leave the stream at a boundary.
- `TestSSEStreamReaderClosedMidLineReportsDisconnect` — disconnect outranks mid-line skip.
- `TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits` — full-fidelity race test with a live `StartSSEHeartbeat` goroutine and co-prime chunk sizes; run under `-race`.
- `TestSSEStreamReaderConcurrentProducersWithHeartbeat` — multiple concurrent producers plus a live heartbeat goroutine; asserts no mid-line splice and no deadlock under `-race`.
- `TestSSEStreamReaderSkippedHeartbeatIsNotDisconnect` — mid-line skip must return `true`.

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

Closes maximhq#5905. Complements maximhq#5883 (trailing blank line removal) and maximhq#5010 (proactive disconnect detection).

## Security considerations

None. This change affects SSE framing only; no auth, secrets, or PII are involved.

## Checklist

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

The heartbeat's trailing blank line is the SSE event-dispatch signal.
Non-conforming decoders (e.g. openai-go ssestream before v3.43.0,
fixed in openai/openai-go#621) dispatch an empty event on it, fail to
unmarshal the empty payload, and abort the stream with "unexpected end
of JSON input" -- so any stream outliving the 1s heartbeat interval
died at the first heartbeat for those consumers.

A bare comment line accumulates nothing and dispatches nothing in every
decoder, while still being a real write on the socket, which is all the
disconnect probe and intermediary idle timers need.

Fixes maximhq#5874
akhsaul pushed a commit to akhsaul/bifrost that referenced this pull request Aug 27, 2026
## Summary

Fixes maximhq#5905: SSE heartbeat frames injected mid-line corrupt the `data:` payload on passthrough streams. When Bifrost forwards raw upstream bytes (e.g. via `StreamPassthrough`, which reads into a fixed 4096-byte buffer), chunks are arbitrary TCP slices and are not guaranteed to end on an SSE line boundary. A heartbeat tick landing between two such chunks splices `: heartbeat\n` into the middle of a half-written `data:` line. Per the SSE spec, a comment is only a comment when the colon is the **first character of a line**; spliced mid-line, the `\n` terminates the `data:` line early and the JSON remainder becomes an unrecognized field that decoders silently discard, producing truncated JSON and a `JSONDecodeError` at the client.

No heartbeat frame shape can fix this — the reader must refuse to emit a heartbeat unless it is at a line boundary.

closes maximhq#5905

## Changes

- **`SSEStreamReader`** **now tracks** **`atLineBoundary`**: a mutex-guarded boolean, initialized `true`, updated on every `Send` call based on whether the last byte written was `\n`. The mutex makes the "check position, then write" pair atomic against the concurrent heartbeat goroutine — an atomic flag alone is insufficient because the producer can enqueue a partial line between the check and the write.
- **`Send`** **is split into** **`Send`** **(acquires lock) and** **`sendLocked`** **(body)**: `SendHeartbeat` calls `sendLocked` without releasing the lock between the boundary check and the write, closing the race window that caused maximhq#5905.
- **`SendHeartbeat`** **skips emission when mid-line, but still returns** **`true`**: a skipped heartbeat is not a disconnect. Returning `false` would cause `StartSSEHeartbeat` to invoke `onDisconnect` and cancel a healthy stream. A real disconnect (`closeCh` closed) returns `false` even when mid-line, preserving the proactive disconnect detection from maximhq#5010.
- **`mcpserver.go`** **switches from a hand-rolled** **`": ping\n\n"`** **byte slice to** **`reader.SendHeartbeat()`**: the old local frame bypassed the line-boundary gate and carried the trailing blank line that maximhq#5883 removed (some decoders dispatch it as an empty event).
- **Investigate-issue skill updated**: regression tests are now written and confirmed red _before_ the plan is presented to the user, rather than after approval. The checklist, approval gate wording, and Step 7 flow are updated accordingly. A new "Regression Rerun Scope" section (Step 5e) requires coverage-attributed test tiers rather than guessed reruns.

## Type of change

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

## Affected areas

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

## How to test

```sh
cd transports/bifrost-http
go test ./lib/... -run TestSSEStreamReaderHeartbeat -race -v
go test ./lib/... -run TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits -race -count=5
go test ./lib/... -race
```

Key tests added:

- `TestSSEStreamReaderHeartbeatNeverSplitsDataLine` — deterministic interleaving: heartbeat tick lands exactly between two chunks of a split `data:` line; asserts structural integrity, byte-for-byte forwarding, and JSON parseability.
- `TestSSEStreamReaderHeartbeatStillSentAtLineBoundary` — guards against over-correction by verifying heartbeats are still emitted on the typed paths (where every `Send` ends in `\n\n`).
- `TestSSEStreamReaderHeartbeatBoundaryMatrix` — exhaustive matrix of write endings vs. expected `atLineBoundary` state.
- `TestSSEStreamReaderWrapperMethodsLeaveBoundary` — all high-level wrapper methods (`SendEvent`, `SendError`, `SendDone`, `SendHeartbeat`) must leave the stream at a boundary.
- `TestSSEStreamReaderClosedMidLineReportsDisconnect` — disconnect outranks mid-line skip.
- `TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits` — full-fidelity race test with a live `StartSSEHeartbeat` goroutine and co-prime chunk sizes; run under `-race`.
- `TestSSEStreamReaderConcurrentProducersWithHeartbeat` — multiple concurrent producers plus a live heartbeat goroutine; asserts no mid-line splice and no deadlock under `-race`.
- `TestSSEStreamReaderSkippedHeartbeatIsNotDisconnect` — mid-line skip must return `true`.

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

Closes maximhq#5905. Complements maximhq#5883 (trailing blank line removal) and maximhq#5010 (proactive disconnect detection).

## Security considerations

None. This change affects SSE framing only; no auth, secrets, or PII are involved.

## Checklist

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

The heartbeat's trailing blank line is the SSE event-dispatch signal.
Non-conforming decoders (e.g. openai-go ssestream before v3.43.0,
fixed in openai/openai-go#621) dispatch an empty event on it, fail to
unmarshal the empty payload, and abort the stream with "unexpected end
of JSON input" -- so any stream outliving the 1s heartbeat interval
died at the first heartbeat for those consumers.

A bare comment line accumulates nothing and dispatches nothing in every
decoder, while still being a real write on the socket, which is all the
disconnect probe and intermediary idle timers need.

Fixes maximhq#5874
occcat pushed a commit to occcat/bifrost that referenced this pull request Sep 2, 2026
## Summary

Fixes maximhq#5905: SSE heartbeat frames injected mid-line corrupt the `data:` payload on passthrough streams. When Bifrost forwards raw upstream bytes (e.g. via `StreamPassthrough`, which reads into a fixed 4096-byte buffer), chunks are arbitrary TCP slices and are not guaranteed to end on an SSE line boundary. A heartbeat tick landing between two such chunks splices `: heartbeat\n` into the middle of a half-written `data:` line. Per the SSE spec, a comment is only a comment when the colon is the **first character of a line**; spliced mid-line, the `\n` terminates the `data:` line early and the JSON remainder becomes an unrecognized field that decoders silently discard, producing truncated JSON and a `JSONDecodeError` at the client.

No heartbeat frame shape can fix this — the reader must refuse to emit a heartbeat unless it is at a line boundary.

closes maximhq#5905

## Changes

- **`SSEStreamReader`** **now tracks** **`atLineBoundary`**: a mutex-guarded boolean, initialized `true`, updated on every `Send` call based on whether the last byte written was `\n`. The mutex makes the "check position, then write" pair atomic against the concurrent heartbeat goroutine — an atomic flag alone is insufficient because the producer can enqueue a partial line between the check and the write.
- **`Send`** **is split into** **`Send`** **(acquires lock) and** **`sendLocked`** **(body)**: `SendHeartbeat` calls `sendLocked` without releasing the lock between the boundary check and the write, closing the race window that caused maximhq#5905.
- **`SendHeartbeat`** **skips emission when mid-line, but still returns** **`true`**: a skipped heartbeat is not a disconnect. Returning `false` would cause `StartSSEHeartbeat` to invoke `onDisconnect` and cancel a healthy stream. A real disconnect (`closeCh` closed) returns `false` even when mid-line, preserving the proactive disconnect detection from maximhq#5010.
- **`mcpserver.go`** **switches from a hand-rolled** **`": ping\n\n"`** **byte slice to** **`reader.SendHeartbeat()`**: the old local frame bypassed the line-boundary gate and carried the trailing blank line that maximhq#5883 removed (some decoders dispatch it as an empty event).
- **Investigate-issue skill updated**: regression tests are now written and confirmed red _before_ the plan is presented to the user, rather than after approval. The checklist, approval gate wording, and Step 7 flow are updated accordingly. A new "Regression Rerun Scope" section (Step 5e) requires coverage-attributed test tiers rather than guessed reruns.

## Type of change

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

## Affected areas

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

## How to test

```sh
cd transports/bifrost-http
go test ./lib/... -run TestSSEStreamReaderHeartbeat -race -v
go test ./lib/... -run TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits -race -count=5
go test ./lib/... -race
```

Key tests added:

- `TestSSEStreamReaderHeartbeatNeverSplitsDataLine` — deterministic interleaving: heartbeat tick lands exactly between two chunks of a split `data:` line; asserts structural integrity, byte-for-byte forwarding, and JSON parseability.
- `TestSSEStreamReaderHeartbeatStillSentAtLineBoundary` — guards against over-correction by verifying heartbeats are still emitted on the typed paths (where every `Send` ends in `\n\n`).
- `TestSSEStreamReaderHeartbeatBoundaryMatrix` — exhaustive matrix of write endings vs. expected `atLineBoundary` state.
- `TestSSEStreamReaderWrapperMethodsLeaveBoundary` — all high-level wrapper methods (`SendEvent`, `SendError`, `SendDone`, `SendHeartbeat`) must leave the stream at a boundary.
- `TestSSEStreamReaderClosedMidLineReportsDisconnect` — disconnect outranks mid-line skip.
- `TestSSEStreamReaderHeartbeatRaceAcrossRandomSplits` — full-fidelity race test with a live `StartSSEHeartbeat` goroutine and co-prime chunk sizes; run under `-race`.
- `TestSSEStreamReaderConcurrentProducersWithHeartbeat` — multiple concurrent producers plus a live heartbeat goroutine; asserts no mid-line splice and no deadlock under `-race`.
- `TestSSEStreamReaderSkippedHeartbeatIsNotDisconnect` — mid-line skip must return `true`.

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

Closes maximhq#5905. Complements maximhq#5883 (trailing blank line removal) and maximhq#5010 (proactive disconnect detection).

## Security considerations

None. This change affects SSE framing only; no auth, secrets, or PII are involved.

## Checklist

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

[Bug]: SSE heartbeat frame aborts streams for openai-go ssestream consumers (< v3.43.0) with "unexpected end of JSON input"

2 participants