Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion UnityCtl.Bridge/BridgeEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,58 @@ private static async Task<IResult> HandleGenericCommandAsync(
return JsonResponse(response);
}

// --- Editor readiness probing ---

/// <summary>
/// Sends editor.ping commands to Unity until one succeeds, proving the main thread
/// is responsive (not blocked on asset import, safe mode dialog, etc.).
/// </summary>
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)
Expand All @@ -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
};
});

Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions UnityCtl.Bridge/BridgeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

/// <summary>
/// Wait for the editor main thread to become responsive.
/// Returns true if ready within timeout, false if timeout expired.
/// </summary>
public async Task<bool> 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;
}
}

/// <summary>
/// Wait for Unity to connect (or reconnect after domain reload).
/// Returns true if connected within timeout, false if timeout expired.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
27 changes: 21 additions & 6 deletions UnityCtl.Cli/WaitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int?>(
"--timeout",
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -127,7 +137,8 @@ public static Command CreateCommand()
{
Console.WriteLine(JsonHelper.Serialize(new
{
unityConnected = false,
unityConnected,
editorReady = false,
bridgeRunning = bridgeFound
}));
}
Expand All @@ -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;
});
Expand Down
3 changes: 3 additions & 0 deletions UnityCtl.Protocol/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions UnityCtl.Protocol/DTOs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading