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 | |
| 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')
| -rw-r--r-- | src/app/actions.rs | 3 | ||||
| -rw-r--r-- | src/app/api.rs | 41 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 6 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 6 | ||||
| -rw-r--r-- | src/app/mod.rs | 124 | ||||
| -rw-r--r-- | src/app/session.rs | 29 |
6 files changed, 197 insertions, 12 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 69b459d2..ca6f6704 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1575,7 +1575,7 @@ impl AppState { pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> { match event { - AppEvent::PaneDied { pane_id } => { + AppEvent::PaneDied { pane_id, .. } => { self.handle_pane_died(pane_id); Vec::new() } @@ -3298,6 +3298,7 @@ mod tests { let deadline = state.next_pending_agent_notification_deadline().unwrap(); state.handle_app_event(AppEvent::PaneDied { pane_id: bg_pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, }); assert!(state.pending_agent_notifications.is_empty()); diff --git a/src/app/api.rs b/src/app/api.rs index c504b4c0..8470818e 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -93,7 +93,10 @@ impl App { return Vec::new(); } worktree_restore_failed = true; - AppEvent::PaneDied { pane_id } + AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, + } } ev => ev, }; @@ -164,7 +167,7 @@ impl App { } let mut worktree_restore_updates = Vec::new(); - if let AppEvent::PaneDied { pane_id } = &ev { + if let AppEvent::PaneDied { pane_id, .. } = &ev { if self .state .popup_pane @@ -224,7 +227,18 @@ impl App { } } - let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { + let checkpointed_pane_exit = matches!( + &ev, + AppEvent::PaneDied { + pane_id, + exit_reason, + } if exit_reason.requires_session_checkpoint() && self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id) + ); + if checkpointed_pane_exit { + self.checkpoint_session_before_pane_exit(); + } + + let overlay_state = if let AppEvent::PaneDied { pane_id, .. } = &ev { self.overlay_panes.remove(pane_id).map(|overlay| { let was_overlay_active = self.state @@ -248,7 +262,7 @@ impl App { None }; - if let AppEvent::PaneDied { pane_id } = &ev { + if let AppEvent::PaneDied { pane_id, .. } = &ev { if let Some((ws_idx, _)) = self.find_pane(*pane_id) { if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { self.emit_event(crate::api::schema::EventEnvelope { @@ -261,7 +275,7 @@ impl App { } } } - let pane_exit_layout_target = if let AppEvent::PaneDied { pane_id } = &ev { + let pane_exit_layout_target = if let AppEvent::PaneDied { pane_id, .. } = &ev { self.find_pane(*pane_id).and_then(|(ws_idx, _)| { self.layout_update_target_after_pane_removal(ws_idx, *pane_id) }) @@ -301,6 +315,9 @@ impl App { if update_ready.is_some() { self.state.latest_release_notes = crate::release_notes::load_latest(); } + if checkpointed_pane_exit { + self.finish_checkpointed_pane_exit(); + } if let Some(agents) = manifest_update_agents { self.reset_agent_detection_for_agents(&agents); } @@ -1968,6 +1985,7 @@ mod tests { app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, + exit_reason: crate::platform::ChildExitReason::Exited, }); let overlay_tab = &app.state.workspaces[0].tabs[0]; @@ -1994,7 +2012,10 @@ mod tests { app.state.ensure_test_terminals(); let tab_id = app.public_tab_id(0, 0).unwrap(); - app.handle_internal_event(AppEvent::PaneDied { pane_id: dead_pane }); + app.handle_internal_event(AppEvent::PaneDied { + pane_id: dead_pane, + exit_reason: crate::platform::ChildExitReason::Exited, + }); let events = event_hub.events_after(0); let pane_exited = events @@ -2143,6 +2164,7 @@ mod tests { app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, + exit_reason: crate::platform::ChildExitReason::Exited, }); let events = event_hub.events_after(0); @@ -2168,6 +2190,7 @@ mod tests { app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, + exit_reason: crate::platform::ChildExitReason::Exited, }); let tab = &app.state.workspaces[0].tabs[0]; @@ -2187,6 +2210,7 @@ mod tests { app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, + exit_reason: crate::platform::ChildExitReason::Exited, }); let tab = &app.state.workspaces[0].tabs[0]; @@ -2226,7 +2250,10 @@ mod tests { .expect("test session id should be valid"), }); - app.handle_internal_event(AppEvent::PaneDied { pane_id }); + app.handle_internal_event(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, + }); assert!( app.find_pane(pane_id).is_some(), diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index f4b6329b..8ad083d4 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -2210,6 +2210,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id: opened_pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, }); assert!(app.state.popup_pane.is_none()); assert!(event_hub.events_after(0).is_empty()); @@ -3448,7 +3449,10 @@ command = ["sh", "-c", "echo ok"] }, ); - app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id }); + app.handle_internal_event(crate::events::AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, + }); assert!(!app.state.plugin_panes.contains_key(&pane_id)); } diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 3c8407b9..71f2e03a 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -2319,8 +2319,10 @@ mod tests { .pending_worktree_remove_runtime_restores .contains_key(&pane_id)); - let pane_updates = - app.handle_internal_event_with_pane_updates(AppEvent::PaneDied { pane_id }); + let pane_updates = app.handle_internal_event_with_pane_updates(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, + }); assert!(matches!( pane_updates.as_slice(), [update] if update.agent_released && update.suppress_completion 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(); diff --git a/src/app/session.rs b/src/app/session.rs index 07bc80a9..49dca914 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -13,6 +13,7 @@ enum SessionSaveJob { impl App { pub(super) fn schedule_session_save(&mut self) { if self.policy.persist_session { + self.pane_exit_checkpoint_pending = false; self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); } } @@ -67,6 +68,7 @@ impl App { } let job = self.capture_session_save_job(); + self.pane_exit_checkpoint_pending = false; self.session_save_deadline = None; match std::thread::Builder::new() .name("herdr-session-save".into()) @@ -91,8 +93,35 @@ impl App { } run_session_save_job(self.capture_session_save_job()); + self.pane_exit_checkpoint_pending = false; self.session_save_deadline = None; } + + pub(crate) fn checkpoint_session_before_pane_exit(&mut self) { + if !self.policy.persist_session + || (self.pane_exit_checkpoint_pending && !self.state.session_dirty) + { + return; + } + self.save_session_now(); + self.pane_exit_checkpoint_pending = true; + self.state.session_dirty = false; + } + + pub(crate) fn finish_checkpointed_pane_exit(&mut self) { + if self.pane_exit_checkpoint_pending { + self.state.session_dirty = false; + self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); + } + } + + pub(crate) fn save_session_on_shutdown(&mut self) { + if self.pane_exit_checkpoint_pending && !self.state.session_dirty { + self.session_save_deadline = None; + return; + } + self.save_session_now(); + } } fn run_session_save_job(job: SessionSaveJob) { |
