diff options
| -rw-r--r-- | docs/next/website/src/content/docs/ja/session-state.mdx | 4 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/session-state.mdx | 4 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/zh-cn/session-state.mdx | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 10 | ||||
| -rw-r--r-- | src/app/session.rs | 25 | ||||
| -rw-r--r-- | src/persist.rs | 4 | ||||
| -rw-r--r-- | src/persist/io.rs | 83 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 8 | ||||
| -rw-r--r-- | src/persist/writer.rs | 461 | ||||
| -rw-r--r-- | tests/cli/sessions.rs | 69 |
10 files changed, 604 insertions, 68 deletions
diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index ed370715..b2d182b0 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -34,6 +34,10 @@ Herdr サーバーが停止して再起動すると、元のペインのプロã‚ スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。 +`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません。 + +復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。 + ## ペイン画面履歴のリプレイ ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。 diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 51ac8f2b..38712689 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -32,6 +32,10 @@ If the Herdr server stops and starts again, the original pane processes are gone Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories. +If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies. + +Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history. + ## Pane screen history replay Pane screen history restores recent terminal contents after a full server restart without restoring the old process. diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index b4f955c6..9747e2b6 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -34,6 +34,10 @@ herdr 快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。 +如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本。 + +Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。 + ## 窗格屏幕历史回放 窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。 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(); + } + } +} diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 5a887698..717f02ef 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -661,6 +661,75 @@ fn server_stop_then_restart_restores_pane_history() { } #[test] +fn unloaded_session_survives_autosave_and_shutdown_when_recovery_is_blocked() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let data_dir = config_home.join(app_dir_name()); + fs::create_dir_all(&data_dir).unwrap(); + let session_path = data_dir.join("session.json"); + let original = b"{unreadable layout"; + fs::write(&session_path, original).unwrap(); + fs::write(data_dir.join("session-backups"), b"blocks recovery").unwrap(); + + let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(wait_until( + Duration::from_secs(10), + Duration::from_millis(25), + || { + fs::read_to_string(data_dir.join("herdr-server.log")) + .is_ok_and(|log| log.contains("event=\"persist.save\"")) + } + )); + assert_eq!(fs::read(&session_path).unwrap(), original); + + assert!(run_cli(&socket_path, &["server", "stop"]).status.success()); + let pid = herdr.child.process_id(); + assert!(herdr.child.wait().unwrap().success()); + unregister_spawned_herdr_pid(pid); + assert_eq!(fs::read(&session_path).unwrap(), original); + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn session_appearing_after_startup_is_preserved_before_autosave() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let data_dir = config_home.join(app_dir_name()); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + // The server has already evaluated restore, but has not created any layout. + let original = include_bytes!("../fixtures/session/current-herdr-session.json"); + fs::write(data_dir.join("session.json"), original).unwrap(); + run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(wait_until( + Duration::from_secs(10), + Duration::from_millis(25), + || { + fs::read_to_string(data_dir.join("herdr-server.log")) + .is_ok_and(|log| log.contains("event=\"persist.save\"")) + } + )); + let backups: Vec<_> = fs::read_dir(data_dir.join("session-backups")) + .expect("late session must be preserved before autosave") + .map(|entry| fs::read(entry.unwrap().path()).unwrap()) + .collect(); + assert_eq!(backups, vec![original.to_vec()]); + cleanup_spawned_herdr(herdr, base); +} + +#[test] fn server_start_restores_legacy_session_through_api_identity() { let base = unique_test_dir(); let config_home = base.join("config"); |
