Skip to content

unbounded: WebRTC widget-proxy integration as a parallel peer source#501

Open
myleshorton wants to merge 12 commits into
fisk/peer-manual-portforwardfrom
fisk/peer-unbounded-integration
Open

unbounded: WebRTC widget-proxy integration as a parallel peer source#501
myleshorton wants to merge 12 commits into
fisk/peer-manual-portforwardfrom
fisk/peer-unbounded-integration

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Summary

Adds the broflake-based Unbounded mode as a second peer-source running alongside Share My Connection. Where SmC is samizdat-over-UPnP (full mode, persistent residential exit), Unbounded is WebRTC widget-proxy (basic mode, ephemeral per-page sessions) — the two run independently and emit identically-shaped connection events so the Flutter globe can render both without caring which protocol the byte came over.

Self-contained unbounded/ package — no changes to existing vpn/ code. Lifecycle wrapped around the broflake widget-proxy state machine; subscribes to config.NewConfigEvent and re-evaluates start/stop on every config refresh.

Runs only when all three conditions hold:

  1. settings.UnboundedKey == true (user opt-in via Flutter UI),
  2. Server-side Features[unbounded] == true (Unleash gate),
  3. cfg.Unbounded != nil (server supplied an UnboundedConfig block with broflake parameters).

Each accepted consumer connection emits unbounded.ConnectionEvent{State, Addr, Timestamp} on the radiance event bus — same shape as peer.ConnectionEvent from 471-A, so lantern-core can subscribe to both and fan them into one EventTypePeerConnection FlutterEvent.

What's new

  • unbounded/unbounded.go — package entry point: InitSubscription(ctx) subscribes to config.NewConfigEvent, manages widget-proxy start/stop based on the three-condition predicate, ensures clean shutdown on context cancel.
  • backend/radiance.go — single unbounded.InitSubscription(ctx) call from Backend.Start. Idempotent if Unbounded is gated off — the package is a no-op until the conditions hold.
  • common/settings/settings.goUnboundedKey (bool).

How this was sliced

Third of three PRs split out of #471. Builds on #fisk/peer-connection-events-A (471-A) for the shared event-bus type, and 471-B (manual port forward) for branch ordering.

Test plan

  • go test ./unbounded/... clean on the branch.
  • go build ./... clean.
  • Local toggle smoke test (after building lantern with the matching Flutter PR):
    • settings.UnboundedKey=true + server flag on + UnboundedConfig present → broflake widget proxy runs; consumer connections emit ConnectionEvents.
    • Flip the local setting off → widget proxy stops on next config tick; no further events.
    • Flip the server flag off → same.
  • Event-shape compatibility: subscribe to both peer.ConnectionEvent and unbounded.ConnectionEvent from one consumer; verify identical field set so the globe doesn't need protocol-aware rendering.

Dependencies

  • #fisk/peer-manual-portforward (471-B) — this PR's base (stacking order).
  • #fisk/peer-connection-events-A (471-A) — event-bus type Unbounded reuses.

Notes

unbounded/ lives at the repo root rather than under vpn/ because the in-flight adam/unbounded-widget-proxy branch refactors vpn/ heavily — keeping this package out of vpn/ lets it land independently. Once that refactor lands, unbounded/ can be relocated trivially.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new unbounded package to run the broflake widget-proxy as a gated, parallel peer source alongside Share My Connection.

Changes:

  • Introduces Unbounded lifecycle management and connection event emission.
  • Adds the settings.UnboundedKey local opt-in setting.
  • Wires Unbounded initialization into LocalBackend.Start.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
