Skip to content

Keep the tunnel state honest: reconcile, retry, detect drops - #14

Merged
grifmo merged 2 commits into
mainfrom
fix/tunnel-state
Oct 10, 2026
Merged

grifmo merged 2 commits into
mainfrom
fix/tunnel-state

Conversation

@grifmo

@grifmo grifmo commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Fixes the "shows protected but isn't" problems from the community review.

What changed

VpnSwitcherService now records which profile the routes ask for and reconciles it with the tunnel that is actually up. App switches, retries, tunnel drops, refreshed configs and the end of a connection test all signal one reconciler. The UI only shows a profile as active once its tunnel is up.

Issue Fix
#1 Mullvad refresh writes an unresolvable endpoint Refresh keeps the existing endpoint; generated configs use <host>.relays.mullvad.net; configs broken by earlier refreshes are repaired on service start; refreshed configs are applied to a running tunnel
#2 Failed start still shows the profile as active Status chip and overlay show ⚠ … not connected; retried after 5/10/20/40/60 s
#3 Tunnel drops not detected onStateChange(DOWN) without our own stop is reported to the switcher. If another VPN app took the slot, UniVPN shows the warning and reconnects on the next app switch instead of fighting it
#4 10-second recovery window Looks back 3 days; the last requested profile also survives a process restart
#5 "Default (keep current tunnel)" disconnects No route now means keep the current tunnel, matching the label and README
#7 Mutex timeout drops app switches Switches queue (latest wins) and run when the lock is free
#10 Test connection doesn't check the handshake Requires bytes from the peer before trusting the external-IP check
#11 Refresh can recreate a deleted profile @Update in a transaction instead of INSERT OR REPLACE

Testing

Unit tests (new, JVM): MullvadEndpointTest, WgConfigTest, RouteDecisionTest — 14 tests.

Instrumented (Android TV emulator, API 30): all 13 pass, including the new ProfileRefreshDaoTest (update keeps metadata, deleted profile stays deleted) and the updated "no route keeps the tunnel" test.

Manual, on the emulator with seeded profiles:

  • Profile with an unresolvable endpoint → DNS_RESOLUTION_FAILURE, chip shows ⚠ in red, retries at 5 s, 10 s, 20 s; switching to a working profile cancels the retries
  • Mullvad profile with a bare al-tia-wg-001 endpoint → repaired on service start, resolves and comes up
  • No-route app (TV Settings) → tunnel kept
  • Second VPN app (a renamed debug build) takes the slot → drop detected, no fight; next app switch takes the slot back once
  • Process crash with an app open for >1 min → restarted service restores its tunnel within 0.25 s
  • App switch during Test Connection → applied as soon as the test finishes; test reports no handshake for an unreachable peer

Not tested: the success path of Test Connection and a real provider refresh (no WireGuard server or provider account in the test setup).

🤖 Generated with Claude Code

The switcher now records which profile the routes ask for and reconciles
it with the tunnel that is actually up. Every trigger (app switch, retry,
tunnel drop, refreshed config, end of a connection test) just signals one
reconciler, and the UI only shows a profile as active once it is up.

- Mullvad refresh kept the relay's bare hostname as Endpoint, which does
  not resolve. Refresh now keeps the existing endpoint, generated configs
  use <host>.relays.mullvad.net, and broken configs are repaired when the
  service starts. Refreshed configs are applied to a running tunnel.
- A failed start no longer marks the profile active. The status chip and
  overlay show "not connected" and the start is retried after 5/10/20/40/60 s.
- A tunnel taken down externally (another VPN app took the slot) is
  detected. UniVPN shows the warning and reconnects on the next app
  switch instead of fighting the other app for the slot.
- Restart and wake-up look back 3 days for the foreground app instead of
  10 s, and the last requested profile survives a process restart.
- "Default (keep current tunnel)" now keeps the current tunnel.
- App switches during a connection test wait for it instead of being
  dropped after 2 s.
- Test connection requires bytes from the peer (a completed handshake)
  before trusting the external-IP check.
- Refresh updates the profile instead of INSERT OR REPLACE, so a profile
  deleted mid-refresh stays deleted and its metadata isn't cascaded away.

Fixes #1, fixes #2, fixes #3, fixes #4, fixes #5, fixes #7, fixes #10, fixes #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grifmo
grifmo merged commit 8a6906c into main Oct 10, 2026
1 check passed
@grifmo
grifmo deleted the fix/tunnel-state branch October 10, 2026 08:48
@grifmo
grifmo restored the fix/tunnel-state branch October 10, 2026 08:49
@grifmo
grifmo deleted the fix/tunnel-state branch October 10, 2026 08:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant