aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/website/src/content/docs/ja/session-state.mdx4
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx4
-rw-r--r--docs/next/website/src/content/docs/zh-cn/session-state.mdx4
-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
-rw-r--r--tests/cli/sessions.rs69
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");