diff --git a/crates/surge-cli/src/commands/setup.rs b/crates/surge-cli/src/commands/setup.rs index cf8b350..e7d3bb0 100644 --- a/crates/surge-cli/src/commands/setup.rs +++ b/crates/surge-cli/src/commands/setup.rs @@ -8,7 +8,7 @@ use surge_core::error::{Result, SurgeError}; use surge_core::install::{self as core_install, InstallProfile}; use surge_core::installer_package::{ InstallerPackageAcquisition, ResolveInstallerPackageOptions, ResolvedInstallerPackage, install_artifact_cache_dir, - prune_install_artifact_cache, resolve_installer_package, retained_artifacts_for_install_cache_without_index, + prune_install_artifact_cache_with_stats, resolve_installer_package, retained_artifacts_for_resolved_install_cache, }; use surge_core::platform::fs::make_executable; use surge_core::platform::paths::default_install_root; @@ -379,23 +379,17 @@ fn ensure_stage_cache_entry( } fn prune_setup_artifact_cache(install_root: &Path, package: &ResolvedPackage, manifest: &InstallerManifest) { - let owned_retained_artifacts; - let retained_artifacts = match package.retained_artifacts.as_ref() { - Some(retained_artifacts) => retained_artifacts, - None => match retained_artifacts_for_install_cache_without_index(manifest) { - Some(retained_artifacts) => { - owned_retained_artifacts = retained_artifacts; - &owned_retained_artifacts - } - None => return, - }, + let Some(retained_artifacts) = retained_artifacts_for_resolved_install_cache(package, manifest) else { + return; }; - match prune_install_artifact_cache(install_root, retained_artifacts) { - Ok(0) => {} - Ok(pruned) => { + match prune_install_artifact_cache_with_stats(install_root, retained_artifacts.as_ref()) { + Ok(result) if result.pruned_artifact_count == 0 => {} + Ok(result) => { logline::info(&format!( - "Pruned {pruned} stale artifact cache entr{}.", - if pruned == 1 { "y" } else { "ies" } + "Pruned {} stale artifact cache entr{}; retained policy key count: {}.", + result.pruned_artifact_count, + if result.pruned_artifact_count == 1 { "y" } else { "ies" }, + result.retained_policy_key_count )); } Err(e) => { diff --git a/crates/surge-core/src/installer_package.rs b/crates/surge-core/src/installer_package.rs index 8fb429b..5023fee 100644 --- a/crates/surge-core/src/installer_package.rs +++ b/crates/surge-core/src/installer_package.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -41,6 +42,12 @@ pub struct ResolvedInstallerPackage { pub acquisition: InstallerPackageAcquisition, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InstallArtifactCachePruneResult { + pub retained_policy_key_count: usize, + pub pruned_artifact_count: usize, +} + impl ResolvedInstallerPackage { #[must_use] pub fn path(&self) -> &Path { @@ -176,6 +183,36 @@ pub fn prune_install_artifact_cache(install_root: &Path, retained_artifacts: &BT prune_cached_artifacts(&install_artifact_cache_dir(install_root), retained_artifacts) } +pub fn prune_install_artifact_cache_with_stats( + install_root: &Path, + retained_artifacts: &BTreeSet, +) -> Result { + prune_install_artifact_cache_dir_with_stats(&install_artifact_cache_dir(install_root), retained_artifacts) +} + +pub fn prune_install_artifact_cache_dir_with_stats( + artifact_cache_dir: &Path, + retained_artifacts: &BTreeSet, +) -> Result { + let pruned_artifact_count = prune_cached_artifacts(artifact_cache_dir, retained_artifacts)?; + Ok(InstallArtifactCachePruneResult { + retained_policy_key_count: retained_artifacts.len(), + pruned_artifact_count, + }) +} + +#[must_use] +pub fn retained_artifacts_for_resolved_install_cache<'a>( + package: &'a ResolvedInstallerPackage, + manifest: &InstallerManifest, +) -> Option>> { + if let Some(retained_artifacts) = &package.retained_artifacts { + Some(Cow::Borrowed(retained_artifacts)) + } else { + retained_artifacts_for_install_cache_without_index(manifest).map(Cow::Owned) + } +} + #[must_use] pub fn retained_artifacts_for_install_cache_without_index(manifest: &InstallerManifest) -> Option> { retained_artifacts_for_cache_policy_without_index( diff --git a/crates/surge-core/src/update/manager.rs b/crates/surge-core/src/update/manager.rs index a641fee..429b17b 100644 --- a/crates/surge-core/src/update/manager.rs +++ b/crates/surge-core/src/update/manager.rs @@ -3288,6 +3288,97 @@ echo started > new-child-started assert!(remaining.is_empty(), "none retention should prune all cached artifacts"); } + #[tokio::test] + async fn test_download_and_apply_latest_full_artifact_retention_prunes_old_fulls_and_deltas() { + let tmp = tempfile::tempdir().unwrap(); + let store_root = tmp.path().join("store"); + let install_root = tmp.path().join("install"); + let app_id = "test-app"; + std::fs::create_dir_all(&store_root).unwrap(); + std::fs::create_dir_all(&install_root).unwrap(); + let app_store = app_scoped_store_root(&store_root, app_id); + + let current_app_dir = install_root.join("app"); + std::fs::create_dir_all(¤t_app_dir).unwrap(); + std::fs::write(current_app_dir.join("payload.txt"), "old payload").unwrap(); + + let rid = current_rid(); + let old_full_filename = format!("{app_id}-1.0.0-{rid}-full.tar.zst"); + let old_delta_filename = format!("{app_id}-1.0.0-{rid}-delta.tar.zst"); + let latest_full_filename = format!("{app_id}-1.1.0-{rid}-full.tar.zst"); + let latest_full_path = app_store.join(&latest_full_filename); + + let artifact_cache = install_root.join(".surge-cache").join("artifacts"); + std::fs::create_dir_all(&artifact_cache).unwrap(); + std::fs::write(artifact_cache.join(&old_full_filename), b"old full").unwrap(); + std::fs::write(artifact_cache.join(&old_delta_filename), b"old delta").unwrap(); + + let mut packer = ArchivePacker::new(3).unwrap(); + packer.add_buffer("payload.txt", b"new payload", 0o644).unwrap(); + packer.finalize_to_file(&latest_full_path).unwrap(); + + let latest_full_size = std::fs::metadata(&latest_full_path).unwrap().len() as i64; + let latest_full_sha256 = sha256_hex_file(&latest_full_path).unwrap(); + let os = current_os_label_for_tests(); + + let mut old_release = make_entry("1.0.0", "stable", &os, &rid); + old_release.full_filename = old_full_filename.clone(); + old_release.deltas = Vec::new(); + old_release.preferred_delta_id.clear(); + + let mut latest_release = make_entry("1.1.0", "stable", &os, &rid); + latest_release.full_filename = latest_full_filename.clone(); + latest_release.full_size = latest_full_size; + latest_release.full_sha256 = latest_full_sha256; + latest_release.deltas = Vec::new(); + latest_release.preferred_delta_id.clear(); + + let index = ReleaseIndex { + app_id: app_id.to_string(), + releases: vec![old_release, latest_release], + ..ReleaseIndex::default() + }; + + write_app_scoped_release_index(&store_root, app_id, &index); + + let ctx = Arc::new(Context::new()); + ctx.set_storage( + StorageProvider::Filesystem, + store_root.to_str().unwrap(), + "", + "", + "", + "", + ); + + let mut manager = UpdateManager::new(ctx, app_id, "1.0.0", "stable", install_root.to_str().unwrap()).unwrap(); + manager + .set_artifact_retention_policy(InstallArtifactCachePolicy { + retention: InstallArtifactCacheRetention::LatestFull, + keep_full_count: 1, + }) + .unwrap(); + + let info = manager.check_for_updates().await.unwrap().unwrap(); + manager + .download_and_apply(&info, None::) + .await + .unwrap(); + + assert!( + artifact_cache.join(&latest_full_filename).is_file(), + "latest full should remain in the local artifact cache" + ); + assert!( + !artifact_cache.join(&old_full_filename).exists(), + "old full should be pruned by latest_full retention" + ); + assert!( + !artifact_cache.join(&old_delta_filename).exists(), + "old delta should be pruned by latest_full retention" + ); + } + #[cfg(target_os = "linux")] #[tokio::test] async fn test_download_and_apply_full_installs_shortcuts() { diff --git a/crates/surge-core/src/update/manager/finalize.rs b/crates/surge-core/src/update/manager/finalize.rs index 0be7d4e..5b57d67 100644 --- a/crates/surge-core/src/update/manager/finalize.rs +++ b/crates/surge-core/src/update/manager/finalize.rs @@ -19,9 +19,9 @@ use crate::install::{ InstallProfile, RuntimeManifestMetadata, copy_persistent_assets, prune_version_snapshots, storage_provider_manifest_name, write_runtime_manifest, }; +use crate::installer_package::prune_install_artifact_cache_dir_with_stats; use crate::platform::fs::atomic_rename; use crate::platform::shortcuts::install_shortcuts; -use crate::releases::artifact_cache::prune_cached_artifacts; use crate::releases::manifest::decompress_release_index; use crate::releases::restore::{ retained_artifacts_for_cache_policy, retained_artifacts_for_cache_policy_without_index, @@ -247,12 +247,12 @@ where retained_artifacts_for_cache_policy_without_index(manager.artifact_retention_policy, &latest.full_filename) }; if let Some(retained_artifacts) = retained_artifacts { - match prune_cached_artifacts(artifact_cache_dir, &retained_artifacts) { - Ok(0) => {} - Ok(pruned) => { + match prune_install_artifact_cache_dir_with_stats(artifact_cache_dir, &retained_artifacts) { + Ok(result) if result.pruned_artifact_count == 0 => {} + Ok(result) => { debug!( - pruned, - retained = retained_artifacts.len(), + pruned = result.pruned_artifact_count, + retained = result.retained_policy_key_count, "Pruned stale local artifact cache entries" ); } diff --git a/crates/surge-installer-ui/src/install.rs b/crates/surge-installer-ui/src/install.rs index 028a68c..7f31b42 100644 --- a/crates/surge-installer-ui/src/install.rs +++ b/crates/surge-installer-ui/src/install.rs @@ -15,8 +15,8 @@ use surge_core::config::installer::InstallerManifest; use surge_core::config::manifest::ShortcutLocation; use surge_core::install::{self as core_install, InstallProfile, InstallProgress, InstallProgressStage}; use surge_core::installer_package::{ - InstallerPackageStage, ResolveInstallerPackageOptions, ResolvedInstallerPackage, prune_install_artifact_cache, - resolve_installer_package, retained_artifacts_for_install_cache_without_index, + InstallArtifactCachePruneResult, InstallerPackageStage, ResolveInstallerPackageOptions, ResolvedInstallerPackage, + prune_install_artifact_cache_with_stats, resolve_installer_package, retained_artifacts_for_resolved_install_cache, }; use surge_core::platform::paths::default_install_root; use surge_core::releases::restore::RestoreProgress; @@ -30,6 +30,12 @@ pub enum ProgressUpdate { type ResolvedPackage = ResolvedInstallerPackage; +enum ArtifactCachePruneOutcome { + Skipped, + Completed(InstallArtifactCachePruneResult), + Failed(String), +} + const RESOLVE_PROGRESS: f32 = 0.05; const DOWNLOAD_START_PROGRESS: f32 = 0.10; const DOWNLOAD_END_PROGRESS: f32 = 0.40; @@ -170,7 +176,11 @@ fn run_install_inner( &manifest.storage.endpoint, ); core_install::write_runtime_manifest(&install_root.join("app"), &profile, &runtime_manifest)?; - prune_artifact_cache_for_package(&install_root, &package, manifest); + send_artifact_cache_prune_outcome( + prune_artifact_cache_for_package(&install_root, &package, manifest), + progress_tx, + ctx, + ); send(progress_tx, ctx, ProgressUpdate::Progress(1.0)); send(progress_tx, ctx, ProgressUpdate::Complete(install_root)); @@ -449,25 +459,65 @@ pub fn run_headless( &manifest.storage.endpoint, ); core_install::write_runtime_manifest(&install_root.join("app"), &profile, &runtime_manifest)?; - prune_artifact_cache_for_package(&install_root, &package, manifest); + match prune_artifact_cache_for_package(&install_root, &package, manifest) { + ArtifactCachePruneOutcome::Skipped => {} + ArtifactCachePruneOutcome::Completed(result) => { + eprintln!("{}", format_artifact_cache_prune_result(result)); + } + ArtifactCachePruneOutcome::Failed(error) => { + eprintln!("Artifact cache pruning failed: {error}"); + } + } eprintln!("Installed '{}' to '{}'", manifest.app_id, install_root.display()); Ok(install_root) } -fn prune_artifact_cache_for_package(install_root: &Path, package: &ResolvedPackage, manifest: &InstallerManifest) { - let owned_retained_artifacts; - let retained_artifacts = match package.retained_artifacts.as_ref() { - Some(retained_artifacts) => retained_artifacts, - None => match retained_artifacts_for_install_cache_without_index(manifest) { - Some(retained_artifacts) => { - owned_retained_artifacts = retained_artifacts; - &owned_retained_artifacts - } - None => return, - }, +fn prune_artifact_cache_for_package( + install_root: &Path, + package: &ResolvedPackage, + manifest: &InstallerManifest, +) -> ArtifactCachePruneOutcome { + let Some(retained_artifacts) = retained_artifacts_for_resolved_install_cache(package, manifest) else { + return ArtifactCachePruneOutcome::Skipped; }; - let _ = prune_install_artifact_cache(install_root, retained_artifacts); + match prune_install_artifact_cache_with_stats(install_root, retained_artifacts.as_ref()) { + Ok(result) => ArtifactCachePruneOutcome::Completed(result), + Err(e) => ArtifactCachePruneOutcome::Failed(e.to_string()), + } +} + +fn send_artifact_cache_prune_outcome( + outcome: ArtifactCachePruneOutcome, + progress_tx: &Sender, + ctx: &egui::Context, +) { + match outcome { + ArtifactCachePruneOutcome::Skipped => {} + ArtifactCachePruneOutcome::Completed(result) => { + send( + progress_tx, + ctx, + ProgressUpdate::Status(format_artifact_cache_prune_result(result)), + ); + } + ArtifactCachePruneOutcome::Failed(error) => { + send( + progress_tx, + ctx, + ProgressUpdate::Status(format!("Artifact cache pruning failed: {error}")), + ); + } + } +} + +fn format_artifact_cache_prune_result(result: InstallArtifactCachePruneResult) -> String { + format!( + "Pruned {} stale artifact cache entr{}; retained policy key count: {}.", + result.pruned_artifact_count, + if result.pruned_artifact_count == 1 { "y" } else { "ies" }, + result.retained_policy_key_count + ) } #[cfg(test)]