diff --git a/src/app/mod.rs b/src/app/mod.rs index 8f33e23a..51bdd33e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -128,6 +128,7 @@ pub struct App { pub(crate) selection_autoscroll_deadline: Option, pub(crate) selection_highlight_clear_deadline: Option, pub(crate) session_save_deadline: Option, + pub(crate) session_save_thread: Option>, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option, pub(crate) suppressed_repeat_keys: @@ -719,6 +720,7 @@ impl App { agent_metadata_deadline: None, pending_agent_resume_deadline: None, session_save_deadline: None, + session_save_thread: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, @@ -1739,6 +1741,14 @@ mod tests { ) } + fn unique_temp_path(name: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("herdr-{name}-{}-{stamp}", std::process::id())) + } + #[cfg(windows)] fn exiting_test_command() -> &'static str { "C:\\Windows\\System32\\whoami.exe" @@ -4075,6 +4085,71 @@ mod tests { assert!(app.session_save_deadline.is_none()); } + #[test] + fn due_session_save_starts_background_writer() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let config_home = unique_temp_path("background-session-save"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + + let mut app = test_app(); + app.no_session = false; + app.state.workspaces = vec![Workspace::test_new("autosave")]; + app.state.ensure_test_terminals(); + app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); + + app.handle_scheduled_tasks(Instant::now(), false); + + assert!(app.session_save_thread.is_some()); + assert!(app.session_save_deadline.is_none()); + app.save_session_now(); + assert!(crate::session::data_dir().join("session.json").exists()); + + std::env::remove_var("XDG_CONFIG_HOME"); + let _ = std::fs::remove_dir_all(config_home); + } + + #[test] + fn background_session_save_reschedules_when_writer_is_busy() { + let mut app = test_app(); + app.no_session = false; + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session_save_thread = Some(std::thread::spawn(move || { + let _ = release_rx.recv(); + })); + + app.start_background_session_save(); + + assert!(app.session_save_thread.is_some()); + assert!(app.session_save_deadline.is_some()); + + release_tx.send(()).unwrap(); + app.no_session = true; + app.save_session_now(); + } + + #[test] + fn final_session_save_joins_background_writer_before_returning() { + let mut app = test_app(); + app.no_session = true; + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + app.session_save_thread = Some(std::thread::spawn(move || { + let _ = release_rx.recv(); + done_tx.send(()).unwrap(); + })); + let releaser = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + release_tx.send(()).unwrap(); + }); + + app.save_session_now(); + + releaser.join().unwrap(); + done_rx.try_recv().unwrap(); + assert!(app.session_save_thread.is_none()); + } + #[test] fn next_loop_deadline_includes_selection_autoscroll_deadline() { let mut app = test_app(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index b8f8091b..83ca2ea6 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -285,7 +285,7 @@ impl App { .session_save_deadline .is_some_and(|deadline| now >= deadline) { - self.save_session_now(); + self.start_background_session_save(); } if let Some(deadline) = self diff --git a/src/app/session.rs b/src/app/session.rs index 5e174507..60d49d52 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -1,7 +1,15 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use super::{App, SESSION_SAVE_DEBOUNCE}; +enum SessionSaveJob { + Clear, + Save { + snapshot: crate::persist::SessionSnapshot, + history: Option, + }, +} + impl App { pub(super) fn schedule_session_save(&mut self) { if !self.no_session { @@ -16,16 +24,23 @@ impl App { } } - pub(crate) fn save_session_now(&mut self) { - if self.no_session { - self.session_save_deadline = None; - return; + fn reap_finished_session_save(&mut self) { + if self + .session_save_thread + .as_ref() + .is_some_and(std::thread::JoinHandle::is_finished) + { + if let Some(thread) = self.session_save_thread.take() { + let _ = thread.join(); + } } + } + fn capture_session_save_job(&self) -> SessionSaveJob { if self.state.workspaces.is_empty() { - crate::persist::clear(); + SessionSaveJob::Clear } else { - let snap = crate::persist::capture( + let snapshot = crate::persist::capture( &self.state.workspaces, &self.state.terminals, &self.terminal_runtimes, @@ -38,9 +53,56 @@ impl App { let history = self.persist_pane_history.then(|| { crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) }); - crate::persist::save(&snap, history.as_ref()); + SessionSaveJob::Save { snapshot, history } + } + } + + pub(crate) fn start_background_session_save(&mut self) { + if self.no_session { + self.session_save_deadline = None; + return; } + self.reap_finished_session_save(); + if self.session_save_thread.is_some() { + self.session_save_deadline = Some(Instant::now() + Duration::from_millis(250)); + return; + } + + let job = self.capture_session_save_job(); + self.session_save_deadline = None; + match std::thread::Builder::new() + .name("herdr-session-save".into()) + .spawn(move || run_session_save_job(job)) + { + Ok(thread) => self.session_save_thread = Some(thread), + Err(err) => { + tracing::warn!(err = %err, "failed to spawn session save thread; saving inline"); + run_session_save_job(self.capture_session_save_job()); + } + } + } + + pub(crate) fn save_session_now(&mut self) { + if let Some(thread) = self.session_save_thread.take() { + let _ = thread.join(); + } + + if self.no_session { + self.session_save_deadline = None; + return; + } + + run_session_save_job(self.capture_session_save_job()); self.session_save_deadline = None; } } + +fn run_session_save_job(job: SessionSaveJob) { + match job { + SessionSaveJob::Clear => crate::persist::clear(), + SessionSaveJob::Save { snapshot, history } => { + crate::persist::save(&snapshot, history.as_ref()); + } + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 04320ff2..57561947 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3716,7 +3716,7 @@ impl HeadlessServer { .session_save_deadline .is_some_and(|deadline| now >= deadline) { - self.app.save_session_now(); + self.app.start_background_session_save(); } if let Some(deadline) = self