aboutsummaryrefslogtreecommitdiff
path: root/src/persist
diff options
context:
space:
mode:
Diffstat (limited to 'src/persist')
-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
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] {