Skip to content

Commit 563b137

Browse files
myleshortonclaude
andcommitted
unbounded/backend: address Copilot review on #501
Four substantive findings + one stale comment (settings.go vpn.Init… reference) answered via reply. 1. PatchSettings now dispatches settings.UnboundedKey to unbounded.SetEnabled the same way it dispatches PeerShareEnabledKey to applyPeerShare. The UI toggle now starts/stops the widget proxy immediately rather than persisting the value and waiting for the next NewConfigEvent. 2. Close now invokes unbounded.Stop(...) before r.cancel(). unbounded workers run on a context.Background-derived ctx (they must outlive any single NewConfigEvent), so without an explicit shutdown hook the broflake widget goroutine survived backend close. Uses a fresh 5s-bounded ctx so a cancelled shutdown path doesn't skip the Stop. 3. SetEnabled now respects the full three-condition predicate before starting. Previously it called manager.start whenever cached *UnboundedConfig was non-nil, ignoring the cached Features[UNBOUNDED] flag — meaning a local toggle could start the proxy even when the server had said don't. Added lastFeatureOn to unboundedManager (set alongside lastCfg on every NewConfigEvent) and a shouldStart() method that re-checks all three conditions. SetEnabled now uses shouldStart(); the standalone shouldRunUnbounded function is removed (its job is now done by the method). 4. unbounded.ConnectionEvent shape aligned with peer.ConnectionEvent: {State, Source, Timestamp}. WorkerIdx is dropped from the wire shape (it remains in the per-callback Debug log for diagnostics but isn't part of the event contract). Doc comment updated to spell out the field semantics. Consumers that need to pair accept/close events for the same arc now key off Source (or arrival sequence within a single connection's lifetime, which is what the globe currently does). The fifth Copilot comment cited a 'vpn.InitUnboundedSubscription' reference in common/settings/settings.go:75 that no longer exists — the consolidation cascade rewrote that doc to say 'the widget proxy starts' without naming the lifecycle function. Answered in the thread rather than re-edited. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a286ce7 commit 563b137

2 files changed

Lines changed: 79 additions & 44 deletions

File tree

