Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 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
24 changes: 16 additions & 8 deletions NetBird/Source/App/NetBirdApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@ 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.checkExtensionState()
viewModel.startPollingDetails()
case .inactive:
break
@unknown default:
break
}
}
}
}
Expand Down
68 changes: 40 additions & 28 deletions NetBird/Source/App/ViewModels/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,41 +112,53 @@ 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

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
}
print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")

if details.managementStatus != self.managementStatus {
self.managementStatus = details.managementStatus
if self.extensionState == .disconnected && self.extensionStateText == "Connected" {
self.showAuthenticationRequired = true
self.extensionStateText = "Disconnected"
}

if details.managementStatus == .disconnected && self.extensionState == .connected && self.networkExtensionAdapter.isLoginRequired() {
self.networkExtensionAdapter.stop()
self.showAuthenticationRequired = true
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
}
print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")

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

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

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
Expand Down
173 changes: 162 additions & 11 deletions NetbirdKit/NetworkExtensionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,21 @@ public class NetworkExtensionAdapter: ObservableObject {
var extensionID = "io.netbird.app.NetbirdNetworkExtension"
var extensionName = "NetBird Network Extension"

let decoder = PropertyListDecoder()
let decoder = PropertyListDecoder()

// Battery optimization: Adaptive polling
// All state variables must be accessed only from pollingQueue to prevent race conditions
private var currentPollingInterval: TimeInterval = 10.0 // Start with 10 seconds
private var consecutiveStablePolls: Int = 0
private var lastStatusHash: Int = 0
private var isInBackground: Bool = false
private var lastTimerInterval: TimeInterval = 10.0 // Track last set interval
private let pollingQueue = DispatchQueue(label: "com.netbird.polling", qos: .utility)

// Polling intervals (in seconds)
private let minPollingInterval: TimeInterval = 10.0 // When changes detected
private let stablePollingInterval: TimeInterval = 20.0 // When stable
private let backgroundPollingInterval: TimeInterval = 60.0 // In background

@Published var timer : Timer

Expand Down Expand Up @@ -244,19 +258,49 @@ public class NetworkExtensionAdapter: ObservableObject {
let messageString = "Status"
if let messageData = messageString.data(using: .utf8) {
do {
try session.sendProviderMessage(messageData) { response in
if let response = response {
try session.sendProviderMessage(messageData) { [weak self] response in
guard let self = self else { return }

// Serialize all response handling and state mutations through pollingQueue
self.pollingQueue.async { [weak self] in
guard let self = self else { return }

guard let response = response else {
let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
completion(defaultStatus)
return
}

do {
let decodedStatus = try self.decoder.decode(StatusDetails.self, from: response)

// Calculate hash to detect changes
let statusHash = self.calculateStatusHash(decodedStatus)
let hasChanged = statusHash != self.lastStatusHash

if hasChanged {
// Status changed - use faster polling
self.consecutiveStablePolls = 0
self.currentPollingInterval = self.minPollingInterval
self.lastStatusHash = statusHash
print("Status changed, using fast polling (\(self.currentPollingInterval)s)")
} else {
// Status stable - gradually increase interval
self.consecutiveStablePolls += 1
if self.consecutiveStablePolls > 3 {
self.currentPollingInterval = self.stablePollingInterval
}
}

// Restart timer with new interval if needed
self.restartTimerIfNeeded(completion: completion)

completion(decodedStatus)
return
} catch {
print("Failed to decode status details.")
let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
completion(defaultStatus)
}
} else {
let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
completion(defaultStatus)
return
}
}
} catch {
Expand All @@ -267,16 +311,123 @@ public class NetworkExtensionAdapter: ObservableObject {
}
}

private func calculateStatusHash(_ status: StatusDetails) -> Int {
var hasher = Hasher()
hasher.combine(status.ip)
hasher.combine(status.fqdn)
hasher.combine(status.managementStatus)
hasher.combine(status.peerInfo.count)
for peer in status.peerInfo {
hasher.combine(peer.ip)
hasher.combine(peer.connStatus)
}
return hasher.finalize()
}

private func restartTimerIfNeeded(completion: @escaping (StatusDetails) -> Void) {
// This function is called from pollingQueue, so we can safely access state variables
// Only restart if interval changed significantly (more than 2 seconds difference)
let targetInterval = isInBackground ? backgroundPollingInterval : currentPollingInterval

// Check if we need to restart timer
if abs(lastTimerInterval - targetInterval) > 2.0 {
lastTimerInterval = targetInterval
// Capture state values here (on pollingQueue) to avoid deadlock
let intervalToUse = targetInterval
let backgroundStateToUse = isInBackground
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.timer.isValid {
self.timer.invalidate()
}
// Pass values directly to avoid pollingQueue.sync call from main thread
self.startTimer(interval: intervalToUse, backgroundState: backgroundStateToUse, completion: completion)
}
}
}

func startTimer(completion: @escaping (StatusDetails) -> Void) {
startTimer(interval: nil, backgroundState: nil, completion: completion)
}

private func startTimer(interval: TimeInterval?, backgroundState: Bool?, completion: @escaping (StatusDetails) -> Void) {
self.timer.invalidate()

// Initial fetch
self.fetchData(completion: completion)
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: { _ in
self.fetchData(completion: completion)
})

// Determine polling interval based on app state
// If values are provided (from restartTimerIfNeeded), use them to avoid deadlock
// Otherwise, read from pollingQueue (when called directly from main thread)
let intervalToUse: TimeInterval
let backgroundStateToUse: Bool

if let providedInterval = interval, let providedBackgroundState = backgroundState {
// Values already captured on pollingQueue, use them directly
intervalToUse = providedInterval
backgroundStateToUse = providedBackgroundState
// Update lastTimerInterval synchronously to prevent race condition
// This is safe because we're not in a deadlock situation (values already captured)
pollingQueue.sync { [weak self] in
guard let self = self else { return }
self.lastTimerInterval = providedInterval
}
} else {
// Called directly, must read from pollingQueue (but this is safe as we're not in a deadlock situation)
var intervalValue: TimeInterval = minPollingInterval
var backgroundValue: Bool = false
pollingQueue.sync {
backgroundValue = isInBackground
intervalValue = backgroundValue ? backgroundPollingInterval : currentPollingInterval
lastTimerInterval = intervalValue
}
intervalToUse = intervalValue
backgroundStateToUse = backgroundValue
}

// Create timer - must be on main thread for RunLoop
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

self.timer = Timer(timeInterval: intervalToUse, repeats: true) { [weak self] _ in
guard let self = self else { return }
// Use background queue for actual network work
self.pollingQueue.async {
self.fetchData(completion: completion)
}
}
Comment on lines 455 to 464
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

stopTimer() is async-invalidating, but the timer tick doesn’t check isPollingActive → extra queued polls after stop.
Even after isPollingActive = false, the existing timer can still fire until the main-queue invalidation runs (Line 430+), and the tick always enqueues fetchData (Line 416-418). Add a cheap gate to avoid work-after-stop.

 self.timer = Timer(timeInterval: intervalToUse, repeats: true) { [weak self] _ in
     guard let self = self else { return }
     // Use background queue for actual network work
     self.pollingQueue.async {
+        guard self.isPollingActive else { return }
         self.fetchData(completion: completion)
     }
 }

And consider invalidating immediately when already on main:

 func stopTimer() {
-    DispatchQueue.main.async { [weak self] in
-        self?.timer.invalidate()
-    }
+    if Thread.isMainThread {
+        self.timer.invalidate()
+    } else {
+        DispatchQueue.main.async { [weak self] in self?.timer.invalidate() }
+    }
     pollingQueue.sync { ... isPollingActive = false ... }
 }

Also applies to: 428-442

🤖 Prompt for AI Agents
In NetbirdKit/NetworkExtensionAdapter.swift around lines 413–419 (and similarly
428–442), the timer closure always enqueues fetchData even if stopTimer() has
set isPollingActive = false but the timer hasn’t been invalidated on the main
queue yet; add a cheap gate at the top of the timer block to return early if
self.isPollingActive is false before dispatching to pollingQueue, preserving the
[weak self] capture and guard; also update stopTimer() to immediately invalidate
the timer if already on the main queue (or dispatch synchronously to main to
invalidate) to avoid extra firings, and keep thread-safe access to
isPollingActive when read/modified.


// Add timer to main RunLoop
RunLoop.main.add(self.timer, forMode: .common)

print("Started polling with interval: \(intervalToUse)s (background: \(backgroundStateToUse))")
}
}

func stopTimer() {
self.timer.invalidate()
// Reset state variables - must be done on pollingQueue to avoid race conditions
pollingQueue.async { [weak self] in
guard let self = self else { return }
self.consecutiveStablePolls = 0
self.currentPollingInterval = self.minPollingInterval
}
}

func setBackgroundMode(_ inBackground: Bool) {
// All state mutations must happen on pollingQueue to prevent race conditions
pollingQueue.async { [weak self] in
guard let self = self else { return }
let wasInBackground = self.isInBackground
self.isInBackground = inBackground

// Restart timer with appropriate interval if state changed
if wasInBackground != inBackground {
let interval = inBackground ? self.backgroundPollingInterval : self.currentPollingInterval
print("App state changed to \(inBackground ? "background" : "foreground"), adjusting polling interval to \(interval)s")
// Timer will be restarted on next fetchData call via restartTimerIfNeeded
}
}
}

func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) {
Expand Down