From e7d8220788b4fa7c266a1e4457591b694478771c Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 7 Sep 2026 16:14:46 +0300 Subject: [PATCH] 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/actions.rs | 3 +- src/app/api.rs | 41 +++++++-- src/app/api/plugins/mod.rs | 6 +- src/app/api/worktrees.rs | 6 +- src/app/mod.rs | 124 ++++++++++++++++++++++++++- src/app/session.rs | 29 +++++++ src/events.rs | 5 +- src/pane.rs | 23 +++-- src/platform/mod.rs | 49 +++++++++++ src/platform/unix_common.rs | 8 ++ src/platform/windows.rs | 9 ++ src/server/headless.rs | 2 +- src/server/headless/notifications.rs | 2 +- src/server/headless/tests/mod.rs | 21 ++++- tests/api_ping.rs | 72 ++++++++++++++++ tests/live_handoff.rs | 90 +++++++++++++++++++ 16 files changed, 467 insertions(+), 23 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 { 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, 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(); 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) { diff --git a/src/events.rs b/src/events.rs index 2901e493..95638991 100644 --- a/src/events.rs +++ b/src/events.rs @@ -56,7 +56,10 @@ pub struct WorktreeRemoveResult { #[derive(Debug)] pub enum AppEvent { /// A pane's child process exited. - PaneDied { pane_id: PaneId }, + PaneDied { + pane_id: PaneId, + exit_reason: crate::platform::ChildExitReason, + }, /// A worktree-removal runtime could not be restored normally. WorktreeRuntimeRestoreFailed { pane_id: PaneId, operation_id: u64 }, /// Process detection identified an agent before its screen state was confirmed. diff --git a/src/pane.rs b/src/pane.rs index 83bad900..aa6acb57 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2194,7 +2194,12 @@ impl PaneRuntime { }); let exit_events = events.clone(); let on_reader_exit = Box::new(move || { - let _ = rt.block_on(exit_events.send(AppEvent::PaneDied { pane_id })); + // Imported handoff panes have no child wait handle, so their exit cause is + // unknowable. Checkpoint conservatively; normal autosave settles clean exits. + let _ = rt.block_on(exit_events.send(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Handoff, + })); debug!(pane = pane_id.raw(), "handoff PTY actor exiting"); }); PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig { @@ -2299,16 +2304,24 @@ impl PaneRuntime { crate::logging::pane_spawned(pane_id.raw(), pid); } tokio::task::spawn_blocking(move || { - match child.wait() { + let exit_reason = match child.wait() { Ok(status) => { + let exit_reason = crate::platform::classify_child_exit(&status); let status_text = format!("{status:?}"); crate::logging::pane_exited(pane_id.raw(), &status_text); + exit_reason } - Err(e) => crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string()), - } + Err(e) => { + crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string()); + crate::platform::ChildExitReason::WaitFailed + } + }; child_wait_completed.store(true, Ordering::Release); // Use blocking send — PaneDied is critical, must not be dropped - if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) { + if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { + pane_id, + exit_reason, + })) { error!(pane = pane_id.raw(), err = %e, "failed to send PaneDied event"); } }); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 86a431aa..d48ad4fa 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -25,6 +25,36 @@ pub enum Signal { Kill, } +/// Why a pane runtime ended, before application persistence policy is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChildExitReason { + Exited, + Interrupted, + /// Imported runtimes have no child wait handle in the replacement server. + #[cfg(unix)] + Handoff, + WaitFailed, +} + +impl ChildExitReason { + pub(crate) fn requires_session_checkpoint(self) -> bool { + match self { + Self::Interrupted => true, + #[cfg(unix)] + Self::Handoff => true, + _ => false, + } + } +} + +#[cfg(unix)] +pub(crate) use unix_common::classify_child_exit; + +#[cfg(not(any(unix, windows)))] +pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason { + ChildExitReason::Exited +} + pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command { let mut process = detached_custom_command_process_platform(command); configure_background_command(&mut process); @@ -415,6 +445,25 @@ impl PrefixInputSource for RealPrefixInputSource { } } +#[cfg(all(test, any(unix, windows)))] +#[test] +fn child_exit_classification_only_checkpoints_interruptions() { + for code in [0, 1, 130, 255, 0xC0000005] { + let reason = classify_child_exit(&portable_pty::ExitStatus::with_exit_code(code)); + assert_eq!(reason, ChildExitReason::Exited, "exit code {code:#x}"); + assert!(!reason.requires_session_checkpoint()); + } + #[cfg(windows)] + let status = portable_pty::ExitStatus::with_exit_code(0xC000013A); + #[cfg(not(windows))] + let status = portable_pty::ExitStatus::with_signal("Terminated: 15"); + assert_eq!(classify_child_exit(&status), ChildExitReason::Interrupted); + assert!(classify_child_exit(&status).requires_session_checkpoint()); + #[cfg(unix)] + assert!(ChildExitReason::Handoff.requires_session_checkpoint()); + assert!(!ChildExitReason::WaitFailed.requires_session_checkpoint()); +} + #[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index dff67357..104f6b69 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -1,5 +1,13 @@ use std::path::{Path, PathBuf}; +pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason { + if status.signal().is_some() { + super::ChildExitReason::Interrupted + } else { + super::ChildExitReason::Exited + } +} + pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { use std::os::fd::{AsFd as _, AsRawFd as _}; let crate::ipc::LocalStream::UdSocket(stream) = stream; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index ce0afedb..4deb1864 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,6 +15,15 @@ use std::{ mod clipboard_image; +pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason { + // STATUS_CONTROL_C_EXIT is reported without a Unix signal by portable-pty. + if status.exit_code() == 0xC000013A { + super::ChildExitReason::Interrupted + } else { + super::ChildExitReason::Exited + } +} + pub(crate) fn wait_client_stream_readable( _stream: &crate::ipc::LocalStream, ) -> std::io::Result<()> { diff --git a/src/server/headless.rs b/src/server/headless.rs index abaca0aa..2d652378 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -721,7 +721,7 @@ impl HeadlessServer { // Save session on exit. if self.app.policy.persist_session { - self.app.save_session_now(); + self.app.save_session_on_shutdown(); } info!("headless server exiting"); diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index dd661809..ddf9a9b3 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -663,7 +663,7 @@ impl HeadlessServer { } true } - AppEvent::PaneDied { pane_id } + AppEvent::PaneDied { pane_id, .. } | AppEvent::WorktreeRuntimeRestoreFailed { pane_id, .. } => { let focus_before = self.shell_focus_targets(); let focused_tabs_before = self.focused_shell_tabs(); diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 5e0e5d01..63f7264d 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -3614,7 +3614,12 @@ async fn pane_death_reconciles_each_client_view_and_focus() { server.clients.get_mut(&71).unwrap().outer_terminal_focus = Some(true); server.clients.get_mut(&72).unwrap().outer_terminal_focus = Some(false); - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id: dead_pane })); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::PaneDied { + pane_id: dead_pane, + exit_reason: crate::platform::ChildExitReason::Exited + }) + ); assert_eq!( server.shell_tab_id_for_client(71).as_deref(), @@ -3678,7 +3683,12 @@ async fn pane_death_reapplies_controller_geometry() { let shrunk = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); assert!(shrunk.0 < 46); - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id: dead_pane })); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::PaneDied { + pane_id: dead_pane, + exit_reason: crate::platform::ChildExitReason::Exited + }) + ); let runtime = &server.app.state.workspaces[0].test_runtimes[&first_pane]; let grown = runtime.current_size(); @@ -3858,7 +3868,12 @@ fn expected_worktree_runtime_exit_does_not_release_agent() { .pending_worktree_remove_runtime_exits .insert(pane_id, 1); - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited + }) + ); assert_eq!( server.app.state.terminals[&terminal_id].state, diff --git a/tests/api_ping.rs b/tests/api_ping.rs index fcfaf41f..b86d0378 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -356,6 +356,78 @@ contains = ["server-reload-marker"] cleanup_spawned_herdr(child, base); } +#[cfg(target_os = "linux")] +#[test] +fn shutdown_preserves_session_after_shell_is_signaled() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let mut child = spawn_herdr_with_shell(&config_home, &runtime_dir, &socket_path, "/bin/sh"); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"create","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id"); + let process_info = send_request( + &socket_path, + &format!( + r#"{{"id":"process","method":"pane.process_info","params":{{"pane_id":"{pane_id}"}}}}"# + ), + ); + let shell_pid = process_info["result"]["process_info"]["shell_pid"] + .as_u64() + .expect("shell pid") as libc::pid_t; + + assert_eq!(unsafe { libc::kill(shell_pid, libc::SIGHUP) }, 0); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let panes = send_request( + &socket_path, + r#"{"id":"panes","method":"pane.list","params":{}}"#, + ); + if panes["result"]["panes"] + .as_array() + .is_some_and(Vec::is_empty) + { + break; + } + assert!(Instant::now() < deadline, "signaled pane was not removed"); + thread::sleep(Duration::from_millis(20)); + } + + let stopped = send_request( + &socket_path, + r#"{"id":"stop","method":"server.stop","params":{}}"#, + ); + assert_eq!(stopped["result"]["type"], "ok"); + child.child.wait().expect("server should stop cleanly"); + + let session: serde_json::Value = serde_json::from_slice( + &fs::read(config_home.join("herdr-dev/session.json")).expect("saved session"), + ) + .expect("valid session json"); + assert_eq!(session["workspaces"].as_array().map(Vec::len), Some(1)); + assert_eq!( + session["workspaces"][0]["tabs"][0]["panes"] + .as_object() + .map(serde_json::Map::len), + Some(1) + ); + + cleanup_spawned_herdr(child, base); +} + #[cfg(not(target_os = "macos"))] #[test] fn workspace_list_and_create_round_trip() { diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 3838b888..034e1e89 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -612,6 +612,96 @@ fn live_server_holds_one_pty_master_fd_per_pane() { cleanup_test_base(&base); } +#[cfg(target_os = "linux")] +#[test] +fn live_handoff_unknown_pane_exit_preserves_session_on_shutdown() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + let old_pid = spawned.child.process_id().expect("old server pid"); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + let replacement_pid = + wait_for_replacement_server_pid(&runtime_dir, old_pid, Duration::from_secs(10)); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + + let process_info = request( + &api_socket, + serde_json::json!({ + "id": "test:process-info", + "method": "pane.process_info", + "params": {"pane_id": pane_id} + }), + ); + let shell_pid = process_info["result"]["process_info"]["shell_pid"] + .as_u64() + .expect("shell pid") as libc::pid_t; + assert_eq!(unsafe { libc::kill(shell_pid, libc::SIGHUP) }, 0); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let panes = request( + &api_socket, + serde_json::json!({"id":"test:panes","method":"pane.list","params":{}}), + ); + if panes["result"]["panes"] + .as_array() + .is_some_and(Vec::is_empty) + { + break; + } + assert!(Instant::now() < deadline, "handoff pane was not removed"); + thread::sleep(Duration::from_millis(20)); + } + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + )); + let deadline = Instant::now() + Duration::from_secs(5); + while Path::new(&format!("/proc/{replacement_pid}")).exists() { + assert!(Instant::now() < deadline, "replacement server did not stop"); + thread::sleep(Duration::from_millis(20)); + } + + let session: serde_json::Value = serde_json::from_slice( + &fs::read(config_home.join("herdr-dev/session.json")).expect("saved session"), + ) + .expect("valid session json"); + assert_eq!(session["workspaces"].as_array().map(Vec::len), Some(1)); + assert_eq!( + session["workspaces"][0]["tabs"][0]["panes"] + .as_object() + .map(serde_json::Map::len), + Some(1) + ); + + cleanup_test_base(&base); +} + #[test] fn live_handoff_preserves_named_session_socket_paths() { let _lock = test_lock();