From 3b0135248e5d5d503d3b0c46cd294e02bb097605 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:17:16 +0100 Subject: [PATCH 01/17] Load and resolve assemblies into the current AssemblyLoadContext --- .../SqlConfigurableRetryLogicLoader.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs index 96eadb9d56..d13f48ed81 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -54,14 +54,24 @@ public SqlConfigurableRetryLogicLoader( // Just only one subscription to this event is required. // This class isn't supposed to be called more than one time; // SqlConfigurableRetryLogicManager manages a single instance of this class. - System.Runtime.Loader.AssemblyLoadContext.Default.Resolving -= Default_Resolving; - System.Runtime.Loader.AssemblyLoadContext.Default.Resolving += Default_Resolving; + AssemblyLoadContext currentAlc = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()); + + currentAlc.Resolving -= AssemblyLoadContext_Resolving; + currentAlc.Resolving += AssemblyLoadContext_Resolving; + currentAlc.Unloading += AssemblyLoadContext_Unloading; #endif AssignProviders(connectionRetryConfigs == null ? null : CreateRetryLogicProvider(cnnSectionName, connectionRetryConfigs), commandRetryConfigs == null ? null : CreateRetryLogicProvider(cmdSectionName, commandRetryConfigs)); } + #if NET + private static void AssemblyLoadContext_Unloading(AssemblyLoadContext alc) + { + alc.Resolving -= AssemblyLoadContext_Resolving; + } + #endif + private static SqlRetryLogicBaseProvider CreateRetryLogicProvider(string sectionName, ISqlConfigurableRetryConnectionSection configSection) { string methodName = nameof(CreateRetryLogicProvider); @@ -344,15 +354,15 @@ private static Assembly AssemblyResolver(AssemblyName arg) SqlClientEventSource.Log.TryTraceEvent(" Looking for '{2}' assembly by '{3}' full path." , TypeName, methodName, arg, fullPath); - return fullPath == null ? null : AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + return fullPath == null ? null : AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()).LoadFromAssemblyPath(fullPath); } /// /// Load assemblies on request. /// - private static Assembly Default_Resolving(AssemblyLoadContext arg1, AssemblyName arg2) + private static Assembly AssemblyLoadContext_Resolving(AssemblyLoadContext arg1, AssemblyName arg2) { - string methodName = nameof(Default_Resolving); + string methodName = nameof(AssemblyLoadContext_Resolving); string target = MakeFullPath(Environment.CurrentDirectory, arg2.Name); SqlClientEventSource.Log.TryTraceEvent(" Looking for '{2}' assembly that is requested by '{3}' ALC from '{4}' path." From 37adedac77ae66e1323cf739988fa10e71dacf32 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:46:29 +0100 Subject: [PATCH 02/17] Create new UnloadableLibrary type/project to test AssemblyLoadContext unloadability --- src/Microsoft.Data.SqlClient.slnx | 4 + ...icrosoft.Data.SqlClient.ManualTests.csproj | 2 + .../UnloadableAssemblyLoadContextTest.cs | 100 ++++++++++++++++++ .../tests/UnloadableLibrary/EntryPoint.cs | 35 ++++++ .../UnloadableLibrary.csproj | 23 ++++ 5 files changed, 164 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj diff --git a/src/Microsoft.Data.SqlClient.slnx b/src/Microsoft.Data.SqlClient.slnx index 9c03addd51..54863d7191 100644 --- a/src/Microsoft.Data.SqlClient.slnx +++ b/src/Microsoft.Data.SqlClient.slnx @@ -175,6 +175,10 @@ + + + + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj index be691606ce..bd9879f564 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj @@ -107,6 +107,7 @@ + @@ -138,6 +139,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs new file mode 100644 index 0000000000..9b8ec834aa --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if NET + +#nullable enable + +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common; +using Microsoft.Data.SqlClient.UnloadableLibrary; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests.SQL.Unloadability; + +[Trait("Set", "2")] +[Collection("AssemblyLoadContext")] +public class UnloadableAssemblyLoadContextTest +{ + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsTCPConnStringSetup))] + public async Task SecondaryAssemblyLoadContext_Unloads() + { + using LocalAppContextSwitchesHelper savedAppContextSwitchState = new(); + + string connStr = DataTestUtility.TCPConnectionString; + string typeName = typeof(EntryPoint).FullName!; + string libraryPath = typeof(EntryPoint).Assembly.Location; + + WeakReference alcWeakReference = await LoadAndUnloadAssemblyLoadContext(typeName, libraryPath, connStr); + + for (int i = 0; i < 10 && alcWeakReference.IsAlive; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + Assert.False(alcWeakReference.IsAlive); + } + + /// + /// Creates an unloadable , then acts within it to open a SqlConnection + /// and to execute a command. + /// + /// The connection to open. + /// A to the AssemblyLoadContext. + /// + /// This method makes use of relection to avoid accidentally creating a hard reference to the + /// secondary ALC's type definitions. It is also marked as + /// in order to ensure that this weak reference is guaranteed to be out of scope in the method's caller. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string connectionString) + { + // This method loads the entry point type into a new, collectible AssemblyLoadContext. It + // then manually instantiates this, and invokes GetDate and GetDateAsync. + AssemblyLoadContext alc = new(nameof(SecondaryAssemblyLoadContext_Unloads), isCollectible: true); + WeakReference weakRef = new(alc, trackResurrection: true); + Assembly loadedAsm = alc.LoadFromAssemblyPath(assemblyPath); + + Type? unloadableLibraryType = loadedAsm.GetType(typeName); + Assert.NotNull(unloadableLibraryType); + + object? instantiated = Activator.CreateInstance(unloadableLibraryType, [connectionString]); + Assert.NotNull(instantiated); + + MethodInfo? getDateMethod = unloadableLibraryType.GetMethod("GetDate"); + Assert.NotNull(getDateMethod); + + getDateMethod.Invoke(instantiated, null); + + MethodInfo? getDateAsyncMethod = unloadableLibraryType.GetMethod("GetDateAsync"); + Assert.NotNull(getDateAsyncMethod); + + Task? getDateTask = getDateAsyncMethod.Invoke(instantiated, null) as Task; + Assert.NotNull(getDateTask); + + await getDateTask; + + alc.Unload(); + + return weakRef; + } +} + +/// +/// Defines a test collection that serializes execution of test classes +/// which load and unload s. +/// +[CollectionDefinition("AssemblyLoadContext")] +public class UnloadableAssemblyLoadContextCollection +{ +} + + +#endif diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs new file mode 100644 index 0000000000..8d0fd38fbc --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Data.SqlClient.UnloadableLibrary; + +public sealed class EntryPoint +{ + public string ConnectionString { get; } + + public EntryPoint(string connectionString) + { + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); + + ConnectionString = connectionString; + } + + public void GetDate() + { + using SqlConnection conn = new(ConnectionString); + using SqlCommand cmd = new("SELECT GETDATE()", conn); + + conn.Open(); + cmd.ExecuteScalar(); + } + + public async Task GetDateAsync() + { + using SqlConnection conn = new(ConnectionString); + using SqlCommand cmd = new("SELECT GETDATE()", conn); + + await conn.OpenAsync().ConfigureAwait(false); + await cmd.ExecuteScalarAsync().ConfigureAwait(false); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj new file mode 100644 index 0000000000..7c82386a73 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj @@ -0,0 +1,23 @@ + + + UnloadableLibrary + + enable + enable + + false + + + + + net462;net8.0;net9.0;net10.0 + + + + + + + + \ No newline at end of file From bb136dfbc8d958b3e140fe1e05c95ef4260aab7b Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:55:34 +0100 Subject: [PATCH 03/17] Load types into current AssemblyLoadContext --- .../SqlConfigurableRetryLogicLoader.cs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs index df556a49ba..1ff33f95ce 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -357,28 +357,30 @@ internal sealed class AssemblyResolutionSubscription : IDisposable { #if NET private bool _isSubscribed; + private readonly AssemblyLoadContext _currentAssemblyLoadContext; #endif internal AssemblyResolutionSubscription(bool subscribe) { - #if NET + #if NET if (subscribe) { - AssemblyLoadContext.Default.Resolving += Default_Resolving; + _currentAssemblyLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()); + _currentAssemblyLoadContext?.Resolving += Default_Resolving; _isSubscribed = true; } - #endif + #endif } public void Dispose() { - #if NET + #if NET if (_isSubscribed) { - AssemblyLoadContext.Default.Resolving -= Default_Resolving; + _currentAssemblyLoadContext?.Resolving -= Default_Resolving; _isSubscribed = false; } - #endif + #endif } } @@ -399,10 +401,11 @@ private static Assembly AssemblyResolver(AssemblyName arg) string methodName = nameof(AssemblyResolver); string fullPath = MakeFullPath(ProbingDirectory, arg.Name); + AssemblyLoadContext currentAlc = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()); SqlClientEventSource.Log.TryTraceEvent(" Looking for '{2}' assembly by '{3}' full path." , TypeName, methodName, arg, fullPath); - return fullPath == null ? null : AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + return fullPath != null && currentAlc != null ? currentAlc.LoadFromAssemblyPath(fullPath) : null; } /// @@ -411,8 +414,8 @@ private static Assembly AssemblyResolver(AssemblyName arg) /// /// This handler is only subscribed while a configured retry logic provider is being /// resolved and constructed, and only when a custom retry logic type has been configured. - /// It must not remain subscribed to after that, - /// because doing so changes assembly resolution behavior for the entire application. + /// It must not remain subscribed to the calling after that, + /// because doing so can change assembly resolution behavior for the entire application. /// private static Assembly Default_Resolving(AssemblyLoadContext arg1, AssemblyName arg2) { From 524a696a1d82d94bbd66510a2148b30b5380a634 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:55:47 +0100 Subject: [PATCH 04/17] Adjust comments in tests --- .../FunctionalTests/SqlConfigurableRetryLogicTest.cs | 11 +++++------ .../UnloadableAssemblyLoadContextTest.cs | 4 ++-- .../SqlClient/SqlConfigurableRetryLogicLoaderTest.cs | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs index b24a32e804..450135f440 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -86,11 +86,10 @@ public void ValidateRetryParameters() #if NET /// /// Regression test: triggering the configurable retry logic loader through its normal - /// entry points must not leave a process-wide - /// resolving handler - /// installed. Such a handler participates in resolution of every assembly the host - /// application fails to find, and serves them out of this component's probing directory, - /// which can load code from an unintended location. + /// entry points must not leave a resolving handler subscribed to the current + /// . Such a handler participates + /// in resolution of every assembly the host application fails to find, and serves them + /// out of this component's probing directory, which can load code from an unintended location. /// [Fact] public void RetryLogicProviderDoesNotLeaveAssemblyProbingEnabled() diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 9b8ec834aa..c31effeff6 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -68,12 +68,12 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string object? instantiated = Activator.CreateInstance(unloadableLibraryType, [connectionString]); Assert.NotNull(instantiated); - MethodInfo? getDateMethod = unloadableLibraryType.GetMethod("GetDate"); + MethodInfo? getDateMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDate)); Assert.NotNull(getDateMethod); getDateMethod.Invoke(instantiated, null); - MethodInfo? getDateAsyncMethod = unloadableLibraryType.GetMethod("GetDateAsync"); + MethodInfo? getDateAsyncMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDateAsync)); Assert.NotNull(getDateAsyncMethod); Task? getDateTask = getDateAsyncMethod.Invoke(instantiated, null) as Task; diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConfigurableRetryLogicLoaderTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConfigurableRetryLogicLoaderTest.cs index d4ef506991..6ab6c9dc68 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConfigurableRetryLogicLoaderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConfigurableRetryLogicLoaderTest.cs @@ -15,8 +15,7 @@ namespace Microsoft.Data.SqlClient.UnitTests; /// /// Unit tests validating that never leaves a -/// process-wide assembly resolving handler attached to -/// . +/// process-wide assembly resolving handler attached to the current . /// /// /// A handler left attached there participates in resolution of every assembly the host @@ -29,7 +28,7 @@ public class SqlConfigurableRetryLogicLoaderTest { /// /// The default code path: no configuration at all. The loader must not subscribe to the - /// default load context. + /// current load context. /// [Fact] public void Constructor_WithNoConfiguration_DoesNotLeaveAssemblyProbingEnabled() @@ -228,8 +227,8 @@ private static TestRetryConnectionSection CreateSection(string? retryLogicType) /// /// Asserts that a failed assembly load is not served out of the loader's probing directory, /// which can only happen while a resolving handler installed by - /// is subscribed to - /// . + /// is subscribed to the current + /// . /// /// /// A file that is not a valid assembly is planted in the probing directory under a name no @@ -259,7 +258,7 @@ private static void AssertNoAssemblyProbingHandlerInstalled() /// /// Reports whether a resolving handler that serves assemblies out of the loader's probing - /// directory is currently subscribed to . + /// directory is currently subscribed to the current . /// /// /// This distinguishes the two states using only public behaviour, which is what actually From 8a11067692c1a5cf9c281fd3f3425f6572516d16 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:49:59 +0100 Subject: [PATCH 05/17] Move RetryLogicConfigHelper class to TestCommon, rename --- .../RetryLogicConfigs.cs | 17 ++++ .../RetryLogicLoaderHelper.cs | 98 +++++++++++++++++++ .../tests/UnloadableLibrary/EntryPoint.cs | 20 ++++ .../UnloadableLibrary.csproj | 2 + 4 files changed, 137 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs diff --git a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs new file mode 100644 index 0000000000..90aa6691e5 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; + +public sealed class RetryLogicConfigs +{ + public TimeSpan DeltaTime { get; set; } + public TimeSpan MaxTimeInterval { get; set; } + public TimeSpan MinTimeInterval { get; set; } + public int NumberOfTries { get; set; } + public string? TransientErrors { get; set; } + public string? AuthorizedSqlCondition { get; set; } + public string? RetryLogicType { get; set; } + public string? RetryMethodName { get; set; } +} diff --git a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs new file mode 100644 index 0000000000..d9eee6e5f4 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Reflection; + +namespace Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; + +public static class RetryLogicLoaderHelper +{ + private const string ConfigurationLoaderTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryLogicLoader"; + private const string InterfaceCnnCfgTypeName = "Microsoft.Data.SqlClient.ISqlConfigurableRetryConnectionSection"; + private const string InterfaceCmdCfgTypeName = "Microsoft.Data.SqlClient.ISqlConfigurableRetryCommandSection"; + private const string CnnCfgTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryConnectionSection"; + private const string CmdCfgTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryCommandSection"; + private const string DefaultTransientErrors = "1204, 1205, 1222, 49918, 49919, 49920, 4060, 4221, 40143, 40613, 40501, 40540, 40197, 42108, 42109, 10929, 10928, 10060, 10054, 10053, 997, 233, 64, 20, 0, -2, 207, 102, 2812"; + + private static readonly Random s_random = new(); + + private static readonly Assembly s_sqlClientAssembly = typeof(SqlConnection).Assembly; + private static readonly Type s_configurationLoaderType = s_sqlClientAssembly.GetType(ConfigurationLoaderTypeName)!; + private static readonly Type s_interfaceCnnCfgType = s_sqlClientAssembly.GetType(InterfaceCnnCfgTypeName)!; + private static readonly Type s_interfaceCmdCfgType = s_sqlClientAssembly.GetType(InterfaceCmdCfgTypeName)!; + private static readonly Type s_cnnCfgType = s_sqlClientAssembly.GetType(CnnCfgTypeName)!; + private static readonly Type s_cmdCfgType = s_sqlClientAssembly.GetType(CmdCfgTypeName)!; + + private static readonly Type[] s_cfgLoaderParamsType = + [ + s_interfaceCnnCfgType, + s_interfaceCmdCfgType, + typeof(string), typeof(string) + ]; + private static readonly ConstructorInfo s_loaderCtorInfo = s_configurationLoaderType.GetConstructor(s_cfgLoaderParamsType)!; + + public static object CreateLoader(RetryLogicConfigs cnnConfig, RetryLogicConfigs cmdConfig) + { + object cnnCfgObj = Activator.CreateInstance(s_cnnCfgType)!; + SetValue(cnnCfgObj, s_cnnCfgType, "DeltaTime", cnnConfig.DeltaTime); + SetValue(cnnCfgObj, s_cnnCfgType, "MinTimeInterval", cnnConfig.MinTimeInterval); + SetValue(cnnCfgObj, s_cnnCfgType, "MaxTimeInterval", cnnConfig.MaxTimeInterval); + SetValue(cnnCfgObj, s_cnnCfgType, "NumberOfTries", cnnConfig.NumberOfTries); + SetValue(cnnCfgObj, s_cnnCfgType, "AuthorizedSqlCondition", cnnConfig.AuthorizedSqlCondition); + SetValue(cnnCfgObj, s_cnnCfgType, "TransientErrors", cnnConfig.TransientErrors); + SetValue(cnnCfgObj, s_cnnCfgType, "RetryLogicType", cnnConfig.RetryLogicType); + SetValue(cnnCfgObj, s_cnnCfgType, "RetryMethod", cnnConfig.RetryMethodName); + + object cmdCfgObj = Activator.CreateInstance(s_cmdCfgType)!; + SetValue(cmdCfgObj, s_cmdCfgType, "DeltaTime", cmdConfig.DeltaTime); + SetValue(cmdCfgObj, s_cmdCfgType, "MinTimeInterval", cmdConfig.MinTimeInterval); + SetValue(cmdCfgObj, s_cmdCfgType, "MaxTimeInterval", cmdConfig.MaxTimeInterval); + SetValue(cmdCfgObj, s_cmdCfgType, "NumberOfTries", cmdConfig.NumberOfTries); + SetValue(cmdCfgObj, s_cmdCfgType, "AuthorizedSqlCondition", cmdConfig.AuthorizedSqlCondition); + SetValue(cmdCfgObj, s_cmdCfgType, "TransientErrors", cmdConfig.TransientErrors); + SetValue(cmdCfgObj, s_cmdCfgType, "RetryLogicType", cmdConfig.RetryLogicType); + SetValue(cmdCfgObj, s_cmdCfgType, "RetryMethod", cmdConfig.RetryMethodName); + + return s_loaderCtorInfo.Invoke([cnnCfgObj, cmdCfgObj, default!, default!]); + } + + public static object ReturnLoaderAndProviders(RetryLogicConfigs cnnCfg, RetryLogicConfigs cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider) + { + object loaderObj = CreateLoader(cnnCfg, cmdCfg); + + cnnProvider = GetValue(loaderObj, s_configurationLoaderType, "ConnectionProvider"); + cmdProvider = GetValue(loaderObj, s_configurationLoaderType, "CommandProvider"); + return loaderObj; + } + + public static RetryLogicConfigs CreateRandomConfig(string? method, string? authorizedSqlCondition = null, string transientErrors = DefaultTransientErrors) + { + TimeSpan start = TimeSpan.Zero; + TimeSpan end = TimeSpan.FromSeconds(60); + TimeSpan min = GenerateTimeSpan(start, end); + + return new RetryLogicConfigs() + { + DeltaTime = GenerateTimeSpan(start, end), + MinTimeInterval = min, + MaxTimeInterval = GenerateTimeSpan(min, end), + NumberOfTries = s_random.Next(1, 60), + AuthorizedSqlCondition = authorizedSqlCondition, + TransientErrors = transientErrors, + RetryMethodName = method + }; + } + + private static T GetValue(object obj, Type type, string propName, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) => + (T)type.GetProperty(propName, flags)?.GetValue(obj)!; + + private static void SetValue(object obj, Type type, string propName, T value, BindingFlags flags = BindingFlags.Public | BindingFlags.Instance) => + type.GetProperty(propName, flags)?.SetValue(obj, value); + + private static TimeSpan GenerateTimeSpan(TimeSpan start, TimeSpan end) + { + int max = (int)(end - start).TotalSeconds; + return start.Add(TimeSpan.FromSeconds(s_random.Next(max))); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index 8d0fd38fbc..759cbbcf29 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; + namespace Microsoft.Data.SqlClient.UnloadableLibrary; public sealed class EntryPoint @@ -13,6 +15,24 @@ public EntryPoint(string connectionString) AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); ConnectionString = connectionString; + + InitializeRetryProviders(); + } + + private void InitializeRetryProviders() + { + RetryLogicConfigs connectionConfigs = new() + { + DeltaTime = TimeSpan.FromSeconds(1), + MaxTimeInterval = TimeSpan.FromSeconds(30), + MinTimeInterval = TimeSpan.FromSeconds(1), + NumberOfTries = 1, + RetryLogicType = typeof(SqlConfigurableRetryFactory).FullName, + RetryMethodName = nameof(SqlConfigurableRetryFactory.CreateNoneRetryProvider) + }; + RetryLogicConfigs commandConfigs = RetryLogicLoaderHelper.CreateRandomConfig(method: null, authorizedSqlCondition: null); + + RetryLogicLoaderHelper.CreateLoader(connectionConfigs, commandConfigs); } public void GetDate() diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj index 7c82386a73..3bddd72891 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj @@ -19,5 +19,7 @@ Condition="'$(ReferenceType)' != 'Package'" /> + + \ No newline at end of file From fd0bb76f95b55f96dba33bcf8a2746ce503d2a2e Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:59:53 +0100 Subject: [PATCH 06/17] Remove old copy of RetryLogicConfigHelper from ManualTests --- .../SQL/RetryLogic/RetryLogicConfigHelper.cs | 115 +----------------- .../SqlConfigurationManagerReliabilityTest.cs | 65 +++++----- 2 files changed, 35 insertions(+), 145 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/RetryLogicConfigHelper.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/RetryLogicConfigHelper.cs index bd6e551890..89bb478fe0 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/RetryLogicConfigHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/RetryLogicConfigHelper.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -6,21 +6,12 @@ using System.Collections.Generic; using System.Reflection; using Xunit; +using Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; namespace Microsoft.Data.SqlClient.ManualTesting.Tests { public class RetryLogicConfigHelper { - private const string ConfigurationLoaderTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryLogicLoader"; - private const string AppContextSwitchManagerTypeName = "Microsoft.Data.SqlClient.SqlAppContextSwitchManager"; - private const string ApplyContextSwitchesMethodName = "ApplyContextSwitches"; - private const string InterfaceCnnCfgTypeName = "Microsoft.Data.SqlClient.ISqlConfigurableRetryConnectionSection"; - private const string InterfaceCmdCfgTypeName = "Microsoft.Data.SqlClient.ISqlConfigurableRetryCommandSection"; - - private const string CnnCfgTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryConnectionSection"; - private const string CmdCfgTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryCommandSection"; - private const string AppCtxCfgTypeName = "Microsoft.Data.SqlClient.AppContextSwitchOverridesSection"; - public const string RetryMethodName_Fix = "CreateFixedRetryProvider"; public const string RetryMethodName_Inc = "CreateIncrementalRetryProvider"; public const string RetryMethodName_Exp = "CreateExponentialRetryProvider"; @@ -31,79 +22,21 @@ public class RetryLogicConfigHelper private const string CreateExceptionMethodName = "CreateException"; private const string AddMethodName = "Add"; - public const string DefaultTransientErrors = "1204, 1205, 1222, 49918, 49919, 49920, 4060, 4221, 40143, 40613, 40501, 40540, 40197, 42108, 42109, 10929, 10928, 10060, 10054, 10053, 997, 233, 64, 20, 0, -2, 207, 102, 2812"; - private static readonly Random s_random = new Random(); private static readonly Assembly s_sqlClientAssembly = typeof(SqlConnection).Assembly; - private static readonly Type s_appContextSwitchManagerType = s_sqlClientAssembly.GetType(AppContextSwitchManagerTypeName); private static readonly Type s_sqlretrylogicType = s_sqlClientAssembly.GetType(SqlRetryLogicTypeName); - private static readonly Type s_configurationLoaderType = s_sqlClientAssembly.GetType(ConfigurationLoaderTypeName); private static readonly Type s_sqlErrorType = typeof(SqlError); private static readonly Type s_sqlErrorCollectionType = typeof(SqlErrorCollection); - private static readonly Type[] s_cfgLoaderParamsType = new Type[] - { - s_sqlClientAssembly.GetType(InterfaceCnnCfgTypeName), - s_sqlClientAssembly.GetType(InterfaceCmdCfgTypeName), - typeof(string), typeof(string) - }; private static readonly Type[] s_sqlErrorParamsType = new Type[] { typeof(int), typeof(byte), typeof(byte), typeof(string), typeof(string), typeof(string), typeof(int), typeof(Exception) }; - private static readonly ConstructorInfo s_loaderCtorInfo = s_configurationLoaderType.GetConstructor(s_cfgLoaderParamsType); private static readonly ConstructorInfo s_sqlErrorCtorInfo = s_sqlErrorType.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, s_sqlErrorParamsType, null); private static readonly ConstructorInfo s_sqlErrorCollectionCtorInfo = s_sqlErrorCollectionType.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); - public static object CreateLoader(RetryLogicConfigs cnnConfig, RetryLogicConfigs cmdConfig) - { - var cnnCfgType = s_sqlClientAssembly.GetType(CnnCfgTypeName); - var cnnCfgObj = Activator.CreateInstance(cnnCfgType); - SetValue(cnnCfgObj, cnnCfgType, "DeltaTime", cnnConfig.DeltaTime); - SetValue(cnnCfgObj, cnnCfgType, "MinTimeInterval", cnnConfig.MinTimeInterval); - SetValue(cnnCfgObj, cnnCfgType, "MaxTimeInterval", cnnConfig.MaxTimeInterval); - SetValue(cnnCfgObj, cnnCfgType, "NumberOfTries", cnnConfig.NumberOfTries); - SetValue(cnnCfgObj, cnnCfgType, "AuthorizedSqlCondition", cnnConfig.AuthorizedSqlCondition); - SetValue(cnnCfgObj, cnnCfgType, "TransientErrors", cnnConfig.TransientErrors); - SetValue(cnnCfgObj, cnnCfgType, "RetryLogicType", cnnConfig.RetryLogicType); - SetValue(cnnCfgObj, cnnCfgType, "RetryMethod", cnnConfig.RetryMethodName); - - var cmdCfgType = s_sqlClientAssembly.GetType(CmdCfgTypeName); - var cmdCfgObj = Activator.CreateInstance(cmdCfgType); - SetValue(cmdCfgObj, cmdCfgType, "DeltaTime", cmdConfig.DeltaTime); - SetValue(cmdCfgObj, cmdCfgType, "MinTimeInterval", cmdConfig.MinTimeInterval); - SetValue(cmdCfgObj, cmdCfgType, "MaxTimeInterval", cmdConfig.MaxTimeInterval); - SetValue(cmdCfgObj, cmdCfgType, "NumberOfTries", cmdConfig.NumberOfTries); - SetValue(cmdCfgObj, cmdCfgType, "AuthorizedSqlCondition", cmdConfig.AuthorizedSqlCondition); - SetValue(cmdCfgObj, cmdCfgType, "TransientErrors", cmdConfig.TransientErrors); - SetValue(cmdCfgObj, cmdCfgType, "RetryLogicType", cmdConfig.RetryLogicType); - SetValue(cmdCfgObj, cmdCfgType, "RetryMethod", cmdConfig.RetryMethodName); - - return s_loaderCtorInfo.Invoke(new object[] { cnnCfgObj, cmdCfgObj, default, default }); - } - - public static void ApplyContextSwitchByManager(string name, bool value) - { -#if !NETFRAMEWORK - var appCtxType = s_sqlClientAssembly.GetType(AppCtxCfgTypeName); - var appCtxObj = Activator.CreateInstance(appCtxType); - SetValue(appCtxObj, appCtxType, "Value", string.Concat(name, "=", value)); - - var method = s_appContextSwitchManagerType.GetMethod(ApplyContextSwitchesMethodName, BindingFlags.Static | BindingFlags.NonPublic); - method.Invoke(null, new object[] { appCtxObj }); -#else - AppContext.SetSwitch(name, value); -#endif - } - - public static SqlRetryLogicBaseProvider GetConnectionProvider(object loader) - => GetValue(loader, s_configurationLoaderType, "ConnectionProvider"); - - public static SqlRetryLogicBaseProvider GetCommandProvider(object loader) - => GetValue(loader, s_configurationLoaderType, "CommandProvider"); - public static void AssessProvider(SqlRetryLogicBaseProvider provider, RetryLogicConfigs option) => AssessRetryLogic(provider.RetryLogic, option); @@ -150,33 +83,6 @@ public static IEnumerable GetIivalidTimes() } } - public static object ReturnLoaderAndProviders(RetryLogicConfigs cnnCfg, RetryLogicConfigs cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider) - { - var loaderObj = CreateLoader(cnnCfg, cmdCfg); - cnnProvider = GetConnectionProvider(loaderObj); - cmdProvider = GetCommandProvider(loaderObj); - return loaderObj; - } - - public static RetryLogicConfigs CreateRandomConfig(string method, string authorizedSqlCondition = null, string transientErrors = DefaultTransientErrors) - { - TimeSpan start = TimeSpan.Zero; - TimeSpan end = TimeSpan.FromSeconds(60); - - var min = GenerateTimeSpan(start, end); - - return new RetryLogicConfigs() - { - DeltaTime = GenerateTimeSpan(start, end), - MinTimeInterval = min, - MaxTimeInterval = GenerateTimeSpan(min, end), - NumberOfTries = s_random.Next(1, 60), - AuthorizedSqlCondition = authorizedSqlCondition, - TransientErrors = transientErrors, - RetryMethodName = method - }; - } - public static SqlException CreateSqlException(int errorNumber) { MethodInfo addSqlErrorMethod = typeof(SqlErrorCollection).GetMethod(AddMethodName, BindingFlags.Instance | BindingFlags.NonPublic); @@ -199,21 +105,4 @@ private static TimeSpan GenerateTimeSpan(TimeSpan start, TimeSpan end) return start.Add(TimeSpan.FromSeconds(s_random.Next(max))); } } - - public class RetryLogicConfigs : ICloneable - { - public TimeSpan DeltaTime { get; set; } - public TimeSpan MaxTimeInterval { get; set; } - public TimeSpan MinTimeInterval { get; set; } - public int NumberOfTries { get; set; } - public string TransientErrors { get; set; } - public string AuthorizedSqlCondition { get; set; } - public string RetryLogicType { get; set; } - public string RetryMethodName { get; set; } - - public object Clone() - { - return MemberwiseClone(); - } - } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/SqlConfigurationManagerReliabilityTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/SqlConfigurationManagerReliabilityTest.cs index 8ffbbdb5bf..053c91c0b9 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/SqlConfigurationManagerReliabilityTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/RetryLogic/SqlConfigurationManagerReliabilityTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -6,6 +6,7 @@ using Xunit; using System.Data; using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; namespace Microsoft.Data.SqlClient.ManualTesting.Tests { @@ -31,15 +32,15 @@ public class SqlConfigurationManagerReliabilityTest [InlineData(RetryLogicConfigHelper.RetryMethodName_Exp, RetryLogicConfigHelper.RetryMethodName_Fix)] public void LoadValidInternalTypes(string method1, string method2) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(method1); - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(method2, + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(method1); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(method2, // Doesn't accept DML statements @"^\b(?!UPDATE|DELETE|TRUNCATE|INSERT( +INTO){0,1})\b"); // for sake of reducing the retry time in total cnnCfg.NumberOfTries = 1; cmdCfg.NumberOfTries = 1; - object loaderObj = RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); + object loaderObj = RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); Assert.NotNull(loaderObj); RetryLogicConfigHelper.AssessProvider(cnnProvider, cnnCfg); RetryLogicConfigHelper.AssessProvider(cmdProvider, cmdCfg); @@ -66,8 +67,8 @@ public void LoadValidInternalTypes(string method1, string method2) [InlineData("Microsoft.Data.SqlClient.Tests.CustomConfigurableRetryLogicEx, ExternalConfigurableRetryLogic", "GetDefaultRetry")] public void LoadCustomMethod(string typeName, string methodName) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName); - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName); // for sake of reducing the retry time in total cnnCfg.RetryLogicType = typeName; cmdCfg.RetryLogicType = typeName; @@ -75,7 +76,7 @@ public void LoadCustomMethod(string typeName, string methodName) cnnCfg.NumberOfTries = 1; cmdCfg.NumberOfTries = 3; - object loaderObj = RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); + object loaderObj = RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); Assert.NotNull(loaderObj); TestConnection(cnnProvider, cnnCfg); @@ -96,8 +97,8 @@ public void LoadCustomMethod(string typeName, string methodName) [InlineData("Microsoft.Data.SqlClient.Tests.CustomConfigurableRetryLogic, ExternalConfigurableRetryLogic", "getdefaultretry")] public void LoadInvalidCustomRetryLogicType(string typeName, string methodName) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName); - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName); // for sake of reducing the retry time in total cnnCfg.RetryLogicType = typeName; cmdCfg.RetryLogicType = typeName; @@ -105,7 +106,7 @@ public void LoadInvalidCustomRetryLogicType(string typeName, string methodName) cnnCfg.NumberOfTries = 1; cmdCfg.NumberOfTries = 3; - object loaderObj = RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); + object loaderObj = RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); Assert.NotNull(loaderObj); s_connectionCRLTest.DefaultOpenWithoutRetry(TcpCnnString, cnnProvider); @@ -121,10 +122,10 @@ public void LoadInvalidCustomRetryLogicType(string typeName, string methodName) [MemberData(nameof(RetryLogicConfigHelper.GetInvalidInternalMethodNames), MemberType = typeof(RetryLogicConfigHelper))] public void InvalidRetryMethodName(string methodName) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName); - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(methodName, @"Don't care!"); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(methodName, @"Don't care!"); - object loaderObj = RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); + object loaderObj = RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); Assert.NotNull(loaderObj); // none retriable logic applies. @@ -141,14 +142,14 @@ public void InvalidRetryMethodName(string methodName) [InlineData("Microsoft.Data.SqlClient.SqlConfigurableRetryFactory")] public void InvalidRetryLogicTypeWithValidInternalMethodName(string typeName) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.RetryLogicType = typeName; - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); // for sake of reducing the retry time in total cnnCfg.NumberOfTries = 1; cmdCfg.NumberOfTries = 1; - object loaderObj = RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); + object loaderObj = RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider); Assert.NotNull(loaderObj); RetryLogicConfigHelper.AssessProvider(cnnProvider, cnnCfg); RetryLogicConfigHelper.AssessProvider(cmdProvider, cmdCfg); @@ -162,23 +163,23 @@ public void InvalidRetryLogicTypeWithValidInternalMethodName(string typeName) [MemberData(nameof(RetryLogicConfigHelper.GetIivalidTimes), MemberType = typeof(RetryLogicConfigHelper))] public void OutOfRangeTime(TimeSpan deltaTime, TimeSpan minTime, TimeSpan maxTime) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.DeltaTime = deltaTime; - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); - var ex = Assert.Throws(() => RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); + var ex = Assert.Throws(() => RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); Assert.Equal(typeof(System.Configuration.ConfigurationErrorsException), ex.InnerException?.GetType()); Assert.Equal(typeof(ArgumentException), ex.InnerException?.InnerException?.GetType()); - cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.MinTimeInterval = minTime; - ex = Assert.Throws(() => RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); + ex = Assert.Throws(() => RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); Assert.Equal(typeof(System.Configuration.ConfigurationErrorsException), ex.InnerException?.GetType()); Assert.Equal(typeof(ArgumentException), ex.InnerException?.InnerException?.GetType()); - cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.MaxTimeInterval = maxTime; - ex = Assert.Throws(() => RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); + ex = Assert.Throws(() => RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); Assert.Equal(typeof(System.Configuration.ConfigurationErrorsException), ex.InnerException?.GetType()); Assert.Equal(typeof(ArgumentException), ex.InnerException?.InnerException?.GetType()); } @@ -190,11 +191,11 @@ public void OutOfRangeTime(TimeSpan deltaTime, TimeSpan minTime, TimeSpan maxTim [InlineData(100)] public void InvalidNumberOfTries(int num) { - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.NumberOfTries = num; - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); - var ex = Assert.Throws(() => RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); + var ex = Assert.Throws(() => RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); Assert.Equal(typeof(System.Configuration.ConfigurationErrorsException), ex.InnerException?.GetType()); Assert.Equal(typeof(ArgumentException), ex.InnerException?.InnerException?.GetType()); } @@ -214,11 +215,11 @@ public void InvalidNumberOfTries(int num) [InlineData(@"~!@#$%^&*()_+={}[]|\""':;.>(() => RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); + var ex = Assert.Throws(() => RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider)); Assert.Equal(typeof(System.Configuration.ConfigurationErrorsException), ex.InnerException?.GetType()); Assert.Equal(typeof(ArgumentException), ex.InnerException?.InnerException?.GetType()); } @@ -232,11 +233,11 @@ public void InvalidTransientError(string errors) public void ValidTransientError(string errors) { string[] transientErrorNumbers = errors.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - RetryLogicConfigs cnnCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); + RetryLogicConfigs cnnCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix); cnnCfg.TransientErrors = errors; - RetryLogicConfigs cmdCfg = RetryLogicConfigHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); + RetryLogicConfigs cmdCfg = RetryLogicLoaderHelper.CreateRandomConfig(RetryLogicConfigHelper.RetryMethodName_Fix, @"Don't care!"); - RetryLogicConfigHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out _); + RetryLogicLoaderHelper.ReturnLoaderAndProviders(cnnCfg, cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out _); foreach(string errorString in transientErrorNumbers) { From a495eab1b7a34a11c864cdaf0848fd6e73c10016 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:28:00 +0100 Subject: [PATCH 07/17] Use a class derived from AssemblyLoadContext to block the use of the default ALC's SqlClient assembly --- .../UnloadableAssemblyLoadContextTest.cs | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index c31effeff6..29ff6efddb 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -7,6 +7,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Loader; @@ -29,8 +30,9 @@ public async Task SecondaryAssemblyLoadContext_Unloads() string connStr = DataTestUtility.TCPConnectionString; string typeName = typeof(EntryPoint).FullName!; string libraryPath = typeof(EntryPoint).Assembly.Location; + string mdsPath = typeof(SqlConnection).Assembly.Location; - WeakReference alcWeakReference = await LoadAndUnloadAssemblyLoadContext(typeName, libraryPath, connStr); + WeakReference alcWeakReference = await LoadAndUnloadAssemblyLoadContext(typeName, libraryPath, mdsPath, connStr); for (int i = 0; i < 10 && alcWeakReference.IsAlive; i++) { @@ -54,11 +56,16 @@ public async Task SecondaryAssemblyLoadContext_Unloads() /// in order to ensure that this weak reference is guaranteed to be out of scope in the method's caller. /// [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string connectionString) + private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string mdsPath, string connectionString) { // This method loads the entry point type into a new, collectible AssemblyLoadContext. It // then manually instantiates this, and invokes GetDate and GetDateAsync. - AssemblyLoadContext alc = new(nameof(SecondaryAssemblyLoadContext_Unloads), isCollectible: true); + Dictionary assemblyPathMappings = new() + { + { "Microsoft.Data.SqlClient", mdsPath } + }; + + UnloadableAssemblyLoadContext alc = new(nameof(SecondaryAssemblyLoadContext_Unloads), assemblyPath, assemblyPathMappings); WeakReference weakRef = new(alc, trackResurrection: true); Assembly loadedAsm = alc.LoadFromAssemblyPath(assemblyPath); @@ -85,6 +92,31 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string return weakRef; } + + private sealed class UnloadableAssemblyLoadContext : AssemblyLoadContext + { + private readonly Dictionary _assemblyPathMappings; + private readonly AssemblyDependencyResolver _resolver; + + public UnloadableAssemblyLoadContext(string name, string assemblyPath, Dictionary assemblyPathMappings) + : base(name, isCollectible: true) + { + _assemblyPathMappings = assemblyPathMappings; + _resolver = new AssemblyDependencyResolver(assemblyPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + string? resolvedAssemblyPath = assemblyName.Name is not null + && _assemblyPathMappings.TryGetValue(assemblyName.Name, out string? mappedPath) + ? mappedPath + : _resolver.ResolveAssemblyToPath(assemblyName); + + return resolvedAssemblyPath is not null + ? LoadFromAssemblyPath(resolvedAssemblyPath) + : null; + } + } } /// From 79f51b949b1677b3eb11886cee48b7dfe1411798 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:28:47 +0100 Subject: [PATCH 08/17] Add assertions to verify that the SqlClient assembly is loaded into the correct ALC --- .../UnloadableAssemblyLoadContextTest.cs | 12 ++++++++++-- .../tests/UnloadableLibrary/EntryPoint.cs | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 29ff6efddb..0e0c7f37d1 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -58,8 +58,10 @@ public async Task SecondaryAssemblyLoadContext_Unloads() [MethodImpl(MethodImplOptions.NoInlining)] private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string mdsPath, string connectionString) { - // This method loads the entry point type into a new, collectible AssemblyLoadContext. It - // then manually instantiates this, and invokes GetDate and GetDateAsync. + // This method loads the entry point type into a new, collectible AssemblyLoadContext and + // verifies that the entry point type is referencing the SqlConnection type in the correct + // AssemblyLoadContext. + // It then manually instantiates this entry point type, and invokes GetDate and GetDateAsync. Dictionary assemblyPathMappings = new() { { "Microsoft.Data.SqlClient", mdsPath } @@ -72,6 +74,12 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string Type? unloadableLibraryType = loadedAsm.GetType(typeName); Assert.NotNull(unloadableLibraryType); + PropertyInfo? alcNameProperty = unloadableLibraryType.GetProperty(nameof(EntryPoint.AssemblyLoadContextName), BindingFlags.Public | BindingFlags.Static); + Assert.NotNull(alcNameProperty); + string? alcName = alcNameProperty.GetValue(null) as string; + + Assert.Equal(alc.Name, alcName); + object? instantiated = Activator.CreateInstance(unloadableLibraryType, [connectionString]); Assert.NotNull(instantiated); diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index 759cbbcf29..2cb3822b72 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -8,6 +8,13 @@ namespace Microsoft.Data.SqlClient.UnloadableLibrary; public sealed class EntryPoint { + public static string? AssemblyLoadContextName => + #if NET + System.Runtime.Loader.AssemblyLoadContext.GetLoadContext(typeof(SqlConnection).Assembly)?.Name; + #else + "Default"; + #endif + public string ConnectionString { get; } public EntryPoint(string connectionString) From 3708cf5894ee8e939884ce1aa7e471474a67d9fb Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:59:49 +0100 Subject: [PATCH 09/17] Address comments --- .../Unloadability/UnloadableAssemblyLoadContextTest.cs | 10 +++++++++- .../tests/UnloadableLibrary/EntryPoint.cs | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 0e0c7f37d1..0a023ce62d 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -18,10 +18,18 @@ namespace Microsoft.Data.SqlClient.ManualTesting.Tests.SQL.Unloadability; +/// +/// Tests verifying SqlClient's behaviour within unloadable assembly load contexts. +/// [Trait("Set", "2")] [Collection("AssemblyLoadContext")] public class UnloadableAssemblyLoadContextTest { + /// + /// Verifies that loading SqlClient into a secondary AssemblyLoadContext does not block that + /// AssemblyLoadContext from being unloaded after use. + /// + /// A task representing the asynchronous operation. [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsTCPConnStringSetup))] public async Task SecondaryAssemblyLoadContext_Unloads() { @@ -51,7 +59,7 @@ public async Task SecondaryAssemblyLoadContext_Unloads() /// The connection to open. /// A to the AssemblyLoadContext. /// - /// This method makes use of relection to avoid accidentally creating a hard reference to the + /// This method makes use of reflection to avoid accidentally creating a hard reference to the /// secondary ALC's type definitions. It is also marked as /// in order to ensure that this weak reference is guaranteed to be out of scope in the method's caller. /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index 2cb3822b72..dd7de7bc43 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -6,6 +6,15 @@ namespace Microsoft.Data.SqlClient.UnloadableLibrary; +/// +/// Entry point and hooks for the unloadable assembly load context test. +/// +/// +/// This is organised into a separate class and assembly to provide isolation from the ManualTesting +/// project. Loading an assembly loads all of its dependencies, so this isolation means that if the +/// test fails, we can be confident that the failure is due to SqlClient itself rather that to another +/// assembly that was loaded into the same context. +/// public sealed class EntryPoint { public static string? AssemblyLoadContextName => From 8e715a082c7488c9423e1c1bfaa6a3147e7fd920 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:19:58 +0100 Subject: [PATCH 10/17] Cleanup tests This removes an unnecessary piece of infrastructure: the AssemblyName-to-path mappings. AssemblyDependencyResolver handles this automatically, and it ensures that the test reflects a real-world environment. --- .../UnloadableAssemblyLoadContextTest.cs | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 0a023ce62d..0f304461ba 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -38,9 +38,8 @@ public async Task SecondaryAssemblyLoadContext_Unloads() string connStr = DataTestUtility.TCPConnectionString; string typeName = typeof(EntryPoint).FullName!; string libraryPath = typeof(EntryPoint).Assembly.Location; - string mdsPath = typeof(SqlConnection).Assembly.Location; - WeakReference alcWeakReference = await LoadAndUnloadAssemblyLoadContext(typeName, libraryPath, mdsPath, connStr); + WeakReference alcWeakReference = await LoadAndUnloadAssemblyLoadContext(typeName, libraryPath, connStr); for (int i = 0; i < 10 && alcWeakReference.IsAlive; i++) { @@ -56,6 +55,8 @@ public async Task SecondaryAssemblyLoadContext_Unloads() /// Creates an unloadable , then acts within it to open a SqlConnection /// and to execute a command. /// + /// The full name of the entry point type to load into the AssemblyLoadContext. + /// The path to the assembly containing the entry point type. /// The connection to open. /// A to the AssemblyLoadContext. /// @@ -64,18 +65,13 @@ public async Task SecondaryAssemblyLoadContext_Unloads() /// in order to ensure that this weak reference is guaranteed to be out of scope in the method's caller. /// [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string mdsPath, string connectionString) + private static async Task LoadAndUnloadAssemblyLoadContext(string typeName, string assemblyPath, string connectionString) { // This method loads the entry point type into a new, collectible AssemblyLoadContext and // verifies that the entry point type is referencing the SqlConnection type in the correct // AssemblyLoadContext. // It then manually instantiates this entry point type, and invokes GetDate and GetDateAsync. - Dictionary assemblyPathMappings = new() - { - { "Microsoft.Data.SqlClient", mdsPath } - }; - - UnloadableAssemblyLoadContext alc = new(nameof(SecondaryAssemblyLoadContext_Unloads), assemblyPath, assemblyPathMappings); + UnloadableAssemblyLoadContext alc = new(nameof(SecondaryAssemblyLoadContext_Unloads), assemblyPath); WeakReference weakRef = new(alc, trackResurrection: true); Assembly loadedAsm = alc.LoadFromAssemblyPath(assemblyPath); @@ -111,22 +107,17 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string private sealed class UnloadableAssemblyLoadContext : AssemblyLoadContext { - private readonly Dictionary _assemblyPathMappings; private readonly AssemblyDependencyResolver _resolver; - public UnloadableAssemblyLoadContext(string name, string assemblyPath, Dictionary assemblyPathMappings) + public UnloadableAssemblyLoadContext(string name, string assemblyPath) : base(name, isCollectible: true) { - _assemblyPathMappings = assemblyPathMappings; _resolver = new AssemblyDependencyResolver(assemblyPath); } protected override Assembly? Load(AssemblyName assemblyName) { - string? resolvedAssemblyPath = assemblyName.Name is not null - && _assemblyPathMappings.TryGetValue(assemblyName.Name, out string? mappedPath) - ? mappedPath - : _resolver.ResolveAssemblyToPath(assemblyName); + string? resolvedAssemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); return resolvedAssemblyPath is not null ? LoadFromAssemblyPath(resolvedAssemblyPath) From bd0ba5bc45ed79289743a5e5255044fb654e25d1 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:20:38 +0100 Subject: [PATCH 11/17] Move LocalAppContextSwitchesHelper to inside ALC This prevents a cross-ALC leak of the AppContext switch --- .../UnloadableAssemblyLoadContextTest.cs | 7 +++++-- .../tests/UnloadableLibrary/EntryPoint.cs | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 0f304461ba..b2545ebcdb 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -33,8 +33,6 @@ public class UnloadableAssemblyLoadContextTest [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsTCPConnStringSetup))] public async Task SecondaryAssemblyLoadContext_Unloads() { - using LocalAppContextSwitchesHelper savedAppContextSwitchState = new(); - string connStr = DataTestUtility.TCPConnectionString; string typeName = typeof(EntryPoint).FullName!; string libraryPath = typeof(EntryPoint).Assembly.Location; @@ -100,6 +98,11 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string await getDateTask; + MethodInfo? disposeMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.Dispose)); + Assert.NotNull(disposeMethod); + + disposeMethod.Invoke(instantiated, null); + alc.Unload(); return weakRef; diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index dd7de7bc43..7c01252b62 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.Data.SqlClient.Tests.Common; using Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; namespace Microsoft.Data.SqlClient.UnloadableLibrary; @@ -15,8 +16,10 @@ namespace Microsoft.Data.SqlClient.UnloadableLibrary; /// test fails, we can be confident that the failure is due to SqlClient itself rather that to another /// assembly that was loaded into the same context. /// -public sealed class EntryPoint +public sealed class EntryPoint : IDisposable { + private readonly LocalAppContextSwitchesHelper _appContextSwitchesHelper; + public static string? AssemblyLoadContextName => #if NET System.Runtime.Loader.AssemblyLoadContext.GetLoadContext(typeof(SqlConnection).Assembly)?.Name; @@ -28,7 +31,10 @@ public sealed class EntryPoint public EntryPoint(string connectionString) { - AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); + _appContextSwitchesHelper = new LocalAppContextSwitchesHelper(); + #if NET + _appContextSwitchesHelper.UseManagedNetworking = true; + #endif ConnectionString = connectionString; @@ -68,4 +74,9 @@ public async Task GetDateAsync() await conn.OpenAsync().ConfigureAwait(false); await cmd.ExecuteScalarAsync().ConfigureAwait(false); } + + public void Dispose() + { + _appContextSwitchesHelper.Dispose(); + } } From ad8edb9559a80adf7c5fffd2dd7c0446268030b4 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:10:35 +0100 Subject: [PATCH 12/17] Add and modify comments These are largely in the test code, with the exception of one in SqlConfigurableRetryLogicLoader. --- .../SqlConfigurableRetryLogicLoader.cs | 4 +-- .../RetryLogicConfigs.cs | 36 +++++++++++++++++++ .../RetryLogicLoaderHelper.cs | 30 ++++++++++++++++ .../UnloadableAssemblyLoadContextTest.cs | 10 ++++++ .../tests/UnloadableLibrary/EntryPoint.cs | 22 +++++++++++- 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs index 1ff33f95ce..a9340cd1b3 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs @@ -429,8 +429,8 @@ private static Assembly Default_Resolving(AssemblyLoadContext arg1, AssemblyName } /// - /// Performs a case-sensitive search to resolve the specified type name - /// and its related assemblies in default assembly load context if they aren't loaded yet. + /// Performs a case-sensitive search to resolve the specified type name and its related + /// assemblies in the calling assembly load context if they aren't loaded yet. /// /// Resolved type if it could resolve the type; otherwise, the `SqlConfigurableRetryFactory` type. private static Type LoadType(string fullyQualifiedName) diff --git a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs index 90aa6691e5..650a6d2acc 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicConfigs.cs @@ -4,14 +4,50 @@ namespace Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; +/// +/// Transport object for retry logic configuration parameters. This is used to pass +/// values which would ordinarily be part of the SqlConfigurableRetryLogicConnection +/// or SqlConfigurableRetryLogicCommand section to +/// public sealed class RetryLogicConfigs { + /// + /// Equivalent of the deltaTime value in an app.config file. + /// public TimeSpan DeltaTime { get; set; } + + /// + /// Equivalent of the maxTime value in an app.config file. + /// public TimeSpan MaxTimeInterval { get; set; } + + /// + /// Equivalent of the minTime value in an app.config file. + /// public TimeSpan MinTimeInterval { get; set; } + + /// + /// Equivalent of the numberOfTries value in an app.config file. + /// public int NumberOfTries { get; set; } + + /// + /// Equivalent of the transientErrors value in an app.config file. + /// public string? TransientErrors { get; set; } + + /// + /// Equivalent of the authorizedSqlCondition value in an app.config file. + /// public string? AuthorizedSqlCondition { get; set; } + + /// + /// Equivalent of the retryLogicType value in an app.config file. + /// public string? RetryLogicType { get; set; } + + /// + /// Equivalent of the retryMethod value in an app.config file. + /// public string? RetryMethodName { get; set; } } diff --git a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs index d9eee6e5f4..b439aa3385 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/ConfigurableRetryLogic/RetryLogicLoaderHelper.cs @@ -6,6 +6,11 @@ namespace Microsoft.Data.SqlClient.Tests.Common.ConfigurableRetryLogic; +/// +/// Contains helper methods for creating and configuring instances of +/// SqlConfigurableRetryLogicLoader and its associated configuration objects without needing +/// to populate an app.config file. +/// public static class RetryLogicLoaderHelper { private const string ConfigurationLoaderTypeName = "Microsoft.Data.SqlClient.SqlConfigurableRetryLogicLoader"; @@ -32,6 +37,13 @@ public static class RetryLogicLoaderHelper ]; private static readonly ConstructorInfo s_loaderCtorInfo = s_configurationLoaderType.GetConstructor(s_cfgLoaderParamsType)!; + /// + /// Creates and returns an instance of SqlConfigurableRetryLogicLoader with the specified + /// connection and command retry logic configurations. + /// + /// The configuration for the connection retry logic. + /// The configuration for the command retry logic. + /// The created loader instance. public static object CreateLoader(RetryLogicConfigs cnnConfig, RetryLogicConfigs cmdConfig) { object cnnCfgObj = Activator.CreateInstance(s_cnnCfgType)!; @@ -57,6 +69,16 @@ public static object CreateLoader(RetryLogicConfigs cnnConfig, RetryLogicConfigs return s_loaderCtorInfo.Invoke([cnnCfgObj, cmdCfgObj, default!, default!]); } + /// + /// Creates and returns an instance of SqlConfigurableRetryLogicLoader with the specified + /// connection and command retry logic configurations, and also retrieves the associated + /// connection and command providers. + /// + /// The configuration for the connection retry logic. + /// The configuration for the command retry logic. + /// When this method returns, contains the connection provider. + /// When this method returns, contains the command provider. + /// The created loader instance. public static object ReturnLoaderAndProviders(RetryLogicConfigs cnnCfg, RetryLogicConfigs cmdCfg, out SqlRetryLogicBaseProvider cnnProvider, out SqlRetryLogicBaseProvider cmdProvider) { object loaderObj = CreateLoader(cnnCfg, cmdCfg); @@ -66,6 +88,14 @@ public static object ReturnLoaderAndProviders(RetryLogicConfigs cnnCfg, RetryLog return loaderObj; } + /// + /// Creates a RetryLogicConfigs object with random values for its properties, optionally specifying + /// default values for certain mandatory properties. + /// + /// The retry method to use. + /// The authorized SQL condition. + /// The transient errors. + /// The created retry logic configuration. public static RetryLogicConfigs CreateRandomConfig(string? method, string? authorizedSqlCondition = null, string transientErrors = DefaultTransientErrors) { TimeSpan start = TimeSpan.Zero; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index b2545ebcdb..239287b4b0 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -108,6 +108,10 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string return weakRef; } + /// + /// Defines an unloadable that resolves assemblies + /// using an . + /// private sealed class UnloadableAssemblyLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; @@ -118,6 +122,12 @@ public UnloadableAssemblyLoadContext(string name, string assemblyPath) _resolver = new AssemblyDependencyResolver(assemblyPath); } + /// + /// Resolves assemblies using the + /// and loads them into this . + /// + /// The object that describes the assembly to be resolved. + /// The resolved assembly, or null if it could not be resolved. protected override Assembly? Load(AssemblyName assemblyName) { string? resolvedAssemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index 7c01252b62..9381dda862 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -13,7 +13,7 @@ namespace Microsoft.Data.SqlClient.UnloadableLibrary; /// /// This is organised into a separate class and assembly to provide isolation from the ManualTesting /// project. Loading an assembly loads all of its dependencies, so this isolation means that if the -/// test fails, we can be confident that the failure is due to SqlClient itself rather that to another +/// test fails, we can be confident that the failure is due to SqlClient itself rather than to another /// assembly that was loaded into the same context. /// public sealed class EntryPoint : IDisposable @@ -29,6 +29,14 @@ public sealed class EntryPoint : IDisposable public string ConnectionString { get; } + /// + /// Instantiates the entry point, specifying the connection string to use. + /// + /// + /// This sets up the AppContext switch interface and forces the configurable retry logic loader + /// to be created, triggering the loading of SqlClient into the current AssemblyLoadContext. + /// + /// Connection string to use for the test. public EntryPoint(string connectionString) { _appContextSwitchesHelper = new LocalAppContextSwitchesHelper(); @@ -57,6 +65,10 @@ private void InitializeRetryProviders() RetryLogicLoaderHelper.CreateLoader(connectionConfigs, commandConfigs); } + /// + /// Executes a simple SQL command to retrieve the current date and time from the database. + /// + /// public void GetDate() { using SqlConnection conn = new(ConnectionString); @@ -66,6 +78,11 @@ public void GetDate() cmd.ExecuteScalar(); } + /// + /// Asynchronously executes a simple SQL command to retrieve the current date and time from the database. + /// + /// A task representing the asynchronous operation. + /// public async Task GetDateAsync() { using SqlConnection conn = new(ConnectionString); @@ -75,6 +92,9 @@ public async Task GetDateAsync() await cmd.ExecuteScalarAsync().ConfigureAwait(false); } + /// + /// Disposes of the resources used by the entry point, including the AppContext switch helper. + /// public void Dispose() { _appContextSwitchesHelper.Dispose(); From e2c04e3a3bf114d9c01bc7db58ed660ede5254aa Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:21:52 +0100 Subject: [PATCH 13/17] Missing constructor documentation --- .../SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 239287b4b0..4a3db3a014 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -116,6 +116,12 @@ private sealed class UnloadableAssemblyLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; + /// + /// Insntantiates a new unloadable with the specified + /// name and assembly path. + /// + /// Name of this . + /// Path to the assembly. public UnloadableAssemblyLoadContext(string name, string assemblyPath) : base(name, isCollectible: true) { From 65360505a2af2dc96393df5c9f0baaa9f5ad1cb2 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:28:26 +0100 Subject: [PATCH 14/17] Post-merge corrections to new library's compilation This still compiled to net8.0/net9.0, which pulled in the netstandard2.0 dependencies. --- .../tests/UnloadableLibrary/UnloadableLibrary.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj index 3bddd72891..3b045a72b5 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/UnloadableLibrary.csproj @@ -10,7 +10,7 @@ - net462;net8.0;net9.0;net10.0 + net462;net10.0 From a9c7950d15be76b1d2f05b7e40007a19457a0aab Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:32:45 +0100 Subject: [PATCH 15/17] Guard Dispose and Unload in a try/finally --- .../UnloadableAssemblyLoadContextTest.cs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 4a3db3a014..05feb91dcb 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -85,25 +85,30 @@ private static async Task LoadAndUnloadAssemblyLoadContext(string object? instantiated = Activator.CreateInstance(unloadableLibraryType, [connectionString]); Assert.NotNull(instantiated); - MethodInfo? getDateMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDate)); - Assert.NotNull(getDateMethod); - - getDateMethod.Invoke(instantiated, null); + try + { + MethodInfo? getDateMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDate)); + Assert.NotNull(getDateMethod); - MethodInfo? getDateAsyncMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDateAsync)); - Assert.NotNull(getDateAsyncMethod); + getDateMethod.Invoke(instantiated, null); - Task? getDateTask = getDateAsyncMethod.Invoke(instantiated, null) as Task; - Assert.NotNull(getDateTask); + MethodInfo? getDateAsyncMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.GetDateAsync)); + Assert.NotNull(getDateAsyncMethod); - await getDateTask; + Task? getDateTask = getDateAsyncMethod.Invoke(instantiated, null) as Task; + Assert.NotNull(getDateTask); - MethodInfo? disposeMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.Dispose)); - Assert.NotNull(disposeMethod); + await getDateTask; + } + finally + { + MethodInfo? disposeMethod = unloadableLibraryType.GetMethod(nameof(EntryPoint.Dispose)); + Assert.NotNull(disposeMethod); - disposeMethod.Invoke(instantiated, null); + disposeMethod.Invoke(instantiated, null); - alc.Unload(); + alc.Unload(); + } return weakRef; } From 26fe548ab2752787c11667234340f8e40dc75f8d Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:03 +0100 Subject: [PATCH 16/17] Ensure RetryLogicType contains assembly-qualified name This point is from private review. It's designed to make sure we exercise the full set of type/assembly resolution --- .../tests/UnloadableLibrary/EntryPoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs index 9381dda862..e65a448e8f 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnloadableLibrary/EntryPoint.cs @@ -57,7 +57,7 @@ private void InitializeRetryProviders() MaxTimeInterval = TimeSpan.FromSeconds(30), MinTimeInterval = TimeSpan.FromSeconds(1), NumberOfTries = 1, - RetryLogicType = typeof(SqlConfigurableRetryFactory).FullName, + RetryLogicType = typeof(SqlConfigurableRetryFactory).AssemblyQualifiedName, RetryMethodName = nameof(SqlConfigurableRetryFactory.CreateNoneRetryProvider) }; RetryLogicConfigs commandConfigs = RetryLogicLoaderHelper.CreateRandomConfig(method: null, authorizedSqlCondition: null); From a8396eda636777b787a823199acf7fce8d993a28 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:39 +0100 Subject: [PATCH 17/17] Fix typo --- .../SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs index 05feb91dcb..bc2972244b 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Unloadability/UnloadableAssemblyLoadContextTest.cs @@ -122,7 +122,7 @@ private sealed class UnloadableAssemblyLoadContext : AssemblyLoadContext private readonly AssemblyDependencyResolver _resolver; /// - /// Insntantiates a new unloadable with the specified + /// Instantiates a new unloadable with the specified /// name and assembly path. /// /// Name of this .