diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs index e26911308a..76477e6027 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs @@ -293,6 +293,70 @@ public async Task CompileAsync_WhenSharedWorkerBuildFails_ShouldFallbackToOneSho } } + /// + /// Verifies a lifecycle transition during worker startup silently falls back to one-shot Roslyn. + /// + [Test] + public async Task CompileAsync_WhenWorkerLifecycleClosesDuringStartup_ShouldFallbackWithoutErrorLogs() + { + ExternalCompilerPaths externalCompilerPaths = ExternalCompilerPathResolver.Resolve(); + Assert.That(externalCompilerPaths, Is.Not.Null, "Unity external compiler layout should be available."); + + string sourcePath = Path.Combine(_tempDirectoryPath, "LifecycleFallbackSmoke.cs"); + string dllPath = Path.Combine(_tempDirectoryPath, "LifecycleFallbackSmoke.dll"); + File.WriteAllText( + sourcePath, + "public static class LifecycleFallbackSmoke { public static int Execute() { return 11; } }"); + List references = new DynamicReferenceSetBuilderService().BuildReferenceSet( + new List(), + null, + externalCompilerPaths); + int buildCount = 0; + bool buildStarted = false; + bool buildFinished = false; + + DynamicCompilationHealthMonitor.ResetForTests(); + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + LogAssert.NoUnexpectedReceived(); + + Func previousWorkerCompiler = + SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( + (ExternalCompilerPaths paths, string workerSourcePath, string workerAssemblyPath, string workerCompileResponseFilePath) => + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + return Array.Empty(); + }); + + try + { + using CancellationTokenSource compileCancellationTokenSource = new CancellationTokenSource(); + compileCancellationTokenSource.CancelAfter(FallbackCompileTimeoutMilliseconds); + DynamicCompilationBackendResult result = await RoslynCompilerBackend.CompileAsync( + sourcePath, + dllPath, + references, + externalCompilerPaths, + new RoslynCompilerOptions(Array.Empty(), false), + compileCancellationTokenSource.Token, + () => buildStarted = true, + () => buildFinished = true, + () => buildCount++); + + Assert.That(result, Is.Not.Null); + Assert.That(result.BackendKind, Is.EqualTo(DynamicCompilationBackendKind.OneShotRoslyn)); + Assert.That(File.Exists(dllPath), Is.True); + Assert.That(buildCount, Is.EqualTo(1)); + Assert.That(buildStarted, Is.True); + Assert.That(buildFinished, Is.True); + LogAssert.NoUnexpectedReceived(); + } + finally + { + SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests(previousWorkerCompiler); + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + } + [Test] public void ReportInfrastructureFallback_WhenCompilerPathLayoutIsKnown_ShouldIncludeLayoutKind() { diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index b13609d5a5..232dd9588e 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading.Tasks; using NUnit.Framework; +using UnityEngine.TestTools; using UnityEditor.Compilation; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -17,6 +18,35 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests [TestFixture] public class SharedRoslynCompilerWorkerHostTests { + /// + /// Verifies lifecycle closure is returned as a non-error compile outcome. + /// + [Test] + public void SharedWorkerCompileOutcome_WhenLifecycleCloses_ShouldCarryLifecycleClosedReason() + { + SharedWorkerCompileOutcome outcome = SharedWorkerCompileOutcome.Failed( + SharedWorkerFailureReasons.LifecycleClosed, + new { reason = "lifecycle_generation_advanced" }); + + Assert.That(outcome.Succeeded, Is.False); + Assert.That(outcome.FailureReason, Is.EqualTo(SharedWorkerFailureReasons.LifecycleClosed)); + Assert.That(outcome.IsLifecycleClosed, Is.True); + } + + /// + /// Verifies non-lifecycle worker failures retain their failure reason for error reporting. + /// + [Test] + public void SharedWorkerCompileOutcome_WhenWorkerStartFails_ShouldCarryFailureReason() + { + SharedWorkerCompileOutcome outcome = SharedWorkerCompileOutcome.Failed( + "worker_start_failed", + new { reason = "process_start_failed" }); + + Assert.That(outcome.Succeeded, Is.False); + Assert.That(outcome.FailureReason, Is.EqualTo("worker_start_failed")); + } + [Test] public void ConfigureWorkerDotnetRuntimeEnvironment_WhenCalled_ShouldDisableMultilevelLookup() { diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Compilation/DynamicCompilationHealthMonitor.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Compilation/DynamicCompilationHealthMonitor.cs index d9ee44db3e..dc0d5979af 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Compilation/DynamicCompilationHealthMonitor.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Compilation/DynamicCompilationHealthMonitor.cs @@ -63,6 +63,15 @@ public static void ReportSharedWorkerFailure(string reason, object context = nul "Investigate worker startup, request handling, and Unity-version-specific runtime assumptions."); } + public static void ReportSharedWorkerLifecycleClosed(object context = null) + { + LogInfoOnce( + SharedWorkerFailureReasons.LifecycleClosed, + "dynamic_code_shared_worker_lifecycle_closed", + "execute-dynamic-code shared Roslyn worker closed during an expected Unity lifecycle transition", + context ?? new { reason = SharedWorkerFailureReasons.LifecycleClosed }); + } + public static void ReportOneShotCompilerStartFailure(object context) { LogErrorOnce( @@ -101,6 +110,25 @@ private static void LogErrorOnce( Debug.LogError(FormatConsoleErrorMessage(operation, message, context)); } + private static void LogInfoOnce( + string issueKey, + string operation, + string message, + object context) + { + string effectiveIssueKey = CreateEffectiveIssueKey(issueKey); + + lock (ReportedIssueLock) + { + if (!ReportedIssues.Add(effectiveIssueKey)) + { + return; + } + } + + VibeLogger.LogInfo(operation, message, context); + } + private static string CreateEffectiveIssueKey(string issueKey) { Debug.Assert(!string.IsNullOrEmpty(issueKey), "issueKey must not be empty"); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/RoslynCompilerBackend.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/RoslynCompilerBackend.cs index 050e331f09..56a32305bd 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/RoslynCompilerBackend.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/RoslynCompilerBackend.cs @@ -71,29 +71,33 @@ public static async Task CompileAsync( defineSymbols, allowUnsafeCode); - CompilerMessage[] workerMessages = await SharedRoslynCompilerWorkerHost.TryCompileAsync( + SharedWorkerCompileOutcome workerOutcome = await SharedRoslynCompilerWorkerHost.TryCompileAsync( workerRequestFilePath, externalCompilerPaths, ct, markBuildStarted, markBuildFinished, incrementBuildCount).ConfigureAwait(false); - if (workerMessages != null) + UnityEngine.Debug.Assert(workerOutcome != null, "Shared worker compile outcome must not be null"); + if (workerOutcome.Succeeded) { return new DynamicCompilationBackendResult( - workerMessages, + workerOutcome.Messages, DynamicCompilationBackendKind.SharedRoslynWorker); } - DynamicCompilationHealthMonitor.ReportSharedWorkerFallback( - "worker_unavailable", - new - { - platform = UnityEngine.Application.platform.ToString(), - dotnet_host_path = externalCompilerPaths.DotnetHostPath, - compiler_dll_path = externalCompilerPaths.CompilerDllPath, - layout_kind = externalCompilerPaths.LayoutKind.ToString() - }); + if (!workerOutcome.IsLifecycleClosed) + { + DynamicCompilationHealthMonitor.ReportSharedWorkerFallback( + "worker_unavailable", + new + { + platform = UnityEngine.Application.platform.ToString(), + dotnet_host_path = externalCompilerPaths.DotnetHostPath, + compiler_dll_path = externalCompilerPaths.CompilerDllPath, + layout_kind = externalCompilerPaths.LayoutKind.ToString() + }); + } ct.ThrowIfCancellationRequested(); OneShotCompileResult oneShotResult = await CompileWithOneShotAsync( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index 757da064e1..79a9fd5823 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -28,7 +28,7 @@ internal static void RegisterLifecycleForEditorStartup() EditorApplication.quitting += ShutdownForQuit; } - public static Task TryCompileAsync( + public static Task TryCompileAsync( string requestFilePath, ExternalCompilerPaths externalCompilerPaths, CancellationToken ct, @@ -47,7 +47,7 @@ public static Task TryCompileAsync( ct); } - private static async Task TryCompileWithRetriesAsync( + private static async Task TryCompileWithRetriesAsync( string requestFilePath, ExternalCompilerPaths externalCompilerPaths, CancellationToken ct, @@ -71,7 +71,7 @@ private static async Task TryCompileWithRetriesAsync( if (attemptResult.Succeeded) { - return attemptResult.Messages; + return SharedWorkerCompileOutcome.SucceededWith(attemptResult.Messages); } ServiceValue.ExecuteWithStateLock(ServiceValue.ShutdownProcessLocked); @@ -81,13 +81,24 @@ private static async Task TryCompileWithRetriesAsync( continue; } - DynamicCompilationHealthMonitor.ReportSharedWorkerFailure( - attemptResult.FailureReason, - AppendAttempt(attemptResult.FailureContext, attempt)); - return null; + object failureContext = AppendAttempt(attemptResult.FailureContext, attempt); + if (attemptResult.FailureReason == SharedWorkerFailureReasons.LifecycleClosed) + { + DynamicCompilationHealthMonitor.ReportSharedWorkerLifecycleClosed(failureContext); + } + else + { + DynamicCompilationHealthMonitor.ReportSharedWorkerFailure( + attemptResult.FailureReason, + failureContext); + } + + return SharedWorkerCompileOutcome.Failed(attemptResult.FailureReason, failureContext); } - return null; + return SharedWorkerCompileOutcome.Failed( + "worker_unknown_failure", + new { reason = "retry_loop_exhausted" }); } private static async Task TryCompileOnceAsync( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs index e6eadfe316..a4f2eba74d 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs @@ -2,6 +2,47 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { + internal static class SharedWorkerFailureReasons + { + internal const string LifecycleClosed = "shared_worker_lifecycle_closed"; + } + + /// + /// Carries shared worker compilation messages and the reason for a failed compilation. + /// + internal sealed class SharedWorkerCompileOutcome + { + public CompilerMessage[] Messages { get; } + + public string FailureReason { get; } + + public object FailureContext { get; } + + private SharedWorkerCompileOutcome( + CompilerMessage[] messages, + string failureReason, + object failureContext) + { + Messages = messages; + FailureReason = failureReason; + FailureContext = failureContext; + } + + public bool Succeeded => Messages != null; + + public bool IsLifecycleClosed => FailureReason == SharedWorkerFailureReasons.LifecycleClosed; + + public static SharedWorkerCompileOutcome SucceededWith(CompilerMessage[] messages) + { + return new SharedWorkerCompileOutcome(messages, null, null); + } + + public static SharedWorkerCompileOutcome Failed(string failureReason, object failureContext) + { + return new SharedWorkerCompileOutcome(null, failureReason, failureContext); + } + } + /// /// Carries the result data produced by Worker Attempt behavior. /// @@ -85,7 +126,7 @@ public static WorkerStartupResult ClosedLifecycleFailure() return new WorkerStartupResult( false, false, - "shared_worker_lifecycle_closed", + SharedWorkerFailureReasons.LifecycleClosed, new { reason = "lifecycle_generation_advanced" }); } }