fix(lifecycle): close leaked goroutines, sessions, and unbounded error reads#497
Conversation
…r 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>
|
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 (2)
📝 WalkthroughWalkthroughThis PR adds panic-safe audit cleanup, bounds upstream error-body reads and raw body cleanup in llmclient, and introduces heartbeat-driven teardown for realtime proxy sessions with new regression tests. ChangesAudit Middleware Panic Cleanup
Bounded Error Bodies and Raw Body Cleanup
Websocket Heartbeat Teardown
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Handler
participant Middleware
participant LiveEventEmitter
Handler->>Middleware: panic
Middleware->>LiveEventEmitter: publish LiveEventAuditRemoved
Middleware->>Handler: re-panic
sequenceDiagram
participant relay
participant ClientConn
participant HeartbeatLoop
participant UpstreamConn
relay->>ClientConn: start copyFrames
relay->>UpstreamConn: start copyFrames
relay->>HeartbeatLoop: start heartbeat ticker
loop every heartbeatInterval
HeartbeatLoop->>ClientConn: ping
HeartbeatLoop->>UpstreamConn: ping
end
HeartbeatLoop-->>relay: heartbeat error or ctx cancellation
relay->>ClientConn: close both peers
relay->>UpstreamConn: close both peers
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! |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/realtime/proxy_test.go`:
- Around line 209-233: The heartbeat test for responsive sessions is too tight
and may flake under CI jitter. In
TestProxyHeartbeatLeavesResponsiveSessionAlive, relax the cadence and timeout
used by SetHeartbeatCadenceForTest and extend the overall loop deadline so the
proxy has more slack while still exercising the same responsive-session
behavior. Keep the assertion flow in proxyServer, dialClient, and the client
read/write loop unchanged, but choose wider timing values that reduce the chance
of a delayed ping/read causing a false unresponsive failure.
In `@internal/realtime/proxy.go`:
- Around line 130-140: The shared timeout in pingBoth causes the second
websocket Ping to inherit a nearly expired deadline after the first Ping
succeeds, so update pingBoth to give client and upstream their own full
heartbeatTimeout budget instead of reusing one pingCtx. Keep the same
heartbeatTimeout behavior by creating separate timeout contexts for each Ping
call, and preserve the existing client heartbeat and upstream heartbeat error
wrapping so the caller can still distinguish which peer failed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 99b4fd33-ec00-451f-a236-8dd72cd8c4ed
📒 Files selected for processing (7)
internal/auditlog/middleware.gointernal/auditlog/middleware_test.gointernal/llmclient/client.gointernal/llmclient/client_test.gointernal/realtime/export_test.gointernal/realtime/proxy.gointernal/realtime/proxy_test.go
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>
Main's realtime hardening (#497) added a ping/pong heartbeat to the websocket relay so silently dead peers tear sessions down. The WebRTC sideband observer has the same exposure — a dead provider connection left it blocked in Read until the 6h call TTL. The heartbeat loop is now shared: relay pings both peers, the observer pings its upstream, and a ping timeout ends the observation immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(realtime): add OpenAI-compatible WebRTC voice endpoints
Expose the GA realtime WebRTC surface behind REALTIME_ENABLED:
- POST /v1/realtime/calls: model-routed SDP exchange accepting both GA
shapes (application/sdp offer with ?model=, or multipart sdp + session
JSON). Provider credentials are injected, the requested model is
rewritten to the resolved provider model (aliases/virtual models work),
and the SDP answer is relayed with a gateway-relative Location header.
- POST /v1/realtime/client_secrets: ephemeral browser credentials routed
by session.model (with the transcription-model fallback), gated by the
same model-access, budget, and rate-limit rules as other endpoints.
- GET /v1/realtime?call_id=...: sideband websocket attach to an existing
call, routed via an in-memory call registry (explicit model+provider
params as the stateless fallback).
WebRTC media and events flow directly between client and provider, so
the gateway attaches a best-effort sideband observer websocket to each
call it creates and records usage per response.done (endpoint
/v1/realtime/calls); gateway-relayed attaches for registry-known calls
skip the usage tap to avoid double counting.
Providers opt in via the new core.RealtimeCallProvider interface
(OpenAI only for now). Swagger regen also picks up previously
ungenerated /admin/rate-limits paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(realtime): pin relayed content types and harden WebRTC signaling responses
Pin the protocol-mandated Content-Type (application/sdp for call answers,
application/json for client secrets) instead of echoing the upstream
header, and set X-Content-Type-Options: nosniff on relayed bodies, so a
misbehaving upstream cannot turn the relay into browser-rendered markup
(CodeQL go/reflected-xss). Add tests for capability gating, malformed
multipart bodies, unreachable upstreams, missing Location headers, and
invalid client-secret JSON.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(realtime): address review findings on attach routing, registry, and spec
- Attach by call_id: the registry route now overrides a client-supplied
model/provider instead of only backfilling, so model-access and
rate-limit checks cannot be steered toward a model the registered call
is not running.
- Call registry: re-registering an existing call id at capacity
overwrites in place instead of evicting an unrelated entry.
- Signaling requests never fall back to the unbounded http.DefaultClient;
the nil-client path now uses the configured-timeout default client.
- OpenAPI: declare both accepted call content types (application/sdp +
multipart), the application/sdp answer, request bodies for both
realtime POST endpoints, and 429 responses on the rate-limited
realtime routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(realtime): add WebRTC signaling support for xAI and Azure OpenAI
xAI mirrors OpenAI's shape exactly, so calls, client secrets, and
call_id sideband attach derive from the base URL via the shared
helpers. Verified live: /v1/realtime/client_secrets mints ephemeral
secrets through the gateway; /v1/realtime/calls exists upstream but is
team-gated by xAI, so unauthorized accounts get the 403 relayed.
Azure OpenAI uses its GA v1 surface at
<resource>/openai/v1/realtime/{calls,client_secrets} (api-key header,
no api-version), with sideband attach on the same GA path; the model
query parameter selects the deployment.
Bailian is deliberately not wired: its WebRTC endpoint is
allowlist-only and provided per customer by sales (probes 404 on both
public hosts), and the documented flow returns no call id for sideband
observation. Z.ai has no WebRTC realtime; both keep websocket-only
support.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(realtime): type the client secret request body in the OpenAPI spec
* fix(realtime): apply the relay heartbeat to the sideband call observer
Main's realtime hardening (#497) added a ping/pong heartbeat to the
websocket relay so silently dead peers tear sessions down. The WebRTC
sideband observer has the same exposure — a dead provider connection
left it blocked in Read until the 6h call TTL. The heartbeat loop is
now shared: relay pings both peers, the observer pings its upstream,
and a ping timeout ends the observation immediately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(azure): share realtime URL root derivation across builders
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
Third slice of the memory-leak investigation (follow-up to #488 and #489): four lifecycle leaks where goroutines, connections, or buffers survive their request instead of being released. None depend on config — all are active in default deployments.
Changes
1. Pipe-producer goroutine leak (
internal/llmclient/client.go)File uploads and audio transcriptions stream a multipart body into the request through an
io.Pipe; the producer goroutine blocks inWriteuntil the transport reads it. When the request died before reaching the transport — circuit breaker open, request build failure — nothing ever read or closed the pipe, so the producer goroutine plus the entire upload buffer leaked until process restart. Trigger pattern: provider outage trips the breaker while clients retry uploads.llmclientnow closesRawBodyReader(when it implementsio.Closer) on every pre-transport error return, mirroring net/http'sDocontract that the body is always closed. Once the request reaches the transport, the transport owns closing it, unchanged.2. Live-audit orphan on handler panic (
internal/auditlog/middleware.go)Recover()is registered outside the audit middleware, so a handler panic unwound past all the terminal live events — theaudit.startedsnapshot stayed in the broker's active-snapshot state forever and was replayed to every dashboard subscriber. The middleware now publishesaudit.removedfrom a defer when a panic passes through, then re-panics so the recover middleware behaves exactly as before.3. Realtime websocket heartbeat (
internal/realtime/proxy.go)The relay's two copy loops block in
Read; a silently dead peer (NAT timeout, power loss — no RST, no close frame) never returns fromRead, leaking two goroutines and both connections per session, indefinitely. The relay now pings both peers every 30s with a 10s timeout — pongs are processed by the always-in-flight reads, so healthy sessions are untouched. One subtlety the new tests caught: a ping failing withnet.ErrClosedmeans teardown is already resolving locally, so the heartbeat waits for the copy loop's definitive close cause instead of racing to report a spurious secondary error.4. Bounded upstream error-body reads (
internal/llmclient/client.go)DoStream's non-200 path anddoRequest's error-status path read the upstream body without a limit; a misbehaving provider answering an error status with an endless body was buffered whole (bounded only byHTTP_TIMEOUT). Both now read through a 64KBLimitReader, matchingcore's existing audit-capture cap for error bodies. Successful responses are read whole, unchanged — large legitimate results must not be truncated.Tests
ReadCloser.audit.startedfollowed byaudit.removed, and the panic still propagates.TestProxyRelaysLargeFrameleaking its hijacked session past the test boundary (Accepthijacks the connection, sohttptest.Server.Closedoesn't wait for relay goroutines) — found because the leaked session raced the new tests' cadence override under-race.Full suite passes (62 packages); realtime suite stressed 8× under
-race.User-visible impact
Long-running deployments stop accumulating goroutines/connections from dead realtime sessions and breaker-rejected uploads. Realtime sessions over connections that silently die now end within ~40s with a logged heartbeat error instead of living forever. No API or config changes.
🤖 Generated with Claude Code
Summary by CodeRabbit
DoRawfailures, and heartbeat behavior for responsive vs. unresponsive proxy sessions.