unbounded/unbounded.go Adds the Unbounded manager, config subscription, start/stop logic, and connection events.
backend/radiance.go Initializes the Unbounded config subscription during backend startup.
common/settings/settings.go Adds the persisted local opt-in key for Unbounded.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread backend/radiance.go Outdated
Comment thread backend/radiance.go Outdated
Comment thread unbounded/unbounded.go Outdated
Comment thread unbounded/unbounded.go Outdated
Comment thread common/settings/settings.go
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from 3908a35 to df954ba Compare May 29, 2026 02:02
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from 33dacd4 to 87ffa0d Compare May 29, 2026 02:02
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from df954ba to 33ca6ac Compare May 29, 2026 19:55
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from 87ffa0d to 3684cef Compare May 29, 2026 19:55
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from 33ca6ac to c657273 Compare May 29, 2026 20:09
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from 3684cef to f566f12 Compare May 29, 2026 20:09
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from c657273 to e77b4d9 Compare May 29, 2026 20:18
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from f566f12 to 58e6bc0 Compare May 29, 2026 20:18
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from e77b4d9 to 18f7db0 Compare May 29, 2026 20:32
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from 58e6bc0 to 0e5cd0c Compare May 29, 2026 20:32
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from 18f7db0 to ad18425 Compare May 29, 2026 20:45
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from 0e5cd0c to 87ce5b8 Compare May 29, 2026 20:45
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from ad18425 to 0c84c5a Compare May 29, 2026 21:04
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch 2 times, most recently from 9264b17 to d307035 Compare May 29, 2026 21:14
@myleshorton myleshorton force-pushed the fisk/peer-manual-portforward branch from 22d1533 to 8667d26 Compare May 30, 2026 06:47
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch 3 times, most recently from a877927 to 3b898d3 Compare May 30, 2026 20:50
Base automatically changed from fisk/peer-manual-portforward to fisk/peer-connection-events-A May 30, 2026 20:51
myleshorton added a commit that referenced this pull request May 30, 2026
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>
myleshorton added a commit that referenced this pull request May 30, 2026
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>
@myleshorton myleshorton force-pushed the fisk/peer-unbounded-integration branch from b246483 to d4fca24 Compare May 30, 2026 21:00
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch from b2b3938 to 06ceb1f Compare May 30, 2026 21:02
myleshorton added a commit that referenced this pull request May 30, 2026
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread unbounded/unbounded.go
Comment thread unbounded/unbounded.go
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread unbounded/unbounded.go Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread backend/radiance.go
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Adam Fisk and others added 10 commits May 31, 2026 16:13
Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI)
WebRTC donor-mode integration. Self-contained package under
radiance/unbounded; drops cleanly onto the current branch without
needing the larger vpn/ refactor that adam/unbounded-widget-proxy
ships with.

unbounded.SetEnabled(bool) toggles the local opt-in
(settings.UnboundedKey). InitSubscription wires the manager to
config.NewConfigEvent — the broflake widget actually runs only when:
  1. settings.UnboundedKey is true (local opt-in)
  2. server cfg.Features[UNBOUNDED] is on
  3. server provides cfg.Unbounded discovery + egress URLs

Each consumer connection change emits unbounded.ConnectionEvent on
the radiance event bus, mirroring the shape of peer.ConnectionEvent
so lantern-core subscribers can feed both into one Flutter event
stream.

Wired into LocalBackend.Start so the manager is live for the process
lifetime; sync.Once-guarded against double-subscribe.

Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy
(#336), with the package moved out of vpn/ since
that branch's vpn/ is undergoing a separate refactor and we want the
unbounded code to land independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Four substantive findings on the cross-process / lifecycle surface:

1. PatchSettings's call to SetEnabled was a no-op for UI toggles
   because settings.Patch above already persisted UnboundedKey, and
   SetEnabled's early-return (`Enabled() == enable`) then short-
   circuited before evaluating the manager. Split SetEnabled into
   two paths: SetEnabled (persist if changed + Apply) for direct
   callers, and Apply() for callers that have already persisted.
   PatchSettings now calls Apply() directly. Apply also has a guard
   so a second 'enable=true' call when already running is a no-op
   (the manager.start path is also guarded but the manager-running
   check is cheaper).

2. unbounded.ConnectionEvent had no IPC bridge — events.Subscribe is
   process-local, so cross-process consumers (Flutter via Liblantern)
   could not see them despite the JSON shape matching
   peer.ConnectionEvent. Added /unbounded/connection/events SSE
   endpoint mirroring /peer/connection/events. Two separate streams
   client-side because events.Emit dispatches by concrete Go type;
   the Flutter side multiplexes them into one globe feed.

3. unbounded.Stop ignored its ctx and returned as soon as the cancel
   signal was queued, so LocalBackend.Close could proceed while the
   broflake goroutine was still inside NewBroflake or ui.Stop. Added
   a 'done' channel that the worker closes on exit; Stop now selects
   on done OR ctx.Done(). The 5s timeout LocalBackend.Close passes
   now actually bounds the wait.

4. ConnectionEvent doc claimed 'single subscriber can handle both
   streams' — true at the JSON level but not at the Go-type level
   (events.Subscribe is type-keyed; Subscribe[peer.ConnectionEvent]
   does not receive unbounded.ConnectionEvent). Reworded to be
   honest about the in-process distinction and to point at the IPC
   bridge as the fan-in point for cross-process consumers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
unbounded/unbounded.go:
- Stop/Start race fix. Round-2's stop() cleared m.cancel
  immediately after signalling cancel, while the worker goroutine
  continued running through ui.Stop. During that window a
  concurrent Apply() or config event could see m.cancel == nil and
  spin up a second broflake widget while the first was still
  tearing down. Stop() likewise only waited on the done channel it
  captured BEFORE the restart.

  Fix: serialize start/stop transitions via a separate
  transitionMu. start() holds it for the brief window of setting up
  cancel/done. stop() holds it for the full cancel-then-wait-on-done
  cycle. Public Stop(ctx) does the same with a context-bounded wait.
  m.mu now only protects field reads/writes; never held across the
  done wait or any broflake call. Lock order is transitionMu →
  mu, consistent across all callers.
- Drop ipc/server.go reference from the ConnectionEvent doc;
  rephrase to describe the in-process event-bus contract directly
  without naming the bridge's source location.

ipc/client_events_{nonmobile,mobile}.go:
- Add UnboundedConnectionEvents method mirroring
  PeerConnectionEvents. The server SSE route landed without a
  corresponding client method, leaving Liblantern / Flutter
  callers to hard-code the private path. Mobile path follows the
  existing localOnly+SSE dual-pattern.

go.mod:
- go mod tidy promoted broflake from // indirect to direct since
  unbounded/unbounded.go now imports clientcore directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two Copilot findings on round 3:

1) Cold-start race: InitSubscription only reacted to future
   NewConfigEvents, but ConfigHandler typically has a cached
   config loaded from disk by the time Start runs. An already-
   opted-in user wouldn't auto-start the widget on launch — the
   three-condition predicate stayed stuck at lastCfg=nil/
   lastFeatureOn=false until the next config refresh arrived.

   Fix: InitSubscription now takes an optional initial *config.Config.
   If non-nil, the same applyConfig handler runs immediately on
   subscribe so the cached config seeds lastCfg/lastFeatureOn and
   the start transition fires synchronously. backend/radiance.go
   passes confHandler.GetConfig() (ignoring the not-yet-fetched
   error) so the seed is best-effort.

