Skip to content

fix(sandbox): stop splitting Cloudflare SSE events at chunk boundaries - #4215

Open
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/cloudflare-sse-chunk-boundary
Open

fix(sandbox): stop splitting Cloudflare SSE events at chunk boundaries#4215
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/cloudflare-sse-chunk-boundary

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

The Cloudflare Worker's server-sent event stream is split into lines by a hand-rolled decoder that classifies a chunk ending on CR immediately: it emits the line and keeps a bare CR as the buffer. A CR at the end of the available bytes cannot be classified yet, because the next chunk may begin with LF and CRLF is a single terminator. The stored CR is then read as the start of a new line, so the following LF produces a blank line, and a blank line dispatches an SSE event. One multi-line event arrives as several.

Transport chunk boundaries are arbitrary, so which events an application receives depended on how the response happened to be framed rather than on its content:

stream = "data: a\r\ndata: b\r\n\r\n"

one chunk        ['data: a', 'data: b', '']          -> [('message', 'a\nb')]
split on the CR  ['data: a', '', 'data: b', '', '']  -> [('message', 'a'), ('message', 'b')]

flush() had the matching problem and appended a blank line for a stream ending on CR. The CR-only and trailing-CR shapes were also wrong when decoded in a single call, independently of chunking.

The decoder is adapted from the httpx line decoder, which defers a trailing CR into the next iteration and documents that it matches str.splitlines(). This restores that behavior: keep the unterminated line, CR included, until the next chunk decides, and let flush close the pending line instead of adding a blank one.

Measured against str.splitlines() as the oracle:

before after
stream shapes disagreeing at some chunk size 6 of 7 0 of 7
randomized streams whose events changed with chunking 191 of 400 0 of 400

Test plan

The decoders had no tests, which is how this survived. Added to tests/extensions/sandbox/test_cloudflare.py:

  • test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary, parametrized over LF, CRLF, CR, mixed, empty-data, trailing-CR and bare-CRLF streams, asserting agreement with str.splitlines() at every chunk size from 1 to the full length.
  • test_sse_event_is_not_split_when_crlf_straddles_a_chunk_boundary, asserting the decoded events are identical for every chunk size.
  • test_sse_line_decoder_flush_does_not_emit_a_phantom_line_for_a_trailing_cr.

8 fail on main and pass with the fix. The LF-only case passes in both runs, which is the control confirming the tests are not simply rejecting the decoder wholesale:

# main
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[crlf]
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[cr]
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[mixed]
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[empty_data]
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[trailing_cr]
FAILED ...test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary[bare_crlf]
FAILED ...test_sse_event_is_not_split_when_crlf_straddles_a_chunk_boundary
FAILED ...test_sse_line_decoder_flush_does_not_emit_a_phantom_line_for_a_trailing_cr
8 failed, 3 passed, 68 deselected

Verification from the repository root:

Command Result
make format clean
make lint all checks passed
make mypy 5 errors, all pre-existing on main, none in the touched files
make pyright 1 error, pre-existing on main (src/agents/sandbox/util/tar_utils.py:161)
uv run pytest tests/extensions/sandbox/test_cloudflare.py 79 passed
make tests 5827 passed

The full suite run was done on Windows, where some sandbox symlink and tracing timing tests fail independently of this change. I diffed the failing set against a clean main checkout in the same environment and the two sets are identical, 54 versus 54, with no differences in either direction.

Issue number

Closes #4214

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script is a bash script that shells out to make. I ran the underlying steps individually instead, with the results above.

The Cloudflare line decoder classified a chunk that ends on CR immediately: it
emitted the line and kept a bare CR as the buffer. A CR at the end of the
available bytes cannot be classified yet, because the next chunk may start with
LF and CRLF is a single terminator. The held CR was then read as the start of a
new line, so the following LF produced a blank line.

A blank line dispatches an SSE event, so one multi-line event arrived as
several. Transport chunk boundaries are arbitrary, so this depended on how the
Worker response happened to be framed rather than on its content. flush() had
the matching problem and appended a blank line for a stream ending on CR, and
the CR-only and trailing-CR shapes were wrong even unchunked.

Hold the unterminated line, CR included, until the next chunk decides, matching
the httpx decoder this was adapted from and str.splitlines(). Before the change
six of seven stream shapes disagreed with splitlines and 191 of 400 randomized
chunkings changed the events produced; both are now zero.
Copilot AI review requested due to automatic review settings August 5, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

Cloudflare sandbox splits SSE events when a chunk boundary falls on a CR

2 participants