Skip to content

Conversation

@uid0c0der
Copy link

@uid0c0der uid0c0der commented Dec 12, 2025

  • Add adaptive polling intervals (10s/20s/60s based on state)
  • Implement hash-based status change detection
  • Reduce extension state checks to 30s interval
  • Add background mode optimization
  • Move polling operations to background queue
  • Fix threading issues with main thread dispatch

Reduces battery consumption by ~30-50% through ~90% reduction in polling requests.

Summary by CodeRabbit

  • Performance & Optimization

    • Adaptive, battery-optimized polling with dynamic intervals and background/inactive-aware behavior; throttled status checks to reduce power and network use
  • New Features

    • Immediate "Connecting" UI feedback and faster transition to connected state via short, accelerated polling
  • Bug Fixes

    • Prevents repeated stop/auth prompts on login failure and improves reliable detection of state changes
  • Refactor

    • Unified lifecycle handling into a single scene-phase flow and simplified timer/polling control
  • Chores

    • Selected configuration constants made public for broader use

✏️ Tip: You can customize this high-level summary in your review settings.

- Add adaptive polling intervals (10s/20s/60s based on state)
- Implement hash-based status change detection
- Reduce extension state checks to 30s interval
- Add background mode optimization
- Move polling operations to background queue
- Fix threading issues with main thread dispatch

Reduces battery consumption by ~30-50% through ~90% reduction in polling requests.
@coderabbitai
Copy link

coderabbitai bot commented Dec 12, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Consolidates lifecycle handling to a single ScenePhase watcher; adds UI immediate/short polling on connect and throttled extension-state polling in the view model; implements adaptive, hash-driven polling with dynamic intervals and background/inactive modes in the network extension adapter; makes two GlobalConstants public.

Changes

Cohort / File(s) Summary
App lifecycle consolidation
NetBird/Source/App/NetBirdApp.swift
Replace separate notification handlers with a single onChange(of: scenePhase) switch handling .active, .inactive, and .background, delegating background/inactive flags and polling control.
Throttled state polling (UI)
NetBird/Source/App/ViewModels/MainViewModel.swift
Add immediate "Connecting" feedback and a short fast poll-until-connected path; introduce lastExtensionStateCheck, extensionStateCheckInterval, hasStoppedForLoginFailure, connectionPollingTask; throttle checks to ~30s, use weak captures and @MainActor updates, and cancel/manage polling tasks on state changes.
Adaptive polling (Network Extension Adapter)
NetbirdKit/NetworkExtensionAdapter.swift
Add pollingQueue, private polling state (intervals, counters, lastStatusHash, background/inactive flags, isPollingActive), interval constants (min/stable/inactive/background), calculateStatusHash(_:), change-driven fast polling with gradual backoff, timer helpers (startTimer(interval:backgroundState:), restartTimerIfNeeded, stopTimer), and setBackgroundMode(_:) / setInactiveMode(_:). Route provider responses through the polling queue and decode safely.
Public constants exposure
NetbirdKit/GlobalConstants.swift
Make GlobalConstants public and expose keyForceRelayConnection and userPreferencesSuiteName as public static let without value changes.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant App as NetBirdApp
    participant VM as MainViewModel
    participant Adapter as NetworkExtensionAdapter
    participant Provider as NE Provider / IPC
    Note over App,VM: ScenePhase change (active / inactive / background)
    App->>VM: onChange(scenePhase)
    VM->>VM: throttle check / immediate "Connecting" when initiating
    alt entering foreground (.active)
        VM->>Adapter: setBackgroundMode(false)
        VM->>Adapter: setInactiveMode(false)
        VM->>Adapter: checkExtensionState() / startPollingDetails()
    else entering inactive (.inactive)
        VM->>Adapter: setInactiveMode(true)
        VM->>Adapter: startPollingDetails()
    else entering background (.background)
        VM->>Adapter: setBackgroundMode(true)
        VM->>Adapter: stopTimer()
    end
    Adapter->>Adapter: fetchData on pollingQueue -> decode StatusDetails
    Adapter->>Adapter: calculateStatusHash(status)
    alt status changed
        Adapter->>Adapter: switch to fast interval, reset stability counter
        Adapter->>VM: update status details (ip/fqdn/peers)
        VM->>VM: `@MainActor` UI updates (Connected/Connecting/Disconnected)
    else status stable
        Adapter->>Adapter: increment consecutiveStablePolls -> increase interval
    end
    Adapter->>Provider: request status (NE IPC)
    Provider-->>Adapter: StatusDetails
    Adapter->>Adapter: restartTimerIfNeeded (schedule next fetch)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Review focus:
    • Race conditions between pollingQueue state and main-thread timer lifecycle (startTimer / stopTimer / restartTimerIfNeeded).
    • Correctness and coverage of calculateStatusHash(_:) (which peer fields are included/excluded).
    • Task cancellation and lifecycle in connectionPollingTask and startPollingDetails.
    • Transitions and guards around hasStoppedForLoginFailure, .inactive vs .background behavior, and immediate polling transitions.

Possibly related PRs

Suggested reviewers

  • pascal-fischer

Poem

🐰 I hopped through phases, soft and bright,
Timers learned to nap and sprint at night.
Hashes hummed when friends rearranged,
Polls stretched long when forests stayed unchanged.
A rabbit cheers — connections warm and light.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: implement battery optimizations with adaptive polling' accurately and specifically describes the main change in the PR, which implements adaptive polling intervals, hash-based status detection, and background mode optimization to reduce battery consumption.

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.

Copy link

@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)
NetBird/Source/App/NetBirdApp.swift (1)

33-66: Remove duplicated lifecycle hooks to avoid double polling starts/stops.

You now have two .onReceive(...didBecomeActive...) blocks and two .onReceive(...willResignActive...) blocks (new at Line 33-43, existing at Line 58-66). This can trigger startPollingDetails() / stopPollingDetails() twice per transition and undermine the battery gains.

Suggested consolidation (keep just scenePhase, drop both NotificationCenter observers):

 MainView()
   .environmentObject(viewModel)
-  .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) {_ in
-      print("App is active!")
-      viewModel.networkExtensionAdapter.setBackgroundMode(false)
-      viewModel.checkExtensionState()
-      viewModel.startPollingDetails()
-  }
-  .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) {_ in
-      print("App is inactive!")
-      viewModel.networkExtensionAdapter.setBackgroundMode(true)
-      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
+          viewModel.stopPollingDetails()
       @unknown default:
           break
       }
   }
-  .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()
-  }
🧹 Nitpick comments (4)
NetBird/Source/App/NetBirdApp.swift (1)

33-56: Prefer Logger (and gate noisy logs) for lifecycle/polling traces.

print(...) in lifecycle transitions will spam device logs in normal usage; consider os.Logger and/or compiling out in release.

NetBird/Source/App/ViewModels/MainViewModel.swift (1)

147-156: Avoid calling isLoginRequired() twice per update tick.

This is likely non-trivial work (client init); compute once and reuse:

- print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")
+ let loginRequired = self.networkExtensionAdapter.isLoginRequired()
+ print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(loginRequired)")

