diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 1e23ec57bc..c886cc4367 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -33,24 +33,53 @@ public void CreateCompileRequestCommand_WhenPathIsWindowsAbsolutePath_ShouldEnco string requestFilePath = @"C:\Users\ExampleUser\Documents\unity\SampleWorkspace\SampleUnityProject\Temp\UnityCliLoopCompilation\DynamicCommand_1.worker"; - string command = SharedRoslynCompilerWorkerHost.CreateCompileRequestCommandForTests(requestFilePath); + string command = SharedRoslynCompilerWorkerProtocol.CreateCompileRequestCommand(requestFilePath); - Assert.That(command, Does.StartWith(SharedRoslynCompilerWorkerHost.CompileRequestPathPrefix)); + Assert.That(command, Does.StartWith(SharedRoslynCompilerWorkerProtocol.CompileRequestPathPrefix)); Assert.That(command, Does.Not.Contain(requestFilePath)); foreach (char character in command) { Assert.That(character, Is.LessThanOrEqualTo((char)127)); } - string encodedPath = command.Substring(SharedRoslynCompilerWorkerHost.CompileRequestPathPrefix.Length); + string encodedPath = command.Substring(SharedRoslynCompilerWorkerProtocol.CompileRequestPathPrefix.Length); string decodedPath = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPath)); Assert.That(decodedPath, Is.EqualTo(Path.GetFullPath(requestFilePath))); } + [Test] + public void TryParseResponseHeader_WhenHeaderContainsExitCode_ShouldReturnParsedCode() + { + // Verifies the worker protocol accepts its result prefix followed by a numeric exit code. + bool parsed = SharedRoslynCompilerWorkerProtocol.TryParseResponseHeader("__ULOOP_RESULT__ 7", out int exitCode); + + Assert.That(parsed, Is.True); + Assert.That(exitCode, Is.EqualTo(7)); + } + + [Test] + public void GetResponseHeaderFailureReason_WhenPrefixIsInvalid_ShouldReportInvalidHeader() + { + // Verifies a response without the worker result prefix is classified as an invalid header. + string failureReason = SharedRoslynCompilerWorkerProtocol.GetResponseHeaderFailureReason("unexpected response"); + + Assert.That(failureReason, Is.EqualTo("worker_invalid_header")); + } + + [Test] + public void GetResponseHeaderFailureReason_WhenExitCodeIsInvalid_ShouldReportInvalidExitCode() + { + // Verifies a prefixed response with a non-numeric status is classified as an invalid exit code. + string failureReason = SharedRoslynCompilerWorkerProtocol.GetResponseHeaderFailureReason( + "__ULOOP_RESULT__ not-a-number"); + + Assert.That(failureReason, Is.EqualTo("worker_invalid_exit_code")); + } + [Test] public void CreateProgramSource_WhenRequestPathHasNoPrefix_ShouldRecoverRawPath() { - string programSource = SharedRoslynCompilerWorkerHost.CreateProgramSourceForTests(); + string programSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); Assert.That(programSource, Does.Contain("return RecoverRawRequestPath(requestPath);")); Assert.That(programSource, Does.Contain("FindWindowsDrivePathIndex")); @@ -60,24 +89,24 @@ public void CreateProgramSource_WhenRequestPathHasNoPrefix_ShouldRecoverRawPath( [Test] public void CreateProgramSource_WhenTemplateIsLoaded_ShouldReplaceTokens() { - string templatePath = SharedRoslynCompilerWorkerHost.GetWorkerProgramTemplatePathForTests(); - string programSource = SharedRoslynCompilerWorkerHost.CreateProgramSourceForTests(); + string templatePath = SharedRoslynCompilerWorkerProtocol.GetWorkerProgramTemplatePath(); + string programSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); Assert.That(File.Exists(templatePath), Is.True); - Assert.That(programSource, Does.Contain(SharedRoslynCompilerWorkerHost.CompileRequestPathPrefix)); + Assert.That(programSource, Does.Contain(SharedRoslynCompilerWorkerProtocol.CompileRequestPathPrefix)); Assert.That(programSource, Does.Contain( - SharedRoslynCompilerWorkerHost.GetSharedCompilerWorkerResultPrefixForTests())); + SharedRoslynCompilerWorkerProtocol.SharedCompilerWorkerResultPrefix)); Assert.That(programSource, Does.Contain( - SharedRoslynCompilerWorkerHost.GetSharedCompilerWorkerEndMarkerForTests())); + SharedRoslynCompilerWorkerProtocol.SharedCompilerWorkerEndMarker)); Assert.That(programSource, Does.Contain( - SharedRoslynCompilerWorkerHost.GetSharedCompilerWorkerQuitCommandForTests())); + SharedRoslynCompilerWorkerProtocol.SharedCompilerWorkerQuitCommand)); Assert.That(programSource, Does.Not.Contain("{{")); } [Test] public void CreateProgramSource_WhenRequestPathPrefixHasLeadingGarbage_ShouldDecodeEncodedPath() { - string programSource = SharedRoslynCompilerWorkerHost.CreateProgramSourceForTests(); + string programSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); Assert.That(programSource, Does.Contain("FindRequestPathPrefixIndex")); Assert.That(programSource, Does.Contain("IndexOf(RequestPathPrefix")); @@ -87,7 +116,7 @@ public void CreateProgramSource_WhenRequestPathPrefixHasLeadingGarbage_ShouldDec [Test] public void CreateProgramSource_WhenRawPathContainsPrefixAfterDirectorySeparator_ShouldRecoverRawPath() { - string programSource = SharedRoslynCompilerWorkerHost.CreateProgramSourceForTests(); + string programSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); Assert.That(programSource, Does.Contain("HasDirectorySeparatorBeforePrefix")); Assert.That(programSource, Does.Contain("return HasDirectorySeparatorBeforePrefix(requestPath, encodedPathIndex) ? -1 : encodedPathIndex;")); @@ -96,7 +125,7 @@ public void CreateProgramSource_WhenRawPathContainsPrefixAfterDirectorySeparator [Test] public void CreateProgramSource_WhenEncodedPayloadIsMalformed_ShouldRecoverRawPath() { - string programSource = SharedRoslynCompilerWorkerHost.CreateProgramSourceForTests(); + string programSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); Assert.That(programSource, Does.Contain("IsBase64Payload")); Assert.That(programSource, Does.Contain("HasValidBase64Padding")); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index ad68bfdf79..a995e0fde1 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -19,20 +18,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class SharedRoslynCompilerWorkerHost { private const int SharedCompilerWorkerMaxAttempts = 2; - private const string SharedCompilerWorkerResultPrefix = "__ULOOP_RESULT__"; - private const string SharedCompilerWorkerEndMarker = "__ULOOP_END__"; - private const string SharedCompilerWorkerQuitCommand = "__QUIT__"; - internal const string CompileRequestPathPrefix = "path-base64:"; private const string RoslynWorkerSourceFileName = "RoslynCompilerWorker.cs"; private const string RoslynWorkerAssemblyFileName = "RoslynCompilerWorker.dll"; private const string RoslynWorkerCompileResponseFileName = "RoslynCompilerWorker.rsp"; - private const string RoslynWorkerProgramTemplateRelativePath = - "Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/Templates/SharedRoslynCompilerWorkerProgram.cs.template"; - private const string SharedCompilerWorkerResultPrefixToken = "{{SHARED_COMPILER_WORKER_RESULT_PREFIX}}"; - private const string SharedCompilerWorkerEndMarkerToken = "{{SHARED_COMPILER_WORKER_END_MARKER}}"; - private const string SharedCompilerWorkerQuitCommandToken = "{{SHARED_COMPILER_WORKER_QUIT_COMMAND}}"; - private const string CompileRequestPathPrefixToken = "{{COMPILE_REQUEST_PATH_PREFIX}}"; - private const int SharedCompilerWorkerResponseTimeoutMilliseconds = 30000; private const int WorkerAssemblyBuildTimeoutMilliseconds = 30000; internal const string DotnetMultilevelLookupEnvironmentVariableName = "DOTNET_MULTILEVEL_LOOKUP"; internal const string DotnetMultilevelLookupDisabledValue = "0"; @@ -392,36 +380,18 @@ private static void SendCompileRequest(string requestFilePath) private static void SendCompileRequestCore(Process workerProcess, string requestFilePath) { - string requestCommand = CreateCompileRequestCommand(requestFilePath); + string requestCommand = SharedRoslynCompilerWorkerProtocol.CreateCompileRequestCommand(requestFilePath); workerProcess.StandardInput.WriteLine(requestCommand); workerProcess.StandardInput.Flush(); } - internal static string CreateCompileRequestCommandForTests(string requestFilePath) - { - return CreateCompileRequestCommand(requestFilePath); - } - - internal static string CreateProgramSourceForTests() - { - return CreateProgramSource(); - } - - private static string CreateCompileRequestCommand(string requestFilePath) - { - Debug.Assert(!string.IsNullOrEmpty(requestFilePath), "requestFilePath must not be empty"); - - string fullRequestFilePath = Path.GetFullPath(requestFilePath); - byte[] requestPathBytes = Encoding.UTF8.GetBytes(fullRequestFilePath); - return CompileRequestPathPrefix + Convert.ToBase64String(requestPathBytes); - } - private static WorkerAttemptResult ReadWorkerResponse( string requestFilePath, CancellationToken ct) { - string responseHeader = ReadProtocolLine( - _sharedCompilerWorkerProcess.StandardOutput, + StreamReader reader = _sharedCompilerWorkerProcess.StandardOutput; + string responseHeader = SharedRoslynCompilerWorkerProtocol.ReadProtocolLine( + reader, ct); if (string.IsNullOrEmpty(responseHeader)) { @@ -430,14 +400,14 @@ private static WorkerAttemptResult ReadWorkerResponse( new { request_file_path = requestFilePath }); } - if (!TryParseResponseHeader(responseHeader, out int exitCode)) + if (!SharedRoslynCompilerWorkerProtocol.TryParseResponseHeader(responseHeader, out int exitCode)) { return WorkerAttemptResult.RetryableFailure( - GetResponseHeaderFailureReason(responseHeader), + SharedRoslynCompilerWorkerProtocol.GetResponseHeaderFailureReason(responseHeader), new { header = responseHeader }); } - List outputLines = ReadDiagnosticLines(ct); + List outputLines = SharedRoslynCompilerWorkerProtocol.ReadDiagnosticLines(reader, ct); if (outputLines == null) { return WorkerAttemptResult.RetryableFailure( @@ -450,49 +420,6 @@ private static WorkerAttemptResult ReadWorkerResponse( return WorkerAttemptResult.Successful(compilerMessages); } - private static bool TryParseResponseHeader(string responseHeader, out int exitCode) - { - exitCode = 0; - - if (!responseHeader.StartsWith(SharedCompilerWorkerResultPrefix, StringComparison.Ordinal)) - { - return false; - } - - string statusText = responseHeader.Substring(SharedCompilerWorkerResultPrefix.Length).Trim(); - return int.TryParse(statusText, out exitCode); - } - - private static string GetResponseHeaderFailureReason(string responseHeader) - { - if (!responseHeader.StartsWith(SharedCompilerWorkerResultPrefix, StringComparison.Ordinal)) - { - return "worker_invalid_header"; - } - - return "worker_invalid_exit_code"; - } - - private static List ReadDiagnosticLines(CancellationToken ct) - { - List outputLines = new(); - while (true) - { - string outputLine = ReadProtocolLine(_sharedCompilerWorkerProcess.StandardOutput, ct); - if (outputLine == null) - { - return null; - } - - if (outputLine == SharedCompilerWorkerEndMarker) - { - return outputLines; - } - - outputLines.Add(outputLine); - } - } - private static WorkerPaths CreateWorkerPaths() { string workerDirectoryPath = GetWorkerDirectoryPath(); @@ -515,7 +442,7 @@ private static string GetWorkerDirectoryPath() private static void SynchronizeWorkerSource(WorkerPaths workerPaths) { - string workerSource = CreateProgramSource(); + string workerSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); if (File.Exists(workerPaths.SourcePath) && File.ReadAllText(workerPaths.SourcePath) == workerSource) { return; @@ -686,22 +613,6 @@ private static void WriteWorkerCompilerResponseFile( File.WriteAllLines(responseFilePath, lines); } - private static string ReadProtocolLine(StreamReader reader, CancellationToken ct) - { - Debug.Assert(reader != null, "reader must not be null"); - - Task readTask = Task.Run(() => reader.ReadLine()); - Task timeoutTask = Task.Delay(SharedCompilerWorkerResponseTimeoutMilliseconds, ct); - Task completedTask = Task.WhenAny(readTask, timeoutTask).GetAwaiter().GetResult(); - if (!ReferenceEquals(completedTask, readTask)) - { - ct.ThrowIfCancellationRequested(); - return null; - } - - return readTask.GetAwaiter().GetResult(); - } - private static bool HasLiveWorkerProcess() { return _sharedCompilerWorkerProcess != null && !_sharedCompilerWorkerProcess.HasExited; @@ -811,7 +722,8 @@ private static void ShutdownWorkerProcessLocked() { if (!workerProcess.HasExited) { - workerProcess.StandardInput.WriteLine(SharedCompilerWorkerQuitCommand); + workerProcess.StandardInput.WriteLine( + SharedRoslynCompilerWorkerProtocol.SharedCompilerWorkerQuitCommand); workerProcess.StandardInput.Flush(); workerProcess.WaitForExit(500); } @@ -949,40 +861,5 @@ private static void AddIfExists( destination.Add(referencePath); } - private static string CreateProgramSource() - { - string templatePath = GetWorkerProgramTemplatePath(); - string templateSource = File.ReadAllText(templatePath, Encoding.UTF8); - return templateSource - .Replace(SharedCompilerWorkerResultPrefixToken, SharedCompilerWorkerResultPrefix) - .Replace(SharedCompilerWorkerEndMarkerToken, SharedCompilerWorkerEndMarker) - .Replace(SharedCompilerWorkerQuitCommandToken, SharedCompilerWorkerQuitCommand) - .Replace(CompileRequestPathPrefixToken, CompileRequestPathPrefix); - } - - internal static string GetWorkerProgramTemplatePathForTests() - { - return GetWorkerProgramTemplatePath(); - } - - internal static string GetSharedCompilerWorkerResultPrefixForTests() - { - return SharedCompilerWorkerResultPrefix; - } - - internal static string GetSharedCompilerWorkerEndMarkerForTests() - { - return SharedCompilerWorkerEndMarker; - } - - internal static string GetSharedCompilerWorkerQuitCommandForTests() - { - return SharedCompilerWorkerQuitCommand; - } - - private static string GetWorkerProgramTemplatePath() - { - return Path.Combine(UnityCliLoopConstants.PackageResolvedPath, RoslynWorkerProgramTemplateRelativePath); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs new file mode 100644 index 0000000000..8379a631aa --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Debug = UnityEngine.Debug; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Encodes and decodes the shared Roslyn compiler worker protocol. + /// + internal static class SharedRoslynCompilerWorkerProtocol + { + internal const string SharedCompilerWorkerResultPrefix = "__ULOOP_RESULT__"; + internal const string SharedCompilerWorkerEndMarker = "__ULOOP_END__"; + internal const string SharedCompilerWorkerQuitCommand = "__QUIT__"; + internal const string CompileRequestPathPrefix = "path-base64:"; + private const string RoslynWorkerProgramTemplateRelativePath = + "Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/Templates/SharedRoslynCompilerWorkerProgram.cs.template"; + private const string SharedCompilerWorkerResultPrefixToken = "{{SHARED_COMPILER_WORKER_RESULT_PREFIX}}"; + private const string SharedCompilerWorkerEndMarkerToken = "{{SHARED_COMPILER_WORKER_END_MARKER}}"; + private const string SharedCompilerWorkerQuitCommandToken = "{{SHARED_COMPILER_WORKER_QUIT_COMMAND}}"; + private const string CompileRequestPathPrefixToken = "{{COMPILE_REQUEST_PATH_PREFIX}}"; + private const int SharedCompilerWorkerResponseTimeoutMilliseconds = 30000; + + internal static string CreateCompileRequestCommand(string requestFilePath) + { + Debug.Assert(!string.IsNullOrEmpty(requestFilePath), "requestFilePath must not be empty"); + + string fullRequestFilePath = Path.GetFullPath(requestFilePath); + byte[] requestPathBytes = Encoding.UTF8.GetBytes(fullRequestFilePath); + return CompileRequestPathPrefix + Convert.ToBase64String(requestPathBytes); + } + + internal static bool TryParseResponseHeader(string responseHeader, out int exitCode) + { + exitCode = 0; + + if (!responseHeader.StartsWith(SharedCompilerWorkerResultPrefix, StringComparison.Ordinal)) + { + return false; + } + + string statusText = responseHeader.Substring(SharedCompilerWorkerResultPrefix.Length).Trim(); + return int.TryParse(statusText, out exitCode); + } + + internal static string GetResponseHeaderFailureReason(string responseHeader) + { + if (!responseHeader.StartsWith(SharedCompilerWorkerResultPrefix, StringComparison.Ordinal)) + { + return "worker_invalid_header"; + } + + return "worker_invalid_exit_code"; + } + + internal static List ReadDiagnosticLines(StreamReader reader, CancellationToken ct) + { + List outputLines = new(); + while (true) + { + string outputLine = ReadProtocolLine(reader, ct); + if (outputLine == null) + { + return null; + } + + if (outputLine == SharedCompilerWorkerEndMarker) + { + return outputLines; + } + + outputLines.Add(outputLine); + } + } + + internal static string ReadProtocolLine(StreamReader reader, CancellationToken ct) + { + Debug.Assert(reader != null, "reader must not be null"); + + Task readTask = Task.Run(() => reader.ReadLine()); + Task timeoutTask = Task.Delay(SharedCompilerWorkerResponseTimeoutMilliseconds, ct); + Task completedTask = Task.WhenAny(readTask, timeoutTask).GetAwaiter().GetResult(); + if (!ReferenceEquals(completedTask, readTask)) + { + ct.ThrowIfCancellationRequested(); + return null; + } + + return readTask.GetAwaiter().GetResult(); + } + + internal static string CreateProgramSource() + { + string templatePath = GetWorkerProgramTemplatePath(); + string templateSource = File.ReadAllText(templatePath, Encoding.UTF8); + return templateSource + .Replace(SharedCompilerWorkerResultPrefixToken, SharedCompilerWorkerResultPrefix) + .Replace(SharedCompilerWorkerEndMarkerToken, SharedCompilerWorkerEndMarker) + .Replace(SharedCompilerWorkerQuitCommandToken, SharedCompilerWorkerQuitCommand) + .Replace(CompileRequestPathPrefixToken, CompileRequestPathPrefix); + } + + internal static string GetWorkerProgramTemplatePath() + { + return Path.Combine(UnityCliLoopConstants.PackageResolvedPath, RoslynWorkerProgramTemplateRelativePath); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs.meta new file mode 100644 index 0000000000..2aeb126f2e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerProtocol.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1207fcd146325443ea468ea8f98e7f63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: