Skip to content

Commit 3103288

Browse files
authored
Fix wait command returning before editor is ready to accept commands (#20)
After WebSocket connection, the bridge now sends editor.ping RPCs to Unity until the main thread responds, proving the editor isn't blocked on asset import or safe mode. The wait command checks editorReady instead of just unityConnected, with intermediate progress messages and distinct timeout errors for each failure mode. Closes #18
1 parent 7c6fd71 commit 3103288

10 files changed

Lines changed: 293 additions & 7 deletions

File tree

UnityCtl.Bridge/BridgeEndpoints.cs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,58 @@ private static async Task<IResult> HandleGenericCommandAsync(
906906
return JsonResponse(response);
907907
}
908908

909+
// --- Editor readiness probing ---
910+
911+
/// <summary>
912+
/// Sends editor.ping commands to Unity until one succeeds, proving the main thread
913+
/// is responsive (not blocked on asset import, safe mode dialog, etc.).
914+
/// </summary>
915+
private static async Task ProbeEditorReadinessAsync(BridgeState state, CancellationToken ct)
916+
{
917+
var pingInterval = TimeSpan.FromSeconds(1);
918+
var pingTimeout = TimeSpan.FromSeconds(5);
919+
920+
Console.WriteLine($"[Bridge] Starting editor readiness probe...");
921+
922+
while (!ct.IsCancellationRequested && state.IsUnityConnected)
923+
{
924+
try
925+
{
926+
var pingRequest = CreateInternalRequest(null, UnityCtlCommands.EditorPing);
927+
var response = await state.SendCommandToUnityAsync(pingRequest, pingTimeout, ct);
928+
929+
if (response.Status == ResponseStatus.Ok)
930+
{
931+
state.SetEditorReady(true);
932+
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Editor ready (ping succeeded)");
933+
return;
934+
}
935+
}
936+
catch (TimeoutException)
937+
{
938+
// Unity main thread is blocked — retry after interval
939+
}
940+
catch (InvalidOperationException)
941+
{
942+
// Unity disconnected — stop probing
943+
return;
944+
}
945+
catch (OperationCanceledException)
946+
{
947+
return;
948+
}
949+
950+
try
951+
{
952+
await Task.Delay(pingInterval, ct);
953+
}
954+
catch (OperationCanceledException)
955+
{
956+
return;
957+
}
958+
}
959+
}
960+
909961
// --- Endpoint mapping ---
910962

911963
public static void MapEndpoints(WebApplication app)
@@ -922,7 +974,8 @@ public static void MapEndpoints(WebApplication app)
922974
ProjectId = state.ProjectId,
923975
UnityConnected = state.IsUnityConnected,
924976
BridgeVersion = VersionInfo.Version,
925-
UnityPluginVersion = unityHello?.PluginVersion
977+
UnityPluginVersion = unityHello?.PluginVersion,
978+
EditorReady = state.IsEditorReady
926979
};
927980
});
928981

@@ -1148,6 +1201,9 @@ await webSocket.SendAsync(
11481201
true,
11491202
cancellationToken
11501203
);
1204+
1205+
// Start background readiness probe — pings Unity until its main thread responds
1206+
_ = Task.Run(() => ProbeEditorReadinessAsync(state, cancellationToken), cancellationToken);
11511207
}
11521208

11531209
private static void HandleResponse(string json, BridgeState state)

UnityCtl.Bridge/BridgeState.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,71 @@ public void SetUnityHelloMessage(HelloMessage? hello)
6666
private DateTime _domainReloadGracePeriodEnd = DateTime.MinValue;
6767
private static readonly TimeSpan DefaultGracePeriod = TimeSpan.FromSeconds(60);
6868

69+
// Editor readiness tracking (main thread responsive after hello handshake)
70+
private bool _isEditorReady = false;
71+
private TaskCompletionSource _editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
72+
6973
// Event-driven signals (replace polling loops)
7074
private TaskCompletionSource _connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
7175
private TaskCompletionSource _domainReloadCompleteSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
7276

7377
public bool IsUnityConnected => UnityConnection?.State == WebSocketState.Open;
7478

