From 12eb6830070f245dd49884535063c1aac939b601 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Wed, 18 Feb 2026 15:43:45 +0100 Subject: [PATCH] Fix intermittent unresponsiveness from slow/hanging IMAP connections Three improvements: - Add 15s connect/read/write timeouts to TcpStream so a hung IMAP server can no longer block the worker thread indefinitely - Cache tui_markdown rendering: convert Text<'a> to Text<'static> on first render and reuse across frames, re-parsing only when the body actually changes - Debounce FetchBody requests on keyboard navigation: wait 150ms of inactivity before sending, so rapid scrolling doesn't flood the worker with stale requests Co-Authored-By: Claude Sonnet 4.6 --- src/connect.rs | 31 ++++++++++++++++++++----------- src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/connect.rs b/src/connect.rs index 9d8b630..f332ad4 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -1,7 +1,11 @@ -use std::net::TcpStream; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; use native_tls::TlsStream; use crate::config::Config; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const IO_TIMEOUT: Duration = Duration::from_secs(15); + pub(crate) enum ImapSession { Plain(imap::Session), Tls(imap::Session>), @@ -25,25 +29,30 @@ impl ImapSession { pub(crate) fn connect(config: &Config) -> Result { let imap_cfg = &config.imap; + + let addr = (&*imap_cfg.host, imap_cfg.port) + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or_else(|| "could not resolve IMAP host".to_string())?; + + let tcp = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) + .map_err(|e| e.to_string())?; + tcp.set_read_timeout(Some(IO_TIMEOUT)).map_err(|e| e.to_string())?; + tcp.set_write_timeout(Some(IO_TIMEOUT)).map_err(|e| e.to_string())?; + if imap_cfg.use_tls { let tls = native_tls::TlsConnector::builder() .build() .map_err(|e| e.to_string())?; - let client = imap::connect( - (&*imap_cfg.host, imap_cfg.port), - &imap_cfg.host, - &tls, - ) + let tls_stream = tls.connect(&imap_cfg.host, tcp) .map_err(|e| e.to_string())?; - let session = client + let session = imap::Client::new(tls_stream) .login(&imap_cfg.username, &imap_cfg.password) .map_err(|(e, _)| e.to_string())?; Ok(ImapSession::Tls(session)) } else { - let stream = - TcpStream::connect((&*imap_cfg.host, imap_cfg.port)).map_err(|e| e.to_string())?; - let client = imap::Client::new(stream); - let session = client + let session = imap::Client::new(tcp) .login(&imap_cfg.username, &imap_cfg.password) .map_err(|(e, _)| e.to_string())?; Ok(ImapSession::Plain(session)) diff --git a/src/lib.rs b/src/lib.rs index bd939b9..e2c551a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ mod connect; mod inbox; const POLL_INTERVAL: Duration = Duration::from_secs(30); +const NAV_DEBOUNCE: Duration = Duration::from_millis(150); #[derive(PartialEq)] enum Focus { @@ -107,10 +108,13 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let mut list_state = ListState::default(); let mut last_fetch = Instant::now(); let mut message_body: String = String::new(); + let mut message_rendered: Option> = None; let mut message_seq: Option = None; let mut body_loading = false; let mut focus = Focus::Inbox; let mut message_scroll: u16 = 0; + let mut pending_fetch_seq: Option = None; + let mut last_nav = Instant::now(); // --- Main loop --- loop { @@ -164,6 +168,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) body_loading = false; if message_seq == Some(seq) { message_scroll = 0; + message_rendered = None; match result { Ok(body) => message_body = body, Err(e) => message_body = format!("Error loading body: {}", e), @@ -178,6 +183,18 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } } + // Fire pending body fetch after nav debounce elapses + if let Some(seq) = pending_fetch_seq { + if last_nav.elapsed() >= NAV_DEBOUNCE { + message_body.clear(); + message_rendered = None; + body_loading = true; + wanted_body_seq.store(seq, Ordering::Relaxed); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + pending_fetch_seq = None; + } + } + // Periodic refresh if !loading && last_fetch.elapsed() >= POLL_INTERVAL { let _ = cmd_tx.send(WorkerCmd::Refresh); @@ -265,7 +282,24 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let message_text = if body_loading { ratatui::text::Text::raw("Loading...") } else { - tui_markdown::from_str(&message_body) + message_rendered + .get_or_insert_with(|| { + // Convert to owned Text<'static> so it can be cached across frames + let t = tui_markdown::from_str(&message_body); + ratatui::text::Text { + alignment: t.alignment, + style: t.style, + lines: t.lines.into_iter().map(|line| ratatui::text::Line { + style: line.style, + alignment: line.alignment, + spans: line.spans.into_iter().map(|span| ratatui::text::Span { + style: span.style, + content: std::borrow::Cow::Owned(span.content.into_owned()), + }).collect(), + }).collect(), + } + }) + .clone() }; let message_title = Line::from(Span::styled( format!("{}Message", message_prefix), @@ -317,11 +351,9 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let seq = emails[new_idx].seq; if message_seq != Some(seq) { message_seq = Some(seq); - message_body.clear(); message_scroll = 0; - body_loading = true; - wanted_body_seq.store(seq, Ordering::Relaxed); - let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); } } }