Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions build/Shared/AotCompatibilityAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#if !NET9_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
internal sealed class FeatureSwitchDefinitionAttribute : Attribute
{
public FeatureSwitchDefinitionAttribute(string switchName) => SwitchName = switchName;
public string SwitchName { get; }
}
}
#endif
35 changes: 35 additions & 0 deletions build/Shared/NuGetFeatureFlags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Diagnostics.CodeAnalysis;
using NuGet.Common;

namespace NuGet.Shared
{
internal static class NuGetFeatureFlags
{
internal const string UseNSJDeserializationSwitchName = "NuGet.UseNSJDeserialization";
internal const string UseNSJDeserializationEnvVar = "NUGET_USE_NSJ_DESERIALIZATION";
Comment thread
Nigusu-Allehu marked this conversation as resolved.
Outdated

private static readonly Lazy<bool> _isNSJDeserializationEnabledByEnvironment =
new Lazy<bool>(() => IsNSJDeserializationEnabledByEnvironment(EnvironmentVariableWrapper.Instance));

/// <summary>Feature switch for NSJ deserialization. Defaults to <see langword="false"/> (STJ is the default).</summary>
[FeatureSwitchDefinition(UseNSJDeserializationSwitchName)]
internal static bool UseNSJDeserializationFeatureSwitch { get; } =
AppContext.TryGetSwitch(UseNSJDeserializationSwitchName, out bool value) && value;

/// <summary>Returns <see langword="true"/> when env var <c>NUGET_USE_NSJ_DESERIALIZATION</c> is <c>true</c>.</summary>
internal static bool IsNSJDeserializationEnabledByEnvironment(IEnvironmentVariableReader? env = null)
{
if (env is null)
{
return _isNSJDeserializationEnabledByEnvironment.Value;
}

string? envValue = env.GetEnvironmentVariable(UseNSJDeserializationEnvVar);
return string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase);
}
}
}
13 changes: 13 additions & 0 deletions src/NuGet.Core/NuGet.Protocol/Model/AutoCompleteModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Text.Json.Serialization;

namespace NuGet.Protocol.Model
{
internal sealed class AutoCompleteModel
{
[JsonPropertyName("data")]
public string[]? Data { get; set; }
}
}
2 changes: 2 additions & 0 deletions src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<Compile Include="$(SharedDirectory)\HashCodeCombiner.cs" />
<Compile Include="$(SharedDirectory)\NoAllocEnumerateExtensions.cs" />
<Compile Include="$(SharedDirectory)\NullableAttributes.cs" />
<Compile Include="$(SharedDirectory)\NuGetFeatureFlags.cs" />
<Compile Include="$(SharedDirectory)\AotCompatibilityAttributes.cs" />
<Compile Include="$(SharedDirectory)\SimplePool.cs" />
<Compile Include="$(SharedDirectory)\StringBuilderPool.cs" />
<Compile Include="$(SharedDirectory)\TaskResult.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,42 @@
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NuGet.Common;
using NuGet.Protocol.Core.Types;
using NuGet.Protocol.Model;
using NuGet.Protocol.Utility;
using NuGet.Shared;
using NuGet.Versioning;

