Repository navigation
fix(client): SSE spec compliance, BOM tolerance, bounded decode, accumulator guards - #151
Conversation
…mulator guards
Four pre-existing client/executor robustness bugs, found by audit:
1. SSE stream loop matched only "data: " (colon+space). The SSE spec makes
the space optional; providers/proxies emitting "data:{...}" had those
chunks silently dropped — no error, no retry, arbitrarily truncated
responses. Now matching on "data:" and stripping one optional space.
2. A UTF-8 BOM glued to the first SSE line or a non-streaming 200 body by
gateways/proxies broke the prefix match / encoding/json decode. The BOM
is now stripped on both paths (stream line + JSON body).
3. Non-streaming ChatCompletion/Completion decoded resp.Body unbounded; a
broken or hostile server streaming an unbounded 200 body could exhaust
memory through the decoder. Bodies are now read through a
maxCompletionBodyBytes (8 MiB) LimitReader before decoding, mirroring
the existing error-path bound.
4. StreamAccumulator.Append indexed a.ToolCalls[index] for any index <
len(a.ToolCalls): a negative index panicked (killing the CLI), and an
out-of-order index spliced arguments into another tool call's slot.
Merging now requires index >= 0 AND the slot's own Index to match;
unmatched deltas append instead, preserving the data.
Regression tests: SSE data-line variants (colon-space, colon-no-space,
mixed, BOM first line, BOM+no-space), BOM body decode, oversized-body
bound, normal-body sanity, accumulator negative-index and out-of-order
guards.
|
Thank you, the SSE
One more thing: unlike #127, this PR doesn't include the CLA checkbox from the template. I'm assuming that's because your statement on #127 ("my contributions to this project") is meant to cover your contributions generally, and I've treated it that way. If that's not what you intended, please say so here, and the CLA would then need to be confirmed per PR. Otherwise there's nothing to do on this point. |
…ve tolerance, sized-body error Review findings on mlhher#151: 1. StreamAccumulator.Append locates the tool-call slot BY ITS INDEX FIELD (first slot whose ToolCalls[i].Index == index) instead of slice position: providers numbering calls from 1 with interleaved fragments now merge into their own calls instead of appending orphaned entries. Lookup only ranges the slice, so malformed indices still cannot panic. Guard tests updated; a 1-based interleaved-fragment regression test added. 2. A bare "data:" SSE line is now skipped (continue) instead of breaking the loop: such lines are keepalives/heartbeats, and breaking on them ended the turn early — a truncated response with no error for any retry tier to see. Keepalive-between-chunks test added. 3. Non-streaming success bodies (ChatCompletion and Completion) are read one byte past maxCompletionBodyBytes; the presence of a (max+1)-th byte returns the new errors.As-able *BodyTooLargeError ("response body too large (>N bytes)") instead of the silently truncated read failing decode with "unexpected end of JSON input". max+1, at-limit, and oversized tests added. 4. The parseRetryAfterAt tests in stream_parse_test.go (which came with the retry-hardening work this PR builds on) are called out in the PR description as a scope note, per review.
|
Thank you for the careful review — all four points are addressed in 6b7158d. 1. Accumulator non-zero-based indices — fixed. 2. Bare 3. Oversized body — fixed. Both non-streaming reads ( 4. Scope — noted in the description. A "Scope note (per review)" section now explains that the CLA: yes — my statement on #127 ("my contributions to this project") is meant to cover my contributions generally, including this PR; I just missed the template checkbox here. If you'd rather have it confirmed per PR going forward, say so and I'll include it on each one. Gates on 6b7158d: gofmt clean on touched files, |
|
Thanks! |
Summary
Four pre-existing robustness bugs in the client/executor streaming and decode paths, found by a 4-subsystem audit. All four fail silently or catastrophically on malformed-but-legal server output.
Bugs and evidence
SSE
data:lines without a space were silently dropped. The stream loop matchedstrings.HasPrefix(line, "data: ")(colon+space) only. The SSE spec makes the single space after the colon optional, so a provider or proxy emitting spec-legaldata:{...}had every such chunk vanish — no error, no log, no retry: the scanner justcontinued past them. A turn could commit an arbitrarily truncated response, and none of the retry tiers ever saw the loss.A leading UTF-8 BOM broke the first chunk. Gateways and proxies sometimes glue
\ufeffto the first line of an SSE stream or to a JSON body. Unstripped, the BOM broke thedata:prefix match (chunk silently dropped, as above) and madeencoding/jsonreject an otherwise healthy non-streaming 200 body with a confusing parse error.Non-streaming 200 bodies were decoded unbounded (memory DoS).
ChatCompletionandCompletiondecodedresp.Bodydirectly. The error path was already bounded (maxErrorBodyBytes), but a broken or hostile server streaming an unbounded success body could exhaust memory through the decoder.StreamAccumulator.Appendpanicked on a negative tool-call delta index and cross-merged out-of-order deltas. The merge conditionif index < len(a.ToolCalls)indexed the slice for ANY index below the length: a malformedindex: -1panicked with index-out-of-range, killing the whole CLI; an out-of-order delta (e.g. index 5 arriving before index 0) spliced its arguments into another tool call slot, corrupting both entries.Fixes
"data:"and strip one optional space:strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ").io.LimitReader(resp.Body, maxCompletionBodyBytes)(8 MiB) beforejson.Unmarshal, mirroring the existing error-path bound.index >= 0 && index < len(a.ToolCalls) && a.ToolCalls[index].Index == index; deltas matching no slot are appended as new entries, preserving the data.StreamAccumulatorexists on this base (internal/executor/executor.go), so the accumulator guard is included here rather than riding with a future PR.Testing
gofmt -l .— no findings in touched filesgo build ./...— passgo vet ./internal/client ./internal/executor— passgo test -race -count=1 ./...— all 15 packages okgolangci-lint run ./internal/client/... ./internal/executor/...(v2.13.2) — 0 issuesRegression tests added:
internal/client/stream_parse_test.go(SSE data-line variants: colon+space, colon-no-space, mixed shapes, BOM on first line, BOM+no-space; BOM body decode; oversized-body bound; normal-body sanity) andinternal/executor/stream_accumulator_guard_test.go(negative-index no-panic, out-of-order no-cross-merge).Same fixes landed on these open PRs
The same client/executor hunks were ported to the open PR branches that carried the buggy code: #129 (feat/otp-gate), #130 (fix/subagent-hang), #131 (feat/subagent-control), #132 (fix/fleet-pacing), #136 (feat/todos-pane-default-open), #141 (feat/config-strict-parsing), #142 (feat/config-cli-parity), #146 (feat/subagent-persistence), #149 (feat/agent-resume). #139 (feat/jev-compaction) already carried them.
Scope note (per review)
The
parseRetryAfterAttests at the end ofinternal/client/stream_parse_test.go(TestParseRetryAfterAt_ExtremeValues,TestParseRetryAfterAt_HugeButValidSecondsDoesNotPanic) are not related to this PR's four fixes: they came with the Retry-After / retry-hardening changes this PR builds on and were carried into the file when it was created. They are called out here for scope instead of being split into a separate PR, as suggested in review. (Review fixes themselves landed in 6b7158d: index-field accumulator slot lookup, baredata:keepalives skipped, and a clear oversized-body error.)