Skip to content

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

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

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

Conversation

@pappz
Copy link
Copy Markdown
Collaborator

@pappz pappz commented Apr 20, 2026

Title

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


Summary

Fixes two issues that caused the NetBird Android app to appear “Disconnected” for up to ~27 seconds during WiFi ↔ cellular transitions, even though the engine eventually recovered.


Problem

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

  1. UI stuck on “Disconnected” during restart
    EngineRestarter stops and restarts the Go engine on network-type changes, but neither the restarter nor the engine reported a “Connecting” state while the restart was in progress.
    As a result, the UI only received onDisconnected and waited for onConnected, which could take 20+ seconds if the new connection stalled.

  2. Socket dials stalled (~12 seconds)
    New sockets (Signal, Management) created immediately after a WiFi switch experienced TCP SYN retransmissions via the outgoing interface.
    This happened because sockets were not pinned to the current default network.
    Calling VpnService.protect(fd) prevents VPN routing loops but does not bind the socket to a specific underlying network.

  3. Spurious restart on cold start
    ConcreteNetworkAvailabilityListener treated Android’s initial onAvailable burst (triggered right after registerNetworkCallback) as a network transition.
    This caused EngineRestarter to cancel the first login with context canceled.


Changes

  • Emit synthetic connection states during restart
    (EngineRestarter.java, EngineRunner.java)

    • Call ConnectionListener.onConnecting() when stop() is invoked and again when restart begins
    • Ensures the UI shows “Connecting” throughout the restart
    • Call onDisconnected() on restart failure or after a 30-second timeout to prevent indefinite “Connecting”
  • Bind process to current default network
    (NetworkChangeDetector.java)

    • Register registerDefaultNetworkCallback
    • Call ConnectivityManager.bindProcessToNetwork(network) on each default network change
    • Ensures all new Go sockets use the active network immediately
    • Unbind on onLost and during unregisterNetworkCallback
  • Ignore initial onAvailable burst
    (ConcreteNetworkAvailabilityListener.java)

    • Introduce a 3-second grace window after subscribe()
    • Prevents startup callback bursts from being treated as real transitions
    • Avoids unintended engine restarts on app launch
  • Update dependency

    • Bump NetBird submodule to test branch

Summary by CodeRabbit

  • Bug Fixes

    • Improved network state change detection to more reliably prevent unnecessary service restarts when network transitions occur
    • Enhanced connection state notification delivery to improve overall service stability
  • Improvements

    • Foreground notification channel reconfigured to be silent with reduced priority level, eliminating sound and vibration alerts
    • Refined default network binding mechanism for better reliability during network transitions

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

Updates the netbird submodule to a new commit, adds connection state notifications to EngineRestarter based on engine lifecycle events, stores ConnectionListener references in EngineRunner, gates network availability listening based on engine running state via a BooleanSupplier predicate, adds default-network process binding logic to NetworkChangeDetector, and reduces foreground notification channel importance while disabling sound and vibration.

Changes

Cohort / File(s) Summary
Submodule Update
.../netbird
Updates netbird submodule commit pointer from f01c1ee to 5b09078.
Connection State Management
tool/src/main/java/io/netbird/client/tool/EngineRestarter.java, tool/src/main/java/io/netbird/client/tool/EngineRunner.java
EngineRestarter imports and invokes ConnectionListener callbacks (notifyConnecting(), notifyDisconnected()) during engine restart lifecycle events. EngineRunner now stores a connectionListener field, exposes it via getConnectionListener(), and clears it in removeStatusListener().
Network Availability Gating
tool/src/main/java/io/netbird/client/tool/VPNService.java, tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
ConcreteNetworkAvailabilityListener accepts a BooleanSupplier shouldNotify predicate to conditionally gate onNetworkTypeChanged() notifications. VPNService passes engineRunner::isRunning as the supplier during initialization, making network notifications conditional on engine state.
Network Process Binding
tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
Adds default-network callback registration with atomic state gating, logging, and explicit bindProcessToNetwork() calls to bind/unbind the process from the default network, managing both availability and loss events.
Notification Configuration
tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
Reduces notification channel importance from IMPORTANCE_DEFAULT to IMPORTANCE_LOW, disables sound, and disables vibration.
Tests
tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java
Updates existing tests to supply () -> true predicate and adds shouldNotNotifyListenerWhenEngineNotRunning() test verifying notifications are suppressed when supplier returns false.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #156: Updates the netbird submodule pointer (same dependency versioning pattern as this PR).
  • PR #163: Modifies the same classes in the tool module (EngineRunner, VPNService), indicating overlapping feature areas.

Suggested reviewers

  • lixmal

Poem

🐰 Hops through the network with careful stride,
Binding to default paths far and wide,
When the engine runs, the listener hears,
Connection states whispered, calm all fears,
Notifications gated, vibrations at peace! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 accurately summarizes the main fix: UI stuck on 'Disconnected' during network-change engine restart, which aligns with the primary objective and file changes.
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
⚔️ Resolve merge conflicts
  • Resolve merge conflict 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

