Skip to content
Draft
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
989c769
feat: implement battery optimizations with adaptive polling
Dec 12, 2025
6464cc3
fix: address code review issues - improve concurrency safety
Dec 12, 2025
916501c
fix: replace DispatchQueue.main.async with Task { @MainActor in }
Dec 12, 2025
bd8b313
fix: remove duplicate notification handlers in NetBirdApp
Dec 12, 2025
3e276c0
fix: synchronize all polling state access to prevent race conditions
Dec 12, 2025
61b45d7
fix: complete scenePhase handler with all polling management operations
Dec 12, 2025
91b9612
fix: remove duplicate notification handlers to prevent redundant calls
Dec 12, 2025
28b9202
fix: read isInBackground from pollingQueue in startTimer to prevent r…
Dec 12, 2025
8322eac
fix: prevent deadlock in startTimer by passing state values as parame…
Dec 12, 2025
0fe91e0
fix: synchronize lastTimerInterval update to prevent race condition
Dec 12, 2025
b7b335a
fix: prevent repeated stop() calls due to asynchronous state update t…
Dec 12, 2025
da5e3a6
docs: add comment explaining calculateStatusHash field selection rati…
Dec 12, 2025
db55401
fix: make stop guard reset unconditional on .disconnected to avoid co…
Dec 12, 2025
5916ea3
fix: add isPollingActive flag to prevent timer recreation after stopT…
Dec 12, 2025
ddf5533
fix: set isPollingActive = true in startTimer (both code paths)
Dec 12, 2025
c79b09b
fix: use sync in setBackgroundMode to prevent timing issue with start…
Dec 12, 2025
d2935ec
fix: set isPollingActive synchronously in stopTimer to prevent race c…
Dec 12, 2025
9269ac6
Merge branch 'netbirdio:main' into battery-optimizations
uid0c0der Dec 13, 2025
b2a3f49
fix: ensure timer.invalidate() is called on main thread
Dec 13, 2025
0a167fe
fix: correct indentation in Task block for peer info sorting
Dec 13, 2025
53a5a8c
fix: synchronize timer invalidation to prevent concurrent timer execu…
Dec 13, 2025
fd236dd
fix: avoid deadlock in startTimer by using async and adding precondition
Dec 13, 2025
4eeda7b
refactor: improve code quality and thread safety
Dec 13, 2025
0cfd0bf
perf: optimize isLoginRequired() call to avoid UI hitching
Dec 13, 2025
a325acc
fix: resolve Swift Concurrency warnings for pollingQueue.sync calls
Dec 13, 2025
56985fd
fix: ensure setBackgroundMode state update is synchronous to prevent …
Dec 13, 2025
2a44312
fix: always compute isLoginRequired() for accurate debug output
Dec 13, 2025
65cff11
fix: stop polling when app becomes inactive to save battery
Dec 13, 2025
ac20fe4
feat: use slower polling instead of stopping when app becomes inactive
Dec 13, 2025
fb2dbb4
fix: automatically start polling when extension becomes connected
Dec 13, 2025
233a1fc
fix: call checkExtensionState immediately after connect to start polling
Dec 13, 2025
77c9adc
fix: improve UI feedback during connect by polling extension state
Dec 13, 2025
6fc69cb
fix: update extensionStateText immediately in checkExtensionState
Dec 13, 2025
819fa3c
fix: make GlobalConstants public for access from NetBird target
Dec 13, 2025
b27ce99
fix: avoid priority inversion by increasing pollingQueue QoS
Dec 13, 2025
c0eb283
fix: use Set comparison for routes to match PeerInfo Equatable
Dec 13, 2025
de4d772
fix: add guard in timer closure to prevent work after stopTimer
Dec 13, 2025
b9eeb74
perf: only start polling when extension is connected in .active state
Dec 13, 2025
524e845
fix: add precondition to stopTimer to prevent Swift Concurrency deadlock
Dec 13, 2025
c6db3e0
fix: cancel existing polling task when connect() is called again
Dec 13, 2025
6c36bcc
fix: replace DispatchQueue with Task for connection polling cancellation
Dec 13, 2025
e76586a
fix: replace recursion with loop in pollExtensionStateUntilConnected
Dec 13, 2025
32515c2
fix: serialize getExtensionStatus() calls to prevent concurrent loadA…
Dec 13, 2025
ce25926
fix: improve UI feedback for disconnect by setting state immediately
Dec 13, 2025
ef68b4e
fix: prevent app hang on launch by adding fallback in getExtensionStatus
Dec 13, 2025
e60fef8
fix: delay state updates in .active case to prevent app launch hang
Dec 13, 2025
805caf9
fix: separate checkExtensionState delay to prevent app launch blocking
Dec 13, 2025
a6ed65b
fix: make checkExtensionState conditional on first launch to prevent …
Dec 13, 2025
bbd7e98
fix: simplify checkExtensionState logic - always delay on .active
Dec 13, 2025
cc5db36
fix: remove artificial delays and checkExtensionState from app launch
Dec 13, 2025
7392d2b
fix: remove redundant setBackgroundMode/setInactiveMode calls on app …
Dec 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions NetBird/Source/App/NetBirdApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,30 @@ struct NetBirdApp: App {
WindowGroup {
MainView()
.environmentObject(viewModel)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) {_ in
print("App is active!")
viewModel.checkExtensionState()
viewModel.startPollingDetails()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) {_ in
print("App is inactive!")
viewModel.stopPollingDetails()
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .background:
print("App moved to background")
viewModel.networkExtensionAdapter.setBackgroundMode(true)
viewModel.stopPollingDetails()
case .active:
print("App became active")
viewModel.networkExtensionAdapter.setBackgroundMode(false)
viewModel.networkExtensionAdapter.setInactiveMode(false)
viewModel.checkExtensionState()
// Only start polling if extension is connected to avoid unnecessary fetchData calls
// startTimer() invalidates existing timer and calls fetchData(), which is wasteful if not connected
if viewModel.extensionState == .connected {
viewModel.startPollingDetails()
}
case .inactive:
print("App became inactive")
// Use slower polling when app becomes inactive (e.g., app switcher, control center)
// This maintains VPN connection monitoring while saving battery during brief inactive periods
viewModel.networkExtensionAdapter.setInactiveMode(true)
@unknown default:
break
}
}
}
}
Expand Down
167 changes: 132 additions & 35 deletions NetBird/Source/App/ViewModels/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,57 @@ class ViewModel: ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.buttonLock = false
}
// Set UI state to "Connecting" immediately for better UX
self.extensionStateText = "Connecting"
Task {
await self.networkExtensionAdapter.start()
print("Connected pressed set to false")

// Poll extension state repeatedly with short intervals until connected
// This ensures UI updates immediately when extension becomes connected
// instead of waiting for the 30s periodic check
self.pollExtensionStateUntilConnected(attempt: 0, maxAttempts: 15)
}
}
}

