perf(memory): stop pinning request-sized buffers for the stream lifetime#489
Conversation
Large streamed requests kept ~4-6x their body size live until stream close. This trims the per-stream footprint at four verified pin points: - RouteHints: gjson result strings alias the full parsed body; the two short selector strings stored on the request context pinned a request-sized backing string. strings.Clone unpins it (+1 tiny alloc per request, reflected in the perf-guard baselines). - ExtraFields: the unknown-fields buffer was pre-sized to the full body, so one small unknown field retained a body-sized array per decoded object (request, message, tool call, ...). The buffer now starts at 256B and re-copies only when it over-grew, bounding retained capacity near the extras' own length without adding allocations on the hot path. - llmclient.DoStream: GetBody closes over the fully marshaled request payload and stays reachable through resp.Request while the stream is open. Redirects and transparent transport retries only consult GetBody inside Do, so it is dropped once the response arrives - a pure reference release with no behavior change. - Anthropic chat stream: the converter accumulated every tool-call argument delta into a strings.Builder that nothing on the chat path ever reads (the Responses converter uses its own state). For coding agents whose tool calls carry file contents this doubled tool-payload memory per stream. Deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughChanges reduce retained backing memory across the gateway hot path: unknown JSON field extraction shrinks buffer growth and conditionally clones raw bytes, selector hint strings are cloned to avoid body aliasing, streaming client drops request GetBody closures, Anthropic tool-call state removes an argument accumulator, and perf guard allocation thresholds are adjusted upward. ChangesMemory Retention Reduction
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…r reads (#497) * fix(lifecycle): close leaked goroutines, sessions, and unbounded error reads Four lifecycle leaks from the memory investigation (follow-up to #488/#489): - llmclient now closes RawBodyReader on every error return that precedes the HTTP transport (circuit breaker open, request build failure), mirroring net/http's Do contract. Pipe-backed uploads (files, audio transcription) previously leaked their producer goroutine and the full upload buffer forever when the breaker rejected the request — exactly the agent retry-storm-during-outage pattern. - The audit middleware publishes the terminal live event when a handler panic unwinds through it (then re-panics for the outer recover). Previously the panic skipped all terminal events, permanently orphaning the entry in the live-dashboard active-snapshot state. - The realtime websocket relay gains a heartbeat (30s ping, 10s timeout, both peers). A silently dead peer — NAT timeout, power loss, no RST — left both copy loops blocked in Read forever, leaking two goroutines and both connections per session. A ping that fails because the connection is already locally closed defers to the copy loop's close cause instead of racing to report a secondary error. - Upstream error bodies are read through a 64KB LimitReader (matching core's audit capture cap) in DoStream's non-200 path and doRequest's error-status path, so a misbehaving provider answering errors with an endless body is no longer buffered whole. Successful response reads are unchanged. Also fixes TestProxyRelaysLargeFrame leaking its hijacked session past the test boundary (Accept hijacks the connection, so httptest's Close does not wait for the relay goroutines). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(realtime): give each heartbeat ping its own timeout budget Review follow-ups on #497: the shared pingBoth deadline let a slow-but-alive client ping leave the upstream ping a nearly expired budget, spuriously blaming a healthy upstream. Each peer now gets its own full heartbeatTimeout. Also widen the responsive-session test's pong timeout (150ms -> 2s) and loop window so scheduler stalls on loaded CI runners cannot fail a healthy session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
Follow-up to #488. Large streamed requests (coding-agent workloads) keep roughly 4-6x their body size live until stream close, which sets the RSS high-water mark that Go's GC then holds. This PR removes the per-stream pins that were cheap to fix without architectural change — four verified pin points from the memory investigation.
Changes
internal/core/semantic.go): gjson result strings alias the full parsed body, and the two short selector strings stored on the request context pinned a request-sized backing string for the whole (possibly streaming) request.strings.Cloneunpins it.internal/core/json_fields.go): the unknown-fields buffer was pre-sized tolen(body), so one small unknown field retained a body-sized array per decoded object — request, message, tool call (13 call sites share the helper). The buffer now starts at 256B and re-copies only when it over-grew (>1KB slack), bounding retained capacity near the extras' own length. New regression test asserts a 1MB body with tiny extras retains <4KB.llmclient.DoStream(internal/llmclient/client.go):GetBodycloses over the fully marshaled request payload and stays reachable throughresp.Requestwhile the stream is open. It is dropped once the response arrives.internal/providers/anthropic/chat_stream.go): the converter accumulated every tool-call argument delta into astrings.Buildernothing on the chat path reads (the Responses converter uses its ownResponsesOutputToolCallState, which legitimately accumulates). For tool calls carrying file contents this doubled tool-payload memory per stream. Deleted.Design notes for review
GetBodyis cleared afterDo, not before: nil-ing it pre-send would disable net/http's transparent retry of requests that fail on a stale pooled connection — a reliability regression for streaming, sinceDoStreamnever retries at our layer. Redirects and transport retries only consultGetBodyinsideDo, so post-response clearing is a pure reference release with no behavior change.resp.Request.Bodyis deliberately left untouched (the h1 write loop can still reference it on early-response paths — clearing it would race). Known limitation: on HTTP/2 the transport's stream state can still hold the drained request reader until stream end; that part is Go internals.tests/perf/hotpath_test.go): thestrings.Cloneadds +1 small alloc per request on the chat hot paths. Main already measured at cap−1 from drift, so the two alloc ceilings move up by 2 with a comment documenting the intentional trade. Byte ceilings unchanged.Measured impact
BenchmarkExtractUnknownJSONFieldsObject_Chat: 1792 → 1152 B/op, allocs unchanged (2), time flat.BenchmarkExtractUnknownJSONFieldsObject_Responses: 896 → 704 B/op, allocs unchanged.Verification
Full suite passes (61 packages); pre-commit race tests, lint, and the hot-path perf guard all pass. Anthropic stream converter behavior is covered by the existing chat-stream tests (argument deltas are relayed verbatim; the deferred-placeholder logic at
chat_stream.go:292is preserved).🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Performance
Tests