namespace NuGet.Protocol
{
public class AutoCompleteResourceV3 : AutoCompleteResource
{
private readonly RegistrationResourceV3 _regResource;
private readonly ServiceIndexResourceV3 _serviceIndex;
private readonly HttpSource _client;
internal readonly RegistrationResourceV3 _regResource;
internal readonly ServiceIndexResourceV3 _serviceIndex;
internal readonly HttpSource _client;
private readonly IEnvironmentVariableReader _environmentVariableReader;

public AutoCompleteResourceV3(HttpSource client, ServiceIndexResourceV3 serviceIndex, RegistrationResourceV3 regResource)
: base()
{
_regResource = regResource;
_serviceIndex = serviceIndex;
_client = client;
_environmentVariableReader = EnvironmentVariableWrapper.Instance;
}

internal AutoCompleteResourceV3(HttpSource client, ServiceIndexResourceV3 serviceIndex, RegistrationResourceV3 regResource, IEnvironmentVariableReader environmentVariableReader)
: base()
{
_regResource = regResource;
_serviceIndex = serviceIndex;
_client = client;
_environmentVariableReader = environmentVariableReader;
}

public override async Task<IEnumerable<string>> IdStartsWith(
Expand All @@ -53,17 +69,51 @@ public override async Task<IEnumerable<string>> IdStartsWith(
queryUrl.Query = queryString;

Common.ILogger logger = log ?? Common.NullLogger.Instance;

var queryUri = queryUrl.Uri;

if (NuGetFeatureFlags.UseNSJDeserializationFeatureSwitch || NuGetFeatureFlags.IsNSJDeserializationEnabledByEnvironment(_environmentVariableReader))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should learn from #7266, because this pattern here will read the environment variable every time, and on .NET Framework that causes memory allocations every time. Making it both testable and avoid allocations is difficult, but we need to find a solution.

When that other PR was open, I remember someone sharing an idea that EnvironmentVariableWrapper could cache values in a dictionary, so it avoids calling the BCL API that causes allocations after the first time. I don't love the idea because adding to static collections feels like a memory leak, but at the least the number of environment variables we check is low. If we can't find a better solution, then implementing this will be an easy fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have been caching. The idea was to pass null in production and the caching is done in the NuGetFeatureClassFlag class. And pass env value during test to avoid the caching.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 37 isn't using null, it's using EnvironmentVariableWrapper.Instance, so it's not going to cache. This shows a risk of this design. It's very prone to error, preventing caching. Even if you manually change it, I'm wondering if AI generated code is going to try to optimisticly keep using EnvironmentVariableWrapper.Instance since that's what's done in most other parts of the code.

From an API design point of view, the best APIs are hard or impossible to use wrong. So, fixing the null value is an option, but if we can find another design, we might be able to reduce risk of repeating the same error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce the risk of future mistakes, I've added an XML docomment on IsLegacyJsonDeserializationEnabledByEnvironment that explicitly says to pass null in production for caching, and only pass a reader in tests. Since it's internal, we can revisit the design at any point during the migration if we find something cleaner.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the comment is helping since I'd say we'll lean towards making it testable as much as wee can
How about we just not cache it right now?
Convention that's not strongly typed is prone to errors. It's not even being used in this PR as is.
Just remove that code for now.

There's other ways we can cache things and we can talk about that as you make progress.

An example could be to cache it at the provider level.

We reuse SourceRepository instances, and we reuse providers in restore https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/CachingSourceProvider.cs

The provider can initialize the resource based on that.

So cache it in the repository provider and pass it down (non public way).

Sure that means every provider has to cache it, but I don't know if that's necessarily a big deal. We're only hitting these codepaths when restore things it needs to download something.
That's gonna be way more costly. This is not the same problem as the one package id validator one that ran every time.
The more I think about it, the less I like that one too.

Note that caching idea is similar to 01276fc.

We can chat more ideas, but having code that's actually not being exercised is unnecessary.

{
return await IdStartsWithNsjAsync(packageIdPrefix, logger, queryUri, token);
Comment thread
Nigusu-Allehu marked this conversation as resolved.
Outdated
}

AutoCompleteModel results = await _client.ProcessStreamAsync(
Copy link
Copy Markdown

@richlander richlander Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised that AutoCompleteModel is always non-null. Is that correct?

Does this library not have nullable enabled?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet. It's #nullable disabled for now, but it's a work in progress

new HttpSourceRequest(queryUri, logger),
async stream =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the team code style is, if they link lamdas defined ahead of the method call or within it.

{
if (stream == null)
{
return null;
}

return await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.AutoCompleteModel, token);
},
logger,
token);

token.ThrowIfCancellationRequested();

return results?.Data?.Where(item => item != null && item.StartsWith(packageIdPrefix, StringComparison.OrdinalIgnoreCase))
?? [];
}

private async Task<IEnumerable<string>> IdStartsWithNsjAsync(
string packageIdPrefix,
Common.ILogger logger,
Uri queryUri,
CancellationToken token)
{
var results = await _client.GetJObjectAsync(
new HttpSourceRequest(queryUri, logger),
logger,
token);

token.ThrowIfCancellationRequested();

if (results == null)
{
return Enumerable.Empty<string>();
}

var data = results.Value<JArray>("data");
if (data == null)
{
Expand Down
1 change: 1 addition & 0 deletions src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace NuGet.Protocol.Utility
[JsonSerializable(typeof(HttpFileSystemBasedFindPackageByIdResource.FlatContainerVersionList))]
[JsonSerializable(typeof(IReadOnlyList<V3VulnerabilityIndexEntry>), TypeInfoPropertyName = "VulnerabilityIndex")]
[JsonSerializable(typeof(CaseInsensitiveDictionary<IReadOnlyList<PackageVulnerabilityInfo>>), TypeInfoPropertyName = "VulnerabilityPage")]
[JsonSerializable(typeof(AutoCompleteModel))]
internal partial class JsonContext : JsonSerializerContext
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NuGet.Common;
using NuGet.Protocol.Core.Types;
using NuGet.Shared;
using NuGet.Test.Utility;
using Test.Utility;
using Xunit;
Expand All @@ -14,25 +17,26 @@ namespace NuGet.Protocol.Tests
{
public class AutoCompleteResourceV3Tests
{
[Fact]
public async Task AutoCompleteResourceV3_IdStartsWithAsync()
[Theory]
[InlineData("true")] // NSJ path
[InlineData("false")] // STJ path
public async Task IdStartsWith_BothPaths_ReturnsResultsAsync(string useNsj)
{
// Arrange
Comment thread
Nigusu-Allehu marked this conversation as resolved.
var responses = new Dictionary<string, string>();
const string sourceName = "http://testsource.com/v3/index.json";
responses.Add(sourceName, JsonData.IndexWithoutFlatContainer);
responses.Add("http://testsource.com/v3/index.json", JsonData.IndexWithoutFlatContainer);
Comment thread
Nigusu-Allehu marked this conversation as resolved.
Outdated
responses.Add("https://api-v3search-0.nuget.org/autocomplete?q=newt&prerelease=true&semVerLevel=2.0.0",
JsonData.AutoCompleteEndpointNewtResult);

var repo = StaticHttpHandler.CreateSource(sourceName, Repository.Provider.GetCoreV3(), responses);
var resource = await repo.GetResourceAsync<AutoCompleteResource>(CancellationToken.None);
var repo = StaticHttpHandler.CreateSource("http://testsource.com/v3/index.json", Repository.Provider.GetCoreV3(), responses);
var resource = (AutoCompleteResourceV3)await repo.GetResourceAsync<AutoCompleteResource>(CancellationToken.None);
Comment thread
Nigusu-Allehu marked this conversation as resolved.
Outdated

var logger = new TestLogger();
var envReader = new Mock<IEnvironmentVariableReader>();
envReader.Setup(e => e.GetEnvironmentVariable(NuGetFeatureFlags.UseNSJDeserializationEnvVar)).Returns(useNsj);
var testResource = new AutoCompleteResourceV3(resource._client, resource._serviceIndex, resource._regResource, envReader.Object);

// Act
var result = await resource.IdStartsWith("newt", true, logger, CancellationToken.None);
var logger = new TestLogger();
var result = await testResource.IdStartsWith("newt", true, logger, CancellationToken.None);

// Assert
Assert.Equal(10, result.Count());
Assert.NotEmpty(logger.Messages);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using NuGet.Shared;
using Test.Utility;
using Xunit;

namespace NuGet.Protocol.Tests
{
public class NuGetFeatureFlagsTests
{
[Fact]
public void UseNSJDeserializationFeatureSwitch_Default_ReturnsFalse()
{
Assert.False(NuGetFeatureFlags.UseNSJDeserializationFeatureSwitch);
}

[Fact]
public void IsNSJDeserializationEnabledByEnvironment_WhenEnvVarNotSet_ReturnsFalse()
{
Assert.False(NuGetFeatureFlags.IsNSJDeserializationEnabledByEnvironment(TestEnvironmentVariableReader.EmptyInstance));
}

[Theory]
[InlineData("true")]
[InlineData("True")]
[InlineData("TRUE")]
public void IsNSJDeserializationEnabledByEnvironment_WhenEnvVarSetToTrue_ReturnsTrue(string value)
{
var env = new TestEnvironmentVariableReader(
new Dictionary<string, string> { [NuGetFeatureFlags.UseNSJDeserializationEnvVar] = value });

Assert.True(NuGetFeatureFlags.IsNSJDeserializationEnabledByEnvironment(env));
}

[Theory]
[InlineData("false")]
[InlineData("0")]
[InlineData("1")]
[InlineData("anything")]
public void IsNSJDeserializationEnabledByEnvironment_WhenEnvVarSetToFalseOrUnrecognized_ReturnsFalse(string value)
{
var env = new TestEnvironmentVariableReader(
new Dictionary<string, string> { [NuGetFeatureFlags.UseNSJDeserializationEnvVar] = value });

Assert.False(NuGetFeatureFlags.IsNSJDeserializationEnabledByEnvironment(env));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<Compile Include="$(SharedDirectory)\*.cs" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
<Compile Remove="$(SharedDirectory)\IsExternalInit.cs" />
<Compile Remove="$(SharedDirectory)\RequiredModifierAttributes.cs" />
<Compile Remove="$(SharedDirectory)\AotCompatibilityAttributes.cs" />
<Compile Remove="$(SharedDirectory)\NuGetFeatureFlags.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\TestUtilities\Test.Utility\Test.Utility.csproj" />
Expand Down
Loading