fix(runtime): don't truncate the SSE stream on tool results >64 KiB - #3883
fix(runtime): don't truncate the SSE stream on tool results >64 KiB#3883EronWright wants to merge 1 commit into
Conversation
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.
|
LGTM |
docker-agent
left a comment
There was a problem hiding this comment.
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.
| // 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)) |
There was a problem hiding this comment.
[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 |
fix(runtime): don't truncate the SSE stream on tool results >64 KiB
Problem
The remote-runtime SSE reader (
pkg/runtime/client.go) usesbufio.NewScannerwith the default 64 KiB token cap (
bufio.MaxScanTokenSize). Each server event isdelivered as one
data: {json}line, so a large tool result — e.g. a bigtool_call_response— overflows it:scanner.Scan()tripsbufio.ErrTooLong, theloop 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 topkg/runtime/defaults.go) and emit anErrorevent onscanner.Err()instead ofclosing 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 KBtool_call_responsethat previously cut the stream mid-run; it now streams tocompletion.