diff --git a/src/bin/ui.rs b/src/bin/ui.rs index c5f9ed63..e5b7ed32 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); @@ -1899,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( @@ -1964,10 +2017,75 @@ fn fmt_bytes(b: u64) -> String { } } +#[cfg(test)] +mod runtime_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 ---------- +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<()>, @@ -2113,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, }; @@ -2492,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 @@ -2568,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); + } +}