diff options
Diffstat (limited to 'src/persist')
| -rw-r--r-- | src/persist/io.rs | 1 | ||||
| -rw-r--r-- | src/persist/restore.rs | 205 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 173 | ||||
| -rw-r--r-- | src/persist/writer.rs | 239 |
4 files changed, 573 insertions, 45 deletions
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] { |
