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>
This commit is contained in:
Can Celik
2026-09-07 15:14:46 +02:00
committed by GitHub
co-authored by Jonathan Liebig
parent 4828181304
commit e7d8220788
16 changed files with 467 additions and 23 deletions
+2 -1
View File
@@ -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());
+34 -7
View File
@@ -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(),
+5 -1
View File
@@ -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));
}
+4 -2
View File
@@ -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
+123 -1
View File
@@ -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();
+29
View File
@@ -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) {
+4 -1
View File
@@ -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.
+18 -5
View File
@@ -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");
}
});
+49
View File
@@ -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::*;
+8
View File
@@ -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;
+9
View File
@@ -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<()> {
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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();
+18 -3
View File
@@ -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,
+72
View File
@@ -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() {
+90
View File
@@ -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();