From bcecdea1853e0a57fb8a1e4abbd469f0d03c3382 Mon Sep 17 00:00:00 2001 From: Adam Stachowicz Date: Sun, 26 Apr 2026 14:04:27 +0200 Subject: [PATCH 1/2] Refresh HTTP cache on miss for exact-version restore When a restore asks for an exact package version that the cached versions list does not contain, refresh the HTTP cache once and retry before declaring the package unresolved. This eliminates spurious NU1102 failures for the common publish-then-consume scenario without requiring users to discover --no-cache or clear the HTTP cache by hand. The refresh reuses the existing SourceCacheContext.WithRefreshCacheTrue plumbing, is bounded to at most one extra HTTP round-trip per cache key per restore, only fires on the failure path, and is opt-out via the NUGET_HTTP_CACHE_REFRESH_ON_MISS environment variable. Floating ranges already satisfied by the cached list are left alone to avoid amplifying traffic on common restores. Addresses NuGet/Home#3116. --- .../ResolverUtility.cs | 122 ++++++++++++++---- .../Strings.Designer.cs | 9 ++ .../Strings.resx | 4 + .../xlf/Strings.cs.xlf | 5 + .../xlf/Strings.de.xlf | 5 + .../xlf/Strings.es.xlf | 5 + .../xlf/Strings.fr.xlf | 5 + .../xlf/Strings.it.xlf | 5 + .../xlf/Strings.ja.xlf | 5 + .../xlf/Strings.ko.xlf | 5 + .../xlf/Strings.pl.xlf | 5 + .../xlf/Strings.pt-BR.xlf | 5 + .../xlf/Strings.ru.xlf | 5 + .../xlf/Strings.tr.xlf | 5 + .../xlf/Strings.zh-Hans.xlf | 5 + .../xlf/Strings.zh-Hant.xlf | 5 + .../FindPackageTests.cs | 114 ++++++++++++++++ 17 files changed, 292 insertions(+), 22 deletions(-) diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs b/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs index 699b5c3188b..e77bffc5d9f 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs @@ -17,6 +17,23 @@ namespace NuGet.DependencyResolver { public static class ResolverUtility { + // Opt-out for refresh-on-miss. See https://github.com/NuGet/Home/issues/3116. + // When set to "false" (case-insensitive), NuGet will not refresh the HTTP cache when + // the cached versions list does not satisfy the requested version range. + private const string RefreshHttpCacheOnMissEnvVar = "NUGET_HTTP_CACHE_REFRESH_ON_MISS"; + + private static readonly Lazy s_refreshHttpCacheOnMissEnabled = new(static () => + IsRefreshOnMissEnabled(EnvironmentVariableWrapper.Instance)); + + internal static bool IsRefreshHttpCacheOnMissEnabled => s_refreshHttpCacheOnMissEnabled.Value; + + internal static bool IsRefreshOnMissEnabled(IEnvironmentVariableReader environmentVariableReader) + { + string? value = environmentVariableReader.GetEnvironmentVariable(RefreshHttpCacheOnMissEnvVar); + return !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "0", StringComparison.Ordinal); + } + public static Task> FindLibraryCachedAsync( LibraryRange libraryRange, NuGetFramework framework, @@ -55,6 +72,8 @@ public static async Task> FindLibraryEntryAsync( LogIfPackageSourceMappingIsEnabled(libraryRange.Name, context, remoteDependencyProviders); } + bool httpCacheRefreshedOnMiss = false; + // Try up to two times to get the package. The second // retry will refresh the cache if a package is listed // but fails to download. This can happen if the feed prunes @@ -74,40 +93,99 @@ public static async Task> FindLibraryEntryAsync( context.Logger, cancellationToken); + bool isStaleCacheMiss = match == null || IsCacheStaleForExactVersion(match, libraryRange); + + if (isStaleCacheMiss + && !httpCacheRefreshedOnMiss + && IsRefreshHttpCacheOnMissEnabled + && libraryRange.TypeConstraintAllows(LibraryDependencyTarget.Package) + && libraryRange.VersionRange != null + && remoteDependencyProviders.Any(p => p.IsHttp)) + { + // The cached versions list for at least one HTTP-backed source did not contain + // the requested version. Refresh the HTTP cache once and retry before declaring + // the package unresolved. See https://github.com/NuGet/Home/issues/3116. + httpCacheRefreshedOnMiss = true; + currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); + + context.Logger.LogMinimal(string.Format( + CultureInfo.CurrentCulture, + Strings.Log_RefreshingHttpCacheOnMiss, + libraryRange.Name, + libraryRange.VersionRange.ToString())); + + // Restart the loop so we re-query providers with a fresh cache. + i = -1; + continue; + } + if (match == null) { return CreateUnresolvedResult(libraryRange); } - else + + if (match.Library?.Type == LibraryType.Unresolved) { + // Already exhausted the refresh-on-miss path (or it wasn't applicable) and we still + // have an unresolved match. Return it as unresolved rather than attempting download. + return CreateUnresolvedResult(libraryRange); + } - try - { - graphItem = await CreateGraphItemAsync(match, framework, currentCacheContext, context.Logger, cancellationToken); - } - catch (InvalidCacheProtocolException) when (i == 0) - { - // 1st failure, invalidate the cache and try again. - // Clear the on disk and memory caches during the next request. - currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); - } - catch (PackageNotFoundProtocolException ex) when (match.Provider.IsHttp && match.Provider.Source != null) - { - // 2nd failure, the feed is likely corrupt or removing packages too fast to keep up with. - var message = string.Format(CultureInfo.CurrentCulture, - Strings.Error_PackageNotFoundWhenExpected, - match.Provider.Source, - ex.PackageIdentity.ToString()); - context.Logger.LogError(message); - - throw new FatalProtocolException(message, ex); - } + try + { + graphItem = await CreateGraphItemAsync(match, framework, currentCacheContext, context.Logger, cancellationToken); + } + catch (InvalidCacheProtocolException) when (i == 0) + { + // 1st failure, invalidate the cache and try again. + // Clear the on disk and memory caches during the next request. + currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); + } + catch (PackageNotFoundProtocolException ex) when (match.Provider.IsHttp && match.Provider.Source != null) + { + // 2nd failure, the feed is likely corrupt or removing packages too fast to keep up with. + var message = string.Format(CultureInfo.CurrentCulture, + Strings.Error_PackageNotFoundWhenExpected, + match.Provider.Source, + ex.PackageIdentity.ToString()); + context.Logger.LogError(message); + + throw new FatalProtocolException(message, ex); } } return graphItem!; } + /// + /// Returns when the resolved match for an exact-version request + /// does not satisfy the requested minimum version, which strongly suggests the cached + /// versions list on at least one HTTP source is stale. + /// + /// + /// Only exact (non-floating, min-inclusive) version requests qualify: those are the ones + /// where "the cache says the version doesn't exist" is unambiguous. Floating ranges may + /// be legitimately satisfied by an older cached version. + /// + private static bool IsCacheStaleForExactVersion(RemoteMatch? match, LibraryRange libraryRange) + { + if (match?.Library?.Type != LibraryType.Unresolved) + { + return false; + } + + var versionRange = libraryRange.VersionRange; + if (versionRange == null + || versionRange.IsFloating + || !versionRange.IsMinInclusive + || versionRange.MinVersion == null) + { + return false; + } + + return true; + } + private static async Task> CreateGraphItemAsync( RemoteMatch match, NuGetFramework framework, diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs index 6e6c68f79c8..2b818ebe26e 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs @@ -86,5 +86,14 @@ internal static string Log_NoMatchingSourceFoundForPackage { return ResourceManager.GetString("Log_NoMatchingSourceFoundForPackage", resourceCulture); } } + + /// + /// Looks up a localized string similar to Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing.. + /// + internal static string Log_RefreshingHttpCacheOnMiss { + get { + return ResourceManager.GetString("Log_RefreshingHttpCacheOnMiss", resourceCulture); + } + } } } diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx index 6b2797d1847..d313b959573 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx @@ -120,6 +120,10 @@ The feed '{0}' lists package '{1}' but multiple attempts to download the nupkg have failed. The feed is either invalid or required packages were removed while the current operation was in progress. Verify the package exists on the feed and try again. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Package source mapping matches found for package ID '{0}' are: '{1}'. {0} - Package id {1} - list of sources diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf index 67ab5dc3fa9..72e48c434a0 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf @@ -17,6 +17,11 @@ Pro ID balíčku {0} se nenašla shoda mapování zdroje balíčku. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf index 3372fbf21f5..3c6629d965a 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf @@ -17,6 +17,11 @@ Die Paketquellzuordnungsübereinstimmung für die Paket-ID "{0}" wurde nicht gefunden. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf index c4393372b53..08f2d8d9e59 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf @@ -17,6 +17,11 @@ Las coincidencias de mapa de origen del paquete no se encuentran para el id. de paquete "{0}". {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf index 4e4f3b5af09..3f9ee9cdedb 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf @@ -17,6 +17,11 @@ Une correspondance de mappage de source de paquet n'a pas été trouvée pour l'ID de paquet '{0}'. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf index ec67ea98d38..555e1dc2d47 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf @@ -17,6 +17,11 @@ Corrispondenza del mapping di origine del pacchetto non trovata per l'ID pacchetto '{0}'. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf index 1e01a746281..7b9ddefe249 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf @@ -17,6 +17,11 @@ パッケージ ID '{0}' には一致するパッケージ ソース マッピングが見つかりませんでした。 {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf index 8b107a1399d..c1ec20bc696 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf @@ -17,6 +17,11 @@ ‘{0}’ 패키지 ID에 대한 패키지 소스 맵 일치 검색 결과가 없습니다. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf index e7be01b5b7c..94bd13d9323 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf @@ -17,6 +17,11 @@ Nie znaleziono dopasowania mapowania źródła pakietu dla identyfikatora pakietu "{0}". {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf index cd6f2ba0215..a2d155bcdbc 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf @@ -17,6 +17,11 @@ As correspondências de mapeamento de origem do pacote não foram encontradas para a ID '{0}'. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf index 5fd5859ce48..e97f898f6e8 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf @@ -17,6 +17,11 @@ Соответствия для сопоставления источника пакета с идентификатором "{0}" не обнаружены. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf index 07f365ae77e..403603b74c2 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf @@ -17,6 +17,11 @@ Paket kimliği '{0}' için paket kaynağı eşlemesi eşleşmeleri bulunamadı. {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf index adcc48c9c6d..516cbf30755 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf @@ -17,6 +17,11 @@ 找不到包 ID "{0}" 的包源映射匹配项。 {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf index 986e7d3f515..b3943391091 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf @@ -17,6 +17,11 @@ 找不到與套件識別碼 '{0}' 相符的套件來源對應。 {0} - Package id + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + \ No newline at end of file diff --git a/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs b/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs index 67d606f6e55..ee1716963ef 100644 --- a/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs +++ b/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs @@ -243,6 +243,120 @@ public async Task FindPackage_VerifyFindLibraryEntryReturnsOriginalCase() Assert.Equal("y", result.Data.Dependencies.Single().Name); } + [Fact] + public async Task FindPackage_WhenExactVersionNotInCachedList_RefreshesHttpCacheAndResolves() + { + // Regression test for https://github.com/NuGet/Home/issues/3116. + // The first call from a stale cache returns no match; after the resolver triggers + // a refresh-on-miss the same source returns the now-published version. + var range = new LibraryRange("x", VersionRange.Parse("1.0.2"), LibraryDependencyTarget.Package); + var cacheContext = new SourceCacheContext(); + var testLogger = new TestLogger(); + var framework = NuGetFramework.Parse("net45"); + var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); + var token = CancellationToken.None; + var actualIdentity = new LibraryIdentity("x", NuGetVersion.Parse("1.0.2"), LibraryType.Package); + var dependencies = new[] { new LibraryDependency() { LibraryRange = new LibraryRange("y", VersionRange.All, LibraryDependencyTarget.Package) } }; + var dependencyInfo = LibraryDependencyInfo.Create(actualIdentity, framework, dependencies); + + int findCallCount = 0; + bool sawRefreshFlag = false; + + var remoteProvider = new Mock(); + remoteProvider.SetupGet(e => e.IsHttp).Returns(true); + remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); + remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) + .Returns((_, _, ctx, _, _) => + { + findCallCount++; + if (ctx.RefreshMemoryCache) + { + sawRefreshFlag = true; + return Task.FromResult(actualIdentity); + } + return Task.FromResult(null); + }); + remoteProvider.Setup(e => e.GetDependenciesAsync(It.IsAny(), It.IsAny(), It.IsAny(), testLogger, token)) + .ReturnsAsync(dependencyInfo); + context.RemoteLibraryProviders.Add(remoteProvider.Object); + + var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); + + Assert.Equal(LibraryType.Package, result.Data.Match.Library.Type); + Assert.Equal("1.0.2", result.Data.Match.Library.Version.ToString()); + Assert.True(sawRefreshFlag, "The cache context should have been refreshed before the second lookup."); + Assert.Equal(2, findCallCount); + } + + [Fact] + public async Task FindPackage_WhenExactVersionStillNotFoundAfterRefresh_ReturnsUnresolved() + { + // Regression test for https://github.com/NuGet/Home/issues/3116: + // when refresh-on-miss runs and the version is genuinely absent, we surface unresolved + // (no extra refreshes, no NU1102 false-positives). + var range = new LibraryRange("x", VersionRange.Parse("1.0.2"), LibraryDependencyTarget.Package); + var cacheContext = new SourceCacheContext(); + var testLogger = new TestLogger(); + var framework = NuGetFramework.Parse("net45"); + var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); + var token = CancellationToken.None; + + int findCallCount = 0; + + var remoteProvider = new Mock(); + remoteProvider.SetupGet(e => e.IsHttp).Returns(true); + remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); + remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) + .Returns((_, _, _, _, _) => + { + findCallCount++; + return Task.FromResult(null); + }); + context.RemoteLibraryProviders.Add(remoteProvider.Object); + + var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); + + Assert.Equal(LibraryType.Unresolved, result.Data.Match.Library.Type); + Assert.Equal(2, findCallCount); + } + + [Fact] + public async Task FindPackage_WhenFloatingRangeIsSatisfiedFromStaleCache_DoesNotRefresh() + { + // Floating ranges should not trigger refresh-on-miss when the cached list satisfies them. + // This guards against amplifying traffic on common restores. + var range = new LibraryRange("x", VersionRange.Parse("1.0.0-*"), LibraryDependencyTarget.Package); + var cacheContext = new SourceCacheContext(); + var testLogger = new TestLogger(); + var framework = NuGetFramework.Parse("net45"); + var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); + var token = CancellationToken.None; + var actualIdentity = new LibraryIdentity("x", NuGetVersion.Parse("1.0.0-beta.1"), LibraryType.Package); + var dependencies = new[] { new LibraryDependency() { LibraryRange = new LibraryRange("y", VersionRange.All, LibraryDependencyTarget.Package) } }; + var dependencyInfo = LibraryDependencyInfo.Create(actualIdentity, framework, dependencies); + + int findCallCount = 0; + + var remoteProvider = new Mock(); + remoteProvider.SetupGet(e => e.IsHttp).Returns(true); + remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); + remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) + .Returns((_, _, _, _, _) => + { + findCallCount++; + return Task.FromResult(actualIdentity); + }); + remoteProvider.Setup(e => e.GetDependenciesAsync(It.IsAny(), It.IsAny(), It.IsAny(), testLogger, token)) + .ReturnsAsync(dependencyInfo); + context.RemoteLibraryProviders.Add(remoteProvider.Object); + + var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); + + Assert.Equal(LibraryType.Package, result.Data.Match.Library.Type); + // Floating range is already satisfied; we must not perform an extra refresh-on-miss query. + Assert.Equal(1, findCallCount); + } + [Fact] public async Task FindPackage_VerifyMissingVersionPackageReturnsUnresolved() { From 7236b75df0f67362bd00f2a99cc5c252eb406ee5 Mon Sep 17 00:00:00 2001 From: Adam Stachowicz Date: Mon, 8 Jun 2026 16:49:22 +0200 Subject: [PATCH 2/2] Refresh HTTP cache once per restore operation on exact-version miss (#3116) Move the refresh-on-miss logic from the per-project ResolverUtility into the operation-shared SourceRepositoryDependencyProvider so the HTTP cache is refreshed at most once per package id per restore operation, instead of once per project/range. This addresses the review feedback that the previous local guard did not coordinate across projects and could trigger redundant HTTP refreshes. The provider now tracks ids already refreshed and ids already fetched fresh during the operation, and only refreshes for exact (non-floating, min-inclusive) version misses on HTTP sources. Opt-out via NUGET_HTTP_CACHE_REFRESH_ON_MISS is preserved. The Log_RefreshingHttpCacheOnMiss resource moved to NuGet.Commands accordingly. --- .../SourceRepositoryDependencyProvider.cs | 96 +++++ .../NuGet.Commands/Strings.Designer.cs | 9 + src/NuGet.Core/NuGet.Commands/Strings.resx | 4 + .../NuGet.Commands/xlf/Strings.cs.xlf | 5 + .../NuGet.Commands/xlf/Strings.de.xlf | 5 + .../NuGet.Commands/xlf/Strings.es.xlf | 5 + .../NuGet.Commands/xlf/Strings.fr.xlf | 5 + .../NuGet.Commands/xlf/Strings.it.xlf | 5 + .../NuGet.Commands/xlf/Strings.ja.xlf | 5 + .../NuGet.Commands/xlf/Strings.ko.xlf | 5 + .../NuGet.Commands/xlf/Strings.pl.xlf | 5 + .../NuGet.Commands/xlf/Strings.pt-BR.xlf | 5 + .../NuGet.Commands/xlf/Strings.ru.xlf | 5 + .../NuGet.Commands/xlf/Strings.tr.xlf | 5 + .../NuGet.Commands/xlf/Strings.zh-Hans.xlf | 5 + .../NuGet.Commands/xlf/Strings.zh-Hant.xlf | 5 + .../ResolverUtility.cs | 122 ++---- .../Strings.Designer.cs | 9 - .../Strings.resx | 4 - .../xlf/Strings.cs.xlf | 5 - .../xlf/Strings.de.xlf | 5 - .../xlf/Strings.es.xlf | 5 - .../xlf/Strings.fr.xlf | 5 - .../xlf/Strings.it.xlf | 5 - .../xlf/Strings.ja.xlf | 5 - .../xlf/Strings.ko.xlf | 5 - .../xlf/Strings.pl.xlf | 5 - .../xlf/Strings.pt-BR.xlf | 5 - .../xlf/Strings.ru.xlf | 5 - .../xlf/Strings.tr.xlf | 5 - .../xlf/Strings.zh-Hans.xlf | 5 - .../xlf/Strings.zh-Hant.xlf | 5 - ...SourceRepositoryDependencyProviderTests.cs | 368 ++++++++++++++++++ .../FindPackageTests.cs | 114 ------ 34 files changed, 564 insertions(+), 292 deletions(-) diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/SourceRepositoryDependencyProvider.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/SourceRepositoryDependencyProvider.cs index 17eb6647aa4..98b96fc5bc2 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/SourceRepositoryDependencyProvider.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/SourceRepositoryDependencyProvider.cs @@ -4,7 +4,9 @@ #nullable disable using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -36,10 +38,34 @@ public class SourceRepositoryDependencyProvider : IRemoteDependencyProvider private bool _ignoreWarning; private bool _isFallbackFolderSource; private bool _useLegacyAssetTargetFallbackBehavior; + private readonly bool _refreshHttpCacheOnMissEnabled; private readonly TaskResultCache _dependencyInfoCache = new(); private readonly TaskResultCache _libraryMatchCache = new(); + // Refresh-on-miss coordination, scoped to this provider instance. Because the provider is + // cached and shared across all projects in a restore operation (see RestoreCommandProvidersCache), + // these collections are effectively operation-wide. See https://github.com/NuGet/Home/issues/3116. + + // Package ids for which the HTTP cache has already been refreshed once during this operation. + // Used to guarantee at most one refresh-on-miss per id per operation. + private readonly ConcurrentDictionary _idsRefreshedOnMiss = new(StringComparer.OrdinalIgnoreCase); + + // Package ids whose version list was already fetched with a fresh cache during this operation. + // A later miss for such an id must not trigger another refresh: the data is already authoritative. + private readonly ConcurrentDictionary _idsFetchedThisOperation = new(StringComparer.OrdinalIgnoreCase); + + // Opt-out for refresh-on-miss. When set to "false"/"0" (case-insensitive), NuGet will not refresh + // the HTTP cache when the cached versions list does not satisfy an exact requested version. + private const string RefreshHttpCacheOnMissEnvVar = "NUGET_HTTP_CACHE_REFRESH_ON_MISS"; + + internal static bool IsRefreshOnMissEnabled(IEnvironmentVariableReader environmentVariableReader) + { + string value = environmentVariableReader.GetEnvironmentVariable(RefreshHttpCacheOnMissEnvVar); + return !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "0", StringComparison.Ordinal); + } + // Limiting concurrent requests to limit the amount of files open at a time. private readonly static SemaphoreSlim _throttle = GetThrottleSemaphoreSlim(EnvironmentVariableWrapper.Instance); internal static SemaphoreSlim GetThrottleSemaphoreSlim(IEnvironmentVariableReader env) @@ -138,6 +164,7 @@ internal SourceRepositoryDependencyProvider( _packageFileCache = fileCache; _isFallbackFolderSource = isFallbackFolderSource; _useLegacyAssetTargetFallbackBehavior = MSBuildStringUtility.IsTrue(environmentVariableReader.GetEnvironmentVariable("NUGET_USE_LEGACY_ASSET_TARGET_FALLBACK_DEPENDENCY_RESOLUTION")); + _refreshHttpCacheOnMissEnabled = IsRefreshOnMissEnabled(environmentVariableReader); } /// @@ -238,6 +265,75 @@ private async Task FindLibraryCoreAsync( { await EnsureResource(cancellationToken); + string id = libraryRange.Name; + + LibraryIdentity result = await FindLibraryFromFeedAsync(libraryRange, cacheContext, logger, cancellationToken); + + // If this lookup already consulted a fresh cache (an explicit refresh-on-miss, --no-cache, + // or the existing download-retry path), record the id so a later miss for the same id does + // not trigger another, redundant refresh during this operation. + if (cacheContext.RefreshMemoryCache) + { + _idsFetchedThisOperation[id] = 0; + } + + if (result != null) + { + return result; + } + + // Refresh-on-miss: the cached versions list for this HTTP source did not contain the + // requested exact version. Refresh the HTTP cache once per id per restore operation and + // retry before declaring the package unresolved. This eliminates spurious NU1102 failures + // for the publish-then-consume scenario. See https://github.com/NuGet/Home/issues/3116. + if (ShouldRefreshHttpCacheOnMiss(libraryRange, cacheContext) + && !_idsFetchedThisOperation.ContainsKey(id) + && _idsRefreshedOnMiss.TryAdd(id, 0)) + { + logger.LogMinimal(string.Format( + CultureInfo.CurrentCulture, + Strings.Log_RefreshingHttpCacheOnMiss, + id, + libraryRange.VersionRange.ToString())); + + SourceCacheContext refreshedCacheContext = cacheContext.WithRefreshCacheTrue(); + + result = await FindLibraryFromFeedAsync(libraryRange, refreshedCacheContext, logger, cancellationToken); + + _idsFetchedThisOperation[id] = 0; + } + + return result; + } + + /// + /// Determines whether a cache miss for the given should trigger a + /// one-time HTTP cache refresh. Only exact (non-floating, min-inclusive) version requests against an + /// HTTP source qualify: those are the ones where "the cache says the version doesn't exist" is + /// unambiguous. Floating ranges may be legitimately satisfied by an older cached version. + /// + private bool ShouldRefreshHttpCacheOnMiss(LibraryRange libraryRange, SourceCacheContext cacheContext) + { + if (!_refreshHttpCacheOnMissEnabled + || !IsHttp + || cacheContext.RefreshMemoryCache) + { + return false; + } + + VersionRange versionRange = libraryRange.VersionRange; + return versionRange != null + && !versionRange.IsFloating + && versionRange.IsMinInclusive + && versionRange.MinVersion != null; + } + + private async Task FindLibraryFromFeedAsync( + LibraryRange libraryRange, + SourceCacheContext cacheContext, + ILogger logger, + CancellationToken cancellationToken) + { if (libraryRange.VersionRange?.MinVersion != null && libraryRange.VersionRange.IsMinInclusive && !libraryRange.VersionRange.IsFloating) { // first check if the exact min version exist then simply return that diff --git a/src/NuGet.Core/NuGet.Commands/Strings.Designer.cs b/src/NuGet.Core/NuGet.Commands/Strings.Designer.cs index 06bd26772ce..95a764ce9ab 100644 --- a/src/NuGet.Core/NuGet.Commands/Strings.Designer.cs +++ b/src/NuGet.Core/NuGet.Commands/Strings.Designer.cs @@ -1637,6 +1637,15 @@ internal static string Log_RestoreNoOpFinish { } } + /// + /// Looks up a localized string similar to Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing.. + /// + internal static string Log_RefreshingHttpCacheOnMiss { + get { + return ResourceManager.GetString("Log_RefreshingHttpCacheOnMiss", resourceCulture); + } + } + /// /// Looks up a localized string similar to Restoring packages for {0}.... /// diff --git a/src/NuGet.Core/NuGet.Commands/Strings.resx b/src/NuGet.Core/NuGet.Commands/Strings.resx index d52747a4410..8d3a3835750 100644 --- a/src/NuGet.Core/NuGet.Commands/Strings.resx +++ b/src/NuGet.Core/NuGet.Commands/Strings.resx @@ -142,6 +142,10 @@ Resolving conflicts for {0}... + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Restoring packages for {0}... diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.cs.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.cs.xlf index 922b78073ba..fa346d54c6d 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.cs.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.cs.xlf @@ -884,6 +884,11 @@ Upgradujte svou sadu .NET SDK nebo odeberte RestoreUseLegacyDependencyResolver, Čte se soubor projektu {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Nejde vyhovět konfliktním žádostem pro {0}: {1} Architektura: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.de.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.de.xlf index 29067bd8dfe..6b346d9b58e 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.de.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.de.xlf @@ -884,6 +884,11 @@ Aktualisieren Sie Ihr .NET SDK oder entfernen Sie RestoreUseLegacyDependencyReso Projektdatei "{0}" wird gelesen. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Die einen Konflikt verursachenden Anforderungen für "{0}" können nicht erfüllt werden: {1} Framework: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.es.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.es.xlf index b2fe07d63ff..104d9fd3905 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.es.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.es.xlf @@ -884,6 +884,11 @@ Actualice el SDK de .NET o quite RestoreUseLegacyDependencyResolver para usar es Leyendo el archivo del proyecto {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} No se pueden satisfacer las solicitudes en conflicto para "{0}": {1} marco de trabajo {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.fr.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.fr.xlf index fcbba8a8d1f..dedac54888d 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.fr.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.fr.xlf @@ -884,6 +884,11 @@ Mettez à niveau votre Kit de développement logiciel (SDK) .NET ou supprimez Re Lecture du fichier projet {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Impossible de satisfaire les requêtes en conflit pour '{0}' : {1} Framework : {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.it.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.it.xlf index f1e6755e468..8e768af0736 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.it.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.it.xlf @@ -884,6 +884,11 @@ Per usare questa funzionalità, aggiornare .NET SDK o rimuovere RestoreUseLegacy Lettura del file del progetto {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Non è possibile soddisfare le richieste in conflitto per '{0}': {1}. Framework: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ja.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ja.xlf index 8fdc1a0171a..8f2b526bdc0 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ja.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ja.xlf @@ -884,6 +884,11 @@ Upgrade your .NET SDK or remove RestoreUseLegacyDependencyResolver to use this f プロジェクト ファイル {0} を読み取っています。 + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} '{0}' の競合する要求を満たすことができません: {1} フレームワーク: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ko.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ko.xlf index 3dbb384be5a..91035e78e9a 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ko.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ko.xlf @@ -884,6 +884,11 @@ Upgrade your .NET SDK or remove RestoreUseLegacyDependencyResolver to use this f 프로젝트 파일 {0}을(를) 읽는 중입니다. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} '{0}'에 대해 충돌하는 요청을 충족할 수 없습니다. {1} 프레임워크: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.pl.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.pl.xlf index f44dfce65d7..0da1eab8713 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.pl.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.pl.xlf @@ -884,6 +884,11 @@ Uaktualnij zestaw .NET SDK lub usuń RestoreUseLegacyDependencyResolver, aby kor Odczytywanie pliku projektu {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Nie można zrealizować żądań będących w konflikcie dla elementu „{0}”: {1}, struktura: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.pt-BR.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.pt-BR.xlf index eff837c3089..6e0a26fc5fa 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.pt-BR.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.pt-BR.xlf @@ -884,6 +884,11 @@ Atualize o SDK do .NET ou remova RestoreUseLegacyDependencyResolver para usar es Lendo o arquivo de projeto {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Não foi possível satisfazer às solicitações conflitantes de '{0}': {1} Estrutura: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ru.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ru.xlf index 2c161ae6197..632abb578ce 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.ru.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.ru.xlf @@ -884,6 +884,11 @@ Upgrade your .NET SDK or remove RestoreUseLegacyDependencyResolver to use this f Чтение файла проекта {0}. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} Не удалось удовлетворить конфликтующие запросы для "{0}": {1}. Платформа: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.tr.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.tr.xlf index a84a95c4db5..a86cad5ed2c 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.tr.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.tr.xlf @@ -884,6 +884,11 @@ Bu özelliği kullanmak için .NET SDK'nizi yükseltin veya RestoreUseLegacyDepe {0} adlı proje dosyası okunuyor. + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} '{0}' için çakışan istekler giderilemiyor: {1} Çerçevesi: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hans.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hans.xlf index bc201f161c6..85e0d13a909 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hans.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hans.xlf @@ -884,6 +884,11 @@ Upgrade your .NET SDK or remove RestoreUseLegacyDependencyResolver to use this f 正在读取项目文件 {0}。 + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} 无法满足“{0}”的互相冲突的请求: {1} 框架: {2} diff --git a/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hant.xlf b/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hant.xlf index f79c778bee3..a6c2870aa53 100644 --- a/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hant.xlf +++ b/src/NuGet.Core/NuGet.Commands/xlf/Strings.zh-Hant.xlf @@ -884,6 +884,11 @@ Upgrade your .NET SDK or remove RestoreUseLegacyDependencyResolver to use this f 正在讀取專案檔 {0}。 + + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. + {0} - Package id, {1} - version range string + Unable to satisfy conflicting requests for '{0}': {1} Framework: {2} 無法滿足 '{0}' 的衝突要求: {1} 架構: {2} diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs b/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs index e77bffc5d9f..699b5c3188b 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/ResolverUtility.cs @@ -17,23 +17,6 @@ namespace NuGet.DependencyResolver { public static class ResolverUtility { - // Opt-out for refresh-on-miss. See https://github.com/NuGet/Home/issues/3116. - // When set to "false" (case-insensitive), NuGet will not refresh the HTTP cache when - // the cached versions list does not satisfy the requested version range. - private const string RefreshHttpCacheOnMissEnvVar = "NUGET_HTTP_CACHE_REFRESH_ON_MISS"; - - private static readonly Lazy s_refreshHttpCacheOnMissEnabled = new(static () => - IsRefreshOnMissEnabled(EnvironmentVariableWrapper.Instance)); - - internal static bool IsRefreshHttpCacheOnMissEnabled => s_refreshHttpCacheOnMissEnabled.Value; - - internal static bool IsRefreshOnMissEnabled(IEnvironmentVariableReader environmentVariableReader) - { - string? value = environmentVariableReader.GetEnvironmentVariable(RefreshHttpCacheOnMissEnvVar); - return !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) - && !string.Equals(value, "0", StringComparison.Ordinal); - } - public static Task> FindLibraryCachedAsync( LibraryRange libraryRange, NuGetFramework framework, @@ -72,8 +55,6 @@ public static async Task> FindLibraryEntryAsync( LogIfPackageSourceMappingIsEnabled(libraryRange.Name, context, remoteDependencyProviders); } - bool httpCacheRefreshedOnMiss = false; - // Try up to two times to get the package. The second // retry will refresh the cache if a package is listed // but fails to download. This can happen if the feed prunes @@ -93,99 +74,40 @@ public static async Task> FindLibraryEntryAsync( context.Logger, cancellationToken); - bool isStaleCacheMiss = match == null || IsCacheStaleForExactVersion(match, libraryRange); - - if (isStaleCacheMiss - && !httpCacheRefreshedOnMiss - && IsRefreshHttpCacheOnMissEnabled - && libraryRange.TypeConstraintAllows(LibraryDependencyTarget.Package) - && libraryRange.VersionRange != null - && remoteDependencyProviders.Any(p => p.IsHttp)) - { - // The cached versions list for at least one HTTP-backed source did not contain - // the requested version. Refresh the HTTP cache once and retry before declaring - // the package unresolved. See https://github.com/NuGet/Home/issues/3116. - httpCacheRefreshedOnMiss = true; - currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); - - context.Logger.LogMinimal(string.Format( - CultureInfo.CurrentCulture, - Strings.Log_RefreshingHttpCacheOnMiss, - libraryRange.Name, - libraryRange.VersionRange.ToString())); - - // Restart the loop so we re-query providers with a fresh cache. - i = -1; - continue; - } - if (match == null) { return CreateUnresolvedResult(libraryRange); } - - if (match.Library?.Type == LibraryType.Unresolved) + else { - // Already exhausted the refresh-on-miss path (or it wasn't applicable) and we still - // have an unresolved match. Return it as unresolved rather than attempting download. - return CreateUnresolvedResult(libraryRange); - } - try - { - graphItem = await CreateGraphItemAsync(match, framework, currentCacheContext, context.Logger, cancellationToken); - } - catch (InvalidCacheProtocolException) when (i == 0) - { - // 1st failure, invalidate the cache and try again. - // Clear the on disk and memory caches during the next request. - currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); - } - catch (PackageNotFoundProtocolException ex) when (match.Provider.IsHttp && match.Provider.Source != null) - { - // 2nd failure, the feed is likely corrupt or removing packages too fast to keep up with. - var message = string.Format(CultureInfo.CurrentCulture, - Strings.Error_PackageNotFoundWhenExpected, - match.Provider.Source, - ex.PackageIdentity.ToString()); - context.Logger.LogError(message); - - throw new FatalProtocolException(message, ex); + try + { + graphItem = await CreateGraphItemAsync(match, framework, currentCacheContext, context.Logger, cancellationToken); + } + catch (InvalidCacheProtocolException) when (i == 0) + { + // 1st failure, invalidate the cache and try again. + // Clear the on disk and memory caches during the next request. + currentCacheContext = currentCacheContext.WithRefreshCacheTrue(); + } + catch (PackageNotFoundProtocolException ex) when (match.Provider.IsHttp && match.Provider.Source != null) + { + // 2nd failure, the feed is likely corrupt or removing packages too fast to keep up with. + var message = string.Format(CultureInfo.CurrentCulture, + Strings.Error_PackageNotFoundWhenExpected, + match.Provider.Source, + ex.PackageIdentity.ToString()); + context.Logger.LogError(message); + + throw new FatalProtocolException(message, ex); + } } } return graphItem!; } - /// - /// Returns when the resolved match for an exact-version request - /// does not satisfy the requested minimum version, which strongly suggests the cached - /// versions list on at least one HTTP source is stale. - /// - /// - /// Only exact (non-floating, min-inclusive) version requests qualify: those are the ones - /// where "the cache says the version doesn't exist" is unambiguous. Floating ranges may - /// be legitimately satisfied by an older cached version. - /// - private static bool IsCacheStaleForExactVersion(RemoteMatch? match, LibraryRange libraryRange) - { - if (match?.Library?.Type != LibraryType.Unresolved) - { - return false; - } - - var versionRange = libraryRange.VersionRange; - if (versionRange == null - || versionRange.IsFloating - || !versionRange.IsMinInclusive - || versionRange.MinVersion == null) - { - return false; - } - - return true; - } - private static async Task> CreateGraphItemAsync( RemoteMatch match, NuGetFramework framework, diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs index 2b818ebe26e..6e6c68f79c8 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.Designer.cs @@ -86,14 +86,5 @@ internal static string Log_NoMatchingSourceFoundForPackage { return ResourceManager.GetString("Log_NoMatchingSourceFoundForPackage", resourceCulture); } } - - /// - /// Looks up a localized string similar to Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing.. - /// - internal static string Log_RefreshingHttpCacheOnMiss { - get { - return ResourceManager.GetString("Log_RefreshingHttpCacheOnMiss", resourceCulture); - } - } } } diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx index d313b959573..6b2797d1847 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/Strings.resx @@ -120,10 +120,6 @@ The feed '{0}' lists package '{1}' but multiple attempts to download the nupkg have failed. The feed is either invalid or required packages were removed while the current operation was in progress. Verify the package exists on the feed and try again. - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - Package source mapping matches found for package ID '{0}' are: '{1}'. {0} - Package id {1} - list of sources diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf index 72e48c434a0..67ab5dc3fa9 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.cs.xlf @@ -17,11 +17,6 @@ Pro ID balíčku {0} se nenašla shoda mapování zdroje balíčku. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf index 3c6629d965a..3372fbf21f5 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.de.xlf @@ -17,11 +17,6 @@ Die Paketquellzuordnungsübereinstimmung für die Paket-ID "{0}" wurde nicht gefunden. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf index 08f2d8d9e59..c4393372b53 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.es.xlf @@ -17,11 +17,6 @@ Las coincidencias de mapa de origen del paquete no se encuentran para el id. de paquete "{0}". {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf index 3f9ee9cdedb..4e4f3b5af09 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.fr.xlf @@ -17,11 +17,6 @@ Une correspondance de mappage de source de paquet n'a pas été trouvée pour l'ID de paquet '{0}'. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf index 555e1dc2d47..ec67ea98d38 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.it.xlf @@ -17,11 +17,6 @@ Corrispondenza del mapping di origine del pacchetto non trovata per l'ID pacchetto '{0}'. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf index 7b9ddefe249..1e01a746281 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ja.xlf @@ -17,11 +17,6 @@ パッケージ ID '{0}' には一致するパッケージ ソース マッピングが見つかりませんでした。 {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf index c1ec20bc696..8b107a1399d 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ko.xlf @@ -17,11 +17,6 @@ ‘{0}’ 패키지 ID에 대한 패키지 소스 맵 일치 검색 결과가 없습니다. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf index 94bd13d9323..e7be01b5b7c 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pl.xlf @@ -17,11 +17,6 @@ Nie znaleziono dopasowania mapowania źródła pakietu dla identyfikatora pakietu "{0}". {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf index a2d155bcdbc..cd6f2ba0215 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.pt-BR.xlf @@ -17,11 +17,6 @@ As correspondências de mapeamento de origem do pacote não foram encontradas para a ID '{0}'. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf index e97f898f6e8..5fd5859ce48 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.ru.xlf @@ -17,11 +17,6 @@ Соответствия для сопоставления источника пакета с идентификатором "{0}" не обнаружены. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf index 403603b74c2..07f365ae77e 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.tr.xlf @@ -17,11 +17,6 @@ Paket kimliği '{0}' için paket kaynağı eşlemesi eşleşmeleri bulunamadı. {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf index 516cbf30755..adcc48c9c6d 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hans.xlf @@ -17,11 +17,6 @@ 找不到包 ID "{0}" 的包源映射匹配项。 {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf index b3943391091..986e7d3f515 100644 --- a/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf +++ b/src/NuGet.Core/NuGet.DependencyResolver.Core/xlf/Strings.zh-Hant.xlf @@ -17,11 +17,6 @@ 找不到與套件識別碼 '{0}' 相符的套件來源對應。 {0} - Package id - - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - Cached versions for '{0}' did not contain a version satisfying '{1}'; refreshing the HTTP cache once before failing. - {0} - Package id, {1} - version range string - \ No newline at end of file diff --git a/test/NuGet.Core.Tests/NuGet.Commands.Test/SourceRepositoryDependencyProviderTests.cs b/test/NuGet.Core.Tests/NuGet.Commands.Test/SourceRepositoryDependencyProviderTests.cs index 61d9fabf260..056c39fc2c2 100644 --- a/test/NuGet.Core.Tests/NuGet.Commands.Test/SourceRepositoryDependencyProviderTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Commands.Test/SourceRepositoryDependencyProviderTests.cs @@ -1001,6 +1001,374 @@ public async Task GetDependenciesAsync_WhenPackageIsSelectedWithAssetTargetFallb } } + [Fact] + public async Task FindLibraryAsync_WhenExactVersionMissesStaleHttpCache_RefreshesOnceAndResolves() + { + // Regression test for https://github.com/NuGet/Home/issues/3116. + // A stale cache misses the exact version on the first pass; after the provider refreshes + // the HTTP cache once, the same source returns the now-published version. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int existsCallCount = 0; + int refreshedExistsCallCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.DoesPackageExistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, _, ctx, _, _) => + { + existsCallCount++; + if (ctx.RefreshMemoryCache) + { + refreshedExistsCallCount++; + return Task.FromResult(true); + } + + return Task.FromResult(false); + }); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource("http://test/index.json")); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true); + + var libraryRange = new LibraryRange("x", new VersionRange(new NuGetVersion(1, 0, 0)), LibraryDependencyTarget.Package); + + // Act + var result = await provider.FindLibraryAsync( + libraryRange, + NuGetFramework.Parse("net45"), + cacheContext, + testLogger, + CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("x", result.Name); + Assert.Equal("1.0.0", result.Version.ToString()); + // One stale pass and exactly one refreshed pass. + Assert.Equal(2, existsCallCount); + Assert.Equal(1, refreshedExistsCallCount); + } + + [Fact] + public async Task FindLibraryAsync_WhenExactVersionGenuinelyMissing_RefreshesOnceAndReturnsNull() + { + // When refresh-on-miss runs and the version is genuinely absent, we return unresolved + // without performing more than one refresh. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int existsCallCount = 0; + int refreshedPassCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.DoesPackageExistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, _, ctx, _, _) => + { + existsCallCount++; + if (ctx.RefreshMemoryCache) + { + refreshedPassCount++; + } + + return Task.FromResult(false); + }); + findResource.Setup(s => s.GetAllVersionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Enumerable.Empty()); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource("http://test/index.json")); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true); + + var libraryRange = new LibraryRange("x", new VersionRange(new NuGetVersion(1, 0, 0)), LibraryDependencyTarget.Package); + + // Act + var result = await provider.FindLibraryAsync( + libraryRange, + NuGetFramework.Parse("net45"), + cacheContext, + testLogger, + CancellationToken.None); + + // Assert + Assert.Null(result); + // Exactly one stale pass and one refreshed pass. + Assert.Equal(2, existsCallCount); + Assert.Equal(1, refreshedPassCount); + } + + [Fact] + public async Task FindLibraryAsync_WhenSameIdMissesForMultipleProjects_RefreshesAtMostOncePerOperation() + { + // The provider instance is shared across projects in a restore operation, so a second project + // resolving a different (still missing) version of the same id must not trigger another refresh. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int refreshedPassCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.DoesPackageExistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, _, ctx, _, _) => + { + if (ctx.RefreshMemoryCache) + { + refreshedPassCount++; + } + + return Task.FromResult(false); + }); + findResource.Setup(s => s.GetAllVersionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Enumerable.Empty()); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource("http://test/index.json")); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true); + + var firstProjectRange = new LibraryRange("x", new VersionRange(new NuGetVersion(1, 0, 0)), LibraryDependencyTarget.Package); + var secondProjectRange = new LibraryRange("x", new VersionRange(new NuGetVersion(2, 0, 0)), LibraryDependencyTarget.Package); + + // Act - two projects sharing the same provider resolve different missing versions of "x". + var firstResult = await provider.FindLibraryAsync( + firstProjectRange, NuGetFramework.Parse("net45"), cacheContext, testLogger, CancellationToken.None); + var secondResult = await provider.FindLibraryAsync( + secondProjectRange, NuGetFramework.Parse("net45"), cacheContext, testLogger, CancellationToken.None); + + // Assert + Assert.Null(firstResult); + Assert.Null(secondResult); + // Only the first miss should have triggered a refresh. + Assert.Equal(1, refreshedPassCount); + } + + [Fact] + public async Task FindLibraryAsync_WhenFloatingRangeMisses_DoesNotRefresh() + { + // Floating ranges must not trigger refresh-on-miss; this guards against amplifying traffic. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int refreshedPassCount = 0; + int getAllVersionsCallCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.GetAllVersionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, ctx, _, _) => + { + getAllVersionsCallCount++; + if (ctx.RefreshMemoryCache) + { + refreshedPassCount++; + } + + return Task.FromResult(Enumerable.Empty()); + }); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource("http://test/index.json")); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true); + + var libraryRange = new LibraryRange("x", VersionRange.Parse("1.0.0-*"), LibraryDependencyTarget.Package); + + // Act + var result = await provider.FindLibraryAsync( + libraryRange, NuGetFramework.Parse("net45"), cacheContext, testLogger, CancellationToken.None); + + // Assert + Assert.Null(result); + Assert.Equal(0, refreshedPassCount); + Assert.Equal(1, getAllVersionsCallCount); + } + + [Fact] + public async Task FindLibraryAsync_WhenRefreshOnMissIsOptedOut_DoesNotRefresh() + { + // NUGET_HTTP_CACHE_REFRESH_ON_MISS=false disables the behavior. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int existsCallCount = 0; + int refreshedPassCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.DoesPackageExistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, _, ctx, _, _) => + { + existsCallCount++; + if (ctx.RefreshMemoryCache) + { + refreshedPassCount++; + } + + return Task.FromResult(false); + }); + findResource.Setup(s => s.GetAllVersionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Enumerable.Empty()); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource("http://test/index.json")); + + var environment = new TestEnvironmentVariableReader(new Dictionary + { + { "NUGET_HTTP_CACHE_REFRESH_ON_MISS", "false" } + }); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true, + fileCache: null, + isFallbackFolderSource: false, + environment); + + var libraryRange = new LibraryRange("x", new VersionRange(new NuGetVersion(1, 0, 0)), LibraryDependencyTarget.Package); + + // Act + var result = await provider.FindLibraryAsync( + libraryRange, NuGetFramework.Parse("net45"), cacheContext, testLogger, CancellationToken.None); + + // Assert + Assert.Null(result); + Assert.Equal(0, refreshedPassCount); + Assert.Equal(1, existsCallCount); + } + + [Fact] + public async Task FindLibraryAsync_WhenSourceIsNotHttp_DoesNotRefreshOnMiss() + { + // Only HTTP-backed sources have an HTTP cache to refresh. + var testLogger = new TestLogger(); + var cacheContext = new SourceCacheContext(); + + int existsCallCount = 0; + int refreshedPassCount = 0; + + var findResource = new Mock(); + findResource.Setup(s => s.DoesPackageExistAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((_, _, ctx, _, _) => + { + existsCallCount++; + if (ctx.RefreshMemoryCache) + { + refreshedPassCount++; + } + + return Task.FromResult(false); + }); + findResource.Setup(s => s.GetAllVersionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Enumerable.Empty()); + + var source = new Mock(); + source.Setup(s => s.GetResourceAsync(CancellationToken.None)) + .ReturnsAsync(findResource.Object); + source.SetupGet(s => s.PackageSource) + .Returns(new PackageSource(@"C:\local\packages")); + + var provider = new SourceRepositoryDependencyProvider( + source.Object, + testLogger, + cacheContext, + ignoreFailedSources: true, + ignoreWarning: true); + + var libraryRange = new LibraryRange("x", new VersionRange(new NuGetVersion(1, 0, 0)), LibraryDependencyTarget.Package); + + // Act + var result = await provider.FindLibraryAsync( + libraryRange, NuGetFramework.Parse("net45"), cacheContext, testLogger, CancellationToken.None); + + // Assert + Assert.Null(result); + Assert.Equal(0, refreshedPassCount); + Assert.Equal(1, existsCallCount); + } + private sealed class SourceRepositoryDependencyProviderTest : IDisposable { internal TestLogger Logger { get; } diff --git a/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs b/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs index ee1716963ef..67d606f6e55 100644 --- a/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs +++ b/test/NuGet.Core.Tests/NuGet.DependencyResolver.Core.Tests/FindPackageTests.cs @@ -243,120 +243,6 @@ public async Task FindPackage_VerifyFindLibraryEntryReturnsOriginalCase() Assert.Equal("y", result.Data.Dependencies.Single().Name); } - [Fact] - public async Task FindPackage_WhenExactVersionNotInCachedList_RefreshesHttpCacheAndResolves() - { - // Regression test for https://github.com/NuGet/Home/issues/3116. - // The first call from a stale cache returns no match; after the resolver triggers - // a refresh-on-miss the same source returns the now-published version. - var range = new LibraryRange("x", VersionRange.Parse("1.0.2"), LibraryDependencyTarget.Package); - var cacheContext = new SourceCacheContext(); - var testLogger = new TestLogger(); - var framework = NuGetFramework.Parse("net45"); - var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); - var token = CancellationToken.None; - var actualIdentity = new LibraryIdentity("x", NuGetVersion.Parse("1.0.2"), LibraryType.Package); - var dependencies = new[] { new LibraryDependency() { LibraryRange = new LibraryRange("y", VersionRange.All, LibraryDependencyTarget.Package) } }; - var dependencyInfo = LibraryDependencyInfo.Create(actualIdentity, framework, dependencies); - - int findCallCount = 0; - bool sawRefreshFlag = false; - - var remoteProvider = new Mock(); - remoteProvider.SetupGet(e => e.IsHttp).Returns(true); - remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); - remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) - .Returns((_, _, ctx, _, _) => - { - findCallCount++; - if (ctx.RefreshMemoryCache) - { - sawRefreshFlag = true; - return Task.FromResult(actualIdentity); - } - return Task.FromResult(null); - }); - remoteProvider.Setup(e => e.GetDependenciesAsync(It.IsAny(), It.IsAny(), It.IsAny(), testLogger, token)) - .ReturnsAsync(dependencyInfo); - context.RemoteLibraryProviders.Add(remoteProvider.Object); - - var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); - - Assert.Equal(LibraryType.Package, result.Data.Match.Library.Type); - Assert.Equal("1.0.2", result.Data.Match.Library.Version.ToString()); - Assert.True(sawRefreshFlag, "The cache context should have been refreshed before the second lookup."); - Assert.Equal(2, findCallCount); - } - - [Fact] - public async Task FindPackage_WhenExactVersionStillNotFoundAfterRefresh_ReturnsUnresolved() - { - // Regression test for https://github.com/NuGet/Home/issues/3116: - // when refresh-on-miss runs and the version is genuinely absent, we surface unresolved - // (no extra refreshes, no NU1102 false-positives). - var range = new LibraryRange("x", VersionRange.Parse("1.0.2"), LibraryDependencyTarget.Package); - var cacheContext = new SourceCacheContext(); - var testLogger = new TestLogger(); - var framework = NuGetFramework.Parse("net45"); - var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); - var token = CancellationToken.None; - - int findCallCount = 0; - - var remoteProvider = new Mock(); - remoteProvider.SetupGet(e => e.IsHttp).Returns(true); - remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); - remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) - .Returns((_, _, _, _, _) => - { - findCallCount++; - return Task.FromResult(null); - }); - context.RemoteLibraryProviders.Add(remoteProvider.Object); - - var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); - - Assert.Equal(LibraryType.Unresolved, result.Data.Match.Library.Type); - Assert.Equal(2, findCallCount); - } - - [Fact] - public async Task FindPackage_WhenFloatingRangeIsSatisfiedFromStaleCache_DoesNotRefresh() - { - // Floating ranges should not trigger refresh-on-miss when the cached list satisfies them. - // This guards against amplifying traffic on common restores. - var range = new LibraryRange("x", VersionRange.Parse("1.0.0-*"), LibraryDependencyTarget.Package); - var cacheContext = new SourceCacheContext(); - var testLogger = new TestLogger(); - var framework = NuGetFramework.Parse("net45"); - var context = new RemoteWalkContext(cacheContext, PackageSourceMapping.GetPackageSourceMapping(NullSettings.Instance), testLogger); - var token = CancellationToken.None; - var actualIdentity = new LibraryIdentity("x", NuGetVersion.Parse("1.0.0-beta.1"), LibraryType.Package); - var dependencies = new[] { new LibraryDependency() { LibraryRange = new LibraryRange("y", VersionRange.All, LibraryDependencyTarget.Package) } }; - var dependencyInfo = LibraryDependencyInfo.Create(actualIdentity, framework, dependencies); - - int findCallCount = 0; - - var remoteProvider = new Mock(); - remoteProvider.SetupGet(e => e.IsHttp).Returns(true); - remoteProvider.SetupGet(e => e.Source).Returns(new PackageSource("test")); - remoteProvider.Setup(e => e.FindLibraryAsync(range, It.IsAny(), It.IsAny(), testLogger, token)) - .Returns((_, _, _, _, _) => - { - findCallCount++; - return Task.FromResult(actualIdentity); - }); - remoteProvider.Setup(e => e.GetDependenciesAsync(It.IsAny(), It.IsAny(), It.IsAny(), testLogger, token)) - .ReturnsAsync(dependencyInfo); - context.RemoteLibraryProviders.Add(remoteProvider.Object); - - var result = await ResolverUtility.FindLibraryEntryAsync(range, framework, null, null, context, token); - - Assert.Equal(LibraryType.Package, result.Data.Match.Library.Type); - // Floating range is already satisfied; we must not perform an extra refresh-on-miss query. - Assert.Equal(1, findCallCount); - } - [Fact] public async Task FindPackage_VerifyMissingVersionPackageReturnsUnresolved() {