Skip to content

fix(iOS): Connection-state-flicker#118

Open
evgeniyChepelev wants to merge 1 commit into
netbirdio:mainfrom
evgeniyChepelev:fix/connection-state-flicker
Open

fix(iOS): Connection-state-flicker#118
evgeniyChepelev wants to merge 1 commit into
netbirdio:mainfrom
evgeniyChepelev:fix/connection-state-flicker

Conversation

@evgeniyChepelev
Copy link
Copy Markdown
Collaborator

@evgeniyChepelev evgeniyChepelev commented May 8, 2026

Problem

During VPN connection, the UI briefly flashes a "Disconnected" state and then
jumps directly to "Connected", skipping the "Connecting..." animation entirely.

Root cause

networkExtensionAdapter.start() is an async function that returns as soon as
startVPNTunnel() is called — the tunnel process hasn't actually launched yet.
At this exact moment:

  • extensionState is still .disconnected (the polling cycle runs every 3s
    and hasn't fired yet)
  • showBrowser is false (no login required)

So the fallback guard immediately fires:

if self.extensionState == .disconnected && !self.networkExtensionAdapter.showBrowser {
    self.connectPressed = false   // ← premature reset
    self.updateVPNDisplayState()  // ← shows Disconnected
}

With connectPressed = false, none of the intermediate NEVPNStatus states
(.disconnecting, .connecting) are suppressed, so the UI shows:


Disconnected  Disconnected (flash)  Connected
instead of the expected:


Disconnected  Connecting...  Connected
Fix
Delay the fallback check by 8 seconds — enough time for the tunnel to start
and for at least two polling cycles to complete. If after 8s the state is
still .disconnected with no browser sheet, the tunnel genuinely failed
(e.g. IPC error) and the stuck "Connecting..." is reset so the user can retry.

How to test
Start from a fully disconnected state
Tap Connect
Verify the button shows the "Connecting..." animation (not a flash of "Disconnected")
Verify it transitions smoothly to "Connected"
Disconnect and repeat — the animation should be consistent every time

… connect start() returns immediately after startVPNTunnel() while the tunnel process is still launching. The premature connectPressed=false caused vpnDisplayState to briefly show .disconnected before the tunnel reached .connected, making the UI skip the connecting animation entirely. Delay the fallback reset by 8s to give the tunnel time to start and the polling cycle to update extensionState before we decide the launch failed.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

performConnect() now introduces an 8-second delay after calling networkExtensionAdapter.start() to allow the tunnel process and extension state polling to complete. After the delay, it checks if extensionState is still .disconnected and showBrowser is false to detect a stuck connection state, then resets the UI to permit retry.

Changes

VPN Connection Retry Logic

Layer / File(s) Summary
VPN Connection Retry Timing
NetBird/Source/App/ViewModels/MainViewModel.swift
After adapter.start() returns, an 8-second wait allows the extension state to update; if state remains disconnected and no browser login is shown, connection state resets to unblock retry.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • netbirdio/ios-client#55: Both PRs modify the VPN startup/connect flow to handle stuck connecting/network-unavailable states by adding delays and reset logic.
  • netbirdio/ios-client#85: Both PRs modify MainViewModel's VPN startup/status handling and decision logic based on networkExtensionAdapter state and polling.
  • netbirdio/ios-client#48: Both PRs address incorrect UI/connection state by improving Network Extension connection state detection and handling.

Suggested reviewers

  • mlsmaycon
  • pappz

Poem

🐰 Eight seconds pass, a steady wait,
For VPN tunnels to seal their fate,
When stuck mid-flight, we check and see,
Then reset swift so users can retry with glee! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(iOS): Connection-state-flicker' directly and clearly addresses the main change: fixing a UI flicker issue during VPN connection state transitions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and detailed, providing problem statement, root cause analysis, the fix, and testing instructions.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

246-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fire-and-forget Task leaves UI stuck in "Connecting…" for 8 s when the user explicitly disconnects

performClose() never resets connectPressed, and the Task {} handle is discarded so it cannot be interrupted from outside. When the user presses Disconnect within the 8-second window, connectPressed stays true — every subsequent updateVPNDisplayState() call triggered by the .disconnecting/.disconnected extension state continues to return .connecting, keeping the UI frozen on "Connecting…" until the timer fires ~8 s later.

Additionally, Task.sleep() automatically checks for cancellation and throws a CancellationError; silencing it with try? means that if the Task were ever cancelled, the failure-detection block below the sleep would still execute and mutate state incorrectly.

Recommended fix — store the Task, cancel it in performClose(), and honour cancellation with a do-catch-return:

🐛 Proposed fix
+    /// Cancellable handle for the post-connect failure-detection timer.
+    private var connectTask: Task<Void, Never>?

     func performConnect() {
         self.connectPressed = true
         // ...
+        connectTask?.cancel()          // cancel any previous in-flight timer
-        Task {
+        connectTask = Task {
             self.logger.info("connect: Task started, calling networkExtensionAdapter.start()")
             await self.networkExtensionAdapter.start()
             self.logger.info("connect: networkExtensionAdapter.start() completed")
-            try? await Task.sleep(nanoseconds: 8_000_000_000) // 8 seconds
+            do {
+                try await Task.sleep(nanoseconds: 8_000_000_000) // 8 seconds
+            } catch {
+                return // Cancelled — user disconnected or a new connect was requested
+            }
             if self.extensionState == .disconnected && !self.networkExtensionAdapter.showBrowser {
                 self.connectPressed = false
                 self.updateVPNDisplayState()
             }
         }
     }

     func performClose() {
+        connectTask?.cancel()
+        connectTask = nil
+        self.connectPressed = false    // clear "Connecting…" guard on explicit disconnect
         self.disconnectPressed = true
         // ...
     }

This ensures:

  • Explicit disconnect immediately clears connectPressed, so "Disconnecting…" / "Disconnected" is shown without the 8-second delay.
  • Rapid re-taps on Connect cancel the previous timer before starting a new one.
  • Tunnel startup noise (iOS emitting .disconnecting during startVPNTunnel) is still suppressed because performClose() is not called in that path.
  • Cancellation is honoured — stale state mutation after task cancellation is prevented.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/ViewModels/MainViewModel.swift` around lines 246 - 263,
The fire-and-forget Task started after calling networkExtensionAdapter.start()
should be stored in a cancellable Task property (e.g. startTimeoutTask) so it
can be cancelled from performClose(); replace the discarded Task { ... } with an
assignment to that property, cancel startTimeoutTask inside performClose(), and
change the sleep from try? await Task.sleep(...) to a do { try await
Task.sleep(...) } catch { return } so CancellationError is honoured and the
post-sleep failure-detection block (which mutates connectPressed and calls
updateVPNDisplayState()) does not run after cancellation; keep all checks
against extensionState and networkExtensionAdapter.showBrowser as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@NetBird/Source/App/ViewModels/MainViewModel.swift`:
- Around line 246-263: The fire-and-forget Task started after calling
networkExtensionAdapter.start() should be stored in a cancellable Task property
(e.g. startTimeoutTask) so it can be cancelled from performClose(); replace the
discarded Task { ... } with an assignment to that property, cancel
startTimeoutTask inside performClose(), and change the sleep from try? await
Task.sleep(...) to a do { try await Task.sleep(...) } catch { return } so
CancellationError is honoured and the post-sleep failure-detection block (which
mutates connectPressed and calls updateVPNDisplayState()) does not run after
cancellation; keep all checks against extensionState and
networkExtensionAdapter.showBrowser as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9f9146f-a973-4151-9a32-c301c5880d83

📥 Commits

Reviewing files that changed from the base of the PR and between 3f67c8b and 3c56c8f.

📒 Files selected for processing (1)
  • NetBird/Source/App/ViewModels/MainViewModel.swift

@pappz
Copy link
Copy Markdown
Contributor

pappz commented May 11, 2026

/testflight

@github-actions
Copy link
Copy Markdown

TestFlight builds uploaded 0.2.1 (13) for 3c56c8f — iOS + tvOS

View workflow run

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