fix(clientcore): stop leaking goroutines and connections on engine teardown#371
Conversation
On engine stop, several long-lived goroutines and a live peer connection
were leaked on every connect/disconnect cycle:
- The bus observer, table routers, and downstream UI ticker had no
shutdown path and ran until process exit. Thread an engine-owned
context into them so stop() cancels them, and guard router sends on
that context so a send can't block teardown.
- The passive user-stream WorkerFSM state blocked on a bare select{}, so
its Start goroutine never returned to release its WaitGroup token and
engine teardown hung. Block on ctx and return instead.
- A WorkerFSM carrying a live *webrtc.PeerConnection (or coder/websocket
Conn) discarded it unclosed when cancelled between states, leaking the
connection's interceptor goroutines. Close the carried resource on the
cancellation exit and in the producer state-1 cancel path.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughBroflakeEngine now owns a cancelable context passed to IPC, routing, and UI components. IPC and router loops honor cancellation and closed channels, while worker FSMs, WebRTC producers, and UI throughput handling perform explicit shutdown cleanup. ChangesLifecycle cancellation and cleanup
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant BroflakeEngine
participant IPCObserver
participant TableRouter
participant WorkerFSM
BroflakeEngine->>IPCObserver: Start(ctx)
BroflakeEngine->>TableRouter: Init(ctx)
BroflakeEngine->>WorkerFSM: propagate cancellation
BroflakeEngine->>BroflakeEngine: cancel()
IPCObserver-->>IPCObserver: stop forwarding
TableRouter-->>TableRouter: stop routing
WorkerFSM-->>WorkerFSM: close pending resource
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
There was a problem hiding this comment.
Pull request overview
This PR addresses resource leaks during BroflakeEngine teardown by introducing context-driven shutdown paths for long-lived goroutines (bus observer, routers, UI ticker) and ensuring WorkerFSM states release their WaitGroup token and close carried connection resources when cancelled.
Changes:
- Replace an infinite
select {}in the passive user-stream producer state with<-ctx.Done()so theWorkerFSMgoroutine can exit cleanly on Stop. - Thread an engine-owned
context.Contextinto the bus observer, table routers, and downstream UI ticker to support cancellation-driven shutdown. - Close connection-like resources on
WorkerFSMcancellation (generic closure inWorkerFSM.Start()plus an explicitPeerConnection.Close()on a cancellation path in producer state 1).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| clientcore/broflake.go | Adds engine-owned context/cancel and wires it into bus/router/UI startup; triggers cancellation on stop. |
| clientcore/ipc.go | Makes the IPC observer forwarders context-cancellable to avoid goroutine leaks. |
| clientcore/protocol.go | Adds generic “close carried resource on cancellation” logic to WorkerFSM shutdown. |
| clientcore/producer.go | Ensures a carried *webrtc.PeerConnection is closed when a state is cancelled mid-loop. |
| clientcore/routing.go | Adds context-aware recv/send helpers so router goroutines can stop and router sends don’t block teardown. |
| clientcore/ui.go | Replaces time.After loop with a time.Ticker and adds context cancellation for the UI throughput ticker goroutine. |
| clientcore/user.go | Fixes passive producer state to block on context cancellation rather than an infinite select. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
clientcore/broflake.go (1)
24-94: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftRecreate the engine context before restart
stop()cancels the onlyctx, butstart()never reinitializesctx/cancel. The wasm UI also expectsstop→ready→startto be a valid cycle, so a same-instance restart leavesbus.Start, both routers, andDownstreamUIHandlerattached to a dead context. Recreate the engine wiring instart(), or make reuse afterstop()unsupported.🤖 Prompt for 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. In `@clientcore/broflake.go` around lines 24 - 94, The start/stop lifecycle leaves BroflakeEngine.ctx canceled across same-instance restarts. Update start() to recreate the context and cancel function before starting the worker tables and dependent components, preserving the stop() cancellation flow so stop → ready → start attaches bus.Start, both routers, and DownstreamUIHandler to a live context.
🧹 Nitpick comments (2)
clientcore/producer.go (1)
148-155: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winGood fix — but sibling State 4 has the same shape of gap.
Closing
peerConnectionhere is correct and necessary; without it,closeWorkerResource(fsm.nextInput)inprotocol.gohas nothing to close since this state returns an empty slice.Worth confirming whether the unchanged State 4 (further down, its
selectonly coversconnectionEstablished/time.After(options.NATFailTimeout)) is intentionally out of scope. If it isn't, an FSM parked in State 4 during teardown will only unblock afterNATFailTimeoutelapses rather than reacting to cancellation immediately, delaying the WaitGroup release covered by this PR's stated goal.🤖 Prompt for 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. In `@clientcore/producer.go` around lines 148 - 155, Update State 4’s select loop to handle ctx.Done() immediately, close the carried peerConnection, and return the empty input state so teardown does not wait for NATFailTimeout. Preserve the existing connectionEstablished and timeout behavior.clientcore/ui.go (1)
78-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTicker cancellation logic is correct; fix the duration typing flagged by static analysis.
tickMsis declared astime.Durationbut actually holds a plain millisecond count, then gets multiplied bytime.Millisecondagain — this is exactly whatdurationcheck(duration×duration multiplication) andstaticcheckST1011 (unit-suffixedDurationvariable) are warning about. It happens to compute the right value here, but it's a latent bug pattern.♻️ Suggested fix
- var bytesPerSec int64 - var tick uint - tickMs := time.Duration(1000 / uiRefreshHz) + var bytesPerSec int64 + var tick uint + tickInterval := time.Second / uiRefreshHz go func() { - ticker := time.NewTicker(tickMs * time.Millisecond) + ticker := time.NewTicker(tickInterval)🤖 Prompt for 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. In `@clientcore/ui.go` around lines 78 - 97, Update tickMs in DownstreamUIHandler to represent the ticker interval as a proper time.Duration once, avoiding multiplication of a duration by time.Millisecond; rename it to remove the misleading unit suffix and pass it directly to time.NewTicker.Source: Linters/SAST tools
🤖 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.
Outside diff comments:
In `@clientcore/broflake.go`:
- Around line 24-94: The start/stop lifecycle leaves BroflakeEngine.ctx canceled
across same-instance restarts. Update start() to recreate the context and cancel
function before starting the worker tables and dependent components, preserving
the stop() cancellation flow so stop → ready → start attaches bus.Start, both
routers, and DownstreamUIHandler to a live context.
---
Nitpick comments:
In `@clientcore/producer.go`:
- Around line 148-155: Update State 4’s select loop to handle ctx.Done()
immediately, close the carried peerConnection, and return the empty input state
so teardown does not wait for NATFailTimeout. Preserve the existing
connectionEstablished and timeout behavior.
In `@clientcore/ui.go`:
- Around line 78-97: Update tickMs in DownstreamUIHandler to represent the
ticker interval as a proper time.Duration once, avoiding multiplication of a
duration by time.Millisecond; rename it to remove the misleading unit suffix and
pass it directly to time.NewTicker.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 77eefbdf-40be-4e2b-b2e7-822b158277c1
📒 Files selected for processing (7)
clientcore/broflake.goclientcore/ipc.goclientcore/producer.goclientcore/protocol.goclientcore/routing.goclientcore/ui.goclientcore/user.go
Address review feedback on the engine-teardown fix: - stop() now cancels the engine context only after wg.Wait() returns, so the routers keep draining com.tx while workers exit. Cancelling first could strand a worker on a bare blocking com.tx send, hanging teardown. - Producer state 4 handles ctx cancellation directly, closing the peer connection instead of waiting out NATFailTimeout. - UI ticker interval is computed as time.Second / uiRefreshHz, avoiding a Duration-times-Duration multiplication.
|
Addressing coderabbitai's review:
|
Bumps github.com/getlantern/broflake c4d1516 → f2cacf69, pulling the consumer-side unbounded fixes into lantern-box's unbounded outbound: - getlantern/unbounded#371: engine teardown no longer leaks the bus observer, table routers, UI ticker, or a live PeerConnection per connect/disconnect cycle. - getlantern/unbounded#372: consumer signaling backoff is context-aware, so a torn-down outbound exits its retry backoff immediately instead of lingering a full ErrorBackoff. - getlantern/unbounded#373: QUICLayer initializes ctx/cancel in its constructor, fixing the Close() data race + lost-cancel leak. broflake is a direct dependency here (protocol/unbounded imports broflake/clientcore), so this is the correct home for the bump — radiance picks it up by bumping lantern-box rather than overriding the transitive version. Addresses getlantern/engineering#3698. Ran go mod tidy. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bumps github.com/getlantern/radiance to e6267cf (main), which pulls the
full unbounded consumer-leak fix chain through the module graph:
radiance e6267cf
→ lantern-box v0.0.103 (getlantern/lantern-box#288)
→ broflake f2cacf69 (getlantern/unbounded#371, #372, #373)
Fixes for getlantern/engineering#3698 (iOS network-extension jetsam kill
from the unbounded outbound leaking memory/goroutines/sockets under
network roaming):
- broflake#371: engine teardown goroutine/PeerConnection leak
- broflake#372: context-aware signaling backoff
- broflake#373: QUICLayer.Close race + lost-cancel leak
Ran go mod tidy; go.mod + go.sum committed together so gomobile bind
resolves the new transitive versions consistently (avoids the
2026-04-13 stale-go.sum regression). lantern-core builds clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
On engine stop, several long-lived goroutines and a live peer connection were leaked on every connect/disconnect cycle:
WorkerFSMstate blocked on a bareselect{}, so itsStartgoroutine never returned to release itsWaitGrouptoken — hanging engine teardown.WorkerFSMcarrying a live*webrtc.PeerConnection(orcoder/websocketConn) discarded it unclosed when cancelled between states, stranding the connection's pion interceptor goroutines (they start at PeerConnection construction).Under repeated connect/disconnect this accumulates goroutines and heap without bound.
Fix
contextinto the bus observer, routers, and UI ticker sostop()cancels them; guard router sends on that context so a send can't block teardown.<-ctx.Done()and return, so its goroutine releases theWaitGrouptoken.WorkerFSMcancellation exit, close whatever connection the interrupted state was carrying (io.Closerfor the pionPeerConnection,CloseNow()forcoder/websocket), plus the producer state-1 cancel path.Verification
PeerConnectioninterceptor goroutines drop to zero at the disconnected state (previously accumulated every cycle). The per-cyclenewIpcChanbus residue no longer ratchets.Summary by CodeRabbit
Bug Fixes
Performance