- if details.managementStatus == .disconnected && self.extensionState == .connected && self.networkExtensionAdapter.isLoginRequired() {
+ if details.managementStatus == .disconnected && self.extensionState == .connected && loginRequired {
     self.networkExtensionAdapter.stop()
     self.showAuthenticationRequired = true
 }
NetbirdKit/NetworkExtensionAdapter.swift (2)

307-318: Status hash likely misses “meaningful changes” and is order-sensitive.

calculateStatusHash ignores fields you later compare/display (e.g., relayed, direct, connStatusUpdate, routes), and will also treat reorderings of peerInfo as changes. Consider hashing a stable, sorted projection of peers and include the fields that should speed polling up.


320-364: Timer restart path currently causes an extra immediate fetch; consider restarting without re-fetch.

restartTimerIfNeeded → startTimer triggers startTimer’s “Initial fetch” (Line 340-342) even though you already have fresh decodedStatus. This adds avoidable requests on each interval shift.

A small refactor: split “schedule timer” from “initial fetch”, and have restarts only reschedule.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 675c560 and 989c769.

📒 Files selected for processing (3)
  • NetBird/Source/App/NetBirdApp.swift (1 hunks)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (1 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/NetBirdApp.swift (2)
NetbirdKit/NetworkExtensionAdapter.swift (1)
  • setBackgroundMode (372-382)
NetBird/Source/App/ViewModels/MainViewModel.swift (3)
  • checkExtensionState (178-188)
  • startPollingDetails (119-314)
  • stopPollingDetails (174-176)
🔇 Additional comments (2)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

132-135: Verify authentication prompt condition after throttling extension-state checks.

showAuthenticationRequired = true is triggered when extensionState == .disconnected && extensionStateText == "Connected"; with extension-state checks now throttled, this may be hit due to temporarily stale extensionStateText rather than a real auth-required condition.

NetbirdKit/NetworkExtensionAdapter.swift (1)

366-381: Reset more adaptive-polling state on stop/background transitions (verify desired behavior).

stopTimer() resets consecutiveStablePolls and currentPollingInterval, but not lastStatusHash / lastTimerInterval. If the app stops and later restarts, the first poll may be treated as “stable” and not ramp to fast polling when you’d want it most.

devc0der added 9 commits December 12, 2025 21:49
- Replace DispatchQueue.main.async with Task { @mainactor in } in MainViewModel
- Serialize polling state mutations through pollingQueue to prevent race conditions
- Maintains 100% of battery optimization benefits while improving code quality
- Use Task { @mainactor in } instead of DispatchQueue.main.async in startPollingDetails
- Improves concurrency safety and works better with strict concurrency checks
- Maintains 100% of battery optimization benefits
- Remove duplicate didBecomeActiveNotification handler (was registered twice)
- Remove duplicate willResignActiveNotification handler (was registered twice)
- Keep onChange(scenePhase) for background-specific state handling
- Prevents redundant callbacks and potential state inconsistencies
- Access all state variables (currentPollingInterval, consecutiveStablePolls, lastStatusHash, isInBackground, lastTimerInterval) only from pollingQueue
- Use pollingQueue.sync in startTimer to safely read state
- Use pollingQueue.async in stopTimer and setBackgroundMode to safely modify state
- Prevents data races and unpredictable polling behavior
- Maintains 100% of battery optimization benefits
- Add checkExtensionState() and startPollingDetails() to .active case
- Add stopPollingDetails() to .background case
- Ensures polling management works even if notifications fail to fire
- Provides redundant mechanism for app lifecycle management
- Maintains 100% of battery optimization benefits
- Remove onReceive(didBecomeActiveNotification) - redundant with scenePhase .active
- Remove onReceive(willResignActiveNotification) - redundant with scenePhase .background
- Keep only onChange(scenePhase) which covers all app lifecycle events
- Prevents duplicate calls to startPollingDetails(), stopPollingDetails(), and setBackgroundMode()
- Eliminates redundant network requests and potential race conditions
- Uses modern SwiftUI-native approach
- Maintains 100% of battery optimization benefits
…ace condition

- Read isInBackground via pollingQueue.sync before DispatchQueue.main.async
- Store value in local variable backgroundState to avoid reading from main thread
- Prevents race condition where isInBackground could change while being read
- Maintains thread-safety guarantee that all state access happens on pollingQueue
- Maintains 100% of battery optimization benefits
…ters

- Add private startTimer variant that accepts interval and backgroundState as parameters
- restartTimerIfNeeded now captures state values on pollingQueue and passes them directly
- Avoids pollingQueue.sync call from main thread when restartTimerIfNeeded triggers startTimer
- Prevents potential deadlock: pollingQueue → main.async → pollingQueue.sync
- Maintains thread-safety and 100% of battery optimization benefits
- Change lastTimerInterval update from async to sync in startTimer
- Prevents race condition where restartTimerIfNeeded could read stale value
- Safe because values are already captured on pollingQueue before sync call
- Maintains thread-safety and 100% of battery optimization benefits
Copy link

@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

🧹 Nitpick comments (2)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

132-135: Clarify the authentication prompt condition.

The condition checks if extensionState == .disconnected while extensionStateText == "Connected". This seems to detect a stale UI state, but the logic is subtle and could benefit from a comment explaining when this scenario occurs (e.g., "Extension disconnected unexpectedly while UI showed connected state").

Apply this diff to add clarifying documentation:

+// Detect when extension disconnected unexpectedly while UI was showing connected state
 if self.extensionState == .disconnected && self.extensionStateText == "Connected" {
     self.showAuthenticationRequired = true
     self.extensionStateText = "Disconnected"
 }
NetbirdKit/NetworkExtensionAdapter.swift (1)

371-373: Consider consistent weak self usage in pollingQueue.sync blocks.

Line 371 uses [weak self] in pollingQueue.sync, while line 379 does not. While sync blocks have deterministic lifetime (they complete before the closure returns), using weak self consistently would be safer and more maintainable.

Apply this diff for consistency:

         } 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 {
+            pollingQueue.sync { [weak self] in
+                guard let self = self else { return }
                 backgroundValue = isInBackground
                 intervalValue = backgroundValue ? backgroundPollingInterval : currentPollingInterval
                 lastTimerInterval = intervalValue
             }
             intervalToUse = intervalValue
             backgroundStateToUse = backgroundValue
         }

Also applies to: 379-383

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 989c769 and 0fe91e0.

📒 Files selected for processing (3)
  • NetBird/Source/App/NetBirdApp.swift (1 hunks)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (1 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)
NetbirdKit/NetworkExtensionAdapter.swift (4)
  • startTimer (349-351)
  • startTimer (353-405)
  • isLoginRequired (109-115)
  • stop (148-150)
🔇 Additional comments (11)
NetBird/Source/App/NetBirdApp.swift (1)

33-48: LGTM! Clean lifecycle consolidation.

The centralized scenePhase handling properly coordinates background mode, extension state checks, and polling lifecycle. This is a cleaner pattern than the previous notification-based approach.

NetBird/Source/App/ViewModels/MainViewModel.swift (4)

115-117: LGTM! Throttling implementation is sound.

The 30-second throttling interval for extension state checks aligns with the battery optimization goals. Using Date.distantPast ensures the first check executes immediately.


124-130: Previous review comment addressed.

The use of Task { @MainActor in ... } instead of DispatchQueue.main.async properly ensures main-actor execution and satisfies Swift concurrency requirements. The throttling logic correctly limits extension state checks to the 30-second interval.


137-152: LGTM! Status update logic is correct.

The change detection and persistence logic properly avoids unnecessary UserDefaults writes by checking for actual changes before updating. The debug logging provides useful diagnostics.


159-169: LGTM! Efficient peer info update logic.

The sorted comparison with detailed field checking prevents unnecessary UI updates, reducing battery consumption from redundant view refreshes.

NetbirdKit/NetworkExtensionAdapter.swift (6)

20-34: LGTM! Well-structured polling state.

The polling state variables are properly documented with clear access requirements, and the interval values (10s, 20s, 60s) align with the PR objectives. Using a dedicated pollingQueue with .utility QoS is appropriate for background work.


261-305: Previous review comment addressed.

The response handling is now properly serialized through pollingQueue.async at line 265, preventing race conditions on polling state mutations. The adaptive interval logic correctly resets to fast polling on changes and gradually transitions to stable polling after 3 consecutive stable polls.


327-347: LGTM! Deadlock-safe timer restart logic.

The approach of capturing state values on pollingQueue (lines 336-337) before dispatching to the main thread prevents potential deadlocks from calling pollingQueue.sync on the main thread. The 2-second threshold avoids excessive timer churn.


353-405: LGTM! Well-documented deadlock avoidance.

The conditional parameter handling (lines 365-386) effectively prevents deadlocks by avoiding pollingQueue.sync calls from the main thread when called via restartTimerIfNeeded. The comments clearly explain the rationale. Timer creation on the main thread (line 389) is correct for RunLoop integration, and scheduling fetches on pollingQueue (line 395) maintains proper serialization.


407-415: LGTM! Proper state cleanup.

The state reset on pollingQueue ensures thread-safe cleanup and prepares for the next polling session with initial values.


417-431: LGTM! Lazy background mode transition.

The background mode change is properly serialized through pollingQueue, and the lazy restart approach (waiting for the next fetchData call) avoids immediate timer manipulation complexity. The logging provides useful diagnostics.

…iming

- Add hasStoppedForLoginFailure flag to prevent multiple stop() calls
- Flag is set when stop() is called for login failure condition
- Flag is reset when extensionState changes to .disconnected
- Prevents repeated stop() calls when condition remains true between polling cycles
- Fixes timing mismatch between managementStatus and extensionState updates
Copy link

@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 (2)
NetBird/Source/App/ViewModels/MainViewModel.swift (2)

142-167: Avoid calling isLoginRequired() on the main actor (and avoid double-calling it).
Line 152 and Line 160-163 call self.networkExtensionAdapter.isLoginRequired() on the main actor (and effectively twice in the “changed details” path). Given isLoginRequired() constructs a client (see NetbirdKit/NetworkExtensionAdapter.swift), this is a good candidate to (a) compute once, and (b) compute off-main, then use the boolean for UI decisions.

At minimum, compute once per update block:

-                    print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")
+                    let loginRequired = self.networkExtensionAdapter.isLoginRequired()
+                    print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(loginRequired)")
...
-                    if details.managementStatus == .disconnected &&
-                       self.extensionState == .connected &&
-                       self.networkExtensionAdapter.isLoginRequired() &&
+                    if details.managementStatus == .disconnected &&
+                       self.extensionState == .connected &&
+                       loginRequired &&
                        !self.hasStoppedForLoginFailure {

Longer-term, I’d move that computation into the adapter’s background/polling queue (or cache it with a TTL) and only publish the boolean to the view-model.


170-180: Peer sorting/diffing on the main actor may become a UI hot spot.
details.peerInfo.sorted(...) and elementsEqual(...) run inside the @MainActor task. If peerInfo can be large, consider doing sort + comparison off-main, then only assigning peerViewModel.peerInfo on the main actor.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0fe91e0 and b7b335a.

📒 Files selected for processing (1)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)
NetbirdKit/NetworkExtensionAdapter.swift (4)
  • startTimer (349-351)
  • startTimer (353-405)
  • isLoginRequired (109-115)
  • stop (148-150)
🔇 Additional comments (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

122-134: Main-actor hop in polling callback looks correct.
Using Task { @MainActor in ... } here is the right shape for an adapter callback that may arrive off-main, and the 30s throttle for checkExtensionState() is straightforward.

devc0der added 2 commits December 12, 2025 22:23
…onale

- Document that hash includes only core connectivity fields (ip, fqdn, managementStatus, peer.ip, peer.connStatus, peer count)
- Explain deliberate omission of peer.relayed, peer.direct, peer.connStatusUpdate, and peer.routes
- Clarify hash is used for battery optimization: major connectivity changes trigger fast (10s) polling, while secondary/visual-only updates use slower intervals
- Reference that MainViewModel performs more detailed comparisons for UI updates
…upling

- Reset hasStoppedForLoginFailure when extensionState == .disconnected, independent of extensionStateText
- Prevents flag from remaining stuck if extensionStateText doesn't match (string drift / future UI changes)
- Decouples flag reset logic from UI text state
- Keeps existing UX logic for extensionStateText update separate
- Ensures future stops are not suppressed due to flag being stuck
Copy link

@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 (2)
NetbirdKit/NetworkExtensionAdapter.swift (2)

20-34: Consider avoiding a shared PropertyListDecoder across concurrent decodes.
decoder is shared, while getRoutes(...) and fetchData(...) may decode concurrently; Foundation decoders aren’t clearly documented as thread-safe. Prefer a local decoder per decode, or serialize all decoding on a single queue.

-    let decoder = PropertyListDecoder()
+    private let decoder = PropertyListDecoder()

(If you want to be safest, instantiate PropertyListDecoder() inside each decode block instead of sharing one.)


358-410: Timer thread-affinity: ensure invalidate/create always happens on main (or switch to DispatchSourceTimer).
You already create/schedule the Timer on main; keep all timer invalidation on main as well, and consider DispatchSourceTimer on pollingQueue to eliminate main-runloop coupling.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b7b335a and da5e3a6.

📒 Files selected for processing (1)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🔇 Additional comments (3)
NetbirdKit/NetworkExtensionAdapter.swift (3)

314-330: Hash scoping comment is clear and matches the intent.
This nicely documents the “battery vs UI fidelity” tradeoff and should prevent future regressions.


422-436: Background transition defers interval change until “next fetch”; verify UX on returning to foreground.
If the timer is at 60s and you come back to foreground, make sure something (e.g., startTimer) triggers an immediate refresh rather than waiting up to 60s.


261-305: The code is already safe as designed. fetchData() intentionally completes on pollingQueue (background thread), but its only caller—MainViewModel.startPollingDetails()—properly marshals all results and state mutations to the main thread using Task { @MainActor in ... }. The completion callback is not directly invoked for UI updates; instead, it passes data to the ViewModel, which handles the main-thread requirement.

Adding DispatchQueue.main.async inside fetchData() would create redundant marshaling and unnecessarily couple the method to always require main-thread completion semantics, preventing flexible use by different callers. The current design—where background work completes on a background queue and the caller chooses threading context—is the correct pattern.

Likely an incorrect or invalid review comment.

devc0der added 4 commits December 12, 2025 22:30
…imer()

- Add isPollingActive Bool field, only mutated and read on pollingQueue
- Set isPollingActive = true when starting timer (both code paths)
- Set isPollingActive = false in stopTimer()
- Bail early in restartTimerIfNeeded if isPollingActive is false (checked on pollingQueue)
- Prevents in-flight fetchResponse from recreating timer after stopTimer() was called
- Ensures all accesses to isPollingActive occur on pollingQueue to avoid races
- Set isPollingActive = true when starting timer in both code paths (provided values and direct call)
- Ensures flag is set synchronously on pollingQueue to prevent race conditions
- Completes the isPollingActive flag implementation
…Timer

- Change setBackgroundMode to use pollingQueue.sync instead of async
- Ensures isInBackground is updated before startTimer reads it
- Fixes timing issue where startPollingDetails immediately calls startTimer after setBackgroundMode
- Prevents wrong polling interval calculation due to stale isInBackground value
- All state mutations still happen on pollingQueue, maintaining thread-safety
…ondition

- Change stopTimer to use pollingQueue.sync instead of async for isPollingActive
- Ensures flag is set immediately before in-flight fetchData callbacks can check it
- Prevents restartTimerIfNeeded from checking isPollingActive while it's still true
- Prevents timer from being recreated after stopTimer() was called
- Fixes race condition where async flag update could be delayed
@mlsmaycon
Copy link
Contributor

Thanks for you contribution @uid0c0der and welcome to github.

Can you please confirm which version of NetBird's Go code this was built against? Were you able to confirm the build and if it was working against a valid NetBird deployment?

@uid0c0der
Copy link
Author

uid0c0der commented Dec 13, 2025

Thanks for you contribution @uid0c0der and welcome to github.

Can you please confirm which version of NetBird's Go code this was built against? Were you able to confirm the build and if it was working against a valid NetBird deployment?

Thank you for the welcome.

NetBird Go version:
Built against the NetBird Go codebase from the main branch. The build script uses version=development by default.
That is what is used.

Build confirmation:
Built with Xcode 26 (Apple Enterprise signed)
Compiled without errors.

Testing results:
Tested on iPhone 15
Patch runs successfully
Battery consumption reduced significantly
No functional regressions observed
Network switching between mobile data and Wi‑Fi works as expected

Edit:
Working with netbird.io @mlsmaycon :
grafik

Copy link

@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: 0

🧹 Nitpick comments (3)
NetbirdKit/NetworkExtensionAdapter.swift (2)

364-418: Consider ensuring timer.invalidate() is called on main thread.

Line 365 calls timer.invalidate() outside the DispatchQueue.main.async block. While current call sites (from @MainActor ViewModel or restartTimerIfNeeded's main dispatch) are safe, the function itself doesn't enforce this precondition.

For defensiveness, consider moving the invalidation inside the main dispatch or adding a precondition:

 private func startTimer(interval: TimeInterval?, backgroundState: Bool?, completion: @escaping (StatusDetails) -> Void) {
-    self.timer.invalidate()
-    
-    // Initial fetch
-    self.fetchData(completion: completion)
+    // Invalidate on main thread where timer was scheduled
+    DispatchQueue.main.async { [weak self] in
+        self?.timer.invalidate()
+    }
     
+    // Initial fetch
+    self.fetchData(completion: completion)

Alternatively, document that this function must be called from the main thread.


420-430: Same thread safety concern for timer.invalidate() in stopTimer().

Line 421 invalidates the timer without explicitly ensuring execution on the main thread. Current callers (from MainViewModel.close()) dispatch to main, but the function could be more defensive:

 func stopTimer() {
-    self.timer.invalidate()
+    DispatchQueue.main.async { [weak self] in
+        self?.timer.invalidate()
+    }
     // Reset state variables and set isPollingActive to false synchronously

Or combine with the sync block if you want stop to be synchronous for the caller.

NetBird/Source/App/ViewModels/MainViewModel.swift (1)

177-188: Inconsistent indentation within the Task block.

Lines 180-188 have reduced indentation compared to the surrounding code within the Task { @MainActor in } block. Consider aligning for consistency:

             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
-        }
+                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
+                }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between da5e3a6 and 9269ac6.

📒 Files selected for processing (2)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (1 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (2)
NetbirdKit/NetworkExtensionAdapter.swift (4)
  • startTimer (360-362)
  • startTimer (364-418)
  • isLoginRequired (110-116)
  • stop (149-151)
NetbirdNetworkExtension/NetBirdAdapter.swift (1)
  • stop (126-128)
🔇 Additional comments (8)
NetbirdKit/NetworkExtensionAdapter.swift (5)

20-36: Well-structured battery optimization state management.

The polling state variables are properly documented with thread-safety comments, and the dedicated pollingQueue provides the necessary serialization. The isPollingActive flag addresses the previous review concern about in-flight responses recreating timers after stop.


262-313: Response handling correctly serialized through pollingQueue.

The refactored fetchData properly routes all state mutations through pollingQueue.async, addressing the previous threading concern. The adaptive polling logic (reset to 10s on changes, increase to 20s after 3 stable polls) is well-implemented.


315-331: Hash scope documentation addresses previous review.

The added comments clearly explain the intentional field exclusions for battery optimization, noting that MainViewModel handles detailed UI comparisons separately.


333-358: Timer restart logic correctly implements the isPollingActive gate.

The early bail on !isPollingActive prevents in-flight responses from recreating the timer after stopTimer(). State values are captured on pollingQueue before dispatching to main thread, avoiding potential deadlocks.


432-447: Background mode handling is thread-safe and well-documented.

State mutation is synchronized through pollingQueue.sync, and the comment correctly notes that timer restart is deferred to the next fetchData call via restartTimerIfNeeded.

NetBird/Source/App/ViewModels/MainViewModel.swift (3)

119-125: Battery optimization state variables are well-designed.

Initializing lastExtensionStateCheck to Date.distantPast ensures the first check happens immediately. The 30-second throttle interval and hasStoppedForLoginFailure guard address the previous review concerns about repeated stop() calls.


126-148: Throttled polling and stop guard correctly implemented.

The Task { @MainActor in } pattern properly addresses the previous review about avoiding DispatchQueue.main.async inside a @MainActor class. The unconditional reset of hasStoppedForLoginFailure when extensionState == .disconnected (lines 140-141) decouples it from extensionStateText as suggested.


166-176: Stop guard prevents repeated calls during async state transitions.

The multi-condition check with hasStoppedForLoginFailure ensures stop() is called only once per login failure state, addressing the previous review concern about timing mismatches between polling and extension state updates.

devc0der added 3 commits December 13, 2025 20:52
- Wrap timer.invalidate() in DispatchQueue.main.async in startTimer()
- Wrap timer.invalidate() in DispatchQueue.main.async in stopTimer()
- Ensures timer operations happen on main thread where timer was scheduled
- Makes code more defensive, independent of caller thread
- Addresses thread-safety concerns from code review
- Fix inconsistent indentation in Task { @mainactor in } block
- Align sortedPeerInfo and if statement with surrounding code
- Improve code readability and consistency
…tion

- Change timer.invalidate() from async to sync in startTimer()
- Use Thread.isMainThread check to avoid deadlock if already on main thread
- Ensures old timer is invalidated before fetchData() is called
- Prevents old and new timers from running simultaneously
- Prevents concurrent fetchData() calls and race conditions in state updates
Copy link

@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

♻️ Duplicate comments (1)
NetbirdKit/NetworkExtensionAdapter.swift (1)

364-373: Avoid DispatchQueue.main.sync here (potential deadlock); enforce call-site preconditions.
If startTimer(...) is ever invoked from pollingQueue while main is blocked in a pollingQueue.sync (e.g., stopTimer() called on main), the main.sync at Line 370 can deadlock. Prefer a precondition or force this API onto the main actor/thread.

 private func startTimer(interval: TimeInterval?, backgroundState: Bool?, completion: @escaping (StatusDetails) -> Void) {
+        dispatchPrecondition(condition: .notOnQueue(pollingQueue))
         // Invalidate timer synchronously on main thread to prevent old timer from running concurrently
         // This is safe because startTimer is either called from main thread or via restartTimerIfNeeded's main.async
         if Thread.isMainThread {
             self.timer.invalidate()
         } else {
-            DispatchQueue.main.sync { [weak self] in
-                self?.timer.invalidate()
-            }
+            DispatchQueue.main.async { [weak self] in
+                self?.timer.invalidate()
+            }
         }

If you truly need “invalidate before continuing”, consider making this whole method @MainActor and moving the non-UI work to pollingQueue.

🧹 Nitpick comments (3)
NetbirdKit/NetworkExtensionAdapter.swift (2)

20-35: Make decoder private and consider narrowing mutable surface (session, vpnManager).
decoder (Line 20) and the adapter’s core state are implementation details; reducing visibility helps prevent accidental cross-thread access.

-    let decoder = PropertyListDecoder()
+    private let decoder = PropertyListDecoder()

252-306: fetchData completion runs on pollingQueue—document or normalize callback queue.
Right now completion(...) is invoked from pollingQueue (Line 266+). That’s fine if all callers hop to @MainActor (as MainViewModel does), but it’s an easy footgun for other call sites.

-                            completion(decodedStatus)
+                            DispatchQueue.main.async {
+                                completion(decodedStatus)
+                            }

(Or add a short doc comment on fetchData / startTimer stating which queue completion is invoked on.)

NetBird/Source/App/ViewModels/MainViewModel.swift (1)

150-175: Compute isLoginRequired() once (and ideally off-main) to avoid UI hitching.
Inside the @MainActor block you call networkExtensionAdapter.isLoginRequired() in the log (Line 160) and again in the stop condition (Line 170). If this touches disk / initializes SDK objects, it can stutter the UI.

-    print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())")
+    let loginRequired = self.networkExtensionAdapter.isLoginRequired()
+    print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(loginRequired)")
 ...
-   self.networkExtensionAdapter.isLoginRequired() &&
+   loginRequired &&

Optionally: only evaluate loginRequired when details.managementStatus == .disconnected && self.extensionState == .connected.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9269ac6 and 53a5a8c.

📒 Files selected for processing (2)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (1 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (2)
NetbirdKit/NetworkExtensionAdapter.swift (4)
  • startTimer (360-362)
  • startTimer (364-426)
  • isLoginRequired (110-116)
  • stop (149-151)
NetbirdNetworkExtension/NetBirdAdapter.swift (1)
  • stop (126-128)
🔇 Additional comments (4)
NetbirdKit/NetworkExtensionAdapter.swift (2)

333-358: Timer restart logic looks solid and correctly gated by isPollingActive.
Capturing intervalToUse / backgroundStateToUse on pollingQueue before hopping to main avoids the sync deadlock that the earlier approach risked.


315-331: Hash scope documentation + background-mode mutation discipline are clear.
The comment above calculateStatusHash and the “all state on pollingQueue” approach in setBackgroundMode make the tradeoffs explicit and keep mutation ordering predictable.

Also applies to: 444-458

NetBird/Source/App/ViewModels/MainViewModel.swift (2)

119-125: Throttling extension-state checks to 30s is a good battery tradeoff.
The lastExtensionStateCheck / extensionStateCheckInterval mechanism is straightforward and keeps status polling from cascading into frequent NE preference loads.


126-148: Main-actor hop + stop-guard reset logic looks correct.
Task { @MainActor in ... } ensures UI safety, and the unconditional reset on .disconnected avoids coupling to extensionStateText.

Comment on lines 413 to 419
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)
}
}
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.

devc0der added 4 commits December 13, 2025 21:38
- Call checkExtensionState() immediately after networkExtensionAdapter.start()
- Ensures polling starts right away if extension is already connected
- Works together with previous fix to eliminate need for multiple connect attempts
- Provides immediate feedback and status updates after connect()
- Set extensionStateText to 'Connecting' immediately when connect() is called
- Add pollExtensionStateUntilConnected() to check extension state every 1 second
- Ensures UI updates immediately when extension becomes connected
- Fixes issue where UI showed 'Disconnected' for ~20 seconds after connect
- Stops polling automatically once extension is connected
- Max 15 attempts (15 seconds) to prevent infinite polling
- Update extensionStateText immediately when extensionState changes
- Ensures UI shows correct status without waiting for CustomLottieView
- Provides immediate feedback when extension becomes connected
- Works together with pollExtensionStateUntilConnected() for instant UI updates
- Change GlobalConstants struct to public
- Make all static properties public
- Fixes 'cannot find GlobalConstants in scope' error in MainViewModel
- Allows NetBird target to access NetbirdKit constants
@uid0c0der uid0c0der marked this pull request as ready for review December 13, 2025 21:03
- Change pollingQueue QoS from .utility to .userInitiated
- Prevents priority inversion when main thread (user-interactive) waits on pollingQueue
- Main thread uses semaphore.wait() in startTimer, stopTimer, setBackgroundMode, setInactiveMode
- .userInitiated is appropriate for user-initiated background work that main thread depends on
- Reduces risk of main thread blocking on lower-priority queue operations
- Improves responsiveness and prevents potential UI freezes
@uid0c0der uid0c0der marked this pull request as draft December 13, 2025 21:06
Copy link

@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: 0

♻️ Duplicate comments (2)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

212-220: Peer route changes may not trigger UI update if content changes but count stays the same.

A previous review flagged that comparing a.routes.count == b.routes.count (line 216) won't detect in-place route changes. If Route conforms to Equatable, consider:

-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
+a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && a.routes == b.routes
NetbirdKit/NetworkExtensionAdapter.swift (1)

453-459: Timer tick can still fire after stopTimer() sets isPollingActive = false.

The past review flagged that after stopTimer() sets isPollingActive = false, the existing timer can still fire (since invalidation is async on main queue), and the tick unconditionally enqueues fetchData without checking isPollingActive. This remains unaddressed.

Add a guard inside the timer closure 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)
     }
 }
🧹 Nitpick comments (3)
NetBird/Source/App/NetBirdApp.swift (1)

33-53: Scene phase handling looks correct for battery optimization.

The consolidated scenePhase approach is cleaner than notification-based handling. The priority of background > inactive > foreground for polling intervals is properly maintained.

One minor consideration: startPollingDetails() is called unconditionally when becoming .active, which triggers a new timer even if polling is already active. The NetworkExtensionAdapter.startTimer() handles this by invalidating the existing timer first, but this still causes an unnecessary fetchData call on each .active transition. If app-switcher dismissals are frequent, consider gating on polling state:

 case .active:
     print("App became active")
     viewModel.networkExtensionAdapter.setBackgroundMode(false)
     viewModel.networkExtensionAdapter.setInactiveMode(false)
     viewModel.checkExtensionState()
-    viewModel.startPollingDetails()
+    if viewModel.extensionState == .connected {
+        viewModel.startPollingDetails()
+    }
NetbirdKit/NetworkExtensionAdapter.swift (1)

468-490: Semaphore pattern is fragile if stopTimer() is ever called from an async context.

The semaphore.wait() call blocks the calling thread. The comment claims this is safe because stopTimer is called from main thread, but there's no enforcement (unlike startTimer which has dispatchPrecondition). If stopTimer() is ever invoked from a Swift Concurrency context (e.g., inside a Task), it will block the cooperative thread pool and potentially deadlock.

Consider adding the same precondition for consistency, or document the contract more prominently:

 func stopTimer() {
+    // IMPORTANT: Must not be called from Swift Concurrency context (Task, async function)
+    // as semaphore.wait() would block the cooperative thread pool
+    dispatchPrecondition(condition: .notOnQueue(pollingQueue))
+    
     // Invalidate timer on main thread where it was scheduled
     DispatchQueue.main.async { [weak self] in
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

114-132: Consider canceling existing fast-poll sequence if connect() is called again.

If the user rapidly taps connect multiple times, multiple pollExtensionStateUntilConnected chains could run concurrently, each checking and potentially calling checkExtensionState() simultaneously.

Consider tracking the polling task and canceling it on new connect attempts:

+private var connectionPollingTask: Task<Void, Never>?
+
 private func pollExtensionStateUntilConnected(attempt: Int, maxAttempts: Int) {
+    connectionPollingTask?.cancel()
+    connectionPollingTask = Task { @MainActor in
         guard attempt < maxAttempts else {
             print("Max attempts reached for extension state polling")
             return
         }
         
         checkExtensionState()
         
-        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
-            guard let self = self else { return }
+        try? await Task.sleep(nanoseconds: 1_000_000_000)
+        guard !Task.isCancelled else { return }
+        
         if self.extensionState != .connected {
             self.pollExtensionStateUntilConnected(attempt: attempt + 1, maxAttempts: maxAttempts)
         }
-        }
+    }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 53a5a8c and 819fa3c.

📒 Files selected for processing (4)
  • NetBird/Source/App/NetBirdApp.swift (1 hunks)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (3 hunks)
  • NetbirdKit/GlobalConstants.swift (1 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/NetBirdApp.swift (2)
NetbirdKit/NetworkExtensionAdapter.swift (2)
  • setBackgroundMode (492-516)
  • setInactiveMode (518-550)
NetBird/Source/App/ViewModels/MainViewModel.swift (3)
  • stopPollingDetails (225-227)
  • checkExtensionState (229-257)
  • startPollingDetails (153-223)
🔇 Additional comments (9)
NetbirdKit/GlobalConstants.swift (1)

8-10: LGTM!

Making GlobalConstants and its members public enables proper cross-module access for shared configuration keys. The change is appropriate for constants used across the app and extension modules.

NetbirdKit/NetworkExtensionAdapter.swift (4)

22-38: Well-structured state management for adaptive polling.

The polling state variables are properly isolated to pollingQueue access, and the interval hierarchy (background > inactive > foreground) is clearly defined. The isPollingActive flag addresses the previously identified race condition concern.


264-316: Response handling properly serialized through pollingQueue.

This addresses the previously identified race condition where state mutations (currentPollingInterval, consecutiveStablePolls, lastStatusHash) could occur on arbitrary completion handler threads. All mutations now happen on pollingQueue, and completions are dispatched to main queue for UI safety.


326-342: Hash calculation documentation is comprehensive.

The comment clearly explains which fields are included/excluded and the rationale (battery optimization vs. UI updates). This addresses the previous review feedback about documenting the hash scope.


344-377: restartTimerIfNeeded correctly guards against stopped polling state.

The isPollingActive check at line 347 prevents in-flight responses from recreating the timer after stopTimer() was called. State is captured before the main.async dispatch to avoid deadlock with pollingQueue.sync.

NetBird/Source/App/ViewModels/MainViewModel.swift (4)

100-112: Good UX improvement with immediate "Connecting" feedback.

Setting extensionStateText to "Connecting" immediately and using fast polling provides better user feedback during connection establishment.


146-151: Battery optimization state tracking is well-designed.

The 30-second throttle for extension state checks (extensionStateCheckInterval) combined with hasStoppedForLoginFailure guard prevents redundant operations and addresses the previously identified timing issues.


153-175: Polling callback correctly uses Task { @MainActor } pattern.

This addresses the previous review feedback about using structured concurrency instead of DispatchQueue.main.async inside a @MainActor ViewModel. The unconditional reset of hasStoppedForLoginFailure on .disconnected state also addresses the prior coupling concern.


229-257: Extension state handling provides good immediate feedback.

The immediate extensionStateText updates and conditional polling start on connection ensure responsive UI. The wasConnected check prevents redundant polling restarts.

devc0der added 7 commits December 13, 2025 22:12
- Change from a.routes.count == b.routes.count to Set(a.routes) == Set(b.routes)
- Consistent with PeerInfo: Equatable which uses Set(lhs.routes) == Set(rhs.routes)
- Properly detects route changes regardless of order
- Count-only comparison could miss changes (e.g., ['A','B'] vs ['C','D'] both count=2)
- Set comparison ignores order and checks actual content, matching Equatable behavior
- Add isPollingActive guard inside timer closure before fetchData()
- Prevents timer tick from executing after stopTimer() sets isPollingActive = false
- Timer invalidation is async on main queue, so timer can still fire before invalidation completes
- Guard ensures no unnecessary work is done after polling is stopped
- Fixes race condition where timer could fire between stopTimer() and actual invalidation
- Gate startPollingDetails() on extensionState == .connected
- Prevents unnecessary fetchData() calls when app becomes active but extension is not connected
- startTimer() invalidates existing timer and immediately calls fetchData(), which is wasteful if not connected
- App-switcher dismissals are frequent, so this optimization reduces battery usage
- checkExtensionState() is still called to update extension state
- Polling will start automatically when extension becomes connected (via checkExtensionState callback)
- Add dispatchPrecondition(condition: .notOnQueue(pollingQueue)) to stopTimer()
- Add prominent comment documenting that stopTimer() must not be called from Swift Concurrency context
- semaphore.wait() blocks the calling thread, which would block cooperative thread pool if called from Task/async
- Ensures consistency with startTimer() which already has the same precondition
- Prevents potential deadlocks when stopTimer() is called from async contexts
- Add connectionPollingTask property to track polling task
- Cancel existing task before starting new one to prevent concurrent polling chains
- Replace DispatchQueue.main.asyncAfter with Task.sleep for modern async/await pattern
- Add Task.isCancelled check to handle cancellation gracefully
- Prevents multiple pollExtensionStateUntilConnected chains running simultaneously
- Reduces redundant checkExtensionState() calls when user rapidly taps connect
- Improves resource usage and prevents race conditions
- Replace DispatchQueue.main.asyncAfter with Task.sleep for modern async/await pattern
- Add Task.isCancelled check to handle cancellation gracefully
- Enables proper cancellation of polling chain when connect() is called again
- Prevents multiple concurrent polling chains from running simultaneously
- Improves resource usage and prevents race conditions
- Replace recursive calls with while loop to prevent resource leaks
- Recursive calls were overwriting connectionPollingTask, leaving earlier Task instances running untracked
- Now a single Task runs from start to finish, properly tracked and cancellable
- Add multiple Task.isCancelled checks for proper cancellation handling
- Early exit when extension becomes connected
- Prevents multiple concurrent polling tasks and resource leaks
@uid0c0der uid0c0der marked this pull request as ready for review December 13, 2025 21:25
Copy link

@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 (3)
NetbirdKit/NetworkExtensionAdapter.swift (3)

385-471: Consider refactoring to avoid blocking the main thread with semaphores.

Lines 427-449 use semaphore.wait() on the main thread to read state from pollingQueue. While the comments indicate this is safe (not called from Swift Concurrency contexts) and the blocking time should be minimal, this can still cause brief UI hitching if pollingQueue is busy.

Consider refactoring to use an async pattern or pre-captured state to eliminate main-thread blocking. For example, you could store the computed interval in an atomic property or use async/await with @MainActor annotations.

That said, given the current architecture constraints and the minimal blocking time, this is a pragmatic solution that works correctly.


473-500: Consider async alternative to semaphore blocking in stopTimer().

Similar to startTimer, stopTimer blocks the main thread with semaphore.wait() (Line 499). While the precondition enforcement prevents deadlocks and the blocking time is minimal, an async/await refactor would eliminate the main-thread blocking entirely.

For example:

func stopTimer() async {
    await MainActor.run {
        self.timer.invalidate()
    }
    await pollingQueue.asyncTask {
        self.consecutiveStablePolls = 0
        self.currentPollingInterval = self.minPollingInterval
        self.isPollingActive = false
    }
}

However, this would require callers to be in async contexts, which might be a larger refactor.


502-560: Semaphore blocking in state setters could be replaced with async patterns.

Both setBackgroundMode and setInactiveMode block the main thread with semaphores (Lines 525, 559). While this ensures state is synchronized before returning, it introduces brief blocking.

Since these are called from SwiftUI lifecycle observers (scene phase changes), consider whether the immediate return value is necessary, or if an async update pattern would work equally well without blocking.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 819fa3c and e76586a.

📒 Files selected for processing (3)
  • NetBird/Source/App/NetBirdApp.swift (1 hunks)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (3 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • NetBird/Source/App/NetBirdApp.swift
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)
NetbirdKit/NetworkExtensionAdapter.swift (5)
  • start (62-70)
  • startTimer (381-383)
  • startTimer (385-471)
  • isLoginRequired (114-120)
  • stop (153-155)
🔇 Additional comments (8)
NetBird/Source/App/ViewModels/MainViewModel.swift (4)

100-101: LGTM: Immediate UI feedback improves UX.

Setting the UI state to "Connecting" immediately provides better user feedback while the extension starts.


163-171: LGTM: Well-structured battery optimization state.

The battery optimization variables are clearly named and documented. The 30-second throttling interval, stop-guard flag, and task lifecycle management provide a solid foundation for the optimizations.


174-242: LGTM: Comprehensive polling refactor with proper concurrency handling.

The refactored polling logic properly:

  • Uses [weak self] and Task { @MainActor in ... } for safe async updates
  • Throttles extension state checks to every 30 seconds
  • Guards against repeated stop() calls with hasStoppedForLoginFailure
  • Unconditionally resets the stop guard on disconnect (addressing past review feedback)
  • Performs detailed peerInfo comparison including route Set equality

253-273: LGTM: Immediate state transitions improve responsiveness.

The immediate UI text updates and automatic polling start on connection provide better user experience without waiting for periodic checks.

NetbirdKit/NetworkExtensionAdapter.swift (4)

20-40: LGTM: Well-organized adaptive polling infrastructure.

The battery optimization state variables are clearly documented, and the use of userInitiated QoS for pollingQueue properly avoids priority inversion when the main thread synchronizes with this queue.


266-319: LGTM: Response handling properly serialized through pollingQueue.

The refactored fetchData correctly:

  • Serializes all state mutations through pollingQueue (addressing past concurrency concerns)
  • Handles nil responses safely
  • Calculates status hash to detect changes
  • Adjusts polling intervals dynamically
  • Dispatches completions to the main queue for thread safety

328-344: LGTM: Hash calculation scope is now well-documented.

The documentation clearly explains why certain fields (relayed, direct, connStatusUpdate, routes) are intentionally omitted from the hash calculation for battery optimization, while MainViewModel performs more detailed comparisons for UI updates.


346-379: LGTM: Timer restart logic prevents races and avoids deadlocks.

The function properly:

  • Guards with isPollingActive to prevent in-flight responses from recreating the timer after stop
  • Captures state on pollingQueue before dispatching to main thread (avoiding deadlock)
  • Only restarts when the interval changes significantly

Comment on lines +116 to +149
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")
}
}
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.

devc0der added 3 commits December 13, 2025 22:29
…llFromPreferences()

- Serialize getExtensionStatus() calls through pollingQueue to prevent concurrent loadAllFromPreferences() invocations
- pollExtensionStateUntilConnected() calls checkExtensionState() every second for up to 15 seconds, potentially spawning 15 concurrent Task instances
- Each Task calls getExtensionStatus() which triggers loadAllFromPreferences() without synchronization
- Now all getExtensionStatus() calls are serialized through pollingQueue, ensuring only one loadAllFromPreferences() call at a time
- Dispatch completion to main thread for thread safety
- Consistent with rest of codebase which uses pollingQueue for serialization
- Set extensionStateText to 'Disconnecting' immediately when disconnect is pressed
- Call checkExtensionState() immediately after stop() to update UI without waiting for 30s periodic check
- Provides immediate UI feedback similar to connect() behavior
- Improves user experience by showing disconnect state immediately instead of delayed update
- Add fallback to .disconnected status when no manager is found
- Add error handling to return .disconnected on loadAllFromPreferences() failure
- Prevents UI hang when getExtensionStatus() is called during app initialization
- Ensures completion handler is always called, even on error
- Improves app startup reliability
Copy link

@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: 3

🧹 Nitpick comments (1)
NetbirdKit/NetworkExtensionAdapter.swift (1)

473-500: Same semaphore risk applies to stopTimer().

The semaphore pattern here has the same fragility as in startTimer. The comment at lines 474-476 is helpful, but a dispatchPrecondition(condition: .onQueue(.main)) would provide stronger enforcement that this is called from the main thread specifically (not just "not pollingQueue").

 func stopTimer() {
     // IMPORTANT: Must not be called from Swift Concurrency context (Task, async function)
     // as semaphore.wait() would block the cooperative thread pool and potentially deadlock.
     // This function must be called from main thread or synchronous context only.
     dispatchPrecondition(condition: .notOnQueue(pollingQueue))
+    // Prefer calling from main thread to ensure semaphore.wait() doesn't block cooperative threads
+    assert(Thread.isMainThread, "stopTimer() should be called from main thread")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e76586a and ef68b4e.

📒 Files selected for processing (2)
  • NetBird/Source/App/ViewModels/MainViewModel.swift (3 hunks)
  • NetbirdKit/NetworkExtensionAdapter.swift (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)
NetbirdKit/NetworkExtensionAdapter.swift (5)
  • start (62-70)
  • stop (153-155)
  • startTimer (381-383)
  • startTimer (385-471)
  • isLoginRequired (114-120)
🔇 Additional comments (11)
NetbirdKit/NetworkExtensionAdapter.swift (6)

20-39: Well-structured state management for adaptive polling.

The polling state variables are properly documented and centralized. The use of .userInitiated QoS for pollingQueue is appropriate given the comment about avoiding priority inversion when main thread waits on this queue via semaphores.


266-318: Good serialization of response handling through pollingQueue.

All state mutations (consecutiveStablePolls, currentPollingInterval, lastStatusHash) now occur on pollingQueue, addressing the previous race condition concern. Completions are correctly dispatched to main queue for thread safety.


328-344: Hash documentation clearly explains the design rationale.

The inline comment effectively communicates why certain peer fields are excluded from the hash and how this relates to battery optimization vs. UI update responsibilities.


346-379: Proper guard against timer recreation after stop.

The isPollingActive guard at line 349 correctly prevents in-flight responses from recreating the timer after stopTimer() has been called. State capture before dispatching to main thread avoids potential deadlocks.


455-464: Timer callback correctly guards against work-after-stop.

The guard self.isPollingActive else { return } check at line 461 prevents unnecessary fetchData calls after stopTimer() sets the flag to false, even if timer invalidation is still pending.


502-560: Background/inactive mode setters follow consistent patterns.

Both methods correctly use pollingQueue for state mutations and defer timer restart to the next fetchData call. The priority logic (background > inactive > foreground) is correctly implemented in both.

NetBird/Source/App/ViewModels/MainViewModel.swift (5)

100-112: Good immediate UI feedback on connect.

Setting extensionStateText = "Connecting" before starting the async work provides immediate visual feedback. The polling initiation with pollExtensionStateUntilConnected ensures the UI updates promptly when connected.


159-167: Immediate UI feedback on disconnect is appropriate.

Setting extensionStateText = "Disconnecting" before stop() provides good UX. The checkExtensionState() call afterward will eventually update the state, though it may take a moment for the VPN to actually disconnect.


170-179: State tracking variables properly address previous concerns.

hasStoppedForLoginFailure prevents repeated stop() calls (addressing a past review comment), and connectionPollingTask enables proper cancellation of polling when connect() is called again.


180-250: Polling details refactored with proper threading and state guards.

Key improvements:

  • Task { @MainActor in ... } for UI updates (addresses past review)
  • Unconditional hasStoppedForLoginFailure reset on .disconnected (addresses past review)
  • Set(a.routes) == Set(b.routes) for proper route comparison (addresses past review)
  • isLoginRequired() computed once per iteration to avoid UI hitching

256-284: Consider handling .disconnecting state in UI text update.

The extensionStateText update handles .connected, .connecting, and .disconnected, but not .disconnecting. When the user taps disconnect, close() sets "Disconnecting" immediately, but if checkExtensionState is called while status is .disconnecting, it won't update the text (which may already be correct from close()). This is likely fine, but worth noting for completeness.

Comment on lines +114 to +149
// 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")
}
}
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.

Comment on lines +427 to +449
let semaphore = DispatchSemaphore(value: 0)
var intervalValue: TimeInterval = minPollingInterval
var backgroundValue: Bool = false
pollingQueue.async {
backgroundValue = isInBackground
let inactiveValue = isInactive
// Priority: background > inactive > current (foreground)
if backgroundValue {
intervalValue = backgroundPollingInterval
} else if inactiveValue {
intervalValue = inactivePollingInterval
} else {
intervalValue = currentPollingInterval
}
lastTimerInterval = intervalValue
isPollingActive = true
semaphore.signal()
}
// Wait for async operation to complete (safe here as we're not in Swift Concurrency context)
semaphore.wait()
intervalToUse = intervalValue
backgroundStateToUse = backgroundValue
}
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

🧩 Analysis chain

🏁 Script executed:

rg -n "startTimer\(" --type swift -B 5 -A 2

Repository: netbirdio/ios-client

Length of output: 5942


🏁 Script executed:

# Check for Task, async/await, or Swift Concurrency patterns in the adapter file
rg -n "(Task|async let|await|actor|nonisolated)" NetbirdKit/NetworkExtensionAdapter.swift | head -50

Repository: netbirdio/ios-client

Length of output: 1092


🏁 Script executed:

# Get lines around 474 and 388 to see the full precondition and comments
sed -n '385,395p' NetbirdKit/NetworkExtensionAdapter.swift
echo "---"
sed -n '470,480p' NetbirdKit/NetworkExtensionAdapter.swift

