From ecb7ac37e7f39f3c7c1bdc78ba3af17a1ba923b5 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 16:34:09 +0100
Subject: [PATCH] fix: parallel DNS resolution and robust domain addition
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DNS resolution was sequential: dig timeouts (~8s) blocked DoH from
running when VPN blocks UDP. Now dig and DoH race in parallel with a
trust hierarchy — dig-based resolvers fire immediately, DoH fires
after a 200ms grace period. First success wins. Resolves in ~2s on
VPN instead of 8+.
Also fixes addDomain: DNS cache, disk cache, and /etc/hosts are now
updated immediately on success (was only done during periodic refresh).
DNS failures trigger a tracked 15s auto-retry with fresh gateway lookup,
deduplication guards, and proper cancellation on domain removal or VPN
disconnect.
Additional hardening:
- failedDomains changed to Set (prevents unbounded growth)
- setAllDomainsEnabled uses single disk write instead of N
- toggleDomain now updates hosts file on enable
- Redundant MainActor.run wrappers removed (already on MainActor)
- isApplyingRoutes properly managed during retries
---
Casks/vpn-bypass.rb | 2 +-
Info.plist | 2 +-
README.md | 2 +-
Sources/RouteManager.swift | 186 ++++++++++++++++++++++++-------------
Sources/SettingsView.swift | 4 +-
docs/CHANGELOG.md | 17 ++++
6 files changed, 141 insertions(+), 72 deletions(-)
diff --git a/Casks/vpn-bypass.rb b/Casks/vpn-bypass.rb
index 59d1665..10bd75d 100644
--- a/Casks/vpn-bypass.rb
+++ b/Casks/vpn-bypass.rb
@@ -3,7 +3,7 @@
# Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb
cask "vpn-bypass" do
- version "1.7.1"
+ version "1.8.0"
sha256 "37b127a55aec0bdb80e824e59e840ce5b529c09086aac7fc24dc4616abb817bd"
url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg"
diff --git a/Info.plist b/Info.plist
index b573d25..6709a02 100644
--- a/Info.plist
+++ b/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.7.1
+ 1.8.0
CFBundleVersion
19
LSMinimumSystemVersion
diff --git a/README.md b/README.md
index 2b26e7a..3c1aff5 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
## Why?
diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift
index fbd801e..603ad6c 100644
--- a/Sources/RouteManager.swift
+++ b/Sources/RouteManager.swift
@@ -599,11 +599,10 @@ final class RouteManager: ObservableObject {
}
}
- // Log disconnection - only if we're confident (interface actually changed)
if !isVPNConnected && wasVPNConnected && vpnInterface != oldInterface {
log(.warning, "VPN disconnected (was: \(oldInterface ?? "unknown"))")
NotificationManager.shared.notifyVPNDisconnected(wasInterface: oldInterface)
- // Clear routes when VPN disconnects
+ cancelAllRetries()
activeRoutes.removeAll()
routeVerificationResults.removeAll()
}
@@ -1003,8 +1002,7 @@ final class RouteManager: ObservableObject {
routesToAdd.append((destination: ip, gateway: gateway, isNetwork: false, source: result.source))
}
} else {
- // DNS failed and no cache - domain truly failed
- failedDomains.append(result.domain)
+ failedDomains.insert(result.domain)
failedCount += 1
}
}
@@ -1101,9 +1099,10 @@ final class RouteManager: ObservableObject {
}
}
+ cancelAllRetries()
activeRoutes.removeAll()
routeVerificationResults.removeAll()
- dnsCache.removeAll() // Clear DNS cache
+ dnsCache.removeAll()
lastUpdate = Date()
if config.manageHostsFile {
@@ -1490,35 +1489,78 @@ final class RouteManager: ObservableObject {
saveConfig()
log(.success, "Added domain: \(cleaned)")
- // Apply route immediately if VPN connected
if isVPNConnected, let gateway = localGateway {
isApplyingRoutes = true
Task {
if let routes = await applyRoutesForDomain(cleaned, gateway: gateway) {
- await MainActor.run {
- activeRoutes.append(contentsOf: routes)
+ activeRoutes.append(contentsOf: routes)
+ if config.manageHostsFile {
+ await updateHostsFile()
}
+ } else {
+ log(.warning, "DNS resolution failed for \(cleaned), retrying in 15s...")
+ scheduleRetry(for: cleaned)
}
- await MainActor.run {
- isApplyingRoutes = false
- }
+ isApplyingRoutes = false
+ }
+ }
+ }
+
+ private func scheduleRetry(for domain: String) {
+ pendingRetryTasks[domain]?.cancel()
+ pendingRetryTasks[domain] = Task { [weak self] in
+ do {
+ try await Task.sleep(nanoseconds: Self.retryDelayNs)
+ } catch {
+ return
+ }
+ guard let self else { return }
+ await self.retryFailedDomain(domain)
+ self.pendingRetryTasks.removeValue(forKey: domain)
+ }
+ }
+
+ private func retryFailedDomain(_ domain: String) async {
+ guard config.domains.contains(where: { $0.domain == domain && $0.enabled }) else { return }
+ guard isVPNConnected, let gateway = localGateway else { return }
+ guard !activeRoutes.contains(where: { $0.source == domain }) else {
+ log(.info, "Skipping retry for \(domain) — routes already exist")
+ return
+ }
+
+ isApplyingRoutes = true
+ defer { isApplyingRoutes = false }
+
+ log(.info, "Retrying DNS for \(domain)...")
+ if let routes = await applyRoutesForDomain(domain, gateway: gateway) {
+ activeRoutes.append(contentsOf: routes)
+ if config.manageHostsFile {
+ await updateHostsFile()
}
+ log(.success, "Retry succeeded for \(domain): \(routes.count) routes added")
+ } else {
+ log(.warning, "Retry failed for \(domain) — will resolve on next DNS refresh")
}
}
+ private func cancelAllRetries() {
+ pendingRetryTasks.values.forEach { $0.cancel() }
+ pendingRetryTasks.removeAll()
+ }
+
func removeDomain(_ domain: DomainEntry) {
+ pendingRetryTasks[domain.domain]?.cancel()
+ pendingRetryTasks.removeValue(forKey: domain.domain)
+
isApplyingRoutes = true
- // Actually remove system routes for this domain
Task {
await removeRoutesForSource(domain.domain)
- await MainActor.run {
- config.domains.removeAll { $0.id == domain.id }
- saveConfig()
- log(.info, "Removed domain: \(domain.domain)")
- isApplyingRoutes = false
- }
+ config.domains.removeAll { $0.id == domain.id }
+ saveConfig()
+ log(.info, "Removed domain: \(domain.domain)")
+ isApplyingRoutes = false
}
}
@@ -1531,24 +1573,20 @@ final class RouteManager: ObservableObject {
let domain = config.domains[index]
log(.info, "\(domain.domain) \(domain.enabled ? "enabled" : "disabled")")
- // Apply or remove routes
if isVPNConnected, let gateway = localGateway {
isApplyingRoutes = true
Task {
if domain.enabled {
- // Domain was just enabled - add its routes
if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) {
- await MainActor.run {
- activeRoutes.append(contentsOf: routes)
+ activeRoutes.append(contentsOf: routes)
+ if config.manageHostsFile {
+ await updateHostsFile()
}
}
} else {
- // Domain was just disabled - remove its routes
await removeRoutesForSource(domain.domain)
}
- await MainActor.run {
- isApplyingRoutes = false
- }
+ isApplyingRoutes = false
}
}
}
@@ -1568,23 +1606,22 @@ final class RouteManager: ObservableObject {
log(.info, enabled ? "Enabled all domains" : "Disabled all domains")
- // Incrementally apply/remove routes for changed domains only
if isVPNConnected, let gateway = localGateway {
Task {
for domain in domainsToChange {
if enabled {
- if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) {
- await MainActor.run {
- activeRoutes.append(contentsOf: routes)
- }
+ if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway, persistCache: false) {
+ activeRoutes.append(contentsOf: routes)
}
} else {
await removeRoutesForSource(domain.domain)
}
}
- await MainActor.run {
- isApplyingRoutes = false
+ saveDNSCache()
+ if enabled && config.manageHostsFile {
+ await updateHostsFile()
}
+ isApplyingRoutes = false
}
} else {
isApplyingRoutes = false
@@ -1923,15 +1960,26 @@ final class RouteManager: ObservableObject {
return nil
}
- private var failedDomains: [String] = [] // Track failed domains for debugging
+ private var failedDomains: Set = []
- private func applyRoutesForDomain(_ domain: String, gateway: String, source: String? = nil) async -> [ActiveRoute]? {
- // Resolve domain IPs
+ private static let retryDelayNs: UInt64 = 15_000_000_000 // 15 seconds
+
+ private var pendingRetryTasks: [String: Task] = [:]
+
+ private func applyRoutesForDomain(_ domain: String, gateway: String, source: String? = nil, persistCache: Bool = true) async -> [ActiveRoute]? {
guard let ips = await resolveIPs(for: domain) else {
- failedDomains.append(domain)
+ failedDomains.insert(domain)
return nil
}
+ if let firstIP = ips.first {
+ dnsCache[domain] = firstIP
+ }
+ dnsDiskCache[domain] = ips
+ if persistCache {
+ saveDNSCache()
+ }
+
var routes: [ActiveRoute] = []
for ip in ips {
@@ -1959,47 +2007,51 @@ final class RouteManager: ObservableObject {
return await Self.resolveIPsParallel(for: domain, userDNS: userDNS, fallbackDNS: fallbackDNS)
}
- /// Nonisolated DNS resolution - runs truly in parallel without MainActor serialization
- /// Includes retry logic and system resolver fallback for robustness
+ /// Nonisolated DNS resolution - races dig and DoH in parallel with trust hierarchy.
+ /// Dig-based resolvers fire immediately (trusted); DoH fires after a 200ms grace period
+ /// so it only wins when VPN blocks UDP DNS. Resolves in ~2s on VPN instead of 8+.
private nonisolated static func resolveIPsParallel(for domain: String, userDNS: String?, fallbackDNS: [String]) async -> [String]? {
- // Try up to 2 attempts with all DNS servers
+ let dohGraceNs: UInt64 = 200_000_000 // 200ms head start for trusted dig resolvers
+ let hardcodedDoH = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
+
for attempt in 1...2 {
- // 1. Try detected non-VPN DNS first (user's original DNS before VPN)
- if let userDNS = userDNS {
- if let ips = await resolveWithDNSParallel(domain, dns: userDNS) {
- return ips
+ let result: [String]? = await withTaskGroup(of: [String]?.self) { group in
+ // Tier 1: dig-based resolvers fire immediately (trusted, local/fast)
+ if let userDNS = userDNS {
+ group.addTask { await resolveWithDNSParallel(domain, dns: userDNS) }
+ }
+ for dns in fallbackDNS {
+ group.addTask { await resolveWithDNSParallel(domain, dns: dns) }
}
+
+ // Tier 2: DoH fires after grace period — only wins when dig is blocked by VPN
+ for doh in hardcodedDoH where !fallbackDNS.contains(doh) {
+ group.addTask {
+ do { try await Task.sleep(nanoseconds: dohGraceNs) } catch { return nil }
+ return await resolveWithDoHParallel(domain, dohURL: doh)
+ }
+ }
+
+ for await result in group {
+ if let ips = result, !ips.isEmpty {
+ group.cancelAll()
+ return ips
+ }
+ }
+ return nil
}
- // 2. Fall back to configured DNS servers
- for dns in fallbackDNS {
- if let ips = await resolveWithDNSParallel(domain, dns: dns) {
- return ips
- }
+ if let result = result {
+ return result
}
- // Wait before retry (only if not last attempt)
if attempt < 2 {
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
- }
- }
-
- // 3. Try DoH (DNS over HTTPS) - bypasses VPN DNS hijacking since it uses HTTPS
- // This is more reliable than getaddrinfo when VPN intercepts DNS
- let dohServers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
- for doh in dohServers {
- if let ips = await resolveWithDoHParallel(domain, dohURL: doh) {
- return ips
+ do { try await Task.sleep(nanoseconds: 500_000_000) } catch { return nil }
}
}
- // 4. Last resort: use system resolver (getaddrinfo) with timeout
- // Note: This uses VPN's DNS when connected, so may not bypass VPN restrictions
- if let ips = await resolveWithSystemResolver(domain, timeout: 3.0) {
- return ips
- }
-
- return nil
+ // System resolver as absolute last resort (uses VPN's DNS, may not bypass)
+ return await resolveWithSystemResolver(domain, timeout: 3.0)
}
/// Resolve using system's getaddrinfo - uses OS-level DNS which may work when dig fails
diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift
index 78a7ffa..e383101 100644
--- a/Sources/SettingsView.swift
+++ b/Sources/SettingsView.swift
@@ -1263,7 +1263,7 @@ struct GeneralTab: View {
HStack {
VStack(alignment: .leading, spacing: 2) {
BrandedAppName(fontSize: 13)
- Text("Version 1.7.1")
+ Text("Version 1.8.0")
.font(.system(size: 11))
.foregroundColor(Color(hex: "6B7280"))
}
@@ -1721,7 +1721,7 @@ struct InfoTab: View {
// App name with branded colors
BrandedAppName(fontSize: 24)
- Text("v1.7.1")
+ Text("v1.8.0")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(Color(hex: "6B7280"))
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 92b520f..769fe2a 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to VPN Bypass will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.8.0] - 2026-02-25
+
+### Added
+- **Parallel DNS Resolution** - Dig and DoH now race simultaneously instead of running sequentially. When VPN blocks UDP DNS, DoH wins in ~2s instead of waiting 8+ seconds for dig timeouts first
+- **Auto-Retry on DNS Failure** - When adding a domain fails DNS resolution, a 15-second auto-retry is scheduled with cancellation support
+- **Immediate Hosts File Update** - Adding or toggling a domain now updates `/etc/hosts` immediately instead of waiting for the periodic refresh
+
+### Fixed
+- **Domain Addition Not Bypassing VPN** - Adding a custom domain while connected to VPN now works instantly: DNS cache, disk cache, and hosts file are all populated immediately on success
+- **Stale Gateway in Retries** - DNS retry now reads the current gateway instead of using a potentially stale captured value
+- **Bulk Enable Disk Thrashing** - "Enable All" no longer writes the DNS cache to disk once per domain; saves once at the end
+
+### Improved
+- **DNS Trust Hierarchy** - Trusted dig-based resolvers get a 200ms head start over DoH, preserving CDN locality when local DNS works while still falling back fast on VPN
+- **Tracked Retry Tasks** - Retry tasks are now tracked and cancelled on domain removal, VPN disconnect, or route cleanup
+- **Consistent State Management** - Removed redundant `MainActor.run` wrappers inside already-MainActor tasks; `isApplyingRoutes` properly set during retries
+
## [1.7.1] - 2026-02-24
### Fixed