2) No tests for the Unbounded lifecycle. The manager coordinates
   persisted settings, server config gates, and start/stop
   concurrency — exactly the kind of code that regresses
   silently. Added unbounded_test.go covering:
     - shouldStart predicate, all 6 (toggle, feature, cfg) combos
     - Apply: disabled is no-op; with all conditions, exactly one
       widget starts; double-Apply is idempotent
     - Stop_WaitsForWorker: stop blocks until worker's ui.Stop
       returns (the round-2 fix's invariant)
     - StartDuringStop_NoOverlap: the round-2 regression guard —
       a concurrent start while a stop is in flight must NOT spin
       up a second widget; transitionMu serializes them
     - InitSubscription_SeedsCachedConfig: passing initial cfg
       kicks off applyConfig immediately
     - InitSubscription_FutureEventStillFires: live NewConfigEvent
       handler still works when initial cfg is nil
     - StopCtx_TimesOut: Stop(ctx) returns DeadlineExceeded when
       the worker can't unwind in time

   To make this testable without spinning up real WebRTC,
   extracted a small widget interface (Stop()) and an injectable
   newWidget factory. Default still calls clientcore.NewBroflake;
   tests swap in a fake that records start/stop calls under their
   own gates.

   TestMain initializes settings once for the binary — calling
   InitSettings per t.TempDir() runs into its sync.Once guard,
   leaving the package pointing at a since-cleaned-up temp dir.

   resetManager's cleanup waits on the worker's done channel
   before swapping newWidget back, so the race detector doesn't
   flag the worker's earlier read of newWidget against the
   cleanup's later write.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
unbounded/unbounded.go:
- Round-3 fix serialized start/stop via transitionMu, but a
  config event firing during public Stop's <-done wait could
  still queue at transitionMu and start a fresh widget the
  moment Stop's caller returned — silently keeping broflake
  alive past the documented backend shutdown contract.

  Added an explicit armed gate on the manager:
    - InitSubscription sets armed=true (every call, not just the
      first — the underlying subscription is still sync.Once-
      guarded, but Start-after-Close needs to re-arm).
    - Public Stop sets armed=false inside transitionMu before
      waiting on done.
    - start, Apply, applyConfig all bail when !armed.
    - start re-checks armed AFTER acquiring transitionMu, so
      even a transition queued at transitionMu during Stop's
      wait stays a no-op.

  Net effect: once Stop returns, the manager is permanently
  off until the next InitSubscription. No further start path
  (Apply, applyConfig, manager.start) can revive the widget.

ipc/client_events_mobile.go:
- Per AGENTS.md, comments must not reference code locations.
  Inlined the full docstrings for PeerStatusEvents,
  PeerConnectionEvents, and UnboundedConnectionEvents
  instead of pointing at client_events_nonmobile.go.

unbounded/unbounded_test.go:
- TestMain: os.Exit bypasses deferred calls, so the tmp dir
  was leaked on every test invocation. Capture m.Run's exit
  code, RemoveAll, then exit with that code.
- resetManager: initialize manager with armed=true so tests
  that call manager.start directly aren't blocked by the new
  gate. Tests exercising Stop's disarm path flip it
  explicitly.
- Added TestStopDisarmsManager: after public Stop, Apply +
  applyConfig + manager.start are all no-ops; a fresh
  InitSubscription re-arms.
- Added TestStartAfterStopWaiting_NoRevival: pin Stop in its
  <-done wait, queue a start at transitionMu, confirm the
  queued start sees armed=false after Stop releases and bails
  without creating a second widget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
unbounded/unbounded.go:
- broflake consumes its discovery / egress endpoints and table-size
  options once in clientcore.NewBroflake. Round-5's applyConfig
  only handled start/stop transitions: when the widget was already
  running and a server config refresh changed the UnboundedConfig
  parameters, the running proxy stayed on stale settings until a
  manual toggle or process restart.

  Manager now tracks runningCfg — the snapshot of UnboundedConfig
  the live worker was started with. applyConfig compares it
  against the freshly-cached lastCfg via cfgEqual (value equality
  on the flat string/int struct) and adds a new branch:
    shouldRun && running && cfgChanged → stop + start

  stop() blocks until the prior worker fully exits (transitionMu
  + <-done), so the restart-start always sees a clean slate.
  runningCfg is set in start() and cleared by the worker on both
  the success exit and the newWidget-error early-return paths.

unbounded/unbounded_test.go:
- Rewrote TestStartDuringStop_NoOverlap and TestStopDisarmsManager
  doc comments to state the invariant directly, dropping the
  'round-2 / round-4 regression guard' framing. The test names
  + bodies are the durable record; the review-history framing
  was noise.
- Added TestApplyConfig_RestartsOnParamChange covering the new
  restart branch:
    1. apply cfg{DiscoverySrv: a} → widget #1 starts
    2. apply same cfg → no restart (starts stays 1)
    3. apply cfg{DiscoverySrv: b} → widget #2 starts (starts == 2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
unbounded/unbounded.go:
- start() now re-checks the full enablement predicate
  (shouldStart()) under m.mu, not just the armed gate. Without
  this, a caller could decide to start based on its earlier
  snapshot, get queued behind a stop or another transition at
  transitionMu, and then create a worker even though a
  concurrent SetEnabled(false) or config update had since
  disabled Unbounded. The widget would run with stale state
  until some later event stopped it.
- start() also drops its ucfg parameter and reads m.lastCfg
  directly under the lock. applyConfig sets m.lastCfg before
  calling start, so the normal path is unchanged; for the race
  case (cfg updated between the caller's read and start's lock
  acquisition), the worker comes up with the latest parameters
  rather than a stale snapshot.

unbounded/unbounded_test.go:
- New primeManager(t, cfg) helper seeds the predicate fields
  (toggle on, lastFeatureOn=true, lastCfg=cfg) so the four
  tests that drive manager.start directly satisfy shouldStart()
  inside the new gate.
- Added TestConnectionEventBridge: captures the
  OnConnectionChangeFunc that start installs on bfOpt via the
  fake newWidget, invokes it with both a non-nil and a nil
  net.IP, and asserts the resulting events.ConnectionEvent
  payloads via events.Subscribe — pins {state, source,
  timestamp} including the addr.String() / empty conversion
  for nil. Set-membership keyed by State because events.Emit
  dispatches each subscriber on a per-callback goroutine, so
  arrival order is not deterministic.

Stress-tested 20x under -race -count=1, clean every run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
manager.stop() (called from Apply and applyConfig) waited on the
worker's done channel with no deadline. If broflake's ui.Stop
hung, toggling Unbounded off via PatchSettings or receiving a
disabling config event would block the caller indefinitely while
holding transitionMu. Only the public Stop(ctx) was bounded.

Extracted stopCtx(ctx, disarm) as the shared implementation for
both paths:
 - Public Stop(ctx) calls stopCtx(ctx, true) — disarm + caller's
   own deadline (5s from LocalBackend.Close).
 - Internal manager.stop() calls stopCtx with a fresh context
   bounded by internalStopTimeout (default 5s). The timeout error
   is logged; callers have no useful action to take, and a
   subsequent start observes 'already running' until the worker
   eventually exits.

internalStopTimeout is a var (not const) so tests can install a
short timeout (50ms) and exercise the timeout path without
holding up the suite.

TestInternalStop_TimesOut: pins ui.Stop past the short timeout
via stopBlock, flips the toggle off so Apply calls
manager.stop, asserts Apply returns within 2s rather than
blocking forever.

Stress-tested 20x under -race -count=1, all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A typo on the diff key (settings.UnboundedKey →
settings.UnboundedKye) or a removal of the unbounded.Apply()
call would silently leave the UI toggle persisted but inert.
The peer-share side has TestPatchSettings_PeerShareDispatches
catching exactly this class of regression; mirror it for
Unbounded.

unbounded/unbounded.go:
- Add applyHook: a nil-in-production function pointer invoked
  at the top of Apply(), before any state check. Backend tests
  install a counter via SetApplyHookForTest to verify dispatch.
  This is the smallest possible test surface — exposing the
  manager's internals across packages just for the test wiring
  would be a much bigger surface, and the hook fires regardless
  of the Enabled() gate so tests catch dispatch even when no
  transition results.

backend/radiance_test.go:
- TestPatchSettings_UnboundedDispatches: install the hook,
  PATCH {UnboundedKey:true}, assert hook fired once; PATCH
  {UnboundedKey:false}, assert hook fired twice. Then PATCH
  {PeerShareEnabledKey:false} (a key OTHER than UnboundedKey)
  and assert the counter doesn't move — confirms the diff
  check is in place rather than always firing.

Sanity-checked the test by removing the Apply call from
PatchSettings: test fails with 'expected 2, actual 0'.
Restored: test passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread unbounded/unbounded.go
Comment thread unbounded/unbounded.go
Two Copilot findings:

1. shouldStart treated any non-nil UnboundedConfig as runnable. A
   config refresh carrying an empty or partially-populated cfg
   block would start broflake with clientcore defaults — pointing
   at the upstream maintainer's discovery/egress infra rather than
   the per-environment URLs the server sends. That bypasses the
   server-supplied endpoint gate and the 'is this user opted in?'
   contract.

   Added cfgUsable(cfg) requiring all four URL fields
   (DiscoverySrv + DiscoveryEndpoint + EgressAddr + EgressEndpoint)
   non-empty. CTableSize / PTableSize stay optional — the server
   only sends them when it wants to override defaults. shouldStart
   now gates on cfgUsable rather than 'cfg != nil'.

   Tests primeManager and the rest of the suite now use a new
   testCfg() helper that returns a populated, predicate-passing
   cfg. Bare &C.UnboundedConfig{} literals were dropped (they'd
   now fail the runnable check). New TestShouldStart row covers
   the partial-cfg case.

2. OnConnectionChangeFunc kept emitting after stop signalled
   cancel — broflake's per-worker connection-change goroutines can
   fire concurrently with ui.Stop, and the broflake API doesn't
   promise no-callbacks-after-Stop. Callbacks that landed in this
   window pushed stale ConnectionEvents onto the event bus after
   the consumer thought Unbounded was off.

   Added a ctx.Err() check at the top of the callback closure.
   ctx is the same one cancelled by stop, so post-cancel
   callbacks short-circuit before reaching events.Emit. Mirrors
   peer.go's listenerDraining pattern; broflake doesn't expose an
   equivalent registration point, so the inline check is the
   next-best place.

Stress-tested 10x under -race -count=1, all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment thread unbounded/unbounded.go Outdated
Comment thread backend/radiance_test.go Outdated
Comment thread unbounded/test_helpers_test.go Outdated
Three small comment cleanups per AGENTS.md (no code-location
references in comments):

- unbounded.go: drop 'peer.go listenerDraining' phrasing from
  the cancellation-drain comment; describe the broflake-side
  constraint directly (no registration point to disarm, so the
  inline ctx check is the next-best place).
- backend/radiance_test.go: drop 'Companion to TestPatchSettings_
  PeerShareDispatches' framing from TestPatchSettings_
  UnboundedDispatches docstring; state the invariant directly.
- unbounded/test_helpers_test.go: drop 'Used by TestStartDuringStop'
  framing from countingWidget docstring; describe the helper's
  role (callback lets test observe shutdown ordering) without
  naming a consumer.

No behavior changes; tests unchanged + still clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants