fix(clientcore): init QUICLayer ctx/cancel in constructor (Close race + leak)#373
Conversation
…routine ListenAndMaintainQUICConnection set c.ctx/c.cancel on its first line, but Close() reads c.cancel from another goroutine. That's a data race, and worse: a Close() that lands before the maintain goroutine's first line reads a nil cancel and no-ops — the goroutine then runs quic.Listen / Accept on a context that never gets cancelled, permanently leaking the listener, the maintain goroutine, and the underlying bfconn (the WebRTC data channels). On iOS this stacks across the connect/disconnect churn of a client roaming networks (getlantern/engineering#3698). Initialize ctx/cancel in NewQUICLayer so they exist before the goroutine starts and before any Close() can race them. Close() keeps its nil guard for struct-literal construction. Refs getlantern/engineering#3698 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 50 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughQUIC lifecycle context and cancellation are initialized during construction. The maintenance loop now rejects layers without an initialized context, and tests cover both closing before listening and starting from a struct literal. ChangesQUIC lifecycle safety
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 fixes a lifecycle race in clientcore’s QUICLayer by ensuring its context.Context and cancel function are initialized before any goroutine using them starts, preventing both a -race data race and a “lost cancel” leak scenario during fast teardown.
Changes:
- Initialize
QUICLayer’sctx/cancelinNewQUICLayerinstead of insideListenAndMaintainQUICConnection. - Add a regression test that calls
Close()before starting the maintain loop and asserts the loop returns promptly afterward.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
clientcore/quic.go |
Moves context initialization into the constructor to remove Close-vs-goroutine initialization race/leak. |
clientcore/quic_close_test.go |
Adds a regression test for “Close before Listen” behavior to prevent reintroducing the leak/race. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ral case) Addresses PR review: with ctx/cancel now set in NewQUICLayer instead of this method, a QUICLayer built via struct literal (no ctx) would nil-deref on c.ctx.Err(). Refuse to start with a logged error instead. Deliberately does NOT lazily init ctx/cancel here — that would move the write back into this goroutine and reintroduce the Close() race the constructor init exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nnel Two PR-review follow-ups: - ListenAndMaintain's guard now checks c.cancel too, not just c.ctx: a layer with a non-nil ctx but nil cancel would pass the old guard yet be uncancellable (Close() no-ops), reintroducing the leak. Refuse to start if either is missing. - TestQUICLayerListenWithoutConstructor conveys any panic over a channel instead of calling t.Errorf from the goroutine, which could fire after the test completed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
Bumps broflake dc7d5ec → f2cacf69, adding getlantern/unbounded#373 (init QUICLayer ctx/cancel in the constructor to fix the Close() data race and the lost-cancel leak) on top of #371 (teardown) and #372 (backoff) already pulled in. Completes the broflake-side fixes for getlantern/engineering#3698. Ran go mod tidy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Problem
ListenAndMaintainQUICConnectioninitializedc.ctx/c.cancelon its first line, butClose()readsc.cancelfrom a different goroutine. Two bugs:c.ctx/c.cancel(writer = the maintain goroutine, reader =Close()), flagged by-race.Close()runs before the maintain goroutine executes its first line (e.g. an unbounded outbound created and torn down in quick succession during a network change),c.cancelis still nil, soClose()no-ops. The goroutine then sets its own ctx/cancel and runsquic.Listen/Accept(c.ctx)forever on a context nobody holds the cancel for — orphaning the QUIC listener, the maintain goroutine, and the underlyingbfconn(the WebRTC data channels). Under the connect/disconnect churn of a roaming iOS client this accumulates. Part of getlantern/engineering#3698.Fix
Initialize
ctx/cancelinNewQUICLayer, so they exist before the goroutine starts and before anyClose()can race them.Close()keeps itsnilguard for direct struct-literal construction.Verification
go test -race ./clientcore/passes.TestQUICLayerCloseBeforeListen: constructs a layer, callsClose()before the maintain loop starts, asserts the context is cancelled and thatListenAndMaintainQUICConnectionthen returns promptly (via its existingctx.Err()bailout) instead of spinning.Refs getlantern/engineering#3698
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests