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/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; }