Comment on lines +68 to +108
private void initDefaultNetworkCallback() {
defaultNetworkCallback = new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(@NonNull Network network) {
if (!defaultNetworkCallbackActive.get()) {
Log.d(LOGTAG, "ignoring onAvailable for " + network + "; default callback inactive");
return;
}
Log.d(LOGTAG, "default network became " + network + ", binding process to it");
try {
if (connectivityManager.bindProcessToNetwork(network)) {
currentlyBoundDefaultNetwork.set(network);
} else {
Log.w(LOGTAG, "bindProcessToNetwork returned false for " + network);
}
} catch (Exception e) {
Log.e(LOGTAG, "bindProcessToNetwork failed", e);
}
}

@Override
public void onLost(@NonNull Network network) {
if (!defaultNetworkCallbackActive.get()) {
Log.d(LOGTAG, "ignoring onLost for " + network + "; default callback inactive");
return;
}
if (!network.equals(currentlyBoundDefaultNetwork.get())) {
Log.d(LOGTAG, "ignoring onLost for " + network + "; not the currently bound default network");
return;
}
Log.d(LOGTAG, "default network " + network + " lost, clearing process binding");
try {
if (connectivityManager.bindProcessToNetwork(null)) {
currentlyBoundDefaultNetwork.compareAndSet(network, null);
}
} catch (Exception e) {
Log.e(LOGTAG, "bindProcessToNetwork(null) failed", e);
}
}
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Residual TOCTOU between active gate and bindProcessToNetwork call.

The AtomicBoolean gate prevents a stale callback from starting work after unregisterNetworkCallback(), but it does not make the gate-check + bind operation atomic with unregister. Interleaving:

  1. onAvailable (callback thread) reads defaultNetworkCallbackActive == true and passes the gate.
  2. unregisterNetworkCallback() sets the flag false, unregisters, then calls bindProcessToNetwork(null) and resets currentlyBoundDefaultNetwork to null.
  3. onAvailable resumes and calls bindProcessToNetwork(network) and sets currentlyBoundDefaultNetwork = network.

The process ends up bound to a network after full shutdown, with active == false so nothing will ever clear it. Symmetric issue exists for onLost racing with a new onAvailable.

A small synchronized block over the gate check together with the bind call (and over the corresponding section in unregisterNetworkCallback) would close this window at effectively no cost and was the shape of the fix suggested previously.

🔧 Proposed hardening
-    private final AtomicBoolean defaultNetworkCallbackActive = new AtomicBoolean(false);
-    private final AtomicReference<Network> currentlyBoundDefaultNetwork = new AtomicReference<>(null);
+    private final Object defaultNetworkBindingLock = new Object();
+    private boolean defaultNetworkCallbackActive = false;
+    private Network currentlyBoundDefaultNetwork = null;
@@
             public void onAvailable(`@NonNull` Network network) {
-                if (!defaultNetworkCallbackActive.get()) {
-                    Log.d(LOGTAG, "ignoring onAvailable for " + network + "; default callback inactive");
-                    return;
-                }
-                Log.d(LOGTAG, "default network became " + network + ", binding process to it");
-                try {
-                    if (connectivityManager.bindProcessToNetwork(network)) {
-                        currentlyBoundDefaultNetwork.set(network);
-                    } else {
-                        Log.w(LOGTAG, "bindProcessToNetwork returned false for " + network);
-                    }
-                } catch (Exception e) {
-                    Log.e(LOGTAG, "bindProcessToNetwork failed", e);
-                }
+                synchronized (defaultNetworkBindingLock) {
+                    if (!defaultNetworkCallbackActive) {
+                        Log.d(LOGTAG, "ignoring onAvailable for " + network + "; default callback inactive");
+                        return;
+                    }
+                    Log.d(LOGTAG, "default network became " + network + ", binding process to it");
+                    try {
+                        if (connectivityManager.bindProcessToNetwork(network)) {
+                            currentlyBoundDefaultNetwork = network;
+                        } else {
+                            Log.w(LOGTAG, "bindProcessToNetwork returned false for " + network);
+                        }
+                    } catch (Exception e) {
+                        Log.e(LOGTAG, "bindProcessToNetwork failed", e);
+                    }
+                }
             }
@@
             public void onLost(`@NonNull` Network network) {
-                if (!defaultNetworkCallbackActive.get()) { ... }
-                if (!network.equals(currentlyBoundDefaultNetwork.get())) { ... }
-                ...
+                synchronized (defaultNetworkBindingLock) {
+                    if (!defaultNetworkCallbackActive) { return; }
+                    if (!network.equals(currentlyBoundDefaultNetwork)) { return; }
+                    try {
+                        if (connectivityManager.bindProcessToNetwork(null)) {
+                            currentlyBoundDefaultNetwork = null;
+                        }
+                    } catch (Exception e) {
+                        Log.e(LOGTAG, "bindProcessToNetwork(null) failed", e);
+                    }
+                }
             }
@@
     public void unregisterNetworkCallback() {
-        defaultNetworkCallbackActive.set(false);
+        synchronized (defaultNetworkBindingLock) {
+            defaultNetworkCallbackActive = false;
+        }
@@
-        try {
-            connectivityManager.bindProcessToNetwork(null);
-            currentlyBoundDefaultNetwork.set(null);
-        } catch (Exception e) {
+        synchronized (defaultNetworkBindingLock) {
+            try {
+                connectivityManager.bindProcessToNetwork(null);
+                currentlyBoundDefaultNetwork = null;
+            } catch (Exception e) {
                 Log.e(LOGTAG, "bindProcessToNetwork(null) on unregister failed", 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 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.

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