backend/radiance.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,17 @@ func (r *LocalBackend) Close() {
326326
r.closeOnce.Do(func() {
327327
slog.Debug("Closing Radiance")
328328
r.closePeerClient()
329+
// unbounded.start spawns its worker on a context.Background-
330+
// derived ctx (it has to outlive any single NewConfigEvent),
331+
// so Close has to explicitly tell it to shut down — otherwise
332+
// the broflake widget goroutine survives backend close and
333+
// leaks until process exit. Use a fresh ctx so a cancelled
334+
// shutdown path doesn't skip the Stop.
335+
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
336+
if err := unbounded.Stop(stopCtx); err != nil {
337+
slog.Warn("unbounded stop on backend close returned error", "error", err)
338+
}
339+
cancel()
329340
// vpnClient is always set in production via NewLocalBackend, but
330341
// peer-focused unit tests construct partial LocalBackends without
331342
// one. Guard the call so Close stays robust under those paths
@@ -510,6 +521,16 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error {
510521
}
511522
}
512523

524+
// Drive the Unbounded widget proxy off the toggle change immediately
525+
// rather than waiting for the next NewConfigEvent to re-evaluate.
526+
// SetEnabled is internally idempotent and checks the cached server
527+
// feature flag + config before actually starting the worker.
528+
if _, ok := diff[settings.UnboundedKey]; ok {
529+
if err := unbounded.SetEnabled(settings.GetBool(settings.UnboundedKey)); err != nil {
530+
slog.Warn("unbounded toggle failed", "error", err)
531+
}
532+
}
533+
513534
return nil
514535
}
515536

unbounded/unbounded.go

Lines changed: 58 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"log/slog"
2929
"net"
3030
"sync"
31+
"time"
3132

3233
C "github.com/getlantern/common"
3334

@@ -40,28 +41,44 @@ import (
4041

4142
// ConnectionEvent fires every time a consumer (i.e. a censored client
4243
// being routed through this widget proxy) connects or disconnects via
43-
// the broflake mesh. State: +1 on accept, -1 on close. WorkerIdx is
44-
// broflake's internal worker slot identifier — used by the Flutter
45-
// globe to pair connect/disconnect events for the same arc. Addr is
46-
// the remote consumer's IP if broflake exposes it, otherwise empty.
44+
// the broflake mesh.
4745
//
48-
// Shape mirrors radiance/peer.ConnectionEvent so consumers (lantern-
49-
// core's listenPeerConnectionEvents in particular) can subscribe with
50-
// a single discriminator and feed both the SmC and Unbounded streams
51-
// into the same globe view.
46+
// State +1 on accept, -1 on close
47+
// Source consumer's IP if broflake exposes it, otherwise empty
48+
// Timestamp emit time in Unix milliseconds
49+
//
50+
// Shape is identical to radiance/peer.ConnectionEvent so a single
51+
// subscriber can handle both the SmC-via-samizdat stream and the
52+
// Unbounded-via-broflake stream as one. Broflake's internal worker-
53+
// slot identifier is not surfaced — a consumer that needs to pair
54+
// accept/close events for the same arc keys off Source (or the
55+
// event's arrival sequence within a single connection lifetime,
56+
// which is what the globe currently does).
5257
type ConnectionEvent struct {
5358
events.Event
5459
State int `json:"state"`
55-
WorkerIdx int `json:"workerIdx"`
56-
Addr string `json:"addr"`
60+
Source string `json:"source"`
61+
Timestamp int64 `json:"timestamp"`
5762
}
5863

5964
var manager = &unboundedManager{}
6065

6166
type unboundedManager struct {
62-
mu sync.Mutex
63-
cancel context.CancelFunc
64-
lastCfg *C.UnboundedConfig // most recent server-supplied config
67+
mu sync.Mutex
68+
cancel context.CancelFunc
69+
// lastCfg + lastFeatureOn cache the server-side half of the
70+
// three-condition predicate so SetEnabled can re-evaluate
71+
// immediately when the local toggle flips, without waiting for
72+
// the next NewConfigEvent. Both are updated atomically when a
73+
// new config arrives.
74+
lastCfg *C.UnboundedConfig
75+
lastFeatureOn bool
76+
}
77+
78+
// shouldStart reports whether all three start conditions hold. Caller
79+
// must hold m.mu.
80+
func (m *unboundedManager) shouldStart() bool {
81+
return settings.GetBool(settings.UnboundedKey) && m.lastFeatureOn && m.lastCfg != nil
6582
}
6683

6784
// Enabled reports whether the local opt-in is set. Doesn't say whether
@@ -71,9 +88,10 @@ func Enabled() bool {
7188
}
7289

7390
// SetEnabled flips the local opt-in. When enabling, the proxy starts
74-
// immediately if a server config is already cached; otherwise it
75-
// starts on the next config event. When disabling, the proxy stops.
76-
// Idempotent — calling with the current value is a no-op.
91+
// immediately if all three start conditions hold (local toggle + server
92+
// feature flag + server config cached); otherwise it stays stopped and
93+
// the next NewConfigEvent will reevaluate. When disabling, the proxy
94+
// stops. Idempotent — calling with the current value is a no-op.
7795
func SetEnabled(enable bool) error {
7896
if Enabled() == enable {
7997
return nil
@@ -82,17 +100,24 @@ func SetEnabled(enable bool) error {
82100
return err
83101
}
84102
slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable)
85-
if enable {
86-
manager.mu.Lock()
87-
cfg := manager.lastCfg
88-
manager.mu.Unlock()
89-
if cfg != nil {
90-
manager.start(cfg)
91-
} else {
92-
slog.Info("Unbounded: enabled locally, will start when server config arrives")
93-
}
94-
} else {
103+
if !enable {
95104
manager.stop()
105+
return nil
106+
}
107+
manager.mu.Lock()
108+
shouldStart := manager.shouldStart()
109+
cfg := manager.lastCfg
110+
feature := manager.lastFeatureOn
111+
manager.mu.Unlock()
112+
if shouldStart {
113+
manager.start(cfg)
114+
return nil
115+
}
116+
switch {
117+
case cfg == nil:
118+
slog.Info("Unbounded: enabled locally, waiting for server config")
119+
case !feature:
120+
slog.Info("Unbounded: enabled locally, but server feature flag is off")
96121
}
97122
return nil
98123
}
@@ -113,13 +138,15 @@ func InitSubscription() {
113138
cfg := *evt.New
114139
manager.mu.Lock()
115140
manager.lastCfg = cfg.Unbounded
141+
manager.lastFeatureOn = cfg.Features[C.UNBOUNDED]
142+
shouldRun := manager.shouldStart()
116143
running := manager.cancel != nil
144+
ucfg := manager.lastCfg
117145
manager.mu.Unlock()
118146

119-
shouldRun := shouldRunUnbounded(cfg)
120147
switch {
121148
case shouldRun && !running:
122-
manager.start(cfg.Unbounded)
149+
manager.start(ucfg)
123150
case !shouldRun && running:
124151
manager.stop()
125152
}
@@ -137,19 +164,6 @@ func Stop(_ context.Context) error {
137164
return nil
138165
}
139166

140-
func shouldRunUnbounded(cfg C.ConfigResponse) bool {
141-
if !settings.GetBool(settings.UnboundedKey) {
142-
return false
143-
}
144-
if !cfg.Features[C.UNBOUNDED] {
145-
return false
146-
}
147-
if cfg.Unbounded == nil {
148-
return false
149-
}
150-
return true
151-
}
152-
153167
func (m *unboundedManager) start(ucfg *C.UnboundedConfig) {
154168
m.mu.Lock()
155169
defer m.mu.Unlock()
@@ -183,11 +197,11 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) {
183197
addrStr = addr.String()
184198
}
185199
slog.Debug("Unbounded: consumer connection change",
186-
"state", state, "workerIdx", workerIdx, "addr", addrStr)
200+
"state", state, "workerIdx", workerIdx, "source", addrStr)
187201
events.Emit(ConnectionEvent{
188202
State: state,
189-
WorkerIdx: workerIdx,
190-
Addr: addrStr,
203+
Source: addrStr,
204+
Timestamp: time.Now().UnixMilli(),
191205
})
192206
}
193207

0 commit comments

Comments
 (0)