diff --git a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs index cae9e3cf02..fbe2e97b88 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs @@ -52,8 +52,8 @@ public static class ReflectionMetadataHook /// All types directly annotated with [TestClass] in the assembly. /// /// A map from each test class to its [TestMethod]-annotated - /// set. The dictionary and arrays are copied defensively; the caller may mutate the inputs - /// after the call. + /// set. Ownership transfers to the adapter: the source generator hands over freshly-built + /// collections and must not mutate them after the call (see the remarks on the full overload). /// /// /// Do not call this method from hand-written code; it is meant to be invoked exclusively from @@ -120,8 +120,20 @@ public static void Register( /// assigns it directly. /// /// + /// /// Do not call this method from hand-written code; it is meant to be invoked exclusively from /// the [ModuleInitializer] emitted by the MSTest source generator. + /// + /// + /// Ownership transfer. The adapter takes ownership of every collection passed in + /// (the array, the array, and + /// each dictionary with its value arrays) and stores them without cloning. Callers MUST hand + /// over freshly-built collections and MUST NOT mutate them after the call returns; the source + /// generator (the only intended caller) already emits fresh, throwaway collections that satisfy + /// this. This is a contract about ownership and mutation, not caller identity: it trades the + /// previous defensive copies for zero-copy startup on the understanding that the inputs are the + /// adapter's to keep. + /// /// [EditorBrowsable(EditorBrowsableState.Never)] public static void Register( @@ -174,29 +186,20 @@ public static void Register( throw new ArgumentNullException(nameof(propertySetters)); } - var typesCopy = (Type[])types.Clone(); + // Ownership transfer (see the remarks on this method): the source generator hands over + // freshly-built, throwaway collections and never mutates them after the call, so we store + // them directly instead of cloning. Arrays are kept by reference; dictionaries are reused + // as-is when already concrete and only materialized when the caller passed some other + // IReadOnlyDictionary implementation. + Dictionary testMethodsMap = AsOwnedDictionary(testMethods); + Dictionary typeAttributesMap = AsOwnedDictionary(typeAttributes); + Dictionary> methodInvokersMap = AsOwnedDictionary(methodInvokers); + Dictionary> propertySettersMap = AsOwnedDictionary(propertySetters); - var testMethodsCopy = new Dictionary(testMethods.Count); - foreach (KeyValuePair kvp in testMethods) - { - testMethodsCopy[kvp.Key] = (MethodInfo[])kvp.Value.Clone(); - } - - var typeAttributesCopy = new Dictionary(typeAttributes.Count); - foreach (KeyValuePair kvp in typeAttributes) - { - typeAttributesCopy[kvp.Key] = (Attribute[])kvp.Value.Clone(); - } - - object[] assemblyAttributesCopy = (object[])assemblyAttributes.Clone(); - - var methodInvokersCopy = new Dictionary>(methodInvokers.Count); - foreach (KeyValuePair> kvp in methodInvokers) - { - methodInvokersCopy[kvp.Key] = kvp.Value; - } - - var constructorInvokersCopy = new Dictionary(constructorInvokers.Count); + // ConstructorInvokerInfo (public struct) has to be projected onto the adapter's internal + // ConstructorInvoker type; this is a representation change, not a defensive copy, and the + // parameter-type arrays are taken by reference. + var constructorInvokersMap = new Dictionary(constructorInvokers.Count); foreach (KeyValuePair kvp in constructorInvokers) { var invokers = new SourceGeneratedReflectionDataProvider.ConstructorInvoker[kvp.Value.Length]; @@ -205,26 +208,20 @@ public static void Register( ConstructorInvokerInfo info = kvp.Value[i]; invokers[i] = new SourceGeneratedReflectionDataProvider.ConstructorInvoker { - Parameters = (Type[])info.ParameterTypes.Clone(), + Parameters = info.ParameterTypes, Invoker = info.Invoker, }; } - constructorInvokersCopy[kvp.Key] = invokers; - } - - var propertySettersCopy = new Dictionary>(propertySetters.Count); - foreach (KeyValuePair> kvp in propertySetters) - { - propertySettersCopy[kvp.Key] = kvp.Value; + constructorInvokersMap[kvp.Key] = invokers; } // TypesByName must always match Type.FullName at runtime (see comment in the source // generator emitter): compute it on the runtime side from typeof(T).FullName so the // generator emits less code and the same FullName conventions are honored for nested // and generic types. - var typesByName = new Dictionary(typesCopy.Length, StringComparer.Ordinal); - foreach (Type type in typesCopy) + var typesByName = new Dictionary(types.Length, StringComparer.Ordinal); + foreach (Type type in types) { if (type.FullName is { } fullName) { @@ -236,14 +233,14 @@ public static void Register( { Assembly = assembly, AssemblyName = assembly.GetName().Name ?? string.Empty, - Types = typesCopy, + Types = types, TypesByName = typesByName, - TypeMethods = testMethodsCopy, - TypeAttributes = typeAttributesCopy, - AssemblyAttributes = assemblyAttributesCopy, - TypeMethodInvokers = methodInvokersCopy, - TypeConstructorsInvoker = constructorInvokersCopy, - TypePropertySetters = propertySettersCopy, + TypeMethods = testMethodsMap, + TypeAttributes = typeAttributesMap, + AssemblyAttributes = assemblyAttributes, + TypeMethodInvokers = methodInvokersMap, + TypeConstructorsInvoker = constructorInvokersMap, + TypePropertySetters = propertySettersMap, }; lock (Lock) @@ -260,6 +257,27 @@ public static void Register( } } + // Reuses the caller-provided dictionary when it is already a concrete Dictionary<,> (the shape + // the source generator always emits), honoring the ownership-transfer contract with zero + // copying. Any other IReadOnlyDictionary implementation is materialized once so the provider + // still owns a concrete instance. Values are always taken by reference. + private static Dictionary AsOwnedDictionary(IReadOnlyDictionary source) + where TKey : notnull + { + if (source is Dictionary concrete) + { + return concrete; + } + + var copy = new Dictionary(source.Count); + foreach (KeyValuePair kvp in source) + { + copy[kvp.Key] = kvp.Value; + } + + return copy; + } + private static readonly Dictionary EmptyTypeAttributes = []; private static readonly Dictionary> EmptyMethodInvokers = [];