From e2568f493e63683e0824a97d2b5dc8981fba3ce8 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Fri, 12 Dec 2025 13:47:21 +0100 Subject: [PATCH] Refactor ProjectInfo creation to resolve Race Condition on multiple test threads --- src/RoslynTestKit/Helpers/AdhocWorkspace.cs | 140 ++++++++++++-------- 1 file changed, 85 insertions(+), 55 deletions(-) diff --git a/src/RoslynTestKit/Helpers/AdhocWorkspace.cs b/src/RoslynTestKit/Helpers/AdhocWorkspace.cs index 41091e5..f21fcde 100644 --- a/src/RoslynTestKit/Helpers/AdhocWorkspace.cs +++ b/src/RoslynTestKit/Helpers/AdhocWorkspace.cs @@ -18,8 +18,8 @@ namespace RoslynTestKit public sealed class AdhocWorkspace : Workspace { #if NET8_0_OR_GREATER - private static MethodInfo? _cachedCreateMethod; - private static ParameterInfo[]? _cachedParameters; + private static readonly object _reflectionLock = new object(); + private static (MethodInfo Method, ParameterInfo[] Parameters)? _cachedCreateMethodInfo; #endif public AdhocWorkspace(HostServices host, string workspaceKind = "Custom") @@ -80,8 +80,6 @@ public Solution AddSolution(SolutionInfo solutionInfo) #if NET8_0_OR_GREATER public Project? AddProject(string name, string language) { - // There's a binary compatibility issue caused by a breaking change in the ProjectInfo.Create method signature between version v17.0.28.6483 and v17.0.28.26016 of Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll. - // hence we use reflection to call the method in a way that works for both versions var info = CreateProjectInfoViaReflection(name, language); return AddProject(info); } @@ -90,74 +88,106 @@ public Solution AddSolution(SolutionInfo solutionInfo) /// Creates a ProjectInfo instance using reflection to handle different versions /// of Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces that have different method signatures. /// + /// + /// This method is necessary because the ProjectInfo.Create method signature changed between + /// version v17.0.28.6483 and v17.0.28.26016 of Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll. + /// By using reflection, we can call the method regardless of which version is loaded at runtime. + /// private static ProjectInfo CreateProjectInfoViaReflection(string name, string language) { - if (_cachedCreateMethod == null) + var (method, parameters) = GetCachedCreateMethod(); + var args = BuildMethodArguments(parameters, name, language); + + var result = method.Invoke(null, args); + if (result is not ProjectInfo projectInfo) + { + throw new InvalidOperationException("ProjectInfo.Create did not return a ProjectInfo instance."); + } + + return projectInfo; + } + + /// + /// Gets the cached MethodInfo and ParameterInfo for ProjectInfo.Create, initializing if necessary. + /// Thread-safe using double-checked locking pattern. + /// + private static (MethodInfo Method, ParameterInfo[] Parameters) GetCachedCreateMethod() + { + var cached = _cachedCreateMethodInfo; + if (cached != null) { - // Find the Create method - there should be only one static Create method - _cachedCreateMethod = typeof(ProjectInfo) + return cached.Value; + } + + lock (_reflectionLock) + { + cached = _cachedCreateMethodInfo; + if (cached != null) + { + return cached.Value; + } + + var createMethod = typeof(ProjectInfo) .GetMethods(BindingFlags.Public | BindingFlags.Static) .FirstOrDefault(m => m.Name == "Create") - ?? throw new InvalidOperationException("Could not find ProjectInfo.Create method via reflection."); + ?? throw new InvalidOperationException( + "Could not find ProjectInfo.Create method. Ensure Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces is referenced."); - _cachedParameters = _cachedCreateMethod.GetParameters(); + var result = (createMethod, createMethod.GetParameters()); + _cachedCreateMethodInfo = result; + return result; } + } - var parameters = _cachedParameters!; + /// + /// Builds the argument array for calling ProjectInfo.Create via reflection. + /// + private static object?[] BuildMethodArguments(ParameterInfo[] parameters, string name, string language) + { var args = new object?[parameters.Length]; - // The first 5 parameters are always: id, version, name, assemblyName, language (required) - // All other parameters are optional - use their default values via ParameterInfo.DefaultValue for (int i = 0; i < parameters.Length; i++) { - var param = parameters[i]; - var paramName = param.Name?.ToLowerInvariant(); - - // Handle the 5 required parameters explicitly - if (paramName == "id") - { - args[i] = ProjectId.CreateNewId(); - } - else if (paramName == "version") - { - args[i] = VersionStamp.Create(); - } - else if (paramName == "name") - { - args[i] = name; - } - else if (paramName == "assemblyname") - { - args[i] = name; - } - else if (paramName == "language") - { - args[i] = language; - } - // For optional parameters, use their default value or appropriate fallback - else if (param.HasDefaultValue) - { - args[i] = param.DefaultValue; - } - else if (!param.ParameterType.IsValueType) - { - // Reference types without default - use null - args[i] = null; - } - else - { - // Value types without default - use default(T) - args[i] = Activator.CreateInstance(param.ParameterType); - } + args[i] = GetParameterValue(parameters[i], name, language); } - var result = _cachedCreateMethod.Invoke(null, args); - if (result is not ProjectInfo projectInfo) + return args; + } + + /// + /// Determines the value to pass for a given parameter of ProjectInfo.Create. + /// + private static object? GetParameterValue(ParameterInfo parameter, string name, string language) + { + var paramName = parameter.Name?.ToLowerInvariant() ?? string.Empty; + + // Handle the required parameters (first 5 in the method signature) + return paramName switch { - throw new InvalidOperationException("ProjectInfo.Create did not return a ProjectInfo instance."); + "id" => ProjectId.CreateNewId(), + "version" => VersionStamp.Create(), + "name" => name, + "assemblyname" => name, + "language" => language, + // For optional parameters, use their declared default value or an appropriate fallback + _ => GetDefaultParameterValue(parameter) + }; + } + + /// + /// Gets the default value for an optional parameter. + /// + private static object? GetDefaultParameterValue(ParameterInfo parameter) + { + if (parameter.HasDefaultValue) + { + return parameter.DefaultValue; } - return projectInfo; + // Fallback for parameters without explicit defaults + return parameter.ParameterType.IsValueType + ? Activator.CreateInstance(parameter.ParameterType) + : null; } #endif