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