feat(dns): 系统 DNS 状态独立探测 + 不一致横幅 (issue #153) - #231
Conversation
闭合 #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)
Review 结论:可合并(4 个非阻塞跟进项)已在本地验证:前端 356 tests passed (27 files),Rust 核心前提(均在代码里逐一验证过)
非阻塞发现(建议开后续 issue 跟进)1. [P2] 护栏测试在「组合层」有盲区 —— 回应 PR 描述里请 reviewer 确认的取舍:方向对,但覆盖不全。 2. [P3] 慢探测的晚到覆盖竞态。 3. [P3] 错误映射语义不准。 4. [观察] 横幅只在 Settings 页。 未覆盖项确认
总结「两个数据源 + 三态判定 + 故意分叉的两条读数路径 + 护栏测试」是解决 #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 全绿。
跟进项已处理(
|
| 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 再合,我可以等。
Closes #153。
背景
#152 的 bug(
disable报告成功、系统 DNS 仍卡在127.0.0.1)之所以让用户完全无计可施,是因为当时前端只有一个数据源:Rust 说「关了」,而真相在另一个进程里(
networksetup),没人去读。这个 PR 补上第二个数据源。实现
新增
probe_system_dnsIPC,直接读networksetup给出 OS 侧真相。前端把两份数据源合成一个三态派生 atom,不一致时在 Settings 的 DNS 卡片里显示横幅。dnsEnabledAtompoints_at_loopbacktruetruetruefalsenot_pointing横幅(仅提示)falsetruestuck_at_loopback横幅 + 一键恢复falsefalse探测接到 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读数,过滤语义故意相反:networksetup_get_dnscapture_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,横幅永不出现,且看起来一切正常)。所以我加了一条护栏测试把分界线钉死:请确认这条护栏的取舍是否合适(用 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 被完全掩盖,测试等于什么都没测。验证
cargo test --all-featurescargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningspnpm testpnpm buildpnpm 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 类型一致)。