Skip to content

fix(runtime): don't truncate the SSE stream on tool results >64 KiB - #3883

Open
EronWright wants to merge 1 commit into
docker:mainfrom
EronWright:contrib/sse-large-events
Open

fix(runtime): don't truncate the SSE stream on tool results >64 KiB#3883
EronWright wants to merge 1 commit into
docker:mainfrom
EronWright:contrib/sse-large-events

Conversation

@EronWright

Copy link
Copy Markdown
Contributor

fix(runtime): don't truncate the SSE stream on tool results >64 KiB

Problem

The remote-runtime SSE reader (pkg/runtime/client.go) uses bufio.NewScanner
with the default 64 KiB token cap (bufio.MaxScanTokenSize). Each server event is
delivered as one data: {json} line, so a large tool result — e.g. a big
tool_call_response — overflows it: scanner.Scan() trips bufio.ErrTooLong, the
loop ends, and the run appears to stop silently right after the last event that
fit. The scanner error was discarded, leaving no trace.

Fix

In both SSE readers: raise the buffer ceiling (maxSSELineBytes, added to
pkg/runtime/defaults.go) and emit an Error event on scanner.Err() instead of
closing the channel without a word.

Impact

Any agent that returns a tool response larger than 64 KiB over a remote runtime —
today that run vanishes with no error.

Test

go build ./pkg/runtime/ && go test ./pkg/runtime/. Reproduced with a ~78 KB
tool_call_response that previously cut the stream mid-run; it now streams to
completion.

The client SSE reader used bufio.Scanner with the default 64KiB token cap
(bufio.MaxScanTokenSize). Each agent event is delivered as one `data: {json}`
line, and a large tool result (e.g. a big tool_call_response) exceeds that,
so scanner.Scan() trips bufio.ErrTooLong, the loop ends, and the run appears
to stop silently after the preceding event. The scanner error was also
swallowed, leaving no trace.

Raise the reader's buffer ceiling and surface any read error as an Error
event instead of closing the stream without a word.
@EronWright
EronWright requested a review from a team as a code owner August 2, 2026 00:07
@aheritier aheritier added area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Aug 2, 2026
@aheritier

Copy link
Copy Markdown
Collaborator

LGTM

@aheritier
aheritier requested a review from docker-agent August 4, 2026 07:10

@docker-agent docker-agent 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.

Assessment: 🟢 APPROVE

The fix correctly raises the bufio.Scanner cap from 64 KiB to 16 MiB and surfaces scanner errors as Error events instead of silently closing the stream. One low-severity concern noted inline.

Comment thread pkg/runtime/client.go
// error after the last event that fit.
if err := scanner.Err(); err != nil {
slog.DebugContext(ctx, "event", "scanner_error", err)
eventChan <- Error(fmt.Sprintf("reading event stream: %v", err))

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.

[low] Blocking eventChan <- Error(...) send after scanner error can leak the goroutine if the consumer has already exited

After scanner.Scan() returns due to an error (including context.Canceled from HTTP-request cancellation), the new code sends one Error event on eventChan before the deferred close(eventChan) runs. The send is a plain blocking operation on a buffered channel (capacity 128). If the consumer (for streamEvent := range streamChan) has already stopped draining the channel — for example because its own context was cancelled and it returned before seeing this final event — the goroutine parks on the blocked send indefinitely, defer close(eventChan) never executes, and the goroutine leaks.

The same risk already exists for every eventChan <- e inside the for scanner.Scan() loop, so this PR does not introduce a new class of bug. However, the context-cancellation path is a new specific scenario where this send is reached after the consumer is most likely gone: when ctx is cancelled, scanner.Scan() returns false, scanner.Err() returns context.Canceled, and the new code immediately attempts the send — right when the consumer has already left. In practice the 128-slot buffer almost always has room, but under back-pressure (slow consumer, burst of events) it is not guaranteed.

Consider guarding the send with a select to avoid blocking:

if err := scanner.Err(); err != nil {
    slog.DebugContext(ctx, "event", "scanner_error", err)
    select {
    case eventChan <- Error(fmt.Sprintf("reading event stream: %v", err)):
    default:
        // consumer gone or buffer full; drop the error event
        slog.WarnContext(ctx, "event channel full, dropping scanner error", "err", err)
    }
    return
}

The same pattern applies to the identical block in StreamSessionEvents (line 600).

Confidence Score
🟡 moderate 70/100

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants