From 0d86003918cda39a42032bdc18f0c2d646c87831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 2 Jun 2026 09:08:15 -0400 Subject: [PATCH 1/3] Avoid repeated preindexed source refreshes Use the freshest available source metadata or index.db timestamp when deciding whether a preindexed source cache is stale. Add C# and Rust regression coverage for stale metadata with a fresh index and clean up Rust temporary index files when replacement fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 36 ++++++++++++++++ .../src/Devolutions.Pinget.Core/Repository.cs | 12 ++++-- rust/crates/pinget-core/src/lib.rs | 43 ++++++++++++++++--- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 158dfcd..effa361 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -2947,6 +2947,42 @@ public void Show_PreindexedStaleIndex_RefreshesBeforeLatestSelection() } } + [Fact] + public void Show_PreindexedFreshIndexMtime_DoesNotRefreshForStaleSourceMetadata() + { + const string packageId = "Test.FreshIndexPackage"; + var initialCatalog = PreindexedCatalogFixture.Create(packageId, "1.0.0"); + var refreshedCatalog = PreindexedCatalogFixture.Create(packageId, "1.1.0", "1.0.0"); + + using var server = new TestPreindexedSourceServer(refreshedCatalog.MsixBytes, initialCatalog.Files.Concat(refreshedCatalog.Files)); + var appRoot = TestPaths.CreateTempAppRoot(); + try + { + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + PreIndexedSourceAutoUpdateInterval = TimeSpan.FromDays(1), + }); + ReplaceSources(repo, ("test", server.Url, SourceKind.PreIndexed)); + repo.ListSources().Single(source => source.Name == "test").LastUpdate = DateTime.UtcNow.AddDays(-2); + WritePreindexedIndex(appRoot, repo, "test", initialCatalog.IndexBytes); + + var result = repo.ShowManifest(new PackageQuery + { + Id = packageId, + Exact = true, + Source = "test", + }); + + Assert.Equal("1.0.0", result.PackageVersion); + Assert.Equal(0, server.SourceUpdateRequests); + } + finally + { + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void Show_PreindexedExplicitMissingVersion_DoesNotRefreshTwiceAfterStaleRefresh() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index c160d28..e83b1f4 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -1434,14 +1434,18 @@ private bool IsPreindexedIndexStale(SourceRecord source, string indexPath) if (_preIndexedSourceAutoUpdateInterval is null) return false; - var lastUpdate = source.LastUpdate; - if (lastUpdate is null && File.Exists(indexPath)) - lastUpdate = File.GetLastWriteTimeUtc(indexPath); + var lastUpdate = source.LastUpdate?.ToUniversalTime(); + if (File.Exists(indexPath)) + { + var indexLastWrite = File.GetLastWriteTimeUtc(indexPath); + if (lastUpdate is null || indexLastWrite > lastUpdate.Value) + lastUpdate = indexLastWrite; + } if (lastUpdate is null) return true; - return DateTime.UtcNow - lastUpdate.Value.ToUniversalTime() >= _preIndexedSourceAutoUpdateInterval.Value; + return DateTime.UtcNow - lastUpdate.Value >= _preIndexedSourceAutoUpdateInterval.Value; } private string RefreshPreindexedSource(int sourceIndex, bool force, PreIndexedRefreshKind kind) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index b3f4880..1186922 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -2864,12 +2864,19 @@ impl Repository { return false; }; - let last_update = source.last_update.or_else(|| { + let index_modified = || { fs::metadata(index_path) .ok() .and_then(|metadata| metadata.modified().ok()) .map(DateTime::::from) - }); + }; + + let last_update = match (source.last_update, index_modified()) { + (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)), + (Some(source_last_update), None) => Some(source_last_update), + (None, Some(index_last_write)) => Some(index_last_write), + (None, None) => None, + }; match last_update { Some(last_update) => Utc::now() - last_update >= interval, @@ -2982,7 +2989,10 @@ impl Repository { Utc::now().timestamp_nanos_opt().unwrap_or_default() )); fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?; - replace_file(&temp_index_path, &index_path).context("failed to persist source index")?; + if let Err(error) = replace_file(&temp_index_path, &index_path) { + let _ = fs::remove_file(&temp_index_path); + return Err(error).context("failed to persist source index"); + } // The new index.db is a fresh database; any WAL/SHM sidecars // left from the previous one no longer match it and would make // SQLite recover against the wrong database. Drop them so the @@ -12865,7 +12875,7 @@ Installers: let mut source = test_source_record(server.url()); source.last_update = Some(Utc::now() - Duration::days(2)); write_test_preindexed_index(&app_root, &source, &["1.0.0"]); - let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1))); + let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero())); let result = repository .show(&show_test_package(None)) @@ -12876,6 +12886,27 @@ Installers: let _ = fs::remove_dir_all(app_root); } + #[test] + fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() { + let app_root = temp_app_root("preindexed_fresh_mtime"); + let server = TestHttpServer::start(); + server.set_status("/source2.msix", 404); + server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh"); + server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0")); + let mut source = test_source_record(server.url()); + source.last_update = Some(Utc::now() - Duration::days(2)); + write_test_preindexed_index(&app_root, &source, &["1.0.0"]); + let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1))); + + let result = repository + .show(&show_test_package(None)) + .expect("show latest from fresh index mtime"); + + assert_eq!(result.manifest.version, "1.0.0"); + assert_eq!(server.request_count("/source.msix"), 0); + let _ = fs::remove_dir_all(app_root); + } + #[test] fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() { let app_root = temp_app_root("preindexed_ttl_fallback"); @@ -12886,7 +12917,7 @@ Installers: let mut source = test_source_record(server.url()); source.last_update = Some(Utc::now() - Duration::days(2)); write_test_preindexed_index(&app_root, &source, &["1.0.0"]); - let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1))); + let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero())); let result = repository .show(&show_test_package(None)) @@ -12905,7 +12936,7 @@ Installers: let mut source = test_source_record(server.url()); source.last_update = Some(Utc::now() - Duration::days(2)); write_test_preindexed_index(&app_root, &source, &["1.0.0"]); - let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1))); + let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero())); let error = repository .show(&show_test_package(Some("3.0.0"))) From ee72d49de9a0ea0980bf621c6fbb7a1f8cd1eaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 2 Jun 2026 09:14:34 -0400 Subject: [PATCH 2/3] Align preindexed refresh interval with WinGet Set the default preindexed source auto-update interval to 15 minutes to match winget-cli's source.autoUpdateIntervalInMinutes default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Devolutions.Pinget.Core/Models.cs | 2 +- rust/crates/pinget-core/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 29ee8a2..1226714 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -45,7 +45,7 @@ public record RepositoryOptions /// Maximum age for an existing pre-indexed source index before Pinget attempts a background refresh. /// Set to null to disable automatic freshness checks. /// - public TimeSpan? PreIndexedSourceAutoUpdateInterval { get; init; } = TimeSpan.FromMinutes(5); + public TimeSpan? PreIndexedSourceAutoUpdateInterval { get; init; } = TimeSpan.FromMinutes(15); } public record RepositoryWarning diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 1186922..eeaf15b 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -41,7 +41,7 @@ const DEFAULT_MAX_RESULTS: usize = 50; const LIST_LOOKUP_MAX_RESULTS: usize = 500; const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"]; const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1"; -const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 5; +const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15; const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5; #[cfg(windows)] const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; From e8ba31b1dd79b54a6a5057e74224fa83b7516774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 2 Jun 2026 09:33:47 -0400 Subject: [PATCH 3/3] Bump Pinget version to 0.8.2 Run scripts/Set-PingetVersion.ps1 0.8.2 to update Rust, C#, PowerShell, and NuGet package version surfaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Devolutions.Pinget.Cli/Program.cs | 2 +- .../ModuleFiles/Devolutions.Pinget.Client.psd1 | 2 +- .../PowerShellEngineVersion.cs | 2 +- .../Devolutions.Pinget.Cli.DotNet.csproj | 2 +- .../Devolutions.Pinget.Cli.Rust.csproj | 2 +- rust/crates/pinget-cli/Cargo.toml | 4 ++-- rust/crates/pinget-com/Cargo.toml | 2 +- rust/crates/pinget-core/Cargo.toml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index 955aac8..dc2cec1 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -6,7 +6,7 @@ using Devolutions.Pinget.Cli; using Devolutions.Pinget.Core; -const string Version = "0.8.1"; +const string Version = "0.8.2"; const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made."; if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase))) diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index ee8f831..ec28782 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Pinget.Client.psm1' - ModuleVersion = '0.8.1' + ModuleVersion = '0.8.2' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd' Author = 'Devolutions' diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs index 35746fb..4abc080 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine; public static class PowerShellEngineVersion { - public const string Current = "0.8.1"; + public const string Current = "0.8.2"; } diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj index d03f398..4e7cbf0 100644 --- a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj @@ -1,7 +1,7 @@ - 0.8.1 + 0.8.2 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.DotNet diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj index 89cd0ee..6b69cfe 100644 --- a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj @@ -1,7 +1,7 @@ - 0.8.1 + 0.8.2 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.Rust diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml index 6f243c9..45e1679 100644 --- a/rust/crates/pinget-cli/Cargo.toml +++ b/rust/crates/pinget-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-cli" -version = "0.8.1" +version = "0.8.2" edition = "2024" [lints] @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -pinget-core = { version = "0.8.1", path = "../pinget-core" } +pinget-core = { version = "0.8.2", path = "../pinget-core" } chrono = "0.4.44" dirs = "6.0" jsonschema = "0.30" diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml index 102dd18..1941996 100644 --- a/rust/crates/pinget-com/Cargo.toml +++ b/rust/crates/pinget-com/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-com" -version = "0.8.1" +version = "0.8.2" edition = "2024" description = "Windows-only native COM bridge for Pinget backed by pinget-core." license = "MIT" diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 6c74c28..c6c0116 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-core" -version = "0.8.1" +version = "0.8.2" edition = "2024" description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM." license = "MIT"