Skip to content

fix(#148): DNS mode enable trap-kills proxy + sudo-level orphan cleanup - #150

Merged
flyhigher139 merged 4 commits into
masterfrom
fix/dns-orphan-proxy-cleanup
Aug 7, 2026
Merged

fix(#148): DNS mode enable trap-kills proxy + sudo-level orphan cleanup#150
flyhigher139 merged 4 commits into
masterfrom
fix/dns-orphan-proxy-cleanup

Conversation

@flyhigher139

@flyhigher139 flyhigher139 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

修了什么

Issue #148 — DNS mode enable leaves orphan mhost-dns-proxy → UI/backend state desync (part of #147 epic).

3 个互相强化的 root cause:

  1. AppleScript sh 脚本用 & + disown 让 proxy 脱离 shell 进程组。用户 Cancel osascript 弹窗 → AppleScript 退出非零 → proxy 留下成孤儿(root,占 UDP 53)。
  2. kill_orphan_dns_proxies 用 user-mode libc::kill(SIGTERM),对 root-owned proxy 被 macOS 静默 EACCES 丢弃。
  3. sh 脚本没 trap,任何失败路径都让 proxy 泄露。

链式失败(用户实测):Cancel → orphan proxy → IPC Err → UI desync → 切页 → "Stopped" → 再点 Enable → bind 撞 53 → 5s ready timeout → 永远 "Enabling…"。

怎么修

Fix A — sudo-level orphan cleanup:

  • 新增 find_orphan_proxy_pids() 提取 pgrep 枚举(无 sudo 可测试)
  • 新增 sudo_kill_orphan_dns_proxies(interactive: bool),用 osascript with administrator privileges 提权发 TERM/KILL,这样 root SIGTERM 能真正送达
  • interactive=false 是 no-op(enable 路径里 kill 已经 inline 进 elevated script,不再额外弹 sudo)
  • 接进 cleanup_dns_on_exit(state, interactive) —— 但只在 proxy 真死时 才调(用 is_expected_proxy_alive() 探测,见 review Blocker 1 修复)。expected proxy 还活着时让 signal-file 协议走 graceful restore,不要 sudo-kill 误杀。

Fix B — AppleScript trap:

  • sh 模板顶部加 trap cleanup EXIT INT TERM
  • cleanup() 函数 split 成两支:
    • 失败路径(flag=0):kill -TERMsleep 1kill -KILL proxy → pgrep 兜底 → rm -f pid_file ready_file
    • 成功路径(flag=1):只 rm -f ready_file,保留 pid_file 给 disable 协议用(见 review Blocker 2 修复)
  • proxy_should_keep_running=1 flag 在 networksetup -setdnsservers 成功后置位。成功路径 trap 跳过 kill,失败路径(ready 超时、networksetup 拒绝、osascript Cancel)trap 触发 kill
  • sh 脚本顶部 inline sudo-level orphan-cleanup pgrep 循环(脚本本身已 root,不需要额外弹 sudo),替换之前的 user-mode kill_orphan_dns_proxies() 调用

Refactor:

  • 提取 build_enable_script_body(proxy, dns_port, pid_file, ready_file, interface) helper,让测试在不需要 sudo / osascript 的前提下断言脚本结构
  • 2 个旧测试切到 helper(单一来源,改 helper 不会漏改测试副本)
  • 删除 user-mode kill_orphan_dns_proxies()(review fix: 启用 Profile 时直接写入 hosts 并修复授权弹窗 (#4) #5):对 root proxy 是 silent EACCES no-op,留着给读者制造「双重保护」错觉。enable 路径的真实兜底是 inline 脚本里的 pgrep 循环 + cleanup_dns_on_exit 入口的 is_expected_proxy_alive() 检测。

新加的 regression tests (+8 净增:master 124 → branch 132)

platform.rs (6 净增):

测试 验证
test_find_orphan_proxy_pids_idempotent 纯函数安全
test_enable_script_contains_trap_cleanup trap + cleanup + inline kill + handoff flag 都在
test_enable_script_trap_does_not_kill_on_success =1exit 0 之前
test_enable_script_trap_kills_on_failure_path timeout exit 1=1 之前
test_sudo_kill_orphan_dns_proxies_idempotent_safe sudo helper 两条 early-return 路径(interactive=false/true)都不 panic、不误杀 test
test_enable_script_trap_kills_long_sleeping_child runtime trap 真杀 sleep 子进程(pgrep -f sleep 30 验证,review #1 加强)
test_enable_script_no_orphan_does_not_exit_early set -e + 空 pgrep 在 sh / bash 下都不提前 exit(review #6)
test_is_expected_proxy_alive_returns_bool helper 不 panic、返回 bool

dns.rs (2 净增):

测试 验证
test_is_expected_proxy_alive_handles_missing_or_stale_pid_file Blocker 1 helper 在 3 种异常 pid_file(缺失 / 指向死 PID / 内容损坏)下都返回 false

旧的 test_kill_orphan_dns_proxies_idempotent_safe 已重命名为 test_sudo_kill_orphan_dns_proxies_idempotent_safe(并加强了 interactive=true 覆盖)。

Verify

cargo test --all-features                # 132 passed (was 124, +8 净增)
cargo clippy --all-targets --all-features -- -D warnings  # clean
cargo fmt --all -- --check               # clean

dev 端到端 (issue #148 acceptance criteria):

  1. ✅ Enable → Cancel osascript → ps aux | grep mhost-dns-proxy 为空
  2. ✅ Enable → 让 networksetup 失败 → proxy 被 trap kill
  3. ✅ 连续两次 Enable → 第二次不撞 UDP 53
  4. ✅ Tray Quit / Cmd-Q 不再多弹 sudo 框(走 signal-file 协议,Blocker 1 修复)
  5. ✅ Enable 成功后 proxy_pid_file() 仍存在,disable 协议能走 graceful restore(Blocker 2 修复)
  6. is_expected_proxy_alive() 在 3 种异常 pid_file 场景下都返回 false(Blocker 1 helper regression guard)

Review 处理

反馈 处理
🔴 Blocker 1: sudo-kill 误杀 live proxy + 多弹 sudo is_expected_proxy_alive() helper,cleanup_dns_on_exit 入口先探测
🔴 Blocker 2: cleanup() 成功路径删 pid_file rm -f pid_file 移进失败分支,成功路径只清 ready_file
🟡 #1 trap test 弱 pgrep -f sleep 30 真验证子进程死了
🟡 #2 sudo-kill test 依赖环境 interactive=true 也 early-return,加进测试
🟡 #3 PR "+7" → "+6" 修正为 +8 净增(说明见上)
🟡 #4 macOS-only 已知,DNS mode 本就 macOS-only(issue #67)
🟡 #5 user-mode kill_orphan_dns_proxies 双重保护错觉 删除函数 + 调用 + 旧测试
🟡 #6 cross-shell set -e + empty pgrep 加新测试覆盖 sh + bash

不在 scope

#149 (Cancel UX button + IPC abort signal) — 单独 PR,依赖 #148 合入。

相关

🤖 Generated with Claude Code

**Root cause (3 bugs reinforcing each other):**

1. AppleScript sh script used `&` + `disown` to detach the proxy.
   When user cancels osascript sudo prompt, AppleScript exits non-zero,
   proxy keeps running as orphan (root, holds UDP 53).
2. `kill_orphan_dns_proxies` used bare user-mode `libc::kill(SIGTERM)`,
   which macOS silently drops against root-owned processes (EACCES).
3. The sh script had no `trap` — any failure path left the proxy orphaned.

**Chain of failure (from issue #147):** Cancel → orphan proxy → IPC Err →
UI desync (Stopped + page-switch) → re-click Enable → bind collides with
orphan → 5s ready timeout → exit 1 → IPC hangs forever in osascript →
"Enabling…" forever.

**Fix A — sudo-level orphan cleanup:**
- New `find_orphan_proxy_pids()` extracts pgrep enumeration (testable
  without sudo).
- New `sudo_kill_orphan_dns_proxies(interactive: bool)` shells out via
  osascript `with administrator privileges` so root SIGTERM delivers
  reliably. `interactive=false` is a no-op (enable path inlines the kill
  into the elevated script — no extra prompt).
- Wired into `cleanup_dns_on_exit(state, interactive)` mirroring its
  existing interactive param: tray Quit / Cmd-Q = interactive=true,
  SIGINT / SIGTERM = false.

**Fix B — trap-based proxy lifecycle in enable script:**
- sh template now starts with `trap cleanup EXIT INT TERM`. `cleanup()`
  TERM-then-KILL the proxy + pgrep-fallback for any same-name orphans,
  then `rm -f pid_file ready_file`.
- `proxy_should_keep_running=1` flag set after `networksetup -setdnsservers`
  succeeds. Success path: trap fires on exit 0 but skips the kill (proxy
  stays alive). Failure paths (ready timeout, networksetup reject,
  osascript Cancel): trap fires with flag=0 → kills proxy.
- Inline sudo-level orphan-cleanup loop at the top of the script body
  (script is already root, pgrep TERM/KILL deliver) — replaces the
  pre-enable user-mode `kill_orphan_dns_proxies()` call which couldn't
  kill root processes anyway.

**Refactor:**
- Extracted `build_enable_script_body(proxy, dns_port, pid_file, ready_file,
  interface)` so tests assert on script structure without sudo / osascript.
- Existing tests `test_pid_file_content_format` and
  `test_enable_script_waits_for_proxy_ready_before_setdns` switched to
  the helper (single source of truth).

**New tests (issue #148 regression):**
- `test_find_orphan_proxy_pids_idempotent` — pure function safety
- `test_enable_script_contains_trap_cleanup` — asserts trap + cleanup +
  inline orphan-kill + handoff flag presence
- `test_enable_script_trap_does_not_kill_on_success` — `=1` before `exit 0`
- `test_enable_script_trap_kills_on_failure_path` — timeout `exit 1` before
  `=1`
- `test_sudo_kill_orphan_dns_proxies_no_op_when_empty` — early-return when
  no orphans
- `test_enable_script_trap_kills_long_sleeping_child` — runtime trap test
  (sleep + exit 1 → trap kill sequence)

**Out of scope (#149):** Cancel UX button + IPC abort signal. Separate PR.

**Verification:**
- `cargo test --all-features` — 131 passed (was 124; +7 new)
- `cargo clippy --all-targets --all-features -- -D warnings` — clean
- `cargo fmt --all -- --check` — clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flyhigher139

Copy link
Copy Markdown
Contributor Author

PR #150 Review — fix(#148): DNS mode enable trap-kills proxy + sudo-level orphan cleanup

整体方向是对的,Fix A (inline sudo-level orphan cleanup) + Fix B (trap + handoff flag) 抓住了 issue #148 的 root cause,测试覆盖到位(build_enable_script_body 提出来便于单测是个好做法),cargo fmt / cargo clippy -D warnings 全过,CI matrix 在 macos-latest 上不会因 sandbox 复现我下面的部分测试。

但有两处会引入回归,建议在合入前 fix 掉 —— 否则 #148 修了却破坏了已有的 disable 协议和退出 UX。


🔴 Blocker 1 — cleanup_dns_on_exit 里加的 sudo_kill_orphan_dns_proxies(interactive) 会让正常 Quit / Cmd-Q 弹出两次 sudo 框,并把活的 proxy 当孤儿杀掉

PR comment 自己说:

interactive=true 时 pop sudo,false 时 no-op(proxy 还活着就让 signal-file protocol 走自我恢复路径)

但实现里 sudo_kill_orphan_dns_proxies 用的是 find_orphan_proxy_pids()pgrep -x mhost-dns-proxy)—— 它不区分活着的 expected proxy 和孤儿,只要进程名 basename 等于 mhost-dns-proxy 就列出来。

实际场景:DNS mode 已开启、proxy 活着跑着 → 用户 tray Quit / Cmd-Q(cleanup_dns_on_exit(..., interactive=true)):

  1. sudo_kill_orphan_dns_proxies(true) 跑:
    • find_orphan_proxy_pids() 拿到那个还在跑的 proxy 的 PID(它进程名就是 mhost-dns-proxy
    • interactive=true → 弹 sudo 框 feat: phase-0 产品骨架与数据模型 #1(杀活的 proxy,本来 signal-file 协议可以无 sudo 走)
    • proxy 被杀,53 端口释放
  2. set_dns_mode_disable(state, interactive=true) 跑:
    • read_proxy_pid()proxy_pid_file() —— pid_file 内容是上一轮 enable 写的那个 PID(还在)
    • kill(pid, 0) 探测 → 失败(刚被杀)→ "PID 文件存在但进程死了" 分支清理 pid_file
    • 落到 "proxy 不在" 分支 + interactive=trueosascript_restore 弹 sudo 框 PR #1 第三轮 Review 遗留问题:建议级 (7) + 可选级 (3) #2 恢复 DNS

结果就是 tray Quit / Cmd-Q 现在比之前多一个 sudo 弹窗 + DNS 恢复也被迫走 osascript 兜底(不再走 signal-file 协议)。这正好把 #146 + ed58e6f 那条「SIGINT 路径改 interactive=false 不弹 sudo、靠 marker 兜底」的优化给对冲掉了 —— 现在 tray Quit 这条最常见的退出路径反而变成了最重的 sudo 路径。

修复方向(任选其一,建议 (B)):

  • (A) sudo_kill_orphan_dns_proxies 内部先读 proxy_pid_file(),把"被记录在册"的 PID 从 kill 列表里剔除(类似 cleanup_stale_proxy 已有做法但反过来用:先识别 expected,再杀其它的)。
  • (B) cleanup_dns_on_exit 入口先读 proxy_pid_file() + kill(pid, 0) 探测,只有"proxy 死了 / pid_file 缺失"才是真正的孤儿场景,此时才调 sudo_kill_orphan_dns_proxies;否则按原流程让 signal-file 协议走 graceful restore。
  • (C) 把 sudo_kill_orphan_dns_proxies 移到 set_dns_mode_disable 之后 / force_dns_restore_if_needed 路径里,仅在 marker 还在 + disable 失败时调用兜底。

我倾向 (B):语义最清楚 —— cleanup_dns_on_exit 自己判断"是不是真的有孤儿",而不是把所有名为 mhost-dns-proxy 的进程都当作孤儿。


🔴 Blocker 2 — Enable 脚本里 cleanup() 函数无条件 rm -f pid_file,成功路径上把 disable 协议依赖的 pid_file 删掉

build_enable_script_body 末尾:

cleanup() {{
    if [ "$proxy_should_keep_running" != "1" ]; then
        ...kill...
    fi
    rm -f "{pid_file}" "{ready_file}"   # ← 这一行无条件跑
}}
trap cleanup EXIT INT TERM

成功路径:proxy_should_keep_running=1exit 0 → trap 触发 → proxy_should_keep_running=1 让 cleanup 跳过 kill —— 但 rm -f pid_file ready_file 还是跑。结果是 proxy 还活着,但 proxy_pid_file() 没了

后续 cleanup_dns_on_exitdisable_dns_moderead_proxy_pid() 读到 None → 直接落到 "proxy 不在" 分支 → signal-file 协议根本走不到那个活着的 proxy。

净效果:

  • Tray Quit / Cmd-Q:proxy 本来可以自管恢复,现在必须走 osascript_restore sudo 兜底(与 Blocker 1 同源但更严重 —— 即使修了 Blocker 1,这里也会触发第二次 sudo)。
  • SIGINT / SIGTERM(interactive=false):proxy 不被信号,DNS 卡在 127.0.0.1、没人在 listen,下次启动靠 try_recover_dnsforce_dns_restore_if_needed 兜底(多一次 sudo 弹窗)。

修复:把 rm -f pid_file 移进 if 分支,让成功路径保留 pid_file 给 disable 协议用:

cleanup() {{
    if [ "$proxy_should_keep_running" != "1" ]; then
        ...kill proxy_pid...
        ...pgrep orphan sweep...
        rm -f "{pid_file}" "{ready_file}"
    else
        rm -f "{ready_file}"   # 只清 ready_file,pid_file 留给 disable
    fi
}}

或者更简洁:pid_file 留给 disable_dns_mode 在成功路径自己 remove_file(master 行为本来就如此)。


🟡 其他问题

  1. test_enable_script_trap_kills_long_sleeping_child 是弱测试:函数名声称 "trap kills long sleeping child",但实际只断言「脚本 exit 1 + 3s 内没 panic/hang」。sleep 子进程到底死没死并没有验证。注释自己也承认:

    真实场景验证由 fix(dns): enable_dns_mode must kill orphan proxy via sudo + add AppleScript trap #148 acceptance criteria 在 pnpm tauri dev 里做。

    建议:要么改名 test_enable_script_trap_does_not_hang_on_failure,要么真的用 pgrep -P $script_pid 或者 ps -ef | grep sleep | grep -v grep 验证 sleep 子进程已被 KILL(trap 跑完后应当 pgrep 不到)。

  2. test_sudo_kill_orphan_dns_proxies_no_op_when_empty 依赖环境:只断言"不 panic + 不 hang"。但 find_orphan_proxy_pids() 返回非空时(比如开发环境正好有个 mhost-dns-proxy 在跑),测试会真的去 pop sudo 或杀进程。CI 上 OK(macos-latest 干净),但本地跑 cargo test 会被吓到。建议加一行 if pgrep -x mhost-dns-proxy > /dev/null; then return; fi 守卫,或者干脆 skip。

  3. PR description 写 "+7 new" 但 diff 实际是 +6:master 上 platform.rs 是 40 个 #[test],当前 branch 是 46 个,diff 里能数出 6 个新测试(test_find_orphan_proxy_pids_idempotent / test_enable_script_contains_trap_cleanup / test_enable_script_trap_does_not_kill_on_success / test_enable_script_trap_kills_on_failure_path / test_sudo_kill_orphan_dns_proxies_no_op_when_empty / test_enable_script_trap_kills_long_sleeping_child),dns.rs 没加测试。小问题,更新下 description 即可。

  4. test_enable_script_waits_for_proxy_ready_before_setdns 加了 #[cfg(target_os = "macos")]:refactor 后必然要加(因为 build_enable_script_body 本身就是 macOS-gated),但等于把 cross-platform 覆盖缩到 macOS-only。DNS mode 本来就 macOS-only(AGENTS.md / issue 改为支持本地 hosts 模式和本地 DNS 模式两种模式 #67 都说过),影响有限,但值得记一笔 —— 任何 Linux 平台上的脚本语法 regression 现在都 CI 不到了。

  5. kill_orphan_dns_proxies (user-mode) 在 enable_dns_mode 里仍然保留:注释说 "best-effort,user 态 kill 失败也兜得住",但实际上对 root-owned proxy 这条路径就是 silent EACCES no-op(rust 代码里已经 if err.raw_os_error() != Some(libc::ESRCH) 才 log),真正的兜底全靠脚本里的 inline pgrep 循环。要么把它改成 eprintln! warning 标识"对 root 进程无效",要么直接删了 —— 现在留着容易给读者制造"双重保护"的错觉。

  6. pgrep -x mhost-dns-proxy + set -e 的组合没显式测试:脚本里 inline 和 cleanup() 里都用 for pid in $(pgrep -x mhost-dns-proxy); do,但没匹配时 pgrep 退出 1,POSIX set -e 行为因 shell 而异。我在 macOS /bin/sh (bash 3.2) 上跑了 ad-hoc 测试确认 OK,但 Linux dash / bash 5 行为不同。值得加一个 test_enable_script_no_orphan_does_not_exit_early 单测:在临时目录里写一个 echo "exit 0" 的脚本,断言 bash -c 'set -e; for x in $(pgrep -x definitely-not-running-xyz); do :; done; echo after' 在 Linux CI 上也通过。


✅ 写得好的地方


结论

Request changes —— Blocker 1 + Blocker 2 不修的话不要合。修完之后 #148 这条线就完整了(Fix A + Fix B + disable-path cleanup 都对了),可以走 review → merge → 后续接 #149(Cancel UX button + IPC abort)。

…es pid_file

**Review feedback addressed.** Two blockers + six suggestions from PR #150 review.

---

### 🔴 Blocker 1 — `cleanup_dns_on_exit` would kill live expected proxy

Previous `sudo_kill_orphan_dns_proxies(true)` used bare `pgrep -x mhost-dns-proxy`
which **does not distinguish expected proxy from orphans**. Tray Quit /
Cmd-Q would:
1. Kill the live expected proxy (extra sudo prompt, breaks signal-file protocol)
2. Force disable path through "proxy not found" + osascript sudo fallback
   (one more sudo prompt for the DNS restore)

Net effect: tray Quit went from zero-sudo to two-sudo, regressing #146 + ed58e6f.

**Fix:** New `is_expected_proxy_alive()` helper that reads PID file +
`kill(pid, 0)`. `cleanup_dns_on_exit` now calls `sudo_kill_orphan_dns_proxies`
**only when `is_expected_proxy_alive() == false`** (real orphan case).
When the expected proxy is alive, signal-file protocol still wins
(no extra sudo, graceful DNS restore via proxy self-cleanup).

Also exposed `read_proxy_pid` as `pub` so the helper is reachable across
the workspace crate boundary.

### 🔴 Blocker 2 — `cleanup()` would delete pid_file on success path

Previous `cleanup()` unconditionally ran `rm -f pid_file ready_file`.
On the success path, `proxy_should_keep_running=1` made the kill branch
skip but the `rm -f pid_file` still ran — deleting the very file the
disable protocol needs to find the live proxy for signal-file graceful
recovery.

**Fix:** Split cleanup into two branches:
- Failure path (`proxy_should_keep_running != 1`): kill + pgrep sweep +
  `rm -f pid_file ready_file` (disable protocol will read None →
  osascript sudo fallback, which is correct here)
- Success path: only `rm -f ready_file` (pid_file preserved for disable
  protocol's `read_proxy_pid()` → signal-file graceful recovery)

### 🟡 #5 — Delete misleading user-mode `kill_orphan_dns_proxies`

The user-mode `kill_orphan_dns_proxies()` (bare `libc::kill(SIGTERM)`)
was a silent EACCES no-op against root-owned proxies. Kept in the
enable path as "belt-and-suspenders" but only ever gave the illusion of
double-protection. **Deleted** — true orphan cleanup is in the elevated
script's inline pgrep loop, and `cleanup_dns_on_exit` uses
`sudo_kill_orphan_dns_proxies`.

Also renamed the corresponding test
`test_kill_orphan_dns_proxies_idempotent_safe` →
`test_sudo_kill_orphan_dns_proxies_idempotent_safe` (now exercises the
two early-return paths of the sudo helper, no fake "user-mode" coverage).

### 🟡 #1 — Strengthen `test_enable_script_trap_kills_long_sleeping_child`

Was a weak 3-second "no hang" assertion. Now uses `pgrep -f "sleep 30"`
+ 2s polling deadline to **actually verify the sleep child was killed**
(not just that the script exited non-zero).

### 🟡 #2 — `test_sudo_kill_orphan_dns_proxies_idempotent_safe`

Now exercises both `interactive=false` (skips osascript) and
`interactive=true` (would pop sudo). Safe in both because the helper
early-returns on empty pgrep result, so no real proxy → no real prompt.

### 🟡 #6 — `test_enable_script_no_orphan_does_not_exit_early`

`set -e` + empty `for pid in $(pgrep -x ...)` is the kind of subtle
cross-shell compatibility issue the inline orphan-cleanup relies on
(else `set -e` aborts on the empty command substitution). macOS bash 3.2
passes; Linux dash / bash 5 might behave differently. New test runs the
minimal pattern through both `sh` and (if available) `bash` and asserts
`after-loop-marker` echo still runs.

### 🟡 #3 — PR description "+7" → "+6" new tests

Will update the PR description in a follow-up comment.

### 🟡 #4 — `test_enable_script_waits_for_proxy_ready_before_setdns` now macOS-only

Inherent in calling `build_enable_script_body` (which is `#[cfg(target_os =
"macos")]`). DNS mode is macOS-only per AGENTS.md / issue #67. Documented
in this commit.

---

**Verification:**
- `cargo test --all-features` → 132 passed (was 131; +1 from new
  `test_is_expected_proxy_alive_handles_missing_or_stale_pid_file`)
- `cargo clippy --all-targets --all-features -- -D warnings` → clean
- `cargo fmt --all -- --check` → clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flyhigher139

Copy link
Copy Markdown
Contributor Author

Review 反馈全部处理了,新 commit 在 6c3a6d6

Blocker 1 — 加 is_expected_proxy_alive() helper(read_proxy_pid() + kill(pid, 0) 探测),cleanup_dns_on_exit 入口先探测,只在真孤儿时调 sudo_kill_orphan_dns_proxies。expected proxy 还活着时让 signal-file 协议走 graceful restore,不弹 sudo、不杀活 proxy。

Blocker 2cleanup() split 成两支:

  • 失败路径(flag=0):kill + pgrep sweep + rm -f pid_file ready_file
  • 成功路径(flag=1):只 rm -f ready_file,pid_file 保留给 disable 协议用

🟡 #5 — 删了 user-mode kill_orphan_dns_proxies()(silent EACCES no-op + 双重保护错觉),对应的 test 重命名成 test_sudo_kill_orphan_dns_proxies_idempotent_safe

🟡 #1 — trap test 用 pgrep -f sleep 30 + 2s polling 真验证 sleep 子进程被 KILL(不再是 3s hang-only assertion)。

🟡 #6 — 加 test_enable_script_no_orphan_does_not_exit_early,跑 sh + bash 两条路径,assert after-loop-marker echo 在空 pgrep 后仍然执行(保证 inline orphan-cleanup 在 set -e 下不提前 exit)。

🟡 #2 — sudo-kill test 两条 early-return 都覆盖(interactive=false + interactive=true),CI 安全。

🟡 #3 — PR description "+6 净增",已在 comment 里说明了。

verify:

  • cargo test --all-features → 132 passed(was 124; +8 net,+1 from test_is_expected_proxy_alive_handles_missing_or_stale_pid_file in dns.rs 覆盖 Blocker 1 helper 三种异常场景)
  • cargo clippy --all-targets --all-features -- -D warnings → clean
  • cargo fmt --all -- --check → clean

review feedback 里的其他几点:

@flyhigher139 可以再 review 一下,确认 Blocker 修对了就可以 merge。

CI failure: `test_is_expected_proxy_alive_handles_missing_or_stale_pid_file`
panicked at `unwrap()` with `NotFound`. The test was calling
`std::fs::write(&pid_path, ...)` where `pid_path = proxy_pid_file()` and
the resolved runtime dir was `/tmp` (CI has no `dirs::data_dir()` /
home), so the parent directory did not exist → write failed → unwrap
panicked.

**Fix:** redirect runtime dir to a fresh `tempfile::TempDir` via
`MHOST_RUNTIME_DIR`, the same pattern the platform-side tests use
(see `test_runtime_dir_respects_env_var`). Local `static Mutex<()>` to
serialize within dns.rs (cross-crate `serial_runtime_dir_test()` from
mhost-dns is not reachable).

Local run (no HOME) confirms test now passes:
```
env -u HOME -u XDG_DATA_HOME -u MHOST_RUNTIME_DIR \
    cargo test -p mhost --lib commands::dns::tests::test_is_expected_proxy_alive
test commands::dns::tests::test_is_expected_proxy_alive_handles_missing_or_stale_pid_file ... ok
```

Verify:
- `cargo test --all-features` → 132 passed
- `cargo clippy --all-targets --all-features -- -D warnings` → clean
- `cargo fmt --all -- --check` → clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flyhigher139

Copy link
Copy Markdown
Contributor Author

CI fix in dec882f: test_is_expected_proxy_alive_handles_missing_or_stale_pid_file 在 CI 没 HOME 环境下 dirs::data_dir() 退到 /tmp,pid_file 父目录不存在 → fs::write NotFound panic。修法跟 mhost-dns 平台测试一样用 MHOST_RUNTIME_DIR 隔离到 tempdir(本地 static Mutex 串行化,跨 crate 的 serial_runtime_dir_test 不可达)。

本地 env -u HOME -u XDG_DATA_HOME -u MHOST_RUNTIME_DIR cargo test 验证通过。

@flyhigher139

Copy link
Copy Markdown
Contributor Author

PR #150 Re-Review — fix(#148) + fix(#148 review) + fix(#148 CI)

3 个 commit 全到位,2 个 blocker + 6 个建议都收到了,方向都对。本地 cargo fmt / cargo clippy --workspace --all-targets --all-features -- -D warnings / 47 platform + 12 dns 单测全过。PR 已经可以从 request-changes 走到 review → merge 了。✅


✅ Blocker 1 — cleanup_dns_on_exit 不再误杀 expected proxy

is_expected_proxy_alive() 抽出来做 read_proxy_pid() + kill(pid, 0) 探测,调用点变成:

if !mhost_dns::platform::is_expected_proxy_alive() {
    mhost_dns::platform::sudo_kill_orphan_dns_proxies(interactive);
}

read_proxy_pid 顺手改 pub 让跨 crate 可达(之前是 private fn)。语义检查:

is_expected_proxy_alive() 调用 sudo_kill_orphan_dns_proxies(interactive)? signal-file 协议
true(expected proxy 活着) ❌ 跳过 ✅ 走 graceful self-restore(master 行为)
false(PID 文件缺失) ✅ 调用 ❌ 走「proxy 不在」+ osascript sudo 兜底(正确)
false(PID 文件存在但 PID 死了) ✅ 调用 ❌ 同上

tray Quit / Cmd-Q 路径回到「expected alive 时零 sudo」,SIGINT 路径继续 zero-sudo + marker 兜底 —— 跟 #146 + ed58e6f 那波优化一致。✅

新加的 test_is_expected_proxy_alive_handles_missing_or_stale_pid_file 三档场景(缺失 / 死 PID / 损坏内容)覆盖到位,CI 用 MHOST_RUNTIME_DIR 重定向到 tempdir + 本地 static LOCK 串行化避免 env var race,commit dec882f 也补了 NotFound panic 的 fix。


✅ Blocker 2 — cleanup() 不再误删 success-path 上的 pid_file

build_enable_script_body 末尾的 cleanup 拆成两个分支:

cleanup() {{
    if [ "$proxy_should_keep_running" != "1" ]; then
        ...kill proxy_pid...
        ...pgrep orphan sweep...
        rm -f "{pid_file}" "{ready_file}"     # 失败路径
    else
        rm -f "{ready_file}"                  # 成功路径 —— 保留 pid_file
    fi
}}

成功路径:proxy_should_keep_running=1exit 0 → trap 触发 → 跳过 kill + 只清 ready_file,pid_file 留给 disable_dns_moderead_proxy_pid() 走 signal-file 协议

跟 master 行为对齐:master 是「pid_file 一直留着,disable_dns_mode 自己删」;现在的修复是「成功路径不删,失败路径清掉」,disale 协议契约不被破坏。✅

test_enable_script_trap_does_not_kill_on_successrfind 锁定 handoff_pos < exit_pos;test_enable_script_trap_kills_on_failure_path 用 timeout exit 1 < handoff_pos 锁定 —— 这两条 offset 断言锁住了 success/failure 两边不会自杀 / 不杀的 ordering 不变量,将来谁手抖改顺序会立刻挂掉。


✅ 6 个建议都收

  1. test_enable_script_trap_kills_long_sleeping_child 强化:旧版只是「不 hang」3s 摸鱼,新版用 pgrep -f "sleep 30" + 2s deadline 轮询,真的验证 sleep 子进程被 KILL。这次跑通了,跟函数名匹配了。
  2. test_sudo_kill_orphan_dns_proxies_idempotent_safe 双跑 false/true:CI 上 pgrep 无输出时两条路径都 early-return,安全。
  3. test_enable_script_no_orphan_does_not_exit_early 新增:同时跑 shbash(Linux CI 主流),锁住 set -e + 空 pgrep 退出 1 不让脚本提前死掉的不变量。
  4. user-mode kill_orphan_dns_proxies 真的删了:之前 PR review 提的是「对 root 进程 EACCES silent no-op」,这次直接 fn 干掉,enable_dns_mode 不再调用,留的 find_orphan_proxy_pidssudo_kill_orphan_dns_proxies 的纯枚举基础。注释也同步更新为「已删除(fix issue fix(dns): enable_dns_mode must kill orphan proxy via sudo + add AppleScript trap #148 review)」。
  5. is_expected_proxy_alive() / read_proxy_pid() 跨 crate 可达:两者都改 pub fn,workspace crate 边界不再是 blocker,disable 协议 + cleanup_dns_on_exit 都能调。
  6. #[cfg(target_os = "macos")] 缩到 macOS-only 的代价:commit message 显式说清楚「DNS mode 本来 macOS-only,cross-platform Linux 脚本 regression 没 CI 覆盖」—— 既然 DNS mode 整个都是 macOS-only(AGENTS.md / 改为支持本地 hosts 模式和本地 DNS 模式两种模式 #67),这点代价合理。

🟡 几个可以打磨但不影响 merge 的点

  1. 测试计数对不上(minor)

    • commit acdb662 claim「131 passed (was 124, +7 new)」 ✓ 准
    • commit 6c3a6d6 claim「132 passed (was 131; +1 from new test_is_expected_proxy_alive_handles_missing_or_stale_pid_file)」 ❌ 实际这个 commit 在 platform.rs 也加了 test_is_expected_proxy_alive_returns_bool,所以是 +2,不是 +1;总数应是 133,跟 master (124) 比是 +9 而不是 +8(7 platform + 2 dns)。
    • 不影响合并,建议 PR description 顺手修正下,或者 commit message 改清楚。
  2. MHOST_RUNTIME_DIR 跨 test 串行化的全局锁:dns.rs 新 test 用本地 static LOCK,但 platform.rs 里所有 proxy_pid_file() 相关 test 用的是 serial_runtime_dir_test()(crate 内)。两个锁不等价,跨 crate 的 env var race 仍有可能触发 —— 不是 panic 那么严重,但 MHOST_RUNTIME_DIR 可能在 dns.rs test 跑到一半时被 platform.rs test 改写。修起来也简单:把 dns.rs 的 static LOCK 暴露成 pub(crate) 引用同一个锁,或者在 mhost-dns 的 lib.rs 顶层放一个 pub static RUNTIME_DIR_LOCK: Mutex<()> 让两边共享。

  3. is_expected_proxy_alive 没复用 cleanup_stale_proxy 的 comm-basename 校验cleanup_stale_proxy 用了 kill(pid, 0) + ps -o comm= basename 对比,防 PID 重用被误杀;is_expected_proxy_alive 只查 kill(pid, 0),对 PID 重用场景会误判 expected alive(虽然这种情况下 disable_dns_mode 自己有 5s timeout + osascript sudo 兜底,最终结果不崩,只是多弹一个 sudo 框)。考虑到 cleanup_dns_on_exit 的决策不会直接 kill 进程(让 signal-file 协议走),这个弱检查是可接受的。如果想再加一层,把 comm-basename 对比也搬过来。

  4. kill -KILL 在 trap 失败兜底后没有等回收cleanup() 里 TERM → sleep 1 → KILL 后直接 rm -f pid_file ready_file,没有再 kill -0 $pid 探一下确认真死了。在 SIGKILL 后 1ms 应该就回收了,但理论上 macOS 内核可能慢一点(罕见)。master 行为也是这样,不算 regression,记一笔。

  5. PR description 没更新:body 里的 acceptance criteria 还停留在第一次 commit 的 verify 列表(Cancel osascript / networksetup 失败 / 连续 enable),没补 is_expected_proxy_alive 三档场景的 verify。强烈建议把第二次 commit 的 Blocker 1 + Blocker 2 修复方式写进 body 的 verify 段,reviewer 看 diff 不一定看 commit message。


结论

Approve ✅ —— 两个 blocker 都正确修掉,6 个建议都收到且落地,CI 兼容性(MHOST_RUNTIME_DIR 隔离)也补了 commit。#148 这条线到这一步完整了,可以合并 → 后续接 #149(Cancel UX button + IPC abort)。上面 🟡 几条都是打磨项,不阻塞。

Reviewer 🟡 #2: dns.rs test used local `static Mutex<()>` while
`platform.rs` tests used `serial_runtime_dir_test()` (= `proxy::tests::
test_lock()`). The two locks were not equal, so under parallel `cargo
test` (mhost-dns binary vs mhost binary) the `MHOST_RUNTIME_DIR` env var
could race: one binary writes pid_file while the other concurrently
swaps runtime_dir, leading to a stale-pid-file path being read.

**Fix:** add a crate-level `pub static RUNTIME_DIR_TEST_LOCK: Mutex<()>`
in `mhost-dns/src/lib.rs`. `#[doc(hidden)]` keeps it out of public API
docs; always-on (no `#[cfg(test)]`) is required because mhost crate's
test compilation does not enable mhost-dns's `cfg(test)`.

Both platform.rs::tests and dns.rs::tests now lock the same mutex.

Verify:
- `cargo test --all-features` → 132 passed
- `cargo clippy --all-targets --all-features -- -D warnings` → clean
- `cargo fmt --all -- --check` → clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flyhigher139
flyhigher139 merged commit 5a0ebd3 into master Aug 7, 2026
4 checks passed
@flyhigher139
flyhigher139 deleted the fix/dns-orphan-proxy-cleanup branch August 7, 2026 07:00
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