Skip to content

Fix UI stuck on “Disconnected” during network-change engine restart#167

Open
pappz wants to merge 22 commits intomainfrom
fix/reconnection-notification
Open

Fix UI stuck on “Disconnected” during network-change engine restart#167
pappz wants to merge 22 commits intomainfrom
fix/reconnection-notification

Conversation

@pappz
Copy link
Copy Markdown
Collaborator

@pappz pappz commented Apr 20, 2026

Summary

Fixes the Android app appearing "Disconnected" during WiFi ↔ cellular roams, even though the engine eventually recovered. Also avoids unnecessary engine restarts when the Go core reconnects on its own faster than the Android debounce can fire.

Problem

Diagnosed from user logs of a WiFi → cellular → WiFi roam:

1. UI stuck on "Disconnected" during restart

EngineRestarter stops/restarts the Go engine on network changes but never emits a Connecting state. The old engine's teardown emits Disconnected before the new engine starts, leaving the UI stuck.

2. Spurious restart on cold start

Android's initial onAvailable burst after registerNetworkCallback was treated as a transition, cancelling the first login.

3. Missed network handover (seamless WiFi switch)

The previous detector relied on per-network onAvailable / onLost pairing. On seamless handovers, Android may omit onLost for the departing WiFi, leaving the WIFI flag set and preventing restart.

4. Unnecessary restart when Go reconnects on its own

The Go core reconnects management/signal/relay within ~1s of a network change, but Android's 2s debounce still triggers an engine restart, disrupting a healthy connection.

5. Pending restart fired after explicit stop / revoke

A debounced restart scheduled just before the user (or the system) stopped the VPN would still fire after the engine was already torn down, causing a spurious re-start of the engine.

Changes

Engine restart UX

Suppress old engine state events during restart

(EngineRestarter.java, EngineRunner.java)

  • Wrap active ConnectionListener to drop onDisconnecting / onDisconnected during restart.
  • Release filtering after the first onConnecting / onConnected from the new engine.
  • Add per-listener suppression in EngineRunner so ServiceStateListener.onStopped / onStarted are not delivered to UI consumers during restart.
  • UI state is driven only by ConnectionListener.
  • Emit synthetic onConnecting() during restart for proper UI feedback.
  • Emit onDisconnected() only on real failure or after the 30s timeout.
  • Unregister the restart's ServiceStateListener on the timeout path so a late onStopped cannot trigger runWithoutAuth against a stale listener.
  • Unwrap any prior ObservingConnectionListener (in EngineRunner.setConnectionListener) and FilteringConnectionListener (in EngineRestarter) so wrappers do not stack on repeated restart cycles.

Skip restart when engine reconnects autonomously

(EngineRestarter.java, EngineRunner.java)

  • EngineRunner.setConnectionListener wraps the listener and fans out onConnected to observers.
  • EngineRestarter registers an observer; if the engine reconnects while the 2s debounce is still pending, the scheduled restart is cancelled.

Cancel pending restart on explicit stop

(VPNService.java, EngineRestarter.java)

  • Add EngineRestarter.cancelPendingRestart() and call it on the stop-engine broadcast, on onRevoke, on runEngine, on stopEngine, and on the always-on start path before runWithoutAuth.

Network detection

Default-network-based type detection

(NetworkAvailabilityListener.java, ConcreteNetworkAvailabilityListener.java, NetworkChangeDetector.java)

  • Add onDefaultNetworkTypeChanged(int) sourced from the default-network callback.
  • Replace per-network onAvailable / onLost pairing for transition detection; the default-network signal is authoritative.
  • Trigger restart on transport change (WIFI ↔ MOBILE).
  • Skip processing when the default network's capabilities are unknown or the network is a VPN — avoids reacting to our own TUN being delivered as the default within the VpnService process.
  • The first observation after subscribe() is treated as the current state, not a transition — avoids the cold-start spurious restart.
  • A BooleanSupplier gate (engineRunner::isRunning) further suppresses notifications until the engine is actually running.
  • Serialize the callback bodies and the unregister teardown with a dedicated lock; track an defaultNetworkCallbackActive flag so callbacks delivered after unregisterNetworkCallback are ignored.

Notification

(ForegroundNotification.java)

  • Lower the foreground notification channel to IMPORTANCE_LOW, disable sound and vibration so the persistent VPN notification does not nag the user on every state change.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Smoother engine restart/reconnect behavior with debounced restarts, safer lifecycle handling, and observers for connection events.
    • Default-network-type notifications to better distinguish Wi‑Fi vs cellular as the system default.
  • Improvements

    • Less intrusive foreground notification: lowered priority, no sound, no vibration.
  • Bug Fixes

    • Pending restarts are cancelled when the engine is stopped/revoked to avoid unintended restarts.
  • Tests

    • Updated unit tests for network detection and notification logic.

pappz added 3 commits April 20, 2026 14:43
When EngineRestarter stopped and restarted the Go engine after a
network type change, the UI only saw the engine's onDisconnected
callback and had no visibility into the reconnect attempt. If the
restart stalled (e.g. on a stale management RPC), the UI stayed on
Disconnected for the full stall window, making it look like the
client never reconnected.

Emit onConnecting() from EngineRestarter at stop and at re-launch to
keep the UI in the Connecting state throughout the restart, and emit
onDisconnected() on error or the 30s safety timeout so a truly failed
restart doesn't leave the UI stuck on Connecting.
Pin the process's outgoing sockets to the current default Android
Network via ConnectivityManager.bindProcessToNetwork so fresh dials
after a WiFi/cellular switch do not stall on TCP SYN retransmits
through the departing interface.

Skip the initial onAvailable burst fired right after registering the
NetworkCallback. That burst reflects current state, not a transition,
and was triggering a spurious EngineRestarter restart that cancelled
the in-flight login on cold start.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

Debounces and cancels engine restarts, suppresses connection/service callbacks during restart windows and restores them on completion, introduces default-network-type change notifications and a default-network callback, gates network notifications on engine-running state, lowers foreground notification prominence, and advances the netbird submodule pointer.

Changes

Cohort / File(s) Summary
Engine restart & connection coordination
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java, tool/src/main/java/io/netbird/client/tool/EngineRunner.java
Adds debounced restarts with cancel support (cancelPendingRestart()); registers connected observers; wraps/unwraps ConnectionListener with FilteringConnectionListener during restarts to suppress disconnect events; snapshots/suppresses external ServiceStateListeners during restart and restores/dispatches saved callbacks on success/error/timeout; adds APIs to suppress/unsuppress and snapshot listeners.
Default-network detection & listener contract
tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java, tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java
Adds a default-network NetworkCallback and active gating; detects default transport type (WiFi/cellular) and notifies new onDefaultNetworkTypeChanged(@Constants.NetworkType int).
Network availability gating & listener behavior
tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java, tool/src/main/java/io/netbird/client/tool/VPNService.java
Concrete listener tracks last type/first-observation and accepts BooleanSupplier shouldNotify to gate notifications; VPNService constructs listener with engineRunner::isRunning and cancels pending restarts on stop/revoke/start paths.
Foreground notification
tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
Reduces channel importance to IMPORTANCE_LOW, disables sound and vibration.
Tests
tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java
Refactors tests to new onDefaultNetworkTypeChanged interface and shouldNotify injection; adds tests for duplicate/no-initial notifications and gating when shouldNotify is false.
Submodule pointer
netbird
Advances the checked-in netbird git submodule commit SHA.

Sequence Diagram

sequenceDiagram
    participant System as System (Network Event)
    participant NCD as NetworkChangeDetector
    participant CNAL as ConcreteNetworkAvailabilityListener
    participant ERestarter as EngineRestarter
    participant ERunner as EngineRunner
    participant ServiceListeners as External ServiceStateListeners

    System->>NCD: default network changes
    NCD->>CNAL: onDefaultNetworkTypeChanged(type)

    rect rgba(100,149,237,0.5)
    CNAL->>CNAL: if shouldNotify()==false -> return
    end

    alt shouldNotify true
        CNAL->>ERestarter: notify network change (may trigger restart)
        ERestarter->>ERunner: getConnectionListener(), snapshotExternalListeners()
        ERestarter->>ERunner: suppress external ServiceStateListeners
        ERestarter->>ERunner: wrap ConnectionListener with FilteringConnectionListener
        ERestarter->>ERunner: trigger synthetic onConnecting()
        ERestarter->>ERunner: perform restart sequence (stop/start)
        alt restart succeeds
            ERunner->>ServiceListeners: onStarted (unsuppress)
            ERestarter->>ERunner: restore original listener
        else timeout/error
            ERestarter->>ERunner: emit saved onDisconnected to original listener, unsuppress ServiceListeners
            ERestarter->>ERunner: restore original listener
        end
    else
        CNAL-->>ERestarter: no action (skipped)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • lixmal
  • doromaraujo
  • mlsmaycon

Poem

🐰
I nudge the nets and softly bound,
Quiet restarts where hops are found,
I hush the clatter, wait to see,
When engines sing, I let them be,
A tiny hop, calm and sound.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely describes the main UX bug being fixed: preventing the UI from showing "Disconnected" during network-change engine restarts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/reconnection-notification

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pappz pappz changed the title Fix/reconnection notification Fix UI stuck on “Disconnected” during network-change engine restart Apr 20, 2026
pappz added 4 commits April 20, 2026 15:40
Replace the time-based grace window with an isEngineRunning predicate.
The initial onAvailable burst that Android fires right after
registerNetworkCallback cannot trigger an EngineRestarter run because
the engine is not up yet at that point.

Tests updated accordingly; adds coverage for the engine-not-running
path.
Use IMPORTANCE_LOW and explicitly clear sound/vibration on the channel
so the persistent VPN notification does not play a sound or vibrate on
creation or each connection state update.
@pappz pappz marked this pull request as ready for review April 21, 2026 09:02
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java (1)

52-57: ⚠️ Potential issue | 🟠 Major

Remove the restart listener when the timeout fires.

After the 30s timeout, isRestartInProgress is reset and onDisconnected() is emitted, but the anonymous ServiceStateListener remains registered. If the engine stops later, that stale listener can still call runWithoutAuth() and restart after the timeout path declared failure.

🔧 Proposed fix
         timeoutCallback = () -> {
             if (isRestartInProgress) {
                 Log.e(LOGTAG, "engine restart timeout - forcing flag reset");
                 isRestartInProgress = false;
+                if (currentListener != null) {
+                    engineRunner.removeServiceStateListener(currentListener);
+                    currentListener = null;
+                }
                 notifyDisconnected();
             }
         };
@@
             public void onStarted() {
                 Log.d(LOGTAG, "engine restarted successfully");
                 isRestartInProgress = false;  // Reset flag on success
                 handler.removeCallbacks(timeoutCallback);  // Cancel timeout
                 engineRunner.removeServiceStateListener(this);
+                currentListener = null;
             }
@@
             public void onError(String msg) {
                 Log.e(LOGTAG, "restart failed: " + msg);
                 isRestartInProgress = false; // Resetting flag on error as well
                 handler.removeCallbacks(timeoutCallback);  // Cancel timeout
                 engineRunner.removeServiceStateListener(this);
+                currentListener = null;
                 notifyDisconnected();
             }

