feat(peer): add transactional local game operations

Implement the peer-owned state model from PLAN.md. A root-level version.ini
is now the download completion sentinel, local/ as a directory is the install
predicate, and exact root-level version.ini detection prevents nested files
from becoming sentinels by accident.

Add the peer operation table that gates downloads, installs, updates, and
uninstalls by game ID. Serving paths now reject non-catalog games, active
operations, missing sentinels, and any request that points under local/.
Remote aggregation treats LocalOnly peers as non-downloadable so they do not
contribute peer counts, candidate source selection, or latest-version checks.

Move install-side filesystem mutation into lanspread-peer::install. The new
module writes atomic .lanspread.json intents, uses .local.installing and
.local.backup with .lanspread_owned markers, and performs startup recovery
from recorded intent plus filesystem state. Downloads now buffer version.ini
chunks in memory and commit the sentinel last through .version.ini.tmp.

Replace the fixed 15-second monitor with notify-backed non-recursive watches,
per-ID rescan gating, and a 300-second fallback scan. The optimized rescan
path updates one cached library-index entry and active operation IDs preserve
their previous summary during scans.

Test Plan:
- just fmt
- just clippy
- just test
- just build

Refs: PLAN.md
This commit is contained in:
2026-05-15 18:18:55 +02:00
parent bff58c6013
commit 6c8a2bb9f0
21 changed files with 2652 additions and 246 deletions
@@ -0,0 +1,651 @@
use std::{
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
};
use eyre::WrapErr;
use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker,
};
use crate::local_games::version_ini_is_regular_file;
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
Present,
Missing,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InstallFsState {
local: FsEntryState,
installing: FsEntryState,
backup: FsEntryState,
}
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await?;
let result = install_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await {
log::warn!(
"Failed to clean install staging {}: {cleanup_err}",
installing_dir(game_root).display()
);
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
)
.await?;
let result = update_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
log::warn!(
"Failed to clean install backup {}: {err}",
backup_dir(game_root).display()
);
}
Ok(())
}
Err(err) => {
let rollback = rollback_update(game_root).await;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
Err(err)
}
}
}
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
)
.await?;
let result = uninstall_inner(game_root).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
let rollback = restore_backup(game_root).await;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn recover_on_startup(game_dir: &Path) -> eyre::Result<()> {
recover_download_transients(game_dir).await?;
let mut entries = match tokio::fs::read_dir(game_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if id == ".lanspread" {
continue;
}
recover_game_root(&entry.path(), &id).await?;
}
Ok(())
}
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
recover_download_transients(game_root).await?;
let intent = read_intent(game_root, id).await;
let fs = inspect_install_fs(game_root).await;
match intent.state {
InstallIntentState::None => recover_none_intent(game_root).await?,
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
}
Ok(())
}
async fn install_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
if path_is_dir(&local).await {
eyre::bail!("game {id} is already installed");
}
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
Ok(())
}
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
let staging = installing_dir(game_root);
if !path_is_dir(&local).await {
eyre::bail!("game {id} is not installed");
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup)
.await
.wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?;
drop_owned_marker(&backup).await?;
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
Ok(())
}
async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&local).await {
return Ok(());
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup).await?;
drop_owned_marker(&backup).await?;
tokio::fs::remove_dir_all(&backup).await?;
Ok(())
}
async fn unpack_archives(
game_root: &Path,
staging: &Path,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let archives = root_eti_archives(game_root).await?;
if archives.is_empty() {
eyre::bail!("no .eti archives found in {}", game_root.display());
}
for archive in archives {
unpacker.unpack(&archive, staging).await?;
}
Ok(())
}
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(game_root).await?;
let mut archives = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "eti") {
archives.push(path);
}
}
archives.sort();
Ok(archives)
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
Ok(())
}
async fn recover_installing(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
if let InstallFsState {
installing: FsEntryState::Present,
..
} = fs
{
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_updating(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
remove_dir_all_if_exists(&backup_dir(game_root)).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_uninstalling(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Missing,
} => uninstall_inner(game_root).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
remove_file_if_exists(&root.join(VERSION_TMP_FILE)).await?;
remove_file_if_exists(&root.join(VERSION_DISCARDED_FILE)).await?;
Ok(())
}
async fn inspect_install_fs(game_root: &Path) -> InstallFsState {
InstallFsState {
local: path_is_dir(&local_dir(game_root)).await.into(),
installing: path_is_dir(&installing_dir(game_root)).await.into(),
backup: path_is_dir(&backup_dir(game_root)).await.into(),
}
}
async fn read_downloaded_version(game_root: &Path) -> Option<String> {
if !version_ini_is_regular_file(game_root).await {
return None;
}
match lanspread_db::db::read_version_from_ini(game_root) {
Ok(version) => version,
Err(err) => {
log::warn!(
"Failed to read version.ini in {}: {err}",
game_root.display()
);
None
}
}
}
async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> {
if path.exists() {
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
} else {
eyre::bail!("refusing to reuse markerless directory {}", path.display());
}
}
tokio::fs::create_dir_all(path).await?;
drop_owned_marker(path).await
}
async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
return Ok(());
}
eyre::bail!("refusing to replace markerless backup {}", path.display());
}
async fn drop_owned_marker(path: &Path) -> eyre::Result<()> {
tokio::fs::write(owned_marker(path), []).await?;
Ok(())
}
async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
remove_dir_all_if_exists(path).await?;
} else {
log::warn!(
"Leaving markerless reserved directory untouched: {}",
path.display()
);
}
Ok(())
}
async fn rollback_update(game_root: &Path) -> eyre::Result<()> {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await
}
async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&backup).await {
return Ok(());
}
remove_dir_all_if_exists(&local).await?;
tokio::fs::rename(&backup, &local).await?;
Ok(())
}
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn path_is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_dir())
}
fn local_dir(game_root: &Path) -> PathBuf {
game_root.join(LOCAL_DIR)
}
fn installing_dir(game_root: &Path) -> PathBuf {
game_root.join(INSTALLING_DIR)
}
fn backup_dir(game_root: &Path) -> PathBuf {
game_root.join(BACKUP_DIR)
}
fn owned_marker(path: &Path) -> PathBuf {
path.join(OWNED_MARKER)
}
impl From<bool> for FsEntryState {
fn from(value: bool) -> Self {
if value { Self::Present } else { Self::Missing }
}
}
#[cfg(test)]
mod tests {
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use super::*;
use crate::install::unpack::UnpackFuture;
#[derive(Default)]
struct FakeUnpacker {
fail: bool,
archives: Mutex<Vec<PathBuf>>,
}
impl FakeUnpacker {
fn failing() -> Self {
Self {
fail: true,
archives: Mutex::new(Vec::new()),
}
}
}
impl Unpacker for FakeUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
self.archives
.lock()
.expect("archive list should not be poisoned")
.push(archive.to_path_buf());
if self.fail {
eyre::bail!("forced unpack failure");
}
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
Ok(())
})
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let mut path = std::env::temp_dir();
path.push(format!(
"lanspread-install-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&path).expect("temp dir should be created");
Self(path)
}
fn game_root(&self) -> PathBuf {
self.0.join("game")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
fn successful_unpacker() -> Arc<dyn Unpacker> {
Arc::new(FakeUnpacker::default())
}
#[tokio::test]
async fn install_success_promotes_staging_and_clears_intent() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(&root, "game", successful_unpacker())
.await
.expect("install should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join(".local.installing").exists());
let intent = read_intent(&root, "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_failure_restores_previous_local() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
.await
.expect_err("update should fail");
assert!(err.to_string().contains("forced unpack failure"));
assert!(root.join("local").join("old.txt").is_file());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn uninstall_removes_only_local_install() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("payload.txt"), b"installed");
uninstall(&root, "game")
.await
.expect("uninstall should succeed");
assert!(!root.join("local").exists());
assert!(root.join("game.eti").is_file());
assert!(root.join("version.ini").is_file());
}
#[tokio::test]
async fn recovery_restores_backup_for_interrupted_update() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
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(
&root,
&InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".into()),
),
)
.await
.expect("intent should be written");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
assert!(root.join("local").join("old.txt").is_file());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
assert_eq!(
read_intent(&root, "game").await.state,
InstallIntentState::None
);
}
#[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join(".local.backup").join("user.txt"), b"user");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
assert!(root.join(".local.backup").join("user.txt").is_file());
}
#[tokio::test]
async fn download_recovery_sweeps_reserved_version_files() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
assert!(!root.join(VERSION_TMP_FILE).exists());
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
}
}