From 6c41e0704e37d3bc20eadc7a8130e7c821a34156 Mon Sep 17 00:00:00 2001 From: Jonathan McCumber Date: Sun, 28 Jun 2026 12:35:37 -0500 Subject: [PATCH] fix: reduce TUI idle CPU and improve server error banners - Replace non-blocking try_recv() event loop with blocking recv() to eliminate idle spin; the sender tick timeout still drives minimum wakeup rate so the UI stays responsive - Lower default tick rate from 50 ms to 100 ms (10 ticks/sec vs 20); add MANAGARR_TICK_RATE_MS env var for runtime tuning - Move terminal draws to event boundaries (key + tick) instead of every loop pass, reducing unnecessary redraws - In network error handling, prefer concise JSON fields (message / errorMessage / error) over the raw HTTP body, so Radarr ASP.NET stack traces don't flood the TUI banner; full body still logged - Add unit tests for tick-rate parsing and Radarr-style JSON error body Observed: ~0.6% total CPU at idle vs measurably higher with prod defaults --- src/event/input_event.rs | 43 ++++++++++++++++++++++++++++++++++-- src/main.rs | 16 ++++++++++---- src/network/mod.rs | 23 +++++++++++++++---- src/network/network_tests.rs | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/event/input_event.rs b/src/event/input_event.rs index 67e5897..a02d96c 100644 --- a/src/event/input_event.rs +++ b/src/event/input_event.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::env; use std::sync::mpsc; use std::sync::mpsc::Receiver; use std::thread; @@ -18,10 +19,24 @@ pub struct Events { rx: Receiver>, } +const DEFAULT_TICK_RATE_MS: u64 = 100; + +fn configured_tick_rate_ms_from(raw: Option<&str>) -> u64 { + raw + .and_then(|value| value.parse::().ok()) + .filter(|ms| *ms > 0) + .unwrap_or(DEFAULT_TICK_RATE_MS) +} + +fn configured_tick_rate_ms() -> u64 { + let raw = env::var("MANAGARR_TICK_RATE_MS").ok(); + configured_tick_rate_ms_from(raw.as_deref()) +} + impl Events { pub fn new() -> Self { let (tx, rx) = mpsc::channel(); - let tick_rate: Duration = Duration::from_millis(50); + let tick_rate: Duration = Duration::from_millis(configured_tick_rate_ms()); thread::spawn(move || { let mut last_tick = Instant::now(); @@ -51,9 +66,33 @@ impl Events { } pub fn next(&self) -> Result>> { - match self.rx.try_recv() { + match self.rx.recv() { Ok(event) => Ok(Some(event)), _ => Ok(None), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_a_slower_idle_tick_rate() { + assert_eq!(DEFAULT_TICK_RATE_MS, 100); + assert_eq!(configured_tick_rate_ms_from(None), 100); + } + + #[test] + fn parses_positive_tick_rates_and_rejects_invalid_values() { + assert_eq!(configured_tick_rate_ms_from(Some("250")), 250); + assert_eq!( + configured_tick_rate_ms_from(Some("0")), + DEFAULT_TICK_RATE_MS + ); + assert_eq!( + configured_tick_rate_ms_from(Some("abc")), + DEFAULT_TICK_RATE_MS + ); + } +} diff --git a/src/main.rs b/src/main.rs index 033c440..db4888f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -276,22 +276,30 @@ async fn start_ui( let input_events = Events::new(); - loop { + { let mut app = app.lock().await; - terminal.draw(|f| ui(f, &mut app))?; + } + loop { match input_events.next()? { Some(InputEvent::KeyEvent(key)) => { + let mut app = app.lock().await; + if key == Key::Char('q') && !app.ignore_special_keys_for_textbox_input { break; } handlers::handle_events(key, &mut app); + terminal.draw(|f| ui(f, &mut app))?; } - Some(InputEvent::Tick) => app.on_tick().await, - _ => {} + Some(InputEvent::Tick) => { + let mut app = app.lock().await; + app.on_tick().await; + terminal.draw(|f| ui(f, &mut app))?; + } + None => break, } } diff --git a/src/network/mod.rs b/src/network/mod.rs index 4be177b..a319881 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -9,6 +9,7 @@ use regex::Regex; use reqwest::{Client, RequestBuilder}; use serde::Serialize; use serde::de::DeserializeOwned; +use serde_json::Value; use sonarr_network::SonarrEvent; use strum_macros::Display; use tokio::select; @@ -142,11 +143,8 @@ impl<'a, 'b> Network<'a, 'b> { } } else { let status = response.status(); - let whitespace_regex = Regex::new(r"\s+")?; let response_body = response.text().await.unwrap_or_default(); - let error_body = whitespace_regex - .replace_all(&response_body.replace('\n', " "), " ") - .to_string(); + let error_body = format_error_body(&response_body)?; error!("Request failed. Received {status} response code with body: {response_body}"); self.app.lock().await.handle_error(anyhow!("Request failed. Received {status} response code with body: {error_body}")); @@ -279,6 +277,23 @@ impl<'a, 'b> Network<'a, 'b> { } } +fn format_error_body(response_body: &str) -> Result { + let whitespace_regex = Regex::new(r"\s+")?; + let body = serde_json::from_str::(response_body) + .ok() + .and_then(|value| { + value + .get("message") + .or_else(|| value.get("errorMessage")) + .or_else(|| value.get("error")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| response_body.replace('\n', " ")); + + Ok(whitespace_regex.replace_all(&body, " ").to_string()) +} + #[derive(Clone, Copy, Debug, Display, PartialEq, Eq)] pub enum RequestMethod { Get, diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 7dd4c20..a55079a 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -353,6 +353,48 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_request_non_success_code_uses_json_message_for_error_body() { + let mut server = Server::new_async().await; + let async_server = server + .mock("GET", "/test") + .match_header("X-Api-Key", "test1234") + .with_status(500) + .with_body(json!({ + "message": "database is locked\ndatabase is locked", + "description": "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker" + }).to_string()) + .create_async() + .await; + let app_arc = Arc::new(Mutex::new(App::test_default())); + let mut network = test_network(&app_arc); + + let resp = network + .handle_request::<(), Test>( + RequestProps { + uri: format!("{}/test", server.url()), + method: RequestMethod::Get, + body: None, + api_token: "test1234".to_owned(), + ignore_status_code: false, + custom_headers: HeaderMap::new(), + }, + |response, mut app| app.error = HorizontallyScrollableText::from(response.value), + ) + .await; + + async_server.assert_async().await; + assert_str_eq!( + app_arc.lock().await.error.text, + "Request failed. Received 500 Internal Server Error response code with body: database is locked database is locked" + ); + assert!(resp.is_err()); + assert_str_eq!( + resp.unwrap_err().to_string(), + "Request failed. Received 500 Internal Server Error response code with body: database is locked database is locked" + ); + } + #[rstest] #[tokio::test] async fn test_call_api(