diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-07 16:14:46 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-07 15:14:46 +0200 |
| commit | e7d8220788b4fa7c266a1e4457591b694478771c (patch) | |
| tree | 272303d74e0b58c11f9c0c434dae8335d3d06703 /src/app/mod.rs | |
| parent | 482818130407691154d1635d4029b95d0e730104 (diff) | |
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 <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/app/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 124 |
1 files changed, 123 insertions, 1 deletions
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<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>, + pane_exit_checkpoint_pending: bool, pub(crate) detached_process_children: Vec<std::process::Child>, tab_bar_status_generation: u64, tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>, @@ -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(); |
