feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes straight into local/ without first storing the peer-owned root archive payload. This is intended for low-disk clients that want to install a game but opt out of becoming a downloadable peer source for that game. The protocol gains a current-version-only StreamInstall request and framed StreamInstallFrame responses. The peer core owns the generic transport, transaction, path validation, size checks, CRC32 verification, and lifecycle state. The archive-specific work is hidden behind StreamInstallProvider so the prototype can use unrar while the final implementation can swap in a better provider without rewriting the peer command path. The receiver writes into .local.installing and only promotes to local/ after the full stream verifies. It deliberately does not write the root version.ini or archive files, so the settled local state is installed=true, downloaded=false, and availability=LocalOnly. That preserves the existing rule that local/ is not served to peers and makes streamed receivers non-sources by construction. The CLI is the only caller for now. It exposes stream-install and provides the prototype unrar implementation with unrar lt for entry metadata and unrar p for file bytes. This is simple and good enough to prove non-solid archive streaming, but it is not the production provider shape for solid archives because per-file unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes stream_install_provider: None, so the GUI behavior stays unchanged until a real product path is designed. Document the production-readiness work in NEXT_STEPS.md. The main follow-up is to make the provider abstraction final-ish and replace the per-file CLI unrar provider with a one-pass archive provider, then wire a deliberate GUI low-disk mode, retry semantics, and broader failure scenarios. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 --build-image - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Follow-up: NEXT_STEPS.md
This commit is contained in:
@@ -4,5 +4,13 @@ mod transaction;
|
||||
pub mod unpack;
|
||||
|
||||
pub use remove::remove_downloaded;
|
||||
pub use transaction::{install, recover_on_startup, uninstall, update};
|
||||
pub(crate) use transaction::root_eti_archives;
|
||||
pub use transaction::{
|
||||
StreamedInstallTransaction,
|
||||
begin_streamed_install,
|
||||
install,
|
||||
recover_on_startup,
|
||||
uninstall,
|
||||
update,
|
||||
};
|
||||
pub use unpack::{UnpackFuture, Unpacker};
|
||||
|
||||
@@ -33,6 +33,103 @@ struct InstallFsState {
|
||||
backup: FsEntryState,
|
||||
}
|
||||
|
||||
pub struct StreamedInstallTransaction {
|
||||
game_root: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
id: String,
|
||||
staging: PathBuf,
|
||||
eti_version: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamedInstallTransaction {
|
||||
#[must_use]
|
||||
pub fn staging_dir(&self) -> &Path {
|
||||
&self.staging
|
||||
}
|
||||
|
||||
pub async fn commit(self) -> eyre::Result<()> {
|
||||
let local = local_dir(&self.game_root);
|
||||
let result = async {
|
||||
tokio::fs::rename(&self.staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote streamed install for {}", self.id))?;
|
||||
reset_launch_settings_marker(&self.state_dir, &self.id).await?;
|
||||
write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
if let Err(cleanup_err) = remove_dir_all_if_exists(&self.staging).await {
|
||||
log::warn!(
|
||||
"Failed to clean streamed install staging {}: {cleanup_err}",
|
||||
self.staging.display()
|
||||
);
|
||||
}
|
||||
let _ = write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn rollback(self) -> eyre::Result<()> {
|
||||
let staging_result = remove_dir_all_if_exists(&self.staging).await;
|
||||
let intent_result = write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
staging_result?;
|
||||
intent_result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn begin_streamed_install(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
) -> eyre::Result<StreamedInstallTransaction> {
|
||||
if path_is_dir(&local_dir(game_root)).await {
|
||||
eyre::bail!("game {id} is already installed");
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(game_root).await?;
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let staging = installing_dir(game_root);
|
||||
if let Err(err) = prepare_owned_empty_dir(&staging).await {
|
||||
let _ = write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let staging = tokio::fs::canonicalize(&staging).await.unwrap_or(staging);
|
||||
|
||||
Ok(StreamedInstallTransaction {
|
||||
game_root: game_root.to_path_buf(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
id: id.to_string(),
|
||||
staging,
|
||||
eti_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn install(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
@@ -258,7 +355,7 @@ async fn unpack_archives(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
pub(crate) 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? {
|
||||
|
||||
Reference in New Issue
Block a user