@alpsckr/unitycli 0.4.0 → 0.4.2
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.
- package/README.md +111 -299
- package/contracts/init-contract.json +64 -0
- package/contracts/instances-contract.json +49 -0
- package/contracts/package-assembly-closure.json +33 -0
- package/contracts/package-files.json +1953 -0
- package/dist/bridge/session-supervisor.d.ts +2 -0
- package/dist/bridge/session-supervisor.js +52 -19
- package/dist/bridge/session-supervisor.js.map +1 -1
- package/dist/cli-commands/command.js +33 -15
- package/dist/cli-commands/command.js.map +1 -1
- package/dist/cli-commands/init-discovery.d.ts +11 -0
- package/dist/cli-commands/init-discovery.js +185 -0
- package/dist/cli-commands/init-discovery.js.map +1 -0
- package/dist/cli-commands/init-help.d.ts +3 -0
- package/dist/cli-commands/init-help.js +41 -0
- package/dist/cli-commands/init-help.js.map +1 -0
- package/dist/cli-commands/init-skill.d.ts +10 -0
- package/dist/cli-commands/init-skill.js +171 -0
- package/dist/cli-commands/init-skill.js.map +1 -0
- package/dist/cli-commands/instances.d.ts +4 -0
- package/dist/cli-commands/instances.js +90 -0
- package/dist/cli-commands/instances.js.map +1 -0
- package/dist/cli-commands/package.d.ts +2 -2
- package/dist/cli-commands/package.js +97 -130
- package/dist/cli-commands/package.js.map +1 -1
- package/dist/cli-commands/skill-installer.d.ts +29 -13
- package/dist/cli-commands/skill-installer.js +127 -88
- package/dist/cli-commands/skill-installer.js.map +1 -1
- package/dist/cli-commands/types.d.ts +4 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +46 -37
- package/dist/cli.js.map +1 -1
- package/dist/contracts/command-contract.js +1 -0
- package/dist/contracts/command-contract.js.map +1 -1
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/instances.d.ts +1 -1
- package/dist/instances.js +63 -38
- package/dist/instances.js.map +1 -1
- package/dist/output.d.ts +4 -4
- package/dist/output.js +3 -4
- package/dist/output.js.map +1 -1
- package/dist/package-manager.d.ts +47 -2
- package/dist/package-manager.js +267 -16
- package/dist/package-manager.js.map +1 -1
- package/dist/tools/.generated-schemas.js +2 -2
- package/dist/tools/.generated-schemas.js.map +1 -1
- package/dist/tools/commands.js +11 -4
- package/dist/tools/commands.js.map +1 -1
- package/dist/tools/dispatcher.js +1 -1
- package/dist/tools/dispatcher.js.map +1 -1
- package/dist/tools/tools/group-list.d.ts +4 -8
- package/dist/tools/tools/group-list.js +12 -13
- package/dist/tools/tools/group-list.js.map +1 -1
- package/dist/tools/tools/pagination.d.ts +17 -0
- package/dist/tools/tools/pagination.js +28 -0
- package/dist/tools/tools/pagination.js.map +1 -0
- package/dist/tools/tools/search.d.ts +3 -5
- package/dist/tools/tools/search.js +12 -12
- package/dist/tools/tools/search.js.map +1 -1
- package/dist/unity-project-processes.d.ts +7 -0
- package/dist/unity-project-processes.js +54 -0
- package/dist/unity-project-processes.js.map +1 -1
- package/docs/README.md +7 -4
- package/docs/architecture.md +103 -0
- package/docs/availability-differences.md +1 -1
- package/docs/command-execution.md +6 -1
- package/docs/development.md +91 -0
- package/docs/error-codes.md +21 -5
- package/docs/extensions.md +13 -13
- package/docs/target-and-instance.md +6 -4
- package/docs/tool-discovery.md +12 -10
- package/docs/tool-execution.md +3 -2
- package/package.json +10 -4
- package/schemas/init.schema.json +209 -0
- package/schemas/instances.schema.json +104 -0
- package/skills/unitycli/SKILL.md +18 -6
- package/skills/unitycli/manifest.json +2 -2
- package/skills/unitycli/references/custom-tools.md +3 -3
- package/skills/unitycli/references/operation-protocol.md +6 -4
- package/skills/unitycli/reports/output_quality_scorecard.md +1 -1
- package/skills/unitycli/reports/trust-report.md +1 -1
- package/unitypkg/Editor/Bridge/BridgeCommandRegistry.cs +1 -0
- package/unitypkg/Editor/Bridge/BridgeWorkQueue.cs +18 -6
- package/unitypkg/Editor/Infrastructure/CodeExecSupport.cs +8 -77
- package/unitypkg/Editor/Infrastructure/UnityCliInstanceCleanup.cs +295 -0
- package/unitypkg/Editor/Infrastructure/UnityCliInstanceRegistry.cs +166 -10
- package/unitypkg/Editor/Infrastructure/UnityCliOperationCoordinator.cs +1 -0
- package/unitypkg/Editor/UnityCliBridge.cs +5 -1
- package/unitypkg/Tests/Editor/UnityCliInstanceCleanupTests.cs +232 -0
- package/unitypkg/Tests/Editor/UnityCliInstancePublicationRetryTests.cs +143 -0
- package/unitypkg/UnityCli.Editor.asmdef +1 -5
- package/unitypkg/package.json +1 -1
- package/unitypkg/Editor/Roslyn/LICENSE.txt +0 -23
- package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.CSharp.dll +0 -0
- package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.dll +0 -0
- package/unitypkg/Editor/Roslyn/System.Collections.Immutable.dll +0 -0
- package/unitypkg/Editor/Roslyn/System.Reflection.Metadata.dll +0 -0
|
@@ -20,6 +20,8 @@ namespace UnityCli
|
|
|
20
20
|
public static partial class UnityCliBridge
|
|
21
21
|
{
|
|
22
22
|
private const int RegistryMutexTimeoutMs = 5000;
|
|
23
|
+
private const long RegistryMaxBytes = 1024L * 1024L;
|
|
24
|
+
private const int RegistryPublicationMaxAttempts = 4;
|
|
23
25
|
private const uint MutexAllAccess = 0x001F0001;
|
|
24
26
|
private const uint WaitObject0 = 0x00000000;
|
|
25
27
|
private const uint WaitAbandoned = 0x00000080;
|
|
@@ -34,6 +36,11 @@ namespace UnityCli
|
|
|
34
36
|
private static string lastRegistrationSignature;
|
|
35
37
|
private static long listenerGeneration;
|
|
36
38
|
private static long publicationSequence;
|
|
39
|
+
private static bool registryPublicationRetryPending;
|
|
40
|
+
private static bool registryPublicationRetryExhausted;
|
|
41
|
+
private static int registryPublicationAttemptCount;
|
|
42
|
+
private static double nextRegistryPublicationRetryAt;
|
|
43
|
+
private static string pendingRegistryRequestedState;
|
|
37
44
|
|
|
38
45
|
/// <summary>当前 listener generation 只用于 transport attestation 与 registry 内部一致性校验。</summary>
|
|
39
46
|
private static long CurrentListenerGeneration()
|
|
@@ -182,8 +189,8 @@ namespace UnityCli
|
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
191
|
|
|
185
|
-
/// <summary>在
|
|
186
|
-
private static
|
|
192
|
+
/// <summary>在native current-user DACL named mutex内尝试临界区;timeout不执行action。</summary>
|
|
193
|
+
private static bool TryWithCurrentUserMutex(string name, int timeoutMs, Action action)
|
|
187
194
|
{
|
|
188
195
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Mutex name is required.", "name");
|
|
189
196
|
if (action == null) throw new ArgumentNullException("action");
|
|
@@ -207,12 +214,13 @@ namespace UnityCli
|
|
|
207
214
|
if (handle == IntPtr.Zero || handle == new IntPtr(-1))
|
|
208
215
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not create the current-user mutex.");
|
|
209
216
|
|
|
210
|
-
var wait = WaitForSingleObject(handle, (uint)timeoutMs);
|
|
211
|
-
if (wait == WaitTimeout)
|
|
217
|
+
var wait = WaitForSingleObject(handle, (uint)Math.Max(0, timeoutMs));
|
|
218
|
+
if (wait == WaitTimeout) return false;
|
|
212
219
|
if (wait == WaitFailed) throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not wait for current-user mutex.");
|
|
213
220
|
if (wait != WaitObject0 && wait != WaitAbandoned) throw new InvalidOperationException("Unexpected current-user mutex wait result: " + wait.ToString(CultureInfo.InvariantCulture));
|
|
214
221
|
acquired = true;
|
|
215
222
|
action();
|
|
223
|
+
return true;
|
|
216
224
|
}
|
|
217
225
|
finally
|
|
218
226
|
{
|
|
@@ -222,6 +230,12 @@ namespace UnityCli
|
|
|
222
230
|
}
|
|
223
231
|
}
|
|
224
232
|
|
|
233
|
+
/// <summary>需要锁的业务写入保留原超时异常语义。</summary>
|
|
234
|
+
private static void WithCurrentUserMutex(string name, int timeoutMs, Action action)
|
|
235
|
+
{
|
|
236
|
+
if (!TryWithCurrentUserMutex(name, timeoutMs, action)) throw new TimeoutException("Timed out waiting for current-user mutex.");
|
|
237
|
+
}
|
|
238
|
+
|
|
225
239
|
/// <summary>保留 registry 原有锁名、超时和调用语义。</summary>
|
|
226
240
|
private static void WithRegistryMutex(Action action)
|
|
227
241
|
{
|
|
@@ -263,17 +277,84 @@ namespace UnityCli
|
|
|
263
277
|
}
|
|
264
278
|
}
|
|
265
279
|
|
|
266
|
-
/// <summary
|
|
280
|
+
/// <summary>请求当前状态发布;已有失败链只更新最新状态,不创建第二条重试链。</summary>
|
|
267
281
|
private static bool RegisterInstance(string requestedState)
|
|
268
282
|
{
|
|
283
|
+
if (registryPublicationRetryExhausted) return false;
|
|
284
|
+
if (registryPublicationRetryPending)
|
|
285
|
+
{
|
|
286
|
+
pendingRegistryRequestedState = requestedState;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return AttemptInstancePublication(requestedState);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// <summary>中央update入口推进唯一重试链;达到上限后本次domain不再后台尝试。</summary>
|
|
293
|
+
private static void TickInstancePublicationRetry()
|
|
294
|
+
{
|
|
295
|
+
if (!registryPublicationRetryPending || registryPublicationRetryExhausted || !started || domainReloading) return;
|
|
296
|
+
if (EditorApplication.timeSinceStartup < nextRegistryPublicationRetryAt) return;
|
|
297
|
+
var requestedState = pendingRegistryRequestedState;
|
|
298
|
+
registryPublicationRetryPending = false;
|
|
299
|
+
pendingRegistryRequestedState = null;
|
|
300
|
+
AttemptInstancePublication(string.IsNullOrEmpty(requestedState) ? "ready" : requestedState);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// <summary>listener停止或domain结束时清空本次启动的重试状态。</summary>
|
|
304
|
+
private static void CancelInstancePublicationRetry()
|
|
305
|
+
{
|
|
306
|
+
registryPublicationRetryPending = false;
|
|
307
|
+
registryPublicationRetryExhausted = false;
|
|
308
|
+
registryPublicationAttemptCount = 0;
|
|
309
|
+
nextRegistryPublicationRetryAt = 0;
|
|
310
|
+
pendingRegistryRequestedState = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// <summary>执行一次写入;只对文件、权限或锁类短暂失败安排有限重试。</summary>
|
|
314
|
+
private static bool AttemptInstancePublication(string requestedState)
|
|
315
|
+
{
|
|
316
|
+
Exception failure;
|
|
317
|
+
if (TryRegisterInstance(requestedState, out failure))
|
|
318
|
+
{
|
|
319
|
+
var recoveredAttempts = registryPublicationAttemptCount;
|
|
320
|
+
CancelInstancePublicationRetry();
|
|
321
|
+
if (recoveredAttempts > 0)
|
|
322
|
+
WriteLog("instance_publication_retry_succeeded attempts=" + (recoveredAttempts + 1).ToString(CultureInfo.InvariantCulture));
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
registryPublicationAttemptCount += 1;
|
|
327
|
+
var failureKind = RegistryPublicationFailureKind(failure);
|
|
328
|
+
var retryable = IsRetryableRegistryPublicationFailure(failure);
|
|
329
|
+
WriteLog("instance_register_failed kind=" + failureKind + " retryable=" + (retryable ? "true" : "false") + " attempt=" + registryPublicationAttemptCount.ToString(CultureInfo.InvariantCulture));
|
|
330
|
+
if (!retryable || registryPublicationAttemptCount >= RegistryPublicationMaxAttempts)
|
|
331
|
+
{
|
|
332
|
+
registryPublicationRetryPending = false;
|
|
333
|
+
registryPublicationRetryExhausted = true;
|
|
334
|
+
pendingRegistryRequestedState = null;
|
|
335
|
+
WriteLog("instance_publication_retry_stopped kind=" + failureKind + " attempts=" + registryPublicationAttemptCount.ToString(CultureInfo.InvariantCulture) + " nextAction=restore-data-root-write-access-then-reload-domain");
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
pendingRegistryRequestedState = requestedState;
|
|
340
|
+
registryPublicationRetryPending = true;
|
|
341
|
+
nextRegistryPublicationRetryAt = EditorApplication.timeSinceStartup + RegistryPublicationRetryDelaySeconds(registryPublicationAttemptCount);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// <summary>写入或更新当前editor session claim;同identity的其他存活claim必须保留以形成conflict。</summary>
|
|
346
|
+
private static bool TryRegisterInstance(string requestedState, out Exception failure)
|
|
347
|
+
{
|
|
348
|
+
failure = null;
|
|
269
349
|
// AutoStart=false不即时停当前domain,但compile/reload边界开始后不得再把旧listener发布成transitioning。
|
|
270
350
|
if (!UnityCliProjectSettings.Current.BridgeAutoStart
|
|
271
351
|
&& (domainReloading || EditorApplication.isCompiling || EditorApplication.isUpdating)) return true;
|
|
272
352
|
try
|
|
273
353
|
{
|
|
354
|
+
string recoveryReason = null;
|
|
274
355
|
WithRegistryMutex(() =>
|
|
275
356
|
{
|
|
276
|
-
var registry =
|
|
357
|
+
var registry = ReadCurrentRegistryForPublication(out recoveryReason);
|
|
277
358
|
var sessions = ReadSessions(registry);
|
|
278
359
|
var previous = sessions.FirstOrDefault(IsCurrentSession);
|
|
279
360
|
EnsureListenerGeneration(registry);
|
|
@@ -289,6 +370,8 @@ namespace UnityCli
|
|
|
289
370
|
var data = RegistryData(revision, sessions);
|
|
290
371
|
WriteRegistryTextAtomically(InstancePath(), UnityCliSerializer.Serialize(data) + "\n");
|
|
291
372
|
});
|
|
373
|
+
if (!string.IsNullOrEmpty(recoveryReason))
|
|
374
|
+
WriteLog("instance_registry_recovered reason=" + recoveryReason + " id=" + InstanceId());
|
|
292
375
|
// Registry是低频publication,不是heartbeat;日志只记录生命周期或listener变化。
|
|
293
376
|
var registrationSignature = NormalizeSessionLifecycle(requestedState) + "|" + ListenerId + "|" + listenerGeneration.ToString(CultureInfo.InvariantCulture);
|
|
294
377
|
if (!string.Equals(registrationSignature, lastRegistrationSignature, StringComparison.Ordinal))
|
|
@@ -296,15 +379,40 @@ namespace UnityCli
|
|
|
296
379
|
lastRegistrationSignature = registrationSignature;
|
|
297
380
|
WriteLog("instance_registered id=" + InstanceId() + " session=" + EditorSessionId() + " lifecycle=" + NormalizeSessionLifecycle(requestedState) + " listenerId=" + ListenerId + " generation=" + listenerGeneration.ToString(CultureInfo.InvariantCulture));
|
|
298
381
|
}
|
|
382
|
+
TryRunAutomaticInstanceCleanup();
|
|
299
383
|
return true;
|
|
300
384
|
}
|
|
301
385
|
catch (Exception exception)
|
|
302
386
|
{
|
|
303
|
-
|
|
387
|
+
failure = exception;
|
|
304
388
|
return false;
|
|
305
389
|
}
|
|
306
390
|
}
|
|
307
391
|
|
|
392
|
+
private static bool IsRetryableRegistryPublicationFailure(Exception failure)
|
|
393
|
+
{
|
|
394
|
+
return failure is IOException
|
|
395
|
+
|| failure is UnauthorizedAccessException
|
|
396
|
+
|| failure is TimeoutException
|
|
397
|
+
|| failure is Win32Exception;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private static string RegistryPublicationFailureKind(Exception failure)
|
|
401
|
+
{
|
|
402
|
+
if (failure is UnauthorizedAccessException) return "access";
|
|
403
|
+
if (failure is TimeoutException) return "lock-timeout";
|
|
404
|
+
if (failure is Win32Exception) return "windows";
|
|
405
|
+
if (failure is IOException) return "io";
|
|
406
|
+
return failure == null ? "unknown" : failure.GetType().Name;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private static double RegistryPublicationRetryDelaySeconds(int failedAttempts)
|
|
410
|
+
{
|
|
411
|
+
if (failedAttempts <= 1) return 0.25;
|
|
412
|
+
if (failedAttempts == 2) return 0.5;
|
|
413
|
+
return 1.0;
|
|
414
|
+
}
|
|
415
|
+
|
|
308
416
|
/// <summary>Editor 退出只移除当前 session claim,保留 offline project identity 记录。</summary>
|
|
309
417
|
private static void DeleteInstanceRegistration()
|
|
310
418
|
{
|
|
@@ -333,13 +441,61 @@ namespace UnityCli
|
|
|
333
441
|
if (parsed == null) throw new InvalidDataException("Instance registry is not a JSON object.");
|
|
334
442
|
if (ReadLong(parsed, "version") != 3L) return new Dictionary<string, object>();
|
|
335
443
|
var identity = ReadDictionary(parsed, "identity");
|
|
336
|
-
if (identity
|
|
337
|
-
|| !string.Equals(ReadString(identity, "instanceId"), InstanceId(), StringComparison.Ordinal)
|
|
338
|
-
|| !string.Equals(ReadString(identity, "canonicalProjectPath"), ProjectIdentity().CanonicalProjectPath, StringComparison.Ordinal))
|
|
444
|
+
if (!RegistryIdentityMatchesCurrentProject(identity))
|
|
339
445
|
throw new InvalidDataException("Instance registry identity does not match CanonicalPath v1.");
|
|
340
446
|
return parsed;
|
|
341
447
|
}
|
|
342
448
|
|
|
449
|
+
/// <summary>当前Unity发布时不继承不可验证的旧状态;文件IO失败仍由外层有界重试处理。</summary>
|
|
450
|
+
private static Dictionary<string, object> ReadCurrentRegistryForPublication(out string recoveryReason)
|
|
451
|
+
{
|
|
452
|
+
recoveryReason = null;
|
|
453
|
+
var path = InstancePath();
|
|
454
|
+
if (!File.Exists(path)) return new Dictionary<string, object>();
|
|
455
|
+
if (new FileInfo(path).Length > RegistryMaxBytes)
|
|
456
|
+
{
|
|
457
|
+
recoveryReason = "oversized";
|
|
458
|
+
return new Dictionary<string, object>();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
object root;
|
|
462
|
+
try
|
|
463
|
+
{
|
|
464
|
+
root = UnityCliSerializer.Parse(File.ReadAllText(path, Encoding.UTF8));
|
|
465
|
+
}
|
|
466
|
+
catch (FormatException)
|
|
467
|
+
{
|
|
468
|
+
recoveryReason = "malformed-json";
|
|
469
|
+
return new Dictionary<string, object>();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
var parsed = root as Dictionary<string, object>;
|
|
473
|
+
if (parsed == null)
|
|
474
|
+
{
|
|
475
|
+
recoveryReason = "non-object-root";
|
|
476
|
+
return new Dictionary<string, object>();
|
|
477
|
+
}
|
|
478
|
+
if (ReadLong(parsed, "version") != 3L)
|
|
479
|
+
{
|
|
480
|
+
recoveryReason = "unsupported-version";
|
|
481
|
+
return new Dictionary<string, object>();
|
|
482
|
+
}
|
|
483
|
+
if (!RegistryIdentityMatchesCurrentProject(ReadDictionary(parsed, "identity")))
|
|
484
|
+
{
|
|
485
|
+
recoveryReason = "identity-mismatch";
|
|
486
|
+
return new Dictionary<string, object>();
|
|
487
|
+
}
|
|
488
|
+
return parsed;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/// <summary>只信任与当前确定性文件名对应的CanonicalPath v1身份。</summary>
|
|
492
|
+
private static bool RegistryIdentityMatchesCurrentProject(Dictionary<string, object> identity)
|
|
493
|
+
{
|
|
494
|
+
return identity != null
|
|
495
|
+
&& string.Equals(ReadString(identity, "instanceId"), InstanceId(), StringComparison.Ordinal)
|
|
496
|
+
&& string.Equals(ReadString(identity, "canonicalProjectPath"), ProjectIdentity().CanonicalProjectPath, StringComparison.Ordinal);
|
|
497
|
+
}
|
|
498
|
+
|
|
343
499
|
/// <summary>读取当前 session 上一次 listener 代次;domain reload 后严格递增。</summary>
|
|
344
500
|
private static void EnsureListenerGeneration(Dictionary<string, object> registry)
|
|
345
501
|
{
|
|
@@ -591,6 +591,7 @@ namespace UnityCli
|
|
|
591
591
|
private static UnityCliToolCompletionContract ResolveRequestCompletionContract(BridgeRequest request)
|
|
592
592
|
{
|
|
593
593
|
var toolName = RequestToolName(request);
|
|
594
|
+
if (IsInstanceCleanupCommand(toolName)) return InstanceCleanupCompletionContract();
|
|
594
595
|
UnityCliToolCompletionContract contract;
|
|
595
596
|
if (UnityCliToolCompletionRegistry.TryGet(toolName, out contract)) return contract;
|
|
596
597
|
if (string.Equals(toolName, "code.exec", StringComparison.Ordinal))
|
|
@@ -67,6 +67,7 @@ namespace UnityCli
|
|
|
67
67
|
private const double RegistryRefreshSeconds = 30.0;
|
|
68
68
|
private static int port;
|
|
69
69
|
private static double nextRegistryRefreshAt;
|
|
70
|
+
private static bool readyPublicationPending;
|
|
70
71
|
private static readonly CancellationTokenSource BridgeLifetime = new CancellationTokenSource();
|
|
71
72
|
private static CancellationTokenSource runtimeSession = new CancellationTokenSource();
|
|
72
73
|
|
|
@@ -194,7 +195,8 @@ namespace UnityCli
|
|
|
194
195
|
port = StartHttpListener(listener);
|
|
195
196
|
started = true;
|
|
196
197
|
Task.Run(ListenLoop);
|
|
197
|
-
|
|
198
|
+
RegisterInstance("transitioning");
|
|
199
|
+
readyPublicationPending = true;
|
|
198
200
|
nextRegistryRefreshAt = EditorApplication.timeSinceStartup + RegistryRefreshSeconds;
|
|
199
201
|
WriteRuntimeLoadDiagnostics("listener_started");
|
|
200
202
|
WriteLog("bridge_started port=" + port + " listenerId=" + ListenerId + " diagnosticsFingerprint=" + BridgeDiagnosticsFingerprint + " assemblyMvid=" + AssemblyMvid() + " packageName=" + BridgePackageName() + " packageAssetPath=" + BridgePackageAssetPath() + " packageResolvedPath=" + BridgePackageResolvedPath());
|
|
@@ -338,6 +340,8 @@ namespace UnityCli
|
|
|
338
340
|
WriteLog("bridge_stop_failed " + exception.GetType().Name + ": " + exception.Message);
|
|
339
341
|
}
|
|
340
342
|
started = false;
|
|
343
|
+
readyPublicationPending = false;
|
|
344
|
+
CancelInstancePublicationRetry();
|
|
341
345
|
}
|
|
342
346
|
|
|
343
347
|
private static async ValueTask<string> Execute(UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#if UNITY_EDITOR
|
|
2
|
+
using System;
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using System.Diagnostics;
|
|
5
|
+
using System.Globalization;
|
|
6
|
+
using System.IO;
|
|
7
|
+
using System.Reflection;
|
|
8
|
+
using System.Security.Cryptography;
|
|
9
|
+
using System.Text;
|
|
10
|
+
using System.Threading;
|
|
11
|
+
using NUnit.Framework;
|
|
12
|
+
|
|
13
|
+
namespace UnityCli.Tests
|
|
14
|
+
{
|
|
15
|
+
public sealed class UnityCliInstanceCleanupTests
|
|
16
|
+
{
|
|
17
|
+
private Type bridgeType;
|
|
18
|
+
private MethodInfo cleanup;
|
|
19
|
+
private MethodInfo boundedLogLine;
|
|
20
|
+
private MethodInfo rollLog;
|
|
21
|
+
private string originalDataRoot;
|
|
22
|
+
private string root;
|
|
23
|
+
private string instances;
|
|
24
|
+
|
|
25
|
+
[SetUp]
|
|
26
|
+
public void SetUp()
|
|
27
|
+
{
|
|
28
|
+
bridgeType = Type.GetType("UnityCli.UnityCliBridge, UnityCli.Editor", true);
|
|
29
|
+
cleanup = bridgeType.GetMethod("RunInstanceCleanup", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("RunInstanceCleanup");
|
|
30
|
+
boundedLogLine = bridgeType.GetMethod("BoundedBridgeLogLine", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("BoundedBridgeLogLine");
|
|
31
|
+
rollLog = bridgeType.GetMethod("RollBridgeLogIfNeeded", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("RollBridgeLogIfNeeded");
|
|
32
|
+
originalDataRoot = Environment.GetEnvironmentVariable("UNITYCLI_DATA_DIR");
|
|
33
|
+
root = Path.Combine(Directory.GetCurrentDirectory(), "Library", "UnityCli", "instance-cleanup-tests");
|
|
34
|
+
instances = Path.Combine(root, "instances");
|
|
35
|
+
if (Directory.Exists(root)) Directory.Delete(root, true);
|
|
36
|
+
Directory.CreateDirectory(instances);
|
|
37
|
+
Environment.SetEnvironmentVariable("UNITYCLI_DATA_DIR", root);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
[TearDown]
|
|
41
|
+
public void TearDown()
|
|
42
|
+
{
|
|
43
|
+
Environment.SetEnvironmentVariable("UNITYCLI_DATA_DIR", originalDataRoot);
|
|
44
|
+
if (Directory.Exists(root)) Directory.Delete(root, true);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
[Test]
|
|
48
|
+
public void TwoBoundedPassesReachOfflineAndDamagedRetentionCaps()
|
|
49
|
+
{
|
|
50
|
+
var now = DateTimeOffset.UtcNow;
|
|
51
|
+
for (var index = 0; index < 115; index++)
|
|
52
|
+
WriteOffline("offline", index, index < 5 ? now.AddDays(-40 - index) : now.AddHours(-index - 1));
|
|
53
|
+
for (var index = 0; index < 30; index++)
|
|
54
|
+
WriteDamaged(index, index < 5 ? now.AddDays(-45 - index) : now.AddHours(-index - 1));
|
|
55
|
+
WriteClaim("active", 0, Process.GetCurrentProcess().Id, Process.GetCurrentProcess().StartTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), "editor");
|
|
56
|
+
WriteClaim("unverifiable", 0, 0, string.Empty, "editor");
|
|
57
|
+
WriteMixedClaims("mixed", 0);
|
|
58
|
+
|
|
59
|
+
var first = cleanup.Invoke(null, null);
|
|
60
|
+
Assert.That(Value(first, "Scanned"), Is.EqualTo(148));
|
|
61
|
+
Assert.That(Value(first, "Offline"), Is.EqualTo(115));
|
|
62
|
+
Assert.That(Value(first, "Damaged"), Is.EqualTo(30));
|
|
63
|
+
Assert.That(Value(first, "Preserved"), Is.EqualTo(3));
|
|
64
|
+
Assert.That(Value(first, "Deleted"), Is.EqualTo(20));
|
|
65
|
+
Assert.That(Value(first, "Deferred"), Is.EqualTo(5));
|
|
66
|
+
Assert.That((bool)Field(first, "Truncated"), Is.False);
|
|
67
|
+
Assert.That(File.Exists(PathFor("damaged", 4)), Is.False, "oldest damaged record must be deleted first");
|
|
68
|
+
Assert.That(File.Exists(PathFor("offline", 4)), Is.False, "oldest offline record must be deleted first");
|
|
69
|
+
Assert.That(File.Exists(PathFor("offline", 114)), Is.False, "oldest over-cap recent offline record must be deleted");
|
|
70
|
+
Assert.That(File.Exists(PathFor("offline", 5)), Is.True, "newer retained offline record must remain");
|
|
71
|
+
Assert.That(File.Exists(PathFor("damaged", 29)), Is.True, "delete budget must defer remaining planned records");
|
|
72
|
+
|
|
73
|
+
var second = cleanup.Invoke(null, null);
|
|
74
|
+
Assert.That(Value(second, "Deleted"), Is.EqualTo(5));
|
|
75
|
+
Assert.That(Value(second, "Deferred"), Is.EqualTo(0));
|
|
76
|
+
Assert.That(CountOffline(), Is.EqualTo(100));
|
|
77
|
+
Assert.That(CountDamaged(), Is.EqualTo(20));
|
|
78
|
+
Assert.That(File.Exists(PathFor("active", 0)), Is.True);
|
|
79
|
+
Assert.That(File.Exists(PathFor("unverifiable", 0)), Is.True);
|
|
80
|
+
Assert.That(File.Exists(PathFor("mixed", 0)), Is.True);
|
|
81
|
+
Assert.That(Directory.GetFiles(instances, "*.tmp").Length, Is.EqualTo(0));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
[Test]
|
|
85
|
+
public void ScanLimitSetsTruncatedAndStillCapsDeletion()
|
|
86
|
+
{
|
|
87
|
+
var now = DateTimeOffset.UtcNow.AddDays(-40);
|
|
88
|
+
for (var index = 0; index < 2001; index++) WriteOffline("scan", index, now.AddMinutes(-index));
|
|
89
|
+
var result = cleanup.Invoke(null, null);
|
|
90
|
+
Assert.That(Value(result, "Scanned"), Is.EqualTo(2000));
|
|
91
|
+
Assert.That((bool)Field(result, "Truncated"), Is.True);
|
|
92
|
+
Assert.That(Value(result, "Deleted"), Is.EqualTo(20));
|
|
93
|
+
Assert.That(Directory.GetFiles(instances, "*.json").Length, Is.EqualTo(1981));
|
|
94
|
+
Assert.That(Directory.GetFiles(instances, "*.tmp").Length, Is.EqualTo(0));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[Test]
|
|
98
|
+
public void RepeatedLogEntriesKeepTwoBoundedSegments()
|
|
99
|
+
{
|
|
100
|
+
var logPath = Path.Combine(root, "bounded.log");
|
|
101
|
+
var oversized = new string('x', 96 * 1024);
|
|
102
|
+
for (var index = 0; index < 400; index++)
|
|
103
|
+
{
|
|
104
|
+
var line = (string)boundedLogLine.Invoke(null, new object[] { oversized, 64 * 1024 });
|
|
105
|
+
var entry = line + Environment.NewLine;
|
|
106
|
+
rollLog.Invoke(null, new object[] { logPath, Encoding.UTF8.GetByteCount(entry) });
|
|
107
|
+
File.AppendAllText(logPath, entry, new UTF8Encoding(false));
|
|
108
|
+
}
|
|
109
|
+
var segments = Directory.GetFiles(root, "bounded.log*");
|
|
110
|
+
Assert.That(segments.Length, Is.EqualTo(2));
|
|
111
|
+
foreach (var segment in segments) Assert.That(new FileInfo(segment).Length, Is.LessThanOrEqualTo(10L * 1024 * 1024));
|
|
112
|
+
Assert.That(Encoding.UTF8.GetByteCount((string)boundedLogLine.Invoke(null, new object[] { oversized, 64 * 1024 })), Is.LessThanOrEqualTo(64 * 1024));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
[Test]
|
|
116
|
+
public void PerInstanceLockDefersThenAllowsTheSameOldRecord()
|
|
117
|
+
{
|
|
118
|
+
var candidate = WriteOffline("locked", 0, DateTimeOffset.UtcNow.AddDays(-40));
|
|
119
|
+
var instanceId = Path.GetFileNameWithoutExtension(candidate);
|
|
120
|
+
using (var held = new ManualResetEventSlim(false))
|
|
121
|
+
using (var release = new ManualResetEventSlim(false))
|
|
122
|
+
{
|
|
123
|
+
var thread = new Thread(delegate()
|
|
124
|
+
{
|
|
125
|
+
using (var mutex = new Mutex(false, "Local\\UnityCli.Registry." + instanceId))
|
|
126
|
+
{
|
|
127
|
+
mutex.WaitOne();
|
|
128
|
+
held.Set();
|
|
129
|
+
release.Wait();
|
|
130
|
+
mutex.ReleaseMutex();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
thread.IsBackground = true;
|
|
134
|
+
thread.Start();
|
|
135
|
+
Assert.That(held.Wait(5000), Is.True);
|
|
136
|
+
var deferred = cleanup.Invoke(null, null);
|
|
137
|
+
Assert.That(Value(deferred, "Deleted"), Is.EqualTo(0));
|
|
138
|
+
Assert.That(Value(deferred, "Deferred"), Is.EqualTo(1));
|
|
139
|
+
Assert.That(File.Exists(candidate), Is.True);
|
|
140
|
+
release.Set();
|
|
141
|
+
Assert.That(thread.Join(5000), Is.True);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var deleted = cleanup.Invoke(null, null);
|
|
145
|
+
Assert.That(Value(deleted, "Deleted"), Is.EqualTo(1));
|
|
146
|
+
Assert.That(File.Exists(candidate), Is.False);
|
|
147
|
+
Assert.That(Directory.GetFiles(instances, "*.tmp").Length, Is.EqualTo(0));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private string WriteOffline(string kind, int index, DateTimeOffset publishedAt)
|
|
151
|
+
{
|
|
152
|
+
var identity = Identity(kind, index);
|
|
153
|
+
var path = Path.Combine(instances, identity.Item1 + ".json");
|
|
154
|
+
var json = "{\"version\":3,\"revision\":1,\"canonicalizationVersion\":1,\"identity\":{\"instanceId\":\"" + identity.Item1 + "\",\"canonicalProjectPath\":\"" + Json(identity.Item2) + "\",\"displayProjectPath\":\"" + Json(identity.Item3) + "\"},\"sessions\":[],\"publishedAt\":\"" + publishedAt.ToString("O", CultureInfo.InvariantCulture) + "\"}\n";
|
|
155
|
+
File.WriteAllText(path, json, new UTF8Encoding(false));
|
|
156
|
+
File.SetLastWriteTimeUtc(path, publishedAt.UtcDateTime);
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private void WriteDamaged(int index, DateTimeOffset writtenAt)
|
|
161
|
+
{
|
|
162
|
+
var path = PathFor("damaged", index);
|
|
163
|
+
File.WriteAllText(path, "{\"version\":3,", new UTF8Encoding(false));
|
|
164
|
+
File.SetLastWriteTimeUtc(path, writtenAt.UtcDateTime);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private void WriteClaim(string kind, int index, int processId, string processStartedAt, string target)
|
|
168
|
+
{
|
|
169
|
+
var identity = Identity(kind, index);
|
|
170
|
+
var path = Path.Combine(instances, identity.Item1 + ".json");
|
|
171
|
+
var sessionId = "session-" + new string(kind == "active" ? 'a' : 'b', 64);
|
|
172
|
+
var json = "{\"version\":3,\"revision\":1,\"canonicalizationVersion\":1,\"identity\":{\"instanceId\":\"" + identity.Item1 + "\",\"canonicalProjectPath\":\"" + Json(identity.Item2) + "\",\"displayProjectPath\":\"" + Json(identity.Item3) + "\"},\"sessions\":[{\"target\":\"" + target + "\",\"sessionId\":\"" + sessionId + "\",\"processId\":" + processId.ToString(CultureInfo.InvariantCulture) + ",\"processStartedAt\":\"" + processStartedAt + "\"}],\"publishedAt\":\"" + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture) + "\"}\n";
|
|
173
|
+
File.WriteAllText(path, json, new UTF8Encoding(false));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private void WriteMixedClaims(string kind, int index)
|
|
177
|
+
{
|
|
178
|
+
var identity = Identity(kind, index);
|
|
179
|
+
var process = Process.GetCurrentProcess();
|
|
180
|
+
var started = process.StartTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
|
181
|
+
var path = Path.Combine(instances, identity.Item1 + ".json");
|
|
182
|
+
var json = "{\"version\":3,\"revision\":1,\"canonicalizationVersion\":1,\"identity\":{\"instanceId\":\"" + identity.Item1 + "\",\"canonicalProjectPath\":\"" + Json(identity.Item2) + "\",\"displayProjectPath\":\"" + Json(identity.Item3) + "\"},\"sessions\":[{\"target\":\"editor\",\"processId\":" + process.Id.ToString(CultureInfo.InvariantCulture) + ",\"processStartedAt\":\"" + started + "\"},{\"target\":\"runtime\",\"processId\":" + process.Id.ToString(CultureInfo.InvariantCulture) + ",\"processStartedAt\":\"" + started + "\"}],\"publishedAt\":\"" + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture) + "\"}\n";
|
|
183
|
+
File.WriteAllText(path, json, new UTF8Encoding(false));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private int CountOffline()
|
|
187
|
+
{
|
|
188
|
+
var count = 0;
|
|
189
|
+
foreach (var path in Directory.GetFiles(instances, "*.json"))
|
|
190
|
+
{
|
|
191
|
+
var text = File.ReadAllText(path, Encoding.UTF8);
|
|
192
|
+
if (text.Contains("\"sessions\":[]")) count++;
|
|
193
|
+
}
|
|
194
|
+
return count;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private int CountDamaged()
|
|
198
|
+
{
|
|
199
|
+
var count = 0;
|
|
200
|
+
foreach (var path in Directory.GetFiles(instances, "*.json"))
|
|
201
|
+
if (File.ReadAllText(path, Encoding.UTF8) == "{\"version\":3,") count++;
|
|
202
|
+
return count;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private string PathFor(string kind, int index)
|
|
206
|
+
{
|
|
207
|
+
return Path.Combine(instances, Identity(kind, index).Item1 + ".json");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private Tuple<string, string, string> Identity(string kind, int index)
|
|
211
|
+
{
|
|
212
|
+
var canonical = "d:\\unitycli-cleanup-tests\\" + kind + "-" + index.ToString("D3", CultureInfo.InvariantCulture);
|
|
213
|
+
return Tuple.Create("unity-" + Sha256(canonical), canonical, "D:\\UnityCli-Cleanup-Tests\\" + kind + "-" + index.ToString("D3", CultureInfo.InvariantCulture));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private static string Sha256(string value)
|
|
217
|
+
{
|
|
218
|
+
using (var sha = SHA256.Create())
|
|
219
|
+
{
|
|
220
|
+
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value));
|
|
221
|
+
var result = new StringBuilder(64);
|
|
222
|
+
foreach (var item in bytes) result.Append(item.ToString("x2", CultureInfo.InvariantCulture));
|
|
223
|
+
return result.ToString();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static string Json(string value) { return value.Replace("\\", "\\\\").Replace("\"", "\\\""); }
|
|
228
|
+
private static int Value(object owner, string name) { return Convert.ToInt32(Field(owner, name), CultureInfo.InvariantCulture); }
|
|
229
|
+
private static object Field(object owner, string name) { return owner.GetType().GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).GetValue(owner); }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
#endif
|