79+
public bool IsEditorReady
80+
{
81+
get
82+
{
83+
lock (_lock)
84+
{
85+
return _isEditorReady && IsUnityConnected;
86+
}
87+
}
88+
}
89+
90+
public void SetEditorReady(bool ready)
91+
{
92+
lock (_lock)
93+
{
94+
_isEditorReady = ready;
95+
if (ready)
96+
{
97+
_editorReadySignal.TrySetResult();
98+
}
99+
else
100+
{
101+
if (_editorReadySignal.Task.IsCompleted)
102+
_editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
103+
}
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Wait for the editor main thread to become responsive.
109+
/// Returns true if ready within timeout, false if timeout expired.
110+
/// </summary>
111+
public async Task<bool> WaitForEditorReadyAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
112+
{
113+
Task signal;
114+
lock (_lock)
115+
{
116+
if (_isEditorReady && IsUnityConnected) return true;
117+
signal = _editorReadySignal.Task;
118+
}
119+
120+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
121+
cts.CancelAfter(timeout);
122+
123+
try
124+
{
125+
await signal.WaitAsync(cts.Token);
126+
return true;
127+
}
128+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
129+
{
130+
return false;
131+
}
132+
}
133+
75134
/// <summary>
76135
/// Wait for Unity to connect (or reconnect after domain reload).
77136
/// Returns true if connected within timeout, false if timeout expired.
@@ -183,6 +242,11 @@ public void SetUnityConnection(WebSocket? connection)
183242
// Clear hello message when Unity disconnects
184243
_unityHelloMessage = null;
185244

245+
// Reset editor readiness — must re-probe after reconnect
246+
_isEditorReady = false;
247+
if (_editorReadySignal.Task.IsCompleted)
248+
_editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
249+
186250
// Reset connection signal so future waiters block until next connection
187251
if (_connectionSignal.Task.IsCompleted)
188252
_connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -237,6 +301,11 @@ public bool ClearUnityConnectionIfCurrent(WebSocket expected)
237301
UnityConnection = null;
238302
_unityHelloMessage = null;
239303

304+
// Reset editor readiness — must re-probe after reconnect
305+
_isEditorReady = false;
306+
if (_editorReadySignal.Task.IsCompleted)
307+
_editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
308+
240309
if (_connectionSignal.Task.IsCompleted)
241310
_connectionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
242311

@@ -312,6 +381,11 @@ public void OnDomainReloadStarting()
312381
{
313382
lock (_lock)
314383
{
384+
// Reset editor readiness — domain reload means main thread is blocked
385+
_isEditorReady = false;
386+
if (_editorReadySignal.Task.IsCompleted)
387+
_editorReadySignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
388+
315389
_isDomainReloadInProgress = true;
316390
_domainReloadGracePeriodEnd = DateTime.UtcNow.Add(DefaultGracePeriod);
317391

UnityCtl.Cli/WaitCommand.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public static class WaitCommand
1515

1616
public static Command CreateCommand()
1717
{
18-
var waitCommand = new Command("wait", "Wait until Unity is connected to the bridge");
18+
var waitCommand = new Command("wait", "Wait until Unity Editor is connected and ready to accept commands");
1919

2020
var timeoutOption = new Option<int?>(
2121
"--timeout",
@@ -57,10 +57,11 @@ public static Command CreateCommand()
5757

5858
if (!json)
5959
{
60-
Console.WriteLine("Waiting for Unity to connect...");
60+
Console.WriteLine("Waiting for Unity Editor to be ready...");
6161
}
6262

6363
var bridgeFound = false;
64+
var unityConnected = false;
6465
var elapsed = 0;
6566
BridgeClient? client = null;
6667
int? lastPort = null;
@@ -86,19 +87,28 @@ public static Command CreateCommand()
8687
if (health != null)
8788
{
8889
bridgeFound = true;
89-
if (health.UnityConnected)
90+
91+
// Log transition to connected (once)
92+
if (health.UnityConnected && !unityConnected && !json)
93+
{
94+
Console.WriteLine("Unity connected, waiting for editor to be ready...");
95+
unityConnected = true;
96+
}
97+
98+
if (health.EditorReady)
9099
{
91100
if (json)
92101
{
93102
Console.WriteLine(JsonHelper.Serialize(new
94103
{
95104
unityConnected = true,
105+
editorReady = true,
96106
bridgeRunning = true
97107
}));
98108
}
99109
else
100110
{
101-
Console.WriteLine("Connected!");
111+
Console.WriteLine("Editor ready!");
102112
}
103113
return;
104114
}
@@ -127,7 +137,8 @@ public static Command CreateCommand()
127137
{
128138
Console.WriteLine(JsonHelper.Serialize(new
129139
{
130-
unityConnected = false,
140+
unityConnected,
141+
editorReady = false,
131142
bridgeRunning = bridgeFound
132143
}));
133144
}
@@ -142,10 +153,14 @@ public static Command CreateCommand()
142153
Console.Error.WriteLine($"Timed out after {timeout}s. Bridge not found.");
143154
Console.Error.WriteLine(" Run 'unityctl bridge start' first.");
144155
}
145-
else
156+
else if (!unityConnected)
146157
{
147158
Console.Error.WriteLine($"Timed out after {timeout}s. Unity is not connected to the bridge.");
148159
}
160+
else
161+
{
162+
Console.Error.WriteLine($"Timed out after {timeout}s. Unity is connected but the editor is not ready (may still be importing assets).");
163+
}
149164
}
150165
context.ExitCode = 1;
151166
});

UnityCtl.Protocol/Constants.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ public static class UnityCtlCommands
3333
public const string RecordStart = "record.start";
3434
public const string RecordStop = "record.stop";
3535
public const string RecordStatus = "record.status";
36+
37+
// Editor readiness
38+
public const string EditorPing = "editor.ping";
3639
}
3740

3841
public static class UnityCtlEvents

UnityCtl.Protocol/DTOs.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ public class HealthResult
1919

2020
[JsonProperty("unityPluginVersion")]
2121
public string? UnityPluginVersion { get; init; }
22+
23+
[JsonProperty("editorReady")]
24+
public required bool EditorReady { get; init; }
2225
}
2326

2427
public class LogEntry

0 commit comments

Comments
 (0)