// Poll extension state repeatedly until connected or max attempts reached
// This provides immediate UI feedback after connect() instead of waiting for periodic check
private func pollExtensionStateUntilConnected(attempt: Int, maxAttempts: Int) {
// Cancel existing polling task if connect() was called again
connectionPollingTask?.cancel()

connectionPollingTask = Task { @MainActor in
var currentAttempt = attempt
while currentAttempt < maxAttempts {
guard !Task.isCancelled else {
print("Connection polling task was cancelled")
return
}

checkExtensionState()

// If connected, stop polling (checkExtensionState will start polling if needed)
if self.extensionState == .connected {
print("Extension connected, stopping state polling")
return
}

// Wait 1 second before next check
try? await Task.sleep(nanoseconds: 1_000_000_000)

guard !Task.isCancelled else {
print("Connection polling task was cancelled during sleep")
return
}

currentAttempt += 1
}

print("Max attempts reached for extension state polling")
}
}
Comment on lines +116 to +149
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check getExtensionStatus implementation for concurrency handling
rg -n "func getExtensionStatus" -A 15 | head -50

Repository: netbirdio/ios-client

Length of output: 1149


🏁 Script executed:

#!/bin/bash
# Find the NetworkExtensionAdapter file
fd "NetworkExtensionAdapter" --type f

Repository: netbirdio/ios-client

Length of output: 104


🏁 Script executed:

#!/bin/bash
# Search for any concurrency-related patterns in NetworkExtensionAdapter
rg -n "DispatchQueue|NSLock|os_unfair_lock|nonisolated|Sendable" --type swift | head -30

Repository: netbirdio/ios-client

Length of output: 3335


🏁 Script executed:

#!/bin/bash
# Find checkExtensionState implementation
rg -n "func checkExtensionState" -A 20 --type swift

Repository: netbirdio/ios-client

Length of output: 2475


🏁 Script executed:

#!/bin/bash
# Check if there's any serialization or locking around loadAllFromPreferences calls
rg -n "loadAllFromPreferences" --type swift -B 3 -A 3

Repository: netbirdio/ios-client

Length of output: 1365


🏁 Script executed:

#!/bin/bash
# Search for any comments about thread safety or synchronization around loadAllFromPreferences
rg -n "thread|concurrent|serial|sync|lock" NetbirdKit/NetworkExtensionAdapter.swift --type swift

Repository: netbirdio/ios-client

Length of output: 4734


🏁 Script executed:

#!/bin/bash
# Check the full context around getExtensionStatus to see if there's any protection
sed -n '550,580p' NetbirdKit/NetworkExtensionAdapter.swift

Repository: netbirdio/ios-client

Length of output: 1156


Add synchronization to prevent concurrent loadAllFromPreferences() calls in getExtensionStatus().

The polling loop calls checkExtensionState() every second for up to 15 seconds, potentially spawning 15 concurrent Task instances that each call getExtensionStatus(). Each invocation triggers NETunnelProviderManager.loadAllFromPreferences() without synchronization, allowing overlapping concurrent calls. While Apple's NetworkExtension framework may handle concurrent preference reads, the pattern is inconsistent with the rest of the codebase, which uses the existing pollingQueue to serialize similar operations. Consider serializing these calls using the existing pollingQueue or another appropriate synchronization mechanism.

Comment on lines +114 to +149
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Polling loop may check stale state due to async checkExtensionState.

checkExtensionState() at line 128 is fire-and-forget—it calls getExtensionStatus with a completion handler that updates extensionState asynchronously. The subsequent check at line 131 (if self.extensionState == .connected) may evaluate before the completion handler has run, potentially causing extra polling iterations.

Consider awaiting the state update or using a completion-based check:

