Skip to content

feat(dns): 系统 DNS 状态独立探测 + 不一致横幅 (issue #153) - #231

Merged
flyhigher139 merged 2 commits into
masterfrom
codex/issue-153-dns-probe
Sep 28, 2026
Merged

flyhigher139 merged 2 commits into
masterfrom
codex/issue-153-dns-probe

Conversation

@flyhigher139

Copy link
Copy Markdown
Contributor

Closes #153。

背景

#152 的 bug(disable 报告成功、系统 DNS 仍卡在 127.0.0.1)之所以让用户完全无计可施,是因为当时前端只有一个数据源:

// commands/dns.rs::get_dns_mode —— 只返回内存里的 AtomicBool
Ok(state.dns_enabled.load(Ordering::Relaxed))

Rust 说「关了」,而真相在另一个进程里(networksetup),没人去读。这个 PR 补上第二个数据源。

实现

新增 probe_system_dns IPC,直接读 networksetup 给出 OS 侧真相。前端把两份数据源合成一个三态派生 atom,不一致时在 Settings 的 DNS 卡片里显示横幅。

dnsEnabledAtom points_at_loopback UI
true true 一致,无横幅
true false not_pointing 横幅(仅提示)
false true stuck_at_loopback 横幅 + 一键恢复
false false 一致,无横幅

探测接到 3 个时机,不引入定时器:启动(与 truth-fetch 并行)/ 每次 toggle 成功后 / 窗口重新获得焦点(1s 冷却 + in-flight 闸门)。

两个方向的处理不同,这是有意的

  • stuck_at_loopback(危险) — DNS 指向一个没人监听的地址,解析直接失败。给一键恢复。
    之所以成立:set_dns_mode_disable 没有「已禁用就短路」的分支,即使内存态已经是 false,它仍会走完 disable_dns_mode() 还原事务。我在代码注释和 e2e 文档里都标了「不要给它加短路优化」——那看起来像性能优化,实际会静默废掉这条恢复路径。

  • not_pointing(不危险) — DNS 本身还能用,只是 mHost 规则没生效。只提示不给一键修复:修它要重启 enable 流程(两次 sudo + 重建 DnsServer),为省一次手动开关在特权路径上新增代码不划算。

需要 review 重点看的两处

1. 一条故意与既有代码相反的读数路径

platform.rs 里现在有两个几乎逐行相同的 networksetup -getdnsservers 读数,过滤语义故意相反:

loopback 处理 服务于
networksetup_get_dns 过滤掉 capture_dns_state()(「用户原始 DNS 是什么」)
networksetup_get_dns_stdout(新) 保留 probe_system_dns_state()(「OS 现在指向哪」)

原因是 #152 root cause 2:capture 路径必须滤掉 mHost 自己注入的 127.0.0.1,否则会把它当用户原始值持久化,后续每次 restore 都把系统 DNS 写成 127.0.0.1——永久污染。

风险点:两者太像,未来有人「顺手统一」的概率不低。而那个统一不会让任何测试变红,只会让本探测静默失效(points_at_loopback 恒 false,横幅永不出现,且看起来一切正常)。所以我加了一条护栏测试把分界线钉死:

test_probe_read_path_is_not_merged_with_capture_read_path

请确认这条护栏的取舍是否合适(用 source 行为断言而非结构约束)。

2. 自查阶段修掉的两个自引入缺陷

a. 假警报闪现。 disable 成功时 dnsEnabledAtom 先翻 false,而 systemDnsAtom 还留着「DNS 开着时探测到的 points_at_loopback=true」,两者组合立刻推导出 stuck_at_loopback——横幅会闪现一条凭空捏造的「系统 DNS 卡在 127.0.0.1 / 点我恢复」,而且那个按钮语义完全错误(DNS 模式刚才是开着的)。修法是 toggle 一开始就置 null(= 不知道 = 不报警)。

b. Restore 按钮只能点一次。 一度直接调 fire() 而没 release,导致 useWebKitPointerDown 的 firedRef 永久 latch。改为复用主开关的 handleToggleDns。

两个回归测试我都验证过「撤掉修复后会失败」。其中 b 的测试有个坑值得提一句:两次点击必须在同一个挂载内——重新 render 会拿到新的 useWebKitPointerDown(firedRef 是 useRef),latch 被完全掩盖,测试等于什么都没测。

验证

Gate 结果
cargo test --all-features 213 passed
cargo fmt --all -- --check clean
cargo clippy --all-targets --all-features -- -D warnings clean
pnpm test 356 passed(27 files)
pnpm build 通过
pnpm tauri build .app 打包通过;DMG 步骤在本机失败

新增测试:+6 Rust,+20 vitest。

关于 DMG 那一步:bundle_dmg.sh 用 AppleScript 驱动 Finder 设置 DMG 窗口样式,在我的环境里 osascript 连不上 Finder(Connection Invalid error for service com.apple.hiservices-xpcservice)。这是环境限制不是代码问题——release 二进制和 mHost.app 都成功产出,且本 PR 不触碰任何打包相关代码。CI 上应该正常。

未覆盖

Scenario F 没有在实机跑过,尤其 stuck_at_loopback 的 Restore 路径涉及真实 sudo 弹窗,CI 覆盖不到。操作步骤写在 doc/tech/dns-mode-e2e-recipe.md 的 Scenario F(两个方向 + 探测不可用三段),建议合并前手动过一遍。

顺带说明

按 issue 的 out-of-scope 约束,get_dns_mode 语义未改动(仍是「信任内存态」),也没有在探测到分歧时自动恢复——横幅只是告知,决定权在用户。非 macOS 返回 UnsupportedPlatform 错误,前端静默吞掉(IPC 仍然注册,以保持各平台 TS 类型一致)。

闭合 #152 调查暴露的第三个结构性缺口:前端此前只有 Rust 内存态
(`get_dns_mode` → `state.dns_enabled`) 一个数据源,内存态与现实分歧时
用户完全无计可施(#152: disable 报告成功,系统 DNS 仍卡在 127.0.0.1)。

新增 `probe_system_dns` IPC,直接读 networksetup 给出 OS 侧真相。

Rust:
- `mhost_core::SystemDnsSnapshot` + `new()`,用 models.rs 里已有的
  `is_local_resolver` 推导 `points_at_loopback`(any 语义)
- `platform::probe_system_dns_state()` / `probe_snapshot_from()` /
  `networksetup_get_dns_stdout()`;非 macOS 返回 `UnsupportedPlatform`
- `PlatformError::UnsupportedPlatform` 变体
- IPC `probe_system_dns` 不带 AppState(不依赖内存态正是它的价值),
  同步 syscall 包 spawn_blocking(同 #214 的 capture_dns_state)

**关键: 新增一条不过滤 loopback 的读数路径 (`networksetup_get_dns_stdout`),
与 `networksetup_get_dns` 的过滤语义故意相反。** 后者服务于
`capture_dns_state()`(「用户原始 DNS 是什么」),必须滤掉 mHost 自己注入的
127.0.0.1,否则会持久化污染后续还原(#152 root cause 2)。两者几乎逐行
相同,未来极易被「顺手统一」—— 那不会让任何测试变红,只会让本探测静默
失效(points_at_loopback 恒 false)。`test_probe_read_path_is_not_merged_with_capture_read_path`
把这个分界线钉死。

前端:
- `systemDnsAtom` + 派生的 `dnsDiscrepancyAtom`(三态: 一致 /
  not_pointing / stuck_at_loopback)
- `probeSystemDnsAtom` 永不 reject,失败置 null 不写 dnsErrorAtom
  (探测是建议性的,route 失败几乎总是没联网,弹 toast 只是噪音)
- 探测接到 3 个时机:启动(与 truth-fetch 并行)/ toggle 成功后 /
  窗口 focus(1s 冷却 + in-flight 闸门)。不引入定时器。
- Settings DNS 卡片横幅:stuck_at_loopback(#152 那个危险方向)给一键
  Restore —— set_dns_mode_disable 没有「已禁用就短路」分支,即使内存态已
  false 也会真的执行系统 DNS 还原;not_pointing 只提示不提供修复(修它要
  重启 enable 流程 = 两次 sudo + 重建 server,不值得为此在特权路径加代码)

自查阶段修掉两个自引入的缺陷:

1. toggle 开始时同步作废旧探测。disable 成功时 dnsEnabledAtom 先翻 false
   而旧快照仍 points_at_loopback=true,会闪现一条凭空捏造的「系统 DNS 卡在
   127.0.0.1 / 点我恢复」横幅。回归测试
   `drops the stale snapshot at toggle start` 已验证无修复时会失败。

2. Restore 按钮一度直接调 `fire()` 而不 release,导致
   `useWebKitPointerDown` 的 firedRef 永久 latch —— 按钮在 Settings 挂载
   期内只能点一次。改为复用主开关的 `handleToggleDns`(fire+releaseSoon
   齐全)。回归测试 `Restore stays usable after a previous use` 覆盖
   **同一挂载内**的两次点击(重新 render 会拿到新 hook,latch 被掩盖,
   那样测不到任何东西),并等过 `POINTER_DOWN_DEBOUNCE_MS` 以免把正常
   防抖误判成 bug;已验证重新引入缺陷时该测试失败。

另: stuck_at_loopback 标题不写死 "127.0.0.1" —— IPv6-only 环境下正文会显示
"::1",写死的标题会和正文自相矛盾。精确值由正文的 servers 承担。

文档: e2e recipe 加 Scenario F(两个方向 + 探测不可用三段),日志表和
关联文档同步。

测试: +6 Rust (213 total), +20 vitest (356 total)
@flyhigher139

Copy link
Copy Markdown
Contributor Author

Review 结论:可合并(4 个非阻塞跟进项)

已在本地验证:前端 356 tests passed (27 files),Rust cargo test -p mhost-core -p mhost-dns 54 + 155 passed。

核心前提(均在代码里逐一验证过)

  • Restore 路径成立:set_dns_mode(src-tauri/src/commands/dns.rs:38)对 enabled=false 无条件走 set_dns_mode_disable,没有「内存态已 false 就短路」的分支;前端 toggleDnsModeAtom 也没有短路。PR 里「不要给 disable 加短路优化」的警告有真实代码支撑。
  • DHCP 空场景:parse_dns_servers(platform.rs:1885)对 "There aren't any DNS Servers set" 返回空 vec;networksetup_get_dns_stdout 的错误处理与久经考验的 capture 路径一致。
  • 安全边界未动:固定参数数组调 networksetup、接口名继承 get_active_network_interface 的白名单校验([P0] platform.rs osascript 提权命令存在 shell 注入面 #77)、纯只读、不写文件、不弹 sudo。
  • 两个自引入缺陷的回归测试是真测试:尤其「同一挂载内两次点击」那个,对 firedRef latch 机制描述准确,等待 POINTER_DOWN_DEBOUNCE_MS 的注释说明作者理解防抖与 latch 的区别。

非阻塞发现(建议开后续 issue 跟进)

1. [P2] 护栏测试在「组合层」有盲区 —— 回应 PR 描述里请 reviewer 确认的取舍:方向对,但覆盖不全。
test_probe_read_path_is_not_merged_with_capture_read_path 只钉死了纯函数层(probe_snapshot_from + SystemDnsSnapshot::new)。最可能发生的「顺手统一」其实在 IO 组合层:有人把 probe_system_dns_state 改成直接调带过滤的 networksetup_get_dns 再拼 snapshot,绕过 probe_snapshot_from —— 这条改动会让现有所有测试全绿。建议后续把 probe_system_dns_state 重构为接受读取函数参数(read: impl Fn(&str) -> Result<String, PlatformError>),即可注入 fake stdout 做端到端断言,把组合层也钉死。

2. [P3] 慢探测的晚到覆盖竞态。
启动时 fetchDnsModeAtom 的探测 P1 若因 wedged configd 卡几秒,期间用户完成一次 toggle(toggle 的 re-probe P2 已写入新快照),P1 返回后会把 P2 的新鲜结果覆盖成 toggle 前的旧快照,横幅可能短暂指向错误方向。概率低且下次 focus 探测自愈;根治方案是给探测加代数(generation)计数,晚到的旧代结果丢弃。

3. [P3] 错误映射语义不准。
probe_system_dns 把 UnsupportedPlatform 和 route 失败都映射成 MhostError::InvalidInput —— 语义上不是「输入无效」。前端目前全部静默吞掉所以无实际影响,但若将来有人展示 extractErrorMessage 的结果,用户会看到一条被标成「无效输入」的平台限制信息。建议换更贴切的错误变体。

4. [观察] 横幅只在 Settings 页。
stuck_at_loopback 意味着用户 DNS 已经坏了,而此时用户最可能做的是到处点别的页面,未必会打开 Settings。这与 issue #153 的 scope 一致,不算缺陷,但值得记一个后续 issue:比如在托盘 tooltip 或主页面加更轻的警示入口。

未覆盖项确认

  • cargo fmt / clippy -D warnings 未本地复跑(依赖 CI 把守)。
  • Scenario F 实机验证未做 —— PR 描述已如实标注;建议合并前按 doc/tech/dns-mode-e2e-recipe.md 5.6 手动过一遍 stuck_at_loopback + Restore 路径(涉及真实 sudo 弹窗)。

总结

「两个数据源 + 三态判定 + 故意分叉的两条读数路径 + 护栏测试」是解决 #152 这类静默失效 bug 的正确姿势。假警报闪现、按钮 latch 两个自查发现说明作者对时序问题有真实把握。建议合并,上述第 1、4 点开后续 issue 跟进。

Review 结论为「可合并」,第 1、4 点建议开后续 issue。本 PR 处理其中
能在本 PR 收口的三项代码改动,并把第 4 点落到 #232。

## [P2] 组合层护栏盲区

`test_probe_read_path_is_not_merged_with_capture_read_path` 只钉住纯函数层
(`probe_snapshot_from` + `SystemDnsSnapshot::new`)。reviewer 指出最现实的
破坏路径在组合层:有人把 `probe_system_dns_state` 改成直接调**带过滤**的
`networksetup_get_dns` 再拼 snapshot,绕过唯一被测的那一层 —— 而这条改动
会让现有全部测试保持绿色。

按 reviewer 建议把 IO 注入化:拆出 `probe_system_dns_state_with`,两个 IO
步骤都是参数,组合层本身可单测。

但注入式测试**测不到真实 wiring 指向哪个 reader**(注入的 reader 是测试
自己给的)。所以补了第二条 source-grep 护栏
`test_probe_read_path_wires_unfiltered_reader`,用本仓库既有的 source-grep
手法(见 `test_try_recover_dns_reads_canonical_marker_path`)钉死
`probe_system_dns_state` 的函数体引用 `networksetup_get_dns_stdout` 且不含
`networksetup_get_dns(`。匹配带左括号是因为前者是后者的前缀。

已反向验证:注入 reviewer 描述的那条 refactor 后,其余三条测试仍绿,
只有新护栏失败 —— 正是 reviewer 指出的盲区,现在被覆盖住了。

## [P3] 慢探测的晚到覆盖竞态

启动探测 P1 因 wedged configd 卡住,期间 toggle 的 re-probe P2 已写入新
快照,P1 随后返回会用 **toggle 之前**的旧快照覆盖它,横幅短暂指向错误方向。

加代数守卫:`runProbe()` 发递增代数,`applyProbe()` 丢弃晚到的旧代结果。
四写入路径(focus / toggle 成功 / toggle 取消 / 启动)全部收口到
`applyProbe`。

测试用可控 deferred 复现时序,并反向验证:撤掉守卫后两条测试均失败。
其中「晚到的失败」那条单独覆盖 —— 无守卫时 P1 的 catch 分支会把 P2 的
快照擦成 null,横幅无缘无故消失。

toggle 开头的 `set(systemDnsAtom, null)` 刻意**不**走守卫:那不是一次探测
结果,而是「主动宣布现有结论失效」;走守卫的话更早的探测会把它挡回来,
假警报重现。已在代码里写明,避免后来人「顺手统一」。

## [P3] 错误映射语义

`probe_system_dns` 原先把 `UnsupportedPlatform` 和 route 失败一律映射成
`InvalidInput` —— 不是「输入无效」。新增 `MhostError::Unsupported(String)`,
平台限制走它,其余读 OS 失败走 `Io { kind: "system-dns-probe" }`。
`extractErrorMessage` 同步新增渲染分支,否则会 fallback 到
`JSON.stringify` 把原始 JSON 泄给 UI。

当前前端两边都静默吞掉,没有行为差异;但一旦有人把这个 message 显示给
用户,「invalid input: system DNS probe is only supported on macOS」是在
告诉用户他输入错了,而他根本没输入任何东西。

映射抽成 `map_probe_error` 独立函数而非内联闭包,让「为什么不是
InvalidInput」的理由不会随闭包一起被压扁。

## [观察] 横幅只在 Settings 页

review 明确标注不算缺陷,但 `stuck_at_loopback` 意味着用户的 DNS 已经坏了,
而他最可能做的事是回浏览器 / 去终端,不一定想到去 Settings。

已开 #232 记录候选方案(托盘 tooltip / StatusBar 状态点 / 全局横幅 /
系统通知)与取舍,留待讨论。

## 测试

+1 Rust(`test_map_probe_error_classification`,含 Display 不含
"invalid input" 的断言)、+2 Rust(组合层注入 + source-grep 护栏)、
+2 vitest(代数竞态 ×2)、+2 vitest(error 渲染 ×2)。

214 Rust / 360 vitest;fmt、clippy -D warnings、tsc、pnpm build 全绿。
@flyhigher139

Copy link
Copy Markdown
Contributor Author

跟进项已处理(2d0ac07)

4 个非阻塞项里 3 项在本 PR 收口,第 4 项落到 #232。两个 PR 内改动都是新提交,没有 amend / force-push。

[P2] 组合层护栏盲区 — 已按建议改,但只做了一半所以又加了第二条

按你说的把 IO 注入化:拆出 probe_system_dns_state_with,resolve_interface 和 read_dns 都是参数。

不过注入式测试测不到你担心的那条路径——它只能证明「给了 unfiltered stdout 时 snapshot 是对的」,而破坏路径是「真实 wiring 改接了另一个 reader」,注入的 reader 是测试自己给的,两者无关。

所以补了第二条 test_probe_read_path_wires_unfiltered_reader,用本仓库既有的 source-grep 手法(test_try_recover_dns_reads_canonical_marker_path 是同类先例)从 include_str! 里取 probe_system_dns_state 的函数体,断言它引用 networksetup_get_dns_stdout 且不含 networksetup_get_dns(。匹配要带左括号,因为前者是后者的前缀,不带会误伤。

验证方式:我把你描述的那条 refactor 真的注进去跑了一遍——

test_probe_read_path_is_not_merged_with_capture_read_path ... ok
test_probe_system_dns_state_with_composition ... ok
test_probe_snapshot_from_parsing_and_loopback_detection ... ok
test_probe_read_path_wires_unfiltered_reader ... FAILED   ← 只有这条抓到

其余三条全绿,正是你指出的盲区形状;现在被新护栏覆盖住了。

probe_system_dns_state_with 的组合测试另外覆盖了两个非 happy path:接口解析失败时不调用读数函数(短路),读数失败向上传播。

[P3] 晚到覆盖竞态 — 已加代数守卫

runProbe() 发递增代数,applyProbe() 丢弃晚到的旧代。四写入路径(focus / toggle 成功 / toggle 取消 / 启动)全部收口到 applyProbe。

测试用可控 deferred 复现时序(P1 pending → P2 先落地 → P1 返回),两条都反向验证过撤掉守卫后会失败:

  1. 晚到的成功结果 —— 否则会用 toggle 前的旧快照覆盖 P2
  2. 晚到的失败结果 —— 这条我一开始漏了。无守卫时 P1 的 catch 分支会把 P2 的快照擦成 null,横幅无缘无故消失。代数守卫对 value: null 同样生效。

一个我特意没「统一」的地方:toggle 开头的 set(systemDnsAtom, null)(作废旧快照防假警报那处)刻意不走守卫。它不是一次探测结果,而是「主动宣布现有结论失效」;走守卫的话更早的探测会把它挡回来,假警报重现。代码里写明了理由,免得后来人顺手改掉。

[P3] 错误映射语义 — 新增 MhostError::Unsupported

平台限制走新增的 Unsupported(String),其余读 OS 失败走 Io { kind: "system-dns-probe" }。extractErrorMessage 同步加了渲染分支——不加的话它会 fallback 到 JSON.stringify,把 {Unsupported: "..."} 原始 JSON 泄给 UI。

映射抽成独立的 map_probe_error 而不是内联闭包,这样「为什么不是 InvalidInput」的理由不会跟着闭包一起被压扁。Rust 侧断言里有一条是直接检查 Display 输出不含 "invalid input" 字样。

你说的「当前前端全静默吞掉所以无实际影响」我同意——这次改动的价值是给未来的改动留一个不骗人的分类,属于低成本防御。

[观察] 横幅只在 Settings 页 — 记为 #232

stuck_at_loopback 意味着用户 DNS 已经坏了,而他最可能回浏览器或去终端,不一定想到去 Settings。这个判断我完全同意。

#232 里列了四个候选(托盘 tooltip / StatusBar 状态点 / 全局横幅 / 系统通知)和各自取舍,没有排序也没有推荐——因为 StatusBar 那条要动 sidebar 的 atom 订阅,而 #90 perf 报告恰好点过名「sidebar atom 订阅」,这个取舍值得单独讨论,不适合在 follow-up 里替 reviewer 拍板。另外系统通知与 readme.md 的「不打扰」产品原则有张力。

#232 里也记了一条约束:任何新入口都应复用 dnsDiscrepancyAtom,不要各自重新判定,否则判定逻辑就分叉了。

验证

Gate 结果
cargo test --all-features 214 passed(+1)
cargo fmt --all -- --check clean
cargo clippy --all-targets --all-features -- -D warnings clean
pnpm test 360 passed(+4)
pnpm build 通过
CI Frontend ✅ / Rust ✅

新增覆盖:组合层注入 + source-grep 护栏(2 Rust)、错误分类(1 Rust)、代数竞态 ×2 + 错误渲染 ×2(4 vitest)。

仍未覆盖

cargo fmt / clippy 没在本地之外的第二台机器复跑(CI 把守);Scenario F 实机验证仍未做,包括 stuck_at_loopback + Restore 那条真实 sudo 路径。这个我没法在这里补——pnpm tauri build 的 DMG 步骤在我环境里也因为 osascript 连不上 Finder 而失败,是同一个沙箱限制。

按你的结论准备合并。如果你想先手动过一遍 Scenario F 再合,我可以等。

@flyhigher139
flyhigher139 merged commit 80984e4 into master Sep 28, 2026
4 checks passed
@flyhigher139
flyhigher139 deleted the codex/issue-153-dns-probe branch September 28, 2026 12:05
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.

[Enhancement] Add OS-probing IPC so UI can independently verify system DNS state

1 participant