Repository: netbirdio/ios-client

Length of output: 1382


🏁 Script executed:

# Show the exact snippet from lines 427-449 with context
sed -n '420,450p' NetbirdKit/NetworkExtensionAdapter.swift

Repository: netbirdio/ios-client

Length of output: 1510


Improve comment clarity for semaphore usage to match the explicit Swift Concurrency constraint documented elsewhere.

The comment at line 446 stating "safe here as we're not in Swift Concurrency context" is vague. Compare this to stopTimer()'s explicit warning (line 474): "Must not be called from Swift Concurrency context (Task, async function) as semaphore.wait() would block the cooperative thread pool and potentially deadlock."

Update the comment for the semaphore.wait() at line 446 to match this clarity:

  • Explicitly state the constraint: "Must not be called from Swift Concurrency context"
  • Explain the risk: "semaphore.wait() blocks the cooperative thread pool"
  • Note the safety mechanism: "dispatchPrecondition enforces this constraint"

The dispatchPrecondition(condition: .notOnQueue(pollingQueue)) at line 388 already prevents unsafe calls, so no code changes are needed—only clarify the documentation to match the safety comments in setBackgroundMode() and setInactiveMode().

🤖 Prompt for AI Agents
In NetbirdKit/NetworkExtensionAdapter.swift around lines 427 to 449, the inline
comment "safe here as we're not in Swift Concurrency context" is vague; update
it to explicitly state "Must not be called from Swift Concurrency context (Task,
async function) as semaphore.wait() blocks the cooperative thread pool and can
deadlock", mention that this risk is why dispatchPrecondition(condition:
.notOnQueue(pollingQueue)) enforces the constraint, and mirror the clearer
wording used in stopTimer() so the semaphore.wait() usage and its safety
mechanism are clearly documented.

Comment on lines 562 to 596
func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) {
Task {
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
completion(manager.connection.status)
// Serialize loadAllFromPreferences() calls to prevent concurrent access
// This is especially important when pollExtensionStateUntilConnected() calls
// checkExtensionState() multiple times per second, which would otherwise
// spawn multiple concurrent Task instances calling loadAllFromPreferences()
// Note: Task is created outside pollingQueue to avoid blocking the queue with async work
pollingQueue.async { [weak self] in
guard let self = self else { return }

// Create Task outside pollingQueue to avoid blocking the serial queue
// The async work (loadAllFromPreferences) will run concurrently, but
// we ensure only one call to getExtensionStatus is processed at a time
Task {
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
DispatchQueue.main.async {
completion(manager.connection.status)
}
} else {
// No manager found, return disconnected status
DispatchQueue.main.async {
completion(.disconnected)
}
}
} catch {
print("Error loading from preferences: \(error)")
// Return disconnected status on error to prevent UI hang
DispatchQueue.main.async {
completion(.disconnected)
}
}
} catch {
print("Error loading from preferences: \(error)")
}
}
}
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

Task creation inside pollingQueue doesn't serialize async operations.

The comment at lines 571-573 acknowledges the issue: "The async work (loadAllFromPreferences) will run concurrently." Creating a Task inside pollingQueue.async immediately returns control to the queue, allowing subsequent getExtensionStatus calls to spawn additional concurrent Task instances.

