fix(macos): restore system DNS reliably after TUN

Detached tasks spawned on every config generation set and restored the
placeholder DNS without ordering, so a set could back up the placeholder
itself and a later restore reported success while leaving it in place.

Reconcile from the applied config under the lifecycle lock, and restore
whenever the core stops. Keep one backup per network service, never back
up the placeholder, and restore only while the service still holds it.
Scripts verify every read and write and are killed after 5 s.

Refs #8070, #8168
This commit is contained in:
wonfen
2026-10-06 12:47:08 +08:00
parent 30f05a7a39
commit 6ec35c8195
12 changed files with 299 additions and 143 deletions
+4
View File
@@ -12,6 +12,10 @@
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
</details>
<details>
+41 -19
View File
@@ -30,29 +30,51 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
original_dns=$(networksetup -getdnsservers "$hardware_port")
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
is_valid_dns=true
break
backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
if [ -f "$backup" ]; then
rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi
networksetup -setdnsservers "$hardware_port" "$1"
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1
networksetup -setdnsservers "$hardware_port" "$1"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then
echo "empty" >"$backup" || exit 1
fi
+57 -8
View File
@@ -1,13 +1,62 @@
#!/bin/bash
nic=$(route -n get default | grep "interface" | awk '{print $2}')
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
result=0
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
original_dns=$(cat .original_dns.txt)
networksetup -setdnsservers "$hardware_port" $original_dns
rm -rf .original_dns.txt
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
fi
for backup in .original_dns/*; do
[ -f "$backup" ] || continue
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1
continue
fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then
original_dns=$(cat "$backup") || { result=1; continue; }
# Older releases could back up the placeholder itself.
[ "$original_dns" = "$1" ] && original_dns=empty
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
rm -f "$backup" || result=1
done
rmdir .original_dns 2>/dev/null
exit $result
+6
View File
@@ -311,6 +311,12 @@ impl CoreManager {
let run_path = Config::write_runtime_file(&yaml).await?;
self.apply_config(run_path).await?;
transaction.commit();
// Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it.
#[cfg(target_os = "macos")]
{
let _lifecycle = self.lifecycle_lock.lock().await;
crate::utils::resolve::dns::sync_public_dns().await;
}
Ok(ValidationOutcome::Valid)
}
+20 -2
View File
@@ -515,7 +515,13 @@ impl CoreManager {
}
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
let result =
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await;
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
@@ -544,6 +550,8 @@ impl CoreManager {
.ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
})?;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
// At login the app usually beats the network. Leave the write to the network watcher
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
@@ -615,6 +623,8 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
@@ -649,6 +659,8 @@ impl CoreManager {
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
return result;
}
@@ -737,11 +749,17 @@ impl CoreManager {
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
let result = match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()),
};
// A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS.
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
#[tracing::instrument(skip_all, level = "info")]
+2
View File
@@ -394,6 +394,8 @@ impl CoreManager {
self.set_job_handle(None);
proxy_control::stop_guard().await;
self.core_stopped();
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
}
}
+7 -1
View File
@@ -1761,12 +1761,18 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
);
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
proxy_control::stop_guard().await;
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
// Clear while still in Service mode with the session: on macOS it routes through the helper.
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
if policy.reset_system_proxy {
clear_proxy_after_owner_loss().await;
}
clear_active_service_session();
CoreManager::global().core_stopped();
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
#[cfg(target_os = "macos")]
if policy.reset_system_proxy {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
async fn clear_proxy_after_owner_loss() {
-16
View File
@@ -1,8 +1,5 @@
use serde_yaml_ng::{Mapping, Value};
#[cfg(target_os = "macos")]
use crate::process::AsyncHandler;
macro_rules! revise {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
@@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
}
#[cfg(target_os = "macos")]
{
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
});
}
}
revise!(config, "dns", dns_val);
} else {
#[cfg(target_os = "macos")]
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
});
}
revise!(tun_val, "enable", enable);
+13
View File
@@ -53,6 +53,19 @@ pub async fn restart_app() {
if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice_message(
"app_restart::core_stop_failed",
cleanup_result.stop_error.unwrap_or_default(),
+23 -2
View File
@@ -81,16 +81,21 @@ where
async fn restore_dns_after_core_stop() -> bool {
#[cfg(target_os = "macos")]
// Outlasts the script's own deadline, so a hung restore is killed with its process group before exit.
match timeout(
Duration::from_millis(1000),
crate::utils::resolve::dns::DNS_SCRIPT_TIMEOUT + Duration::from_secs(1),
crate::utils::resolve::dns::restore_public_dns(),
)
.await
{
Ok(_) => {
Ok(Ok(())) => {
logging!(debug, Type::Window, "DNS设置已恢复");
true
}
Ok(Err(err)) => {
logging!(warn, Type::Window, "恢复DNS设置失败: {err:#}");
false
}
Err(_) => {
logging!(warn, Type::Window, "恢复DNS设置超时");
false
@@ -128,6 +133,19 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
if should_abort_exit_after_cleanup(cleanup_result.core_stopped) {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice_message(
"app_quit::core_stop_failed",
cleanup_result.stop_error.unwrap_or_default(),
@@ -178,6 +196,9 @@ pub async fn clean_async() -> CleanupResult {
pub async fn clean_session_ending_best_effort() -> CleanupResult {
#[cfg(target_os = "windows")]
let stop_timeout = Duration::from_secs(2);
// TODO(macOS): a DNS script already blocked when the session ends holds the lifecycle lock for up to
// DNS_SCRIPT_TIMEOUT, past this deadline; the stop is cancelled, the DNS restore skipped, and the
// script can still write after the app exits.
#[cfg(not(target_os = "windows"))]
let stop_timeout = Duration::from_secs(3);
+124 -94
View File
@@ -1,123 +1,149 @@
use anyhow::Context as _;
use anyhow::{Context as _, bail, ensure};
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
use serde_yaml_ng::{Mapping, Value};
use std::{
io::Read as _,
os::unix::process::CommandExt as _,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[cfg(target_os = "macos")]
const DNS_STATE_FILE: &str = ".original_dns.txt";
const DNS_BACKUP_DIR: &str = ".original_dns";
// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind.
const PLACEHOLDER_DNS: &str = "114.114.114.114";
// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit.
pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5);
static DNS_LOCK: Mutex<()> = Mutex::const_new(());
fn dns_state_dir() -> anyhow::Result<PathBuf> {
// The DNS scripts persist .original_dns.txt relative to their working directory.
// The DNS scripts persist their backups relative to their working directory.
let dir = crate::utils::dirs::app_home_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
Ok(dir)
}
#[cfg(target_os = "macos")]
fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf {
if resource_dir.join(DNS_STATE_FILE).exists() {
if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() {
resource_dir.to_path_buf()
} else {
state_dir
}
}
pub async fn set_public_dns(dns_server: String) {
use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt as _;
let app_handle = handle::Handle::app_handle();
logging!(debug, Type::Config, "try to set system dns");
let resource_dir = match dirs::app_resources_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get resource directory: {}", e);
return;
}
// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed.
fn needs_public_dns(config: &Mapping) -> bool {
let section = |name: &str| config.get(name).and_then(Value::as_mapping);
let enabled = |name: &str| {
section(name)
.and_then(|map| map.get("enable"))
.and_then(Value::as_bool)
.unwrap_or(false)
};
let script = resource_dir.join("set_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
enabled("tun")
&& enabled("dns")
&& section("dns")
.and_then(|dns| dns.get("enhanced-mode"))
.and_then(Value::as_str)
== Some("fake-ip")
}
/// Sets the placeholder DNS while the running config needs it, and restores the system DNS otherwise.
pub async fn sync_public_dns() {
use crate::{
config::Config,
core::{CoreManager, handle::Handle, manager::RunningMode},
};
let _guard = DNS_LOCK.lock().await;
// Exit cleanup restores DNS on its own budget; a sync queued behind it must not undo that.
if Handle::global().is_exiting() {
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
match app_handle
.shell()
.command("bash")
.args([script, dns_server])
.current_dir(state_dir)
.status()
.await
{
Ok(status) => {
if status.success() {
logging!(info, Type::Config, "set system dns successfully");
} else {
let code = status.code().unwrap_or(-1);
logging!(error, Type::Config, "set system dns failed: {code}");
}
}
Err(err) => {
logging!(error, Type::Config, "set system dns failed: {err}");
}
let core_running = !matches!(*CoreManager::global().get_running_mode(), RunningMode::NotRunning);
let runtime = Config::runtime().await.latest_arc();
if core_running && runtime.config.as_ref().is_some_and(needs_public_dns) {
set_public_dns().await;
} else {
let _ = unset_public_dns().await;
}
}
#[cfg(target_os = "macos")]
pub async fn restore_public_dns() {
use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt as _;
let app_handle = handle::Handle::app_handle();
pub async fn restore_public_dns() -> anyhow::Result<()> {
let _guard = DNS_LOCK.lock().await;
unset_public_dns().await
}
async fn set_public_dns() {
logging!(debug, Type::Config, "try to set system dns");
let result = async {
let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh");
run_dns_script(&script, dns_state_dir()?).await
}
.await;
match result {
Ok(()) => logging!(info, Type::Config, "set system dns successfully"),
Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"),
}
}
async fn unset_public_dns() -> anyhow::Result<()> {
logging!(debug, Type::Config, "try to unset system dns");
let resource_dir = match dirs::app_resources_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get resource directory: {}", e);
return;
}
};
let script = resource_dir.join("unset_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
return;
let result = async {
let resource_dir = crate::utils::dirs::app_resources_dir()?;
let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?);
run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
let state_dir = restore_dns_state_dir(&resource_dir, state_dir);
match app_handle
.shell()
.command("bash")
.args([script])
.current_dir(state_dir)
.status()
.await
{
Ok(status) => {
if status.success() {
logging!(info, Type::Config, "unset system dns successfully");
} else {
let code = status.code().unwrap_or(-1);
logging!(error, Type::Config, "unset system dns failed: {code}");
.await;
match &result {
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
}
result
}
/// On timeout the script's whole process group is killed, so no networksetup it started writes late.
async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> {
ensure!(script.exists(), "DNS script not found: {}", script.display());
let mut child = Command::new("bash")
.arg(script)
.arg(PLACEHOLDER_DNS)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.with_context(|| format!("failed to run {}", script.display()))?;
crate::process::AsyncHandler::spawn_blocking(move || {
let deadline = Instant::now() + DNS_SCRIPT_TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
unsafe { libc::killpg(libc::pid_t::try_from(child.id())?, libc::SIGKILL) };
child.wait()?;
bail!("timed out after {} s", DNS_SCRIPT_TIMEOUT.as_secs());
}
std::thread::sleep(Duration::from_millis(20));
};
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
Err(err) => {
logging!(error, Type::Config, "unset system dns failed: {err}");
}
}
ensure!(
status.success(),
"exit code {}: {}",
status.code().unwrap_or(-1),
stderr.trim()
);
Ok(())
})
.await
.context("DNS script task failed")?
}
#[cfg(test)]
@@ -125,7 +151,6 @@ mod tests {
#[test]
#[allow(clippy::expect_used)]
#[cfg(target_os = "macos")]
fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() {
use super::restore_dns_state_dir;
@@ -137,6 +162,11 @@ mod tests {
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir);
std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir);
// A migrated legacy backup whose restore failed must still be retried there.
std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state");
std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir);
std::fs::remove_dir_all(root).expect("remove test directory");
+2 -1
View File
@@ -21,6 +21,7 @@ use crate::{
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")]
pub mod dns;
mod scheme;
pub(crate) mod window;
@@ -102,7 +103,7 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
#[cfg(target_os = "macos")]
{
use dns::restore_public_dns;
restore_public_dns().await;
restore_public_dns().await?;
}
Ok(())