refactor(peer): split download pipeline into modules
The download pipeline had grown into one large file that mixed sentinel transaction handling, peer planning, transport, retry, and top-level orchestration. Split it into a download/ module tree with one file per concern so future lifecycle changes can be reviewed at the right boundary. The public crate surface remains download::download_game_files. Helper types and functions are kept pub(super) or private so the refactor does not widen the API or encourage new callers to depend on internals. The version.ini transaction helpers stay local to version_ini.rs; the proposed fs_util extraction is intentionally left for the later atomic-index work, where a second caller exists. There is no intended runtime behavior change. Test Plan: - just fmt - just test - just clippy - just build Refs: none
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
use std::path::Path;
|
||||
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct VersionIniBuffer {
|
||||
relative_path: String,
|
||||
bytes: Mutex<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl VersionIniBuffer {
|
||||
pub(super) fn new(desc: &GameFileDescription) -> eyre::Result<Self> {
|
||||
if desc.is_dir {
|
||||
eyre::bail!("version.ini sentinel cannot be a directory");
|
||||
}
|
||||
let size = usize::try_from(desc.size)?;
|
||||
Ok(Self {
|
||||
relative_path: desc.relative_path.clone(),
|
||||
bytes: Mutex::new(vec![0; size]),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn matches(&self, relative_path: &str) -> bool {
|
||||
self.relative_path == relative_path
|
||||
}
|
||||
|
||||
pub(super) async fn write_at(&self, offset: u64, bytes: &[u8]) -> eyre::Result<()> {
|
||||
let offset = usize::try_from(offset)?;
|
||||
let mut buffer = self.bytes.lock().await;
|
||||
let end = offset
|
||||
.checked_add(bytes.len())
|
||||
.ok_or_else(|| eyre::eyre!("version.ini chunk offset overflow"))?;
|
||||
if end > buffer.len() {
|
||||
eyre::bail!(
|
||||
"version.ini chunk exceeds buffer: end {end}, buffer {}",
|
||||
buffer.len()
|
||||
);
|
||||
}
|
||||
buffer[offset..end].copy_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn snapshot(&self) -> Vec<u8> {
|
||||
self.bytes.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> {
|
||||
tokio::fs::create_dir_all(game_root).await?;
|
||||
remove_file_if_exists(&game_root.join(".version.ini.tmp")).await?;
|
||||
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;
|
||||
|
||||
let version_path = game_root.join("version.ini");
|
||||
if tokio::fs::metadata(&version_path)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.is_file())
|
||||
{
|
||||
tokio::fs::rename(version_path, game_root.join(".version.ini.discarded")).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn rollback_version_ini_transaction(game_root: &Path) {
|
||||
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.tmp")).await {
|
||||
log::warn!(
|
||||
"Failed to sweep partial version.ini tmp in {}: {err}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.discarded")).await {
|
||||
log::warn!(
|
||||
"Failed to sweep discarded version.ini in {}: {err}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn commit_version_ini_buffer(
|
||||
game_root: &Path,
|
||||
buffer: &VersionIniBuffer,
|
||||
) -> eyre::Result<()> {
|
||||
let tmp_path = game_root.join(".version.ini.tmp");
|
||||
let version_path = game_root.join("version.ini");
|
||||
let bytes = buffer.snapshot().await;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, &version_path).await?;
|
||||
sync_parent_dir(&version_path)?;
|
||||
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
||||
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() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_ini_buffer_accepts_out_of_order_chunks() {
|
||||
let desc = GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
};
|
||||
let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
|
||||
|
||||
buffer
|
||||
.write_at(4, b"0101")
|
||||
.await
|
||||
.expect("second chunk should write");
|
||||
buffer
|
||||
.write_at(0, b"2025")
|
||||
.await
|
||||
.expect("first chunk should write");
|
||||
|
||||
assert_eq!(buffer.snapshot().await, b"20250101");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = temp.path().join("game");
|
||||
tokio::fs::create_dir_all(&game_root)
|
||||
.await
|
||||
.expect("game root should be created");
|
||||
tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
|
||||
.await
|
||||
.expect("discarded sentinel should be written");
|
||||
|
||||
let desc = GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
};
|
||||
let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
|
||||
buffer
|
||||
.write_at(0, b"20250101")
|
||||
.await
|
||||
.expect("version should be buffered");
|
||||
|
||||
commit_version_ini_buffer(&game_root, &buffer)
|
||||
.await
|
||||
.expect("version sentinel should commit");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join("version.ini")).expect("version.ini should exist"),
|
||||
b"20250101"
|
||||
);
|
||||
assert!(!game_root.join(".version.ini.tmp").exists());
|
||||
assert!(!game_root.join(".version.ini.discarded").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_version_ini_transaction_parks_existing_sentinel() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = temp.path().join("game");
|
||||
tokio::fs::create_dir_all(&game_root)
|
||||
.await
|
||||
.expect("game root should be created");
|
||||
tokio::fs::write(game_root.join("version.ini"), b"20240101")
|
||||
.await
|
||||
.expect("version sentinel should be written");
|
||||
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
.expect("tmp sentinel should be written");
|
||||
|
||||
begin_version_ini_transaction(&game_root)
|
||||
.await
|
||||
.expect("transaction should begin");
|
||||
|
||||
assert!(!game_root.join("version.ini").exists());
|
||||
assert!(!game_root.join(".version.ini.tmp").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join(".version.ini.discarded"))
|
||||
.expect("discarded sentinel should exist"),
|
||||
b"20240101"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_version_ini_transaction_sweeps_transients() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = temp.path().join("game");
|
||||
tokio::fs::create_dir_all(&game_root)
|
||||
.await
|
||||
.expect("game root should be created");
|
||||
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
.expect("tmp sentinel should be written");
|
||||
tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
|
||||
.await
|
||||
.expect("discarded sentinel should be written");
|
||||
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
|
||||
assert!(!game_root.join(".version.ini.tmp").exists());
|
||||
assert!(!game_root.join(".version.ini.discarded").exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user