test(peer): cover install recovery matrix

FOLLOW_UP_2.md called out that recovery only covered one intent-driven update
row. Replace that single-case assertion with a table over the ten recovery
rows documented in PLAN.md, spanning Installing, Updating, and Uninstalling
intents across local, staging, and backup directory states.

The cases intentionally use markerless reserved directories while an intent is
present. That pins the contract that the intent log proves Lanspread ownership
during crash recovery, including the crash windows before ownership markers are
dropped. The test still keeps the existing None-intent markerless case separate
so user-owned reserved names remain protected.

Running the larger table in parallel exposed that this module's TempDir helper
could collide on pid plus timestamp paths. Add a local atomic suffix so these
tests stop deleting each other's directories without doing the broader helper
consolidation reserved for the later hygiene phase.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_2.md
This commit is contained in:
2026-05-16 09:04:53 +02:00
parent 95e70ef520
commit 47733713ca
+165 -20
View File
@@ -464,7 +464,11 @@ mod tests {
use std::{ use std::{
collections::HashSet, collections::HashSet,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{
Arc,
Mutex,
atomic::{AtomicU64, Ordering},
},
}; };
use super::*; use super::*;
@@ -503,12 +507,16 @@ mod tests {
struct TempDir(PathBuf); struct TempDir(PathBuf);
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
impl TempDir { impl TempDir {
fn new() -> Self { fn new() -> Self {
let mut path = std::env::temp_dir(); let mut path = std::env::temp_dir();
let unique_id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
path.push(format!( path.push(format!(
"lanspread-install-{}-{}", "lanspread-install-{}-{}-{}",
std::process::id(), std::process::id(),
unique_id,
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -637,38 +645,175 @@ mod tests {
assert!(root.join("version.ini").is_file()); assert!(root.join("version.ini").is_file());
} }
#[derive(Clone)]
struct RecoveryCase {
name: &'static str,
intent_state: InstallIntentState,
has_local: bool,
has_installing: bool,
has_backup: bool,
expected_local_payload: Option<&'static [u8]>,
}
const LOCAL_PAYLOAD: &[u8] = b"local";
const INSTALLING_PAYLOAD: &[u8] = b"installing";
const BACKUP_PAYLOAD: &[u8] = b"backup";
fn seed_recovery_case(root: &Path, case: &RecoveryCase) {
write_file(&root.join("version.ini"), b"20250101");
if case.has_local {
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
}
if case.has_installing {
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
}
if case.has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
}
fn assert_recovered_case(root: &Path, case: &RecoveryCase) {
let local_payload = root.join(LOCAL_DIR).join("payload.txt");
match case.expected_local_payload {
Some(expected) => assert_eq!(
std::fs::read(&local_payload)
.unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)),
expected,
"{} local payload",
case.name
),
None => assert!(
!root.join(LOCAL_DIR).exists(),
"{} local dir should be absent",
case.name
),
}
assert!(
!root.join(INSTALLING_DIR).exists(),
"{} installing dir should be absent",
case.name
);
assert!(
!root.join(BACKUP_DIR).exists(),
"{} backup dir should be absent",
case.name
);
}
#[tokio::test] #[tokio::test]
async fn recovery_restores_backup_for_interrupted_update() { async fn recovery_covers_install_matrix_rows() {
let cases = [
RecoveryCase {
name: "installing_commit_landed",
intent_state: InstallIntentState::Installing,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "installing_staging_only",
intent_state: InstallIntentState::Installing,
has_local: false,
has_installing: true,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "installing_before_staging",
intent_state: InstallIntentState::Installing,
has_local: false,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "updating_restore_backup",
intent_state: InstallIntentState::Updating,
has_local: false,
has_installing: true,
has_backup: true,
expected_local_payload: Some(BACKUP_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_landed_with_stale_dirs",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: true,
has_backup: true,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_landed_before_backup_cleanup",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: false,
has_backup: true,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_and_cleanup_landed",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "uninstalling_delete_backup",
intent_state: InstallIntentState::Uninstalling,
has_local: false,
has_installing: false,
has_backup: true,
expected_local_payload: None,
},
RecoveryCase {
name: "uninstalling_already_clean",
intent_state: InstallIntentState::Uninstalling,
has_local: false,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "uninstalling_before_fs_work",
intent_state: InstallIntentState::Uninstalling,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
];
for case in cases {
let temp = TempDir::new(); let temp = TempDir::new();
let root = temp.game_root(); let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101"); seed_recovery_case(&root, &case);
write_file(&root.join(".local.backup").join("old.txt"), b"old");
write_file(&root.join(".local.installing").join("new.txt"), b"new");
write_file(&root.join(".local.backup").join(OWNED_MARKER), b"");
write_file(&root.join(".local.installing").join(OWNED_MARKER), b"");
write_intent( write_intent(
&root, &root,
&InstallIntent::new( &InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
"game",
InstallIntentState::Updating,
Some("20250101".into()),
),
) )
.await .await
.expect("intent should be written"); .unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
recover_game_root(&root, "game") recover_game_root(&root, "game")
.await .await
.expect("recovery should succeed"); .unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
assert!(root.join("local").join("old.txt").is_file()); assert_recovered_case(&root, &case);
assert!(!root.join(".local.installing").exists()); let intent = read_intent(&root, "game").await;
assert!(!root.join(".local.backup").exists()); assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!( assert_eq!(
read_intent(&root, "game").await.state, intent.eti_version.as_deref(),
InstallIntentState::None Some("20250101"),
"{}",
case.name
); );
} }
}
#[tokio::test] #[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() { async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {