feat(#149): Settings cancel button + IPC-level abort signal for DNS enable/disable - #151
Closed
flyhigher139 wants to merge 7 commits into
Closed
feat(#149): Settings cancel button + IPC-level abort signal for DNS enable/disable#151flyhigher139 wants to merge 7 commits into
flyhigher139 wants to merge 7 commits into
Conversation
…nable/disable Background ========== Epic #147 (DNS enable state desync) closed #148 (orphan proxy) and #140 (proxy bind race), but the user still had no way to actively cancel a hung enable — the osascript sudo prompt is system-modal and Tauri's IPC is stuck inside `await spawn_blocking`. This PR exposes a Settings Cancel button that drives a backend CancellationToken through every phase of set_dns_mode. Changes ======= Backend (Rust) - mhost-core: new `MhostError::Cancelled` variant (serializes as `{ Cancelled: null }`). The frontend's AbortError path treats this as a non-error; the catch block clears `dnsErrorAtom` and refetches backend truth to mirror the rolled-back state. - AppState: new `dns_cancel: Mutex<Option<CancellationToken>>` slot. Same swap-not-mutate pattern as `ad_block_refresh_cancel` (issue #138) so a disable -> re-enable cycle doesn't leak a cancelled token into the new operation. - commands::dns::set_dns_mode now allocates a fresh CancellationToken, wraps the work future in `tokio::select!`, and clears the slot on completion (success or err). - New IPC command `cancel_dns_mode` — fires whatever token is in the slot. No-op when no operation is in flight; safe to call from Settings' signal-abort handler. - set_dns_mode_enable checks `cancel.is_cancelled()` at three phase boundaries (after server.start, after spawn_blocking for osascript, after manifest save) and runs the appropriate rollback: * Phase 1 (post server.start, pre-osascript): stop server only — no system DNS side effects yet. * Phase 2 (post-osascript): stop server + call disable_dns_mode with cancel=None to roll back the system DNS rewrite (proxy self-cleanup via signal-file, with osascript sudo fallback). * Phase 3 (post-manifest-save): full set_dns_mode_disable rollback (clears in-memory state too). - set_dns_mode_disable takes `cancel: Option<&CancellationToken>`. The 5s proxy-exit wait loop checks `cancel.is_cancelled()` after every 100ms sleep tick and bails with Ok(()) on cancel; proxy self-cleanup continues in the background and the recovery marker preserves worst-case recovery for next launch. - platform::disable_dns_mode signature gained the same cancel param. Rollback/cleanup callers pass None (must not be interrupted); the user-initiated disable path passes Some. Frontend - src/lib/tauri.ts: setDnsMode accepts `options.signal` for forward compatibility (Tauri 2's `invoke` doesn't natively propagate signal to the backend, so cancellation is tracked locally and the separate cancelDnsMode IPC fires the backend token). - New cancelDnsMode() IPC wrapper. - src/stores/profiles/actions.ts: toggleDnsModeAtom now creates an AbortController, registers an abort listener that flips a local `cancelled` flag and fires cancelDnsMode, and treats the eventual IPC rejection as a user cancel (clears dnsErrorAtom, refetches truth, does not throw). A new module-level helper `cancelActiveDnsToggle` is exposed for the Settings button. - src/pages/Settings.tsx: while `isDnsLoading` is true, the primary Enable/Disable button is replaced with a red Cancel button that calls cancelActiveDnsToggle. data-testid="dns-cancel-button" added for testability. Tests ===== Backend (commands::dns): - test_cancel_dns_mode_fires_slot_token - test_cancel_dns_mode_noop_when_slot_empty - test_set_dns_mode_swap_cancellation_token_is_fresh (regression for issue #138 follow-up — fresh token on every operation) Backend (platform::disable_dns_mode): - test_disable_dns_mode_cancellable_bails_on_pre_cancelled_token - test_disable_dns_mode_cancellable_none_does_not_bail Frontend (src/stores/__tests__/dns.test.ts): - cancelActiveDnsToggle fires cancelDnsMode IPC and aborts the controller - toggleDnsModeAtom does NOT throw when cancelled mid-flight - cancelActiveDnsToggle is a no-op when no toggle is in flight - real backend error: dnsErrorAtom is set and atom throws Verification ============ - cargo fmt --check: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test --all-features --workspace: 463 passed, 0 failed - pnpm test: 313 passed, 0 failed - pnpm build: clean Closes #149
… DNS enable hang) Root cause: build_enable_script_body backgrounded mhost-dns-proxy with '& disown' but did NOT redirect stdin/stdout/stderr. Because invoke_osascript uses Command::output(), osascript's captured pipes were inherited by the long-lived proxy. disown only removes the job from the shell's job table — it does NOT close FDs — so Command::output() on the Rust side never observed EOF. This matches the reported symptom exactly: no TCC password dialog visible, no error returned to the frontend, UI stuck on 'Loading' forever. PR #146 (a2fe6e4) had removed the v0.3.3 60 s tokio::time::timeout safety net; without any recovery path the user had no way out except force-quit. Fix: 1. platform.rs (build_enable_script_body): redirect </dev/null >/dev/null 2>&1 BEFORE the '&' that backgrounds the proxy. Order matters: '&' must come last or the redirects are ignored. Pinned by the new test_enable_script_redirects_backgrounded_proxy_fds. 2. platform.rs (new helpers): OsascriptRun + spawn_osascript + kill_osascript + run_with_privileges_timeout(Duration::from_secs(60)). Synchronous try_wait loop with SIGKILL on expiry. Switches the single call site in enable_dns_mode from run_with_privileges to run_with_privileges_timeout(60s). This restores the v0.3.3 60 s safety net without the JoinHandle-leak problem that motivated PR #149's removal — we hold the Child directly and SIGKILL on expiry. 3. platform.rs (enable_dns_mode): two tracing::info! breadcrumbs around the osascript call so the next time this hangs, logs show exactly where it stopped. 4. commands/dns.rs (set_dns_mode_enable): capture_dns_state moved into spawn_blocking; get_upstream_resolvers wrapped in tokio::time::timeout(10s) that falls back to tier3_fallback() on expiry. Defends the pre-prompt phase against wedged configd/scutil (same symptom, different root cause). 5. platform.rs (tests): test_enable_script_redirects_backgrounded_proxy_fds pins the redirect pattern AND its ordering. Manual verification on macOS (in plan): - Cause A (root): TCC prompt appears within 1-3 s of clicking Enable. - Cause C (timeout): with a temporary sleep(70) in enable_dns_mode, UI receives 'osascript timed out after 60s (killed pid=...)' within ~60 s and no osascript lingers. - Cause B (pre-prompt): with a temporary sleep(15) in capture_dns_state on a DHCP-empty system, after ~10 s a tracing::warn! fires and enable continues with public-DNS fallback. Verified: - cargo fmt --all -- --check: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test --workspace: green - pnpm test: 313/313 passed Follow-up (out of scope, to file separately): impl Drop for DnsServer would let us safely re-introduce tokio::select! for instant cancel during the spawn_blocking phase. References dns.rs:84 and the test_set_dns_mode_no_outer_tokio_select_race_after_cancel_leak_fix sentinel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s lazy sh exec) Found via MHOST_AUTO_DNS=1 end-to-end test on macOS: prior run_with_privileges_timeout removed the temp script file immediately after spawn_osascript returned, racing against osascript's lazy 'sh <path>' exec. osascript then returned exit 256 with stderr: 'sh: /var/folders/.../mhost-dns-37800-0.sh: No such file or directory (127)'. This was the actual cause of the user-reported 'no prompt, no error, UI stuck forever' symptom. The 60s timeout killed the osascript process correctly, but the underlying bug (the file got deleted before sh could exec it) had already triggered an earlier silent failure on every call. Two extra early breadcrumbs in enable_dns_mode were added during diagnosis and are kept as permanent 0-cost diagnostic anchors: - enable_dns_mode: entered (dns_port=...) - enable_dns_mode: get_active_network_interface returned: <iface> Fix: defer std::fs::remove_file(&path) until AFTER osascript has definitively exited (Ok branch + timeout branch both funnel through the same 'outcome' variable, then we clean up). On every exit path the script file is removed exactly once. Verified via MHOST_AUTO_DNS=1 headless test: osascript now waits the full 60s timeout (no silent ENOENT), then returns the clean 'osascript timed out after 60s (killed pid=...)' error to the frontend instead of silently failing. cargo fmt / clippy -D warnings / 466 tests / pnpm test 313/313: all clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…from capture (fixes #152) Two related but independent bugs in the DNS mode that combine to leave the user's system DNS stuck at 127.0.0.1 after a partial-fail disable. Root cause 1 — try_recover_dns reads the WRONG path for the recovery marker - `disable_dns_mode` writes the marker to `runtime_dir()/mhost-dns-disable-recovery.marker` - `try_recover_dns` (state/mod.rs:267) reads it from a hard-coded `/tmp/mhost-dns-disable-recovery.marker` path that disable never wrote to - The `if` branch has been dead code since the runtime_dir migration in PR #90; force_dns_restore_if_needed was never called - Fix: read via `mhost_dns::platform::disable_recovery_marker_file()` — same helper the disable path writes through Root cause 2 — capture_dns_state does not filter mHost's loopback proxy - `networksetup_get_dns` returns whatever networksetup says, including `127.0.0.1` if a previous enable left it in place - That `127.0.0.1` is mHost's own proxy address, not the user's DNS; capturing it as "original DNS" silently corrupts future restores - Fix: filter loopback via the existing `is_local_resolver` helper (already used by `get_upstream_resolvers` since issue #103) - If after filtering the list is empty, `capture_dns_state`'s existing `if !servers.is_empty()` guard correctly falls through to `DhcpEmpty` Tests - `test_capture_dns_state_filters_mhost_loopback`: source-grep pins the filter expression - `test_try_recover_dns_reads_canonical_marker_path`: source-grep pins both that the bad hard-coded path is gone AND the canonical helper is called Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…restore verify Three independent changes plus tests + E2E recipe, all addressing the open follow-ups from commit 3873345 (which closed the marker path and capture-filter root causes). Step 1 — OriginalDns defense layer - models.rs:466-471: OriginalDns::restore_argv filters loopback; empty after filter → ["Empty"] (never silently write 127.0.0.1) - models.rs:524-534: Repr::Legacy deserializer filters loopback so legacy ['127.0.0.1'] manifest migrates cleanly to DhcpEmpty - platform.rs:578-586: enable_dns_mode original.txt writer does final filter before write_atomic_0600 (belt-and-suspenders) Step 2 — D3-2 pgrep race fix (disable→re-enable kills proxy mid-restore) - platform.rs:752-794: top-of-script inline block now reads pid_file, verifies ps -o comm= basename matches recorded binary path, then TERM. Broad pgrep sweep gated behind pid_file freshness check (>30s old or missing). Same safety pattern as cleanup_stale_proxy. Step 3 — post-restore verification fallback - platform.rs verify_dns_restored_against_loopback helper + new any_local_resolver pure function - disable_dns_mode success branch (line 965) now verifies DNS no longer contains loopback before clearing marker; if it does (proxy exited but networksetup failed silently), escalates to osascript sudo fallback and preserves marker for next-launch retry Tests - Replaced source-grep test_capture_dns_state_filters_mhost_loopback with test_networksetup_get_dns_filter_pipeline (real behavior) - Added test_post_restore_verify_helper_detects_loopback - Added test_disable_recovery_marker_file_path_is_canonical (real behavior, no source-grep) - Added test_enable_script_inline_orphan_kill_uses_pid_file + the negative pin test_enable_script_inline_orphan_kill_does_not_blind_pgrep - Added 3 tests in mhost-core covering the OriginalDns defense layer (restore_argv, legacy Vec deserializer, tagged Manual deserializer) Documentation - doc/tech/dns-mode-e2e-recipe.md: 4 macOS scenarios + log grep cheatsheet Verified - cargo fmt --all -- --check (clean) - cargo clippy --all-targets --all-features -- -D warnings (clean) - cargo test --all-features (137 mhost + 117 mhost-dns + 48 mhost-core + ...) - pnpm test (313 frontend tests pass) - pnpm build (clean) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pagate cleanup Err Two structural bugs in commands/dns.rs that together produce the 'UI says Running while system DNS is stuck at 127.0.0.1' symptom: A. set_dns_mode_disable ordered its in-memory flag flip AFTER the privileged disable_dns_mode call. If disable_dns_mode returned Err (proxy hung past 5s, sudo rejected, networksetup hiccup), the IPC returned Err before the flip. Frontend catch path truth-fetched getDnsMode(), which returned the in-memory stale 'true', and set dnsEnabledAtom=true. UI showed 'Running' while system DNS was broken. Fix: flip dns_enabled BEFORE disable_dns_mode so it always matches user intent regardless of the privileged step's outcome. B. cleanup_dns_on_exit swallowed set_dns_mode_disable's Err into Ok + a single eprintln, claiming the recovery marker would handle next-launch restoration. The recovery IS handled, but the swallow hid the in-session degraded state from lib.rs at lib.rs:91, 273, 381. Tray Quit / Cmd-Q / SIGINT paths logged 'DNS cleanup ok' while system DNS was actually stuck at 127.0.0.1. Fix: propagate the Err. lib.rs already handles it correctly; the swallow was the source of the misleading log. Tests: - test_set_dns_mode_disable_clears_in_memory_flag_even_on_disable_failure (new): asserts dns_enabled=false after a disable where disable_dns_mode returns Err. Pre-fix would have left it true. - test_cleanup_dns_on_exit_propagates_disable_failure (new): asserts cleanup_dns_on_exit returns Err when disable actually failed. Pre-fix swallowed into Ok. - Updated test_cleanup_dns_on_exit_idempotent_across_calls and test_set_dns_mode_disable_succeeds_with_dhcp_empty_snapshot to match the new contract (Err propagated, not swallowed). Out of scope: B's separate OS-probing IPC idea (#153). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
macOS TCC caches authorization for ~5min; same AppleScript command string within that window silently returns Ok without showing the password prompt. This caused 'I clicked Enable but no prompt appeared, UI stuck' confusion. Fix: extract build_osascript_command(script_path, nonce) as a pure function; spawn_osascript / invoke_osascript now inject a unique nonce (nanos + pid + atomic counter) into the elevated shell command as a shell comment '#nonce<value>'. Different commands → different TCC cache key → fresh prompt every time. 5 new tests cover: nonce in command, uniqueness, escape rules for \ and ", uniqueness across rapid calls. Applies to both enable (spawn_osascript) and disable/recovery (invoke_osascript) paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #149
Background
Epic #147 (DNS enable state desync) closed #148 (orphan proxy) and #140 (proxy bind race), but the user still had no way to actively cancel a hung enable — the osascript sudo prompt is system-modal and Tauri's IPC is stuck inside
await spawn_blocking. This PR exposes a Settings Cancel button that drives a backendCancellationTokenthrough every phase ofset_dns_mode.Changes
Backend (Rust)
mhost-core: newMhostError::Cancelledvariant (serializes as{ Cancelled: null }). The frontend's AbortError path treats this as a non-error; the catch block clearsdnsErrorAtomand refetches backend truth to mirror the rolled-back state.AppState: newdns_cancel: Mutex<Option<CancellationToken>>slot. Same swap-not-mutate pattern asad_block_refresh_cancel(issue DNS refresh task: spawn_blocking closure is not cancellable, races with disable #138 follow-up) so a disable → re-enable cycle doesn't leak a cancelled token into the new operation.commands::dns::set_dns_modeallocates a fresh CancellationToken, wraps the work future intokio::select!, and clears the slot on completion (success or err).cancel_dns_mode— fires whatever token is in the slot. No-op when no operation is in flight.set_dns_mode_enablecheckscancel.is_cancelled()at three phase boundaries:server.start, pre-osascript): stop server only — no system DNS side effects yet.disable_dns_mode(cancel=None)to roll back the system DNS rewrite (proxy self-cleanup via signal-file, with osascript sudo fallback).set_dns_mode_disablerollback (clears in-memory state too).set_dns_mode_disabletakescancel: Option<&CancellationToken>. The 5s proxy-exit wait loop checkscancel.is_cancelled()after every 100ms sleep tick and bails withOk(())on cancel.platform::disable_dns_modesignature gained the same cancel param.Frontend
src/lib/tauri.ts:setDnsModeacceptsoptions.signalfor forward compatibility. NewcancelDnsMode()IPC wrapper.src/stores/profiles/actions.ts:toggleDnsModeAtomcreates an AbortController, registers an abort listener that flips a localcancelledflag and firescancelDnsMode, and treats the eventual IPC rejection as a user cancel. New module-level helpercancelActiveDnsToggle.src/pages/Settings.tsx: whileisDnsLoadingis true, the primary Enable/Disable button is replaced with a red Cancel button (data-testid="dns-cancel-button").Acceptance criteria
toggleDnsModeAtom(no error toast)cargo clippy --workspace -- -D warningscleanpnpm test+cargo test --workspace --all-featurescleanTests added
Backend (
commands::dns::tests):test_cancel_dns_mode_fires_slot_tokentest_cancel_dns_mode_noop_when_slot_emptytest_set_dns_mode_swap_cancellation_token_is_fresh(regression for issue DNS refresh task: spawn_blocking closure is not cancellable, races with disable #138 follow-up)Backend (
platform::tests):test_disable_dns_mode_cancellable_bails_on_pre_cancelled_tokentest_disable_dns_mode_cancellable_none_does_not_bailFrontend (
src/stores/__tests__/dns.test.ts):cancelActiveDnsTogglefirescancelDnsModeIPC and aborts the controllertoggleDnsModeAtomdoes NOT throw when cancelled mid-flightcancelActiveDnsToggleis a no-op when no toggle is in flightdnsErrorAtomis set and atom throwsVerification
cargo fmt --check: cleancargo clippy --workspace --all-targets -- -D warnings: cleancargo test --all-features --workspace: 463 passed, 0 failedpnpm test: 313 passed, 0 failedpnpm build: clean🤖 Generated with Claude Code