aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-07 16:14:46 +0300
committerGitHub <noreply@github.com>2026-09-07 15:14:46 +0200
commite7d8220788b4fa7c266a1e4457591b694478771c (patch)
tree272303d74e0b58c11f9c0c434dae8335d3d06703 /src/app
parent482818130407691154d1635d4029b95d0e730104 (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.rs3
-rw-r--r--src/app/api.rs41
-rw-r--r--src/app/api/plugins/mod.rs6
-rw-r--r--src/app/api/worktrees.rs6
-rw-r--r--src/app/mod.rs124
-rw-r--r--src/app/session.rs29
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) {