From 1b5835ac3d91e98bb8797ea61330d3ee689a0daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sun, 19 Jul 2026 16:17:44 -0400 Subject: [PATCH] feat(mmterm): auto-save session on SIGTERM/SIGHUP/SIGINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unattended termination — PC shutdown/reboot (SIGTERM), session teardown (SIGHUP), or kill (SIGINT) — previously lost the session because persistence only ran through the interactive Ctrl+Q / window-close prompt. A background signal-watcher thread now flips a flag and wakes the winit event loop via the existing EventLoopProxy; the main thread saves in user_event and exits without prompting. The save reuses build_saved_session + session::save_to and routes through the scope-aware session_path(), so reopening with the same --scope restores tabs/panes/layout/cwds/theme. Respects the general.restore_session gate. --- CHANGELOG.md | 1 + Cargo.lock | 21 ++++++++++++ Cargo.toml | 4 +++ src/main.rs | 32 +++++++++++++++++++ src/restore.rs | 16 ++++++++++ src/restore_test.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++ src/winit_handler.rs | 10 +++++- 7 files changed, 159 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01c89c..dcf6e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- auto-save the session on SIGTERM/SIGHUP/SIGINT so an unattended shutdown or kill preserves it per `--scope` - show the hovered link's URL in the status bar - add triple-click to select the whole line - add `--maximized` and `--fullscreen` flags to start the window in that mode diff --git a/Cargo.lock b/Cargo.lock index a618ead..6e4fe28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,6 +1307,7 @@ dependencies = [ "portable-pty", "regex", "serde", + "signal-hook", "softbuffer", "tempfile", "toml", @@ -2234,6 +2235,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index bf5c061..f24418b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,10 @@ fern = "0.7" chrono = "0.4" base64 = "0.22.1" +# Unix signal handling for auto-saving the session on unattended termination +[target.'cfg(unix)'.dependencies] +signal-hook = "0.3" + [dev-dependencies] tempfile = "3" criterion = { version = "0.8", features = ["html_reports"] } diff --git a/src/main.rs b/src/main.rs index 0135a77..424bc1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,9 @@ struct App { pending_screenshot: Option<([u32; 4], String)>, /// Named session scope from `--scope `; `None` means the default session. scope: Option, + /// Set by the Unix signal-watcher thread on SIGTERM/SIGHUP/SIGINT; polled on + /// the main thread in `user_event` to save the session before exiting. + shutdown_requested: Arc, /// Startup window mode from `--maximized` / `--fullscreen`; overrides the /// config window size when set. startup_window: Option, @@ -118,6 +121,7 @@ impl App { wakeup_pending, pending_screenshot: None, scope, + shutdown_requested: Arc::new(AtomicBool::new(false)), startup_window, update_rx, update_apply_rx: None, @@ -232,6 +236,32 @@ impl App { } } +/// Spawn a background thread that watches for unattended-termination signals +/// (SIGTERM from `systemd`/reboot, SIGHUP from session teardown, SIGINT from +/// `kill`/Ctrl-C). On any of them it raises `flag` and nudges the event loop +/// awake via `proxy`, so `user_event` saves the session and exits promptly +/// instead of the process dying with no chance to persist state. +#[cfg(unix)] +fn spawn_signal_watcher(flag: Arc, proxy: EventLoopProxy<()>) { + use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; + use std::sync::atomic::Ordering; + + let mut signals = match signal_hook::iterator::Signals::new([SIGTERM, SIGHUP, SIGINT]) { + Ok(s) => s, + Err(e) => { + log::warn!("could not install signal handlers: {e}"); + return; + } + }; + std::thread::spawn(move || { + for _ in &mut signals { + flag.store(true, Ordering::Release); + // Wake a parked event loop; the main thread does the actual save. + let _ = proxy.send_event(()); + } + }); +} + fn init_logging(log_path: Option<&str>) { let level = if log_path.is_some() { log::LevelFilter::Debug @@ -318,5 +348,7 @@ fn main() { let event_loop = EventLoop::new().unwrap(); let proxy = event_loop.create_proxy(); let mut app = App::new(config, proxy, scope, startup_window); + #[cfg(unix)] + spawn_signal_watcher(app.shutdown_requested.clone(), app.proxy.clone()); event_loop.run_app(&mut app).unwrap(); } diff --git a/src/restore.rs b/src/restore.rs index b8c4132..0924ad0 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -63,6 +63,22 @@ impl App { } } + /// Save the session in response to an unattended termination signal + /// (SIGTERM/SIGHUP/SIGINT). Respects the `general.restore_session` gate and + /// routes through the scope-aware `session_path()`, so reopening with the + /// same `--scope` restores the tabs/panes/layout/cwds/theme. Unlike the + /// interactive `Ctrl+Q` flow this never prompts — a shutdown must not block. + pub(super) fn save_session_on_shutdown(&self) { + if !self.state.config.general.restore_session { + return; + } + let s = self.build_saved_session(); + let path = self.session_path(); + if let Err(e) = session::save_to(&path, &s) { + log::warn!("shutdown session save failed: {e}"); + } + } + pub(super) fn restore_session( &mut self, saved: session::SavedSession, diff --git a/src/restore_test.rs b/src/restore_test.rs index 229dca5..1fd59e5 100644 --- a/src/restore_test.rs +++ b/src/restore_test.rs @@ -90,6 +90,82 @@ fn build_saved_session_without_window_yields_no_window_state() { ); } +/// Unique scope name per test process so we never collide with a real +/// user scope; the caller is responsible for removing what it writes. +fn unique_scope(tag: &str) -> String { + format!("__mmterm_shutdown_test_{tag}_{}", std::process::id()) +} + +fn cleanup_scope(scope: &str) { + let _ = std::fs::remove_file(crate::session::session_path_for(Some(scope))); + let _ = std::fs::remove_dir_all(crate::session::scrollback_dir_for(Some(scope))); +} + +#[test] +fn save_session_on_shutdown_writes_when_restore_enabled() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + let scope = unique_scope("enabled"); + cleanup_scope(&scope); + app.scope = Some(scope.clone()); + app.state.config.general.restore_session = true; + app.new_tab(800, 600); + + app.save_session_on_shutdown(); + + let path = crate::session::session_path_for(Some(&scope)); + assert!(path.exists(), "session file written on shutdown"); + let loaded = crate::session::load_from(&path).expect("session round-trips"); + assert_eq!(loaded.tabs.len(), 1, "the single tab was persisted"); + cleanup_scope(&scope); +} + +#[test] +fn save_session_on_shutdown_is_noop_when_restore_disabled() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + let scope = unique_scope("disabled"); + cleanup_scope(&scope); + app.scope = Some(scope.clone()); + app.state.config.general.restore_session = false; + app.new_tab(800, 600); + + app.save_session_on_shutdown(); + + let path = crate::session::session_path_for(Some(&scope)); + assert!( + !path.exists(), + "nothing must be written when restore_session is disabled" + ); + cleanup_scope(&scope); +} + +#[test] +fn save_session_on_shutdown_honors_scope_path() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + let scope = unique_scope("scoped"); + cleanup_scope(&scope); + app.scope = Some(scope.clone()); + app.state.config.general.restore_session = true; + app.new_tab(800, 600); + + app.save_session_on_shutdown(); + + // The save must land at the scoped path, not the default session file. + let scoped = crate::session::session_path_for(Some(&scope)); + assert!(scoped.exists(), "scoped session file written"); + assert_ne!( + scoped, + crate::session::session_path_for(None), + "scoped path differs from the default session path" + ); + cleanup_scope(&scope); +} + #[test] fn restore_session_empty_tabs_is_noop() { let Some(mut app) = make_app() else { diff --git a/src/winit_handler.rs b/src/winit_handler.rs index 31dd11f..b6944e1 100644 --- a/src/winit_handler.rs +++ b/src/winit_handler.rs @@ -161,7 +161,15 @@ impl ApplicationHandler for App { } } - fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ()) { + fn user_event(&mut self, event_loop: &ActiveEventLoop, _event: ()) { + // A termination signal (SIGTERM/SIGHUP/SIGINT) was caught by the watcher + // thread. Save the session (scope-aware, gated on restore_session) and + // exit without prompting — an unattended shutdown must not block. + if self.shutdown_requested.load(Ordering::Acquire) { + self.save_session_on_shutdown(); + event_loop.exit(); + return; + } self.wakeup_pending.store(false, Ordering::Release); if let Some(window) = &self.window { window.request_redraw();