aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-21 00:29:45 +0300
committerGitHub <noreply@github.com>2026-09-21 00:29:45 +0300
commit0ff0f27e222633c97ba4291f6b9be4137002ca84 (patch)
tree97ee77c8a85801ab90133492ddd71b3ce635b8fb /src
parentf090ce95d05d2e7811869be93f9a23bf4ac2c3af (diff)
fix: preserve session layouts across shutdown and restore failures (#4400)
* fix: preserve session layouts across shutdown and restore failures refs #4320 * test: update subscription fixture for pane restore errors --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/panes.rs2
-rw-r--r--src/api/schema/tests.rs2
-rw-r--r--src/api/server.rs1
-rw-r--r--src/api/server/subscription_socket_tests.rs1
-rw-r--r--src/api/subscriptions.rs1
-rw-r--r--src/app/agent_resume.rs58
-rw-r--r--src/app/creation.rs1
-rw-r--r--src/app/session.rs6
-rw-r--r--src/client/shell/tests/mod.rs1
-rw-r--r--src/pane.rs53
-rw-r--r--src/persist/io.rs1
-rw-r--r--src/persist/restore.rs205
-rw-r--r--src/persist/snapshot.rs173
-rw-r--r--src/persist/writer.rs239
-rw-r--r--src/platform/linux.rs3
-rw-r--r--src/platform/linux/shutdown.rs208
-rw-r--r--src/platform/mod.rs30
-rw-r--r--src/server/headless.rs20
-rw-r--r--src/server/headless/notifications.rs6
-rw-r--r--src/server/headless/tests/mod.rs32
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/terminal/state.rs2
-rw-r--r--src/ui/panes.rs42
23 files changed, 1040 insertions, 51 deletions
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index 6cbcaf43..67d5185e 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -535,6 +535,8 @@ pub struct PaneInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground_cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub restore_error: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index d51c565d..4f15b322 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -825,6 +825,7 @@ fn worktree_request_and_response_round_trip() {
focused: true,
cwd: Some("/worktrees/herdr/worktree-api".into()),
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: None,
title: None,
@@ -1253,6 +1254,7 @@ fn create_response_round_trips_with_root_pane() {
focused: false,
cwd: Some("/tmp/review".into()),
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: None,
title: None,
diff --git a/src/api/server.rs b/src/api/server.rs
index 44a2a0df..23817fff 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -1037,6 +1037,7 @@ mod tests {
focused: true,
cwd: None,
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: Some("pi".into()),
title: None,
diff --git a/src/api/server/subscription_socket_tests.rs b/src/api/server/subscription_socket_tests.rs
index 5cfbc706..35b0bf12 100644
--- a/src/api/server/subscription_socket_tests.rs
+++ b/src/api/server/subscription_socket_tests.rs
@@ -224,6 +224,7 @@ fn reply_to_probe(request: ApiRequestMessage) {
focused: true,
cwd: None,
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: Some("pi".into()),
title: None,
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index 5be2d523..2258a3ce 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -701,6 +701,7 @@ mod tests {
focused: true,
cwd: None,
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: None,
title: None,
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
index 2cbd93ab..9bf2157f 100644
--- a/src/app/agent_resume.rs
+++ b/src/app/agent_resume.rs
@@ -233,6 +233,15 @@ impl App {
return false;
};
+ if !cwd.is_dir() {
+ if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
+ terminal.pending_agent_resume_plan = None;
+ terminal.restore_error = Some("Saved directory is unavailable. Restore the directory and restart this session.".into());
+ terminal.revision = terminal.revision.saturating_add(1);
+ }
+ return true;
+ }
+
let runtime = match crate::terminal::TerminalRuntime::spawn(
pane_id,
rows,
@@ -257,9 +266,11 @@ impl App {
"failed to start shell for deferred agent resume"
);
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
- terminal.clear_agent_runtime_identity_after_respawn();
+ terminal.pending_agent_resume_plan = None;
+ terminal.restore_error = Some(format!("Could not start the saved shell: {err}. Fix the shell configuration and restart this session."));
+ terminal.revision = terminal.revision.saturating_add(1);
}
- return false;
+ return true;
}
};
@@ -380,6 +391,49 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
+ async fn failed_deferred_restore_keeps_session_reference_without_retrying_elsewhere() {
+ for missing_shell in [false, true] {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("unavailable");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).unwrap().clone();
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ if missing_shell {
+ app.state.default_shell = "__herdr_missing_resume_shell__".into();
+ }
+ let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
+ if !missing_shell {
+ terminal.cwd = std::env::current_dir()
+ .unwrap()
+ .join("__herdr_missing_resume_cwd__");
+ assert!(!terminal.cwd.exists());
+ }
+ let session = crate::agent_resume::PersistedAgentSession {
+ source: "herdr:codex".into(),
+ agent: "codex".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::id("resume-test").unwrap(),
+ };
+ terminal.persisted_agent_session = Some(session.clone());
+ terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: long_running_test_argv(),
+ dedupe_key: "resume-test".into(),
+ });
+ app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true);
+ assert!(app.terminal_runtimes.get(&terminal_id).is_none());
+ let terminal = &app.state.terminals[&terminal_id];
+ assert!(terminal.pending_agent_resume_plan.is_none());
+ assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&session));
+ assert!(terminal.restore_error.is_some());
+ assert!(!app.has_pending_agent_resumes());
+ assert!(!app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true));
+ }
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
async fn pending_agent_resume_waits_for_host_theme_before_launch() {
let mut app = test_app();
let workspace = crate::workspace::Workspace::test_new("restored");
diff --git a/src/app/creation.rs b/src/app/creation.rs
index af34e540..05736fe5 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -340,6 +340,7 @@ impl App {
foreground_cwd: ws.tabs[tab_idx]
.foreground_cwd_for_pane(pane_id, &self.terminal_runtimes)
.map(|cwd| cwd.display().to_string()),
+ restore_error: terminal.restore_error.clone(),
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
title: presentation.title,
diff --git a/src/app/session.rs b/src/app/session.rs
index 08c4cd44..509d4050 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -49,7 +49,11 @@ impl App {
self.state.selected,
);
let history = self.persist_pane_history.then(|| {
- crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes)
+ crate::persist::capture_history(
+ &snapshot,
+ &self.state.workspaces,
+ &self.terminal_runtimes,
+ )
});
SessionSaveJob::Save { snapshot, history }
}
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index 9f04b527..9d960143 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -183,6 +183,7 @@ fn pane_scroll_result(
focused: true,
cwd: None,
foreground_cwd: None,
+ restore_error: None,
label: None,
agent: None,
title: None,
diff --git a/src/pane.rs b/src/pane.rs
index 3e1c52a4..099580c3 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1255,6 +1255,8 @@ pub struct PaneRuntime {
current_size: Cell<(u16, u16, u32, u32)>,
child_pid: Arc<AtomicU32>,
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
+ persistence_cwd: Mutex<Option<std::path::PathBuf>>,
+ cwd_process_exited: Arc<AtomicBool>,
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
content_seq: Arc<AtomicU64>,
@@ -2304,6 +2306,7 @@ impl PaneRuntime {
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let child_pid = Arc::new(AtomicU32::new(child_pid));
let reported_cwd = Arc::new(Mutex::new(None));
+ let cwd_process_exited = Arc::new(AtomicBool::new(false));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
let content_seq = Arc::new(AtomicU64::new(0));
let content_write_lock = Arc::new(Mutex::new(()));
@@ -2370,7 +2373,9 @@ impl PaneRuntime {
}
});
let exit_events = events.clone();
+ let cwd_process_exited = cwd_process_exited.clone();
let on_reader_exit = Box::new(move || {
+ cwd_process_exited.store(true, Ordering::Release);
// 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 {
@@ -2405,6 +2410,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
child_pid,
reported_cwd,
+ persistence_cwd: Mutex::new(None),
+ cwd_process_exited,
child_wait_completed: None,
kitty_keyboard_flags,
content_seq,
@@ -2981,6 +2988,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, 0, 0)),
child_pid,
reported_cwd,
+ persistence_cwd: Mutex::new(None),
+ cwd_process_exited: child_wait_completed.clone(),
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
content_seq,
@@ -3491,6 +3500,27 @@ impl PaneRuntime {
crate::platform::process_cwd(pid)
}
+ pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> {
+ let pid = self.child_pid.load(Ordering::Acquire);
+ let exited = self.cwd_process_exited.load(Ordering::Acquire);
+ if let Some(cwd) = (!exited)
+ .then(|| crate::platform::process_cwd(pid))
+ .flatten()
+ .filter(|cwd| cwd.is_absolute())
+ {
+ // Persistence observations must not change OSC authority or follow-cwd behavior.
+ if let Ok(mut known) = self.persistence_cwd.lock() {
+ *known = Some(cwd.clone());
+ }
+ return Some(cwd);
+ }
+ self.persistence_cwd
+ .lock()
+ .ok()
+ .and_then(|cwd| cwd.clone())
+ .or_else(|| self.reported_cwd.lock().ok().and_then(|cwd| cwd.clone()))
+ }
+
pub fn child_pid(&self) -> Option<u32> {
let pid = self.child_pid.load(Ordering::Acquire);
(pid > 0).then_some(pid)
@@ -3661,6 +3691,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
reported_cwd: Arc::new(Mutex::new(None)),
+ persistence_cwd: Mutex::new(None),
+ cwd_process_exited: Arc::new(AtomicBool::new(false)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
content_seq: Arc::new(AtomicU64::new(0)),
@@ -4594,6 +4626,23 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
+ async fn ended_handoff_keeps_persistence_cwd_when_pid_is_reused() {
+ let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24);
+ assert!(runtime.child_wait_completed.is_none());
+ let saved = std::env::temp_dir().join("saved-handoff-cwd");
+ *runtime.persistence_cwd.lock().unwrap() = Some(saved.clone());
+ // A different live process now owns the imported shell's numeric PID.
+ runtime
+ .child_pid
+ .store(std::process::id(), Ordering::Release);
+ runtime.cwd_process_exited.store(true, Ordering::Release);
+ assert_eq!(runtime.cwd_for_persistence(), Some(saved));
+ *runtime.persistence_cwd.lock().unwrap() = None;
+ assert_eq!(runtime.cwd_for_persistence(), None);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
async fn handoff_runtime_state_captures_terminal_input_and_title_state() {
let runtime = PaneRuntime::test_with_screen_bytes(
80,
@@ -4749,6 +4798,8 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
+ cwd_process_exited: Arc::new(AtomicBool::new(false)),
+ persistence_cwd: Mutex::new(None),
pane_id,
terminal,
io: PaneRuntimeIo::TestChannel {
@@ -4786,6 +4837,8 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
+ cwd_process_exited: Arc::new(AtomicBool::new(false)),
+ persistence_cwd: Mutex::new(None),
pane_id,
terminal,
io: PaneRuntimeIo::TestChannel {
diff --git a/src/persist/io.rs b/src/persist/io.rs
index 6f5ed459..b483b264 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -198,6 +198,7 @@ mod tests {
fn history_snapshot(secret: &str) -> SessionHistorySnapshot {
SessionHistorySnapshot {
version: SNAPSHOT_VERSION,
+ layout_fingerprint: None,
workspaces: vec![WorkspaceHistorySnapshot {
tabs: vec![TabHistorySnapshot {
panes: std::collections::HashMap::from([(
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 3c5775c9..31ee0a37 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -267,6 +267,14 @@ fn restore_with_imports_and_failures(
render_notify: Arc<Notify>,
render_dirty: Arc<RenderSignal>,
) -> RestoreFailures<RestoredSession> {
+ let history = history.filter(|history| {
+ let matches = history.layout_fingerprint.is_some()
+ && history.layout_fingerprint == super::snapshot::layout_fingerprint(snapshot);
+ if !matches {
+ tracing::warn!("Ignoring pane history without a matching session layout");
+ }
+ matches
+ });
let mut workspaces = Vec::new();
let mut terminals = HashMap::new();
let mut terminal_runtimes = HashMap::new();
@@ -433,6 +441,34 @@ fn restore_workspace(
)
}
+fn unavailable_restored_terminal(
+ pane: Option<&super::snapshot::PaneSnapshot>,
+ cwd: PathBuf,
+ reason: String,
+) -> TerminalState {
+ warn!(cwd = %cwd.display(), reason = %reason, "preserving unavailable restored pane");
+ let mut terminal = TerminalState::new(TerminalId::alloc(), cwd);
+ terminal.restore_error = Some(reason);
+ if let Some(pane) = pane {
+ terminal.manual_label = pane.label.clone();
+ terminal.launch_argv = pane.launch_argv.clone();
+ if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) {
+ terminal.set_persisted_agent_session(session);
+ }
+ match (
+ pane.agent_name.as_ref(),
+ pane.managed_agent_kind
+ .as_deref()
+ .and_then(crate::detect::parse_canonical_agent_label),
+ ) {
+ (Some(name), Some(agent)) => terminal.restore_managed_agent(name.clone(), agent),
+ (Some(name), None) => terminal.set_agent_name(name.clone()),
+ _ => {}
+ }
+ }
+ terminal
+}
+
fn restored_worktree_space_membership(
space: Option<crate::workspace::WorktreeSpaceMembership>,
) -> Option<crate::workspace::WorktreeSpaceMembership> {
@@ -473,22 +509,19 @@ fn restore_tab(
.map(|p| p.cwd.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let cwd = if saved_cwd.exists() {
- saved_cwd
- } else {
- warn!(
- cwd = %saved_cwd.display(),
- "saved pane cwd does not exist, falling back to HOME"
+ let cwd = saved_cwd;
+ let has_import = old_id.is_some_and(|old_id| imported_panes.contains_key(old_id));
+ if !has_import && !cwd.is_dir() {
+ let terminal = unavailable_restored_terminal(
+ saved_pane,
+ cwd,
+ "Saved directory is unavailable. Restore the directory and restart this session."
+ .into(),
);
- let home = std::env::var("HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| PathBuf::from("/"));
- if home.exists() {
- home
- } else {
- PathBuf::from("/")
- }
- };
+ panes.insert(*id, PaneState::new(terminal.id.clone()));
+ terminals.push(terminal);
+ continue;
+ }
let saved_label = saved_pane.and_then(|p| p.label.clone());
let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone());
@@ -681,8 +714,16 @@ fn restore_tab(
tab = ?snap.custom_name,
pane_id = id.raw(),
err = %e,
- "failed to restore pane, skipping"
+ "failed to restore pane"
);
+ if !was_imported {
+ let terminal = unavailable_restored_terminal(
+ saved_pane, cwd,
+ format!("Could not start the saved shell: {e}. Fix the shell configuration and restart this session."),
+ );
+ panes.insert(*id, PaneState::new(terminal.id.clone()));
+ terminals.push(terminal);
+ }
}
}
}
@@ -1168,6 +1209,92 @@ mod tests {
}
#[tokio::test]
+ async fn failed_cold_restore_preserves_panes_and_saved_directories() {
+ for missing_shell in [false, true] {
+ let mut snapshot: SessionSnapshot = serde_json::from_str(include_str!(
+ "../../tests/fixtures/session/current-herdr-session.json"
+ ))
+ .unwrap();
+ let cwd = std::env::current_dir().unwrap();
+ let missing = cwd.join("__herdr_missing_restore_directory__");
+ assert!(!missing.exists());
+ for workspace in &mut snapshot.workspaces {
+ workspace.identity_cwd = cwd.clone();
+ for tab in &mut workspace.tabs {
+ for pane in tab.panes.values_mut() {
+ pane.cwd = cwd.clone();
+ }
+ }
+ }
+ let failed = snapshot.workspaces[0].tabs[0].panes.get_mut(&1).unwrap();
+ failed.cwd = missing.clone();
+ failed.label = Some("keep my pane".into());
+ failed.agent_session = Some(super::super::snapshot::PaneAgentSessionSnapshot {
+ source: "herdr:opencode".into(),
+ agent: "opencode".into(),
+ kind: crate::agent_resume::AgentSessionRefKind::Id,
+ value: "keep-my-session".into(),
+ });
+ let (events, _rx) = mpsc::channel(32);
+ let (workspaces, terminals, runtimes) = restore(
+ &snapshot,
+ None,
+ 24,
+ 80,
+ 0,
+ if missing_shell {
+ "__herdr_missing_restore_shell__"
+ } else {
+ test_restore_shell()
+ },
+ crate::config::ShellModeConfig::NonLogin,
+ false,
+ events,
+ Arc::new(Notify::new()),
+ Arc::new(RenderSignal::new()),
+ );
+ let runtimes = crate::terminal::TerminalRuntimeRegistry::from(runtimes);
+ let captured = crate::persist::capture(&workspaces, &terminals, &runtimes, Some(0), 0);
+ assert_eq!(
+ captured.workspaces.len(),
+ 2,
+ "a launch failure must not delete a workspace"
+ );
+ assert_eq!(captured.workspaces[0].tabs.len(), 2);
+ let pane = captured.workspaces[0].tabs[0]
+ .panes
+ .values()
+ .next()
+ .unwrap();
+ assert_eq!(
+ pane.cwd, missing,
+ "fallback cwd must not replace saved intent"
+ );
+ assert_eq!(pane.label.as_deref(), Some("keep my pane"));
+ assert_eq!(
+ pane.agent_session.as_ref().unwrap().value,
+ "keep-my-session"
+ );
+ let root = workspaces[0].tabs[0].root_pane;
+ let terminal_id = workspaces[0].tabs[0].terminal_id(root).unwrap();
+ assert!(
+ runtimes.get(terminal_id).is_none(),
+ "do not open a replacement shell elsewhere"
+ );
+ let healthy = workspaces[1].tabs[0]
+ .terminal_id(workspaces[1].tabs[0].root_pane)
+ .unwrap();
+ assert_eq!(runtimes.get(healthy).is_some(), !missing_shell);
+ assert!(terminals[terminal_id].restore_error.is_some());
+ let mut state = crate::app::AppState::test_new();
+ state.workspaces = workspaces;
+ state.terminals = terminals;
+ state.active = Some(0);
+ state.assert_invariants_for_test();
+ }
+ }
+
+ #[tokio::test]
async fn restore_carries_persisted_agent_session_metadata() {
let cwd = std::env::current_dir().unwrap();
let snapshot = SessionSnapshot {
@@ -1658,6 +1785,48 @@ mod tests {
let _ = runtime.try_send_bytes(bytes::Bytes::from_static(b"exit\n"));
}
+ #[tokio::test]
+ async fn restore_rejects_history_from_another_layout_or_without_provenance() {
+ for legacy in [false, true] {
+ let (mut snapshot, history) = snapshot_with_saved_pane_history();
+ let mut value = serde_json::to_value(history).unwrap();
+ if legacy {
+ value.as_object_mut().unwrap().remove("layout_fingerprint");
+ } else {
+ snapshot.workspaces[0].tabs[0]
+ .panes
+ .get_mut(&0)
+ .unwrap()
+ .cwd = std::env::temp_dir();
+ }
+ let history = serde_json::from_value(value).unwrap();
+ let (events, _rx) = mpsc::channel(8);
+ let (_, _, runtimes) = restore(
+ &snapshot,
+ Some(&history),
+ 5,
+ 80,
+ 4096,
+ test_restore_shell(),
+ crate::config::ShellModeConfig::NonLogin,
+ false,
+ events,
+ Arc::new(Notify::new()),
+ Arc::new(RenderSignal::new()),
+ );
+ let runtime = runtimes.values().next().unwrap();
+ assert!(
+ !runtime
+ .recent_unwrapped_text(10)
+ .contains("RESTORED_HISTORY"),
+ "screen history must belong to the exact saved layout"
+ );
+ for (_, runtime) in runtimes {
+ runtime.shutdown();
+ }
+ }
+ }
+
fn snapshot_with_saved_pane_history() -> (SessionSnapshot, SessionHistorySnapshot) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let mut panes = HashMap::new();
@@ -1672,8 +1841,9 @@ mod tests {
launch_argv: None,
},
);
- let history = SessionHistorySnapshot {
+ let mut history = SessionHistorySnapshot {
version: super::super::snapshot::SNAPSHOT_VERSION,
+ layout_fingerprint: None,
workspaces: vec![WorkspaceHistorySnapshot {
tabs: vec![super::super::snapshot::TabHistorySnapshot {
panes: HashMap::from([(
@@ -1717,6 +1887,7 @@ mod tests {
sidebar_section_split: Some(0.5),
collapsed_space_keys: Default::default(),
};
+ history.layout_fingerprint = super::super::snapshot::layout_fingerprint(&snapshot);
(snapshot, history)
}
}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index 07aaf3b9..d7db3e97 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -33,6 +33,8 @@ pub struct SessionHistorySnapshot {
/// Format version follows the matching session snapshot version.
#[serde(default)]
pub version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub layout_fingerprint: Option<String>,
pub workspaces: Vec<WorkspaceHistorySnapshot>,
}
@@ -281,12 +283,20 @@ fn capture_workspace(
>,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> WorkspaceSnapshot {
+ let tabs: Vec<_> = ws
+ .tabs
+ .iter()
+ .map(|tab| capture_tab(tab, terminals, terminal_runtimes))
+ .collect();
+ let identity_cwd = tabs
+ .first()
+ .and_then(|tab| tab.root_pane.and_then(|id| tab.panes.get(&id)))
+ .map(|pane| pane.cwd.clone())
+ .unwrap_or_else(|| ws.identity_cwd.clone());
WorkspaceSnapshot {
id: Some(ws.id.clone()),
custom_name: ws.custom_name.clone(),
- identity_cwd: ws
- .resolved_identity_cwd_from(terminals, terminal_runtimes)
- .unwrap_or_else(|| ws.identity_cwd.clone()),
+ identity_cwd,
worktree_space: ws.worktree_space.clone(),
public_pane_numbers: ws
.public_pane_numbers
@@ -296,11 +306,7 @@ fn capture_workspace(
next_public_pane_number: ws.next_public_pane_number,
public_tab_numbers: ws.tabs.iter().map(|tab| tab.number).collect(),
next_public_tab_number: ws.next_public_tab_number,
- tabs: ws
- .tabs
- .iter()
- .map(|tab| capture_tab(tab, terminals, terminal_runtimes))
- .collect(),
+ tabs,
active_tab: ws.active_tab,
}
}
@@ -315,13 +321,13 @@ fn capture_tab(
) -> TabSnapshot {
let mut panes = HashMap::new();
for id in tab.panes.keys() {
- let cwd = tab
- .cwd_for_pane(*id, terminals, terminal_runtimes)
+ let terminal_id = tab.terminal_id(*id);
+ let terminal = terminal_id.and_then(|id| terminals.get(id));
+ let cwd = terminal_id
+ .and_then(|id| terminal_runtimes.get(id))
+ .and_then(|runtime| runtime.cwd_for_persistence())
+ .or_else(|| terminal.map(|terminal| terminal.cwd.clone()))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let terminal = tab
- .panes
- .get(id)
- .and_then(|pane| terminals.get(&pane.attached_terminal_id));
let label = terminal.and_then(|terminal| terminal.manual_label.clone());
let (agent_name, managed_agent_kind) = terminal
.filter(|terminal| !terminal.managed_agent_launch_pending())
@@ -378,13 +384,27 @@ fn capture_tab(
}
}
+pub(super) fn layout_fingerprint(snapshot: &SessionSnapshot) -> Option<String> {
+ use sha2::{Digest, Sha256};
+
+ let mut value = serde_json::to_value(snapshot).ok()?;
+ // Sets serialize as arrays; normalize their order as well as JSON object keys.
+ let mut collapsed: Vec<_> = snapshot.collapsed_space_keys.iter().collect();
+ collapsed.sort_unstable();
+ value["collapsed_space_keys"] = serde_json::to_value(collapsed).ok()?;
+ let bytes = serde_json::to_vec(&value).ok()?;
+ Some(format!("{:x}", Sha256::digest(bytes)))
+}
+
/// Capture pane screen history separately from the structural session snapshot.
pub fn capture_history(
+ snapshot: &SessionSnapshot,
workspaces: &[Workspace],
terminal_runtimes: &TerminalRuntimeRegistry,
) -> SessionHistorySnapshot {
SessionHistorySnapshot {
version: SNAPSHOT_VERSION,
+ layout_fingerprint: layout_fingerprint(snapshot),
workspaces: workspaces
.iter()
.map(|workspace| WorkspaceHistorySnapshot {
@@ -546,7 +566,8 @@ mod tests {
state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> SessionHistorySnapshot {
- capture_history(&state.workspaces, terminal_runtimes)
+ let snapshot = capture_from_state_with_runtimes(state, terminal_runtimes);
+ capture_history(&snapshot, &state.workspaces, terminal_runtimes)
}
fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> {
@@ -594,6 +615,28 @@ mod tests {
}
#[test]
+ fn layout_fingerprint_survives_json_round_trip() {
+ let mut snapshot = parse_snapshot(include_str!(
+ "../../tests/fixtures/session/current-herdr-session.json"
+ ))
+ .unwrap();
+ snapshot.collapsed_space_keys = ["z", "a", "m"].map(String::from).into();
+ let expected = layout_fingerprint(&snapshot).unwrap();
+ for _ in 0..16 {
+ snapshot = parse_snapshot(&serde_json::to_string(&snapshot).unwrap()).unwrap();
+ assert_eq!(
+ layout_fingerprint(&snapshot).as_deref(),
+ Some(expected.as_str())
+ );
+ }
+ snapshot.workspaces.swap(0, 1);
+ assert_ne!(
+ layout_fingerprint(&snapshot).as_deref(),
+ Some(expected.as_str())
+ );
+ }
+
+ #[test]
fn round_trip_empty_session() {
let snap = SessionSnapshot {
version: SNAPSHOT_VERSION,
@@ -1002,6 +1045,104 @@ mod tests {
assert_eq!(workspace.next_public_tab_number, 3);
}
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn capture_prefers_live_shell_cwd_and_keeps_it_after_exit() {
+ let old = std::env::current_dir().unwrap();
+ let new = std::env::temp_dir().join(format!(
+ "herdr-persist-cwd-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir(&new).unwrap();
+ let new = std::fs::canonicalize(new).unwrap();
+ let mut state = AppState::test_new();
+ state.workspaces = vec![Workspace::test_new("cwd-source")];
+ state.workspaces[0].identity_cwd = old.clone();
+ state.active = Some(0);
+ state.ensure_test_terminals();
+ let pane_id = state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = state.workspaces[0].terminal_id(pane_id).unwrap().clone();
+ let (events, _rx) = tokio::sync::mpsc::channel(32);
+ let runtime = crate::terminal::TerminalRuntime::spawn(
+ pane_id,
+ 24,
+ 80,
+ old.clone(),
+ 0,
+ Default::default(),
+ None,
+ crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
+ events,
+ std::sync::Arc::new(tokio::sync::Notify::new()),
+ std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
+ )
+ .unwrap();
+ let pid = runtime.child_pid().unwrap();
+ runtime
+ .try_send_bytes(bytes::Bytes::from(format!(
+ "cd '{}'; printf '\\033]7;file://{}\\007'; exec sleep 30\n",
+ new.display(),
+ old.display()
+ )))
+ .unwrap();
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while (crate::platform::process_cwd(pid).as_ref() != Some(&new)
+ || runtime.cwd().as_ref() != Some(&old))
+ && std::time::Instant::now() < deadline
+ {
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
+ assert_eq!(crate::platform::process_cwd(pid), Some(new.clone()));
+ assert_eq!(
+ runtime.cwd(),
+ Some(old.clone()),
+ "existing reported-cwd accessor is unchanged"
+ );
+ let mut runtimes = TerminalRuntimeRegistry::new();
+ runtimes.insert(terminal_id, runtime);
+ let before = capture_from_state_with_runtimes(&state, &runtimes);
+ assert_eq!(
+ before.workspaces[0].tabs[0]
+ .panes
+ .values()
+ .next()
+ .unwrap()
+ .cwd,
+ new
+ );
+ assert_eq!(before.workspaces[0].identity_cwd, new);
+ assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old.clone()));
+ crate::platform::signal_processes(&[pid], crate::platform::Signal::Kill);
+ let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while crate::platform::process_cwd(pid).is_some()
+ && std::time::Instant::now() < exit_deadline
+ {
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
+ assert!(crate::platform::process_cwd(pid).is_none());
+ let after = capture_from_state_with_runtimes(&state, &runtimes);
+ assert_eq!(
+ after.workspaces[0].tabs[0]
+ .panes
+ .values()
+ .next()
+ .unwrap()
+ .cwd,
+ new
+ );
+ assert_eq!(after.workspaces[0].identity_cwd, new);
+ assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old));
+ for (_, runtime) in runtimes.drain() {
+ runtime.shutdown();
+ }
+ std::fs::remove_dir(new).unwrap();
+ }
+
#[test]
fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
let mut state = state_with_workspaces(&["one"]);
@@ -1199,7 +1340,7 @@ mod tests {
}
#[test]
- fn restore_falls_back_to_home_when_cwd_missing() {
+ fn snapshot_parsing_preserves_missing_cwd() {
let mut panes = HashMap::new();
panes.insert(
0,
diff --git a/src/persist/writer.rs b/src/persist/writer.rs
index 48bbc496..be87a490 100644
--- a/src/persist/writer.rs
+++ b/src/persist/writer.rs
@@ -26,20 +26,31 @@ impl SessionWriter {
Ok(())
}
+ fn preserve_snapshot_history(&self) {
+ if let Err(err) = preserve_snapshot_history(&self.path) {
+ tracing::warn!(
+ event = "persist.snapshot", outcome = "error", path = %self.path.display(),
+ err = %err, "failed to preserve session snapshot"
+ );
+ }
+ }
+
pub(crate) fn save(
&mut self,
snapshot: &SessionSnapshot,
history: Option<&SessionHistorySnapshot>,
) {
- let result = self
- .preserve_unloaded()
- .and_then(|()| super::io::save_to_path(&self.path, snapshot));
+ let result = self.preserve_unloaded().and_then(|()| {
+ self.preserve_snapshot_history();
+ super::io::save_to_path(&self.path, snapshot)
+ });
if let Err(err) = result {
crate::logging::session_save_failed(&self.path, &err.to_string());
return;
}
// Optional history failure must not reclassify our committed layout as unloaded.
self.protect_unloaded = false;
+ self.preserve_snapshot_history();
let history_path = self.path.with_file_name("session-history.json");
if let Err(err) = super::io::save_history_to_path(&history_path, history) {
crate::logging::session_save_failed(&history_path, &err.to_string());
@@ -48,9 +59,10 @@ impl SessionWriter {
}
pub(crate) fn clear(&mut self) {
- let result = self
- .preserve_unloaded()
- .and_then(|()| super::io::clear_path(&self.path));
+ let result = self.preserve_unloaded().and_then(|()| {
+ self.preserve_snapshot_history();
+ super::io::clear_path(&self.path)
+ });
if let Err(err) = result {
crate::logging::session_clear_failed(&self.path, &err.to_string());
return;
@@ -63,7 +75,55 @@ impl SessionWriter {
}
}
+const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
+const SNAPSHOT_LIMIT: usize = 48;
+
+fn preserve_snapshot_history(path: &Path) -> io::Result<()> {
+ let directory = path.with_file_name("session-snapshots");
+ let existing = match recovery_files(&directory) {
+ Ok(files) => files,
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(),
+ Err(err) => return Err(err),
+ };
+ if let Some((_, latest)) = existing.last() {
+ let modified = std::fs::metadata(latest)?.modified()?;
+ if SystemTime::now()
+ .duration_since(modified)
+ .is_ok_and(|age| age < SNAPSHOT_INTERVAL)
+ {
+ return Ok(());
+ }
+ }
+ let bytes = match std::fs::read(path) {
+ Ok(bytes) => bytes,
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
+ Err(err) => return Err(err),
+ };
+ let Ok(snapshot) = serde_json::from_slice::<SessionSnapshot>(&bytes) else {
+ return Ok(());
+ };
+ if snapshot.version > super::snapshot::SNAPSHOT_VERSION || snapshot.workspaces.is_empty() {
+ return Ok(());
+ }
+ if let Some((_, latest)) = existing.last() {
+ let previous_bytes = std::fs::read(latest)?;
+ if let Ok(previous) = serde_json::from_slice::<SessionSnapshot>(&previous_bytes) {
+ if super::snapshot::layout_fingerprint(&snapshot).is_some_and(|fingerprint| {
+ super::snapshot::layout_fingerprint(&previous).as_ref() == Some(&fingerprint)
+ }) {
+ return Ok(());
+ }
+ }
+ }
+ preserve_existing_in(path, "session-snapshots", SNAPSHOT_LIMIT)?;
+ Ok(())
+}
+
fn preserve_existing(path: &Path) -> io::Result<bool> {
+ preserve_existing_in(path, "session-backups", 3)
+}
+
+fn preserve_existing_in(path: &Path, directory_name: &str, keep: usize) -> io::Result<bool> {
let mut source = match File::open(path) {
Ok(file) => file,
// Recheck on the next mutation until a fresh session is actually saved.
@@ -73,7 +133,7 @@ fn preserve_existing(path: &Path) -> io::Result<bool> {
if !source.metadata()?.is_file() {
return Err(io::Error::other("session path is not a regular file"));
}
- let directory = path.with_file_name("session-backups");
+ let directory = path.with_file_name(directory_name);
std::fs::create_dir_all(&directory)?;
let older = recovery_files(&directory)?;
let now = SystemTime::now()
@@ -105,9 +165,13 @@ fn preserve_existing(path: &Path) -> io::Result<bool> {
outcome = "ok",
path = %path.display(),
backup_path = %backup.display(),
- "preserved unloaded session before replacement"
+ "preserved session recovery copy"
);
- if let Err(err) = prune_backups(&older) {
+ if let Err(err) = prune_backups(&older, keep) {
+ if directory_name == "session-snapshots" {
+ std::fs::remove_file(&backup)?;
+ return Err(err);
+ }
tracing::warn!(
event = "persist.backup", subsystem = "persist", outcome = "prune_error",
path = %directory.display(), err = %err, "failed to prune session recovery copies"
@@ -168,10 +232,23 @@ fn recovery_files(directory: &Path) -> io::Result<Vec<(u128, PathBuf)>> {
Ok(files)
}
-fn prune_backups(older: &[(u128, PathBuf)]) -> io::Result<()> {
+fn prune_backups(older: &[(u128, PathBuf)], keep: usize) -> io::Result<()> {
// The new copy is durable before any of the previous copies are removed.
- for (_, path) in older.iter().take(older.len().saturating_sub(2)) {
- std::fs::remove_file(path)?;
+ let mut remaining = older.len().saturating_sub(keep.saturating_sub(1));
+ let mut failure = None;
+ for (_, path) in older {
+ if remaining == 0 {
+ return Ok(());
+ }
+ match std::fs::remove_file(path) {
+ Ok(()) => remaining -= 1,
+ Err(err) => failure = Some(err),
+ }
+ }
+ if remaining > 0 {
+ if let Some(err) = failure {
+ return Err(err);
+ }
}
Ok(())
}
@@ -234,6 +311,144 @@ mod tests {
.collect()
}
+ fn snapshots(writer: &SessionWriter) -> Vec<(u128, PathBuf)> {
+ recovery_files(&writer.path.with_file_name("session-snapshots")).unwrap()
+ }
+
+ #[test]
+ fn snapshot_survives_exit_bursts_clears_and_writer_restarts() {
+ let mut writer = writer(false);
+ let original = snapshot();
+ writer.save(&original, None);
+ let files = snapshots(&writer);
+ assert_eq!(files.len(), 1);
+ let saved = std::fs::read(&files[0].1).unwrap();
+ for i in 0..100 {
+ let mut shrinking = snapshot();
+ shrinking.workspaces[0].custom_name = Some(format!("remaining pane {i}"));
+ writer.save(&shrinking, None);
+ writer = SessionWriter {
+ path: writer.path.clone(),
+ protect_unloaded: false,
+ };
+ }
+ writer.clear();
+ assert!(
+ !writer.path.exists(),
+ "intentional clear must still persist"
+ );
+ assert_eq!(snapshots(&writer), files);
+ assert_eq!(std::fs::read(&files[0].1).unwrap(), saved);
+ assert!(backups(&writer).is_empty());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn snapshot_history_is_bounded_and_does_not_rotate_identical_layouts() {
+ let mut writer = writer(false);
+ let directory = writer.path.with_file_name("session-snapshots");
+ std::fs::create_dir(&directory).unwrap();
+ let manual = directory.join("my-layout.json");
+ std::fs::write(&manual, b"manual").unwrap();
+ for i in 0..SNAPSHOT_LIMIT {
+ std::fs::write(
+ directory.join(format!("session-{i:039}-1-0.json")),
+ b"old snapshot",
+ )
+ .unwrap();
+ }
+ for (_, path) in snapshots(&writer) {
+ File::options()
+ .write(true)
+ .open(path)
+ .unwrap()
+ .set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH))
+ .unwrap();
+ }
+ writer.save(&snapshot(), None);
+ assert_eq!(snapshots(&writer).len(), SNAPSHOT_LIMIT);
+ assert!(!directory
+ .join(format!("session-{:039}-1-0.json", 0))
+ .exists());
+ assert!(manual.exists());
+
+ for (_, path) in snapshots(&writer) {
+ std::fs::remove_file(path).unwrap();
+ }
+ let old = directory.join(format!("session-{:039}-1-0.json", 1));
+ let saved = std::fs::read(&writer.path).unwrap();
+ let reordered: serde_json::Value = serde_json::from_slice(&saved).unwrap();
+ let equivalent = serde_json::to_vec(&reordered).unwrap();
+ assert_ne!(saved, equivalent);
+ std::fs::write(&old, equivalent).unwrap();
+ File::options()
+ .write(true)
+ .open(&old)
+ .unwrap()
+ .set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH))
+ .unwrap();
+ writer.save(&snapshot(), None);
+ assert_eq!(snapshots(&writer), vec![(1, old)]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn snapshot_cadence_recovers_after_clock_rollback_and_restart() {
+ let mut writer = writer(false);
+ writer.save(&snapshot(), None);
+ let file = snapshots(&writer).pop().unwrap().1;
+ File::options()
+ .write(true)
+ .open(&file)
+ .unwrap()
+ .set_times(
+ std::fs::FileTimes::new()
+ .set_modified(SystemTime::now() + std::time::Duration::from_secs(86400)),
+ )
+ .unwrap();
+ let mut changed = snapshot();
+ changed.workspaces[0].custom_name = Some("after clock rollback".into());
+ writer.save(&changed, None);
+ assert_eq!(snapshots(&writer).len(), 2);
+ writer = SessionWriter {
+ path: writer.path.clone(),
+ protect_unloaded: false,
+ };
+ changed.workspaces[0].custom_name = Some("after restart".into());
+ writer.save(&changed, None);
+ assert_eq!(
+ snapshots(&writer).len(),
+ 2,
+ "new mtime restores cadence across restart"
+ );
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn pruning_continues_past_an_undeletable_entry() {
+ let writer = writer(false);
+ let locked = writer.path.with_file_name("undeletable");
+ std::fs::create_dir(&locked).unwrap();
+ let removable = writer.path.with_file_name("removable");
+ std::fs::write(&removable, b"old").unwrap();
+ assert!(prune_backups(&[(1, locked.clone()), (2, removable.clone())], 2).is_ok());
+ assert!(locked.exists());
+ assert!(!removable.exists());
+ assert!(prune_backups(&[(1, locked)], 1).is_err());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn snapshot_failure_does_not_block_primary_save_and_clear() {
+ let mut writer = writer(false);
+ std::fs::write(writer.path.with_file_name("session-snapshots"), b"blocked").unwrap();
+ writer.save(&snapshot(), None);
+ assert!(writer.path.exists());
+ writer.clear();
+ assert!(!writer.path.exists());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
#[test]
fn healthy_and_fresh_sessions_save_and_clear_without_backups() {
for protect_unloaded in [false, true] {
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 7dd2879e..c73584e5 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -26,6 +26,9 @@ pub(crate) use super::unix_common::{
#[cfg(test)]
mod config_file_tests;
+mod shutdown;
+pub(crate) use shutdown::monitor_host_shutdown;
+
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION";
const CHILD_GROUPS_SCAN_LIMIT: usize = 64;
diff --git a/src/platform/linux/shutdown.rs b/src/platform/linux/shutdown.rs
new file mode 100644
index 00000000..b678161a
--- /dev/null
+++ b/src/platform/linux/shutdown.rs
@@ -0,0 +1,208 @@
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use futures_util::StreamExt;
+
+pub(crate) fn monitor_host_shutdown(
+ requested: Arc<AtomicBool>,
+ wake: impl Fn() + Send + Sync + 'static,
+) -> Option<tokio::task::JoinHandle<()>> {
+ Some(tokio::spawn(async move {
+ let mut retry = Duration::from_secs(1);
+ loop {
+ match watch_shutdown(&requested, &wake).await {
+ Ok(()) => retry = Duration::from_secs(1),
+ Err(err) => {
+ tracing::debug!(err = %err, retry_seconds = retry.as_secs(), "host shutdown notification unavailable");
+ tokio::time::sleep(retry).await;
+ retry = (retry * 2).min(Duration::from_secs(60));
+ }
+ }
+ }
+ }))
+}
+
+async fn watch_shutdown(
+ requested: &AtomicBool,
+ wake: &(impl Fn() + Send + Sync),
+) -> zbus::Result<()> {
+ let connection = zbus::Connection::system().await?;
+ watch_connection(connection, requested, wake).await
+}
+
+async fn watch_connection(
+ connection: zbus::Connection,
+ requested: &AtomicBool,
+ wake: &(impl Fn() + Send + Sync),
+) -> zbus::Result<()> {
+ let manager = zbus::Proxy::new(
+ &connection,
+ "org.freedesktop.login1",
+ "/org/freedesktop/login1",
+ "org.freedesktop.login1.Manager",
+ )
+ .await?;
+ let mut owners = manager.receive_owner_changed().await?;
+ let mut signals = manager.receive_signal("PrepareForShutdown").await?;
+ let inhibitor: zbus::zvariant::OwnedFd = manager
+ .call(
+ "Inhibit",
+ &(
+ "shutdown",
+ "Herdr",
+ "Save terminal workspace layout",
+ "delay",
+ ),
+ )
+ .await?;
+ tracing::debug!("host shutdown notification ready");
+
+ let mut preparing: bool = manager.get_property("PreparingForShutdown").await?;
+ loop {
+ if preparing {
+ tracing::info!("host shutdown requested; preserving session before pane termination");
+ requested.store(true, Ordering::Release);
+ wake();
+ // Keep the delay lock until the server has saved and drops its monitor.
+ // logind caps the delay even if the server gets stuck.
+ std::future::pending::<()>().await;
+ }
+ tokio::select! {
+ biased;
+ _ = owners.next() => break,
+ signal = signals.next() => {
+ let Some(signal) = signal else { break };
+ preparing = signal.body().deserialize()?;
+ }
+ }
+ }
+ drop(inhibitor);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{BufRead, Read};
+ use std::os::unix::net::UnixStream;
+ use std::process::{Child, Command, Stdio};
+ use std::sync::Mutex;
+
+ struct PrivateBus(Child);
+
+ impl Drop for PrivateBus {
+ fn drop(&mut self) {
+ let _ = self.0.kill();
+ let _ = self.0.wait();
+ }
+ }
+
+ struct LoginManager {
+ preparing: bool,
+ peer: Arc<Mutex<Option<UnixStream>>>,
+ }
+
+ #[zbus::interface(name = "org.freedesktop.login1.Manager")]
+ impl LoginManager {
+ fn inhibit(
+ &self,
+ what: &str,
+ _who: &str,
+ _why: &str,
+ mode: &str,
+ ) -> zbus::fdo::Result<zbus::zvariant::OwnedFd> {
+ assert_eq!((what, mode), ("shutdown", "delay"));
+ let (lock, peer) = UnixStream::pair().unwrap();
+ peer.set_nonblocking(true).unwrap();
+ *self.peer.lock().unwrap() = Some(peer);
+ Ok(std::os::fd::OwnedFd::from(lock).into())
+ }
+
+ #[zbus(property)]
+ fn preparing_for_shutdown(&self) -> bool {
+ self.preparing
+ }
+ }
+
+ #[tokio::test]
+ #[ignore = "requires dbus-daemon; uses a private bus, never requests host shutdown"]
+ async fn shutdown_warning_holds_inhibitor_until_monitor_is_dropped() {
+ for already_preparing in [false, true] {
+ let mut bus = PrivateBus(
+ Command::new("dbus-daemon")
+ .args(["--session", "--nofork", "--print-address=1"])
+ .stdout(Stdio::piped())
+ .spawn()
+ .unwrap(),
+ );
+ let mut address = String::new();
+ std::io::BufReader::new(bus.0.stdout.take().unwrap())
+ .read_line(&mut address)
+ .unwrap();
+ let peer = Arc::new(Mutex::new(None));
+ let service = zbus::connection::Builder::address(address.trim())
+ .unwrap()
+ .name("org.freedesktop.login1")
+ .unwrap()
+ .serve_at(
+ "/org/freedesktop/login1",
+ LoginManager {
+ preparing: already_preparing,
+ peer: peer.clone(),
+ },
+ )
+ .unwrap()
+ .build()
+ .await
+ .unwrap();
+ let client = zbus::connection::Builder::address(address.trim())
+ .unwrap()
+ .build()
+ .await
+ .unwrap();
+ let requested = Arc::new(AtomicBool::new(false));
+ let wake = Arc::new(tokio::sync::Notify::new());
+ let task = tokio::spawn({
+ let requested = requested.clone();
+ let wake = wake.clone();
+ async move {
+ watch_connection(client, &requested, &move || wake.notify_one())
+ .await
+ .unwrap();
+ }
+ });
+ tokio::time::timeout(Duration::from_secs(5), async {
+ while peer.lock().unwrap().is_none() {
+ tokio::task::yield_now().await;
+ }
+ if !already_preparing {
+ assert!(!requested.load(Ordering::Acquire));
+ service
+ .emit_signal(
+ None::<&str>,
+ "/org/freedesktop/login1",
+ "org.freedesktop.login1.Manager",
+ "PrepareForShutdown",
+ &true,
+ )
+ .await
+ .unwrap();
+ }
+ wake.notified().await;
+ })
+ .await
+ .unwrap();
+ assert!(requested.load(Ordering::Acquire));
+ let mut peer = peer.lock().unwrap().take().unwrap();
+ assert_eq!(
+ peer.read(&mut [0]).unwrap_err().kind(),
+ std::io::ErrorKind::WouldBlock,
+ "shutdown must remain inhibited while the server saves"
+ );
+ task.abort();
+ assert!(task.await.unwrap_err().is_cancelled());
+ assert_eq!(peer.read(&mut [0]).unwrap(), 0);
+ }
+ }
+}
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 2ba3e6f3..488c5bee 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -3,6 +3,36 @@
//! Centralizes OS-dependent behavior behind a clean boundary so core
//! modules don't scatter `#[cfg]` branches through product logic.
+pub(crate) struct HostShutdownMonitor {
+ task: Option<tokio::task::JoinHandle<()>>,
+}
+
+impl HostShutdownMonitor {
+ pub(crate) fn start(
+ requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
+ wake: impl Fn() + Send + Sync + 'static,
+ ) -> Self {
+ let task = monitor_host_shutdown(requested, wake);
+ Self { task }
+ }
+}
+
+impl Drop for HostShutdownMonitor {
+ fn drop(&mut self) {
+ if let Some(task) = self.task.take() {
+ task.abort();
+ }
+ }
+}
+
+#[cfg(not(target_os = "linux"))]
+fn monitor_host_shutdown(
+ _requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
+ _wake: impl Fn() + Send + Sync + 'static,
+) -> Option<tokio::task::JoinHandle<()>> {
+ None
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundProcess {
pub pid: u32,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index f07c0c37..638559ad 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -250,6 +250,7 @@ pub struct HeadlessServer {
pending_handoff_repaint_nudge: bool,
/// Flag set by Ctrl+C or `server stop` signal.
should_quit: Arc<AtomicBool>,
+ host_shutdown_requested: Arc<AtomicBool>,
/// Channel for receiving server events from client connection threads.
server_event_rx: mpsc::Receiver<ServerEvent>,
/// Sender for server events (cloned for each client thread).
@@ -373,6 +374,7 @@ impl HeadlessServer {
headless_size,
effective_size: headless_size,
shutting_down: false,
+ host_shutdown_requested: Arc::new(AtomicBool::new(false)),
handoff_in_progress: false,
#[cfg(unix)]
pending_handoff_repaint_nudge: false,
@@ -398,6 +400,13 @@ impl HeadlessServer {
let should_quit = self.should_quit.clone();
let quit_notify = self.server_event_tx.clone();
ctrlc_handler(should_quit, quit_notify);
+ let quit_notify = self.server_event_tx.clone();
+ let _host_shutdown = crate::platform::HostShutdownMonitor::start(
+ self.host_shutdown_requested.clone(),
+ move || {
+ let _ = quit_notify.try_send(ServerEvent::QuitSignal);
+ },
+ );
let mut needs_render = true;
let mut needs_full_render = true;
@@ -414,6 +423,13 @@ impl HeadlessServer {
break;
}
+ // A host shutdown warning precedes process termination. Do not drain pane
+ // deaths here: logind's delay lock stays held until the final session save.
+ if self.host_shutdown_requested.load(Ordering::Acquire) {
+ self.initiate_shutdown();
+ continue;
+ }
+
// Check if we should start shutting down.
if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
self.drain_internal_events_with_forwarding_up_to(
@@ -638,7 +654,9 @@ impl HeadlessServer {
}
};
- if self.should_quit.load(Ordering::Acquire) {
+ if self.should_quit.load(Ordering::Acquire)
+ || self.host_shutdown_requested.load(Ordering::Acquire)
+ {
match event {
LoopEvent::Internal(ev) => {
self.handle_internal_event_with_forwarding(ev);
diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs
index 39938b74..722f95c5 100644
--- a/src/server/headless/notifications.rs
+++ b/src/server/headless/notifications.rs
@@ -321,6 +321,9 @@ impl HeadlessServer {
///
/// Returns true if the event changed visual state (requiring a re-render).
pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool {
+ if self.host_shutdown_requested.load(Ordering::Acquire) {
+ return false;
+ }
let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev {
result
.api_request
@@ -752,6 +755,9 @@ impl HeadlessServer {
let mut had_event = false;
let mut changed = false;
for _ in 0..limit {
+ if self.host_shutdown_requested.load(Ordering::Acquire) {
+ break;
+ }
let Ok(ev) = self.app.event_rx.try_recv() else {
break;
};
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index 60f89d63..cf6d389b 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -96,6 +96,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ
headless_size,
effective_size: headless_size,
shutting_down: false,
+ host_shutdown_requested: Arc::new(AtomicBool::new(false)),
handoff_in_progress: false,
#[cfg(unix)]
pending_handoff_repaint_nudge: false,
@@ -4186,6 +4187,37 @@ fn changed_git_refresh_requests_headless_render() {
}
#[tokio::test]
+async fn host_shutdown_preserves_panes_from_queued_and_selected_death_events() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("host-shutdown");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ let event = || AppEvent::PaneDied {
+ pane_id,
+ exit_reason: crate::platform::ChildExitReason::Exited,
+ };
+ server.app.event_tx.try_send(event()).unwrap();
+ server
+ .host_shutdown_requested
+ .store(true, Ordering::Release);
+ assert_eq!(
+ server.drain_internal_events_with_forwarding_up_to(16),
+ (false, false)
+ );
+ assert!(!server.handle_internal_event_with_forwarding(event()));
+ assert!(server.app.find_pane(pane_id).is_some());
+ assert!(server.app.event_rx.try_recv().is_ok());
+ server
+ .host_shutdown_requested
+ .store(false, Ordering::Release);
+ assert!(server.handle_internal_event_with_forwarding(event()));
+ assert!(server.app.find_pane(pane_id).is_none());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
async fn pane_death_reconciles_each_client_view_and_focus() {
let mut server = test_headless_server();
let mut workspace = crate::workspace::Workspace::test_new("pane-death-views");
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index a10bec78..7d5542a6 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -562,6 +562,10 @@ impl TerminalRuntime {
self.0.cwd()
}
+ pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> {
+ self.0.cwd_for_persistence()
+ }
+
pub fn follow_cwd(&self) -> Option<std::path::PathBuf> {
self.0.follow_cwd()
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 7ceaede7..c084b034 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -148,6 +148,7 @@ pub struct TerminalState {
recent_agent_process_exit: Option<RecentAgentProcessExit>,
agent_process_acquisition_pending: bool,
pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>,
+ pub restore_error: Option<String>,
}
impl TerminalState {
@@ -183,6 +184,7 @@ impl TerminalState {
recent_agent_process_exit: None,
agent_process_acquisition_pending: false,
pending_agent_resume_plan: None,
+ restore_error: None,
}
}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index ab3e352a..be9df8da 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -2,7 +2,7 @@ use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
- widgets::{Block, Borders},
+ widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
@@ -392,9 +392,10 @@ pub(super) fn render_panes(
pane_infos: &[PaneInfo],
split_borders: &[crate::layout::SplitBorder],
) {
- let Some(ws_idx) = target.map(|target| target.workspace_index) else {
+ let Some(target) = target else {
return;
};
+ let ws_idx = target.workspace_index;
let Some(ws) = app.workspaces.get(ws_idx) else {
return;
};
@@ -406,6 +407,17 @@ pub(super) fn render_panes(
&& app.pane_exposes_host_cursor(ws_idx, info.id);
rt.render(frame, info.inner_rect, show_cursor);
render_pane_scrollbar(app, frame, info, rt);
+ } else if let Some(reason) = ws
+ .tabs
+ .get(target.tab_index)
+ .and_then(|tab| tab.terminal_id(info.id))
+ .and_then(|id| app.terminals.get(id))
+ .and_then(|terminal| terminal.restore_error.as_deref())
+ {
+ frame.render_widget(
+ Paragraph::new(reason).wrap(Wrap { trim: false }),
+ info.inner_rect,
+ );
}
}
@@ -848,6 +860,32 @@ mod tests {
}
#[test]
+ fn unavailable_pane_renders_restore_failure_without_a_runtime() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("unavailable")];
+ app.active = Some(0);
+ app.ensure_test_terminals();
+ let pane_id = app.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.workspaces[0].terminal_id(pane_id).unwrap().clone();
+ app.terminals.get_mut(&terminal_id).unwrap().restore_error =
+ Some("Saved directory is unavailable. Restart to retry.".into());
+ let (buffer, cursor, _, _) = crate::server::render_stream::render_tab_surface_virtual(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index: 0,
+ tab_index: 0,
+ }),
+ Rect::new(0, 0, 80, 24),
+ false,
+ Default::default(),
+ );
+ let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
+ assert!(text.contains("Saved directory is unavailable."));
+ assert!(cursor.is_none_or(|cursor| !cursor.visible));
+ }
+
+ #[test]
fn pane_border_title_trims_and_truncates() {
assert_eq!(
pane_border_title(" claude ", 20, false).as_deref(),