fix(dns): surface silent failures when mhost-dns-proxy binary is missing (#155) - #156
Conversation
…ing (#155) `pnpm tauri dev` does not auto-build the `mhost-dns-proxy` sidecar bin (declared as [[bin]] in crates/mhost-dns/). After a disk cleanup / `cargo clean` / fresh clone, the binary is missing from target/debug/, and the privileged enable script's `set -e + &` pattern silently swallows the failure: POSIX sh does not monitor async-list exit codes, so the script exits 0, osascript returns 0, enable_dns_mode returns Ok, the UI reports success — but no process listens on 53, so every DNS query hangs (local rules AND external domains like baidu.com). Three layers of defense: 1. Rust pre-check in platform.rs::enable_dns_mode verifies `proxy_path` exists and is executable before any side effects (writes / osascript). Missing binary returns a clear Err with the exact rebuild command. 2. The osascript script now performs a [ ! -x ] early check, captures `$!` into a named `PROXY_PID` variable, and verifies with `kill -0` after a 1s grace that the proxy is still alive. On failure the proxy's log is dumped to stderr and the script exits non-zero, propagating to enable_dns_mode. networksetup runs strictly after kill -0 — system DNS never gets redirected to a black-hole port. 3. scripts/dev.sh (with pnpm dev:full alias) builds the proxy before invoking `pnpm tauri dev`, so devs no longer need to remember to run `cargo build -p mhost-dns --bin mhost-dns-proxy` manually. Tests: 3 new tests in platform.rs cover the script structure (safety layers, ordering of networksetup vs kill -0) and the shell-execution behavior (missing binary exits 127 with clear stderr). All existing tests pass (420 Rust + 255 frontend). Docs: CLAUDE.md and doc/dev-guide.md updated to document the required workflow and the failure mode.
flyhigher139
left a comment
There was a problem hiding this comment.
Code review — 8 findings (1 blocker, 5 major, 2 minor)
Subagent-driven review (code-reviewer) covering pre-check ordering, shell-quoting, kill -0 semantics, transactional failure, dev script portability, and doc consistency.
🛑 Blocker
F6 — scripts/dev.sh line 17/44: crashes on stock macOS Bash 3.2
set -euo pipefail + empty ${PROFILE_FLAGS[@]} expansion → /bin/bash 3.2.57 (Apple-shipped) aborts with PROFILE_FLAGS[@]: unbound variable. Both pnpm dev:full and bash scripts/dev.sh fail before Cargo runs on systems without Homebrew bash.
Fix: avoid expanding empty arrays under nounset; explicit if/else between debug and release, or use a Bash-3.2-safe ${arr[@]+"${arr[@]}"} expansion. Add a script test that runs under /bin/bash with fake cargo / pnpm so CI catches this.
🔥 Major
F1 — 3 new tests in platform.rs don't exercise the production code
test_pid_file_content_format and test_enable_script_contains_safety_layers each format! their own copy of the script; reverting the real enable_dns_mode script to the old buggy version would leave these passing. test_enable_script_loudly_fails_when_proxy_missing only executes a 6-line [ ! -x ] snippet — never PROXY_PID, kill -0, log dump, PID-file creation, or networksetup ordering.
Fix: extract production builders — validate_proxy_binary(&Path) and build_enable_script(...) — and have all tests consume the string returned by the production function. Execute the generated end-to-end script with fake mhost-dns-proxy + fake networksetup executables so the old script (exits 0) and fixed script (exits 1) are distinguishable. Add cases for paths with spaces, delayed readiness, bind failure, networksetup failure.
F2 — enable_dns_mode script body line ~534: PID file path is unquoted → redirect silently broken
echo "$PROXY_PID {proxy}" > {pid_file}
{pid_file} resolves to ~/Library/Application Support/mHost/.runtime/mhost-dns-proxy.pid on stock macOS. The space in Application Support truncates the redirection to > ~/Library/Application, leaving the rest as echo arguments. The script still exits 0; the PID file is just never written → disable_dns_mode and cleanup_stale_proxy can't find the root process → proxy stays orphaned, holding port 53.
This is a pre-existing bug in the old script too — the new code preserves and propagates it. Same issue applies to interface name Thunderbolt Ethernet if not quoted, and any path with shell metacharacters.
Fix: wrap every interpolated path/argument in double quotes consistently ("$PROXY_PID" "$proxy" > "$pid_file"). Add a test with MHOST_RUNTIME_DIR=/tmp/path with space and an interface containing spaces.
F3 — kill -0 after 1s does not establish port 53 is ready
The proxy could be alive but pre-bind (panic in main before UdpSocket::bind, hung on init, delayed by filesystem). After 1s, kill -0 succeeds and networksetup flips DNS anyway → same black-hole window the PR claims to eliminate. The exact "binary missing" path is already caught by [ ! -x ]; kill -0 only proves process existence.
Fix: readiness handshake — proxy writes a marker file after successful bind; script polls kill -0 + marker presence with a bounded timeout before flipping DNS. A proxy-emitted readiness signal is preferable to UDP probe (connect checks aren't reliable for unconnected UDP).
Suggested follow-up issue: not strictly required to merge if we accept the residual risk on slow systems — the original 60s of networksetup Redis args means worst-case is one query timeout, not a full hang. Documenting the limitation explicitly in code is acceptable.
F4 — Failure after spawning proxy is not transactional
If networksetup -setdnsservers returns non-zero, set -e aborts the script but the disowned root proxy keeps running. Rust then deletes original-DNS + shutdown files, returns "proxy failed to start" (inaccurate for networksetup failures), and stops the DnsServer on 1053. Result: port 53 still occupied by a proxy forwarding to a stopped backend; system DNS may be partially redirected to 127.0.0.1 if networksetup applied before erroring.
Fix: install an error trap right after spawning the proxy:
trap 'rc=$?
if [ -n "${PROXY_PID:-}" ] && kill -0 "$PROXY_PID" 2>/dev/null; then
kill "$PROXY_PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
exit $rc' EXITDisarm the trap only after networksetup succeeds. Add a test with a long-running fake proxy + a failing fake networksetup asserting the child is reaped and PID file gone.
F5 — The recovery command in error messages is wrong
platform.rs::enable_dns_mode, CLAUDE.md, doc/dev-guide.md — all recommend cargo build --bin mhost-dns-proxy from src-tauri/. This fails with no bin target named 'mhost-dns-proxy' in default-run packages (workspace root lookup doesn't see member-crate [[bin]]s). scripts/dev.sh correctly uses -p mhost-dns — but no other surface does.
Fix: replace every occurrence with cargo build -p mhost-dns --bin mhost-dns-proxy. Centralize in one constant/helper so user-facing guidance and docs stay in sync.
📝 Minor
F7 — scripts/dev.sh:17,23-25,44,56: unsupported arguments silently ignored
Any first argument other than --release silently selects debug; release-mode log says "Building mhost-dns-proxy (debug)...". Typo → silent wrong build.
Fix: explicit case over the full argv — accept no args or exactly --release, otherwise print usage and exit 2. Derive log line from TARGET_DIR, not hardcoded.
F8 — Documentation contradictions
CLAUDE.md:25-27,49-57anddoc/dev-guide.md:72-117: pre-fix failure mode described in present tense ("system DNS gets rewritten... all queries hang"). After this PR that's history, not current behavior.CLAUDE.mdlists rawpnpm tauri devbeforepnpm dev:full; the DNS-capable command should lead.doc/dev-guide.mdwrites#[bin]instead of[[bin]].scripts/dev.shheader comment says "不写pnpm dev:full" but the alias is added.
Fix: rewrite to describe pre-fix as past behavior, surface pnpm dev:full as the primary DNS-capable command, fix the #[bin] typo, correct the script header.
✅ Explicitly no-finding
- Rust pre-check is ordered correctly (active-interface → pre-check → runtime writes → osascript).
cleanup_stale_proxyruns inAppState::new, not concurrent with enablement — no race.mode & 0o111is correct for executable-bit check; minor win from also callingmeta.is_file().- The exact missing/non-executable binary path is loudly rejected (both Rust +
[ ! -x ]). dev.shhasset -euo pipefail; failed Cargo stops before Tauri; project-root resolution works from any CWD; the two cargo invocations are sequential, no file-lock contention.#155references are correct;pnpm dev:fullalias is wired inpackage.json.- No newly introduced high-confidence security or performance issue beyond F2.
Recommended merge blocker set
F5 + F6 + F2 are quick and necessary before merge. F4 is a few-line trap + a test, also recommended.
F1 + F3 are larger and can ship as follow-up issues with the existing PR unblocking the immediate silent-failure class.
|
Follow-up: deferred items from the review above have been filed:
PR #156 should now merge after addressing F2, F4, F5, F6, F7, F8 (blockers + recommended majors). F1 + F3 will land separately via the issues above. |
Resolves blocking + major review items from PR #156 before merge: **F2 — Shell quoting** (PR #156 review). The osascript script injected paths via raw format! substitution. With default macOS runtime_dir `~/Library/Application Support/mHost/` (containing a space), `echo $PID > {pid_file}` truncated to `echo $PID > ~/Library/Application` and the rest became echo arguments — PID file never written, disable path unable to find the proxy. Fix: extract `shell_single_quote(s)` POSIX helper, wrap all interpolated path/interface values via env-var indirection (`PROXY='...'` etc.), access via "$PROXY" everywhere. Path with spaces now correctly reaches the redirection target. **F4 — Transactional failure**. After proxy is spawned, if `networksetup -setdnsservers` fails, set -e exits the script but the disowned root proxy keeps running on port 53 and the PID file lingers. Fix: register a `cleanup` function via `trap cleanup EXIT` before spawning the proxy; on any non-zero exit, kill the orphan and `rm -f` the PID file. `trap - EXIT` disarms only after `networksetup` succeeds. Backend DnsServer on 1053 is unaffected (no port 53 leak). **F5 — Wrong cargo build command in user-facing messages + docs**. `cd src-tauri && cargo build --bin mhost-dns-proxy` fails at workspace root with 'no bin target named mhost-dns-proxy in default-run packages' since mhost-dns is a workspace member, not default-run. Centralize the correct command in `platform::PROXY_BUILD_INSTR` constant; update CLAUDE.md and doc/dev-guide.md to match. All error messages and doc-strings now reference the same string. **F6 — macOS bash 3.2 incompatibility in scripts/dev.sh**. `set -euo pipefail` + empty `${PROFILE_FLAGS[@]}` expansion aborts on stock macOS /bin/bash 3.2.57 with 'unbound variable'. Fix: drop the array, use explicit `case` + `if [ \${#CARGO_FLAGS[@]} -gt 0 ]` branches for both the cargo invocation and the final `exec pnpm tauri dev`. **F7 — dev.sh silently ignoring arguments** (drive-by, same edit). Explicit `case` rejects unknown args with usage to stderr and exit 2; --help / -h / no-args / --release are the only accepted forms. **F1 partial — Tests now consume production builder**. Extracted `validate_proxy_binary`, `EnableScriptInputs`, `build_enable_script` as `pub(crate)`. The 3 script-shape tests now call the production builder instead of format!ing their own copy — they will fail when the real code regresses. Full F1 (integration tests with fake proxy + networksetup) remains tracked at #158. Tests added/updated: - `test_shell_single_quote_*` — injection safety round-trip - `test_pid_file_content_format` — production builder + variable-based - `test_enable_script_contains_safety_layers` — Layer 6 EXIT trap + Layer 7 single-quote path assert - `test_enable_script_loudly_fails_when_proxy_missing` — exec real builder output through /bin/sh - `test_enable_script_with_spaces_in_path_loudly_fails` — exec with proxy at '/tmp/.../proxy binary' and verifies stderr contains full path (no word-splitting truncation) Quality: - cargo fmt --all: clean - cargo clippy --all-targets --all-features -- -D warnings: clean - cargo test --workspace: 424/424 pass (107 + 70 + 40 + 104 + 47 + 56) - pnpm test: 255/255 pass
Follow-up commit: review findings F2/F4/F5/F6 (+ F7) addressed in 5e0b109Pushed a follow-up commit fixing the blocker + recommended-major review items. What changed
F2 (shell quoting) — added F4 (transactional) — added F5 (wrong cargo command) — every reference to F6 (macOS Bash 3.2 compat) — F7 (silent ignore of unknown args) — drive-by fix in same edit. Explicit Test refactor (F1 partial)Existing tests now call
Quality gates
Still deferred#158 (F1 follow-up) — production builders are extracted and tests consume them; the full integration test suite with fake proxy + fake networksetup executables remains tracked separately. #159 (F3 readiness handshake) — unchanged. Ready for re-review. |
Summary
Closes #155.
pnpm tauri devdoes not auto-build themhost-dns-proxysidecar binary — it's declared as a[[bin]]incrates/mhost-dns/Cargo.toml, separate from the workspace rootmhostbin. After acargo clean, a disk cleanup that wipestarget/, or any fresh clone, the binary is missing fromtarget/debug/mhost-dns-proxy. The privileged enable script then runsset -e + cmd &, and POSIX sh does not monitor async-list exit codes — so the script exits 0,osascriptreturns 0,enable_dns_modereturnsOk, the UI reports "DNS mode enabled" — but no process is listening on port 53. Every DNS query hangs (local rules AND external domains likebaidu.com), until the user toggles DNS mode off.System DNS restoration works correctly when disabling (because
disable_dns_modefalls back tonetworksetup -setdnsservers <iface> Emptywhen the proxy PID isn't reachable), which is why exiting the mode "fixed" the symptom and masked the actual problem.What's new
Three layers of defense, applied together so any one would block the failure on its own:
Rust pre-check in
platform.rs::enable_dns_mode. Before writing any state file or invokingosascript, the function now doesfs::metadata(&proxy_path)and verifies the executable bit. Missing or non-executable binary returnsPlatformError::SetDnswith a clear message including the exact rebuild command and a pointer todoc/dev-guide.md. The Rust-side check exists because the privileged script context (different uid) cannot trust the user-side assumption that the binary exists.Script-level fail-loud in the
osascriptsh body (was a pure silent-pass before):[ ! -x "$proxy" ]early check before the spawn$!into a namedPROXY_PIDvariablekill -0 "$PROXY_PID"after a 1s grace period — if the proxy died (bind 53 failed, panic, etc.), cat its log to stderr,rmthe PID file,exit 1networksetup -setdnsserversruns strictly afterkill -0— system DNS never gets redirected to a black-hole portscripts/dev.sh+pnpm dev:fullwire the proxy build into the dev workflow so users no longer have to remembercargo build -p mhost-dns --bin mhost-dns-proxy. The script handles both debug and--release. Docs updated inCLAUDE.mdanddoc/dev-guide.md.What's NOT in this PR
bundle.externalBinsidecar wiring (mentioned in [Bug] DNS mode 在 pnpm tauri dev 下静默失效:mhost-dns-proxy binary 缺失导致所有查询卡死 #155 as "远期"): would letpnpm tauri buildautomatically bundle the proxy as a sidecar. This requirestauri-plugin-shell+ capability manifest updates + a[[bin]]re-target in Cargo.toml, which is a larger architectural change and overlaps with the Windows/Linux support work tracked under 改为支持本地 hosts 模式和本地 DNS 模式两种模式 #67. Tracked as a follow-up.MHOST_DNS_PROXY_PATHenv-var override (also mentioned as future work): same scope, deferred.Test plan
Automated (each runs green locally):
cargo test --workspace --lib --all-features: 420 passed (107 + 70 + 40 + 100 + 47 + 56), including 3 new tests:test_pid_file_content_format— updated to assert the newPROXY_PID+ kill -0 script structuretest_enable_script_contains_safety_layers— verifies all 5 must-have layers (executable check, kill -0, log dump on failure, named variable,networksetupordering)test_enable_script_loudly_fails_when_proxy_missing— actually invokes/bin/shon a synthetic script and asserts it exits 127 (not 0!) withstderrcontaining the missing path. This is the direct regression for the bug.cargo fmt --all -- --check: cleancargo clippy --workspace --all-targets --all-features -- -D warnings: cleanpnpm test: 255 passed across 22 test filespnpm build: clean (tsc + vite)Manual smoke (covered by
scripts/dev.sh):bash scripts/dev.shbuilds proxy → starts dev → UI launches127.0.0.1 test.dns.comdig @127.0.0.1 -p 1053 test.dns.com A +short→ expect127.0.0.1(local rule) ANDdig @127.0.0.1 -p 1053 baidu.com A +short→ expect real IP via upstream (both rules work end-to-end)Manual smoke for the regression case (verify pre-check surfaces the error, doesn't go silent):
rm src-tauri/target/debug/mhost-dns-proxy127.0.0.1🤖 Generated with Claude Code