From 0ff0f27e222633c97ba4291f6b9be4137002ca84 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 21 Sep 2026 00:29:45 +0300 Subject: 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 --- src/api/schema/panes.rs | 2 + src/api/schema/tests.rs | 2 + src/api/server.rs | 1 + src/api/server/subscription_socket_tests.rs | 1 + src/api/subscriptions.rs | 1 + src/app/agent_resume.rs | 58 ++++++- src/app/creation.rs | 1 + src/app/session.rs | 6 +- src/client/shell/tests/mod.rs | 1 + src/pane.rs | 53 ++++++ src/persist/io.rs | 1 + src/persist/restore.rs | 205 ++++++++++++++++++++++-- src/persist/snapshot.rs | 173 ++++++++++++++++++-- src/persist/writer.rs | 239 ++++++++++++++++++++++++++-- src/platform/linux.rs | 3 + src/platform/linux/shutdown.rs | 208 ++++++++++++++++++++++++ src/platform/mod.rs | 30 ++++ src/server/headless.rs | 20 ++- src/server/headless/notifications.rs | 6 + src/server/headless/tests/mod.rs | 32 ++++ src/terminal/runtime.rs | 4 + src/terminal/state.rs | 2 + src/ui/panes.rs | 42 ++++- 23 files changed, 1040 insertions(+), 51 deletions(-) create mode 100644 src/platform/linux/shutdown.rs (limited to 'src') 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, #[serde(default, skip_serializing_if = "Option::is_none")] + pub restore_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, 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; } }; @@ -378,6 +389,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() { 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, reported_cwd: Arc>>, + persistence_cwd: Mutex>, + cwd_process_exited: Arc, child_wait_completed: Option>, kitty_keyboard_flags: Arc, content_seq: Arc, @@ -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 { + 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 { 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)), @@ -4592,6 +4624,23 @@ mod tests { assert!(runtime.handoff_history_ansi().is_none()); } + #[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() { @@ -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, render_dirty: Arc, ) -> RestoreFailures { + 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, ) -> Option { @@ -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); + } } } } @@ -1167,6 +1208,92 @@ mod tests { assert!(restored_terminal_agent_session(Some(&session), true).is_none()); } + #[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(); @@ -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, pub workspaces: Vec, } @@ -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 { + 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 { @@ -593,6 +614,28 @@ mod tests { assert_eq!(active_pane.managed_agent_kind.as_deref(), Some("pi")); } + #[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 { @@ -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::(&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::(&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 { + preserve_existing_in(path, "session-backups", 3) +} + +fn preserve_existing_in(path: &Path, directory_name: &str, keep: usize) -> io::Result { 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 { 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 { 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> { 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, + wake: impl Fn() + Send + Sync + 'static, +) -> Option> { + 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>>, + } + + #[zbus::interface(name = "org.freedesktop.login1.Manager")] + impl LoginManager { + fn inhibit( + &self, + what: &str, + _who: &str, + _why: &str, + mode: &str, + ) -> zbus::fdo::Result { + 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>, +} + +impl HostShutdownMonitor { + pub(crate) fn start( + requested: std::sync::Arc, + 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, + _wake: impl Fn() + Send + Sync + 'static, +) -> Option> { + 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, + host_shutdown_requested: Arc, /// Channel for receiving server events from client connection threads. server_event_rx: mpsc::Receiver, /// 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, @@ -4185,6 +4186,37 @@ fn changed_git_refresh_requests_headless_render() { assert!(changed); } +#[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(); 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 { + self.0.cwd_for_persistence() + } + pub fn follow_cwd(&self) -> Option { 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, agent_process_acquisition_pending: bool, pub pending_agent_resume_plan: Option, + pub restore_error: Option, } 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, + ); } } @@ -847,6 +859,32 @@ mod tests { render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame); } + #[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!( -- cgit v1.2.3