aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs10
-rw-r--r--src/app/session.rs25
-rw-r--r--src/persist.rs4
-rw-r--r--src/persist/io.rs83
-rw-r--r--src/persist/snapshot.rs8
-rw-r--r--src/persist/writer.rs461
6 files changed, 523 insertions, 68 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 4ac109c7..757a7f66 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -138,6 +138,7 @@ pub struct App {
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
+ session_writer: Arc<std::sync::Mutex<crate::persist::SessionWriter>>,
pane_exit_checkpoint_pending: bool,
pub(crate) detached_process_children: Vec<std::process::Child>,
tab_bar_status_generation: u64,
@@ -370,9 +371,11 @@ impl App {
// Try to restore previous session
let mut restored_terminals = std::collections::HashMap::new();
let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (workspaces, active, selected) = if !policy.restore_session {
- (Vec::new(), None, 0)
- } else if let Some(snap) = crate::persist::load() {
+ let snapshot = policy.restore_session.then(crate::persist::load).flatten();
+ let session_writer = Arc::new(std::sync::Mutex::new(crate::persist::SessionWriter::new(
+ policy.restore_session && snapshot.is_none(),
+ )));
+ let (workspaces, active, selected) = if let Some(snap) = snapshot {
let history = config
.experimental
.pane_history
@@ -599,6 +602,7 @@ impl App {
pending_agent_resume_deadline: None,
session_save_deadline: None,
session_save_thread: None,
+ session_writer,
pane_exit_checkpoint_pending: false,
detached_process_children: Vec::new(),
tab_bar_status_generation: 0,
diff --git a/src/app/session.rs b/src/app/session.rs
index 49dca914..08c4cd44 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -70,14 +70,15 @@ impl App {
let job = self.capture_session_save_job();
self.pane_exit_checkpoint_pending = false;
self.session_save_deadline = None;
+ let writer = self.session_writer.clone();
match std::thread::Builder::new()
.name("herdr-session-save".into())
- .spawn(move || run_session_save_job(job))
+ .spawn(move || run_session_save_job(job, &writer))
{
Ok(thread) => self.session_save_thread = Some(thread),
Err(err) => {
tracing::warn!(err = %err, "failed to spawn session save thread; saving inline");
- run_session_save_job(self.capture_session_save_job());
+ run_session_save_job(self.capture_session_save_job(), &self.session_writer);
}
}
}
@@ -92,7 +93,7 @@ impl App {
return;
}
- run_session_save_job(self.capture_session_save_job());
+ run_session_save_job(self.capture_session_save_job(), &self.session_writer);
self.pane_exit_checkpoint_pending = false;
self.session_save_deadline = None;
}
@@ -124,11 +125,19 @@ impl App {
}
}
-fn run_session_save_job(job: SessionSaveJob) {
- match job {
- SessionSaveJob::Clear => crate::persist::clear(),
- SessionSaveJob::Save { snapshot, history } => {
- crate::persist::save(&snapshot, history.as_ref());
+fn run_session_save_job(
+ job: SessionSaveJob,
+ writer: &std::sync::Mutex<crate::persist::SessionWriter>,
+) {
+ let mut writer = match writer.lock() {
+ Ok(writer) => writer,
+ Err(err) => {
+ tracing::warn!(err = %err, "session writer is poisoned; refusing to modify session");
+ return;
}
+ };
+ match job {
+ SessionSaveJob::Clear => writer.clear(),
+ SessionSaveJob::Save { snapshot, history } => writer.save(&snapshot, history.as_ref()),
}
}
diff --git a/src/persist.rs b/src/persist.rs
index 822af263..c95355e6 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -8,8 +8,9 @@ mod io;
pub mod plugin_registry;
mod restore;
mod snapshot;
+mod writer;
-pub use self::io::{clear, clear_history, load, load_history, save};
+pub use self::io::{clear_history, load, load_history};
pub use self::restore::restore;
#[cfg(unix)]
pub use self::restore::{handoff_pane_aliases, restore_handoff};
@@ -17,3 +18,4 @@ pub use self::snapshot::{
capture, capture_history, DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot,
SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
};
+pub(crate) use self::writer::SessionWriter;
diff --git a/src/persist/io.rs b/src/persist/io.rs
index 3344f5ec..6f5ed459 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -7,7 +7,7 @@ use super::snapshot::{
SessionSnapshot, SNAPSHOT_VERSION,
};
-fn session_path() -> PathBuf {
+pub(super) fn session_path() -> PathBuf {
crate::session::data_dir().join("session.json")
}
@@ -60,19 +60,14 @@ fn save_json_to_path<T: serde::Serialize>(path: &Path, snapshot: &T) -> std::io:
Ok(())
}
-pub(super) fn save_to_paths(
- session_path: &Path,
- history_path: &Path,
- snapshot: &SessionSnapshot,
+pub(super) fn save_history_to_path(
+ path: &Path,
history: Option<&SessionHistorySnapshot>,
) -> std::io::Result<()> {
- save_to_path(session_path, snapshot)?;
- if let Some(history) = history {
- save_json_to_path(history_path, history)?;
- } else {
- clear_path(history_path)?;
+ match history {
+ Some(history) => save_json_to_path(path, history),
+ None => clear_path(path),
}
- Ok(())
}
pub(super) fn clear_path(path: &Path) -> std::io::Result<()> {
@@ -83,26 +78,6 @@ pub(super) fn clear_path(path: &Path) -> std::io::Result<()> {
}
}
-pub fn save(snapshot: &SessionSnapshot, history: Option<&SessionHistorySnapshot>) {
- let path = session_path();
- let history_path = session_history_path();
- if let Err(err) = save_to_paths(&path, &history_path, snapshot, history) {
- crate::logging::session_save_failed(&path, &err.to_string());
- return;
- }
- crate::logging::session_saved(&path, snapshot.workspaces.len());
-}
-
-pub fn clear() {
- let path = session_path();
- if let Err(err) = clear_path(&path) {
- crate::logging::session_clear_failed(&path, &err.to_string());
- return;
- }
- clear_history();
- crate::logging::session_cleared(&path);
-}
-
pub fn clear_history() {
let path = session_history_path();
if let Err(err) = clear_path(&path) {
@@ -112,13 +87,20 @@ pub fn clear_history() {
pub fn load() -> Option<SessionSnapshot> {
let path = session_path();
- if !path.exists() {
- return None;
- }
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+ tracing::info!(
+ event = "persist.restore", subsystem = "persist", outcome = "missing",
+ path = %path.display(), "session file is missing"
+ );
+ return None;
+ }
Err(err) => {
- warn!(err = %err, "failed to read session file");
+ warn!(
+ event = "persist.restore", subsystem = "persist", outcome = "read_error",
+ path = %path.display(), err = %err, "failed to read session file"
+ );
return None;
}
};
@@ -128,14 +110,17 @@ pub fn load() -> Option<SessionSnapshot> {
if let Some(version) = snapshot_file_version(&content) {
if version > SNAPSHOT_VERSION {
warn!(
- file_version = version,
- supported = SNAPSHOT_VERSION,
+ event = "persist.restore", subsystem = "persist", outcome = "unsupported_version",
+ path = %path.display(), file_version = version, supported = SNAPSHOT_VERSION,
"session file is from a newer herdr version, ignoring"
);
return None;
}
}
- warn!(err = %err, "failed to parse session file, ignoring");
+ warn!(
+ event = "persist.restore", subsystem = "persist", outcome = "parse_error",
+ path = %path.display(), err = %err, "failed to parse session file, ignoring"
+ );
None
}
}
@@ -231,13 +216,8 @@ mod tests {
fn save_to_paths_writes_pane_history_only_to_history_file() {
let (session_path, history_path) = temp_session_paths("split-history");
- save_to_paths(
- &session_path,
- &history_path,
- &empty_snapshot(),
- Some(&history_snapshot("split-secret")),
- )
- .unwrap();
+ save_to_path(&session_path, &empty_snapshot()).unwrap();
+ save_history_to_path(&history_path, Some(&history_snapshot("split-secret"))).unwrap();
let session = std::fs::read_to_string(&session_path).unwrap();
let history = std::fs::read_to_string(&history_path).unwrap();
@@ -249,15 +229,10 @@ mod tests {
#[test]
fn save_to_paths_removes_stale_history_when_history_is_disabled() {
let (session_path, history_path) = temp_session_paths("clear-history");
- save_to_paths(
- &session_path,
- &history_path,
- &empty_snapshot(),
- Some(&history_snapshot("stale-secret")),
- )
- .unwrap();
-
- save_to_paths(&session_path, &history_path, &empty_snapshot(), None).unwrap();
+ save_to_path(&session_path, &empty_snapshot()).unwrap();
+ save_history_to_path(&history_path, Some(&history_snapshot("stale-secret"))).unwrap();
+
+ save_history_to_path(&history_path, None).unwrap();
assert!(session_path.exists());
assert!(!history_path.exists());
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index e6ca70f9..07aaf3b9 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -468,9 +468,13 @@ pub(super) fn parse_history_snapshot(content: &str) -> Result<SessionHistorySnap
}
pub(super) fn snapshot_file_version(content: &str) -> Option<u32> {
- serde_json::from_str::<RawSessionSnapshot>(content)
+ #[derive(Deserialize)]
+ struct Header {
+ version: u32,
+ }
+ serde_json::from_str::<Header>(content)
.ok()
- .map(|raw| raw.version)
+ .map(|header| header.version)
}
#[cfg(test)]
diff --git a/src/persist/writer.rs b/src/persist/writer.rs
new file mode 100644
index 00000000..48bbc496
--- /dev/null
+++ b/src/persist/writer.rs
@@ -0,0 +1,461 @@
+use std::fs::File;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use super::{SessionHistorySnapshot, SessionSnapshot};
+
+/// Shared by autosave, pane-exit checkpoints, and shutdown.
+pub(crate) struct SessionWriter {
+ path: PathBuf,
+ protect_unloaded: bool,
+}
+
+impl SessionWriter {
+ pub(crate) fn new(protect_unloaded: bool) -> Self {
+ Self {
+ path: super::io::session_path(),
+ protect_unloaded,
+ }
+ }
+
+ fn preserve_unloaded(&mut self) -> io::Result<()> {
+ if self.protect_unloaded && preserve_existing(&self.path)? {
+ self.protect_unloaded = false;
+ }
+ Ok(())
+ }
+
+ 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));
+ 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;
+ 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());
+ }
+ crate::logging::session_saved(&self.path, snapshot.workspaces.len());
+ }
+
+ pub(crate) fn clear(&mut self) {
+ let result = self
+ .preserve_unloaded()
+ .and_then(|()| super::io::clear_path(&self.path));
+ if let Err(err) = result {
+ crate::logging::session_clear_failed(&self.path, &err.to_string());
+ return;
+ }
+ let history_path = self.path.with_file_name("session-history.json");
+ if let Err(err) = super::io::clear_path(&history_path) {
+ crate::logging::session_clear_failed(&history_path, &err.to_string());
+ }
+ crate::logging::session_cleared(&self.path);
+ }
+}
+
+fn preserve_existing(path: &Path) -> 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.
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
+ Err(err) => return Err(err),
+ };
+ 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");
+ std::fs::create_dir_all(&directory)?;
+ let older = recovery_files(&directory)?;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ // Keep creation order even when the wall clock moves backwards.
+ let timestamp = match older.last() {
+ Some((previous, _)) => now.max(
+ previous
+ .checked_add(1)
+ .ok_or_else(|| io::Error::other("session recovery sequence exhausted"))?,
+ ),
+ None => now,
+ };
+ for sequence in 0..128 {
+ let backup = directory.join(format!(
+ "session-{timestamp:039}-{}-{sequence}.json",
+ std::process::id()
+ ));
+ match copy_recovery(&mut source, &backup) {
+ Ok(()) => {}
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ }
+ tracing::info!(
+ event = "persist.backup",
+ subsystem = "persist",
+ outcome = "ok",
+ path = %path.display(),
+ backup_path = %backup.display(),
+ "preserved unloaded session before replacement"
+ );
+ if let Err(err) = prune_backups(&older) {
+ tracing::warn!(
+ event = "persist.backup", subsystem = "persist", outcome = "prune_error",
+ path = %directory.display(), err = %err, "failed to prune session recovery copies"
+ );
+ }
+ return Ok(true);
+ }
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "could not allocate session recovery copy",
+ ))
+}
+
+fn copy_recovery(source: &mut impl io::Read, backup: &Path) -> io::Result<()> {
+ let directory = backup.parent().unwrap_or_else(|| Path::new("."));
+ let pending = backup.with_extension("pending");
+ let mut output = crate::platform::create_config_temporary(&pending, true)?;
+ let mut published = false;
+ let result = (|| {
+ match std::fs::symlink_metadata(backup) {
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {}
+ Err(err) => return Err(err),
+ Ok(_) => {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "recovery copy already exists",
+ ))
+ }
+ }
+ io::copy(source, &mut output)?;
+ output.sync_all()?;
+ drop(output);
+ std::fs::rename(&pending, backup)?;
+ published = true;
+ crate::platform::sync_parent_directory(directory)?;
+ crate::platform::sync_parent_directory(directory.parent().unwrap_or_else(|| Path::new(".")))
+ })();
+ if result.is_err() {
+ let _ = std::fs::remove_file(pending);
+ if published {
+ let _ = std::fs::remove_file(backup);
+ }
+ }
+ result
+}
+
+fn recovery_files(directory: &Path) -> io::Result<Vec<(u128, PathBuf)>> {
+ let mut files = Vec::new();
+ for entry in std::fs::read_dir(directory)? {
+ let entry = entry?;
+ if entry.file_type()?.is_file() {
+ if let Some(timestamp) = entry.file_name().to_str().and_then(recovery_timestamp) {
+ files.push((timestamp, entry.path()));
+ }
+ }
+ }
+ files.sort();
+ Ok(files)
+}
+
+fn prune_backups(older: &[(u128, PathBuf)]) -> 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)?;
+ }
+ Ok(())
+}
+
+fn recovery_timestamp(name: &str) -> Option<u128> {
+ let fields = name.strip_prefix("session-")?.strip_suffix(".json")?;
+ let fields: Vec<_> = fields.split('-').collect();
+ if fields.len() == 3
+ && fields[0].len() == 39
+ && fields
+ .iter()
+ .all(|field| !field.is_empty() && field.bytes().all(|byte| byte.is_ascii_digit()))
+ {
+ fields[0].parse().ok()
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn writer(protect_unloaded: bool) -> SessionWriter {
+ let directory = std::env::temp_dir().join(format!(
+ "herdr-session-recovery-{}-{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&directory).unwrap();
+ SessionWriter {
+ path: directory.join("session.json"),
+ protect_unloaded,
+ }
+ }
+
+ fn snapshot() -> SessionSnapshot {
+ serde_json::from_str(include_str!(
+ "../../tests/fixtures/session/current-herdr-session.json"
+ ))
+ .unwrap()
+ }
+
+ fn backups(writer: &SessionWriter) -> Vec<Vec<u8>> {
+ let directory = writer.path.with_file_name("session-backups");
+ if !directory.exists() {
+ return Vec::new();
+ }
+ let mut entries: Vec<_> = std::fs::read_dir(directory)
+ .unwrap()
+ .map(|entry| entry.unwrap().path())
+ .collect();
+ entries.sort();
+ entries
+ .into_iter()
+ .map(|path| std::fs::read(path).unwrap())
+ .collect()
+ }
+
+ #[test]
+ fn healthy_and_fresh_sessions_save_and_clear_without_backups() {
+ for protect_unloaded in [false, true] {
+ let mut writer = writer(protect_unloaded);
+ if !protect_unloaded {
+ super::super::io::save_to_path(&writer.path, &snapshot()).unwrap();
+ }
+ writer.save(&snapshot(), None);
+ assert!(!writer.protect_unloaded);
+ assert!(writer.path.exists());
+ writer.save(&snapshot(), None);
+ writer.clear();
+ assert!(!writer.path.exists());
+ assert!(backups(&writer).is_empty());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+ }
+
+ #[test]
+ fn failed_recovery_blocks_mutations_then_retries_preserving_exact_bytes_once() {
+ let original = b"invalid utf8 \xff";
+ let mut writer = writer(true);
+ std::fs::write(&writer.path, original).unwrap();
+ let history_path = writer.path.with_file_name("session-history.json");
+ std::fs::write(&history_path, b"history").unwrap();
+ let directory = writer.path.with_file_name("session-backups");
+ std::fs::write(&directory, b"blocks recovery").unwrap();
+ writer.save(&snapshot(), None);
+ writer.clear();
+ assert!(writer.protect_unloaded);
+ assert_eq!(std::fs::read(&writer.path).unwrap(), original);
+ assert_eq!(std::fs::read(&history_path).unwrap(), b"history");
+
+ std::fs::remove_file(&directory).unwrap();
+ writer.save(&snapshot(), None);
+ assert!(!writer.protect_unloaded);
+ writer.save(&snapshot(), None);
+ writer.clear();
+ assert!(!writer.path.exists());
+ assert_eq!(backups(&writer), vec![original.to_vec()]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn optional_history_failure_does_not_block_later_layout_saves() {
+ let mut writer = writer(true);
+ let history = writer.path.with_file_name("session-history.json");
+ std::fs::create_dir(&history).unwrap();
+ std::fs::write(
+ writer.path.with_file_name("session-backups"),
+ b"unavailable",
+ )
+ .unwrap();
+ writer.save(&snapshot(), None);
+ assert!(
+ !writer.protect_unloaded,
+ "structural session was saved successfully"
+ );
+ let mut changed = snapshot();
+ changed.workspaces[0].custom_name = Some("latest layout".into());
+ writer.save(&changed, None);
+ let saved: SessionSnapshot =
+ serde_json::from_slice(&std::fs::read(&writer.path).unwrap()).unwrap();
+ assert_eq!(
+ saved.workspaces[0].custom_name.as_deref(),
+ Some("latest layout")
+ );
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn pruning_leaves_user_named_recovery_files_alone() {
+ let mut writer = writer(true);
+ let directory = writer.path.with_file_name("session-backups");
+ std::fs::create_dir(&directory).unwrap();
+ let manual = directory.join("session-000-manual.json");
+ std::fs::write(&manual, b"manual recovery copy").unwrap();
+ for i in 0..5u8 {
+ writer.protect_unloaded = true;
+ std::fs::write(&writer.path, [i]).unwrap();
+ writer.save(&snapshot(), None);
+ }
+ assert_eq!(std::fs::read(manual).unwrap(), b"manual recovery copy");
+ assert_eq!(std::fs::read_dir(directory).unwrap().count(), 4);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn first_clear_preserves_an_unloaded_file_even_after_an_earlier_missing_clear() {
+ let mut writer = writer(true);
+ writer.clear();
+ assert!(writer.protect_unloaded);
+ std::fs::write(&writer.path, b"late layout").unwrap();
+ writer.clear();
+ assert!(!writer.path.exists());
+ assert_eq!(backups(&writer), vec![b"late layout".to_vec()]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn repeated_failed_saves_do_not_replace_a_completed_recovery_copy() {
+ let mut writer = writer(true);
+ std::fs::write(&writer.path, b"original").unwrap();
+ let temporary = writer.path.with_extension("json.tmp");
+ std::fs::create_dir(&temporary).unwrap();
+ writer.save(&snapshot(), None);
+ writer.save(&snapshot(), None);
+ assert_eq!(std::fs::read(&writer.path).unwrap(), b"original");
+ std::fs::remove_dir(&temporary).unwrap();
+ writer.save(&snapshot(), None);
+ assert_eq!(backups(&writer), vec![b"original".to_vec()]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn interrupted_copy_is_not_published_as_a_recovery_file() {
+ use std::io::Read;
+ struct Interrupted;
+ impl Read for Interrupted {
+ fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
+ panic!("interrupt the copy after writing its prefix");
+ }
+ }
+ let writer = writer(true);
+ let backup = writer
+ .path
+ .with_file_name("session-000000000000000000000000000000000000001-1-0.json");
+ let mut source = io::Cursor::new(b"partial").chain(Interrupted);
+ assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ copy_recovery(&mut source, &backup)
+ }))
+ .is_err());
+ assert!(
+ !backup.exists(),
+ "an interrupted copy must not look complete"
+ );
+ assert_eq!(
+ std::fs::read(backup.with_extension("pending")).unwrap(),
+ b"partial"
+ );
+ assert!(recovery_files(writer.path.parent().unwrap())
+ .unwrap()
+ .is_empty());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn recovery_order_survives_clock_rollback() {
+ let mut writer = writer(true);
+ let directory = writer.path.with_file_name("session-backups");
+ std::fs::create_dir(&directory).unwrap();
+ let future = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ + 1_000_000_000_000_000;
+ for i in 0..2u8 {
+ std::fs::write(
+ directory.join(format!("session-{:039}-1-0.json", future + u128::from(i))),
+ [i],
+ )
+ .unwrap();
+ }
+ for i in 2..4u8 {
+ writer.protect_unloaded = true;
+ std::fs::write(&writer.path, [i]).unwrap();
+ writer.save(&snapshot(), None);
+ }
+ assert_eq!(backups(&writer), vec![vec![1], vec![2], vec![3]]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn recovery_keeps_three_copies_and_healthy_saves_do_not_rotate_them() {
+ let mut writer = writer(true);
+ for i in 0..5u8 {
+ writer.protect_unloaded = true;
+ std::fs::write(&writer.path, [i]).unwrap();
+ writer.save(&snapshot(), None);
+ }
+ assert_eq!(backups(&writer), vec![vec![2], vec![3], vec![4]]);
+ writer.save(&snapshot(), None);
+ writer.clear();
+ assert_eq!(backups(&writer), vec![vec![2], vec![3], vec![4]]);
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn dangling_symlink_allows_first_save_and_late_target_is_preserved() {
+ use std::os::unix::{fs::symlink, fs::PermissionsExt};
+ for late_target in [false, true] {
+ let mut writer = writer(true);
+ let target = writer.path.with_file_name("target.json");
+ symlink("target.json", &writer.path).unwrap();
+ if late_target {
+ std::fs::write(&target, b"late layout").unwrap();
+ }
+ writer.save(&snapshot(), None);
+ assert!(std::fs::symlink_metadata(&writer.path)
+ .unwrap()
+ .file_type()
+ .is_symlink());
+ assert!(target.exists());
+ if late_target {
+ assert_eq!(backups(&writer), vec![b"late layout".to_vec()]);
+ let backup = std::fs::read_dir(writer.path.with_file_name("session-backups"))
+ .unwrap()
+ .next()
+ .unwrap()
+ .unwrap()
+ .path();
+ assert_eq!(
+ std::fs::metadata(backup).unwrap().permissions().mode() & 0o777,
+ 0o600
+ );
+ } else {
+ assert!(backups(&writer).is_empty());
+ }
+ writer.clear();
+ assert!(target.exists());
+ std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
+ }
+ }
+}