Skip to content

Commit e12e2fa

Browse files
rzikmMihaZupan
andauthored
Implement MsQuicConfiguration cache (#99371)
* Implement MsQuicConfiguration cache * Fix creds with custom cipher suites * Polishing * Dispose discarded handle when racing to add into cache * Shuffle code around, add AppCtx switch for disabling * Code review feedback * Add comments, minor fixes * Fix failing test on Windows * Code review feedback * Apply suggestions from code review Co-authored-by: Miha Zupan <[email protected]> * Code review changes --------- Co-authored-by: Miha Zupan <[email protected]>
1 parent 8288d6a commit e12e2fa

File tree

4 files changed

+314
-15
lines changed

4 files changed

+314
-15
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Collections.Generic;
6+
using System.Collections.Concurrent;
7+
using System.Collections.ObjectModel;
8+
using System.Security.Authentication;
9+
using System.Net.Security;
10+
using System.Security.Cryptography.X509Certificates;
11+
using System.Threading;
12+
using Microsoft.Quic;
13+
14+
namespace System.Net.Quic;
15+
16+
internal static partial class MsQuicConfiguration
17+
{
18+
private const int CheckExpiredModulo = 32;
19+
20+
private const string DisableCacheEnvironmentVariable = "DOTNET_SYSTEM_NET_QUIC_DISABLE_CONFIGURATION_CACHE";
21+
private const string DisableCacheCtxSwitch = "System.Net.Quic.DisableConfigurationCache";
22+
23+
internal static bool ConfigurationCacheEnabled { get; } = GetConfigurationCacheEnabled();
24+
private static bool GetConfigurationCacheEnabled()
25+
{
26+
// AppContext switch takes precedence
27+
if (AppContext.TryGetSwitch(DisableCacheCtxSwitch, out bool value))
28+
{
29+
return !value;
30+
}
31+
else
32+
{
33+
// check environment variable
34+
return
35+
Environment.GetEnvironmentVariable(DisableCacheEnvironmentVariable) is string envVar &&
36+
!(envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase));
37+
}
38+
}
39+
40+
private static readonly ConcurrentDictionary<CacheKey, MsQuicConfigurationSafeHandle> s_configurationCache = new();
41+
42+
private readonly struct CacheKey : IEquatable<CacheKey>
43+
{
44+
public readonly List<byte[]> CertificateThumbprints;
45+
public readonly QUIC_CREDENTIAL_FLAGS Flags;
46+
public readonly QUIC_SETTINGS Settings;
47+
public readonly List<SslApplicationProtocol> ApplicationProtocols;
48+
public readonly QUIC_ALLOWED_CIPHER_SUITE_FLAGS AllowedCipherSuites;
49+
50+
public CacheKey(QUIC_SETTINGS settings, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol> alpnProtocols, QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites)
51+
{
52+
CertificateThumbprints = certificate == null ? new List<byte[]>() : new List<byte[]> { certificate.GetCertHash() };
53+
54+
if (intermediates != null)
55+
{
56+
foreach (X509Certificate2 intermediate in intermediates)
57+
{
58+
CertificateThumbprints.Add(intermediate.GetCertHash());
59+
}
60+
}
61+
62+
Flags = flags;
63+
Settings = settings;
64+
// make defensive copy to prevent modification (the list comes from user code)
65+
ApplicationProtocols = new List<SslApplicationProtocol>(alpnProtocols);
66+
AllowedCipherSuites = allowedCipherSuites;
67+
}
68+
69+
public override bool Equals(object? obj) => obj is CacheKey key && Equals(key);
70+
71+
public bool Equals(CacheKey other)
72+
{
73+
if (CertificateThumbprints.Count != other.CertificateThumbprints.Count)
74+
{
75+
return false;
76+
}
77+
78+
for (int i = 0; i < CertificateThumbprints.Count; i++)
79+
{
80+
if (!CertificateThumbprints[i].AsSpan().SequenceEqual(other.CertificateThumbprints[i]))
81+
{
82+
return false;
83+
}
84+
}
85+
86+
if (ApplicationProtocols.Count != other.ApplicationProtocols.Count)
87+
{
88+
return false;
89+
}
90+
91+
for (int i = 0; i < ApplicationProtocols.Count; i++)
92+
{
93+
if (ApplicationProtocols[i] != other.ApplicationProtocols[i])
94+
{
95+
return false;
96+
}
97+
}
98+
99+
return
100+
Flags == other.Flags &&
101+
Settings.Equals(other.Settings) &&
102+
AllowedCipherSuites == other.AllowedCipherSuites;
103+
}
104+
105+
public override int GetHashCode()
106+
{
107+
HashCode hash = default;
108+
109+
foreach (var thumbprint in CertificateThumbprints)
110+
{
111+
hash.AddBytes(thumbprint);
112+
}
113+
114+
hash.Add(Flags);
115+
hash.Add(Settings);
116+
117+
foreach (var protocol in ApplicationProtocols)
118+
{
119+
hash.AddBytes(protocol.Protocol.Span);
120+
}
121+
122+
hash.Add(AllowedCipherSuites);
123+
124+
return hash.ToHashCode();
125+
}
126+
}
127+
128+
private static MsQuicConfigurationSafeHandle GetCachedCredentialOrCreate(QUIC_SETTINGS settings, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol> alpnProtocols, QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites)
129+
{
130+
CacheKey key = new CacheKey(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites);
131+
132+
MsQuicConfigurationSafeHandle? handle;
133+
134+
if (s_configurationCache.TryGetValue(key, out handle) && handle.TryAddRentCount())
135+
{
136+
if (NetEventSource.Log.IsEnabled())
137+
{
138+
NetEventSource.Info(null, $"Found cached MsQuicConfiguration: {handle}.");
139+
}
140+
return handle;
141+
}
142+
143+
// if we get here, the handle is either not in the cache, or we lost the race between
144+
// TryAddRentCount on this thread and MarkForDispose on another thread doing cache cleanup.
145+
// In either case, we need to create a new handle.
146+
147+
if (NetEventSource.Log.IsEnabled())
148+
{
149+
NetEventSource.Info(null, $"MsQuicConfiguration not found in cache, creating new.");
150+
}
151+
152+
handle = CreateInternal(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites);
153+
handle.TryAddRentCount(); // we are the first renter
154+
155+
MsQuicConfigurationSafeHandle cached;
156+
do
157+
{
158+
cached = s_configurationCache.GetOrAdd(key, handle);
159+
}
160+
// If we get the same handle back, we successfully added it to the cache and we are done.
161+
// If we get a different handle back, we need to increase the rent count.
162+
// If we fail to add the rent count, then the existing/cached handle is in process of
163+
// being removed from the cache and we can try again, eventually either succeeding to add our
164+
// new handle or getting a fresh handle inserted by another thread meanwhile.
165+
while (cached != handle && !cached.TryAddRentCount());
166+
167+
if (cached != handle)
168+
{
169+
// we lost a race with another thread to insert new handle into the cache
170+
if (NetEventSource.Log.IsEnabled())
171+
{
172+
NetEventSource.Info(null, $"Discarding MsQuicConfiguration {handle} (preferring cached {cached}).");
173+
}
174+
175+
// First dispose decrements the rent count we added before attempting the cache insertion
176+
// and second closes the handle
177+
handle.Dispose();
178+
handle.Dispose();
179+
Debug.Assert(handle.IsClosed);
180+
181+
return cached;
182+
}
183+
184+
// we added a new handle, check if we need to cleanup
185+
var count = s_configurationCache.Count;
186+
if (count % CheckExpiredModulo == 0)
187+
{
188+
// let only one thread perform cleanup at a time
189+
lock (s_configurationCache)
190+
{
191+
// check again, if another thread just cleaned up (and cached count went down) we are unlikely
192+
// to clean anything
193+
if (s_configurationCache.Count >= count)
194+
{
195+
CleanupCache();
196+
}
197+
}
198+
}
199+
200+
return handle;
201+
}
202+
203+
private static void CleanupCache()
204+
{
205+
if (NetEventSource.Log.IsEnabled())
206+
{
207+
NetEventSource.Info(null, $"Cleaning up MsQuicConfiguration cache, current size: {s_configurationCache.Count}.");
208+
}
209+
210+
foreach ((CacheKey key, MsQuicConfigurationSafeHandle handle) in s_configurationCache)
211+
{
212+
if (!handle.TryMarkForDispose())
213+
{
214+
// handle in use
215+
continue;
216+
}
217+
218+
// the handle is not in use and has been marked such that no new rents can be added.
219+
if (NetEventSource.Log.IsEnabled())
220+
{
221+
NetEventSource.Info(null, $"Removing cached MsQuicConfiguration {handle}.");
222+
}
223+
224+
bool removed = s_configurationCache.TryRemove(key, out _);
225+
Debug.Assert(removed);
226+
handle.Dispose();
227+
Debug.Assert(handle.IsClosed);
228+
}
229+
230+
if (NetEventSource.Log.IsEnabled())
231+
{
232+
NetEventSource.Info(null, $"Cleaning up MsQuicConfiguration cache, new size: {s_configurationCache.Count}.");
233+
}
234+
}
235+
}

src/libraries/System.Net.Quic/src/System/Net/Quic/Internal/MsQuicConfiguration.cs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
namespace System.Net.Quic;
1313

14-
internal static class MsQuicConfiguration
14+
internal static partial class MsQuicConfiguration
1515
{
1616
private static bool HasPrivateKey(this X509Certificate certificate)
1717
=> certificate is X509Certificate2 certificate2 && certificate2.Handle != IntPtr.Zero && certificate2.HasPrivateKey;
1818

19-
public static MsQuicSafeHandle Create(QuicClientConnectionOptions options)
19+
public static MsQuicConfigurationSafeHandle Create(QuicClientConnectionOptions options)
2020
{
2121
SslClientAuthenticationOptions authenticationOptions = options.ClientAuthenticationOptions;
2222

@@ -79,7 +79,7 @@ public static MsQuicSafeHandle Create(QuicClientConnectionOptions options)
7979
return Create(options, flags, certificate, intermediates, authenticationOptions.ApplicationProtocols, authenticationOptions.CipherSuitesPolicy, authenticationOptions.EncryptionPolicy);
8080
}
8181

82-
public static MsQuicSafeHandle Create(QuicServerConnectionOptions options, string? targetHost)
82+
public static MsQuicConfigurationSafeHandle Create(QuicServerConnectionOptions options, string? targetHost)
8383
{
8484
SslServerAuthenticationOptions authenticationOptions = options.ServerAuthenticationOptions;
8585

@@ -117,7 +117,7 @@ public static MsQuicSafeHandle Create(QuicServerConnectionOptions options, strin
117117
return Create(options, flags, certificate, intermediates, authenticationOptions.ApplicationProtocols, authenticationOptions.CipherSuitesPolicy, authenticationOptions.EncryptionPolicy);
118118
}
119119

120-
private static unsafe MsQuicSafeHandle Create(QuicConnectionOptions options, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol>? alpnProtocols, CipherSuitesPolicy? cipherSuitesPolicy, EncryptionPolicy encryptionPolicy)
120+
private static MsQuicConfigurationSafeHandle Create(QuicConnectionOptions options, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol>? alpnProtocols, CipherSuitesPolicy? cipherSuitesPolicy, EncryptionPolicy encryptionPolicy)
121121
{
122122
// Validate options and SSL parameters.
123123
if (alpnProtocols is null || alpnProtocols.Count <= 0)
@@ -176,31 +176,51 @@ private static unsafe MsQuicSafeHandle Create(QuicConnectionOptions options, QUI
176176
: 0; // 0 disables the timeout
177177
}
178178

179+
QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites = QUIC_ALLOWED_CIPHER_SUITE_FLAGS.NONE;
180+
181+
if (cipherSuitesPolicy != null)
182+
{
183+
flags |= QUIC_CREDENTIAL_FLAGS.SET_ALLOWED_CIPHER_SUITES;
184+
allowedCipherSuites = CipherSuitePolicyToFlags(cipherSuitesPolicy);
185+
}
186+
187+
if (!MsQuicApi.UsesSChannelBackend)
188+
{
189+
flags |= QUIC_CREDENTIAL_FLAGS.USE_PORTABLE_CERTIFICATES;
190+
}
191+
192+
if (ConfigurationCacheEnabled)
193+
{
194+
return GetCachedCredentialOrCreate(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites);
195+
}
196+
197+
return CreateInternal(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites);
198+
}
199+
200+
private static unsafe MsQuicConfigurationSafeHandle CreateInternal(QUIC_SETTINGS settings, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol> alpnProtocols, QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites)
201+
{
179202
QUIC_HANDLE* handle;
180203

181204
using MsQuicBuffers msquicBuffers = new MsQuicBuffers();
182205
msquicBuffers.Initialize(alpnProtocols, alpnProtocol => alpnProtocol.Protocol);
183206
ThrowHelper.ThrowIfMsQuicError(MsQuicApi.Api.ConfigurationOpen(
184207
MsQuicApi.Api.Registration,
185208
msquicBuffers.Buffers,
186-
(uint)alpnProtocols.Count,
209+
(uint)msquicBuffers.Count,
187210
&settings,
188211
(uint)sizeof(QUIC_SETTINGS),
189212
(void*)IntPtr.Zero,
190213
&handle),
191214
"ConfigurationOpen failed");
192-
MsQuicSafeHandle configurationHandle = new MsQuicSafeHandle(handle, SafeHandleType.Configuration);
215+
MsQuicConfigurationSafeHandle configurationHandle = new MsQuicConfigurationSafeHandle(handle);
193216

194217
try
195218
{
196-
QUIC_CREDENTIAL_CONFIG config = new QUIC_CREDENTIAL_CONFIG { Flags = flags };
197-
config.Flags |= (MsQuicApi.UsesSChannelBackend ? QUIC_CREDENTIAL_FLAGS.NONE : QUIC_CREDENTIAL_FLAGS.USE_PORTABLE_CERTIFICATES);
198-
199-
if (cipherSuitesPolicy != null)
219+
QUIC_CREDENTIAL_CONFIG config = new QUIC_CREDENTIAL_CONFIG
200220
{
201-
config.Flags |= QUIC_CREDENTIAL_FLAGS.SET_ALLOWED_CIPHER_SUITES;
202-
config.AllowedCipherSuites = CipherSuitePolicyToFlags(cipherSuitesPolicy);
203-
}
221+
Flags = flags,
222+
AllowedCipherSuites = allowedCipherSuites
223+
};
204224

205225
int status;
206226
if (certificate is null)

src/libraries/System.Net.Quic/src/System/Net/Quic/Internal/MsQuicSafeHandle.cs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics;
55
using System.Runtime.InteropServices;
6+
using System.Threading;
67
using Microsoft.Quic;
78

89
namespace System.Net.Quic;
@@ -52,7 +53,8 @@ public MsQuicSafeHandle(QUIC_HANDLE* handle, SafeHandleType safeHandleType)
5253
SafeHandleType.Stream => MsQuicApi.Api.ApiTable->StreamClose,
5354
_ => throw new ArgumentException($"Unexpected value: {safeHandleType}", nameof(safeHandleType))
5455
},
55-
safeHandleType) { }
56+
safeHandleType)
57+
{ }
5658

5759
protected override bool ReleaseHandle()
5860
{
@@ -142,3 +144,46 @@ protected override unsafe bool ReleaseHandle()
142144
return true;
143145
}
144146
}
147+
148+
internal sealed class MsQuicConfigurationSafeHandle : MsQuicSafeHandle
149+
{
150+
// MsQuicConfiguration handles are cached, so we need to keep track of the
151+
// number of times a handle is rented. Once we decide to dispose the handle,
152+
// we set the _rentCount to -1.
153+
private volatile int _rentCount;
154+
155+
public unsafe MsQuicConfigurationSafeHandle(QUIC_HANDLE* handle)
156+
: base(handle, SafeHandleType.Configuration) { }
157+
158+
public bool TryAddRentCount()
159+
{
160+
int oldCount;
161+
162+
do
163+
{
164+
oldCount = _rentCount;
165+
if (oldCount < 0)
166+
{
167+
// The handle is already disposed.
168+
return false;
169+
}
170+
} while (Interlocked.CompareExchange(ref _rentCount, oldCount + 1, oldCount) != oldCount);
171+
172+
return true;
173+
}
174+
175+
public bool TryMarkForDispose()
176+
{
177+
return Interlocked.CompareExchange(ref _rentCount, -1, 0) == 0;
178+
}
179+
180+
protected override void Dispose(bool disposing)
181+
{
182+
if (Interlocked.Decrement(ref _rentCount) < 0)
183+
{
184+
// _rentCount is 0 if the handle was never rented (e.g. failure during creation),
185+
// and is -1 when evicted from cache.
186+
base.Dispose(disposing);
187+
}
188+
}
189+
}

0 commit comments

Comments
 (0)