For true serialization of loadAllFromPreferences() calls during aggressive polling (15 calls/second in pollExtensionStateUntilConnected), consider using an AsyncStream or actor-based approach. However, given that Apple's NetworkExtension framework likely handles concurrent reads safely, this may be acceptable for now.

- Delay setBackgroundMode, setInactiveMode, and checkExtensionState by 0.1s in .active case
- These operations use semaphores that could block app launch if pollingQueue is busy
- Prevents app from hanging on launch screen after 'App became active' log
- App can fully load before state updates are performed
- Improves app startup reliability
@uid0c0der uid0c0der marked this pull request as draft December 13, 2025 21:46
devc0der added 5 commits December 13, 2025 22:50
- Separate checkExtensionState() call with longer delay (0.5s) from state updates (0.1s)
- Prevents app from hanging if extension is not configured or not running
- App can now start even if VPN is not manually started
- setBackgroundMode/setInactiveMode still execute early (0.1s) for proper state
- checkExtensionState() executes later (0.5s) to avoid blocking app launch
- Improves app startup reliability when extension is not available
…blocking

- Only call checkExtensionState() when extensionState is .disconnected (first launch scenario)
- On first launch, extensionState defaults to .disconnected, so check is delayed (0.5s)
- When extension state is already known (not first launch), check immediately
- Prevents app from blocking on first launch when extension doesn't exist yet
- Improves app startup reliability for new installations
- Remove conditional logic that was always true (extensionState defaults to .disconnected)
- Always delay checkExtensionState() by 0.5s in .active case
- getExtensionStatus() already has fallback to .disconnected if extension doesn't exist
- This ensures app can start on first launch when extension doesn't exist yet
- Simpler, clearer logic that works for all scenarios
- Remove artificial delays (0.1s, 0.5s) that were workarounds for blocking operations
- Remove checkExtensionState() call from .active case - it will be called automatically when needed:
  * When user taps connect (pollExtensionStateUntilConnected)
  * When extension becomes connected (checkExtensionState in startPollingDetails)
  * Periodically during polling (every 30s)
- Call setBackgroundMode/setInactiveMode asynchronously on main queue (non-blocking)
- App can now start immediately without any blocking operations
- Prevents blocking on first launch when extension doesn't exist yet
- Cleaner code without workarounds
…launch

- Remove setBackgroundMode(false) and setInactiveMode(false) calls in .active case
- Initial state is already false (foreground, active), so these calls are redundant
- These functions use semaphore.wait() which could block even when called asynchronously
- State will be properly updated when app actually transitions from background/inactive
- Prevents any potential blocking during app launch
- Cleaner code without unnecessary state updates
@mlsmaycon
Copy link
Contributor

Thanks for clarifying @uid0c0der.

I had issues compiling it with some missing closing curly braces, and after fixing (probably breaking) it, it wasn't stable. In any case, yesterday we started improving the reconnecting logic too, here #39. We like your take, especially on memory and status. We are still validating it too.

@uid0c0der
Copy link
Author

Thanks for clarifying @uid0c0der.

I had issues compiling it with some missing closing curly braces, and after fixing (probably breaking) it, it wasn't stable. In any case, yesterday we started improving the reconnecting logic too, here #39. We like your take, especially on memory and status. We are still validating it too.

@mlsmaycon my test was without #39, i can confirm that this has some impact on this PR. Tried to fix this yesterday but did not work very well.
Thats why i marked this PR as Draft until i find sometime to investigate this

My successfull test was without #39 and
#38 (commits)

Battery consumption went down from 23% on my iphone to 8% (~24hours)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants