diff --git a/UnityCtl.Bridge/BridgeEndpoints.cs b/UnityCtl.Bridge/BridgeEndpoints.cs index 95eb418..a57a6f3 100644 --- a/UnityCtl.Bridge/BridgeEndpoints.cs +++ b/UnityCtl.Bridge/BridgeEndpoints.cs @@ -903,6 +903,58 @@ private static async Task HandleGenericCommandAsync( return JsonResponse(response); } + // --- Editor readiness probing --- + + /// + /// Sends editor.ping commands to Unity until one succeeds, proving the main thread + /// is responsive (not blocked on asset import, safe mode dialog, etc.). + /// + private static async Task ProbeEditorReadinessAsync(BridgeState state, CancellationToken ct) + { + var pingInterval = TimeSpan.FromSeconds(1); + var pingTimeout = TimeSpan.FromSeconds(5); + + Console.WriteLine($"[Bridge] Starting editor readiness probe..."); + + while (!ct.IsCancellationRequested && state.IsUnityConnected) + { + try + { + var pingRequest = CreateInternalRequest(null, UnityCtlCommands.EditorPing); + var response = await state.SendCommandToUnityAsync(pingRequest, pingTimeout, ct); + + if (response.Status == ResponseStatus.Ok) + { + state.SetEditorReady(true); + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Editor ready (ping succeeded)"); + return; + } + } + catch (TimeoutException) + { + // Unity main thread is blocked — retry after interval + } + catch (InvalidOperationException) + { + // Unity disconnected — stop probing + return; + } + catch (OperationCanceledException) + { + return; + } + + try + { + await Task.Delay(pingInterval, ct); + } + catch (OperationCanceledException) + { + return; + } + } + } + // --- Endpoint mapping --- public static void MapEndpoints(WebApplication app) @@ -919,7 +971,8 @@ public static void MapEndpoints(WebApplication app) ProjectId = state.ProjectId, UnityConnected = state.IsUnityConnected, BridgeVersion = VersionInfo.Version, - UnityPluginVersion = unityHello?.PluginVersion + UnityPluginVersion = unityHello?.PluginVersion, + EditorReady = state.IsEditorReady }; }); @@ -1145,6 +1198,9 @@ await webSocket.SendAsync( true, cancellationToken ); + + // Start background readiness probe — pings Unity until its main thread responds + _ = Task.Run(() => ProbeEditorReadinessAsync(state, cancellationToken), cancellationToken); } private static void HandleResponse(string json, BridgeState state) diff --git a/UnityCtl.Bridge/BridgeState.cs b/UnityCtl.Bridge/BridgeState.cs index e6feee9..53f3d89 100644 --- a/UnityCtl.Bridge/BridgeState.cs +++ b/UnityCtl.Bridge/BridgeState.cs @@ -66,12 +66,71 @@ public void SetUnityHelloMessage(HelloMessage? hello) private DateTime _domainReloadGracePeriodEnd = DateTime.MinValue; private static readonly TimeSpan DefaultGracePeriod = TimeSpan.FromSeconds(60); + // Editor readiness tracking (main thread responsive after hello handshake) + private bool _isEditorReady = false; + private TaskCompletionSource _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + // Event-driven signals (replace polling loops) private TaskCompletionSource _connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); private TaskCompletionSource _domainReloadCompleteSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool IsUnityConnected => UnityConnection?.State == WebSocketState.Open; + public bool IsEditorReady + { + get + { + lock (_lock) + { + return _isEditorReady && IsUnityConnected; + } + } + } + + public void SetEditorReady(bool ready) + { + lock (_lock) + { + _isEditorReady = ready; + if (ready) + { + _editorReadySignal.TrySetResult(); + } + else + { + if (_editorReadySignal.Task.IsCompleted) + _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + } + + /// + /// Wait for the editor main thread to become responsive. + /// Returns true if ready within timeout, false if timeout expired. + /// + public async Task WaitForEditorReadyAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + Task signal; + lock (_lock) + { + if (_isEditorReady && IsUnityConnected) return true; + signal = _editorReadySignal.Task; + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + try + { + await signal.WaitAsync(cts.Token); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + } + /// /// Wait for Unity to connect (or reconnect after domain reload). /// Returns true if connected within timeout, false if timeout expired. @@ -183,6 +242,11 @@ public void SetUnityConnection(WebSocket? connection) // Clear hello message when Unity disconnects _unityHelloMessage = null; + // Reset editor readiness — must re-probe after reconnect + _isEditorReady = false; + if (_editorReadySignal.Task.IsCompleted) + _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + // Reset connection signal so future waiters block until next connection if (_connectionSignal.Task.IsCompleted) _connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -237,6 +301,11 @@ public bool ClearUnityConnectionIfCurrent(WebSocket expected) UnityConnection = null; _unityHelloMessage = null; + // Reset editor readiness — must re-probe after reconnect + _isEditorReady = false; + if (_editorReadySignal.Task.IsCompleted) + _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + if (_connectionSignal.Task.IsCompleted) _connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -312,6 +381,11 @@ public void OnDomainReloadStarting() { lock (_lock) { + // Reset editor readiness — domain reload means main thread is blocked + _isEditorReady = false; + if (_editorReadySignal.Task.IsCompleted) + _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + _isDomainReloadInProgress = true; _domainReloadGracePeriodEnd = DateTime.UtcNow.Add(DefaultGracePeriod); diff --git a/UnityCtl.Cli/WaitCommand.cs b/UnityCtl.Cli/WaitCommand.cs index 505b7df..15e80a4 100644 --- a/UnityCtl.Cli/WaitCommand.cs +++ b/UnityCtl.Cli/WaitCommand.cs @@ -15,7 +15,7 @@ public static class WaitCommand public static Command CreateCommand() { - var waitCommand = new Command("wait", "Wait until Unity is connected to the bridge"); + var waitCommand = new Command("wait", "Wait until Unity Editor is connected and ready to accept commands"); var timeoutOption = new Option( "--timeout", @@ -57,10 +57,11 @@ public static Command CreateCommand() if (!json) { - Console.WriteLine("Waiting for Unity to connect..."); + Console.WriteLine("Waiting for Unity Editor to be ready..."); } var bridgeFound = false; + var unityConnected = false; var elapsed = 0; BridgeClient? client = null; int? lastPort = null; @@ -86,19 +87,28 @@ public static Command CreateCommand() if (health != null) { bridgeFound = true; - if (health.UnityConnected) + + // Log transition to connected (once) + if (health.UnityConnected && !unityConnected && !json) + { + Console.WriteLine("Unity connected, waiting for editor to be ready..."); + unityConnected = true; + } + + if (health.EditorReady) { if (json) { Console.WriteLine(JsonHelper.Serialize(new { unityConnected = true, + editorReady = true, bridgeRunning = true })); } else { - Console.WriteLine("Connected!"); + Console.WriteLine("Editor ready!"); } return; } @@ -127,7 +137,8 @@ public static Command CreateCommand() { Console.WriteLine(JsonHelper.Serialize(new { - unityConnected = false, + unityConnected, + editorReady = false, bridgeRunning = bridgeFound })); } @@ -142,10 +153,14 @@ public static Command CreateCommand() Console.Error.WriteLine($"Timed out after {timeout}s. Bridge not found."); Console.Error.WriteLine(" Run 'unityctl bridge start' first."); } - else + else if (!unityConnected) { Console.Error.WriteLine($"Timed out after {timeout}s. Unity is not connected to the bridge."); } + else + { + Console.Error.WriteLine($"Timed out after {timeout}s. Unity is connected but the editor is not ready (may still be importing assets)."); + } } context.ExitCode = 1; }); diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index 080b4ef..9672836 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -33,6 +33,9 @@ public static class UnityCtlCommands public const string RecordStart = "record.start"; public const string RecordStop = "record.stop"; public const string RecordStatus = "record.status"; + + // Editor readiness + public const string EditorPing = "editor.ping"; } public static class UnityCtlEvents diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index cb0407e..5a1e630 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -19,6 +19,9 @@ public class HealthResult [JsonProperty("unityPluginVersion")] public string? UnityPluginVersion { get; init; } + + [JsonProperty("editorReady")] + public required bool EditorReady { get; init; } } public class LogEntry diff --git a/UnityCtl.Tests/Integration/EditorReadinessTests.cs b/UnityCtl.Tests/Integration/EditorReadinessTests.cs new file mode 100644 index 0000000..4cd0206 --- /dev/null +++ b/UnityCtl.Tests/Integration/EditorReadinessTests.cs @@ -0,0 +1,122 @@ +using UnityCtl.Protocol; +using UnityCtl.Tests.Fakes; +using UnityCtl.Tests.Helpers; +using Xunit; + +namespace UnityCtl.Tests.Integration; + +/// +/// Tests for the editor readiness probe (editor.ping) that verifies Unity's main thread +/// is responsive before reporting the editor as ready. +/// +public class EditorReadinessTests : IAsyncLifetime +{ + private readonly BridgeTestFixture _fixture = new(); + + public Task InitializeAsync() => _fixture.InitializeAsync(); + public Task DisposeAsync() => _fixture.DisposeAsync(); + + [Fact] + public async Task EditorReady_BecomesTrue_AfterPingSucceeds() + { + // FakeUnity's default handler responds OK to editor.ping, + // so the readiness probe should succeed shortly after connection. + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10), + message: "Editor should become ready after successful ping"); + } + + [Fact] + public async Task HealthEndpoint_IncludesEditorReady() + { + // Wait for readiness probe to complete + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10)); + + var response = await _fixture.HttpClient.GetAsync("/health"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var health = JsonHelper.Deserialize(json); + + Assert.NotNull(health); + Assert.True(health.UnityConnected); + Assert.True(health.EditorReady); + } + + [Fact] + public async Task EditorReady_ResetToFalse_AfterDisconnect() + { + // Wait for readiness + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10)); + + // Disconnect + await _fixture.FakeUnity.DisconnectAsync(); + await AssertExtensions.WaitUntilAsync( + () => !_fixture.BridgeState.IsUnityConnected); + + Assert.False(_fixture.BridgeState.IsEditorReady); + } + + [Fact] + public async Task EditorReady_RestoredAfterReconnect() + { + // Wait for initial readiness + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10)); + + // Disconnect + await _fixture.FakeUnity.DisconnectAsync(); + await AssertExtensions.WaitUntilAsync( + () => !_fixture.BridgeState.IsUnityConnected); + Assert.False(_fixture.BridgeState.IsEditorReady); + + // Reconnect with new client + var newFake = _fixture.CreateFakeUnity(); + await newFake.ConnectAsync(_fixture.BaseUri); + + // Should become ready again after ping succeeds + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10), + message: "Editor should become ready again after reconnection"); + + await newFake.DisposeAsync(); + } + + [Fact] + public async Task EditorReady_ResetDuringDomainReload() + { + // Wait for initial readiness + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10)); + + // Simulate domain reload starting + await _fixture.FakeUnity.SendEventAsync( + UnityCtlEvents.DomainReloadStarting, new { }); + await Task.Delay(50); + + Assert.False(_fixture.BridgeState.IsEditorReady); + } + + [Fact] + public async Task HealthEndpoint_ShowsNotReady_WhenDisconnected() + { + await _fixture.FakeUnity.DisconnectAsync(); + await Task.Delay(100); + + var response = await _fixture.HttpClient.GetAsync("/health"); + var json = await response.Content.ReadAsStringAsync(); + var health = JsonHelper.Deserialize(json); + + Assert.NotNull(health); + Assert.False(health.UnityConnected); + Assert.False(health.EditorReady); + } +} diff --git a/UnityCtl.Tests/Integration/HealthEndpointTests.cs b/UnityCtl.Tests/Integration/HealthEndpointTests.cs index ccf3275..0d04320 100644 --- a/UnityCtl.Tests/Integration/HealthEndpointTests.cs +++ b/UnityCtl.Tests/Integration/HealthEndpointTests.cs @@ -17,6 +17,12 @@ public class HealthEndpointTests : IAsyncLifetime [Fact] public async Task Health_ReturnsOk_WhenUnityConnected() { + // EditorReady is set asynchronously after hello handshake via editor.ping probe. + // FakeUnity auto-responds OK to unknown commands, so the probe completes quickly. + await AssertExtensions.WaitUntilAsync( + () => _fixture.BridgeState.IsEditorReady, + timeout: TimeSpan.FromSeconds(10)); + var response = await _fixture.HttpClient.GetAsync("/health"); response.EnsureSuccessStatusCode(); @@ -27,6 +33,7 @@ public async Task Health_ReturnsOk_WhenUnityConnected() Assert.Equal("ok", health.Status); Assert.Equal(_fixture.ProjectId, health.ProjectId); Assert.True(health.UnityConnected); + Assert.True(health.EditorReady); Assert.NotNull(health.BridgeVersion); Assert.Equal("0.3.6", health.UnityPluginVersion); } diff --git a/UnityCtl.Tests/Unit/Protocol/DtoSerializationTests.cs b/UnityCtl.Tests/Unit/Protocol/DtoSerializationTests.cs index 4413bb9..5c6ec58 100644 --- a/UnityCtl.Tests/Unit/Protocol/DtoSerializationTests.cs +++ b/UnityCtl.Tests/Unit/Protocol/DtoSerializationTests.cs @@ -14,6 +14,7 @@ public void HealthResult_Serializes_CamelCase() Status = "ok", ProjectId = "proj-abc", UnityConnected = true, + EditorReady = true, BridgeVersion = "0.3.6", UnityPluginVersion = "0.3.6" }; @@ -24,6 +25,7 @@ public void HealthResult_Serializes_CamelCase() Assert.Equal("ok", jObj["status"]?.ToString()); Assert.Equal("proj-abc", jObj["projectId"]?.ToString()); Assert.True(jObj["unityConnected"]?.Value()); + Assert.True(jObj["editorReady"]?.Value()); Assert.Equal("0.3.6", jObj["bridgeVersion"]?.ToString()); } diff --git a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs index 3e6d57e..80b14f2 100644 --- a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs +++ b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs @@ -481,6 +481,10 @@ private void HandleCommand(RequestMessage request) result = Editor.RecordingManager.Instance.GetStatus(); break; + case UnityCtlCommands.EditorPing: + result = new { status = "pong" }; + break; + default: SendResponseError(request.RequestId, "unknown_command", $"Unknown command: {request.Command}"); return; diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index d04742a..7d07cef 100644 Binary files a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll and b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll differ