-                checkExtensionState()
-                
-                // If connected, stop polling (checkExtensionState will start polling if needed)
-                if self.extensionState == .connected {
+                await withCheckedContinuation { continuation in
+                    networkExtensionAdapter.getExtensionStatus { [weak self] status in
+                        DispatchQueue.main.async {
+                            if let self = self, status == .connected && self.extensionState != .connected {
+                                self.extensionState = status
+                                self.extensionStateText = "Connected"
+                                self.startPollingDetails()
+                            }
+                            continuation.resume()
+                        }
+                    }
+                }
+                
+                if self.extensionState == .connected {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In NetBird/Source/App/ViewModels/MainViewModel.swift around lines 114–149, the
polling loop calls checkExtensionState() which updates extensionState
asynchronously via a completion handler, so the immediate if self.extensionState
== .connected check can read a stale value; change checkExtensionState to
provide an async result (or add a completion closure) and await that result
inside the Task before evaluating extensionState, e.g., convert
getExtensionStatus call path to an async function or use withCheckedContinuation
to bridge the callback, then use the returned state to decide whether to stop
polling; keep existing cancellation checks and sleep logic.


func close() -> Void {
self.disconnectPressed = true
DispatchQueue.main.async {
Expand All @@ -116,50 +160,85 @@ class ViewModel: ObservableObject {
}
}

// Battery optimization: Track last extension state check
private var lastExtensionStateCheck: Date = Date.distantPast
private let extensionStateCheckInterval: TimeInterval = 30.0 // Check every 30 seconds instead of every poll

// Prevent repeated stop() calls due to asynchronous state update timing
private var hasStoppedForLoginFailure: Bool = false

// Track connection polling task to cancel it if connect() is called again
private var connectionPollingTask: Task<Void, Never>?

func startPollingDetails() {
networkExtensionAdapter.startTimer { details in

self.checkExtensionState()
if self.extensionState == .disconnected && self.extensionStateText == "Connected" {
self.showAuthenticationRequired = true
self.extensionStateText = "Disconnected"
}
networkExtensionAdapter.startTimer { [weak self] details in
guard let self = self else { return }

if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus
{
if !details.fqdn.isEmpty && details.fqdn != self.fqdn {
self.defaults.set(details.fqdn, forKey: "fqdn")
self.fqdn = details.fqdn

// Ensure all UI updates happen on the main thread
Task { @MainActor in
// Battery optimization: Only check extension state periodically, not on every poll
let now = Date()
if now.timeIntervalSince(self.lastExtensionStateCheck) >= self.extensionStateCheckInterval {
self.checkExtensionState()
self.lastExtensionStateCheck = now
}
if !details.ip.isEmpty && details.ip != self.ip {
self.defaults.set(details.ip, forKey: "ip")
self.ip = details.ip

// Reset stop guard when extension disconnects (unconditional to avoid coupling to extensionStateText)
if self.extensionState == .disconnected {
self.hasStoppedForLoginFailure = false

// UX logic: Update UI state if needed
if self.extensionStateText == "Connected" {
self.showAuthenticationRequired = true
self.extensionStateText = "Disconnected"
}
}
print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")

if details.managementStatus != self.managementStatus {
self.managementStatus = details.managementStatus
if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus
{
if !details.fqdn.isEmpty && details.fqdn != self.fqdn {
self.defaults.set(details.fqdn, forKey: "fqdn")
self.fqdn = details.fqdn
}
if !details.ip.isEmpty && details.ip != self.ip {
self.defaults.set(details.ip, forKey: "ip")
self.ip = details.ip
}

// Compute isLoginRequired() once to avoid UI hitching from multiple calls
// Always compute for accurate debug output, but only use in condition when relevant
let loginRequired = self.networkExtensionAdapter.isLoginRequired()

print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(loginRequired)")

if details.managementStatus != self.managementStatus {
self.managementStatus = details.managementStatus
}

// Prevent repeated stop() calls due to asynchronous state update timing
// Only call stop() once per login failure state, until extensionState updates
if details.managementStatus == .disconnected &&
self.extensionState == .connected &&
loginRequired &&
!self.hasStoppedForLoginFailure {
self.hasStoppedForLoginFailure = true
self.networkExtensionAdapter.stop()
self.showAuthenticationRequired = true
}
}

if details.managementStatus == .disconnected && self.extensionState == .connected && self.networkExtensionAdapter.isLoginRequired() {
self.networkExtensionAdapter.stop()
self.showAuthenticationRequired = true
self.statusDetailsValid = true

let sortedPeerInfo = details.peerInfo.sorted(by: { a, b in
a.ip < b.ip
})
if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in
a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && Set(a.routes) == Set(b.routes)
}) {
print("Setting new peer info: \(sortedPeerInfo.count) Peers")
self.peerViewModel.peerInfo = sortedPeerInfo
}
}

self.statusDetailsValid = true

let sortedPeerInfo = details.peerInfo.sorted(by: { a, b in
a.ip < b.ip
})
if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in
a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && a.routes.count == b.routes.count
}) {
print("Setting new peer info: \(sortedPeerInfo.count) Peers")
self.peerViewModel.peerInfo = sortedPeerInfo
}

}
}

Expand All @@ -171,9 +250,27 @@ class ViewModel: ObservableObject {
networkExtensionAdapter.getExtensionStatus { status in
let statuses : [NEVPNStatus] = [.connected, .disconnected, .connecting, .disconnecting]
DispatchQueue.main.async {
let wasConnected = self.extensionState == .connected
if statuses.contains(status) && self.extensionState != status {
print("Changing extension status")
self.extensionState = status

// Update extensionStateText immediately for better UX
// CustomLottieView will also update it, but this ensures immediate feedback
if status == .connected {
self.extensionStateText = "Connected"
} else if status == .connecting {
self.extensionStateText = "Connecting"
} else if status == .disconnected {
self.extensionStateText = "Disconnected"
}

// Start polling when extension becomes connected (if not already polling)
// This ensures polling starts immediately after connect() without waiting for .active event
if status == .connected && !wasConnected {
print("Extension connected, starting polling")
self.startPollingDetails()
}
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions NetbirdKit/GlobalConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by Diego Romar on 03/12/25.
//

struct GlobalConstants {
static let keyForceRelayConnection = "isConnectionForceRelayed"
static let userPreferencesSuiteName = "group.io.netbird.app"
public struct GlobalConstants {
public static let keyForceRelayConnection = "isConnectionForceRelayed"
public static let userPreferencesSuiteName = "group.io.netbird.app"
}
Loading