uloop-cli 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,7 +72,16 @@ export class DirectUnityClient {
72
72
 
73
73
  return new Promise((resolve, reject) => {
74
74
  const socket = this.socket!;
75
+
76
+ const cleanup = (): void => {
77
+ clearTimeout(timeoutId);
78
+ socket.off('data', onData);
79
+ socket.off('error', onError);
80
+ socket.off('close', onClose);
81
+ };
82
+
75
83
  const timeoutId = setTimeout(() => {
84
+ cleanup();
76
85
  reject(
77
86
  new Error(
78
87
  `Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Run 'uloop focus-window' to bring Unity to the front, then retry the tool. If the issue persists, report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
@@ -98,9 +107,7 @@ export class DirectUnityClient {
98
107
  return;
99
108
  }
100
109
 
101
- clearTimeout(timeoutId);
102
- socket.off('data', onData);
103
-
110
+ cleanup();
104
111
  this.receiveBuffer = extractResult.remainingData;
105
112
 
106
113
  const response = JSON.parse(extractResult.jsonContent) as JsonRpcResponse;
@@ -113,7 +120,19 @@ export class DirectUnityClient {
113
120
  resolve(response.result as T);
114
121
  };
115
122
 
123
+ const onError = (error: Error): void => {
124
+ cleanup();
125
+ reject(new Error(`Connection lost: ${error.message}`));
126
+ };
127
+
128
+ const onClose = (): void => {
129
+ cleanup();
130
+ reject(new Error('UNITY_NO_RESPONSE'));
131
+ };
132
+
116
133
  socket.on('data', onData);
134
+ socket.on('error', onError);
135
+ socket.on('close', onClose);
117
136
  socket.write(framedMessage);
118
137
  });
119
138
  }
@@ -17,6 +17,13 @@ import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './
17
17
  import { VERSION } from './version.js';
18
18
  import { createSpinner } from './spinner.js';
19
19
  import { findUnityProjectRoot } from './project-root.js';
20
+ import {
21
+ type CompileExecutionOptions,
22
+ ensureCompileRequestId,
23
+ resolveCompileExecutionOptions,
24
+ sleep,
25
+ waitForCompileCompletion,
26
+ } from './compile-helpers.js';
20
27
 
21
28
  /**
22
29
  * Suppress stdin echo during async operation to prevent escape sequences from being displayed.
@@ -56,11 +63,29 @@ export interface GlobalOptions {
56
63
  port?: string;
57
64
  }
58
65
 
66
+ function stripInternalFields(result: Record<string, unknown>): Record<string, unknown> {
67
+ const cleaned = { ...result };
68
+ delete cleaned['ProjectRoot'];
69
+ return cleaned;
70
+ }
71
+
59
72
  const RETRY_DELAY_MS = 500;
60
73
  const MAX_RETRIES = 3;
74
+ const COMPILE_WAIT_TIMEOUT_MS = 90000;
75
+ const COMPILE_WAIT_POLL_INTERVAL_MS = 100;
76
+
77
+ function getCompileExecutionOptions(
78
+ toolName: string,
79
+ params: Record<string, unknown>,
80
+ ): CompileExecutionOptions {
81
+ if (toolName !== 'compile') {
82
+ return {
83
+ forceRecompile: false,
84
+ waitForDomainReload: false,
85
+ };
86
+ }
61
87
 
62
- function sleep(ms: number): Promise<void> {
63
- return new Promise((resolve) => setTimeout(resolve, ms));
88
+ return resolveCompileExecutionOptions(params);
64
89
  }
65
90
 
66
91
  function isRetryableError(error: unknown): boolean {
@@ -75,6 +100,18 @@ function isRetryableError(error: unknown): boolean {
75
100
  );
76
101
  }
77
102
 
103
+ // Distinct from isRetryableError(): that function covers pre-connection failures
104
+ // (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch.
105
+ // This function covers post-dispatch TCP failures where Unity may have received
106
+ // the request but the response was lost — file-based recovery is appropriate.
107
+ export function isTransportDisconnectError(error: unknown): boolean {
108
+ if (!(error instanceof Error)) {
109
+ return false;
110
+ }
111
+ const message: string = error.message;
112
+ return message === 'UNITY_NO_RESPONSE' || message.startsWith('Connection lost:');
113
+ }
114
+
78
115
  /**
79
116
  * Compare two semantic versions safely.
80
117
  * Returns true if v1 < v2, false otherwise.
@@ -161,11 +198,25 @@ export async function executeToolCommand(
161
198
  portNumber = parsed;
162
199
  }
163
200
  const port = await resolveUnityPort(portNumber);
201
+ const compileOptions = getCompileExecutionOptions(toolName, params);
202
+ const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
203
+ const compileRequestId = shouldWaitForDomainReload ? ensureCompileRequestId(params) : undefined;
164
204
 
165
205
  const restoreStdin = suppressStdinEcho();
166
206
  const spinner = createSpinner('Connecting to Unity...');
167
207
 
168
208
  let lastError: unknown;
209
+ let immediateResult: Record<string, unknown> | undefined;
210
+ const projectRoot = findUnityProjectRoot();
211
+
212
+ // Monotonically-increasing flag: once true, retries cannot reset it to false.
213
+ // The retry loop overwrites `lastError` and `immediateResult` on each attempt,
214
+ // which destroys the evidence of whether an earlier attempt successfully dispatched
215
+ // the request to Unity. This flag preserves that information across retries.
216
+ // See: git log cb3d63e..HEAD for the history of oscillating fixes caused by
217
+ // inferring dispatch status from `immediateResult` alone.
218
+ let requestDispatched = false;
219
+
169
220
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
170
221
  checkUnityBusyState();
171
222
 
@@ -174,25 +225,51 @@ export async function executeToolCommand(
174
225
  await client.connect();
175
226
 
176
227
  spinner.update(`Executing ${toolName}...`);
228
+ // connect() succeeded: socket is established. sendRequest() calls socket.write()
229
+ // synchronously (direct-unity-client.ts:136), so the data reaches the kernel
230
+ // send buffer before any async error can occur. Safe to mark as dispatched here.
231
+ requestDispatched = true;
177
232
  const result = await client.sendRequest<Record<string, unknown>>(toolName, params);
178
233
 
179
234
  if (result === undefined || result === null) {
180
235
  throw new Error('UNITY_NO_RESPONSE');
181
236
  }
182
237
 
183
- // Success - stop spinner and output result
184
- spinner.stop();
185
- restoreStdin();
238
+ immediateResult = result;
239
+ if (!shouldWaitForDomainReload) {
240
+ spinner.stop();
241
+ restoreStdin();
186
242
 
187
- // Check server version and warn if mismatched
188
- checkServerVersion(result);
243
+ checkServerVersion(result);
244
+ console.log(JSON.stringify(stripInternalFields(result), null, 2));
245
+ return;
246
+ }
189
247
 
190
- console.log(JSON.stringify(result, null, 2));
191
- return;
248
+ break;
192
249
  } catch (error) {
193
250
  lastError = error;
194
251
  client.disconnect();
195
252
 
253
+ // After a compile request has been dispatched, retrying is counterproductive:
254
+ // the next loop iteration calls checkUnityBusyState() OUTSIDE the try block,
255
+ // which throws UNITY_DOMAIN_RELOAD during domain reload and escapes the
256
+ // entire function — bypassing waitForCompileCompletion() recovery.
257
+ if (requestDispatched && shouldWaitForDomainReload) {
258
+ if (isTransportDisconnectError(error)) {
259
+ // Unity may have received the request before the TCP drop.
260
+ // Break out of retry loop → proceed to file-based recovery below.
261
+ spinner.update('Connection lost during compile. Waiting for result file...');
262
+ break;
263
+ }
264
+ // JSON-RPC error (e.g. "Unity error: ..."): Unity processed the request
265
+ // and returned an explicit error. No result file will be written
266
+ // (confirmed: CompileUseCase.ExecuteAsync() is not reached when
267
+ // JSON-RPC error occurs at parameter validation / security check).
268
+ spinner.stop();
269
+ restoreStdin();
270
+ throw error instanceof Error ? error : new Error(String(error));
271
+ }
272
+
196
273
  if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
197
274
  break;
198
275
  }
@@ -203,9 +280,92 @@ export async function executeToolCommand(
203
280
  }
204
281
  }
205
282
 
283
+ if (shouldWaitForDomainReload && compileRequestId) {
284
+ // Fail fast when the compile request never reached Unity.
285
+ // Without this guard, unreachable Unity would cause a 90-second wait for a
286
+ // result file that will never be created.
287
+ // We check both conditions because:
288
+ // - immediateResult === undefined: no JSON-RPC response was received
289
+ // - !requestDispatched: no attempt ever successfully connected and called sendRequest()
290
+ // If requestDispatched is true but immediateResult is undefined, the request was sent
291
+ // but the TCP connection dropped before the response arrived (domain reload scenario).
292
+ // In that case, Unity may have already written the result file, so we proceed to
293
+ // file-based polling recovery.
294
+ if (immediateResult === undefined && !requestDispatched) {
295
+ spinner.stop();
296
+ restoreStdin();
297
+ if (lastError instanceof Error) {
298
+ throw lastError;
299
+ }
300
+ throw new Error(
301
+ 'Compile request never reached Unity. Check that Unity is running and retry.',
302
+ );
303
+ }
304
+
305
+ const projectRootFromUnity: string | undefined =
306
+ immediateResult !== undefined
307
+ ? (immediateResult['ProjectRoot'] as string | undefined)
308
+ : undefined;
309
+ const effectiveProjectRoot: string | null = projectRootFromUnity ?? projectRoot;
310
+
311
+ // File-based polling requires a known project root
312
+ if (effectiveProjectRoot === null) {
313
+ spinner.stop();
314
+ restoreStdin();
315
+ if (immediateResult !== undefined) {
316
+ checkServerVersion(immediateResult);
317
+ console.log(JSON.stringify(stripInternalFields(immediateResult), null, 2));
318
+ return;
319
+ }
320
+ if (lastError instanceof Error) {
321
+ throw lastError;
322
+ }
323
+ throw new Error(
324
+ 'Compile request failed and project root is unknown. Check connection and retry.',
325
+ );
326
+ }
327
+
328
+ spinner.update('Waiting for domain reload to complete...');
329
+ const { outcome, result: storedResult } = await waitForCompileCompletion<
330
+ Record<string, unknown>
331
+ >({
332
+ projectRoot: effectiveProjectRoot,
333
+ requestId: compileRequestId,
334
+ timeoutMs: COMPILE_WAIT_TIMEOUT_MS,
335
+ pollIntervalMs: COMPILE_WAIT_POLL_INTERVAL_MS,
336
+ unityPort: port,
337
+ });
338
+
339
+ if (outcome === 'timed_out') {
340
+ lastError = new Error(
341
+ `Compile wait timed out after ${COMPILE_WAIT_TIMEOUT_MS}ms. Run 'uloop fix' and retry.`,
342
+ );
343
+ } else {
344
+ const finalResult = storedResult ?? immediateResult;
345
+ if (finalResult !== undefined) {
346
+ spinner.stop();
347
+ restoreStdin();
348
+ checkServerVersion(finalResult);
349
+ console.log(JSON.stringify(stripInternalFields(finalResult), null, 2));
350
+ return;
351
+ }
352
+ }
353
+ }
354
+
206
355
  spinner.stop();
207
356
  restoreStdin();
208
- throw lastError;
357
+ if (lastError === undefined) {
358
+ throw new Error('Tool execution failed without error details.');
359
+ }
360
+ if (lastError instanceof Error) {
361
+ throw lastError;
362
+ }
363
+ if (typeof lastError === 'string') {
364
+ throw new Error(lastError);
365
+ }
366
+
367
+ const serializedError = JSON.stringify(lastError);
368
+ throw new Error(serializedError ?? 'Unknown error');
209
369
  }
210
370
 
211
371
  export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * This file exists to avoid bundling the entire package.json into the CLI bundle.
5
5
  * This version is automatically updated by release-please.
6
6
  */
7
- export const VERSION = '0.64.0'; // x-release-please-version
7
+ export const VERSION = '0.65.0'; // x-release-please-version