From e7d8220788b4fa7c266a1e4457591b694478771c Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 7 Sep 2026 16:14:46 +0300 Subject: fix: preserve sessions after interrupted pane exits (#3418) * fix: preserve sessions when pane shells are signaled refs #3415 * fix: preserve shutdown checkpoints across platforms refs #3415 --------- Co-authored-by: Jonathan Liebig --- src/app/mod.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) (limited to 'src/app/mod.rs') diff --git a/src/app/mod.rs b/src/app/mod.rs index a0b90023..b327b5bb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -138,6 +138,7 @@ pub struct App { pub(crate) pending_agent_resume_deadline: Option, pub(crate) session_save_deadline: Option, pub(crate) session_save_thread: Option>, + pane_exit_checkpoint_pending: bool, pub(crate) detached_process_children: Vec, tab_bar_status_generation: u64, tab_bar_datetimes: Vec, @@ -598,6 +599,7 @@ impl App { pending_agent_resume_deadline: None, session_save_deadline: None, session_save_thread: None, + pane_exit_checkpoint_pending: false, detached_process_children: Vec::new(), tab_bar_status_generation: 0, tab_bar_datetimes: Vec::new(), @@ -695,9 +697,17 @@ impl App { } let cwd = self.resolve_new_terminal_cwd(None); + let preserve_checkpoint = self.pane_exit_checkpoint_pending && !self.state.session_dirty; match self.create_workspace_with_options(cwd, true) { - Ok(_) => true, + Ok(_) => { + if preserve_checkpoint { + // Automatic replacement is part of pane removal, not a new user mutation. + self.pane_exit_checkpoint_pending = true; + self.finish_checkpointed_pane_exit(); + } + true + } Err(err) => { tracing::error!(err = %err, "failed to create default workspace"); self.state.mode = Mode::Navigate; @@ -3075,6 +3085,118 @@ mod tests { done_rx.try_recv().unwrap(); assert!(app.session_save_thread.is_none()); } + + #[tokio::test] + async fn pane_exit_checkpoint_survives_automatic_workspace_creation_on_shutdown() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let config_home = unique_temp_path("signaled-pane-session-checkpoint"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + + let mut app = test_app(); + app.policy.persist_session = true; + let mut workspace = Workspace::test_new("preserved"); + let first_pane = workspace.tabs[0].root_pane; + let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + + app.handle_internal_event(AppEvent::PaneDied { + pane_id: first_pane, + exit_reason: crate::platform::ChildExitReason::Interrupted, + }); + app.handle_internal_event(AppEvent::PaneDied { + pane_id: second_pane, + exit_reason: crate::platform::ChildExitReason::Interrupted, + }); + assert!(app.state.workspaces.is_empty()); + assert!(app.ensure_default_workspace()); + + app.save_session_on_shutdown(); + + let snapshot = crate::persist::load().expect("checkpointed session should survive"); + assert_eq!(snapshot.workspaces.len(), 1); + assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 2); + + std::env::remove_var("XDG_CONFIG_HOME"); + let _ = std::fs::remove_dir_all(config_home); + } + + #[test] + fn normal_autosave_replaces_a_signaled_exit_checkpoint() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let config_home = unique_temp_path("signaled-pane-autosave"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + + let mut app = test_app(); + app.policy.persist_session = true; + let workspace = Workspace::test_new("closed"); + let pane_id = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + + app.handle_internal_event(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Interrupted, + }); + assert!(crate::persist::load().is_some()); + + app.start_background_session_save(); + if let Some(thread) = app.session_save_thread.take() { + thread.join().unwrap(); + } + app.save_session_on_shutdown(); + + assert!(crate::persist::load().is_none()); + + std::env::remove_var("XDG_CONFIG_HOME"); + let _ = std::fs::remove_dir_all(config_home); + } + + #[test] + fn durable_mutation_after_pane_exit_checkpoint_wins_on_shutdown() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let config_home = unique_temp_path("pane-exit-newer-session-state"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + + for another_interrupted_exit in [false, true] { + let mut app = test_app(); + app.policy.persist_session = true; + let workspace = Workspace::test_new("old"); + let pane_id = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + + app.handle_internal_event(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Interrupted, + }); + app.state.workspaces = vec![Workspace::test_new("newer")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.mark_session_dirty(); + if another_interrupted_exit { + app.handle_internal_event(AppEvent::PaneDied { + pane_id: app.state.workspaces[0].tabs[0].root_pane, + exit_reason: crate::platform::ChildExitReason::Interrupted, + }); + } + app.save_session_on_shutdown(); + + let snapshot = crate::persist::load().expect("newer session should be saved"); + assert_eq!(snapshot.workspaces.len(), 1); + assert_eq!(snapshot.workspaces[0].custom_name.as_deref(), Some("newer")); + } + + std::env::remove_var("XDG_CONFIG_HOME"); + let _ = std::fs::remove_dir_all(config_home); + } + #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); -- cgit v1.2.3