Compare commits
2 Commits
47e2bbd454
...
7e97d6a83a
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e97d6a83a
|
|||
|
c3800461a4
|
+14
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
### Crash-during-download leaves orphan archive files
|
||||||
|
|
||||||
|
`crates/lanspread-peer/src/install/transaction.rs:329` — `recover_download_transients`
|
||||||
|
sweeps only `.version.ini.tmp` and `.version.ini.discarded` on startup. The new
|
||||||
|
cancel-cleanup (`download/storage.rs::discard_cancelled_download`) is only invoked
|
||||||
|
from the in-flight orchestrator, so a crash mid-download leaves partial `.eti`
|
||||||
|
archives in the game root. After restart the user sees a game that looks
|
||||||
|
half-downloaded with no way to clean it up except `RemoveDownloadedGame`. Closing
|
||||||
|
this would mean calling the same discard pass during recovery for any game root
|
||||||
|
whose intent is `None` and whose `version.ini` is absent.
|
||||||
|
|
||||||
|
Not blocking. The cancel-button fix is correct in its scope; this is the symmetric
|
||||||
|
crash-recovery case.
|
||||||
|
|
||||||
### `handleErrorEvent` still writes status fields directly
|
### `handleErrorEvent` still writes status fields directly
|
||||||
|
|
||||||
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
|
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
active for that ID, and the root-level `version.ini` sentinel exists.
|
active for that ID, and the root-level `version.ini` sentinel exists.
|
||||||
- `local/` paths are never served, even if a stale or malicious manifest request
|
- `local/` paths are never served, even if a stale or malicious manifest request
|
||||||
asks for them.
|
asks for them.
|
||||||
|
- Cancelling a download discards the peer-owned root download payload and
|
||||||
|
scratch sentinel files. `local/` and install transaction metadata are
|
||||||
|
preserved, so a cancelled update of an installed game settles as local-only.
|
||||||
|
|
||||||
## Fault tolerance rules
|
## Fault tolerance rules
|
||||||
|
|
||||||
|
|||||||
@@ -77,15 +77,17 @@ When the UI asks to download a game:
|
|||||||
different regions of the same file.
|
different regions of the same file.
|
||||||
5. `version.ini` chunks are buffered in memory and committed last via
|
5. `version.ini` chunks are buffered in memory and committed last via
|
||||||
`.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
|
`.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
|
||||||
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed or
|
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed downloads
|
||||||
cancelled downloads sweep `.version.ini.tmp` and `.version.ini.discarded`
|
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
|
||||||
without restoring the previous sentinel.
|
previous sentinel. Cancelled downloads also discard the peer-owned download
|
||||||
|
payload while preserving `local/` and install transaction metadata.
|
||||||
6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
||||||
is emitted and the peer auto-runs the install transaction.
|
is emitted and the peer auto-runs the install transaction.
|
||||||
|
|
||||||
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
||||||
transfer. The transfer task remains responsible for clearing `active_operations`,
|
transfer. The transfer task remains responsible for clearing `active_operations`,
|
||||||
so the UI continues to treat active-operation snapshots as the single source of
|
discarding partial payload files, and refreshing the settled local snapshot, so
|
||||||
|
the UI continues to treat active-operation snapshots as the single source of
|
||||||
truth for whether a download is still running.
|
truth for whether a download is still running.
|
||||||
|
|
||||||
### Install Transactions
|
### Install Transactions
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod orchestrator;
|
|||||||
mod planning;
|
mod planning;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod retry;
|
mod retry;
|
||||||
|
mod storage;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod version_ini;
|
mod version_ini;
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,10 @@ use tokio::sync::mpsc::UnboundedSender;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
planning::{
|
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
|
||||||
ChunkDownloadResult,
|
|
||||||
DownloadChunk,
|
|
||||||
build_peer_plans,
|
|
||||||
extract_version_descriptor,
|
|
||||||
prepare_game_storage,
|
|
||||||
},
|
|
||||||
progress::{DownloadProgressTracker, sample_download_progress},
|
progress::{DownloadProgressTracker, sample_download_progress},
|
||||||
retry::{RetryContext, retry_failed_chunks},
|
retry::{RetryContext, retry_failed_chunks},
|
||||||
|
storage::{discard_cancelled_download, prepare_game_storage},
|
||||||
transport::download_from_peer,
|
transport::download_from_peer,
|
||||||
version_ini::{
|
version_ini::{
|
||||||
VersionIniBuffer,
|
VersionIniBuffer,
|
||||||
@@ -67,8 +62,17 @@ pub async fn download_game_files(
|
|||||||
})?;
|
})?;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
|
}
|
||||||
if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await {
|
if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
|
}
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||||
id: game_id.to_string(),
|
id: game_id.to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -76,6 +80,7 @@ pub async fn download_game_files(
|
|||||||
}
|
}
|
||||||
if cancel_token.is_cancelled() {
|
if cancel_token.is_cancelled() {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
eyre::bail!("download cancelled for game {game_id}");
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +109,9 @@ pub async fn download_game_files(
|
|||||||
|
|
||||||
if let Err(err) = transfer_result {
|
if let Err(err) = transfer_result {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
if !cancel_token.is_cancelled() {
|
if cancel_token.is_cancelled() {
|
||||||
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
|
} else {
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||||
id: game_id.to_string(),
|
id: game_id.to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -112,6 +119,12 @@ pub async fn download_game_files(
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
|
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||||
@@ -126,6 +139,12 @@ pub async fn download_game_files(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn discard_cancelled_download_best_effort(games_folder: &Path, game_id: &str) {
|
||||||
|
if let Err(err) = discard_cancelled_download(games_folder, game_id).await {
|
||||||
|
log::warn!("Failed to discard cancelled download payload for {game_id}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TransferContext<'a> {
|
struct TransferContext<'a> {
|
||||||
game_id: &'a str,
|
game_id: &'a str,
|
||||||
games_folder: &'a Path,
|
games_folder: &'a Path,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::{collections::HashMap, net::SocketAddr, path::Path};
|
use std::{collections::HashMap, net::SocketAddr};
|
||||||
|
|
||||||
use lanspread_db::db::GameFileDescription;
|
use lanspread_db::db::GameFileDescription;
|
||||||
use tokio::{fs::OpenOptions, sync::mpsc::UnboundedSender};
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use crate::{PeerEvent, config::CHUNK_SIZE, path_validation::validate_game_file_path};
|
use crate::{PeerEvent, config::CHUNK_SIZE};
|
||||||
|
|
||||||
/// Represents a chunk of a file to be downloaded.
|
/// Represents a chunk of a file to be downloaded.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -60,56 +60,6 @@ pub(super) fn extract_version_descriptor(
|
|||||||
Ok((version_desc, transfer_descs))
|
Ok((version_desc, transfer_descs))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepares storage for game files by creating directories and pre-allocating files.
|
|
||||||
pub(super) async fn prepare_game_storage(
|
|
||||||
games_folder: &Path,
|
|
||||||
file_descs: &[GameFileDescription],
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
for desc in file_descs {
|
|
||||||
if desc.is_version_ini() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the path to prevent directory traversal
|
|
||||||
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
|
|
||||||
|
|
||||||
if desc.is_dir {
|
|
||||||
tokio::fs::create_dir_all(&validated_path).await?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = validated_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and pre-allocate the file with the expected size
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.write(true)
|
|
||||||
.open(&validated_path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Pre-allocate the file with the expected size
|
|
||||||
let size = desc.size;
|
|
||||||
if let Err(e) = file.set_len(size).await {
|
|
||||||
log::warn!(
|
|
||||||
"Failed to pre-allocate file {} (size: {}): {}",
|
|
||||||
desc.relative_path,
|
|
||||||
size,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
// Continue without pre-allocation - the file will grow as chunks are written
|
|
||||||
} else {
|
|
||||||
log::debug!(
|
|
||||||
"Pre-allocated file {} with {} bytes",
|
|
||||||
desc.relative_path,
|
|
||||||
size
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves which peers have a specific file.
|
/// Resolves which peers have a specific file.
|
||||||
pub(super) fn resolve_file_peers<'a>(
|
pub(super) fn resolve_file_peers<'a>(
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
@@ -206,8 +156,6 @@ mod tests {
|
|||||||
use lanspread_db::db::GameFileDescription;
|
use lanspread_db::db::GameFileDescription;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_support::TempDir;
|
|
||||||
|
|
||||||
fn loopback_addr(port: u16) -> SocketAddr {
|
fn loopback_addr(port: u16) -> SocketAddr {
|
||||||
SocketAddr::from(([127, 0, 0, 1], port))
|
SocketAddr::from(([127, 0, 0, 1], port))
|
||||||
}
|
}
|
||||||
@@ -346,23 +294,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
|
||||||
let temp = TempDir::new("lanspread-download");
|
|
||||||
let descs = vec![GameFileDescription {
|
|
||||||
game_id: "game".to_string(),
|
|
||||||
relative_path: "game/version.ini".to_string(),
|
|
||||||
is_dir: false,
|
|
||||||
size: 8,
|
|
||||||
}];
|
|
||||||
|
|
||||||
prepare_game_storage(temp.path(), &descs)
|
|
||||||
.await
|
|
||||||
.expect("storage preparation should succeed");
|
|
||||||
|
|
||||||
assert!(!temp.path().join("game").join("version.ini").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
use std::{io::ErrorKind, path::Path};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameFileDescription;
|
||||||
|
use tokio::fs::OpenOptions;
|
||||||
|
|
||||||
|
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
|
||||||
|
|
||||||
|
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
||||||
|
const SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
|
||||||
|
const SYNC_DIR: &str = ".sync";
|
||||||
|
|
||||||
|
/// Prepares storage for game files by creating directories and pre-allocating files.
|
||||||
|
pub(super) async fn prepare_game_storage(
|
||||||
|
games_folder: &Path,
|
||||||
|
file_descs: &[GameFileDescription],
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
for desc in file_descs {
|
||||||
|
if desc.is_version_ini() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
|
||||||
|
|
||||||
|
if desc.is_dir {
|
||||||
|
tokio::fs::create_dir_all(&validated_path).await?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = validated_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&validated_path)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let size = desc.size;
|
||||||
|
if let Err(e) = file.set_len(size).await {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to pre-allocate file {} (size: {}): {}",
|
||||||
|
desc.relative_path,
|
||||||
|
size,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Pre-allocated file {} with {} bytes",
|
||||||
|
desc.relative_path,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards the peer-owned downloaded payload after a cancelled transfer.
|
||||||
|
///
|
||||||
|
/// Downloads own the root archive/cache files, but not `local/` or install
|
||||||
|
/// transaction metadata. Preserving those paths lets a cancelled update settle
|
||||||
|
/// as a local-only install instead of deleting user-owned extracted files.
|
||||||
|
pub(super) async fn discard_cancelled_download(
|
||||||
|
games_folder: &Path,
|
||||||
|
game_id: &str,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
let game_root = games_folder.join(game_id);
|
||||||
|
let Some(metadata) = symlink_metadata_if_exists(&game_root).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
eyre::bail!(
|
||||||
|
"refusing to discard cancelled download through symlink root {}",
|
||||||
|
game_root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
eyre::bail!(
|
||||||
|
"refusing to discard cancelled download from non-directory root {}",
|
||||||
|
game_root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = tokio::fs::read_dir(&game_root).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(should_preserve_on_download_discard)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_entry(&entry.path()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_dir_if_empty(&game_root).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_preserve_on_download_discard(name: &str) -> bool {
|
||||||
|
is_local_dir_name(name)
|
||||||
|
|| name.starts_with(".local.")
|
||||||
|
|| name == INTENT_LOG_FILE
|
||||||
|
|| name == SOFTLAN_INSTALL_MARKER
|
||||||
|
|| name == SYNC_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_entry(path: &Path) -> eyre::Result<()> {
|
||||||
|
let Some(metadata) = symlink_metadata_if_exists(path).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if metadata.file_type().is_symlink() || metadata.is_file() {
|
||||||
|
remove_file_if_exists(path).await
|
||||||
|
} else if metadata.is_dir() {
|
||||||
|
tokio::fs::remove_dir_all(path).await?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
remove_file_if_exists(path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_if_empty(path: &Path) -> eyre::Result<()> {
|
||||||
|
match tokio::fs::remove_dir(path).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err)
|
||||||
|
if matches!(
|
||||||
|
err.kind(),
|
||||||
|
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
|
||||||
|
match tokio::fs::symlink_metadata(path).await {
|
||||||
|
Ok(metadata) => Ok(Some(metadata)),
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use lanspread_db::db::GameFileDescription;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::test_support::TempDir;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
||||||
|
let temp = TempDir::new("lanspread-download");
|
||||||
|
let descs = vec![GameFileDescription {
|
||||||
|
game_id: "game".to_string(),
|
||||||
|
relative_path: "game/version.ini".to_string(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 8,
|
||||||
|
}];
|
||||||
|
|
||||||
|
prepare_game_storage(temp.path(), &descs)
|
||||||
|
.await
|
||||||
|
.expect("storage preparation should succeed");
|
||||||
|
|
||||||
|
assert!(!temp.path().join("game").join("version.ini").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn discard_cancelled_download_removes_peer_owned_payload() {
|
||||||
|
let temp = TempDir::new("lanspread-download-discard");
|
||||||
|
let root = temp.game_root();
|
||||||
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
write_file(&root.join(".version.ini.tmp"), b"tmp");
|
||||||
|
write_file(&root.join(".version.ini.discarded"), b"old");
|
||||||
|
write_file(&root.join("archive.eti"), b"partial");
|
||||||
|
write_file(&root.join("nested").join("payload.bin"), b"partial");
|
||||||
|
|
||||||
|
discard_cancelled_download(temp.path(), "game")
|
||||||
|
.await
|
||||||
|
.expect("cancelled payload should be discarded");
|
||||||
|
|
||||||
|
assert!(!root.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn discard_cancelled_download_preserves_local_install_state() {
|
||||||
|
let temp = TempDir::new("lanspread-download-discard-local");
|
||||||
|
let root = temp.game_root();
|
||||||
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
write_file(&root.join("archive.eti"), b"partial");
|
||||||
|
write_file(&root.join("local").join("save.dat"), b"user-data");
|
||||||
|
write_file(&root.join(".lanspread.json"), b"{\"intent\":\"None\"}");
|
||||||
|
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
|
||||||
|
|
||||||
|
discard_cancelled_download(temp.path(), "game")
|
||||||
|
.await
|
||||||
|
.expect("cancelled payload should be discarded");
|
||||||
|
|
||||||
|
assert!(!root.join("version.ini").exists());
|
||||||
|
assert!(!root.join("archive.eti").exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.join("local").join("save.dat"))
|
||||||
|
.expect("local install should remain"),
|
||||||
|
b"user-data"
|
||||||
|
);
|
||||||
|
assert!(root.join(".lanspread.json").is_file());
|
||||||
|
assert!(root.join(".local.backup").is_dir());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -311,7 +311,12 @@ pub(super) async fn download_from_peer(
|
|||||||
|
|
||||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||||
|
|
||||||
let mut conn = match connect_to_peer(peer_addr).await {
|
let mut conn = match tokio::select! {
|
||||||
|
() = cancel_token.cancelled() => {
|
||||||
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
|
}
|
||||||
|
result = connect_to_peer(peer_addr) => result,
|
||||||
|
} {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
|
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -330,6 +330,15 @@ pub async fn handle_download_game_files_command(
|
|||||||
let Some(prepared) =
|
let Some(prepared) =
|
||||||
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||||
else {
|
else {
|
||||||
|
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||||
|
&ctx_clone,
|
||||||
|
&tx_notify_ui_clone,
|
||||||
|
&download_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("Failed to refresh local library after download: {err}");
|
||||||
|
}
|
||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
return;
|
return;
|
||||||
@@ -356,16 +365,31 @@ pub async fn handle_download_game_files_command(
|
|||||||
clear_active_download(&ctx_clone, &download_id).await;
|
clear_active_download(&ctx_clone, &download_id).await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||||
if let Err(err) =
|
&ctx_clone,
|
||||||
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
&tx_notify_ui_clone,
|
||||||
|
&download_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
log::error!("Failed to refresh local library after download: {err}");
|
log::error!("Failed to refresh local library after download: {err}");
|
||||||
}
|
}
|
||||||
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
}
|
}
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
||||||
|
&ctx_clone,
|
||||||
|
&tx_notify_ui_clone,
|
||||||
|
&download_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to refresh local library after download failure: {refresh_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
log::error!("Download failed for {download_id}: {e}");
|
log::error!("Download failed for {download_id}: {e}");
|
||||||
@@ -851,25 +875,6 @@ async fn scan_and_announce_local_library(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_local_game(
|
|
||||||
ctx: &Ctx,
|
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
||||||
id: &str,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
|
||||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
|
||||||
update_and_announce_games_with_policy(
|
|
||||||
ctx,
|
|
||||||
tx_notify_ui,
|
|
||||||
scan,
|
|
||||||
LocalLibraryEventPolicy::OnChange,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refreshes the game whose operation has completed before clearing its
|
/// Refreshes the game whose operation has completed before clearing its
|
||||||
/// active-operation snapshot, while preserving freeze behavior for other games.
|
/// active-operation snapshot, while preserving freeze behavior for other games.
|
||||||
async fn refresh_local_game_for_ending_operation(
|
async fn refresh_local_game_for_ending_operation(
|
||||||
|
|||||||
Reference in New Issue
Block a user