Skip to content

fix(dns): surface silent failures when mhost-dns-proxy binary is missing (#155) - #156

Merged
flyhigher139 merged 2 commits into
masterfrom
fix/dns-mode-silent-failure-155
Aug 18, 2026
Merged

fix(dns): surface silent failures when mhost-dns-proxy binary is missing (#155)#156
flyhigher139 merged 2 commits into
masterfrom
fix/dns-mode-silent-failure-155

Conversation

@flyhigher139

Copy link
Copy Markdown
Contributor

Summary

Closes #155.

pnpm tauri dev does not auto-build the mhost-dns-proxy sidecar binary — it's declared as a [[bin]] in crates/mhost-dns/Cargo.toml, separate from the workspace root mhost bin. After a cargo clean, a disk cleanup that wipes target/, or any fresh clone, the binary is missing from target/debug/mhost-dns-proxy. The privileged enable script then runs set -e + cmd &, and 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 "DNS mode enabled" — but no process is listening on port 53. Every DNS query hangs (local rules AND external domains like baidu.com), until the user toggles DNS mode off.

System DNS restoration works correctly when disabling (because disable_dns_mode falls back to networksetup -setdnsservers <iface> Empty when 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:

  1. Rust pre-check in platform.rs::enable_dns_mode. Before writing any state file or invoking osascript, the function now does fs::metadata(&proxy_path) and verifies the executable bit. Missing or non-executable binary returns PlatformError::SetDns with a clear message including the exact rebuild command and a pointer to doc/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.

  2. Script-level fail-loud in the osascript sh body (was a pure silent-pass before):

    • [ ! -x "$proxy" ] early check before the spawn
    • Capture $! into a named PROXY_PID variable
    • kill -0 "$PROXY_PID" after a 1s grace period — if the proxy died (bind 53 failed, panic, etc.), cat its log to stderr, rm the PID file, exit 1
    • networksetup -setdnsservers runs strictly after kill -0 — system DNS never gets redirected to a black-hole port
  3. scripts/dev.sh + pnpm dev:full wire the proxy build into the dev workflow so users no longer have to remember cargo build -p mhost-dns --bin mhost-dns-proxy. The script handles both debug and --release. Docs updated in CLAUDE.md and doc/dev-guide.md.

What's NOT in this PR

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 new PROXY_PID + kill -0 script structure
    • test_enable_script_contains_safety_layers — verifies all 5 must-have layers (executable check, kill -0, log dump on failure, named variable, networksetup ordering)
    • test_enable_script_loudly_fails_when_proxy_missing — actually invokes /bin/sh on a synthetic script and asserts it exits 127 (not 0!) with stderr containing the missing path. This is the direct regression for the bug.
  • cargo fmt --all -- --check: clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
  • pnpm test: 255 passed across 22 test files
  • pnpm build: clean (tsc + vite)

Manual smoke (covered by scripts/dev.sh):

  1. bash scripts/dev.sh builds proxy → starts dev → UI launches
  2. Settings → toggle DNS mode ON with a local rule 127.0.0.1 test.dns.com
  3. dig @127.0.0.1 -p 1053 test.dns.com A +short → expect 127.0.0.1 (local rule) AND dig @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):

  1. rm src-tauri/target/debug/mhost-dns-proxy
  2. Reload dev / trigger DNS mode enable
  3. UI shows error message containing "mhost-dns-proxy binary not found at ; This usually means `pnpm tauri dev` was run without first building the proxy. Fix: `cd src-tauri && cargo build --bin mhost-dns-proxy`, or use `bash scripts/dev.sh` which builds it for you."
  4. No silent "enabled" state; no system DNS change to 127.0.0.1

🤖 Generated with Claude Code

…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 flyhigher139 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' EXIT

Disarm 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-57 and doc/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.md lists raw pnpm tauri dev before pnpm dev:full; the DNS-capable command should lead.
  • doc/dev-guide.md writes #[bin] instead of [[bin]].
  • scripts/dev.sh header 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_proxy runs in AppState::new, not concurrent with enablement — no race.
  • mode & 0o111 is correct for executable-bit check; minor win from also calling meta.is_file().
  • The exact missing/non-executable binary path is loudly rejected (both Rust + [ ! -x ]).
  • dev.sh has set -euo pipefail; failed Cargo stops before Tauri; project-root resolution works from any CWD; the two cargo invocations are sequential, no file-lock contention.
  • #155 references are correct; pnpm dev:full alias is wired in package.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.

@flyhigher139

Copy link
Copy Markdown
Contributor Author

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
@flyhigher139

Copy link
Copy Markdown
Contributor Author

Follow-up commit: review findings F2/F4/F5/F6 (+ F7) addressed in 5e0b109

Pushed a follow-up commit fixing the blocker + recommended-major review items.

What changed

platform.rs — extracted 3 production helpers as pub(crate):

  • validate_proxy_binary(&Path)
  • EnableScriptInputs<'a> (struct of inputs)
  • build_enable_script(&EnableScriptInputs) -> String
  • PROXY_BUILD_INSTR constant (the single source of truth for the cargo command)

F2 (shell quoting) — added shell_single_quote(s) -> String POSIX helper. All path/interface values now inject via single-quoted env vars (PROXY='...' etc.) and access via "$PROXY". Default macOS ~/Library/Application Support/... paths with spaces now correctly reach the redirection target instead of being split on the space (which previously broke the PID file write entirely — pre-existing latent bug, not introduced by #156).

F4 (transactional) — added cleanup() function with trap cleanup EXIT registered before the proxy is spawned. On any non-zero exit (incl. networksetup failure), kills the disowned root proxy + rm -f the PID file. trap - EXIT disarms only after networksetup succeeds.

F5 (wrong cargo command) — every reference to cargo build --bin mhost-dns-proxy now points to PROXY_BUILD_INSTR = "cd src-tauri && cargo build -p mhost-dns --bin mhost-dns-proxy". Fixed in: Rust error messages, CLAUDE.md, doc/dev-guide.md.

F6 (macOS Bash 3.2 compat)scripts/dev.sh no longer expands empty arrays under set -u. case + if [ ${#CARGO_FLAGS[@]} -gt 0 ] for both the cargo invocation and the final exec pnpm tauri dev.

F7 (silent ignore of unknown args) — drive-by fix in same edit. Explicit case rejects unknown args with usage to stderr and exit 2.

Test refactor (F1 partial)

Existing tests now call build_enable_script(...) instead of format!ing their own script copies — they will fail when production code regresses. New tests:

  • test_shell_single_quote_basic, test_shell_single_quote_with_apostrophe, test_shell_single_quote_injection_safe — round-trip quoting for ordinary / apostrophe / metacharacter inputs
  • test_enable_script_loudly_fails_when_proxy_missing — exec's the actual builder output through /bin/sh, asserts exit 127 + stderr contains full missing path + PID file cleaned
  • test_enable_script_with_spaces_in_path_loudly_fails — exec with proxy at /tmp/mhost enable script test/proxy binary and asserts stderr contains the full path (proves word-splitting truncated neither [ -x ] check nor the path echo)

Quality gates

  • cargo fmt --all: clean
  • cargo clippy --all-targets --all-features -- -D warnings: clean
  • cargo test --workspace: 424/424 pass (was 420; +4 new shell/quoting/script tests)
  • pnpm test: 255/255 pass

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.

@flyhigher139
flyhigher139 merged commit ca38ad5 into master Aug 18, 2026
4 checks passed
@flyhigher139
flyhigher139 deleted the fix/dns-mode-silent-failure-155 branch August 18, 2026 12:17
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.

[Bug] DNS mode 在 pnpm tauri dev 下静默失效:mhost-dns-proxy binary 缺失导致所有查询卡死

1 participant