Also applies to: 65-90, 142-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java` around lines
52 - 57, The timeoutCallback currently resets isRestartInProgress and calls
notifyDisconnected() but leaves the anonymous ServiceStateListener registered;
update timeoutCallback to also unregister/remove that listener (the same
instance registered earlier) when the timeout fires so the stale
ServiceStateListener can't later call runWithoutAuth() and trigger another
restart; apply the same fix to the other restart-timeout handlers in the file
(the blocks around the ServiceStateListener registration in the 65-90 and
142-155 sections) ensuring you keep a reference to the ServiceStateListener
instance so you can call the appropriate remove/unregister method when
cancelling on success or timeout.
tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java (1)

25-31: ⚠️ Potential issue | 🟠 Major

Use a new channel ID for silent/low-importance notification settings to affect existing installs.

When createNotificationChannel() is called with an existing channel ID, Android ignores updates to sound, vibration, and lights properties—these can only be set on initial channel creation. Importance can only be lowered if the user hasn't modified channel settings. Existing users will retain their original audible behavior unless you use a different channel ID.

Suggested fix
-        String channelId = service.getPackageName();
+        String channelId = service.getPackageName() + ".foreground.silent";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java` around
lines 25 - 31, ForegroundNotification currently re-uses service.getPackageName()
as the NotificationChannel ID which prevents changing sound/vibration for
existing installs; change to a new, distinct channel ID (e.g., a constant like
FOREGROUND_CHANNEL_ID_SILENT or service.getPackageName() + ".fg_silent") used
when creating the NotificationChannel in the same creation block (the
NotificationChannel constructor and channel.setSound/enableVibration calls) and
update any places that build/post the foreground Notification to use that new
channel ID so the silent/low-importance settings apply to all users.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbird`:
- Line 1: The submodule pointer references commit
5b09078da2ac14550741ce8731e7cf4b4a62a728 which is not reachable; verify whether
that commit exists in the official netbirdio/netbird.git or a private fork, then
update the submodule to a valid commit: check the branch containing the intended
change, fetch the correct commit hash (or switch the submodule URL if it should
point to a different repo), and update the submodule reference (e.g., via git
submodule update --init --remote or by committing the corrected SHA in the
superproject and updating .gitmodules if the URL must change) so cloning with
--recurse-submodules succeeds.

In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 63-85: The onAvailable/onLost handlers in
initDefaultNetworkCallback must be guarded by an atomic/current bound-network
state and a callback-active flag so late onLost or onAvailable after unregister
don't undo a newer binding; add a field (e.g., defaultNetworkCallbackActive) set
to true before registering the defaultNetworkCallback and set to false when
unregisterNetworkCallback begins, track the currentlyBoundDefaultNetwork (or
similar) when bindProcessToNetwork(network) succeeds, and in onLost only clear
the binding if the lost network equals the currentlyBoundDefaultNetwork and
defaultNetworkCallbackActive is true; likewise ignore onAvailable if
defaultNetworkCallbackActive is false to avoid rebinding after shutdown.

---

Outside diff comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java`:
- Around line 52-57: The timeoutCallback currently resets isRestartInProgress
and calls notifyDisconnected() but leaves the anonymous ServiceStateListener
registered; update timeoutCallback to also unregister/remove that listener (the
same instance registered earlier) when the timeout fires so the stale
ServiceStateListener can't later call runWithoutAuth() and trigger another
restart; apply the same fix to the other restart-timeout handlers in the file
(the blocks around the ServiceStateListener registration in the 65-90 and
142-155 sections) ensuring you keep a reference to the ServiceStateListener
instance so you can call the appropriate remove/unregister method when
cancelling on success or timeout.

In `@tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java`:
- Around line 25-31: ForegroundNotification currently re-uses
service.getPackageName() as the NotificationChannel ID which prevents changing
sound/vibration for existing installs; change to a new, distinct channel ID
(e.g., a constant like FOREGROUND_CHANNEL_ID_SILENT or service.getPackageName()
+ ".fg_silent") used when creating the NotificationChannel in the same creation
block (the NotificationChannel constructor and channel.setSound/enableVibration
calls) and update any places that build/post the foreground Notification to use
that new channel ID so the silent/low-importance settings apply to all users.
🪄 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: CHILL

Plan: Pro

Run ID: b5d47af5-9d72-4650-b86d-dbd761803e47

📥 Commits

Reviewing files that changed from the base of the PR and between e92798a and b2d0f6d.

📒 Files selected for processing (8)
  • netbird
  • tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
  • tool/src/main/java/io/netbird/client/tool/EngineRunner.java
  • tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
  • tool/src/main/java/io/netbird/client/tool/VPNService.java
  • tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
  • tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java

Comment thread netbird Outdated
pappz added 2 commits April 24, 2026 22:05
Track the currently bound default network and an active flag so late
onLost callbacks cannot clear a newer binding and post-unregister
onAvailable callbacks cannot rebind after shutdown.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java (1)

110-116: registerNetworkCallback() is not idempotent and can leak callback state.

Two concerns here:

  1. If a caller invokes registerNetworkCallback() twice without an intervening unregisterNetworkCallback(), registerDefaultNetworkCallback(defaultNetworkCallback) will throw IllegalArgumentException ("NetworkCallback was already registered"). defaultNetworkCallbackActive is already true by then, and the first networkCallback is also re-registered — same failure mode applies to registerNetworkCallback(..., networkCallback) on L113.
  2. If registerDefaultNetworkCallback on L115 throws for any reason (e.g., transient service error), defaultNetworkCallbackActive is left as true while no callback is actually subscribed. Subsequent unregisterNetworkCallback() will try to unregister an unregistered callback (caught) but the state flag is otherwise harmless. Still, wrapping L115 in try/catch that reverts the flag on failure would keep state consistent.

Not blocking, but worth a small defensive tweak.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`
around lines 110 - 116, Make registerNetworkCallback() idempotent and keep the
boolean state consistent: before calling
connectivityManager.registerNetworkCallback(...) or
registerDefaultNetworkCallback(...), check whether
networkCallback/defaultNetworkCallback are already registered (use
defaultNetworkCallbackActive and a similar flag for networkCallback) and return
early if so; set the corresponding active flag only after the register call
succeeds; wrap registerDefaultNetworkCallback(defaultNetworkCallback) in a
try/catch that reverts defaultNetworkCallbackActive on failure and gracefully
handles IllegalArgumentException/RuntimeException to avoid leaking state; ensure
unregisterNetworkCallback() relies on these flags to decide whether to call
connectivityManager.unregisterNetworkCallback(...).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 68-108: The onAvailable/onLost callbacks in
initDefaultNetworkCallback have a TOCTOU race with unregisterNetworkCallback
because defaultNetworkCallbackActive is checked outside the bindProcessToNetwork
calls; make the gate check and the bind (and the currentlyBoundDefaultNetwork
updates) atomic by synchronizing them on a dedicated lock object (e.g., a
private final Object networkCallbackLock), i.e., wrap the checks of
defaultNetworkCallbackActive plus the subsequent
connectivityManager.bindProcessToNetwork(...) and
currentlyBoundDefaultNetwork.set/compareAndSet(...) in a
synchronized(networkCallbackLock) block, and also wrap the
unregisterNetworkCallback logic that sets defaultNetworkCallbackActive to false,
unregisters the callback, calls bindProcessToNetwork(null), and clears
currentlyBoundDefaultNetwork inside the same synchronized(networkCallbackLock)
to eliminate the race.

---

Nitpick comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 110-116: Make registerNetworkCallback() idempotent and keep the
boolean state consistent: before calling
connectivityManager.registerNetworkCallback(...) or
registerDefaultNetworkCallback(...), check whether
networkCallback/defaultNetworkCallback are already registered (use
defaultNetworkCallbackActive and a similar flag for networkCallback) and return
early if so; set the corresponding active flag only after the register call
succeeds; wrap registerDefaultNetworkCallback(defaultNetworkCallback) in a
try/catch that reverts defaultNetworkCallbackActive on failure and gracefully
handles IllegalArgumentException/RuntimeException to avoid leaking state; ensure
unregisterNetworkCallback() relies on these flags to decide whether to call
connectivityManager.unregisterNetworkCallback(...).
🪄 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: CHILL

Plan: Pro

Run ID: f8b16d92-e50e-4c8b-95fb-329fb80c36fb

📥 Commits

Reviewing files that changed from the base of the PR and between b2d0f6d and ff71758.

📒 Files selected for processing (1)
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java

Add a dedicated lock and wrap the default network callback's onAvailable
and onLost bodies, plus the unregister teardown, in synchronized blocks
to close the TOCTOU race where a stale callback could re-bind the
process after unregisterNetworkCallback had cleared the binding.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 115-121: The registerNetworkCallback() method sets
defaultNetworkCallbackActive and calls
connectivityManager.registerDefaultNetworkCallback(...) outside the
networkCallbackLock, creating a race with unregisterNetworkCallback(); move the
defaultNetworkCallbackActive.set(true) and the call to
connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback)
inside the same synchronized(networkCallbackLock) block used for registering the
primary networkCallback so the flag and default callback registration are
performed atomically, mirroring unregisterNetworkCallback()'s symmetry and
preventing a leaked defaultNetworkCallback when concurrent register/unregister
occur.
🪄 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: CHILL

Plan: Pro

Run ID: c6d65f2e-b0d1-4d0c-b801-df75e81ddbf2

📥 Commits

Reviewing files that changed from the base of the PR and between ff71758 and 48ae2d5.

📒 Files selected for processing (1)
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java

pappz added 8 commits April 27, 2026 16:02
If registerDefaultNetworkCallback ever delivers our own TUN as the
default network, binding the process to it would create a routing loop.
Log a warning to surface that case if it happens on any device.
Mirror the unregister teardown's locking by wrapping the active flag and
registerDefaultNetworkCallback in the same synchronized block. Closes the
asymmetry between register and unregister so concurrent calls cannot leak
the default callback or leave the active flag inconsistent.
Detach the ConnectionListener before stopping the engine so the old
engine's Disconnecting/Disconnected teardown events do not reach the UI
and cause a brief visible Disconnected flash before the restart kicks
in. The listener is re-attached after the new engine starts; the Go
notifier delivers the current state on attach so the UI converges
without our help.

While the engine is detached, the EngineRestarter drives the UI itself
via notifyConnecting on stop and notifyDisconnected on timeout/error.
Replace the per-network onAvailable/onLost pairing with a default-network
type observation. Android sometimes skips onLost on seamless WiFi
handovers, leaving the previous mechanism unable to detect the
transition. The default-network callback delivers the authoritative
current transport, so any change of type triggers an engine restart.
Two related changes to avoid disrupting a working connection during a
network handover:

- Filter Disconnecting/Disconnected events from the old engine teardown
  via a wrapper around ConnectionListener, and suppress per-listener the
  ServiceStateListener.onStopped/onStarted notifications so the UI does
  not flash through Disconnected during the restart window.

- Subscribe to OnConnected events from the engine. If the Go core
  reconnects autonomously while the 2s restart debounce is still
  pending, cancel the restart instead of tearing down the working
  connection.
Picks up the fix that prevents transient JOB stream errors from being
reported as a management disconnect, which would otherwise stick the
UI on Connecting after the JOB stream silently reconnects.
On some devices the default network callback delivers our own TUN as
the default within a VpnService process. Binding the process to that
risks a routing loop. The Android default-network signal is replayed
seconds later with the underlying physical network, so skipping the
bind on a VPN result waits for that follow-up signal instead.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java (1)

39-68: ⚠️ Potential issue | 🟡 Minor

Minor: tiny race between onEngineReconnected cancellation and the runnable executing.

onEngineReconnected uses handler.removeCallbacks(restartRunnable) to abort, but if restartRunnable has already begun executing, removeCallbacks won't stop it and we'll proceed to tear down a freshly-reconnected engine. Both flow on the main looper so the window is small, but a single extra check after the isRestartInProgress guard would close it cleanly:

     private void restartEngine() {
-        restartScheduled = false;
+        if (!restartScheduled) {
+            // onEngineReconnected (or cleanup) cancelled this run before it executed.
+            return;
+        }
+        restartScheduled = false;

Couple this with an explicit restartScheduled = true set inside onNetworkTypeChanged (already present) and the runnable becomes idempotent against late cancellation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java` around lines
39 - 68, There’s a tiny race where handler.removeCallbacks(restartRunnable) in
onEngineReconnected won’t stop the runnable if it already started; make the
runnable idempotent by checking the cancellation flag again at its start (e.g.,
read restartScheduled and/or isRestartInProgress) so a late-started runnable
bails out, and ensure onNetworkTypeChanged sets restartScheduled = true as
noted; update restartRunnable and restartEngine to re-check restartScheduled
(and respect isRestartInProgress) immediately before doing any teardown so late
cancellations are honored.
🧹 Nitpick comments (2)
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java (1)

75-80: FilteringConnectionListener accumulates across successive restarts.

On onStarted/onError/timeout we only flip the filter to passthrough mode (allowAfterFirstConnectingOrConnected() / allowAll()) but never reinstall the original savedListener on the runner. The next restart then reads engineRunner.getConnectionListener() — which is the previous FilteringConnectionListener — and wraps it again, so each WiFi↔cellular handover adds another wrapper layer to the chain.

Functionally each wrapper becomes transparent after release, so this isn't a correctness bug, but over many handovers it's an unbounded growth of nested delegates and try/catch hops on every connection event. The cleanest fix is to restore savedListener on the success path (after the first onConnecting/onConnected arrives, where the filter window naturally ends) and on the error/timeout paths.

♻️ One option: restore the original on terminal events
             public void onStarted() {
                 ...
                 engineRunner.removeServiceStateListener(this);
-                if (filteringListener != null) {
-                    filteringListener.allowAfterFirstConnectingOrConnected();
-                }
+                if (filteringListener != null) {
+                    filteringListener.allowAfterFirstConnectingOrConnected();
+                    // Once filtering naturally releases on the first Connecting/Connected,
+                    // swap the wrapper out so it doesn't accumulate across restarts.
+                    filteringListener.setOnRelease(() -> engineRunner.setConnectionListener(savedListener));
+                }
                 unsuppressAll(suppressedHolder.get());
             }
@@
             public void onError(String msg) {
                 ...
                 if (filteringListener != null) {
-                    filteringListener.allowAll();
+                    filteringListener.allowAll();
+                    engineRunner.setConnectionListener(savedListener);
                 }
                 ...
             }

FilteringConnectionListener would gain a one-shot setOnRelease(Runnable) invoked from the spot that flips dropDisconnects = false. Alternatively, on entry to restartEngine you can unwrap any pre-existing FilteringConnectionListener before re-wrapping.

Also applies to: 114-117, 132-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java` around lines
75 - 80, The FilteringConnectionListener wrappers accumulate because
restartEngine wraps whatever is returned by engineRunner.getConnectionListener()
without restoring the original; fix by ensuring the original savedListener is
reinstalled on terminal paths (onStarted/onError/timeout) or by unwrapping any
existing FilteringConnectionListener before creating a new one: in
EngineRestarter.restartEngine, detect if engineRunner.getConnectionListener() is
an instance of FilteringConnectionListener and retrieve its delegate (the
original savedListener) before creating a new FilteringConnectionListener,
and/or add a one-shot release callback on FilteringConnectionListener that
invokes engineRunner.setConnectionListener(savedListener) when the filter window
ends (allowAfterFirstConnectingOrConnected()/allowAll()) so
setConnectionListener(savedListener) is called on success, error, or timeout to
prevent nested wrappers.
tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java (1)

9-9: availableNetworkTypes is unused and should be removed.

This field is maintained in onNetworkAvailable (line 29) and onNetworkLost (line 34) but never read anywhere in this class or referenced elsewhere in the codebase. Notification logic is driven by lastDefaultType in onDefaultNetworkTypeChanged, making this tracking redundant state from the pre-refactor logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java`
at line 9, Remove the redundant field availableNetworkTypes from
ConcreteNetworkAvailabilityListener and any updates to it in onNetworkAvailable
and onNetworkLost; keep notification logic driven solely by lastDefaultType as
handled in onDefaultNetworkTypeChanged and ensure no other methods reference
availableNetworkTypes before deletion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 80-94: The code currently proceeds to bindProcessToNetwork when
getNetworkCapabilities(network) returns null, which bypasses the VPN
routing-loop guard; change the early capability check in the
NetworkChangeDetector logic to treat a null NetworkCapabilities as
unknown/unsafe and skip binding (i.e., return when caps == null ||
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)), mirroring the
behavior of checkNetworkCapabilities and ensuring bindProcessToNetwork(network)
is only attempted when capabilities are present and explicitly indicate non-VPN;
update any logging (LOGTAG) to reflect skipping due to unknown capabilities and
leave currentlyBoundDefaultNetwork.set only inside the successful bind branch.

---

Outside diff comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java`:
- Around line 39-68: There’s a tiny race where
handler.removeCallbacks(restartRunnable) in onEngineReconnected won’t stop the
runnable if it already started; make the runnable idempotent by checking the
cancellation flag again at its start (e.g., read restartScheduled and/or
isRestartInProgress) so a late-started runnable bails out, and ensure
onNetworkTypeChanged sets restartScheduled = true as noted; update
restartRunnable and restartEngine to re-check restartScheduled (and respect
isRestartInProgress) immediately before doing any teardown so late cancellations
are honored.

---

Nitpick comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java`:
- Around line 75-80: The FilteringConnectionListener wrappers accumulate because
restartEngine wraps whatever is returned by engineRunner.getConnectionListener()
without restoring the original; fix by ensuring the original savedListener is
reinstalled on terminal paths (onStarted/onError/timeout) or by unwrapping any
existing FilteringConnectionListener before creating a new one: in
EngineRestarter.restartEngine, detect if engineRunner.getConnectionListener() is
an instance of FilteringConnectionListener and retrieve its delegate (the
original savedListener) before creating a new FilteringConnectionListener,
and/or add a one-shot release callback on FilteringConnectionListener that
invokes engineRunner.setConnectionListener(savedListener) when the filter window
ends (allowAfterFirstConnectingOrConnected()/allowAll()) so
setConnectionListener(savedListener) is called on success, error, or timeout to
prevent nested wrappers.

In
`@tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java`:
- Line 9: Remove the redundant field availableNetworkTypes from
ConcreteNetworkAvailabilityListener and any updates to it in onNetworkAvailable
and onNetworkLost; keep notification logic driven solely by lastDefaultType as
handled in onDefaultNetworkTypeChanged and ensure no other methods reference
availableNetworkTypes before deletion.
🪄 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: CHILL

Plan: Pro

Run ID: 8918a2d0-38fa-470d-91f2-7420fe32b624

📥 Commits

Reviewing files that changed from the base of the PR and between 48ae2d5 and 380808a.

📒 Files selected for processing (7)
  • netbird
  • tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
  • tool/src/main/java/io/netbird/client/tool/EngineRunner.java
  • tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
  • tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java
  • tool/src/main/java/io/netbird/client/tool/EngineRunner.java

pappz added 3 commits April 27, 2026 18:38
Treat a null NetworkCapabilities as unsafe and skip the bind. The
previous null-tolerant check would have bypassed the VPN routing-loop
guard if Android happened to return null in a race between the
default-network callback firing and getNetworkCapabilities.
A debounced restart scheduled in response to a network change can fire
after the user has manually started or stopped the engine, killing the
user's action mid-flight (auth context canceled, restart fails, UI
stays Disconnected).

Cancel any pending restart before the user-facing entry points run:
binder runEngine/stopEngine, broadcast stop, always-on start, and VPN
permission revoke. The EngineRestarter's own internal stop+restart
remains unaffected.
Reverts the bindProcessToNetwork side of f0df3f5. Pinning the process
to the current default network helps when the kernel routing table
lags the actual network change, but hurts when Android lingers a
departing network as default for tens of seconds: every fresh socket
gets stuck on a dying interface.

The default-network callback now only feeds the type-change signal
used for engine restart decisions; the kernel decides which interface
new sockets actually use.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java (1)

116-129: ⚠️ Potential issue | 🔴 Critical

Clear the active flag before unregistering callbacks.

unregisterNetworkCallback(networkCallback) at line 118 runs while defaultNetworkCallbackActive is still true, creating a window where a pending defaultNetworkCallback.onAvailable() can acquire the lock, see the flag as true (line 74), and notify the listener (line 100) even though shutdown has started. Set the flag to false first under networkCallbackLock, then unregister both callbacks.

🔧 Suggested fix
 public void unregisterNetworkCallback() {
+    synchronized (networkCallbackLock) {
+        defaultNetworkCallbackActive.set(false);
+    }
     try {
         connectivityManager.unregisterNetworkCallback(networkCallback);
     } catch (Exception e) {
         Log.e(LOGTAG, "failed to unregister network callback", e);
     }
     synchronized (networkCallbackLock) {
-        defaultNetworkCallbackActive.set(false);
         try {
             connectivityManager.unregisterNetworkCallback(defaultNetworkCallback);
         } catch (Exception e) {
             Log.e(LOGTAG, "failed to unregister default network callback", e);
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`
around lines 116 - 129, Clear the race by setting defaultNetworkCallbackActive
to false under networkCallbackLock before any unregister calls: acquire
networkCallbackLock, set defaultNetworkCallbackActive.set(false), then call
connectivityManager.unregisterNetworkCallback(defaultNetworkCallback) and
connectivityManager.unregisterNetworkCallback(networkCallback) (each guarded
with try/catch and logging) so that defaultNetworkCallback.onAvailable() cannot
observe the flag as true during shutdown; update the logic around
networkCallback, defaultNetworkCallback, defaultNetworkCallbackActive,
networkCallbackLock, and unregisterNetworkCallback accordingly.
♻️ Duplicate comments (1)
tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java (1)

106-113: ⚠️ Potential issue | 🟠 Major

Rollback the active flag if default-callback registration fails.

The registerDefaultNetworkCallback() call on Android can throw a SecurityException (especially on Android 11 due to a platform bug) or TooManyRequestsException. If it throws, the defaultNetworkCallbackActive flag remains true even though the callback was never registered, creating a state inconsistency that leaves teardown in an incorrect state.

Suggested fix
 synchronized (networkCallbackLock) {
     defaultNetworkCallbackActive.set(true);
-    connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback);
+    try {
+        connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback);
+    } catch (RuntimeException e) {
+        defaultNetworkCallbackActive.set(false);
+        throw e;
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`
around lines 106 - 113, In registerNetworkCallback(), avoid leaving
defaultNetworkCallbackActive set to true if
connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback)
throws (e.g., SecurityException or TooManyRequestsException): perform the
registerDefaultNetworkCallback call inside the synchronized
(networkCallbackLock) block and only set defaultNetworkCallbackActive.set(true)
after the call succeeds, or catch those specific exceptions inside the
synchronized block, rollback defaultNetworkCallbackActive.set(false) and rethrow
or handle/log as appropriate; ensure you reference the existing symbols
(registerNetworkCallback, defaultNetworkCallbackActive, networkCallbackLock,
connectivityManager.registerDefaultNetworkCallback, defaultNetworkCallback) so
the flag state stays consistent on failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java`:
- Around line 86-95: The timeout branch currently leaves currentListener
registered so a late onStopped() can call engineRunner.runWithoutAuth(); update
the timeoutCallback (and the similar branches around the other noted blocks) to
unregister the listener before resetting state: explicitly set currentListener =
null (or call the existing unregister method if present) right after handling
filteringListener/unsuppressAll and before notifyDisconnected(savedListener),
and do the same in the other restart-failure/abort paths so that onStopped()
cannot run against a stale listener and trigger engineRunner.runWithoutAuth().
- Around line 75-80: Saved listener wrappers are being stacked on each restart
because EngineRestarter calls getConnectionListener() (EngineRestarter) which
returns an already-wrapped listener and then wraps it again in
FilteringConnectionListener before calling EngineRunner.setConnectionListener(),
causing nested wrappers; fix by unwrapping any existing wrapper before
reinstalling: when reading savedListener from
engineRunner.getConnectionListener(), detect if savedListener is an instance of
FilteringConnectionListener (or the EngineRunner anonymous wrapper) and extract
the underlying delegate (add a small accessor like
FilteringConnectionListener.getDelegate() if needed) and pass that raw
user-provided listener into engineRunner.setConnectionListener(); apply the same
unwrapping logic to the other similar sites mentioned (the blocks around lines
114–116 and 146–148) or alternatively change
EngineRunner.setConnectionListener() to ignore/wrap only raw user listeners to
prevent double-wrapping.

---

Outside diff comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 116-129: Clear the race by setting defaultNetworkCallbackActive to
false under networkCallbackLock before any unregister calls: acquire
networkCallbackLock, set defaultNetworkCallbackActive.set(false), then call
connectivityManager.unregisterNetworkCallback(defaultNetworkCallback) and
connectivityManager.unregisterNetworkCallback(networkCallback) (each guarded
with try/catch and logging) so that defaultNetworkCallback.onAvailable() cannot
observe the flag as true during shutdown; update the logic around
networkCallback, defaultNetworkCallback, defaultNetworkCallbackActive,
networkCallbackLock, and unregisterNetworkCallback accordingly.

---

Duplicate comments:
In
`@tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java`:
- Around line 106-113: In registerNetworkCallback(), avoid leaving
defaultNetworkCallbackActive set to true if
connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback)
throws (e.g., SecurityException or TooManyRequestsException): perform the
registerDefaultNetworkCallback call inside the synchronized
(networkCallbackLock) block and only set defaultNetworkCallbackActive.set(true)
after the call succeeds, or catch those specific exceptions inside the
synchronized block, rollback defaultNetworkCallbackActive.set(false) and rethrow
or handle/log as appropriate; ensure you reference the existing symbols
(registerNetworkCallback, defaultNetworkCallbackActive, networkCallbackLock,
connectivityManager.registerDefaultNetworkCallback, defaultNetworkCallback) so
the flag state stays consistent on failure.
🪄 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: CHILL

Plan: Pro

Run ID: 709606a8-dd3f-437a-8071-fc93c36b7105

📥 Commits

Reviewing files that changed from the base of the PR and between 380808a and fb3a440.

📒 Files selected for processing (3)
  • tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
  • tool/src/main/java/io/netbird/client/tool/VPNService.java
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java

Comment thread tool/src/main/java/io/netbird/client/tool/EngineRestarter.java Outdated
Comment thread tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
EngineRunner.setConnectionListener stacked a fresh ObservingConnectionListener
around whatever it received, so EngineRestarter snapshotting the current
listener and re-installing a FilteringConnectionListener around it grew the
chain by one level on every restart cycle. Unwrap any prior
ObservingConnectionListener inside setConnectionListener and any prior
FilteringConnectionListener when EngineRestarter snapshots, so the chain
stays at most two layers deep across repeated restarts.

Also unregister the restart's ServiceStateListener (and clear
currentListener) on the 30s timeout path so a late onStopped cannot fire
runWithoutAuth against a stale listener and silently restart the engine
after the timeout already gave up. Mirror the cleanup in onStarted and
onError for consistency.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java (1)

113-127: 🛠️ Refactor suggestion | 🟠 Major

FilteringConnectionListener is not restored to original on successful restart, causing wrapper accumulation.

After onStarted(), the code calls filteringListener.allowAfterFirstConnectingOrConnected() but does not restore the original listener via setConnectionListener(savedListener). The FilteringConnectionListener remains installed (with filtering disabled).

On the next restart cycle:

  1. getConnectionListener() returns ObservingConnectionListenerFilteringConnectionListener → original
  2. unwrapFilter() doesn't drill through ObservingConnectionListener, so it returns the ObservingConnectionListener
  3. A new FilteringConnectionListener wraps that
  4. setConnectionListener() wraps it in another ObservingConnectionListener

This causes listener wrappers to accumulate with each restart. While functionally correct (events still propagate), it wastes memory and causes connectedObservers to run multiple times.

💡 Option 1: Restore original listener on success
             public void onStarted() {
                 Log.d(LOGTAG, "engine restarted successfully");
                 isRestartInProgress = false;
                 handler.removeCallbacks(timeoutCallback);
                 engineRunner.removeServiceStateListener(this);
                 currentListener = null;
-                if (filteringListener != null) {
-                    filteringListener.allowAfterFirstConnectingOrConnected();
-                }
+                // Restore the original listener now that restart completed
+                if (savedListener != null) {
+                    engineRunner.setConnectionListener(savedListener);
+                }
                 unsuppressAll(suppressedHolder.get());
             }
💡 Option 2: Make unwrapFilter handle both wrapper types

Since ObservingConnectionListener is private in EngineRunner, this would require exposing it or adding a package-level unwrap helper in EngineRunner that EngineRestarter can call.

Also applies to: 185-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java` around lines
113 - 127, The FilteringConnectionListener wrapper is never removed after a
successful restart, causing wrapper accumulation; in EngineRestarter.onStarted()
(and the corresponding success path around lines ~185-191) after calling
filteringListener.allowAfterFirstConnectingOrConnected() and unsuppressAll(...),
restore the original listener by calling
engineRunner.setConnectionListener(savedListener) (or
setConnectionListener(currentListenerSaved) if you store it) and clear
filteringListener/currentListener as done elsewhere; ensure you reference and
use the saved original listener variable used when installing the filter (and
update any other success/cleanup paths to do the same) so unwrapFilter() is no
longer necessary to traverse ObservingConnectionListener wrappers.
🧹 Nitpick comments (2)
tool/src/main/java/io/netbird/client/tool/VPNService.java (1)

88-90: Consistent cancellation of pending restarts across lifecycle events - minor style inconsistency.

All the cancelPendingRestart() call sites correctly ensure that any pending network-change-driven restart is cancelled when explicit engine lifecycle actions occur (stop broadcast, always-on start, revoke, user-driven start/stop). This prevents spurious restarts from interfering with explicit user/system actions.

Minor observation: The null check pattern if (engineRestarter != null) is used in the broadcast receiver (line 88) and onRevoke (line 174), but not in onStartCommand (line 115) or the binder methods (lines 203, 208). Since engineRestarter is initialized in onCreate() before any of these methods can be invoked, the null check isn't strictly necessary anywhere. For consistency, either add null checks everywhere or remove them where present.

Also applies to: 115-115, 174-176, 203-203, 208-208

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/VPNService.java` around lines 88 -
90, Remove the redundant null-checks around engineRestarter so the call sites
are consistent: call engineRestarter.cancelPendingRestart() directly (without if
(engineRestarter != null) guards) in the broadcast receiver and onRevoke to
match the existing direct calls in onStartCommand and the binder methods in
VPNService; locate usages by searching for engineRestarter and
cancelPendingRestart within the VPNService class and update the broadcast
receiver and onRevoke sites accordingly.
tool/src/main/java/io/netbird/client/tool/EngineRunner.java (1)

129-146: Wrapper stacking occurs when EngineRestarter installs its FilteringConnectionListener.

The problem: getConnectionListener() returns the wrapped listener (ObservingConnectionListener), and EngineRestarter.unwrapFilter() only removes FilteringConnectionListener wrappers. When EngineRestarter snapshots and re-wraps:

  1. savedListener = unwrapFilter(getConnectionListener()) returns ObservingConnectionListener → userListener (unwrapFilter stops at non-FilteringConnectionListener types)
  2. filteringListener = new FilteringConnectionListener(savedListener) creates FilteringConnectionListener → ObservingConnectionListener → userListener
  3. setConnectionListener(filteringListener) calls unwrap() which returns it unchanged (not an ObservingConnectionListener), then wraps it again
  4. Final chain: ObservingConnectionListener → FilteringConnectionListener → ObservingConnectionListener → userListener

This causes connectedObservers to fire twice on each onConnected event. The impact is limited because EngineRestarter.onEngineReconnected() is idempotent (due to the restartScheduled flag), making this wasteful rather than breaking.

Consider tracking the raw user-provided listener separately so EngineRestarter can safely wrap and unwrap without stacking layers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tool/src/main/java/io/netbird/client/tool/EngineRunner.java` around lines 129
- 146, The current setConnectionListener/unwrap design allows
ObservingConnectionListener wrappers to be duplicated when EngineRestarter
rewraps a saved listener; modify EngineRunner to track the raw user-provided
listener separately (e.g., add a private field rawConnectionListener) so
setConnectionListener(rawOrWrapped) will: 1) extract the true user listener
(using or replacing unwrap if needed) and store it in rawConnectionListener, 2)
create exactly one ObservingConnectionListener around that raw listener and
assign that to connectionListener and goClient.setConnectionListener; also add a
getter like getRawConnectionListener() so EngineRestarter can snapshot and wrap
the raw listener with FilteringConnectionListener without causing stacking of
ObservingConnectionListener wrappers (keep references to
ObservingConnectionListener, FilteringConnectionListener, setConnectionListener,
unwrap, getConnectionListener and EngineRestarter.unwrapFilter in mind).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java`:
- Around line 113-127: The FilteringConnectionListener wrapper is never removed
after a successful restart, causing wrapper accumulation; in
EngineRestarter.onStarted() (and the corresponding success path around lines
~185-191) after calling filteringListener.allowAfterFirstConnectingOrConnected()
and unsuppressAll(...), restore the original listener by calling
engineRunner.setConnectionListener(savedListener) (or
setConnectionListener(currentListenerSaved) if you store it) and clear
filteringListener/currentListener as done elsewhere; ensure you reference and
use the saved original listener variable used when installing the filter (and
update any other success/cleanup paths to do the same) so unwrapFilter() is no
longer necessary to traverse ObservingConnectionListener wrappers.

---

Nitpick comments:
In `@tool/src/main/java/io/netbird/client/tool/EngineRunner.java`:
- Around line 129-146: The current setConnectionListener/unwrap design allows
ObservingConnectionListener wrappers to be duplicated when EngineRestarter
rewraps a saved listener; modify EngineRunner to track the raw user-provided
listener separately (e.g., add a private field rawConnectionListener) so
setConnectionListener(rawOrWrapped) will: 1) extract the true user listener
(using or replacing unwrap if needed) and store it in rawConnectionListener, 2)
create exactly one ObservingConnectionListener around that raw listener and
assign that to connectionListener and goClient.setConnectionListener; also add a
getter like getRawConnectionListener() so EngineRestarter can snapshot and wrap
the raw listener with FilteringConnectionListener without causing stacking of
ObservingConnectionListener wrappers (keep references to
ObservingConnectionListener, FilteringConnectionListener, setConnectionListener,
unwrap, getConnectionListener and EngineRestarter.unwrapFilter in mind).

In `@tool/src/main/java/io/netbird/client/tool/VPNService.java`:
- Around line 88-90: Remove the redundant null-checks around engineRestarter so
the call sites are consistent: call engineRestarter.cancelPendingRestart()
directly (without if (engineRestarter != null) guards) in the broadcast receiver
and onRevoke to match the existing direct calls in onStartCommand and the binder
methods in VPNService; locate usages by searching for engineRestarter and
cancelPendingRestart within the VPNService class and update the broadcast
receiver and onRevoke sites accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dce469c4-7c40-4618-b94d-df1c3e7d9ab1

📥 Commits

Reviewing files that changed from the base of the PR and between 380808a and c257fb5.

📒 Files selected for processing (4)
  • tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
  • tool/src/main/java/io/netbird/client/tool/EngineRunner.java
  • tool/src/main/java/io/netbird/client/tool/VPNService.java
  • tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java

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.

1 participant