Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
32 changes: 32 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ struct App {
pending_screenshot: Option<([u32; 4], String)>,
/// Named session scope from `--scope <name>`; `None` means the default session.
scope: Option<String>,
/// 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<AtomicBool>,
/// Startup window mode from `--maximized` / `--fullscreen`; overrides the
/// config window size when set.
startup_window: Option<StartupWindowMode>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<AtomicBool>, 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
Expand Down Expand Up @@ -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();
}
16 changes: 16 additions & 0 deletions src/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions src/restore_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion src/winit_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down