From d304bcac86e636e6cfb4ac49f46929cdd0eac805 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 02:18:26 +0330 Subject: [PATCH 1/3] fix(ui): pace desktop repaint work by visible activity The desktop UI scheduled a repaint every 500 ms and sent stats-poll commands on the same update cadence regardless of whether the proxy was running, a transient message was visible, or any background operation was in flight. That kept the immediate-mode interface waking regularly even when the app was stopped and the window contained only static state. Add a small activity predicate over UI state and use it to select the repaint cadence. Running proxy sessions, proxy startup/shutdown windows, certificate operations, downloads, update checks, SNI probes, and fresh transient status lines keep the existing 500 ms cadence. Fully idle UI state falls back to a slower two-second repaint request while still allowing egui to repaint immediately for user input. Gate stats polling on the same active-state predicate so stopped or fully idle windows do not enqueue redundant PollStats commands into the background thread. The visible text, controls, command handlers, proxy lifecycle, and persisted configuration remain unchanged. Add focused unit tests for the activity predicate covering idle state, running proxy state, in-flight SNI probes, and expired transient status timestamps. --- src/bin/ui.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index c5f9ed63..1eb4b6ea 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -22,6 +22,12 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); const WIN_WIDTH: f32 = 520.0; const WIN_HEIGHT: f32 = 680.0; const LOG_MAX: usize = 200; +const UI_STATS_POLL_EVERY: Duration = Duration::from_millis(700); +const UI_ACTIVE_REPAINT_AFTER: Duration = Duration::from_millis(500); +const UI_IDLE_REPAINT_AFTER: Duration = Duration::from_secs(2); +const UI_TRANSIENT_TTL: Duration = Duration::from_secs(10); +const UI_TOAST_TTL: Duration = Duration::from_secs(5); +const UI_LOAD_ERROR_TOAST_TTL: Duration = Duration::from_secs(30); fn main() -> eframe::Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -215,6 +221,50 @@ struct App { toast: Option<(String, Instant)>, } +impl App { + fn ui_needs_active_repaint(&self) -> bool { + let state = self.shared.state.lock().unwrap(); + state.needs_active_repaint() || self.toast_is_visible() + } + + fn toast_is_visible(&self) -> bool { + self.toast.as_ref().map_or(false, |(msg, created_at)| { + let ttl = if msg.contains("failed to load") { + UI_LOAD_ERROR_TOAST_TTL + } else { + UI_TOAST_TTL + }; + created_at.elapsed() < ttl + }) + } +} + +impl UiState { + fn needs_active_repaint(&self) -> bool { + self.running + || self.proxy_active + || self.cert_op_in_progress + || self.download_in_progress + || self + .last_test_msg_at + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL) + || self + .ca_trusted_at + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL) + || self + .last_update_check_at + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL) + || self + .last_download_at + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL) + || matches!(self.last_update_check, Some(UpdateProbeState::InFlight)) + || self + .sni_probe + .values() + .any(|probe| matches!(probe, SniProbeState::InFlight)) + } +} + #[derive(Clone)] struct FormState { /// `"apps_script"` (default), `"direct"`, or `"full"`. Controls @@ -698,11 +748,16 @@ fn form_row( impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { - if self.last_poll.elapsed() > Duration::from_millis(700) { + let needs_active_repaint = self.ui_needs_active_repaint(); + if needs_active_repaint && self.last_poll.elapsed() > UI_STATS_POLL_EVERY { let _ = self.cmd_tx.send(Cmd::PollStats); self.last_poll = Instant::now(); } - ctx.request_repaint_after(Duration::from_millis(500)); + ctx.request_repaint_after(if needs_active_repaint { + UI_ACTIVE_REPAINT_AFTER + } else { + UI_IDLE_REPAINT_AFTER + }); egui::CentralPanel::default().show(ctx, |ui| { ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 6.0); @@ -1477,18 +1532,17 @@ impl eframe::App for App { // stale messages don't keep pushing the log panel off-screen. // Priority: update-check in flight > fresh test msg > fresh CA // result > update-check result. Old/expired entries are dropped. - const TRANSIENT_TTL: Duration = Duration::from_secs(10); let (test_msg_fresh, ca_trusted_fresh, update_check_fresh, download_fresh) = { let s = self.shared.state.lock().unwrap(); ( s.last_test_msg_at - .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL), s.ca_trusted_at - .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL), s.last_update_check_at - .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL), s.last_download_at - .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), + .map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL), ) }; @@ -1693,9 +1747,9 @@ impl eframe::App for App { // 30s instead of 5 because they explain why the form looks empty. if let Some((msg, t)) = &self.toast { let ttl = if msg.contains("failed to load") { - Duration::from_secs(30) + UI_LOAD_ERROR_TOAST_TTL } else { - Duration::from_secs(5) + UI_TOAST_TTL }; if t.elapsed() < ttl { ui.add_space(4.0); @@ -1964,6 +2018,41 @@ fn fmt_bytes(b: u64) -> String { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_ui_does_not_request_active_repaint() { + let state = UiState::default(); + assert!(!state.needs_active_repaint()); + } + + #[test] + fn running_proxy_requests_active_repaint() { + let mut state = UiState::default(); + state.running = true; + assert!(state.needs_active_repaint()); + } + + #[test] + fn inflight_probe_requests_active_repaint() { + let mut state = UiState::default(); + state + .sni_probe + .insert("docs.google.com".into(), SniProbeState::InFlight); + assert!(state.needs_active_repaint()); + } + + #[test] + fn expired_transient_state_does_not_keep_ui_active() { + let mut state = UiState::default(); + state.last_test_msg_at = + Some(Instant::now() - UI_TRANSIENT_TTL - Duration::from_millis(1)); + assert!(!state.needs_active_repaint()); + } +} + // ---------- Background thread: owns the tokio runtime + proxy lifecycle ---------- fn background_thread(shared: Arc, rx: Receiver) { From e8c77236a9738fb25c46e0404478ab9d27178f52 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 03:49:53 +0330 Subject: [PATCH 2/3] fix(ui): bound desktop runtime worker allocation Replace the desktop UI background runtime's implicit Tokio defaults with an explicit multi-thread runtime builder. The UI now sizes its async worker pool from available_parallelism(), clamps long-lived worker threads to a small 2..=4 range, names worker threads as mhrv-ui-worker, and bounds the blocking pool separately at 32 threads. This keeps proxy lifecycle tasks, stats polling, certificate operations, tests, scans, and update/download work on the same command paths while making the scheduler shape predictable on both low-core machines and high-core desktops. Small devices avoid a single-worker UI runtime, and large desktops avoid creating an oversized worker pool for a UI-owned background coordinator. Log the selected runtime shape at UI startup so support reports can distinguish runtime sizing from proxy transport behavior. Add focused unit coverage for the worker-count policy across low-core, midrange, and high-core inputs. --- src/bin/ui.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 1eb4b6ea..818bceae 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -1953,8 +1953,7 @@ impl App { let custom_label = ui.add_sized( [0.0, 0.0], egui::Label::new( - egui::RichText::new("Custom SNI") - .color(egui::Color32::TRANSPARENT), + egui::RichText::new("Custom SNI").color(egui::Color32::TRANSPARENT), ), ); ui.add( @@ -2055,8 +2054,38 @@ mod tests { // ---------- Background thread: owns the tokio runtime + proxy lifecycle ---------- +const DESKTOP_RUNTIME_MIN_WORKERS: usize = 2; +const DESKTOP_RUNTIME_MAX_WORKERS: usize = 4; +const DESKTOP_RUNTIME_MAX_BLOCKING_THREADS: usize = 32; + +fn desktop_runtime_worker_count(available_parallelism: usize) -> usize { + available_parallelism.clamp(DESKTOP_RUNTIME_MIN_WORKERS, DESKTOP_RUNTIME_MAX_WORKERS) +} + +fn build_desktop_runtime() -> std::io::Result<(Runtime, usize)> { + let available = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(DESKTOP_RUNTIME_MIN_WORKERS); + let workers = desktop_runtime_worker_count(available); + let runtime = tokio::runtime::Builder::new_multi_thread() + .thread_name("mhrv-ui-worker") + .worker_threads(workers) + .max_blocking_threads(DESKTOP_RUNTIME_MAX_BLOCKING_THREADS) + .thread_keep_alive(Duration::from_secs(30)) + .enable_all() + .build()?; + Ok((runtime, workers)) +} + fn background_thread(shared: Arc, rx: Receiver) { - let rt = Runtime::new().expect("failed to create tokio runtime"); + let (rt, runtime_workers) = build_desktop_runtime().expect("failed to create tokio runtime"); + push_log( + &shared, + &format!( + "[ui] tokio runtime ready: {} worker threads, {} max blocking threads", + runtime_workers, DESKTOP_RUNTIME_MAX_BLOCKING_THREADS + ), + ); let mut active: Option<( JoinHandle<()>, @@ -2202,14 +2231,14 @@ fn background_thread(shared: Arc, rx: Receiver) { https://whatismyipaddress.com in your browser \ via 127.0.0.1:8085. The IP shown should be your \ tunnel-node's VPS IP. Tracking a real Full-mode \ - test in #160." + test in #160.", ), Some(mhrv_rs::config::Mode::Direct) => Some( "Test Relay is wired only for apps_script mode. \ In direct mode there is no Apps Script relay — \ every request goes through the SNI-rewrite tunnel \ straight to Google's edge. Verify by loading \ - https://www.google.com via the proxy." + https://www.google.com via the proxy.", ), _ => None, }; @@ -2581,10 +2610,7 @@ fn install_ui_tracing(shared: Arc, config_level: &str) { /// by `install_ui_tracing`. `apply_log_level` uses it to swap in a new /// filter when the user clicks Save with a different log level (#401). static LOG_RELOAD: std::sync::OnceLock< - tracing_subscriber::reload::Handle< - tracing_subscriber::EnvFilter, - tracing_subscriber::Registry, - >, + tracing_subscriber::reload::Handle, > = std::sync::OnceLock::new(); /// Reinstall the tracing filter at runtime. Called from the Save handler @@ -2657,3 +2683,27 @@ fn push_log(shared: &Shared, msg: &str) { s.log.pop_front(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_runtime_worker_count_clamps_small_devices_to_two_workers() { + assert_eq!(desktop_runtime_worker_count(0), 2); + assert_eq!(desktop_runtime_worker_count(1), 2); + assert_eq!(desktop_runtime_worker_count(2), 2); + } + + #[test] + fn desktop_runtime_worker_count_uses_midrange_core_counts_directly() { + assert_eq!(desktop_runtime_worker_count(3), 3); + assert_eq!(desktop_runtime_worker_count(4), 4); + } + + #[test] + fn desktop_runtime_worker_count_caps_large_desktops_at_four_workers() { + assert_eq!(desktop_runtime_worker_count(8), 4); + assert_eq!(desktop_runtime_worker_count(32), 4); + } +} From 1aa2be1501b781aadbee842d3de1d6bbae4c92d5 Mon Sep 17 00:00:00 2001 From: May Knott Date: Mon, 25 May 2026 00:49:35 +0330 Subject: [PATCH 3/3] test(ui): separate repaint and runtime test modules Rename the desktop runtime sizing tests into their own module so they do not collide with the repaint pacing tests in the same binary test namespace. This keeps the repaint activity checks and runtime worker-count checks independently addressable while allowing the UI test binary to compile with the ui feature enabled. --- src/bin/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 818bceae..e5b7ed32 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -2018,7 +2018,7 @@ fn fmt_bytes(b: u64) -> String { } #[cfg(test)] -mod tests { +mod runtime_tests { use super::*; #[test]