mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
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:
co-authored by
Jonathan Liebig
parent
4828181304
commit
e7d8220788
+2
-1
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user