From 589b15d1dacfb855cfe416f5637136c62f20d359 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 01:13:00 +0900 Subject: [PATCH] Extract shared Roslyn worker assembly builder Move worker compiler invocation, response-file construction, references, quoting, cleanup, and .NET process policy out of the lifecycle host. Keep the mutable test override in the host through a narrow seam adapter. --- .../SharedRoslynCompilerWorkerHostTests.cs | 8 +- ...aredRoslynCompilerWorkerAssemblyBuilder.cs | 219 ++++++++++++++++++ ...oslynCompilerWorkerAssemblyBuilder.cs.meta | 11 + .../SharedRoslynCompilerWorkerHost.cs | 210 +---------------- 4 files changed, 245 insertions(+), 203 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs.meta diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index c886cc4367..950d57a2e0 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -18,13 +18,13 @@ public class SharedRoslynCompilerWorkerHostTests public void ConfigureWorkerDotnetRuntimeEnvironment_WhenCalled_ShouldDisableMultilevelLookup() { ProcessStartInfo startInfo = new(); - startInfo.EnvironmentVariables[SharedRoslynCompilerWorkerHost.DotnetMultilevelLookupEnvironmentVariableName] = "1"; + startInfo.EnvironmentVariables[SharedRoslynCompilerWorkerAssemblyBuilder.DotnetMultilevelLookupEnvironmentVariableName] = "1"; - SharedRoslynCompilerWorkerHost.ConfigureWorkerDotnetRuntimeEnvironment(startInfo); + SharedRoslynCompilerWorkerAssemblyBuilder.ConfigureWorkerDotnetRuntimeEnvironment(startInfo); Assert.That( - startInfo.EnvironmentVariables[SharedRoslynCompilerWorkerHost.DotnetMultilevelLookupEnvironmentVariableName], - Is.EqualTo(SharedRoslynCompilerWorkerHost.DotnetMultilevelLookupDisabledValue)); + startInfo.EnvironmentVariables[SharedRoslynCompilerWorkerAssemblyBuilder.DotnetMultilevelLookupEnvironmentVariableName], + Is.EqualTo(SharedRoslynCompilerWorkerAssemblyBuilder.DotnetMultilevelLookupDisabledValue)); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs new file mode 100644 index 0000000000..468d84030a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using UnityEditor.Compilation; +using Debug = UnityEngine.Debug; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds the shared Roslyn compiler worker and configures its .NET process environment. + /// + internal static class SharedRoslynCompilerWorkerAssemblyBuilder + { + private const int WorkerAssemblyBuildTimeoutMilliseconds = 30000; + internal const string DotnetMultilevelLookupEnvironmentVariableName = "DOTNET_MULTILEVEL_LOOKUP"; + internal const string DotnetMultilevelLookupDisabledValue = "0"; + + /// + /// Carries the result data produced by Worker Assembly Build behavior. + /// + internal sealed class WorkerAssemblyBuildResult + { + public bool StartedSuccessfully { get; } + + public CompilerMessage[] Messages { get; } + + public string FailureReason { get; } + + public object FailureContext { get; } + + private WorkerAssemblyBuildResult( + bool startedSuccessfully, + CompilerMessage[] messages, + string failureReason, + object failureContext) + { + StartedSuccessfully = startedSuccessfully; + Messages = messages; + FailureReason = failureReason; + FailureContext = failureContext; + } + + public static WorkerAssemblyBuildResult Started(CompilerMessage[] messages) + { + return new WorkerAssemblyBuildResult(true, messages, null, null); + } + + public static WorkerAssemblyBuildResult StartFailure(string failureReason, object failureContext) + { + return new WorkerAssemblyBuildResult(false, null, failureReason, failureContext); + } + } + + internal static void ConfigureWorkerDotnetRuntimeEnvironment(ProcessStartInfo startInfo) + { + Debug.Assert(startInfo != null, "startInfo must not be null"); + + // Why: global probing can select a system .NET 6 runtime while Unity 6000.4 worker + // references come from the bundled .NET 8 runtime, which breaks assembly binding. + startInfo.EnvironmentVariables[DotnetMultilevelLookupEnvironmentVariableName] = + DotnetMultilevelLookupDisabledValue; + } + + internal static WorkerAssemblyBuildResult CompileWorkerAssembly( + ExternalCompilerPaths externalCompilerPaths, + string workerSourcePath, + string workerAssemblyPath, + string workerCompileResponseFilePath) + { + WriteWorkerCompilerResponseFile( + workerCompileResponseFilePath, + workerSourcePath, + workerAssemblyPath, + BuildWorkerReferenceSet(externalCompilerPaths)); + + ProcessStartInfo startInfo = new() { + FileName = externalCompilerPaths.DotnetHostPath, + Arguments = $"{QuoteCommandLineArgument(externalCompilerPaths.CompilerDllPath)} @{QuoteCommandLineArgument(workerCompileResponseFilePath)}", + WorkingDirectory = Path.GetDirectoryName(workerSourcePath), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + ConfigureWorkerDotnetRuntimeEnvironment(startInfo); + + using Process process = ProcessStartHelper.TryStart(startInfo); + if (process == null) + { + return WorkerAssemblyBuildResult.StartFailure( + "worker_compiler_start_failed", + new + { + dotnet_host_path = externalCompilerPaths.DotnetHostPath, + compiler_dll_path = externalCompilerPaths.CompilerDllPath + }); + } + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + Task stderrTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(WorkerAssemblyBuildTimeoutMilliseconds)) + { + if (!process.HasExited) + { + process.Kill(); + process.WaitForExit(500); + } + + Task.WaitAll(stdoutTask, stderrTask); + return WorkerAssemblyBuildResult.StartFailure( + "worker_compiler_timeout", + new + { + timeout_ms = WorkerAssemblyBuildTimeoutMilliseconds, + dotnet_host_path = externalCompilerPaths.DotnetHostPath, + compiler_dll_path = externalCompilerPaths.CompilerDllPath + }); + } + + Task.WaitAll(stdoutTask, stderrTask); + CompilerMessage[] compilerMessages = ExternalCompilerMessageParser.Parse( + stdoutTask.GetAwaiter().GetResult(), + stderrTask.GetAwaiter().GetResult(), + process.ExitCode); + return WorkerAssemblyBuildResult.Started(compilerMessages); + } + + private static List BuildWorkerReferenceSet(ExternalCompilerPaths externalCompilerPaths) + { + string sharedRuntimeDirectoryPath = externalCompilerPaths.NetCoreRuntimeSharedDirectoryPath; + List references = new() { + Path.Combine(sharedRuntimeDirectoryPath, "System.Private.CoreLib.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Console.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Collections.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.IO.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.Tasks.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Text.Encoding.Extensions.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.Extensions.dll"), + Path.Combine(sharedRuntimeDirectoryPath, "netstandard.dll"), + externalCompilerPaths.CodeAnalysisDllPath, + externalCompilerPaths.CodeAnalysisCSharpDllPath + }; + + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Collections.Immutable.dll")); + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Reflection.Metadata.dll")); + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.CompilerServices.Unsafe.dll")); + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Memory.dll")); + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Buffers.dll")); + AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.Tasks.Extensions.dll")); + + return references; + } + + private static void WriteWorkerCompilerResponseFile( + string responseFilePath, + string sourcePath, + string dllPath, + IReadOnlyCollection references) + { + List lines = new() { + "-nologo", + "-nostdlib+", + "-target:exe", + "-optimize+", + "-debug-", + QuoteResponseFileArgument("-out:", dllPath) + }; + + foreach (string reference in references) + { + lines.Add(QuoteResponseFileArgument("-r:", reference)); + } + + lines.Add(QuoteResponseFilePath(sourcePath)); + File.WriteAllLines(responseFilePath, lines); + } + + internal static void DeleteWorkerAssemblyIfPresent(string assemblyPath) + { + if (File.Exists(assemblyPath)) + { + File.Delete(assemblyPath); + } + } + + private static string QuoteResponseFileArgument(string prefix, string value) + { + return $"{prefix}{QuoteResponseFilePath(value)}"; + } + + private static string QuoteResponseFilePath(string path) + { + return $"\"{path}\""; + } + + internal static string QuoteCommandLineArgument(string value) + { + return $"\"{value}\""; + } + + private static void AddIfExists( + List destination, + string referencePath) + { + if (string.IsNullOrEmpty(referencePath) || !File.Exists(referencePath)) + { + return; + } + + destination.Add(referencePath); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs.meta new file mode 100644 index 0000000000..a51b9bf37a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f87f89a82616c479e829263620053937 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index a995e0fde1..db6be6a175 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.IO; using System.Threading; -using System.Threading.Tasks; using UnityEditor; using UnityEditor.Compilation; using Debug = UnityEngine.Debug; @@ -21,9 +20,6 @@ internal static class SharedRoslynCompilerWorkerHost private const string RoslynWorkerSourceFileName = "RoslynCompilerWorker.cs"; private const string RoslynWorkerAssemblyFileName = "RoslynCompilerWorker.dll"; private const string RoslynWorkerCompileResponseFileName = "RoslynCompilerWorker.rsp"; - private const int WorkerAssemblyBuildTimeoutMilliseconds = 30000; - internal const string DotnetMultilevelLookupEnvironmentVariableName = "DOTNET_MULTILEVEL_LOOKUP"; - internal const string DotnetMultilevelLookupDisabledValue = "0"; private static readonly object SharedCompilerWorkerLock = new(); private static Action s_deleteWorkerDirectory = path => Directory.Delete(path, true); @@ -100,42 +96,6 @@ public static WorkerStartupResult Failure(string failureReason, object failureCo } } - /// - /// Carries the result data produced by Worker Assembly Build behavior. - /// - private sealed class WorkerAssemblyBuildResult - { - public bool StartedSuccessfully { get; } - - public CompilerMessage[] Messages { get; } - - public string FailureReason { get; } - - public object FailureContext { get; } - - private WorkerAssemblyBuildResult( - bool startedSuccessfully, - CompilerMessage[] messages, - string failureReason, - object failureContext) - { - StartedSuccessfully = startedSuccessfully; - Messages = messages; - FailureReason = failureReason; - FailureContext = failureContext; - } - - public static WorkerAssemblyBuildResult Started(CompilerMessage[] messages) - { - return new WorkerAssemblyBuildResult(true, messages, null, null); - } - - public static WorkerAssemblyBuildResult StartFailure(string failureReason, object failureContext) - { - return new WorkerAssemblyBuildResult(false, null, failureReason, failureContext); - } - } - /// /// Provides Worker Paths behavior for Unity CLI Loop. /// @@ -283,7 +243,7 @@ private static WorkerStartupResult EnsureWorkerAssemblyBuilt( return WorkerStartupResult.Ready(); } - WorkerAssemblyBuildResult buildResult = CompileWorkerAssembly( + SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult buildResult = CompileWorkerAssembly( externalCompilerPaths, workerPaths.SourcePath, workerPaths.AssemblyPath, @@ -300,7 +260,7 @@ private static WorkerStartupResult EnsureWorkerAssemblyBuilt( return WorkerStartupResult.Ready(); } - DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); + SharedRoslynCompilerWorkerAssemblyBuilder.DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); return WorkerStartupResult.Failure( "worker_build_failed", new @@ -462,9 +422,9 @@ private static ProcessStartInfo CreateWorkerStartInfo( ProcessStartInfo startInfo = new() { FileName = externalCompilerPaths.DotnetHostPath, Arguments = "exec" - + " --runtimeconfig " + QuoteCommandLineArgument(externalCompilerPaths.CompilerRuntimeConfigPath) - + " --depsfile " + QuoteCommandLineArgument(externalCompilerPaths.CompilerDepsFilePath) - + " " + QuoteCommandLineArgument(workerPaths.AssemblyPath), + + " --runtimeconfig " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerRuntimeConfigPath) + + " --depsfile " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerDepsFilePath) + + " " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(workerPaths.AssemblyPath), WorkingDirectory = workerPaths.DirectoryPath, UseShellExecute = false, RedirectStandardInput = true, @@ -473,21 +433,11 @@ private static ProcessStartInfo CreateWorkerStartInfo( CreateNoWindow = true }; - ConfigureWorkerDotnetRuntimeEnvironment(startInfo); + SharedRoslynCompilerWorkerAssemblyBuilder.ConfigureWorkerDotnetRuntimeEnvironment(startInfo); return startInfo; } - internal static void ConfigureWorkerDotnetRuntimeEnvironment(ProcessStartInfo startInfo) - { - Debug.Assert(startInfo != null, "startInfo must not be null"); - - // Why: global probing can select a system .NET 6 runtime while Unity 6000.4 worker - // references come from the bundled .NET 8 runtime, which breaks assembly binding. - startInfo.EnvironmentVariables[DotnetMultilevelLookupEnvironmentVariableName] = - DotnetMultilevelLookupDisabledValue; - } - - private static WorkerAssemblyBuildResult CompileWorkerAssembly( + private static SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult CompileWorkerAssembly( ExternalCompilerPaths externalCompilerPaths, string workerSourcePath, string workerAssemblyPath, @@ -495,7 +445,7 @@ private static WorkerAssemblyBuildResult CompileWorkerAssembly( { if (s_compileWorkerAssemblyForTests != null) { - return WorkerAssemblyBuildResult.Started( + return SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult.Started( s_compileWorkerAssemblyForTests( externalCompilerPaths, workerSourcePath, @@ -503,114 +453,11 @@ private static WorkerAssemblyBuildResult CompileWorkerAssembly( workerCompileResponseFilePath)); } - WriteWorkerCompilerResponseFile( - workerCompileResponseFilePath, + return SharedRoslynCompilerWorkerAssemblyBuilder.CompileWorkerAssembly( + externalCompilerPaths, workerSourcePath, workerAssemblyPath, - BuildWorkerReferenceSet(externalCompilerPaths)); - - ProcessStartInfo startInfo = new() { - FileName = externalCompilerPaths.DotnetHostPath, - Arguments = $"{QuoteCommandLineArgument(externalCompilerPaths.CompilerDllPath)} @{QuoteCommandLineArgument(workerCompileResponseFilePath)}", - WorkingDirectory = Path.GetDirectoryName(workerSourcePath), - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - ConfigureWorkerDotnetRuntimeEnvironment(startInfo); - - using Process process = ProcessStartHelper.TryStart(startInfo); - if (process == null) - { - return WorkerAssemblyBuildResult.StartFailure( - "worker_compiler_start_failed", - new - { - dotnet_host_path = externalCompilerPaths.DotnetHostPath, - compiler_dll_path = externalCompilerPaths.CompilerDllPath - }); - } - - Task stdoutTask = process.StandardOutput.ReadToEndAsync(); - Task stderrTask = process.StandardError.ReadToEndAsync(); - if (!process.WaitForExit(WorkerAssemblyBuildTimeoutMilliseconds)) - { - if (!process.HasExited) - { - process.Kill(); - process.WaitForExit(500); - } - - Task.WaitAll(stdoutTask, stderrTask); - return WorkerAssemblyBuildResult.StartFailure( - "worker_compiler_timeout", - new - { - timeout_ms = WorkerAssemblyBuildTimeoutMilliseconds, - dotnet_host_path = externalCompilerPaths.DotnetHostPath, - compiler_dll_path = externalCompilerPaths.CompilerDllPath - }); - } - - Task.WaitAll(stdoutTask, stderrTask); - CompilerMessage[] compilerMessages = ExternalCompilerMessageParser.Parse( - stdoutTask.GetAwaiter().GetResult(), - stderrTask.GetAwaiter().GetResult(), - process.ExitCode); - return WorkerAssemblyBuildResult.Started(compilerMessages); - } - - private static List BuildWorkerReferenceSet(ExternalCompilerPaths externalCompilerPaths) - { - string sharedRuntimeDirectoryPath = externalCompilerPaths.NetCoreRuntimeSharedDirectoryPath; - List references = new() { - Path.Combine(sharedRuntimeDirectoryPath, "System.Private.CoreLib.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Console.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Collections.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.IO.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.Tasks.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Text.Encoding.Extensions.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.Extensions.dll"), - Path.Combine(sharedRuntimeDirectoryPath, "netstandard.dll"), - externalCompilerPaths.CodeAnalysisDllPath, - externalCompilerPaths.CodeAnalysisCSharpDllPath - }; - - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Collections.Immutable.dll")); - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Reflection.Metadata.dll")); - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Runtime.CompilerServices.Unsafe.dll")); - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Memory.dll")); - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Buffers.dll")); - AddIfExists(references, Path.Combine(sharedRuntimeDirectoryPath, "System.Threading.Tasks.Extensions.dll")); - - return references; - } - - private static void WriteWorkerCompilerResponseFile( - string responseFilePath, - string sourcePath, - string dllPath, - IReadOnlyCollection references) - { - List lines = new() { - "-nologo", - "-nostdlib+", - "-target:exe", - "-optimize+", - "-debug-", - QuoteResponseFileArgument("-out:", dllPath) - }; - - foreach (string reference in references) - { - lines.Add(QuoteResponseFileArgument("-r:", reference)); - } - - lines.Add(QuoteResponseFilePath(sourcePath)); - File.WriteAllLines(responseFilePath, lines); + workerCompileResponseFilePath); } private static bool HasLiveWorkerProcess() @@ -644,14 +491,6 @@ private static string FindFirstErrorMessage(IReadOnlyCollection return string.Empty; } - private static void DeleteWorkerAssemblyIfPresent(string assemblyPath) - { - if (File.Exists(assemblyPath)) - { - File.Delete(assemblyPath); - } - } - private static void ShutdownForReload() { Shutdown(); @@ -834,32 +673,5 @@ private static object AppendAttempt(object failureContext, int attempt) }; } - private static string QuoteResponseFileArgument(string prefix, string value) - { - return $"{prefix}{QuoteResponseFilePath(value)}"; - } - - private static string QuoteResponseFilePath(string path) - { - return $"\"{path}\""; - } - - private static string QuoteCommandLineArgument(string value) - { - return $"\"{value}\""; - } - - private static void AddIfExists( - List destination, - string referencePath) - { - if (string.IsNullOrEmpty(referencePath) || !File.Exists(referencePath)) - { - return; - } - - destination.Add(referencePath); - } - } }