diff --git a/Assets/Editor/CompileCheckWindow/CompileEditorWindow.cs b/Assets/Editor/CompileCheckWindow/CompileEditorWindow.cs index e453b7435..25ad4b4fe 100644 --- a/Assets/Editor/CompileCheckWindow/CompileEditorWindow.cs +++ b/Assets/Editor/CompileCheckWindow/CompileEditorWindow.cs @@ -1,7 +1,9 @@ using UnityEngine; using UnityEditor; using UnityEditor.Compilation; +using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -18,6 +20,7 @@ public class CompileEditorWindow : EditorWindow private CompileLogDisplay _logDisplay; private Vector2 _scrollPosition; private bool _forceRecompile = false; + private bool _isPostCompileReadinessRunning = false; // Note: Compile window data is now managed via McpSessionManager @@ -94,9 +97,8 @@ private void OnGUI() _forceRecompile = EditorGUILayout.Toggle("Force Recompile", _forceRecompile); GUILayout.Space(5); - EditorGUI.BeginDisabledGroup(_compileController.IsCompiling); - string buttonText = _compileController.IsCompiling ? "Compiling..." : - (_forceRecompile ? "Run Force Compile" : "Run Compile"); + EditorGUI.BeginDisabledGroup(IsCompileActionBusy()); + string buttonText = CreateCompileButtonText(); if (GUILayout.Button(buttonText, GUILayout.Height(30))) { // Execute compilation using async/await @@ -125,7 +127,21 @@ private void OnGUI() private async Task ExecuteCompileAsync() { - CompileResult result = await _compileController.TryCompileAsync(_forceRecompile); + CompileResult result = await _compileController.TryCompileAsync(_forceRecompile, CancellationToken.None); + if (ShouldRunExecuteDynamicCodeReadinessAfterCompile(result)) + { + _isPostCompileReadinessRunning = true; + Repaint(); + try + { + await RunExecuteDynamicCodeReadinessProbesAfterCompileAsync(CancellationToken.None); + } + finally + { + _isPostCompileReadinessRunning = false; + Repaint(); + } + } // Output result to log (for debugging) string message = string.IsNullOrEmpty(result.Message) ? "(none)" : result.Message; @@ -143,6 +159,50 @@ private async Task ExecuteCompileAsync() UnityEngine.Debug.Log(logMessage); } + private static bool ShouldRunExecuteDynamicCodeReadinessAfterCompile(CompileResult result) + { + return result.Success == true; + } + + private static async Task RunExecuteDynamicCodeReadinessProbesAfterCompileAsync(CancellationToken ct) + { + // Why: the editor Compile Tool bypasses the native CLI's post-compile readiness wait, + // so it must run the same hidden probe path before handing control back to the user. + foreach (string code in ExecuteDynamicCodeReadinessProbe.CreateReturnStringProbeCodes()) + { + ct.ThrowIfCancellationRequested(); + JObject parameters = new() + { + ["Code"] = code, + ["CompileOnly"] = false, + ["YieldToForegroundRequests"] = false + }; + await UnityCliLoopToolRegistrar.GetRegistry().ExecuteToolAsync( + "execute-dynamic-code", + parameters); + } + } + + private bool IsCompileActionBusy() + { + return _compileController.IsCompiling || _isPostCompileReadinessRunning; + } + + private string CreateCompileButtonText() + { + if (_compileController.IsCompiling) + { + return "Compiling..."; + } + + if (_isPostCompileReadinessRunning) + { + return "Preparing..."; + } + + return _forceRecompile ? "Run Force Compile" : "Run Compile"; + } + private void OnCompileCompleted(CompileResult result) { _logDisplay.AppendCompletionMessage(result); @@ -180,4 +240,4 @@ private void ClearLog() Repaint(); } } -} \ No newline at end of file +} diff --git a/Assets/Editor/uLoopMCP.Dev.asmdef b/Assets/Editor/uLoopMCP.Dev.asmdef index e24817449..bb89a6c39 100644 --- a/Assets/Editor/uLoopMCP.Dev.asmdef +++ b/Assets/Editor/uLoopMCP.Dev.asmdef @@ -13,6 +13,7 @@ "UnityCLILoop.FirstPartyTools.Common.MouseUi.Editor", "UnityCLILoop.FirstPartyTools.Common.Overlay.Editor", "UnityCLILoop.FirstPartyTools.Compile.Editor", + "UnityCLILoop.FirstPartyTools.ExecuteDynamicCode.Editor", "UnityCLILoop.FirstPartyTools.FindGameObjects.Editor", "UnityCLILoop.FirstPartyTools.GetLogs.Editor", "UnityCLILoop.FirstPartyTools.RunTests.Editor", diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs deleted file mode 100644 index 4d4f9bbc2..000000000 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Application; -using io.github.hatayama.UnityCliLoop.FirstPartyTools; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests -{ - /// - /// Test fixture that verifies startup prewarm keeps execute-dynamic-code's first visible request warm. - /// - [TestFixture] - public class DynamicCodeStartupPrewarmerTests - { - [SetUp] - public void SetUp() - { - DynamicCodeForegroundWarmupState.Reset(); - } - - [TearDown] - public void TearDown() - { - DynamicCodeForegroundWarmupState.Reset(); - } - - [Test] - public async Task RequestAsync_WhenStartupPrewarmSucceeds_ShouldSkipFirstForegroundWarmup() - { - // Tests that successful startup prewarm prevents the first user request from paying hidden warmup cost. - FakeDynamicCodeExecutionRuntime runtime = new( - new ExecutionResult - { - Success = true, - Result = "warm" - }, - new ExecutionResult - { - Success = true, - Result = "user" - }); - DynamicCodeStartupPrewarmer prewarmer = new(runtime, 0); - ExecuteDynamicCodeUseCase useCase = new(runtime); - - DynamicCodeSecurityLevel previous = ULoopSettings.GetDynamicCodeSecurityLevel(); - ULoopSettings.SetDynamicCodeSecurityLevel(DynamicCodeSecurityLevel.Restricted); - - try - { - await prewarmer.RequestAsync(CancellationToken.None); - ExecuteDynamicCodeResponse response = await useCase.ExecuteAsync( - new ExecuteDynamicCodeSchema - { - Code = "return 1;", - CompileOnly = false - }, - CancellationToken.None); - - Assert.That(response.Success, Is.True); - Assert.That(runtime.TryExecuteRequests, Has.Count.EqualTo(1)); - Assert.That(runtime.TryExecuteRequests[0].YieldToForegroundRequests, Is.True); - Assert.That(runtime.Requests, Has.Count.EqualTo(1)); - Assert.That(runtime.Requests[0].Code, Is.EqualTo("return 1;")); - } - finally - { - ULoopSettings.SetDynamicCodeSecurityLevel(previous); - } - } - - [Test] - public async Task RequestAsync_WhenCalledTwice_ShouldRunOnlyOnePrewarmRequest() - { - // Tests that repeated startup notifications do not compile duplicate warmup snippets. - FakeDynamicCodeExecutionRuntime runtime = new( - new ExecutionResult - { - Success = true, - Result = "warm" - }); - DynamicCodeStartupPrewarmer prewarmer = new(runtime, 0); - - await prewarmer.RequestAsync(CancellationToken.None); - await prewarmer.RequestAsync(CancellationToken.None); - - Assert.That(runtime.TryExecuteRequests, Has.Count.EqualTo(1)); - } - - [Test] - public async Task RequestAsync_WhenIdleExecutionDoesNotEnter_ShouldAllowRetry() - { - // Tests that transient busy startup prewarm attempts do not permanently block later startup prewarm. - FakeDynamicCodeExecutionRuntime runtime = new( - new ExecutionResult - { - Success = true, - Result = "warm" - }); - runtime.EnqueueIdleEntryResults(false, true); - DynamicCodeStartupPrewarmer prewarmer = new(runtime, 0); - - await prewarmer.RequestAsync(CancellationToken.None); - await prewarmer.RequestAsync(CancellationToken.None); - - Assert.That(runtime.TryExecuteRequests, Has.Count.EqualTo(2)); - } - - [Test] - public async Task RequestAsync_WhenIdleExecutionFails_ShouldAllowRetry() - { - // Tests that failed startup prewarm attempts do not make startup prewarm one-shot. - FakeDynamicCodeExecutionRuntime runtime = new( - new ExecutionResult - { - Success = false - }, - new ExecutionResult - { - Success = true, - Result = "warm" - }); - DynamicCodeStartupPrewarmer prewarmer = new(runtime, 0); - - await prewarmer.RequestAsync(CancellationToken.None); - await prewarmer.RequestAsync(CancellationToken.None); - - Assert.That(runtime.TryExecuteRequests, Has.Count.EqualTo(2)); - } - - [Test] - public async Task RequestAsync_WhenForegroundWarmupAlreadyStarted_ShouldNotEnterRuntime() - { - // Tests that startup prewarm yields when the visible foreground warmup path already owns the warmup state. - bool started = DynamicCodeForegroundWarmupState.TryBegin(); - Assert.That(started, Is.True); - FakeDynamicCodeExecutionRuntime runtime = new(); - DynamicCodeStartupPrewarmer prewarmer = new(runtime, 0); - - await prewarmer.RequestAsync(CancellationToken.None); - - Assert.That(runtime.TryExecuteRequests, Is.Empty); - } - - /// - /// Test support runtime that records requests and returns queued results. - /// - private sealed class FakeDynamicCodeExecutionRuntime : IDynamicCodeExecutionRuntime - { - private readonly Queue _results; - private readonly Queue _idleEntryResults = new(); - - internal FakeDynamicCodeExecutionRuntime(params ExecutionResult[] results) - { - _results = new Queue(results); - } - - internal List Requests { get; } = new List(); - - internal List TryExecuteRequests { get; } = new List(); - - internal void EnqueueIdleEntryResults(params bool[] enteredResults) - { - for (int index = 0; index < enteredResults.Length; index++) - { - _idleEntryResults.Enqueue(enteredResults[index]); - } - } - - public Task ExecuteAsync( - DynamicCodeExecutionRequest request, - CancellationToken ct = default) - { - Requests.Add(CloneRequest(request)); - return Task.FromResult(_results.Dequeue()); - } - - public Task<(bool Entered, ExecutionResult Result)> TryExecuteIfIdleAsync( - DynamicCodeExecutionRequest request, - CancellationToken ct = default) - { - TryExecuteRequests.Add(CloneRequest(request)); - bool entered = _idleEntryResults.Count == 0 || _idleEntryResults.Dequeue(); - ExecutionResult result = entered && _results.Count > 0 - ? _results.Dequeue() - : new ExecutionResult { Success = false }; - return Task.FromResult<(bool, ExecutionResult)>((entered, result)); - } - - private static DynamicCodeExecutionRequest CloneRequest(DynamicCodeExecutionRequest request) - { - return new DynamicCodeExecutionRequest - { - Code = request.Code, - ClassName = request.ClassName, - Parameters = request.Parameters, - CompileOnly = request.CompileOnly, - SecurityLevel = request.SecurityLevel, - YieldToForegroundRequests = request.YieldToForegroundRequests - }; - } - } - } -} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs index 99f93eaca..bb4ceabf9 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; using io.github.hatayama.UnityCliLoop.Application; @@ -23,6 +25,24 @@ public void SetUp() DynamicCodeForegroundWarmupState.Reset(); } + [Test] + public void ExecuteDynamicCodeResponse_WhenSerializedWithoutTimings_DoesNotExposeTimingControlFields() + { + // Tests that timing control remains an internal interface concern by default. + ExecuteDynamicCodeResponse response = new() + { + Success = true, + Result = "ok", + EmitTimingsInJsonResponse = false + }; + + JObject serializedResponse = JObject.Parse(JsonConvert.SerializeObject(response)); + + Assert.That(serializedResponse["Timings"], Is.Null); + Assert.That(serializedResponse["EmitTimingsInJsonResponse"], Is.Null); + Assert.That(serializedResponse["EmitsTimingsInJsonResponse"], Is.Null); + } + [Test] public async Task ExecuteAsync_WhenInitialCompilationLooksLikeMissingReturn_ShouldRetryOnce() { @@ -210,6 +230,54 @@ public async Task ExecuteAsync_WhenYieldingRequestNeedsMissingReturnRetry_Should } } + [Test] + public async Task ExecuteAsync_WhenYieldingStartupProbeSucceeds_ShouldSkipNextForegroundWarmup() + { + // Tests that tool readiness warmup prevents the next user request from paying hidden warmup cost. + FakeDynamicCodeExecutionRuntime runtime = new( + new ExecutionResult + { + Success = true, + Result = "probe" + }, + new ExecutionResult + { + Success = true, + Result = "user" + }); + ExecuteDynamicCodeUseCase useCase = new(runtime); + + DynamicCodeSecurityLevel previous = ULoopSettings.GetDynamicCodeSecurityLevel(); + ULoopSettings.SetDynamicCodeSecurityLevel(DynamicCodeSecurityLevel.Restricted); + + try + { + ExecuteDynamicCodeResponse probeResponse = await useCase.ExecuteAsync( + new ExecuteDynamicCodeSchema + { + Code = "return \"probe\";", + YieldToForegroundRequests = true + }, + CancellationToken.None); + ExecuteDynamicCodeResponse userResponse = await useCase.ExecuteAsync( + new ExecuteDynamicCodeSchema + { + Code = "return \"user\";" + }, + CancellationToken.None); + + Assert.That(probeResponse.Success, Is.True); + Assert.That(userResponse.Success, Is.True); + Assert.That(runtime.TryExecuteRequests, Has.Count.EqualTo(1)); + Assert.That(runtime.Requests, Has.Count.EqualTo(1)); + Assert.That(runtime.Requests[0].Code, Is.EqualTo("return \"user\";")); + } + finally + { + ULoopSettings.SetDynamicCodeSecurityLevel(previous); + } + } + [Test] public async Task ExecuteAsync_WhenInitialExecutionSucceeds_ShouldNotRetry() { @@ -254,6 +322,11 @@ public async Task ExecuteAsync_WhenFirstForegroundExecutionRuns_ShouldWarmHidden Result = "warm" }, new ExecutionResult + { + Success = true, + Result = "warm" + }, + new ExecutionResult { Success = true, Result = "ok" @@ -273,9 +346,14 @@ public async Task ExecuteAsync_WhenFirstForegroundExecutionRuns_ShouldWarmHidden CancellationToken.None); Assert.That(response.Success, Is.True); - Assert.That(runtime.Requests, Has.Count.EqualTo(2)); - Assert.That(runtime.Requests[0].Code, Does.Contain("Unity CLI Loop dynamic code prewarm")); - Assert.That(runtime.Requests[1].Code, Is.EqualTo("return 1;")); + Assert.That(runtime.Requests, Has.Count.EqualTo(3)); + AssertPrewarmCodeMatchesLiteralReturnShape( + runtime.Requests[0].Code, + "return \"user value\";"); + AssertPrewarmCodeMatchesLiteralReturnShape( + runtime.Requests[1].Code, + "return\n \"user value\";"); + Assert.That(runtime.Requests[2].Code, Is.EqualTo("return 1;")); } finally { @@ -293,6 +371,11 @@ public async Task ExecuteAsync_WhenForegroundWarmupAlreadyCompleted_ShouldNotRep Result = "warm" }, new ExecutionResult + { + Success = true, + Result = "warm" + }, + new ExecutionResult { Success = true, Result = "first" @@ -324,8 +407,8 @@ public async Task ExecuteAsync_WhenForegroundWarmupAlreadyCompleted_ShouldNotRep Assert.That(firstResponse.Success, Is.True); Assert.That(secondResponse.Success, Is.True); - Assert.That(runtime.Requests, Has.Count.EqualTo(3)); - Assert.That(runtime.Requests[2].Code, Is.EqualTo("return 2;")); + Assert.That(runtime.Requests, Has.Count.EqualTo(4)); + Assert.That(runtime.Requests[3].Code, Is.EqualTo("return 2;")); } finally { @@ -375,7 +458,9 @@ public async Task ExecuteAsync_WhenWarmupFailsButForegroundExecutionSucceeds_Sho Assert.That(firstResponse.Success, Is.True); Assert.That(secondResponse.Success, Is.True); Assert.That(runtime.Requests, Has.Count.EqualTo(3)); - Assert.That(runtime.Requests[0].Code, Does.Contain("Unity CLI Loop dynamic code prewarm")); + AssertPrewarmCodeMatchesLiteralReturnShape( + runtime.Requests[0].Code, + "return \"user value\";"); Assert.That(runtime.Requests[1].Code, Is.EqualTo("return 1;")); Assert.That(runtime.Requests[2].Code, Is.EqualTo("return 2;")); } @@ -777,6 +862,22 @@ private static void MarkForegroundWarmupCompleted() DynamicCodeForegroundWarmupState.MarkCompleted(); } + private static void AssertPrewarmCodeMatchesLiteralReturnShape( + string prewarmCode, + string userCode) + { + PreparedDynamicCode prewarm = DynamicCodeSourcePreparer.Prepare( + prewarmCode, + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + PreparedDynamicCode userReturn = DynamicCodeSourcePreparer.Prepare( + userCode, + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(prewarm.PreparedSource, Is.EqualTo(userReturn.PreparedSource)); + } + /// /// Test support type used by editor and play mode fixtures. /// diff --git a/Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs b/Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs deleted file mode 100644 index a537e0819..000000000 --- a/Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Infrastructure; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Test fixture that verifies JSON RPC Request Identity Validator behavior. - /// - [TestFixture] - public class JsonRpcRequestIdentityValidatorTests - { - [Test] - public void Validate_WhenMetadataIsNull_ShouldSucceed() - { - Assert.DoesNotThrow(() => - JsonRpcRequestIdentityValidator.Validate(null, "/project")); - } - - [Test] - public void Validate_WhenExpectedProjectRootIsMissing_ShouldThrow() - { - JsonRpcRequestUloopMetadata metadata = new() - { - ExpectedProjectRoot = string.Empty - }; - - UnityCliLoopToolParameterValidationException exception = Assert.Throws(() => - JsonRpcRequestIdentityValidator.Validate(metadata, "/project")); - - Assert.That(exception.Message, Does.Contain("expectedProjectRoot is required")); - } - - [Test] - public void Validate_WhenActualProjectRootIsUnavailable_ShouldThrow() - { - JsonRpcRequestUloopMetadata metadata = new() - { - ExpectedProjectRoot = "/project" - }; - - UnityCliLoopToolParameterValidationException exception = Assert.Throws(() => - JsonRpcRequestIdentityValidator.Validate(metadata, string.Empty)); - - Assert.That(exception.Message, Does.Contain("Fast project validation is unavailable")); - } - - [Test] - public void Validate_WhenProjectRootDiffers_ShouldThrow() - { - JsonRpcRequestUloopMetadata metadata = new() - { - ExpectedProjectRoot = "/project-a" - }; - - UnityCliLoopToolParameterValidationException exception = Assert.Throws(() => - JsonRpcRequestIdentityValidator.Validate(metadata, "/project-b")); - - Assert.That(exception.Message, Does.Contain("different project")); - } - - [Test] - public void Validate_WhenProjectRootMatchesCurrentProject_ShouldSucceed() - { - JsonRpcRequestUloopMetadata metadata = new() - { - ExpectedProjectRoot = "/project" - }; - - Assert.DoesNotThrow(() => - JsonRpcRequestIdentityValidator.Validate(metadata, "/project")); - } - } -} diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 14a4fbb88..e6585c273 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -39,6 +39,7 @@ public sealed class OnionAssemblyDependencyTests private const string SimulateMouseInputAssemblyName = "UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor"; private const string SimulateMouseUiAssemblyName = "UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor"; private const string InfrastructureAssemblyName = "UnityCLILoop.Infrastructure"; + private const string InternalApiBridgeAssemblyName = "Unity.InternalAPIEditorBridge.024"; private const string MetadataValidationAssemblyName = "UnityCLILoop.FirstPartyTools.ExecuteDynamicCode.MetadataValidation.Editor"; private const string PresentationAssemblyName = "UnityCLILoop.Presentation"; @@ -65,19 +66,10 @@ public void PlatformResultTypes_WhenLoaded_CompileUnderDomainAssembly() Assert.That(serviceResultAssemblyName, Is.EqualTo(DomainAssemblyName)); } - [Test] - public void ProjectRootIdentityValidator_WhenLoaded_CompilesUnderDomainAssembly() - { - // Tests that project identity safety policy lives in the domain layer. - string validatorAssemblyName = typeof(ProjectRootIdentityValidator).Assembly.GetName().Name; - - Assert.That(validatorAssemblyName, Is.EqualTo(DomainAssemblyName)); - } - [Test] public void ProjectRootCanonicalizer_WhenLoaded_CompilesUnderDomainAssembly() { - // Tests that project-root identity normalization stays with the domain policy. + // Tests that project-root endpoint normalization stays with the domain policy. string canonicalizerAssemblyName = typeof(ProjectRootCanonicalizer).Assembly.GetName().Name; Assert.That(canonicalizerAssemblyName, Is.EqualTo(DomainAssemblyName)); @@ -437,6 +429,7 @@ public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotRefere Assert.That(references, Is.EquivalentTo(new[] { + InternalApiBridgeAssemblyName, ApplicationAssemblyName, DomainAssemblyName, ToolContractsAssemblyName @@ -496,6 +489,7 @@ public void ApplicationSources_WhenLoaded_DoNotReferenceProjectIpcInfrastructure "UnityCliLoopBridgeServer", "BridgeTransportEndpoint", "BridgeTransportListener", + "ProjectIpcWarmupClient", "MessageReassembler", "DynamicBufferManager", "FrameParser" @@ -614,6 +608,8 @@ public void ProjectIpcInfrastructure_WhenLoaded_CompilesUnderInfrastructureAssem "io.github.hatayama.UnityCliLoop.Infrastructure.BridgeTransportEndpoint, " + InfrastructureAssemblyName); Type listenerFactoryType = Type.GetType( "io.github.hatayama.UnityCliLoop.Infrastructure.BridgeTransportListenerFactory, " + InfrastructureAssemblyName); + Type warmupClientType = Type.GetType( + "io.github.hatayama.UnityCliLoop.Infrastructure.ProjectIpcWarmupClient, " + InfrastructureAssemblyName); string bridgeAssemblyName = typeof(UnityCliLoopBridgeServer).Assembly.GetName().Name; string reassemblerAssemblyName = typeof(MessageReassembler).Assembly.GetName().Name; string bufferAssemblyName = typeof(DynamicBufferManager).Assembly.GetName().Name; @@ -621,9 +617,11 @@ public void ProjectIpcInfrastructure_WhenLoaded_CompilesUnderInfrastructureAssem Assert.That(endpointType, Is.Not.Null); Assert.That(listenerFactoryType, Is.Not.Null); + Assert.That(warmupClientType, Is.Not.Null); Assert.That(bridgeAssemblyName, Is.EqualTo(InfrastructureAssemblyName)); Assert.That(endpointType.Assembly.GetName().Name, Is.EqualTo(InfrastructureAssemblyName)); Assert.That(listenerFactoryType.Assembly.GetName().Name, Is.EqualTo(InfrastructureAssemblyName)); + Assert.That(warmupClientType.Assembly.GetName().Name, Is.EqualTo(InfrastructureAssemblyName)); Assert.That(reassemblerAssemblyName, Is.EqualTo(InfrastructureAssemblyName)); Assert.That(bufferAssemblyName, Is.EqualTo(InfrastructureAssemblyName)); Assert.That(parserAssemblyName, Is.EqualTo(InfrastructureAssemblyName)); diff --git a/Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs b/Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs new file mode 100644 index 000000000..a086a029e --- /dev/null +++ b/Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + [TestFixture] + public sealed class ProjectIpcWarmupClientTests + { + [Test] + public void ParseContentLength_WhenPayloadIsWithinLimit_ReturnsLength() + { + // Tests that warmup response framing accepts payloads within the shared IPC size limit. + ProjectIpcWarmupClient client = new(); + List headerBytes = HeaderBytes("Content-Length: 12\r\n\r\n"); + + int contentLength = client.ParseContentLength(headerBytes); + + Assert.That(contentLength, Is.EqualTo(12)); + } + + [Test] + public void ParseContentLength_WhenPayloadExceedsLimit_Throws() + { + // Tests that warmup response framing rejects payloads that would allocate too much memory. + ProjectIpcWarmupClient client = new(); + List headerBytes = HeaderBytes($"Content-Length: {BufferConfig.MAX_MESSAGE_SIZE + 1}\r\n\r\n"); + + InvalidOperationException exception = Assert.Throws( + () => client.ParseContentLength(headerBytes)); + + Assert.That(exception.Message, Does.Contain("invalid Content-Length")); + } + + private static List HeaderBytes(string header) + { + return new List(Encoding.ASCII.GetBytes(header)); + } + } +} diff --git a/Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs.meta b/Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs.meta similarity index 83% rename from Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs.meta rename to Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs.meta index 5c3cf8f2f..c13243d9d 100644 --- a/Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs.meta +++ b/Assets/Tests/Editor/ProjectIpcWarmupClientTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 78711776de3744abc891f85c84586d83 +guid: 65f55d5b08ebc430ea08d3183bec96ae MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs b/Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs deleted file mode 100644 index 7920401f8..000000000 --- a/Assets/Tests/Editor/ProjectRootIdentityValidatorTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Domain; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Test fixture that verifies Project Root Identity Validator behavior. - /// - [TestFixture] - public sealed class ProjectRootIdentityValidatorTests - { - [Test] - public void Validate_WhenExpectedProjectRootIsMissing_ReturnsInvalidResult() - { - // Verifies that missing expected project roots are rejected before request execution. - ProjectRootIdentityValidationResult result = ProjectRootIdentityValidator.Validate(string.Empty, "/project"); - - Assert.That(result.IsValid, Is.False); - Assert.That(result.ErrorMessage, Does.Contain("expectedProjectRoot is required")); - } - - [Test] - public void Validate_WhenActualProjectRootIsUnavailable_ReturnsInvalidResult() - { - // Verifies that unavailable actual project roots fail closed instead of allowing execution. - ProjectRootIdentityValidationResult result = ProjectRootIdentityValidator.Validate("/project", string.Empty); - - Assert.That(result.IsValid, Is.False); - Assert.That(result.ErrorMessage, Does.Contain("Fast project validation is unavailable")); - } - - [Test] - public void Validate_WhenProjectRootDiffers_ReturnsInvalidResult() - { - // Verifies that a CLI request for another project cannot execute against this Unity instance. - ProjectRootIdentityValidationResult result = ProjectRootIdentityValidator.Validate("/project-a", "/project-b"); - - Assert.That(result.IsValid, Is.False); - Assert.That(result.ErrorMessage, Does.Contain("different project")); - } - - [Test] - public void Validate_WhenProjectRootMatches_ReturnsValidResult() - { - // Verifies that matching project identity allows request execution to continue. - ProjectRootIdentityValidationResult result = ProjectRootIdentityValidator.Validate("/project", "/project"); - - Assert.That(result.IsValid, Is.True); - } - } -} diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs index 594694b88..6f01d69c6 100644 --- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using System.Threading; using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.Application; @@ -103,6 +104,33 @@ public async Task ScheduleStartupRecovery_WhenRecoveryIsAsync_KeepsTaskIncomplet Assert.That(service.RecoveryTask, Is.Null); } + [Test] + public async Task StartRecoveryIfNeededAsync_WhenStartupLockExists_ShouldReleaseLockAfterWarmup() + { + // Tests that recovery keeps the startup lock until post-bind warmup has completed. + TestServerInstanceFactory serverInstanceFactory = new(); + UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = + new UnityCliLoopServerLifecycleRegistryService(); + UnityCliLoopServerControllerService service = new( + serverInstanceFactory, + lifecycleRegistry); + string claimedLockPath = null; + ServerStartingLockService.OnOwnedLockFileClaimedForDeletionForTests = path => claimedLockPath = path; + + try + { + await service.StartRecoveryIfNeededAsync(isAfterCompile: true, CancellationToken.None); + + Assert.That(serverInstanceFactory.LastCreated.ClearServerStartingLockWhenReady, Is.False); + Assert.That(claimedLockPath, Is.Not.Null); + } + finally + { + ServerStartingLockService.OnOwnedLockFileClaimedForDeletionForTests = null; + ServerStartingLockService.DeleteLockFile(); + } + } + private static UnityCliLoopServerControllerService CreateControllerService() { TestServerInstanceFactory serverInstanceFactory = new(); @@ -118,9 +146,12 @@ private static UnityCliLoopServerControllerService CreateControllerService() /// private sealed class TestServerInstanceFactory : IUnityCliLoopServerInstanceFactory { + public TestServerInstance LastCreated { get; private set; } + public IUnityCliLoopServerInstance Create() { - return new TestServerInstance(); + LastCreated = new TestServerInstance(); + return LastCreated; } } @@ -129,20 +160,26 @@ public IUnityCliLoopServerInstance Create() /// private sealed class TestServerInstance : IUnityCliLoopServerInstance { - public bool IsRunning => false; + public bool IsRunning { get; private set; } + + public bool? ClearServerStartingLockWhenReady { get; private set; } public string Endpoint => "test"; public void StartServer(bool clearServerStartingLockWhenReady = true) { + ClearServerStartingLockWhenReady = clearServerStartingLockWhenReady; + IsRunning = true; } public void StopServer() { + IsRunning = false; } public void Dispose() { + IsRunning = false; } } } diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index 6468b2e63..8f5665425 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -282,9 +282,17 @@ public async Task ExecuteCommandAsync_WhenCommandIsGetVersion_ReturnsBridgeVersi GetVersionResponse getVersionResponse = response as GetVersionResponse; Assert.That(getVersionResponse, Is.Not.Null); - Assert.That(getVersionResponse.Ver, Is.EqualTo(UnityCliLoopVersion.VERSION)); Assert.That(getVersionResponse.UnityVersion, Is.Not.Empty); - Assert.That(getVersionResponse.IsEditor, Is.True); + + JObject serializedResponse = JObject.FromObject(response); + Assert.That(serializedResponse["Ver"], Is.Null); + Assert.That(serializedResponse["Platform"], Is.Null); + Assert.That(serializedResponse["DataPath"], Is.Null); + Assert.That(serializedResponse["PersistentDataPath"], Is.Null); + Assert.That(serializedResponse["TemporaryCachePath"], Is.Null); + Assert.That(serializedResponse["IsEditor"], Is.Null); + Assert.That(serializedResponse["ProductName"], Is.Null); + Assert.That(serializedResponse["CompanyName"], Is.Null); } [Test] @@ -297,7 +305,9 @@ public async Task ExecuteCommandAsync_WhenCommandIsGetToolDetails_ReturnsCatalog GetToolDetailsResponse getToolDetailsResponse = response as GetToolDetailsResponse; Assert.That(getToolDetailsResponse, Is.Not.Null); - Assert.That(getToolDetailsResponse.Ver, Is.EqualTo(UnityCliLoopVersion.VERSION)); + + JObject serializedResponse = JObject.FromObject(response); + Assert.That(serializedResponse["Ver"], Is.Null); string[] toolNames = getToolDetailsResponse.Tools .Select(tool => tool.Name) @@ -351,9 +361,9 @@ public async Task ExecuteCommandAsync_WhenSampleToolUsesTypedContract_ReturnsTyp } [Test] - public async Task ExecuteToolAsync_WhenToolReturnsResponse_AssignsVersionToResponseInstance() + public async Task ExecuteToolAsync_WhenToolReturnsResponse_DoesNotAddProtocolVersionToResponseInstance() { - // Tests that response versioning is assigned per response instead of using global contract state. + // Tests that tool responses do not carry obsolete per-response protocol version metadata. UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); JObject parameters = JObject.FromObject(new { @@ -363,8 +373,9 @@ public async Task ExecuteToolAsync_WhenToolReturnsResponse_AssignsVersionToRespo }); UnityCliLoopToolResponse response = await registry.ExecuteToolAsync("hello-world", parameters); + JObject serializedResponse = JObject.FromObject(response); - Assert.That(response.Ver, Is.EqualTo(UnityCliLoopVersion.VERSION)); + Assert.That(serializedResponse["Ver"], Is.Null); } [Test] diff --git a/Packages/src/Cli~/.go-version b/Packages/src/Cli~/.go-version index dd43a143f..f8f738140 100644 --- a/Packages/src/Cli~/.go-version +++ b/Packages/src/Cli~/.go-version @@ -1 +1 @@ -1.26.1 +1.26.3 diff --git a/Packages/src/Cli~/Core~/dist/darwin-amd64/uloop-core b/Packages/src/Cli~/Core~/dist/darwin-amd64/uloop-core index f19fc1813..11c674fce 100755 Binary files a/Packages/src/Cli~/Core~/dist/darwin-amd64/uloop-core and b/Packages/src/Cli~/Core~/dist/darwin-amd64/uloop-core differ diff --git a/Packages/src/Cli~/Core~/dist/darwin-arm64/uloop-core b/Packages/src/Cli~/Core~/dist/darwin-arm64/uloop-core index 6a9c2e114..7f8e42c2d 100755 Binary files a/Packages/src/Cli~/Core~/dist/darwin-arm64/uloop-core and b/Packages/src/Cli~/Core~/dist/darwin-arm64/uloop-core differ diff --git a/Packages/src/Cli~/Core~/dist/windows-amd64/uloop-core.exe b/Packages/src/Cli~/Core~/dist/windows-amd64/uloop-core.exe index e83453850..65583e0f7 100755 Binary files a/Packages/src/Cli~/Core~/dist/windows-amd64/uloop-core.exe and b/Packages/src/Cli~/Core~/dist/windows-amd64/uloop-core.exe differ diff --git a/Packages/src/Cli~/Core~/internal/adapters/unity/client.go b/Packages/src/Cli~/Core~/internal/adapters/unity/client.go index 1fd3c3a33..354d27417 100644 --- a/Packages/src/Cli~/Core~/internal/adapters/unity/client.go +++ b/Packages/src/Cli~/Core~/internal/adapters/unity/client.go @@ -21,11 +21,10 @@ type Client struct { type ProgressFunc = func(message string) type rpcRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]any `json:"params"` - ID int `json:"id"` - Uloop *domain.RequestMetadata `json:"x-uloop,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]any `json:"params"` + ID int `json:"id"` } type rpcResponse struct { @@ -82,9 +81,15 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() + startedAt := time.Now() + timing := domain.UnitySendTiming{} + + dialStartedAt := time.Now() conn, err := dialEndpoint(ctx, client.connection.Endpoint) + timing.Dial = time.Since(dialStartedAt) if err != nil { - return domain.UnitySendOutcome{}, formatConnectionAttemptError(client.connection, err) + timing.Total = time.Since(startedAt) + return domain.UnitySendOutcome{Timing: timing}, formatConnectionAttemptError(client.connection, err) } defer func() { _ = conn.Close() @@ -100,7 +105,6 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string Method: method, Params: params, ID: client.requestID, - Uloop: client.connection.RequestMetadata, } payload, err := json.Marshal(request) @@ -112,21 +116,36 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string _ = conn.SetDeadline(deadline) } + writeStartedAt := time.Now() if err := framing.Write(conn, payload); err != nil { - return domain.UnitySendOutcome{}, err + timing.Write = time.Since(writeStartedAt) + timing.Total = time.Since(startedAt) + return domain.UnitySendOutcome{Timing: timing}, err } + timing.Write = time.Since(writeStartedAt) outcome := domain.UnitySendOutcome{RequestDispatched: true} + readStartedAt := time.Now() responsePayload, err := framing.Read(bufio.NewReader(conn)) + timing.Read = time.Since(readStartedAt) if err != nil { + timing.Total = time.Since(startedAt) + outcome.Timing = timing return outcome, err } + decodeStartedAt := time.Now() var response rpcResponse if err := json.Unmarshal(responsePayload, &response); err != nil { + timing.Decode = time.Since(decodeStartedAt) + timing.Total = time.Since(startedAt) + outcome.Timing = timing return outcome, err } + timing.Decode = time.Since(decodeStartedAt) if response.Error != nil { + timing.Total = time.Since(startedAt) + outcome.Timing = timing return outcome, &RPCError{ Code: response.Error.Code, Message: response.Error.Message, @@ -134,10 +153,14 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string } } if len(response.Result) == 0 { + timing.Total = time.Since(startedAt) + outcome.Timing = timing return outcome, fmt.Errorf("UNITY_NO_RESPONSE") } outcome.Result = response.Result + timing.Total = time.Since(startedAt) + outcome.Timing = timing return outcome, nil } diff --git a/Packages/src/Cli~/Core~/internal/adapters/unity/client_test.go b/Packages/src/Cli~/Core~/internal/adapters/unity/client_test.go index 78263277e..377876b68 100644 --- a/Packages/src/Cli~/Core~/internal/adapters/unity/client_test.go +++ b/Packages/src/Cli~/Core~/internal/adapters/unity/client_test.go @@ -1,13 +1,20 @@ package unity import ( + "bufio" + "context" + "encoding/json" "errors" + "net" + "runtime" "testing" + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/adapters/framing" "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" ) func TestFormatConnectionAttemptErrorExplainsDialFailureWithoutDisconnectClaim(t *testing.T) { + // Verifies that dial failures report connection attempts without implying a lost active connection. connection := domain.Connection{ Endpoint: domain.Endpoint{ Network: "unix", @@ -31,3 +38,71 @@ func TestFormatConnectionAttemptErrorExplainsDialFailureWithoutDisconnectClaim(t t.Fatalf("cause mismatch: %v", connectionErr.Unwrap()) } } + +func TestSendDoesNotIncludeProjectIdentityMetadata(t *testing.T) { + // Verifies that per-project endpoints do not need legacy project identity metadata. + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + captured := make(chan map[string]any, 1) + serverErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer func() { + _ = conn.Close() + }() + + payload, err := framing.Read(bufio.NewReader(conn)) + if err != nil { + serverErr <- err + return + } + + var request map[string]any + if err := json.Unmarshal(payload, &request); err != nil { + serverErr <- err + return + } + captured <- request + + response := []byte(`{"jsonrpc":"2.0","result":{"ok":true},"id":1}`) + if err := framing.Write(conn, response); err != nil { + serverErr <- err + return + } + }() + + connection := domain.Connection{ + Endpoint: domain.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: "/tmp/MyProject", + } + client := NewClient(connection) + if _, err := client.Send(context.Background(), "get-version", map[string]any{}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + case request := <-captured: + if _, ok := request["x-uloop"]; ok { + t.Fatalf("request should not include x-uloop metadata: %#v", request["x-uloop"]) + } + } +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait.go b/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait.go index 8a13db501..18bd6581f 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait.go @@ -37,18 +37,27 @@ func shouldWaitForCompileDomainReload(command string, params map[string]any) boo if command != compileCommandName { return false } - return boolParam(params[compileWaitParam]) + return compileDomainReloadWaitEnabled(params) } -func boolParam(value any) bool { - switch typed := value.(type) { - case bool: - return typed - case string: - return strings.EqualFold(typed, "true") - default: - return false +func compileDomainReloadWaitEnabled(params map[string]any) bool { + value, ok := params[compileWaitParam].(bool) + if ok { + return value } + + // Why: native CLI compile is a user-facing checkpoint; waiting by default + // ensures the post-compile readiness probe runs after the domain is usable. + return true +} + +func prepareCompileWaitParams(params map[string]any) (string, error) { + requestID, err := ensureCompileRequestID(params) + if err != nil { + return "", err + } + params[compileWaitParam] = true + return requestID, nil } func ensureCompileRequestID(params map[string]any) (string, error) { diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait_test.go index 0bcdec85c..5f25df976 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/compile_wait_test.go @@ -1,10 +1,12 @@ package cli import ( + "bytes" "context" "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -43,6 +45,43 @@ func TestEnsureCompileRequestIDReplacesUnsafeValue(t *testing.T) { } } +// Verifies that compile commands wait for domain reload even without an explicit flag. +func TestShouldWaitForCompileDomainReloadDefaultsToCompileCommands(t *testing.T) { + if !shouldWaitForCompileDomainReload(compileCommandName, map[string]any{}) { + t.Fatal("compile should wait for domain reload by default") + } + + if shouldWaitForCompileDomainReload("get-logs", map[string]any{}) { + t.Fatal("non-compile commands should not use compile wait") + } +} + +// Verifies that the explicit compile no-wait flag preserves the fast fire-and-forget path. +func TestShouldWaitForCompileDomainReloadRespectsExplicitFalse(t *testing.T) { + params := map[string]any{compileWaitParam: false} + + if shouldWaitForCompileDomainReload(compileCommandName, params) { + t.Fatal("compile wait should be disabled by an explicit false flag") + } +} + +// Verifies that compile wait preparation creates a request id and enables reload waiting. +func TestPrepareCompileWaitParamsForcesDomainReloadWait(t *testing.T) { + params := map[string]any{} + + requestID, err := prepareCompileWaitParams(params) + if err != nil { + t.Fatalf("prepareCompileWaitParams failed: %v", err) + } + + if requestID == "" { + t.Fatal("request id was not generated") + } + if params[compileWaitParam] != true { + t.Fatalf("compile wait flag was not forced: %#v", params[compileWaitParam]) + } +} + func TestWaitForCompileCompletionReadsResultAfterLocksClear(t *testing.T) { projectRoot := t.TempDir() requestID := "compile_test" @@ -144,3 +183,29 @@ func TestShouldWaitForCompileResultRequiresDispatchedTransportError(t *testing.T t.Fatal("dispatched transport error should wait") } } + +// Verifies that post-compile readiness runs only after successful compile results. +func TestCompileResultSucceededRequiresTrueSuccess(t *testing.T) { + if !compileResultSucceeded([]byte(`{"Success":true}`)) { + t.Fatal("successful compile result was not accepted") + } + + if compileResultSucceeded([]byte(`{"Success":false,"Errors":[{"Message":"boom"}]}`)) { + t.Fatal("failed compile result should not trigger readiness") + } + + if compileResultSucceeded([]byte(`{"Message":"indeterminate"}`)) { + t.Fatal("indeterminate compile result should not trigger readiness") + } +} + +// Verifies that failed best-effort warmup reports a warning without taking over compile output. +func TestWritePostCompileWarmupWarningReportsNonFatalFailure(t *testing.T) { + var stderr bytes.Buffer + + writePostCompileWarmupWarning(&stderr, fmt.Errorf("probe failed")) + + if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: probe failed") { + t.Fatalf("warning mismatch: %s", stderr.String()) + } +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/completion.go b/Packages/src/Cli~/Core~/internal/presentation/cli/completion.go index bf76d7a92..eb3d25a51 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/completion.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/completion.go @@ -104,6 +104,19 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write return true, 0 } +func shouldHandleCompletionRequest(args []string) bool { + if len(args) == 0 { + return false + } + + switch args[0] { + case listCommandsFlag, listOptionsFlag, completionCommand: + return true + default: + return false + } +} + type completionRequest struct { install bool shell string diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/completion_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/completion_test.go index 04e4ef62a..69e1a444e 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/completion_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/completion_test.go @@ -50,7 +50,7 @@ func TestCompletionListOptionsUsesToolSchema(t *testing.T) { } output := stdout.String() - for _, option := range []string{"--force-recompile", "--wait-for-domain-reload"} { + for _, option := range []string{"--force-recompile", "--no-wait-for-domain-reload"} { if !strings.Contains(output, option) { t.Fatalf("option %s was not listed: %s", option, output) } @@ -110,6 +110,26 @@ func TestCompletionPrintsShellScriptWithoutProject(t *testing.T) { } } +func TestCompletionDetectionSkipsRegularToolCommands(t *testing.T) { + // Verifies that normal tool execution avoids completion cache loading. + if shouldHandleCompletionRequest([]string{executeDynamicCodeCommandName, "--code", "return 1;"}) { + t.Fatal("execute-dynamic-code should not enter completion handling") + } +} + +func TestCompletionDetectionHandlesCompletionCommands(t *testing.T) { + // Verifies that completion-specific commands still load completion metadata. + for _, args := range [][]string{ + {completionCommand, "--shell", "bash"}, + {listCommandsFlag}, + {listOptionsFlag, "compile"}, + } { + if !shouldHandleCompletionRequest(args) { + t.Fatalf("completion request was not detected: %#v", args) + } + } +} + // Tests that Git Bash auto-install writes bash completion instead of PowerShell completion. func TestDetectShellOnWindowsGitBashUsesBash(t *testing.T) { shellName := detectShellFromEnvironment("windows", "/usr/bin/bash", "MINGW64") diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing.go b/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing.go new file mode 100644 index 000000000..f5296e1f6 --- /dev/null +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing.go @@ -0,0 +1,91 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" +) + +const ( + debugTimingEnvName = "ULOOP_DEBUG_TIMING" + dynamicCodeIncludeTimingsParamName = "IncludeTimings" +) + +func writeDebugTiming(writer io.Writer, command string, total time.Duration, outcome domain.UnitySendOutcome) { + if !isDebugTimingEnabled() { + return + } + + timing := outcome.Timing + _, _ = fmt.Fprintf( + writer, + "[uloop timing] command=%s total=%s rpc_total=%s dial=%s write=%s read=%s decode=%s\n", + command, + formatDebugDuration(total), + formatDebugDuration(timing.Total), + formatDebugDuration(timing.Dial), + formatDebugDuration(timing.Write), + formatDebugDuration(timing.Read), + formatDebugDuration(timing.Decode), + ) + for _, unityTiming := range extractUnityDebugTimings(command, outcome.Result) { + _, _ = fmt.Fprintf(writer, "[uloop timing] unity %s\n", unityTiming) + } +} + +func applyDebugTimingParams(command string, params map[string]any) { + if command != executeDynamicCodeCommandName || !isDebugTimingEnabled() { + return + } + + params[dynamicCodeIncludeTimingsParamName] = true +} + +func stripDebugTimingResult(command string, result json.RawMessage) json.RawMessage { + if command != executeDynamicCodeCommandName || !isDebugTimingEnabled() { + return result + } + + var payload map[string]any + if err := json.Unmarshal(result, &payload); err != nil { + return result + } + + delete(payload, "Timings") + sanitized, err := json.Marshal(payload) + if err != nil { + return result + } + return sanitized +} + +func extractUnityDebugTimings(command string, result json.RawMessage) []string { + if command != executeDynamicCodeCommandName { + return nil + } + + var payload struct { + Timings []string `json:"Timings"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return nil + } + return payload.Timings +} + +func isDebugTimingEnabled() bool { + value := strings.TrimSpace(os.Getenv(debugTimingEnvName)) + if value == "" || value == "0" { + return false + } + return !strings.EqualFold(value, "false") +} + +func formatDebugDuration(duration time.Duration) string { + return duration.Round(time.Microsecond).String() +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing_test.go new file mode 100644 index 000000000..4dccbd0df --- /dev/null +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/debug_timing_test.go @@ -0,0 +1,121 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" +) + +// Verifies that timing output stays disabled unless explicitly requested. +func TestWriteDebugTiming_WhenEnvironmentIsUnset_ShouldStaySilent(t *testing.T) { + t.Setenv(debugTimingEnvName, "") + var stderr bytes.Buffer + + writeDebugTiming(&stderr, "execute-dynamic-code", time.Millisecond, domain.UnitySendOutcome{}) + + if stderr.Len() != 0 { + t.Fatalf("debug timing wrote output while disabled: %q", stderr.String()) + } +} + +// Verifies that timing output includes command and RPC phase durations when enabled. +func TestWriteDebugTiming_WhenEnvironmentIsEnabled_ShouldWriteTimingLine(t *testing.T) { + t.Setenv(debugTimingEnvName, "1") + var stderr bytes.Buffer + + writeDebugTiming( + &stderr, + "execute-dynamic-code", + 10*time.Millisecond, + domain.UnitySendOutcome{ + Timing: domain.UnitySendTiming{ + Total: 9 * time.Millisecond, + Dial: time.Millisecond, + Write: 2 * time.Millisecond, + Read: 5 * time.Millisecond, + Decode: time.Millisecond, + }, + }, + ) + + output := stderr.String() + for _, expected := range []string{ + "[uloop timing]", + "command=execute-dynamic-code", + "total=10ms", + "rpc_total=9ms", + "dial=1ms", + "write=2ms", + "read=5ms", + "decode=1ms", + } { + if !strings.Contains(output, expected) { + t.Fatalf("debug timing output missing %q: %s", expected, output) + } + } +} + +// Verifies that debug timing requests Unity-side timings only for execute-dynamic-code. +func TestApplyDebugTimingParams_WhenEnabledForDynamicCode_ShouldRequestUnityTimings(t *testing.T) { + t.Setenv(debugTimingEnvName, "1") + params := map[string]any{} + + applyDebugTimingParams(executeDynamicCodeCommandName, params) + + if params[dynamicCodeIncludeTimingsParamName] != true { + t.Fatalf("IncludeTimings was not requested: %#v", params) + } +} + +// Verifies that debug timing does not alter other tool requests. +func TestApplyDebugTimingParams_WhenEnabledForOtherCommand_ShouldLeaveParamsUnchanged(t *testing.T) { + t.Setenv(debugTimingEnvName, "1") + params := map[string]any{} + + applyDebugTimingParams("get-logs", params) + + if _, ok := params[dynamicCodeIncludeTimingsParamName]; ok { + t.Fatalf("IncludeTimings was added for another command: %#v", params) + } +} + +// Verifies that Unity-side timing entries are mirrored to stderr for diagnosis. +func TestWriteDebugTiming_WhenUnityTimingsExist_ShouldWriteUnityTimingLines(t *testing.T) { + t.Setenv(debugTimingEnvName, "1") + var stderr bytes.Buffer + outcome := domain.UnitySendOutcome{ + Result: []byte(`{"Success":true,"Timings":["[Perf] Build: 12.3ms","[Perf] Execution: 4.5ms"]}`), + } + + writeDebugTiming(&stderr, executeDynamicCodeCommandName, time.Millisecond, outcome) + + output := stderr.String() + for _, expected := range []string{ + "[uloop timing] unity [Perf] Build: 12.3ms", + "[uloop timing] unity [Perf] Execution: 4.5ms", + } { + if !strings.Contains(output, expected) { + t.Fatalf("debug timing output missing %q: %s", expected, output) + } + } +} + +// Verifies that debug-only Unity timings are removed before printing JSON stdout. +func TestStripDebugTimingResult_WhenUnityTimingsExist_ShouldRemoveTimings(t *testing.T) { + t.Setenv(debugTimingEnvName, "1") + result := stripDebugTimingResult( + executeDynamicCodeCommandName, + []byte(`{"Success":true,"Result":"ok","Timings":["[Perf] Build: 12.3ms"]}`), + ) + + output := string(result) + if strings.Contains(output, "Timings") { + t.Fatalf("Timings remained in sanitized result: %s", output) + } + if !strings.Contains(output, `"Result":"ok"`) { + t.Fatalf("sanitized result lost normal fields: %s", output) + } +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/default-tools.json b/Packages/src/Cli~/Core~/internal/presentation/cli/default-tools.json index c5cb7f5c2..45845fa86 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/default-tools.json +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/default-tools.json @@ -13,7 +13,8 @@ }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning" + "description": "Wait for domain reload completion before returning", + "default": true } } } diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope.go b/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope.go index 08c2c681f..378569766 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope.go @@ -244,7 +244,7 @@ func compileWaitTimeoutError(projectRoot string) cliError { Command: compileCommandName, NextActions: []string{ "Run `uloop fix` to remove stale lock files.", - "Retry `uloop compile --wait-for-domain-reload` after Unity becomes responsive.", + "Retry `uloop compile` after Unity becomes responsive.", }, } } diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope_test.go index 71544858f..d967306d9 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/error_envelope_test.go @@ -192,7 +192,7 @@ func TestCompileWaitTimeoutError(t *testing.T) { if cliErr.ProjectRoot != "/tmp/MyProject" { t.Fatalf("project root mismatch: %#v", cliErr) } - expectedRetryAction := "Retry `uloop compile --wait-for-domain-reload` after Unity becomes responsive." + expectedRetryAction := "Retry `uloop compile` after Unity becomes responsive." if len(cliErr.NextActions) < 2 || cliErr.NextActions[1] != expectedRetryAction { t.Fatalf("retry action mismatch: %#v", cliErr.NextActions) } diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/launch.go b/Packages/src/Cli~/Core~/internal/presentation/cli/launch.go index 4857df732..80a149742 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/launch.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/launch.go @@ -2,7 +2,6 @@ package cli import ( "context" - "encoding/json" "fmt" "io" "os" @@ -14,7 +13,6 @@ import ( "strings" "time" - "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Core/internal/adapters/unity" "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/adapters/project" ) @@ -22,9 +20,6 @@ const ( launchCommandName = "launch" launchLockfilePoll = 100 * time.Millisecond launchLockfileTimeout = 5 * time.Second - launchReadinessTimeout = 180 * time.Second - launchReadinessPoll = 1 * time.Second - launchProbeTimeout = 5 * time.Second projectVersionFilePath = "ProjectSettings/ProjectVersion.txt" recoveryDirectoryPath = "Assets/_Recovery" launchTempDirectoryName = "Temp" @@ -33,8 +28,6 @@ const ( var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) -const launchDynamicCodeProbe = `UnityEngine.LogType previous = UnityEngine.Debug.unityLogger.filterLogType; UnityEngine.Debug.unityLogger.filterLogType = UnityEngine.LogType.Warning; try { UnityEngine.Debug.Log("Unity CLI Loop dynamic code prewarm"); return "Unity CLI Loop dynamic code prewarm"; } finally { UnityEngine.Debug.unityLogger.filterLogType = previous; }` - type launchOptions struct { projectPath string restart bool @@ -261,7 +254,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } - if err := waitForLaunchReady(ctx, projectRoot); err != nil { + if err := waitForToolReadiness(ctx, projectRoot); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } @@ -316,73 +309,6 @@ func unityLockfilePath(projectRoot string) string { return filepath.Join(projectRoot, launchTempDirectoryName, unityLockfileName) } -func waitForLaunchReady(ctx context.Context, projectRoot string) error { - timeoutContext, cancel := context.WithTimeout(ctx, launchReadinessTimeout) - defer cancel() - - for { - if err := probeLaunchReady(timeoutContext, projectRoot); err == nil { - return nil - } - - select { - case <-timeoutContext.Done(): - return fmt.Errorf("timed out waiting for Unity to become ready after launch") - case <-time.After(launchReadinessPoll): - } - } -} - -func probeLaunchReady(ctx context.Context, projectRoot string) error { - probeContext, cancel := context.WithTimeout(ctx, launchProbeTimeout) - defer cancel() - - connection, err := project.ResolveConnection(projectRoot, projectRoot) - if err != nil { - return err - } - - if !isExecuteDynamicCodeAvailable(projectRoot) { - _, err := unity.NewClient(connection).Send(probeContext, "get-version", map[string]any{}) - return err - } - - response, err := unity.NewClient(connection).Send(probeContext, "execute-dynamic-code", map[string]any{ - "Code": launchDynamicCodeProbe, - "CompileOnly": false, - "YieldToForegroundRequests": true, - }) - if err != nil { - return err - } - - var payload executeDynamicCodeLaunchResponse - if err := json.Unmarshal(response, &payload); err != nil { - return err - } - if !payload.Success { - if payload.ErrorMessage != "" { - return fmt.Errorf("execute-dynamic-code launch readiness probe failed: %s", payload.ErrorMessage) - } - return fmt.Errorf("execute-dynamic-code launch readiness probe failed") - } - return nil -} - -type executeDynamicCodeLaunchResponse struct { - Success bool `json:"Success"` - ErrorMessage string `json:"ErrorMessage"` -} - -func isExecuteDynamicCodeAvailable(projectRoot string) bool { - cache, err := loadTools(projectRoot) - if err != nil { - return true - } - _, ok := findTool(cache, "execute-dynamic-code") - return ok -} - func resolveLaunchProjectRoot(startPath string, options launchOptions) (string, error) { if options.projectPath != "" { projectRoot, err := filepath.Abs(options.projectPath) diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/launch_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/launch_test.go index 538f65e1a..628657061 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/launch_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/launch_test.go @@ -102,6 +102,15 @@ func TestRunLaunchQuitDoesNotLaunchWhenUnityIsNotRunning(t *testing.T) { } } +// Verifies that readiness probes exercise the same foreground warmup path as user executions. +func TestExecuteDynamicCodeReadinessProbeParamsUseForegroundWarmup(t *testing.T) { + params := executeDynamicCodeReadinessProbeParams() + + if params["YieldToForegroundRequests"] != false { + t.Fatalf("readiness probe should use foreground warmup: %#v", params["YieldToForegroundRequests"]) + } +} + func TestNewUnityLaunchCommandIsNotContextCancelable(t *testing.T) { command := newUnityLaunchCommand("/bin/echo", []string{"hello"}) diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/run.go b/Packages/src/Cli~/Core~/internal/presentation/cli/run.go index 3e954b04c..b8d2bbcb0 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/run.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/run.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "time" corecontract "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Core" "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Core/internal/adapters/unity" @@ -44,9 +45,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return 1 } - completionTools := loadCompletionTools(startPath, projectPath) - if handled, code := tryHandleCompletionRequest(remainingArgs, completionTools, stdout, stderr); handled { - return code + if shouldHandleCompletionRequest(remainingArgs) { + completionTools := loadCompletionTools(startPath, projectPath) + if handled, code := tryHandleCompletionRequest(remainingArgs, completionTools, stdout, stderr); handled { + return code + } } if handled, code := tryHandleUpdateRequest(ctx, remainingArgs, stdout, stderr); handled { return code @@ -71,12 +74,6 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return 1 } - cache, err := loadTools(connection.ProjectRoot) - if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: command}) - return 1 - } - switch command { case "list": return runList(ctx, connection, stdout, stderr) @@ -87,7 +84,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder case "fix": return runFix(connection.ProjectRoot, stdout, stderr) default: - tool, ok := findTool(cache, command) + tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: command}) + return 1 + } if !ok { writeErrorEnvelope(stderr, unknownCommandError(command, cache, errorContext{ projectRoot: connection.ProjectRoot, @@ -123,6 +124,8 @@ func runTool(ctx context.Context, connection domain.Connection, command string, return runCompileWithDomainReloadWait(ctx, connection, params, stdout, stderr) } + applyDebugTimingParams(command, params) + startedAt := time.Now() spinner := newToolSpinner(stderr, command) dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ @@ -134,18 +137,20 @@ func runTool(ctx context.Context, connection domain.Connection, command string, }) spinner.Stop() if err != nil { + writeDebugTiming(stderr, command, time.Since(startedAt), outcome) writeToolFailure(stderr, err, outcome, errorContext{ projectRoot: connection.ProjectRoot, command: command, }) return 1 } - writeJSON(stdout, outcome.Result) + writeJSON(stdout, stripDebugTimingResult(command, outcome.Result)) + writeDebugTiming(stderr, command, time.Since(startedAt), outcome) return 0 } func runCompileWithDomainReloadWait(ctx context.Context, connection domain.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { - requestID, err := ensureCompileRequestID(params) + requestID, err := prepareCompileWaitParams(params) if err != nil { writeClassifiedError(stderr, err, errorContext{ projectRoot: connection.ProjectRoot, @@ -154,6 +159,7 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection domain.Conne return 1 } + startedAt := time.Now() spinner := newToolSpinner(stderr, compileCommandName) dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ @@ -183,8 +189,8 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection domain.Conne pollInterval: compileWaitPollInterval, lockGrace: compileLockGracePeriod, }) - spinner.Stop() if waitErr != nil { + spinner.Stop() writeClassifiedError(stderr, waitErr, errorContext{ projectRoot: connection.ProjectRoot, command: compileCommandName, @@ -192,13 +198,32 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection domain.Conne return 1 } if !completed { + spinner.Stop() writeErrorEnvelope(stderr, compileWaitTimeoutError(connection.ProjectRoot)) return 1 } + if compileResultSucceeded(result) { + spinner.Update("Warming execute-dynamic-code after compile...") + if err := waitForToolReadiness(ctx, connection.ProjectRoot); err != nil { + spinner.Stop() + writePostCompileWarmupWarning(stderr, err) + } + } + spinner.Stop() writeJSON(stdout, result) + writeDebugTiming(stderr, compileCommandName, time.Since(startedAt), outcome) return 0 } +func writePostCompileWarmupWarning(stderr io.Writer, err error) { + if err == nil { + return + } + // Why: this warmup is a hidden optimization, so it must not turn a + // successful compile result into a user-visible command failure. + _, _ = fmt.Fprintf(stderr, "warning: post-compile warmup skipped: %v\n", err) +} + func runList(ctx context.Context, connection domain.Connection, stdout io.Writer, stderr io.Writer) int { spinner := newToolSpinner(stderr, "list") dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} @@ -263,3 +288,15 @@ func writeJSON(stdout io.Writer, result json.RawMessage) { encoder.SetIndent("", " ") _ = encoder.Encode(pretty) } + +type compileResultStatus struct { + Success *bool `json:"Success"` +} + +func compileResultSucceeded(result json.RawMessage) bool { + var status compileResultStatus + if json.Unmarshal(result, &status) != nil { + return false + } + return status.Success != nil && *status.Success +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/spinner.go b/Packages/src/Cli~/Core~/internal/presentation/cli/spinner.go index 2cbbe1c10..008a49971 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/spinner.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/spinner.go @@ -8,7 +8,10 @@ import ( "time" ) -const spinnerFrameInterval = 80 * time.Millisecond +const ( + spinnerFrameInterval = 80 * time.Millisecond + executeDynamicCodeCommandName = "execute-dynamic-code" +) var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} @@ -24,7 +27,7 @@ type terminalSpinner struct { } func newToolSpinner(stderr io.Writer, command string) *terminalSpinner { - return newSpinner(stderr, isTerminalWriter(stderr), "Connecting to Unity...") + return newSpinner(stderr, shouldShowToolFeedback(command) && isTerminalWriter(stderr), "Connecting to Unity...") } func newLaunchSpinner(stdout io.Writer, stderr io.Writer) *terminalSpinner { @@ -115,3 +118,7 @@ func isTerminalWriter(writer io.Writer) bool { return info.Mode()&os.ModeCharDevice != 0 } + +func shouldShowToolFeedback(command string) bool { + return command != executeDynamicCodeCommandName +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/spinner_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/spinner_test.go index d87f0370a..aeed59110 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/spinner_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/spinner_test.go @@ -7,6 +7,7 @@ import ( ) func TestSpinnerDoesNotWriteWhenDisabled(t *testing.T) { + // Verifies that disabled spinners stay silent. var stderr bytes.Buffer spinner := newSpinner(&stderr, false, "Executing compile...") @@ -18,6 +19,7 @@ func TestSpinnerDoesNotWriteWhenDisabled(t *testing.T) { } func TestSpinnerWritesMessageAndClearsLine(t *testing.T) { + // Verifies that enabled spinners render and clean up their terminal line. var stderr bytes.Buffer spinner := newSpinner(&stderr, true, "Executing compile...") @@ -33,6 +35,7 @@ func TestSpinnerWritesMessageAndClearsLine(t *testing.T) { } func TestLaunchSpinnerWritesStartupMessage(t *testing.T) { + // Verifies that launch spinners show startup progress text. var stdout bytes.Buffer spinner := newSpinner(&stdout, true, "Waiting for Unity to finish starting...") @@ -46,3 +49,17 @@ func TestLaunchSpinnerWritesStartupMessage(t *testing.T) { t.Fatalf("launch spinner output did not clear the line before returning: %q", output) } } + +func TestToolFeedbackSkipsExecuteDynamicCode(t *testing.T) { + // Verifies that execute-dynamic-code keeps the CLI hot path quiet. + if shouldShowToolFeedback(executeDynamicCodeCommandName) { + t.Fatal("execute-dynamic-code should skip spinner feedback on the hot path") + } +} + +func TestToolFeedbackKeepsOtherUnityTools(t *testing.T) { + // Verifies that regular Unity tools still show interactive feedback. + if !shouldShowToolFeedback("get-logs") { + t.Fatal("non-hot-path Unity tools should keep spinner feedback") + } +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness.go b/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness.go new file mode 100644 index 000000000..00dbb8d34 --- /dev/null +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness.go @@ -0,0 +1,110 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Core/internal/adapters/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/adapters/project" +) + +const ( + toolReadinessTimeout = 180 * time.Second + toolReadinessPoll = 1 * time.Second + toolReadinessProbeTimeout = 5 * time.Second + toolReadinessProbeCount = 3 +) + +const executeDynamicCodeReadinessProbe = `return "Unity CLI Loop dynamic code prewarm";` + +func waitForToolReadiness(ctx context.Context, projectRoot string) error { + // Why: launch and compile can both recreate Unity's project IPC server; a real + // tool request proves the user-visible command will not be the cold transport probe. + timeoutContext, cancel := context.WithTimeout(ctx, toolReadinessTimeout) + defer cancel() + + for { + if err := probeToolReadinessSequence(timeoutContext, projectRoot); err == nil { + return nil + } + + select { + case <-timeoutContext.Done(): + return toolReadinessDoneError(ctx) + case <-time.After(toolReadinessPoll): + } + } +} + +func toolReadinessDoneError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return fmt.Errorf("timed out waiting for Unity tool readiness") +} + +func probeToolReadinessSequence(ctx context.Context, projectRoot string) error { + for probeIndex := 0; probeIndex < toolReadinessProbeCount; probeIndex++ { + if err := probeToolReadiness(ctx, projectRoot); err != nil { + return err + } + } + + return nil +} + +func probeToolReadiness(ctx context.Context, projectRoot string) error { + probeContext, cancel := context.WithTimeout(ctx, toolReadinessProbeTimeout) + defer cancel() + + connection, err := project.ResolveConnection(projectRoot, projectRoot) + if err != nil { + return err + } + + if !isExecuteDynamicCodeAvailable(projectRoot) { + _, err := unity.NewClient(connection).Send(probeContext, "get-version", map[string]any{}) + return err + } + + response, err := unity.NewClient(connection).Send(probeContext, "execute-dynamic-code", executeDynamicCodeReadinessProbeParams()) + if err != nil { + return err + } + + var payload executeDynamicCodeReadinessResponse + if err := json.Unmarshal(response, &payload); err != nil { + return err + } + if !payload.Success { + if payload.ErrorMessage != "" { + return fmt.Errorf("execute-dynamic-code readiness probe failed: %s", payload.ErrorMessage) + } + return fmt.Errorf("execute-dynamic-code readiness probe failed") + } + return nil +} + +func executeDynamicCodeReadinessProbeParams() map[string]any { + return map[string]any{ + "Code": executeDynamicCodeReadinessProbe, + "CompileOnly": false, + "YieldToForegroundRequests": false, + } +} + +type executeDynamicCodeReadinessResponse struct { + Success bool `json:"Success"` + ErrorMessage string `json:"ErrorMessage"` +} + +func isExecuteDynamicCodeAvailable(projectRoot string) bool { + cache, err := loadTools(projectRoot) + if err != nil { + return true + } + _, ok := findTool(cache, "execute-dynamic-code") + return ok +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness_test.go new file mode 100644 index 000000000..bb54b99bc --- /dev/null +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/tool_readiness_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "context" + "errors" + "testing" +) + +// Verifies that parent cancellation is preserved instead of being reported as a timeout. +func TestToolReadinessDoneErrorPropagatesParentCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := toolReadinessDoneError(ctx) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } +} + +// Verifies that readiness timeout still uses the user-facing timeout message. +func TestToolReadinessDoneErrorReportsTimeoutWhenParentIsActive(t *testing.T) { + err := toolReadinessDoneError(context.Background()) + + if err == nil || err.Error() != "timed out waiting for Unity tool readiness" { + t.Fatalf("timeout error mismatch: %v", err) + } +} diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/tools.go b/Packages/src/Cli~/Core~/internal/presentation/cli/tools.go index 1fd8a6de1..0e67bff7c 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/tools.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/tools.go @@ -74,6 +74,26 @@ func findTool(cache toolsCache, name string) (toolDefinition, bool) { return toolDefinition{}, false } +func findToolForCommand(projectRoot string, command string) (toolDefinition, toolsCache, bool, error) { + if shouldUseEmbeddedToolDefinition(command) { + cache := loadDefaultTools() + tool, ok := findTool(cache, command) + return tool, cache, ok, nil + } + + cache, err := loadTools(projectRoot) + if err != nil { + return toolDefinition{}, toolsCache{}, false, err + } + + tool, ok := findTool(cache, command) + return tool, cache, ok, nil +} + +func shouldUseEmbeddedToolDefinition(command string) bool { + return command == executeDynamicCodeCommandName +} + func filterInternalSkillTools(projectRoot string, cache toolsCache) toolsCache { internalToolNames := collectInternalSkillToolNames(projectRoot) if len(internalToolNames) == 0 { diff --git a/Packages/src/Cli~/Core~/internal/presentation/cli/tools_test.go b/Packages/src/Cli~/Core~/internal/presentation/cli/tools_test.go index aff113f0f..5c8b5ece6 100644 --- a/Packages/src/Cli~/Core~/internal/presentation/cli/tools_test.go +++ b/Packages/src/Cli~/Core~/internal/presentation/cli/tools_test.go @@ -124,6 +124,54 @@ internal: true } } +func TestFindToolForCommandUsesEmbeddedExecuteDynamicCodeDefinition(t *testing.T) { + // Verifies that execute-dynamic-code avoids project tool-cache loading on the hot path. + projectRoot := t.TempDir() + writeToolCache(t, projectRoot, `{ + "version": "test", + "tools": [] +}`) + + tool, _, ok, err := findToolForCommand(projectRoot, executeDynamicCodeCommandName) + if err != nil { + t.Fatalf("findToolForCommand failed: %v", err) + } + + if !ok { + t.Fatal("execute-dynamic-code was not loaded from embedded definitions") + } + if tool.Name != executeDynamicCodeCommandName { + t.Fatalf("tool name mismatch: %s", tool.Name) + } +} + +func TestFindToolForCommandUsesProjectCacheForRegularTools(t *testing.T) { + // Verifies that non-hot-path tools still come from the project tool cache. + projectRoot := t.TempDir() + writeToolCache(t, projectRoot, `{ + "version": "test", + "tools": [ + { + "name": "cached-tool", + "description": "cached", + "inputSchema": {"type": "object", "properties": {}} + } + ] +}`) + + tool, _, ok, err := findToolForCommand(projectRoot, "cached-tool") + if err != nil { + t.Fatalf("findToolForCommand failed: %v", err) + } + + if !ok { + t.Fatal("cached tool was not loaded") + } + if tool.Name != "cached-tool" { + t.Fatalf("tool name mismatch: %s", tool.Name) + } +} + // Tests that internal skills without frontmatter names are filtered by their directory-derived tool names. func TestLoadToolsFiltersDerivedInternalSkillToolNameFromCache(t *testing.T) { projectRoot := t.TempDir() diff --git a/Packages/src/Cli~/Dispatcher~/dist/darwin-amd64/uloop-dispatcher b/Packages/src/Cli~/Dispatcher~/dist/darwin-amd64/uloop-dispatcher index 3ffa89334..cf0283302 100755 Binary files a/Packages/src/Cli~/Dispatcher~/dist/darwin-amd64/uloop-dispatcher and b/Packages/src/Cli~/Dispatcher~/dist/darwin-amd64/uloop-dispatcher differ diff --git a/Packages/src/Cli~/Dispatcher~/dist/darwin-arm64/uloop-dispatcher b/Packages/src/Cli~/Dispatcher~/dist/darwin-arm64/uloop-dispatcher index 97408888b..776d54ac9 100755 Binary files a/Packages/src/Cli~/Dispatcher~/dist/darwin-arm64/uloop-dispatcher and b/Packages/src/Cli~/Dispatcher~/dist/darwin-arm64/uloop-dispatcher differ diff --git a/Packages/src/Cli~/Dispatcher~/dist/windows-amd64/uloop-dispatcher.exe b/Packages/src/Cli~/Dispatcher~/dist/windows-amd64/uloop-dispatcher.exe index c1c3924ba..a1cfc19e8 100755 Binary files a/Packages/src/Cli~/Dispatcher~/dist/windows-amd64/uloop-dispatcher.exe and b/Packages/src/Cli~/Dispatcher~/dist/windows-amd64/uloop-dispatcher.exe differ diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/default-tools.json b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/default-tools.json index c5cb7f5c2..45845fa86 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/default-tools.json +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/default-tools.json @@ -13,7 +13,8 @@ }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning" + "description": "Wait for domain reload completion before returning", + "default": true } } } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher.go index f768034fe..66f5e7a64 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher.go @@ -155,7 +155,7 @@ func forwardedProjectLocalArgs(args []string, explicitProjectPath string, projec if isLaunchCommand(forwardedArgs) { return replaceLaunchPositionalProjectPath(forwardedArgs, projectRoot) } - return forwardedArgs + return append(forwardedArgs, "--project-path", projectRoot) } func replaceLaunchPositionalProjectPath(args []string, projectRoot string) []string { diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher_test.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher_test.go index 257498ccb..02b7493dd 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher_test.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/dispatcher_test.go @@ -602,6 +602,22 @@ func TestForwardedProjectLocalArgsForLaunchUsesResolvedPositionalProjectPath(t * } } +func TestForwardedProjectLocalArgsAddsResolvedProjectPathForImplicitToolDispatch(t *testing.T) { + // Verifies that project-local core dispatch skips duplicate project-root discovery. + projectRoot := filepath.Join(t.TempDir(), "Game") + args := []string{"execute-dynamic-code", "--code", "return 1;"} + + forwarded := forwardedProjectLocalArgs(args, "", projectRoot) + + expected := []string{"execute-dynamic-code", "--code", "return 1;", "--project-path", projectRoot} + if strings.Join(forwarded, "\n") != strings.Join(expected, "\n") { + t.Fatalf("forwarded args mismatch: %#v", forwarded) + } + if len(args) != 3 { + t.Fatalf("input args were mutated: %#v", args) + } +} + func TestRunCompletionScriptDoesNotRequireUnityProject(t *testing.T) { // Verifies that shell completion stays a dispatcher-owned global command. changeDirectory(t, t.TempDir()) @@ -675,7 +691,7 @@ func TestRunCompletionListsDefaultToolOptionsWithoutProject(t *testing.T) { t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) } output := stdout.String() - for _, option := range []string{"--force-recompile", "--wait-for-domain-reload"} { + for _, option := range []string{"--force-recompile", "--no-wait-for-domain-reload"} { if !strings.Contains(output, option) { t.Fatalf("option %s was not listed: %s", option, output) } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/launch.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/launch.go index bbaeea423..d44cdac58 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/launch.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/launch.go @@ -15,17 +15,17 @@ import ( ) const ( - launchCommandName = "launch" - launchPathPollInterval = 500 * time.Millisecond - launchCoreReadyTimeout = 180 * time.Second - launchReadinessTimeout = 180 * time.Second - launchReadinessPoll = 1 * time.Second - launchProbeTimeout = 5 * time.Second - launchLockfileTimeout = 5 * time.Second - projectVersionFilePath = "ProjectSettings/ProjectVersion.txt" - recoveryDirectoryPath = "Assets/_Recovery" - launchTempDirectoryName = "Temp" - unityLockfileName = "UnityLockfile" + launchCommandName = "launch" + launchPathPollInterval = 500 * time.Millisecond + launchCoreReadyTimeout = 180 * time.Second + toolReadinessTimeout = 180 * time.Second + toolReadinessPoll = 1 * time.Second + toolReadinessProbeTimeout = 5 * time.Second + launchLockfileTimeout = 5 * time.Second + projectVersionFilePath = "ProjectSettings/ProjectVersion.txt" + recoveryDirectoryPath = "Assets/_Recovery" + launchTempDirectoryName = "Temp" + unityLockfileName = "UnityLockfile" ) var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) @@ -132,7 +132,7 @@ func runLaunchBootstrap(ctx context.Context, args []string, explicitProjectPath writeError(stderr, internalError(err.Error(), projectRoot)) return 1 } - if err := waitForLaunchReady(ctx, projectRoot); err != nil { + if err := waitForToolReadiness(ctx, projectRoot); err != nil { writeError(stderr, internalError(err.Error(), projectRoot)) return 1 } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness.go index 6979a6c6d..414280f5c 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness.go @@ -12,40 +12,44 @@ import ( "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" ) -const launchDynamicCodeProbe = `UnityEngine.LogType previous = UnityEngine.Debug.unityLogger.filterLogType; UnityEngine.Debug.unityLogger.filterLogType = UnityEngine.LogType.Warning; try { UnityEngine.Debug.Log("Unity CLI Loop dynamic code prewarm"); return "Unity CLI Loop dynamic code prewarm"; } finally { UnityEngine.Debug.unityLogger.filterLogType = previous; }` - -type launchReadyRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]any `json:"params"` - ID int `json:"id"` - Uloop *domain.RequestMetadata `json:"x-uloop,omitempty"` +const ( + executeDynamicCodeReadinessProbe = `return "Unity CLI Loop dynamic code prewarm";` + toolReadinessProbeCount = 3 +) + +type toolReadinessRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]any `json:"params"` + ID int `json:"id"` } -type launchReadyResponse struct { - Result json.RawMessage `json:"result,omitempty"` - Error *launchReadyRPCError `json:"error,omitempty"` - ID int `json:"id"` +type toolReadinessResponse struct { + Result json.RawMessage `json:"result,omitempty"` + Error *toolReadinessRPCError `json:"error,omitempty"` + ID int `json:"id"` } -type launchReadyRPCError struct { +type toolReadinessRPCError struct { Message string `json:"message"` } -type launchDynamicCodeResponse struct { +type executeDynamicCodeReadinessResponse struct { Success bool `json:"Success"` ErrorMessage string `json:"ErrorMessage"` } -func waitForLaunchReady(ctx context.Context, projectRoot string) error { - timeoutContext, cancel := context.WithTimeout(ctx, launchReadinessTimeout) +func waitForToolReadiness(ctx context.Context, projectRoot string) error { + // Why: dispatcher launch returns directly to the user; probing a real tool request + // prevents the first command after launch from paying the cold project IPC path. + timeoutContext, cancel := context.WithTimeout(ctx, toolReadinessTimeout) defer cancel() - ticker := time.NewTicker(launchReadinessPoll) + ticker := time.NewTicker(toolReadinessPoll) defer ticker.Stop() for { - if err := probeLaunchReady(timeoutContext, projectRoot); err == nil { + if err := probeToolReadinessSequence(timeoutContext, projectRoot); err == nil { return nil } @@ -54,14 +58,24 @@ func waitForLaunchReady(ctx context.Context, projectRoot string) error { if ctx.Err() != nil { return ctx.Err() } - return fmt.Errorf("timed out waiting for Unity to become ready after launch") + return fmt.Errorf("timed out waiting for Unity tool readiness") case <-ticker.C: } } } -func probeLaunchReady(ctx context.Context, projectRoot string) error { - probeContext, cancel := context.WithTimeout(ctx, launchProbeTimeout) +func probeToolReadinessSequence(ctx context.Context, projectRoot string) error { + for probeIndex := 0; probeIndex < toolReadinessProbeCount; probeIndex++ { + if err := probeToolReadiness(ctx, projectRoot); err != nil { + return err + } + } + + return nil +} + +func probeToolReadiness(ctx context.Context, projectRoot string) error { + probeContext, cancel := context.WithTimeout(ctx, toolReadinessProbeTimeout) defer cancel() connection, err := project.ResolveConnection(projectRoot, projectRoot) @@ -70,49 +84,53 @@ func probeLaunchReady(ctx context.Context, projectRoot string) error { } if isExecuteDynamicCodeAvailable(projectRoot) { - return probeLaunchDynamicCode(probeContext, connection) + return probeExecuteDynamicCodeReadiness(probeContext, connection) } - return probeLaunchVersion(probeContext, connection) + return probeVersionReadiness(probeContext, connection) } -func probeLaunchVersion(ctx context.Context, connection domain.Connection) error { - response, err := sendLaunchReadyRequest(ctx, connection, "get-version", map[string]any{}) +func probeVersionReadiness(ctx context.Context, connection domain.Connection) error { + response, err := sendToolReadinessRequest(ctx, connection, "get-version", map[string]any{}) if err != nil { return err } if len(response.Result) == 0 { - return fmt.Errorf("launch readiness probe returned no result") + return fmt.Errorf("tool readiness probe returned no result") } return nil } -func probeLaunchDynamicCode(ctx context.Context, connection domain.Connection) error { - response, err := sendLaunchReadyRequest(ctx, connection, "execute-dynamic-code", map[string]any{ - "Code": launchDynamicCodeProbe, - "CompileOnly": false, - "YieldToForegroundRequests": true, - }) +func probeExecuteDynamicCodeReadiness(ctx context.Context, connection domain.Connection) error { + response, err := sendToolReadinessRequest(ctx, connection, "execute-dynamic-code", executeDynamicCodeReadinessProbeParams()) if err != nil { return err } - var payload launchDynamicCodeResponse + var payload executeDynamicCodeReadinessResponse if err := json.Unmarshal(response.Result, &payload); err != nil { return err } if !payload.Success { if payload.ErrorMessage != "" { - return fmt.Errorf("execute-dynamic-code launch readiness probe failed: %s", payload.ErrorMessage) + return fmt.Errorf("execute-dynamic-code readiness probe failed: %s", payload.ErrorMessage) } - return fmt.Errorf("execute-dynamic-code launch readiness probe failed") + return fmt.Errorf("execute-dynamic-code readiness probe failed") } return nil } -func sendLaunchReadyRequest(ctx context.Context, connection domain.Connection, method string, params map[string]any) (launchReadyResponse, error) { - conn, err := dialLaunchReadyEndpoint(ctx, connection.Endpoint) +func executeDynamicCodeReadinessProbeParams() map[string]any { + return map[string]any{ + "Code": executeDynamicCodeReadinessProbe, + "CompileOnly": false, + "YieldToForegroundRequests": false, + } +} + +func sendToolReadinessRequest(ctx context.Context, connection domain.Connection, method string, params map[string]any) (toolReadinessResponse, error) { + conn, err := dialToolReadinessEndpoint(ctx, connection.Endpoint) if err != nil { - return launchReadyResponse{}, err + return toolReadinessResponse{}, err } defer func() { _ = conn.Close() @@ -122,31 +140,30 @@ func sendLaunchReadyRequest(ctx context.Context, connection domain.Connection, m _ = conn.SetDeadline(deadline) } - payload, err := json.Marshal(launchReadyRequest{ + payload, err := json.Marshal(toolReadinessRequest{ JSONRPC: "2.0", Method: method, Params: params, ID: 1, - Uloop: connection.RequestMetadata, }) if err != nil { - return launchReadyResponse{}, err + return toolReadinessResponse{}, err } if err := framing.Write(conn, payload); err != nil { - return launchReadyResponse{}, err + return toolReadinessResponse{}, err } responsePayload, err := framing.Read(bufio.NewReader(conn)) if err != nil { - return launchReadyResponse{}, err + return toolReadinessResponse{}, err } - var response launchReadyResponse + var response toolReadinessResponse if err := json.Unmarshal(responsePayload, &response); err != nil { - return launchReadyResponse{}, err + return toolReadinessResponse{}, err } if response.Error != nil { - return launchReadyResponse{}, fmt.Errorf("launch readiness probe failed: %s", response.Error.Message) + return toolReadinessResponse{}, fmt.Errorf("tool readiness probe failed: %s", response.Error.Message) } return response, nil } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_unix.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_unix.go index 3b77b029c..f6be8ddfa 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_unix.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_unix.go @@ -9,7 +9,7 @@ import ( "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" ) -func dialLaunchReadyEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { +func dialToolReadinessEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { dialer := net.Dialer{} return dialer.DialContext(ctx, endpoint.Network, endpoint.Address) } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_windows.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_windows.go index 252afdd14..5cace603f 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_windows.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_dial_windows.go @@ -10,6 +10,6 @@ import ( "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/domain" ) -func dialLaunchReadyEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { +func dialToolReadinessEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { return winio.DialPipeContext(ctx, endpoint.Address) } diff --git a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_unix_test.go b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_unix_test.go index 44367d0ea..a80480e9c 100644 --- a/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_unix_test.go +++ b/Packages/src/Cli~/Dispatcher~/internal/dispatcher/readiness_unix_test.go @@ -17,7 +17,7 @@ import ( "github.com/hatayama/unity-cli-loop/Packages/src/Cli/Shared/adapters/project" ) -func TestWaitForLaunchReadyProbesUnityServer(t *testing.T) { +func TestWaitForToolReadinessProbesUnityServer(t *testing.T) { // Verifies that bootstrap launch waits for a real Unity RPC response before returning. projectRoot := t.TempDir() createUnityProject(t, projectRoot) @@ -28,16 +28,16 @@ func TestWaitForLaunchReadyProbesUnityServer(t *testing.T) { _ = os.Remove(endpointPath) }() served := make(chan error, 1) - go serveLaunchReadyProbe(listener, "get-version", map[string]any{"version": "test"}, served) + go serveToolReadinessProbes(listener, "get-version", map[string]any{"version": "test"}, toolReadinessProbeCount, served) - if err := waitForLaunchReady(context.Background(), projectRoot); err != nil { - t.Fatalf("waitForLaunchReady failed: %v", err) + if err := waitForToolReadiness(context.Background(), projectRoot); err != nil { + t.Fatalf("waitForToolReadiness failed: %v", err) } - assertLaunchReadyProbeServed(t, served) + assertToolReadinessProbeServed(t, served) } -func TestWaitForLaunchReadyUsesVersionProbeWhenToolCacheIsMissing(t *testing.T) { +func TestWaitForToolReadinessUsesVersionProbeWhenToolCacheIsMissing(t *testing.T) { // Verifies that unknown core capabilities fall back to the always-supported version probe. projectRoot := t.TempDir() createUnityProject(t, projectRoot) @@ -47,17 +47,17 @@ func TestWaitForLaunchReadyUsesVersionProbeWhenToolCacheIsMissing(t *testing.T) _ = os.Remove(endpointPath) }() served := make(chan error, 1) - go serveLaunchReadyProbe(listener, "get-version", map[string]any{"version": "test"}, served) + go serveToolReadinessProbes(listener, "get-version", map[string]any{"version": "test"}, toolReadinessProbeCount, served) - if err := waitForLaunchReady(context.Background(), projectRoot); err != nil { - t.Fatalf("waitForLaunchReady failed: %v", err) + if err := waitForToolReadiness(context.Background(), projectRoot); err != nil { + t.Fatalf("waitForToolReadiness failed: %v", err) } - assertLaunchReadyProbeServed(t, served) + assertToolReadinessProbeServed(t, served) } -func TestWaitForLaunchReadyUsesDynamicCodeProbeWhenToolExists(t *testing.T) { - // Verifies that first-run launch mirrors core launch readiness when dynamic code is available. +func TestWaitForToolReadinessUsesDynamicCodeProbeWhenToolExists(t *testing.T) { + // Verifies that first-run launch mirrors core tool readiness when dynamic code is available. projectRoot := t.TempDir() createUnityProject(t, projectRoot) createDynamicCodeToolCache(t, projectRoot) @@ -67,13 +67,22 @@ func TestWaitForLaunchReadyUsesDynamicCodeProbeWhenToolExists(t *testing.T) { _ = os.Remove(endpointPath) }() served := make(chan error, 1) - go serveLaunchReadyProbe(listener, "execute-dynamic-code", map[string]any{"Success": true}, served) + go serveToolReadinessProbes(listener, "execute-dynamic-code", map[string]any{"Success": true}, toolReadinessProbeCount, served) - if err := waitForLaunchReady(context.Background(), projectRoot); err != nil { - t.Fatalf("waitForLaunchReady failed: %v", err) + if err := waitForToolReadiness(context.Background(), projectRoot); err != nil { + t.Fatalf("waitForToolReadiness failed: %v", err) } - assertLaunchReadyProbeServed(t, served) + assertToolReadinessProbeServed(t, served) +} + +// Verifies that readiness probes exercise the same foreground warmup path as user executions. +func TestExecuteDynamicCodeReadinessProbeParamsUseForegroundWarmup(t *testing.T) { + params := executeDynamicCodeReadinessProbeParams() + + if params["YieldToForegroundRequests"] != false { + t.Fatalf("readiness probe should use foreground warmup: %#v", params["YieldToForegroundRequests"]) + } } func listenOnProjectEndpoint(t *testing.T, projectRoot string) (net.Listener, string) { @@ -93,11 +102,27 @@ func listenOnProjectEndpoint(t *testing.T, projectRoot string) (net.Listener, st return listener, connection.Endpoint.Address } -func serveLaunchReadyProbe(listener net.Listener, expectedMethod string, result map[string]any, served chan<- error) { +func serveToolReadinessProbes( + listener net.Listener, + expectedMethod string, + result map[string]any, + probeCount int, + served chan<- error, +) { + for probeIndex := 0; probeIndex < probeCount; probeIndex++ { + if err := serveToolReadinessProbe(listener, expectedMethod, result); err != nil { + served <- err + return + } + } + + served <- nil +} + +func serveToolReadinessProbe(listener net.Listener, expectedMethod string, result map[string]any) error { conn, err := listener.Accept() if err != nil { - served <- err - return + return err } defer func() { _ = conn.Close() @@ -105,19 +130,16 @@ func serveLaunchReadyProbe(listener net.Listener, expectedMethod string, result requestPayload, err := framing.Read(bufio.NewReader(conn)) if err != nil { - served <- err - return + return err } var request struct { Method string `json:"method"` } if err := json.Unmarshal(requestPayload, &request); err != nil { - served <- err - return + return err } if request.Method != expectedMethod { - served <- fmt.Errorf("method mismatch: %s", request.Method) - return + return fmt.Errorf("method mismatch: %s", request.Method) } response := map[string]any{ @@ -127,13 +149,12 @@ func serveLaunchReadyProbe(listener net.Listener, expectedMethod string, result } payload, err := json.Marshal(response) if err != nil { - served <- err - return + return err } - served <- framing.Write(conn, payload) + return framing.Write(conn, payload) } -func assertLaunchReadyProbeServed(t *testing.T, served <-chan error) { +func assertToolReadinessProbeServed(t *testing.T, served <-chan error) { t.Helper() select { diff --git a/Packages/src/Cli~/Shared~/adapters/project/project.go b/Packages/src/Cli~/Shared~/adapters/project/project.go index 4956b1f01..14912e073 100644 --- a/Packages/src/Cli~/Shared~/adapters/project/project.go +++ b/Packages/src/Cli~/Shared~/adapters/project/project.go @@ -44,9 +44,8 @@ func ResolveConnection(startPath string, explicitProjectPath string) (domain.Con canonicalProjectRoot = trimTrailingSeparators(canonicalProjectRoot) return domain.Connection{ - Endpoint: CreateEndpoint(canonicalProjectRoot), - ProjectRoot: canonicalProjectRoot, - RequestMetadata: createRequestMetadata(canonicalProjectRoot), + Endpoint: CreateEndpoint(canonicalProjectRoot), + ProjectRoot: canonicalProjectRoot, }, nil } @@ -184,12 +183,6 @@ func resolveProjectRoot(startPath string, explicitProjectPath string) (string, e return projectRoot, nil } -func createRequestMetadata(projectRoot string) *domain.RequestMetadata { - return &domain.RequestMetadata{ - ExpectedProjectRoot: projectRoot, - } -} - func createEndpointName(canonicalProjectRoot string) string { sum := sha256.Sum256([]byte(canonicalProjectRoot)) hash := hex.EncodeToString(sum[:])[:ipcHashLength] diff --git a/Packages/src/Cli~/Shared~/adapters/project/project_test.go b/Packages/src/Cli~/Shared~/adapters/project/project_test.go index 4449dcd15..88ddc8d90 100644 --- a/Packages/src/Cli~/Shared~/adapters/project/project_test.go +++ b/Packages/src/Cli~/Shared~/adapters/project/project_test.go @@ -134,12 +134,6 @@ func assertProjectConnection(t *testing.T, connection domain.Connection, project if connection.ProjectRoot != canonicalProjectRoot { t.Fatalf("project root mismatch: %s", connection.ProjectRoot) } - if connection.RequestMetadata == nil { - t.Fatal("request metadata should be present") - } - if connection.RequestMetadata.ExpectedProjectRoot != canonicalProjectRoot { - t.Fatalf("expected project root mismatch: %s", connection.RequestMetadata.ExpectedProjectRoot) - } if connection.Endpoint != CreateEndpoint(canonicalProjectRoot) { t.Fatalf("endpoint mismatch: %#v", connection.Endpoint) } diff --git a/Packages/src/Cli~/Shared~/domain/project.go b/Packages/src/Cli~/Shared~/domain/project.go index a94e016d2..e472d33a3 100644 --- a/Packages/src/Cli~/Shared~/domain/project.go +++ b/Packages/src/Cli~/Shared~/domain/project.go @@ -5,12 +5,7 @@ type Endpoint struct { Address string } -type RequestMetadata struct { - ExpectedProjectRoot string `json:"expectedProjectRoot"` -} - type Connection struct { - Endpoint Endpoint - ProjectRoot string - RequestMetadata *RequestMetadata + Endpoint Endpoint + ProjectRoot string } diff --git a/Packages/src/Cli~/Shared~/domain/unity.go b/Packages/src/Cli~/Shared~/domain/unity.go index 8ab8031a0..f2f28ef96 100644 --- a/Packages/src/Cli~/Shared~/domain/unity.go +++ b/Packages/src/Cli~/Shared~/domain/unity.go @@ -1,8 +1,20 @@ package domain -import "encoding/json" +import ( + "encoding/json" + "time" +) type UnitySendOutcome struct { Result json.RawMessage RequestDispatched bool + Timing UnitySendTiming +} + +type UnitySendTiming struct { + Total time.Duration + Dial time.Duration + Write time.Duration + Read time.Duration + Decode time.Duration } diff --git a/Packages/src/Editor/Application/Api/Tools/Core/UnityCliLoopToolRegistry.cs b/Packages/src/Editor/Application/Api/Tools/Core/UnityCliLoopToolRegistry.cs index cc0b62cdf..9e4374bfb 100644 --- a/Packages/src/Editor/Application/Api/Tools/Core/UnityCliLoopToolRegistry.cs +++ b/Packages/src/Editor/Application/Api/Tools/Core/UnityCliLoopToolRegistry.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -148,20 +147,14 @@ public async Task ExecuteToolAsync(string toolName, JT throw new UnityCliLoopSecurityException(toolName, "Tool is blocked by security settings"); } - Stopwatch mainThreadHopStopwatch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(); - mainThreadHopStopwatch.Stop(); - Stopwatch toolBodyStopwatch = Stopwatch.StartNew(); UnityCliLoopToolResponse response = await tool.ExecuteAsync(paramsToken); - toolBodyStopwatch.Stop(); if (response == null) { throw new InvalidOperationException($"Tool returned null response: {toolName}"); } - response.SetVersion(UnityCliLoopVersion.VERSION); - return response; } diff --git a/Packages/src/Editor/Application/Server/UnityCliLoopServerApplicationService.cs b/Packages/src/Editor/Application/Server/UnityCliLoopServerApplicationService.cs index fa0014f48..a6452f1bb 100644 --- a/Packages/src/Editor/Application/Server/UnityCliLoopServerApplicationService.cs +++ b/Packages/src/Editor/Application/Server/UnityCliLoopServerApplicationService.cs @@ -54,6 +54,10 @@ public interface IUnityCliLoopServerController void AddServerStateChangedHandler(Action handler); void RemoveServerStateChangedHandler(Action handler); + + void AddServerStartedHandler(Action handler); + + void RemoveServerStartedHandler(Action handler); } /// @@ -243,6 +247,16 @@ public void RemoveServerStateChangedHandler(Action handler) { _controller.RemoveServerStateChangedHandler(handler); } + + public void AddServerStartedHandler(Action handler) + { + _controller.AddServerStartedHandler(handler); + } + + public void RemoveServerStartedHandler(Action handler) + { + _controller.RemoveServerStartedHandler(handler); + } } /// @@ -280,6 +294,16 @@ public static void RemoveServerStateChangedHandler(Action handler) GetService().RemoveServerStateChangedHandler(handler); } + public static void AddServerStartedHandler(Action handler) + { + GetService().AddServerStartedHandler(handler); + } + + public static void RemoveServerStartedHandler(Action handler) + { + GetService().RemoveServerStartedHandler(handler); + } + public static bool IsServerRunning => GetService().IsServerRunning; public static Task RecoveryTask => GetService().RecoveryTask; diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs index cfab2fe77..cd673112c 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs @@ -1,5 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; + using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace io.github.hatayama.UnityCliLoop.CompositionRoot { @@ -8,14 +15,48 @@ namespace io.github.hatayama.UnityCliLoop.CompositionRoot /// internal sealed class UnityCliLoopFirstPartyServerLifecycleBinding { + private readonly ProjectIpcWarmupClient _projectIpcWarmupClient = new(); + internal void Initialize() { - UnityCliLoopServerApplicationFacade.AddServerStateChangedHandler(OnServerStateChanged); + UnityCliLoopServerApplicationFacade.AddServerStartedHandler(OnServerStarted); + } + + private void OnServerStarted() + { + ResetServerScopedServicesAndWarmProjectIpcAsync(CancellationToken.None).Forget(); } - private void OnServerStateChanged() + private async Task ResetServerScopedServicesAndWarmProjectIpcAsync(CancellationToken ct) { FirstPartyToolsEditorStartup.ResetServerScopedServices(); + string requestJson = CreateExecuteDynamicCodeReadinessRequestJson( + FirstPartyToolsEditorStartup.CreateExecuteDynamicCodeReadinessProbeCode()); + + // Why: after server recovery, the next external CLI request otherwise pays the cold + // project IPC and editor-thread wakeup cost. The composition root owns this transport + // readiness work so execute-dynamic-code stays focused on executing user code. + await _projectIpcWarmupClient.SendProjectIpcRequestAsync( + UnityEngine.Application.dataPath + "/..", + requestJson, + ct); + } + + private static string CreateExecuteDynamicCodeReadinessRequestJson(string code) + { + JObject request = new() + { + ["jsonrpc"] = "2.0", + ["method"] = "execute-dynamic-code", + ["id"] = 1, + ["params"] = new JObject + { + ["Code"] = code, + ["CompileOnly"] = false, + ["YieldToForegroundRequests"] = false + } + }; + return request.ToString(Formatting.None); } } } diff --git a/Packages/src/Editor/Domain/ProjectRootIdentityValidator.cs b/Packages/src/Editor/Domain/ProjectRootCanonicalizer.cs similarity index 72% rename from Packages/src/Editor/Domain/ProjectRootIdentityValidator.cs rename to Packages/src/Editor/Domain/ProjectRootCanonicalizer.cs index c6d111e4e..1f393e5d1 100644 --- a/Packages/src/Editor/Domain/ProjectRootIdentityValidator.cs +++ b/Packages/src/Editor/Domain/ProjectRootCanonicalizer.cs @@ -6,60 +6,6 @@ namespace io.github.hatayama.UnityCliLoop.Domain { - /// - /// Carries the result data produced by Project Root Identity Validation behavior. - /// - public sealed class ProjectRootIdentityValidationResult - { - public bool IsValid { get; } - - public string ErrorMessage { get; } - - private ProjectRootIdentityValidationResult(bool isValid, string errorMessage) - { - IsValid = isValid; - ErrorMessage = errorMessage; - } - - public static ProjectRootIdentityValidationResult Success() - { - return new ProjectRootIdentityValidationResult(true, null); - } - - public static ProjectRootIdentityValidationResult Failure(string errorMessage) - { - return new ProjectRootIdentityValidationResult(false, errorMessage); - } - } - - /// - /// Validates Project Root Identity data before the owning workflow continues. - /// - public static class ProjectRootIdentityValidator - { - public static ProjectRootIdentityValidationResult Validate( - string expectedProjectRoot, - string actualProjectRoot) - { - if (string.IsNullOrWhiteSpace(expectedProjectRoot)) - { - return ProjectRootIdentityValidationResult.Failure("Invalid x-uloop metadata: expectedProjectRoot is required."); - } - - if (string.IsNullOrWhiteSpace(actualProjectRoot)) - { - return ProjectRootIdentityValidationResult.Failure("Fast project validation is unavailable. Restart Unity CLI Loop and retry."); - } - - if (!string.Equals(expectedProjectRoot, actualProjectRoot, StringComparison.Ordinal)) - { - return ProjectRootIdentityValidationResult.Failure("Connected Unity instance belongs to a different project."); - } - - return ProjectRootIdentityValidationResult.Success(); - } - } - /// /// Provides Project Root Canonicalizer behavior for Unity CLI Loop. /// diff --git a/Packages/src/Editor/Domain/ProjectRootIdentityValidator.cs.meta b/Packages/src/Editor/Domain/ProjectRootCanonicalizer.cs.meta similarity index 100% rename from Packages/src/Editor/Domain/ProjectRootIdentityValidator.cs.meta rename to Packages/src/Editor/Domain/ProjectRootCanonicalizer.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs index a691a48ad..a04d5c9dc 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs @@ -18,7 +18,7 @@ public class CompileSchema : UnityCliLoopToolSchema /// /// Whether to wait for domain reload completion before the caller returns. /// - public bool WaitForDomainReload { get; set; } = false; + public bool WaitForDomainReload { get; set; } = true; /// /// Internal request identifier used for delayed result recovery across domain reload. diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index d60ee794a..ecd0f6e15 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--wait-for-domain-reload] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] ``` ## Parameters @@ -19,7 +19,7 @@ uloop compile [--force-recompile] [--wait-for-domain-reload] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `--force-recompile` | boolean | `false` | Force full recompilation (triggers Domain Reload) | -| `--wait-for-domain-reload` | boolean | `false` | Wait until Domain Reload completes before returning | +| `--no-wait-for-domain-reload` | boolean | `false` | Return before Domain Reload completion | ## Global Options @@ -33,14 +33,11 @@ uloop compile [--force-recompile] [--wait-for-domain-reload] # Check compilation uloop compile -# Force full recompilation +# Force full recompilation and wait for Domain Reload completion uloop compile --force-recompile -# Force recompilation and wait for Domain Reload completion -uloop compile --force-recompile --wait-for-domain-reload - -# Wait for Domain Reload completion even without force recompilation -uloop compile --wait-for-domain-reload +# Start compilation without waiting for Domain Reload completion +uloop compile --no-wait-for-domain-reload ``` ## Output diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs index 1225d537b..b604ebac1 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.FirstPartyTools.Factory; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -10,11 +11,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class DynamicCodeServicesRegistry { - private const int StartupPrewarmDelayFrameCount = 1; private readonly object _serverScopedServicesLock = new(); private Task _serverScopedDrainTask = Task.CompletedTask; private IDynamicCodeExecutionRuntime _runtimeFacade; - private DynamicCodeStartupPrewarmer _startupPrewarmer; private readonly Lazy _sourcePreparationServiceValue; @@ -44,11 +43,6 @@ internal IExecuteDynamicCodeUseCase GetExecuteDynamicCodeUseCase() return new ExecuteDynamicCodeUseCase(runtimeFacade); } - internal void RequestStartupPrewarm() - { - GetStartupPrewarmer().Request(); - } - internal void ResetServerScopedServices() { IDynamicCodeExecutionRuntime runtimeFacade; @@ -57,7 +51,6 @@ internal void ResetServerScopedServices() { runtimeFacade = _runtimeFacade; _runtimeFacade = null; - _startupPrewarmer = null; _serverScopedDrainTask = ChainDrainTask( _serverScopedDrainTask, ShutdownRuntimeAsync(runtimeFacade)); @@ -78,21 +71,6 @@ private IDynamicCodeExecutionRuntime GetRuntimeFacade() } } - private DynamicCodeStartupPrewarmer GetStartupPrewarmer() - { - lock (_serverScopedServicesLock) - { - if (_startupPrewarmer == null) - { - _startupPrewarmer = new DynamicCodeStartupPrewarmer( - GetRuntimeFacade(), - StartupPrewarmDelayFrameCount); - } - - return _startupPrewarmer; - } - } - private static Task ShutdownRuntimeAsync(IDynamicCodeExecutionRuntime runtimeFacade) { SharedRoslynCompilerWorkerHost.ShutdownForServerReset(); @@ -137,7 +115,7 @@ private static Task ObserveDrainTask(Task drainTask) return drainTask.ContinueWith( task => LogDrainFailure(task), - System.Threading.CancellationToken.None, + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } @@ -199,11 +177,6 @@ internal static IExecuteDynamicCodeUseCase GetExecuteDynamicCodeUseCase() return GetRegistry().GetExecuteDynamicCodeUseCase(); } - internal static void RequestStartupPrewarm() - { - GetRegistry().RequestStartupPrewarm(); - } - internal static void ResetServerScopedServices() { GetRegistry().ResetServerScopedServices(); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs index 176ff1892..a2570c750 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs @@ -12,13 +12,11 @@ internal static void Initialize() AssemblyTypeIndex.InvalidateForEditorStartup(); DynamicReferenceSetBuilder.InvalidateReferenceCacheForEditorStartup(); SharedRoslynCompilerWorkerHost.RegisterLifecycleForEditorStartup(); - DynamicCodeServices.RequestStartupPrewarm(); } internal static void ResetServerScopedServices() { DynamicCodeServices.ResetServerScopedServices(); - DynamicCodeServices.RequestStartupPrewarm(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs index 76eb98a78..775e6967c 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs @@ -9,10 +9,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// Related classes: ExecuteDynamicCodeTool, ExecuteDynamicCodeSchema /// - public class ExecuteDynamicCodeResponse : UnityCliLoopToolResponse + public class ExecuteDynamicCodeResponse : UnityCliLoopToolResponse, IUnityCliLoopTimingResponse { - private const bool EmitTimingsInJsonResponses = false; - /// Execution success flag public bool Success { get; set; } @@ -60,11 +58,30 @@ public string Error /// public List Timings { get; set; } = new(); + public bool EmitTimingsInJsonResponse { get; set; } = false; + + bool IUnityCliLoopTimingResponse.EmitsTimingsInJsonResponse => EmitTimingsInJsonResponse; + + public void AddTiming(string timing) + { + if (Timings == null) + { + Timings = new List(); + } + + Timings.Add(timing); + } + // Keep timings available in memory for diagnostics and readiness decisions // while avoiding noisy default payloads for normal execute-dynamic-code users. public bool ShouldSerializeTimings() { - return EmitTimingsInJsonResponses; + return EmitTimingsInJsonResponse && Timings != null && Timings.Count > 0; + } + + public bool ShouldSerializeEmitTimingsInJsonResponse() + { + return false; } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs index f5297e5c7..7e23ed9b4 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -20,5 +21,8 @@ public class ExecuteDynamicCodeSchema : UnityCliLoopToolSchema public bool CompileOnly { get; set; } = false; public bool YieldToForegroundRequests { get; set; } = false; + + [Browsable(false)] + public bool IncludeTimings { get; set; } = false; } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs index 20605c32d..ac3bd0fc0 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs @@ -15,8 +15,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class ExecuteDynamicCodeUseCase : IExecuteDynamicCodeUseCase { - private const string ForegroundWarmupCode = - "using UnityEngine; LogType previous = Debug.unityLogger.filterLogType; Debug.unityLogger.filterLogType = LogType.Warning; try { Debug.Log(\"Unity CLI Loop dynamic code prewarm\"); return \"Unity CLI Loop dynamic code prewarm\"; } finally { Debug.unityLogger.filterLogType = previous; }"; private readonly IDynamicCodeExecutionRuntime _runtime; private readonly DynamicCodeFriendlyErrorConverter _friendlyErrorConverter; @@ -38,7 +36,6 @@ public async Task ExecuteAsync( editorLevel = FirstPartyDynamicCodeSettings.GetDynamicCodeSecurityLevel(); object[] parametersArray = ConvertParameters(parameters.Parameters); string originalCode = parameters.Code ?? string.Empty; - bool shouldWarmForegroundExecutionPath = ShouldWarmForegroundExecutionPath(parameters); LogExecutionStart(parameters, editorLevel, correlationId); @@ -60,9 +57,9 @@ public async Task ExecuteAsync( parameters.YieldToForegroundRequests, cancellationToken); - if (shouldWarmForegroundExecutionPath && finalResult.Success) + if (ShouldMarkExecutionPathWarm(parameters, finalResult)) { - DynamicCodeForegroundWarmupState.MarkCompletedByForegroundExecution(); + DynamicCodeForegroundWarmupState.MarkCompletedBySuccessfulExecution(); } if (IsCancelledResult(finalResult)) @@ -72,6 +69,7 @@ public async Task ExecuteAsync( cancelledResponse.Timings = finalResult.Timings != null ? new List(finalResult.Timings) : cancelledResponse.Timings; + cancelledResponse.EmitTimingsInJsonResponse = parameters.IncludeTimings; return cancelledResponse; } @@ -79,16 +77,21 @@ public async Task ExecuteAsync( finalResult, originalCode); response.SecurityLevel = editorLevel.ToString(); + response.EmitTimingsInJsonResponse = parameters.IncludeTimings; return response; } catch (OperationCanceledException) { - return CreateCancelledResponse(editorLevel); + ExecuteDynamicCodeResponse response = CreateCancelledResponse(editorLevel); + response.EmitTimingsInJsonResponse = parameters?.IncludeTimings ?? false; + return response; } catch (Exception ex) { LogExecutionException(ex, correlationId); - return CreateExceptionResponse(ex, editorLevel); + ExecuteDynamicCodeResponse response = CreateExceptionResponse(ex, editorLevel); + response.EmitTimingsInJsonResponse = parameters?.IncludeTimings ?? false; + return response; } } @@ -277,14 +280,9 @@ private async Task WarmForegroundExecutionPathIfNeededAsync( bool completed = false; try { - DynamicCodeExecutionRequest warmupRequest = CreateExecutionRequest( - ForegroundWarmupCode, - null, - compileOnly: false, + completed = await ExecuteForegroundWarmupSequenceAsync( securityLevel, - yieldToForegroundRequests: false); - ExecutionResult warmupResult = await ExecuteRequestAsync(warmupRequest, cancellationToken); - completed = warmupResult.Success; + cancellationToken); if (completed) { DynamicCodeForegroundWarmupState.MarkCompleted(); @@ -299,6 +297,17 @@ private async Task WarmForegroundExecutionPathIfNeededAsync( } } + private async Task ExecuteForegroundWarmupSequenceAsync( + DynamicCodeSecurityLevel securityLevel, + CancellationToken ct) + { + return await DynamicCodeForegroundWarmupRunner.RunForegroundSequenceAsync( + _runtime, + securityLevel, + yieldToForegroundRequests: false, + ct); + } + private static bool ShouldWarmForegroundExecutionPath(ExecuteDynamicCodeSchema parameters) { if (parameters == null) @@ -314,6 +323,16 @@ private static bool ShouldWarmForegroundExecutionPath(ExecuteDynamicCodeSchema p return !parameters.CompileOnly && !parameters.YieldToForegroundRequests; } + private static bool ShouldMarkExecutionPathWarm( + ExecuteDynamicCodeSchema parameters, + ExecutionResult executionResult) + { + return parameters != null + && !parameters.CompileOnly + && executionResult != null + && executionResult.Success; + } + private static bool IsCancelledResult(ExecutionResult executionResult) { return executionResult != null diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeExecutionFacade.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeExecutionFacade.cs index 5e6a3b9bb..8c76f8261 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeExecutionFacade.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeExecutionFacade.cs @@ -30,10 +30,14 @@ public async Task ExecuteAsync( Debug.Assert(request != null, "request must not be null"); Debug.Assert(!string.IsNullOrWhiteSpace(request.Code), "request.Code must not be empty"); - return await _executionScheduler.RunForegroundAsync( + Stopwatch schedulerStopwatch = Stopwatch.StartNew(); + ExecutionResult result = await _executionScheduler.RunForegroundAsync( innerCancellationToken => ExecuteCoreAsync(request, innerCancellationToken), CreateExecutionInProgressResult, cancellationToken); + schedulerStopwatch.Stop(); + AppendTiming(result, $"[Perf] SchedulerTotal: {schedulerStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + return result; } public async Task<(bool Entered, ExecutionResult Result)> TryExecuteIfIdleAsync( @@ -43,10 +47,14 @@ public async Task ExecuteAsync( Debug.Assert(request != null, "request must not be null"); Debug.Assert(!string.IsNullOrWhiteSpace(request.Code), "request.Code must not be empty"); - return await _executionScheduler.TryRunIfIdleAsync( + Stopwatch schedulerStopwatch = Stopwatch.StartNew(); + (bool entered, ExecutionResult result) = await _executionScheduler.TryRunIfIdleAsync( request.YieldToForegroundRequests, innerCancellationToken => ExecuteCoreAsync(request, innerCancellationToken), cancellationToken); + schedulerStopwatch.Stop(); + AppendTiming(result, $"[Perf] SchedulerTotal: {schedulerStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + return (entered, result); } private async Task ExecuteCoreAsync( @@ -76,6 +84,21 @@ private async Task ExecuteCoreAsync( return result; } + private static void AppendTiming(ExecutionResult result, string timing) + { + if (result == null) + { + return; + } + + if (result.Timings == null) + { + result.Timings = new System.Collections.Generic.List(); + } + + result.Timings.Add(timing); + } + private static ExecutionResult CreateExecutionInProgressResult() { return new ExecutionResult diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs new file mode 100644 index 000000000..81b09e023 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs @@ -0,0 +1,78 @@ +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + // Keeps every foreground warmup entrypoint on the same snippets and request shape. + internal static class DynamicCodeForegroundWarmupRunner + { + internal static async Task RunForegroundSequenceAsync( + IDynamicCodeExecutionRuntime runtime, + DynamicCodeSecurityLevel securityLevel, + bool yieldToForegroundRequests, + CancellationToken ct) + { + System.Diagnostics.Debug.Assert(runtime != null, "runtime must not be null"); + + // Why: foreground fallback and transport readiness must compile the same source shapes; + // otherwise one path can report warm while the user's first return-string shape is still cold. + foreach (string warmupCode in ExecuteDynamicCodeReadinessProbe.CreateReturnStringProbeCodes()) + { + DynamicCodeExecutionRequest request = CreateRequest( + warmupCode, + securityLevel, + yieldToForegroundRequests); + ExecutionResult result = await runtime.ExecuteAsync(request, ct); + if (!result.Success) + { + return false; + } + } + + return true; + } + + internal static async Task TryRunBackgroundSequenceAsync( + IDynamicCodeExecutionRuntime runtime, + DynamicCodeSecurityLevel securityLevel, + bool yieldToForegroundRequests, + CancellationToken ct) + { + System.Diagnostics.Debug.Assert(runtime != null, "runtime must not be null"); + + // Why: background probes must match the foreground sequence so whichever path succeeds + // first marks the same execution shape as ready. + foreach (string warmupCode in ExecuteDynamicCodeReadinessProbe.CreateReturnStringProbeCodes()) + { + DynamicCodeExecutionRequest request = CreateRequest( + warmupCode, + securityLevel, + yieldToForegroundRequests); + (bool entered, ExecutionResult result) = await runtime.TryExecuteIfIdleAsync(request, ct); + if (!entered || !result.Success) + { + return false; + } + } + + return true; + } + + private static DynamicCodeExecutionRequest CreateRequest( + string code, + DynamicCodeSecurityLevel securityLevel, + bool yieldToForegroundRequests) + { + return new DynamicCodeExecutionRequest + { + Code = code, + ClassName = DynamicCodeConstants.DEFAULT_CLASS_NAME, + CompileOnly = false, + SecurityLevel = securityLevel, + YieldToForegroundRequests = yieldToForegroundRequests + }; + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs.meta similarity index 83% rename from Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs.meta rename to Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs.meta index f99faab6b..3cceb9f5b 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeStartupPrewarmerTests.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupRunner.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a8b9fff9cd5b84aaf97328bd70554a61 +guid: 2f8ff8d33762a41a6951c97b74288592 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs new file mode 100644 index 000000000..3dba8371b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Keeps hidden warmup aligned with the literal-hoisted return-string shapes users commonly execute first. + /// + internal static class DynamicCodeForegroundWarmupSnippets + { + internal static readonly string[] ReturnStringShapes = + { + "return \"Unity CLI Loop dynamic code prewarm\";", + "return\n \"Unity CLI Loop dynamic code prewarm\";" + }; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs.meta similarity index 83% rename from Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs.meta rename to Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs.meta index ef0fb6562..69c1d5cbc 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupSnippets.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d36eb248e237e4626963e331d1ea339a +guid: 49c607487938d4a3b8ef19942bc96e9b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupState.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupState.cs index 0bbdfcfe0..43ee821cb 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupState.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupState.cs @@ -39,12 +39,12 @@ internal void MarkCompleted() } } - internal void MarkCompletedByForegroundExecution() + internal void MarkCompletedBySuccessfulExecution() { lock (_syncRoot) { - // Why: a real foreground execution succeeding after a transient hidden-warmup miss - // proves the user-visible path is already usable for the next request. + // Why: a successful runtime execution after startup or reload proves the next + // user-visible path is already usable, even when it came from tool readiness. // Why not insist on the hidden warmup succeeding first: that keeps Pending alive // after the exact success case we care about and injects another needless warmup. _status = ForegroundWarmupStatus.Completed; @@ -102,9 +102,9 @@ internal static void MarkCompleted() ServiceValue.MarkCompleted(); } - internal static void MarkCompletedByForegroundExecution() + internal static void MarkCompletedBySuccessfulExecution() { - ServiceValue.MarkCompletedByForegroundExecution(); + ServiceValue.MarkCompletedBySuccessfulExecution(); } internal static void ResetAfterIncompleteAttempt() diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs deleted file mode 100644 index 40d990cd2..000000000 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeStartupPrewarmer.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.FirstPartyTools -{ - /// - /// Warms the dynamic-code execution path after editor startup so the first user request avoids startup compiler cost. - /// - internal sealed class DynamicCodeStartupPrewarmer - { - private const string StartupPrewarmCode = - "using UnityEngine; LogType previous = Debug.unityLogger.filterLogType; Debug.unityLogger.filterLogType = LogType.Warning; try { Debug.Log(\"Unity CLI Loop dynamic code prewarm\"); return \"Unity CLI Loop dynamic code prewarm\"; } finally { Debug.unityLogger.filterLogType = previous; }"; - - private readonly object _syncRoot = new(); - private readonly IDynamicCodeExecutionRuntime _runtime; - private readonly int _delayFrameCount; - private Task _prewarmTask; - private bool _requested; - - internal DynamicCodeStartupPrewarmer( - IDynamicCodeExecutionRuntime runtime, - int delayFrameCount) - { - System.Diagnostics.Debug.Assert(runtime != null, "runtime must not be null"); - System.Diagnostics.Debug.Assert(delayFrameCount >= 0, "delayFrameCount must not be negative"); - - _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _delayFrameCount = delayFrameCount; - } - - internal void Request() - { - RequestAsync(CancellationToken.None).Forget(); - } - - internal Task RequestAsync(CancellationToken ct) - { - lock (_syncRoot) - { - if (_requested) - { - return _prewarmTask ?? Task.CompletedTask; - } - - _requested = true; - _prewarmTask = RunAsync(ct); - return _prewarmTask; - } - } - - private async Task RunAsync(CancellationToken ct) - { - await EditorDelay.DelayFrame(_delayFrameCount, ct); - if (!DynamicCodeForegroundWarmupState.TryBegin()) - { - return; - } - - bool completed = false; - try - { - DynamicCodeExecutionRequest request = new() - { - Code = StartupPrewarmCode, - ClassName = DynamicCodeConstants.DEFAULT_CLASS_NAME, - CompileOnly = false, - SecurityLevel = FirstPartyDynamicCodeSettings.GetDynamicCodeSecurityLevel(), - YieldToForegroundRequests = true - }; - - (bool entered, ExecutionResult result) = await _runtime.TryExecuteIfIdleAsync(request, ct); - completed = entered && result.Success; - if (completed) - { - DynamicCodeForegroundWarmupState.MarkCompleted(); - } - } - finally - { - if (!completed) - { - DynamicCodeForegroundWarmupState.ResetAfterIncompleteAttempt(); - ResetRequestStateAfterIncompleteAttempt(); - } - } - } - - private void ResetRequestStateAfterIncompleteAttempt() - { - lock (_syncRoot) - { - _requested = false; - _prewarmTask = null; - } - } - } -} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs new file mode 100644 index 000000000..1649dafd7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs @@ -0,0 +1,20 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + // Why: transport readiness and foreground fallback must warm identical source shapes; + // otherwise one path can look ready while the user's first return-string execution is still cold. + public static class ExecuteDynamicCodeReadinessProbe + { + public static string CreatePrimaryReturnStringProbeCode() + { + return DynamicCodeForegroundWarmupSnippets.ReturnStringShapes[0]; + } + + public static string[] CreateReturnStringProbeCodes() + { + string[] source = DynamicCodeForegroundWarmupSnippets.ReturnStringShapes; + string[] copy = new string[source.Length]; + System.Array.Copy(source, copy, source.Length); + return copy; + } + } +} diff --git a/Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs.meta similarity index 83% rename from Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs.meta rename to Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs.meta index da3f30e7e..ba2646b11 100644 --- a/Assets/Tests/Editor/JsonRpcRequestIdentityValidatorTests.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/ExecuteDynamicCodeReadinessProbe.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f3e18add6e50444528a4223fc5b7aa60 +guid: 094a9a53b69c040cf8acd82071f03d3c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index b32992d98..d613ee74a 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -23,5 +23,12 @@ public static void ResetServerScopedServices() { ExecuteDynamicCodeEditorStartup.ResetServerScopedServices(); } + + public static string CreateExecuteDynamicCodeReadinessProbeCode() + { + // Why: composition root can only depend on the bundled-tool facade assembly, + // so the dynamic-code assembly keeps ownership of the actual probe source shape. + return ExecuteDynamicCodeReadinessProbe.CreatePrimaryReturnStringProbeCode(); + } } } diff --git a/Packages/src/Editor/Infrastructure/Api/GetVersionBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/GetVersionBridgeCommand.cs index 38cf1bbc6..dd399daa7 100644 --- a/Packages/src/Editor/Infrastructure/Api/GetVersionBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/GetVersionBridgeCommand.cs @@ -9,15 +9,9 @@ internal static class GetVersionBridgeCommand { public static GetVersionResponse Execute() { - GetVersionResponse response = new() { - UnityVersion = UnityEngine.Application.unityVersion, - Platform = UnityEngine.Application.platform.ToString(), - DataPath = UnityEngine.Application.dataPath, - PersistentDataPath = UnityEngine.Application.persistentDataPath, - TemporaryCachePath = UnityEngine.Application.temporaryCachePath, - IsEditor = UnityEngine.Application.isEditor, - ProductName = UnityEngine.Application.productName, - CompanyName = UnityEngine.Application.companyName + GetVersionResponse response = new() + { + UnityVersion = UnityEngine.Application.unityVersion }; Debug.Assert(!string.IsNullOrWhiteSpace(response.UnityVersion), "Unity version must be available."); diff --git a/Packages/src/Editor/Infrastructure/Api/GetVersionResponse.cs b/Packages/src/Editor/Infrastructure/Api/GetVersionResponse.cs index e21985541..dbaa58e6c 100644 --- a/Packages/src/Editor/Infrastructure/Api/GetVersionResponse.cs +++ b/Packages/src/Editor/Infrastructure/Api/GetVersionResponse.cs @@ -8,12 +8,5 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure public class GetVersionResponse : UnityCliLoopToolResponse { public string UnityVersion { get; set; } = string.Empty; - public string Platform { get; set; } = string.Empty; - public string DataPath { get; set; } = string.Empty; - public string PersistentDataPath { get; set; } = string.Empty; - public string TemporaryCachePath { get; set; } = string.Empty; - public bool IsEditor { get; set; } - public string ProductName { get; set; } = string.Empty; - public string CompanyName { get; set; } = string.Empty; } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs index 7776e8fb6..4d7365a0d 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs @@ -1,9 +1,8 @@ using System; -using System.Diagnostics; -using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.Domain; @@ -11,19 +10,6 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { - /// - /// Current client context for JSON-RPC processing - /// - public class ClientExecutionContext - { - public string Endpoint { get; } - - public ClientExecutionContext(string endpoint) - { - Endpoint = endpoint; - } - } - /// /// Class specialized in handling JSON-RPC 2.0 processing /// @@ -34,46 +20,16 @@ public ClientExecutionContext(string endpoint) /// - Project IPC server: Receives JSON-RPC messages from CLI clients /// - MainThreadSwitcher: Ensures Unity API calls run on the main thread /// - JsonRpcRequest: Request model for JSON-RPC 2.0 protocol - /// - ClientExecutionContext: Thread-local context for tracking current client /// /// Processing flow: /// 1. Receives JSON message from the project IPC server /// 2. Parses and validates JSON-RPC 2.0 format - /// 3. Sets client context for the current thread - /// 4. Delegates to UnityApiHandler for command execution - /// 5. Formats response according to JSON-RPC 2.0 specification - /// 6. Returns JSON response to be sent back to client + /// 3. Delegates to UnityApiHandler for command execution + /// 4. Formats response according to JSON-RPC 2.0 specification + /// 5. Returns JSON response to be sent back to client /// public static class JsonRpcProcessor { - /// - /// Current client context for async operations - /// - private static readonly AsyncLocal _currentClientContext = new(); - - /// - /// Get current client context (ProcessID and Endpoint) - /// - public static ClientExecutionContext CurrentClientContext => _currentClientContext.Value; - - /// - /// Process JSON-RPC request and generate response with client context - /// - public static async Task ProcessRequest(string jsonRequest, string clientEndpoint) - { - var context = new ClientExecutionContext(clientEndpoint); - _currentClientContext.Value = context; - - try - { - return await ProcessRequest(jsonRequest); - } - finally - { - _currentClientContext.Value = null; - } - } - /// /// Process JSON-RPC request and generate response /// @@ -111,21 +67,7 @@ private static JsonRpcRequest ParseRequest(string jsonRequest) { Method = request["method"]?.ToString(), Params = request["params"], - Id = request["id"]?.ToObject(), - UloopMetadata = ParseUloopMetadata(request["x-uloop"]) - }; - } - - private static JsonRpcRequestUloopMetadata ParseUloopMetadata(JToken metadataToken) - { - if (metadataToken == null || metadataToken.Type == JTokenType.Null) - { - return null; - } - - return new JsonRpcRequestUloopMetadata - { - ExpectedProjectRoot = metadataToken["expectedProjectRoot"]?.ToString() + Id = request["id"]?.ToObject() }; } @@ -171,16 +113,24 @@ private static async Task ProcessRpcRequest(JsonRpcRequest request, stri { try { - ValidateClientIdentityIfNeeded(request); - - Stopwatch mainThreadWaitStopwatch = Stopwatch.StartNew(); + Stopwatch requestStopwatch = Stopwatch.StartNew(); + Stopwatch mainThreadSwitchStopwatch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(); - mainThreadWaitStopwatch.Stop(); + mainThreadSwitchStopwatch.Stop(); - Stopwatch toolStopwatch = Stopwatch.StartNew(); + Stopwatch executeMethodStopwatch = Stopwatch.StartNew(); UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params); - toolStopwatch.Stop(); - result.SetVersion(UnityCliLoopVersion.VERSION); + executeMethodStopwatch.Stop(); + + AppendTimingIfRequested( + result, + $"[Perf] RpcSwitchToMainThread: {mainThreadSwitchStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + AppendTimingIfRequested( + result, + $"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + AppendTimingIfRequested( + result, + $"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms"); string response = CreateSuccessResponse(request.Id, result); return response; @@ -202,17 +152,19 @@ private static async Task ProcessRpcRequest(JsonRpcRequest request, stri } } - private static void ValidateClientIdentityIfNeeded(JsonRpcRequest request) + private static void AppendTimingIfRequested(UnityCliLoopToolResponse result, string timing) { - JsonRpcRequestIdentityValidator.Validate( - request?.UloopMetadata, - GetCurrentProjectRoot()); - } + if (result is not IUnityCliLoopTimingResponse timingResponse) + { + return; + } - private static string GetCurrentProjectRoot() - { - string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); - return ProjectRootCanonicalizer.Canonicalize(projectRoot); + if (!timingResponse.EmitsTimingsInJsonResponse) + { + return; + } + + timingResponse.AddTiming(timing); } private static void LogUnityCliLoopToolParameterValidationException(UnityCliLoopToolParameterValidationException exception) diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequest.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequest.cs index 09696deb1..1f964c691 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequest.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequest.cs @@ -15,12 +15,13 @@ internal class JsonRpcRequest /// This provides flexibility at the protocol layer and type safety at the command layer. /// public JToken Params { get; set; } + /// /// JSON-RPC 2.0 spec requires id type to match the request. /// Must be string, number, or null - same as received. /// public object Id { get; set; } - public JsonRpcRequestUloopMetadata UloopMetadata { get; set; } + /// /// JSON-RPC 2.0 notification flag. True when id is null/missing. /// Notifications are fire-and-forget messages that don't expect a response. @@ -28,12 +29,4 @@ internal class JsonRpcRequest /// public bool IsNotification => Id == null; } - - /// - /// Provides JSON RPC Request Uloop Metadata behavior for Unity CLI Loop. - /// - internal class JsonRpcRequestUloopMetadata - { - public string ExpectedProjectRoot { get; set; } - } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs deleted file mode 100644 index 0dcd12e91..000000000 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Validates JSON RPC Request Identity data before the owning workflow continues. - /// - internal static class JsonRpcRequestIdentityValidator - { - public static void Validate( - JsonRpcRequestUloopMetadata metadata, - string actualProjectRoot) - { - if (metadata == null) - { - return; - } - - ProjectRootIdentityValidationResult validation = ProjectRootIdentityValidator.Validate( - metadata.ExpectedProjectRoot, - actualProjectRoot); - if (!validation.IsValid) - { - throw new UnityCliLoopToolParameterValidationException(validation.ErrorMessage); - } - } - } -} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs.meta deleted file mode 100644 index cb65165ee..000000000 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestIdentityValidator.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 105ea7a6425e04852b1f41d84dabc8ec -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/UnityApiHandler.cs b/Packages/src/Editor/Infrastructure/Api/UnityApiHandler.cs index e1b41ca74..7af2a790d 100644 --- a/Packages/src/Editor/Infrastructure/Api/UnityApiHandler.cs +++ b/Packages/src/Editor/Infrastructure/Api/UnityApiHandler.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; -using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -50,18 +49,12 @@ public static async Task ExecuteCommandAsync(string co if (InternalBridgeCommandRouter.IsInternalCommand(commandName)) { response = InternalBridgeCommandRouter.Execute(commandName, paramsToken); - response.SetVersion(UnityCliLoopVersion.VERSION); return response; } - Stopwatch registryAcquireStopwatch = Stopwatch.StartNew(); UnityCliLoopToolRegistry registry = UnityCliLoopToolRegistrar.GetRegistry(); - registryAcquireStopwatch.Stop(); - Stopwatch registryExecuteStopwatch = Stopwatch.StartNew(); response = await registry.ExecuteToolAsync(commandName, paramsToken); - registryExecuteStopwatch.Stop(); - response.SetVersion(UnityCliLoopVersion.VERSION); return response; } } diff --git a/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs b/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs new file mode 100644 index 000000000..c84cb9ce6 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Sends a local bridge request through the same project IPC transport used by external CLI clients. + /// + internal sealed class ProjectIpcWarmupClient + { + private const string ContentLengthHeader = "Content-Length:"; + private const int EndpointConnectTimeoutMilliseconds = 5000; + private const int MaxHeaderByteCount = 8192; + private const int MaxPayloadByteCount = BufferConfig.MAX_MESSAGE_SIZE; + private readonly byte[] _headerSeparatorBytes = { 13, 10, 13, 10 }; + + internal Task SendProjectIpcRequestAsync( + string projectRoot, + string requestJson, + CancellationToken ct) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(projectRoot), "projectRoot must not be empty"); + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(requestJson), "requestJson must not be empty"); + + BridgeTransportEndpoint endpoint = BridgeTransportEndpoint.CreateProjectIpc(projectRoot); + // Why: server lifecycle callbacks run on Unity's editor thread; connecting on a worker + // thread lets the local readiness request exercise the same IPC path as an external CLI + // command without blocking editor startup or recovery callbacks. + return Task.Run(async () => + { + ct.ThrowIfCancellationRequested(); + using Stream stream = await ConnectToEndpointAsync(endpoint, ct); + await WriteFrameAsync(stream, requestJson, ct); + await ReadResponseFrameAsync(stream, ct); + }, ct); + } + + private async Task ConnectToEndpointAsync(BridgeTransportEndpoint endpoint, CancellationToken ct) + { + System.Diagnostics.Debug.Assert(endpoint != null, "endpoint must not be null"); + + ct.ThrowIfCancellationRequested(); + switch (endpoint.Kind) + { + case BridgeTransportKind.UnixDomainSocket: + using (CancellationTokenSource connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + connectCts.CancelAfter(EndpointConnectTimeoutMilliseconds); + return await ConnectToUnixDomainSocketAsync(endpoint, connectCts.Token); + } + case BridgeTransportKind.WindowsNamedPipe: + return ConnectToWindowsNamedPipe(endpoint); + default: + throw new ArgumentOutOfRangeException(nameof(endpoint)); + } + } + + private async Task ConnectToUnixDomainSocketAsync(BridgeTransportEndpoint endpoint, CancellationToken ct) + { + Socket socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + bool connected = false; + try + { + Task connectTask = Task.Factory.FromAsync( + socket.BeginConnect, + socket.EndConnect, + new UnixDomainSocketEndPoint(endpoint.Path), + null); + await WaitForConnectAsync(connectTask, socket, ct); + connected = true; + return new NetworkStream(socket, ownsSocket: true); + } + finally + { + if (!connected) + { + socket.Dispose(); + } + } + } + + private Stream ConnectToWindowsNamedPipe(BridgeTransportEndpoint endpoint) + { + NamedPipeClientStream pipe = new NamedPipeClientStream( + ".", + endpoint.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous); + bool connected = false; + try + { + pipe.Connect(5000); + connected = true; + return pipe; + } + finally + { + if (!connected) + { + pipe.Dispose(); + } + } + } + + private async Task WriteFrameAsync( + Stream stream, + string requestJson, + CancellationToken ct) + { + byte[] payload = Encoding.UTF8.GetBytes(requestJson); + string header = $"{ContentLengthHeader} {payload.Length}\r\n\r\n"; + byte[] headerBytes = Encoding.ASCII.GetBytes(header); + await stream.WriteAsync(headerBytes, 0, headerBytes.Length, ct); + await stream.WriteAsync(payload, 0, payload.Length, ct); + } + + private async Task ReadResponseFrameAsync(Stream stream, CancellationToken ct) + { + int contentLength = await ReadContentLengthAsync(stream, ct); + byte[] payload = new byte[contentLength]; + await ReadPayloadAsync(stream, payload, ct); + } + + private async Task ReadContentLengthAsync(Stream stream, CancellationToken ct) + { + List headerBytes = new List(); + byte[] buffer = new byte[1]; + while (!EndsWithHeaderSeparator(headerBytes)) + { + int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, ct); + if (bytesRead == 0) + { + throw new EndOfStreamException("Project IPC warmup ended before response headers were complete."); + } + + headerBytes.Add(buffer[0]); + if (headerBytes.Count > MaxHeaderByteCount) + { + throw new InvalidOperationException("Project IPC warmup response headers exceeded the maximum size."); + } + } + + return ParseContentLength(headerBytes); + } + + private bool EndsWithHeaderSeparator(List headerBytes) + { + if (headerBytes.Count < _headerSeparatorBytes.Length) + { + return false; + } + + int startIndex = headerBytes.Count - _headerSeparatorBytes.Length; + for (int i = 0; i < _headerSeparatorBytes.Length; i++) + { + if (headerBytes[startIndex + i] != _headerSeparatorBytes[i]) + { + return false; + } + } + + return true; + } + + internal int ParseContentLength(List headerBytes) + { + string headerText = Encoding.ASCII.GetString(headerBytes.ToArray()); + string[] lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None); + foreach (string line in lines) + { + if (!line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string value = line.Substring(ContentLengthHeader.Length).Trim(); + if (int.TryParse(value, out int contentLength) && contentLength >= 0 && contentLength <= MaxPayloadByteCount) + { + return contentLength; + } + + throw new InvalidOperationException($"Project IPC warmup response had an invalid Content-Length: {line}"); + } + + throw new InvalidOperationException("Project IPC warmup response did not include Content-Length."); + } + + private async Task WaitForConnectAsync(Task connectTask, Socket socket, CancellationToken ct) + { + System.Diagnostics.Debug.Assert(connectTask != null, "connectTask must not be null"); + System.Diagnostics.Debug.Assert(socket != null, "socket must not be null"); + + Task cancellationTask = Task.Delay(Timeout.Infinite, ct); + Task completedTask = await Task.WhenAny(connectTask, cancellationTask); + if (completedTask == connectTask) + { + await connectTask; + return; + } + + // Why: Unity 2022 does not expose a cancellable Unix socket connect API, + // so disposing the socket is the only reliable way to release the pending OS connect. + socket.Dispose(); + ObserveConnectFault(connectTask); + ct.ThrowIfCancellationRequested(); + } + + private void ObserveConnectFault(Task connectTask) + { + _ = connectTask.ContinueWith( + completedTask => _ = completedTask.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private async Task ReadPayloadAsync( + Stream stream, + byte[] payload, + CancellationToken ct) + { + int totalRead = 0; + while (totalRead < payload.Length) + { + int bytesRead = await stream.ReadAsync(payload, totalRead, payload.Length - totalRead, ct); + if (bytesRead == 0) + { + throw new EndOfStreamException("Project IPC warmup ended before response payload was complete."); + } + + totalRead += bytesRead; + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs.meta b/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs.meta new file mode 100644 index 000000000..7adbb0083 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ProjectIpcWarmupClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1829f2ba686f3407e99fc4c839865335 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs index 461716a1a..3bec7eed5 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs @@ -522,7 +522,9 @@ public async Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationTo 5000, 250, cancellationToken, - clearServerStartingLockWhenReady: true); + // Why: the endpoint can bind before recovery warmup finishes, and deleting + // the lock at bind time lets the next CLI request block on the main thread. + clearServerStartingLockWhenReady: false); if (!started) { @@ -542,6 +544,9 @@ public async Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationTo UnityCliLoopToolRegistrar.WarmupRegistry(); ActivateStartupProtection(5000); + // Why: external CLI waiters should observe readiness only after recovery + // warmup has finished on the Unity main thread. + ServerStartingLockService.DeleteOwnedLockFile(serverStartingLockToken); } catch { @@ -661,6 +666,16 @@ public void RemoveServerStateChangedHandler(Action handler) { _serverLifecycleRegistry.ServerStateChanged -= handler; } + + public void AddServerStartedHandler(Action handler) + { + _serverLifecycleRegistry.ServerStarted += handler; + } + + public void RemoveServerStartedHandler(Action handler) + { + _serverLifecycleRegistry.ServerStarted -= handler; + } } } diff --git a/Packages/src/Editor/Infrastructure/Threading/EditorMainThreadDispatcher.cs b/Packages/src/Editor/Infrastructure/Threading/EditorMainThreadDispatcher.cs index 39797d4fa..746634336 100644 --- a/Packages/src/Editor/Infrastructure/Threading/EditorMainThreadDispatcher.cs +++ b/Packages/src/Editor/Infrastructure/Threading/EditorMainThreadDispatcher.cs @@ -4,6 +4,7 @@ using UnityEditor; using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; namespace io.github.hatayama.UnityCliLoop.Infrastructure { @@ -24,6 +25,8 @@ public void Initialize() EditorApplication.update -= ProcessContinuationQueue; EditorApplication.update += ProcessContinuationQueue; + EditorApplicationTickBridge.RemoveTickHandler(ProcessContinuationQueue); + EditorApplicationTickBridge.AddTickHandler(ProcessContinuationQueue); } public void AddContinuation(Action continuation) @@ -34,6 +37,7 @@ public void AddContinuation(Action continuation) } _continuationQueue.Enqueue(continuation); + EditorApplicationTickBridge.SignalTick(); } private void ProcessContinuationQueue() diff --git a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef index 30972d48c..01d64e81b 100644 --- a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef +++ b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef @@ -4,7 +4,8 @@ "references": [ "UnityCLILoop.Application", "UnityCLILoop.Domain", - "UnityCLILoop.ToolContracts" + "UnityCLILoop.ToolContracts", + "Unity.InternalAPIEditorBridge.024" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs index 9abd11e35..b1103b606 100644 --- a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs @@ -95,11 +95,6 @@ public class UnityCliLoopBridgeServer : IUnityCliLoopServerInstance /// public event Action OnError; - private string GenerateClientKey(string endpoint) - { - return endpoint; - } - public void StartServer(bool clearServerStartingLockWhenReady = true) { if (_isRunning) @@ -384,8 +379,7 @@ private async Task AcceptClientAsync(IBridgeTransportLis /// private async Task HandleClientAsync(BridgeClientConnection client, CancellationToken cancellationToken) { - string clientEndpoint = client.Endpoint; - string clientKey = GenerateClientKey(clientEndpoint); + string clientKey = client.Endpoint; // Initialize new components for Content-Length framing DynamicBufferManager bufferManager = null; @@ -431,8 +425,7 @@ private async Task HandleClientAsync(BridgeClientConnection client, Cancellation { if (string.IsNullOrWhiteSpace(requestJson)) continue; - // JSON-RPC processing and response sending with client context - string responseJson = await JsonRpcProcessor.ProcessRequest(requestJson, clientEndpoint); + string responseJson = await JsonRpcProcessor.ProcessRequest(requestJson); if (!string.IsNullOrEmpty(responseJson)) { diff --git a/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs b/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs new file mode 100644 index 000000000..175140ee5 --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs @@ -0,0 +1,31 @@ +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.InternalAPIBridge +{ + /// + /// SignalTick is Unity's thread-safe route for waking editor ticks from background IPC without OS run-loop APIs. + /// + public static class EditorApplicationTickBridge + { + public static void AddTickHandler(EditorApplication.CallbackFunction callback) + { + Debug.Assert(callback != null, "callback must not be null"); + + EditorApplication.tick -= callback; + EditorApplication.tick += callback; + } + + public static void RemoveTickHandler(EditorApplication.CallbackFunction callback) + { + Debug.Assert(callback != null, "callback must not be null"); + + EditorApplication.tick -= callback; + } + + public static void SignalTick() + { + EditorApplication.SignalTick(); + } + } +} diff --git a/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs.meta b/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs.meta new file mode 100644 index 000000000..c35476a1f --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/EditorApplicationTickBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54392f47408bd4718b5404655cc902cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs index 8ddbd411a..224597d6b 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs @@ -5,15 +5,12 @@ namespace io.github.hatayama.UnityCliLoop.ToolContracts /// public abstract class UnityCliLoopToolResponse { - /// - /// Unity package version for CLI compatibility checks. - /// - public string Ver { get; private set; } = string.Empty; + } + + public interface IUnityCliLoopTimingResponse + { + bool EmitsTimingsInJsonResponse { get; } - public void SetVersion(string version) - { - System.Diagnostics.Debug.Assert(version != null, "version must not be null."); - Ver = version; - } + void AddTiming(string timing); } } diff --git a/Packages/src/README.md b/Packages/src/README.md index df4f946cf..e47aea2d4 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -185,8 +185,8 @@ uloop launch -r # Execute compilation uloop compile -# Compile and wait for Domain Reload to complete -uloop compile --wait-for-domain-reload +# Compile without waiting for Domain Reload +uloop compile --no-wait-for-domain-reload # Get logs uloop get-logs --max-count 10 @@ -242,9 +242,9 @@ Dedicated tools exist only for operations that dynamic code execution cannot han # Key Features ## Development Loop Tools ### 1. compile - Execute Compilation -Performs AssetDatabase.Refresh() and then compiles, returning the results. Can detect errors and warnings that built-in linters cannot find. +Performs AssetDatabase.Refresh() and then compiles, returning the results after Domain Reload completes. Can detect errors and warnings that built-in linters cannot find. You can choose between incremental compilation and forced full compilation. -With `WaitForDomainReload=true`, results are returned after Domain Reload completes, regardless of the `ForceRecompile` value. +Use `WaitForDomainReload=false` only when you need the fire-and-forget path. ```text → Execute compile, analyze error and warning content → Automatically fix relevant files diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md index 0585558ad..4b4022060 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -186,8 +186,8 @@ uloop launch -r # コンパイルを実行 uloop compile -# コンパイルしてDomain Reload完了まで待つ -uloop compile --wait-for-domain-reload +# Domain Reloadを待たずにコンパイルを開始 +uloop compile --no-wait-for-domain-reload # ログを取得 uloop get-logs --max-count 10 @@ -243,9 +243,9 @@ Unity CLI Loop はツールの数を追い求めません。C#コードの動的 # 主要機能 ## 自律開発ループ系ツール ### 1. compile - コンパイルの実行 -AssetDatabase.Refresh()をした後、コンパイルして結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 +AssetDatabase.Refresh()をした後、Domain Reload完了まで待ってコンパイル結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 差分コンパイルと強制全体コンパイルを選択できます。 -`WaitForDomainReload=true` を指定すると、`ForceRecompile` の値に関係なく Domain Reload完了後に結果を返せます。 +即時に戻したい場合だけ `WaitForDomainReload=false` を指定します。 ```text → compile実行、エラー・警告内容を解析 → 該当ファイルを自動修正 diff --git a/Packages/src/TOOL_REFERENCE.md b/Packages/src/TOOL_REFERENCE.md index 4bdfd1992..4ad991b38 100644 --- a/Packages/src/TOOL_REFERENCE.md +++ b/Packages/src/TOOL_REFERENCE.md @@ -20,7 +20,7 @@ All tools automatically include the following property: - **Description**: Executes compilation after AssetDatabase.Refresh(). Returns compilation results with detailed timing information. - **Parameters**: - `ForceRecompile` (boolean): Whether to perform forced recompilation (default: false) - - `WaitForDomainReload` (boolean): Whether to wait for domain reload completion before returning (default: false) + - `WaitForDomainReload` (boolean): Whether to wait for domain reload completion before returning (default: true) - **Response**: - `Success` (boolean | null): Whether compilation was successful. Null when ForceRecompile=true because results are unavailable until domain reload completes - `ErrorCount` (number | null): Total number of errors. Null when ForceRecompile=true diff --git a/Packages/src/TOOL_REFERENCE_ja.md b/Packages/src/TOOL_REFERENCE_ja.md index c082def24..07d7e9e8f 100644 --- a/Packages/src/TOOL_REFERENCE_ja.md +++ b/Packages/src/TOOL_REFERENCE_ja.md @@ -20,7 +20,7 @@ - **説明**: AssetDatabase.Refresh()を実行後、コンパイルを行います。詳細なタイミング情報付きでコンパイル結果を返します。 - **パラメータ**: - `ForceRecompile` (boolean): 強制再コンパイルを実行するかどうか(デフォルト: false) - - `WaitForDomainReload` (boolean): Domain Reload完了まで待機するかどうか(デフォルト: false) + - `WaitForDomainReload` (boolean): Domain Reload完了まで待機するかどうか(デフォルト: true) - **レスポンス**: - `Success` (boolean | null): コンパイルが成功したかどうか。ForceRecompile=true時はDomain Reload完了まで結果が取得できないためnull - `ErrorCount` (number | null): エラーの総数。ForceRecompile=true時はnull diff --git a/README.md b/README.md index a7ab3f6b2..9631b1b12 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ uloop launch -r # Execute compilation uloop compile -# Compile and wait for Domain Reload to complete -uloop compile --wait-for-domain-reload +# Compile without waiting for Domain Reload +uloop compile --no-wait-for-domain-reload # Get logs uloop get-logs --max-count 10 @@ -242,9 +242,9 @@ Dedicated tools exist only for operations that dynamic code execution cannot han # Key Features ## Development Loop Tools ### 1. compile - Execute Compilation -Performs AssetDatabase.Refresh() and then compiles, returning the results. Can detect errors and warnings that built-in linters cannot find. +Performs AssetDatabase.Refresh() and then compiles, returning the results after Domain Reload completes. Can detect errors and warnings that built-in linters cannot find. You can choose between incremental compilation and forced full compilation. -With `WaitForDomainReload=true`, results are returned after Domain Reload completes, regardless of the `ForceRecompile` value. +Use `WaitForDomainReload=false` only when you need the fire-and-forget path. ```text → Execute compile, analyze error and warning content → Automatically fix relevant files diff --git a/README_ja.md b/README_ja.md index 70aebcb90..e06912bbf 100644 --- a/README_ja.md +++ b/README_ja.md @@ -186,8 +186,8 @@ uloop launch -r # コンパイルを実行 uloop compile -# コンパイルしてDomain Reload完了まで待つ -uloop compile --wait-for-domain-reload +# Domain Reloadを待たずにコンパイルを開始 +uloop compile --no-wait-for-domain-reload # ログを取得 uloop get-logs --max-count 10 @@ -243,9 +243,9 @@ Unity CLI Loop はツールの数を追い求めません。C#コードの動的 # 主要機能 ## 自律開発ループ系ツール ### 1. compile - コンパイルの実行 -AssetDatabase.Refresh()をした後、コンパイルして結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 +AssetDatabase.Refresh()をした後、Domain Reload完了まで待ってコンパイル結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 差分コンパイルと強制全体コンパイルを選択できます。 -`WaitForDomainReload=true` を指定すると、`ForceRecompile` の値に関係なく Domain Reload完了後に結果を返せます。 +即時に戻したい場合だけ `WaitForDomainReload=false` を指定します。 ```text → compile実行、エラー・警告内容を解析 → 該当ファイルを自動修正