@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.
Files changed (98) hide show
  1. package/README.md +111 -299
  2. package/contracts/init-contract.json +64 -0
  3. package/contracts/instances-contract.json +49 -0
  4. package/contracts/package-assembly-closure.json +33 -0
  5. package/contracts/package-files.json +1953 -0
  6. package/dist/bridge/session-supervisor.d.ts +2 -0
  7. package/dist/bridge/session-supervisor.js +52 -19
  8. package/dist/bridge/session-supervisor.js.map +1 -1
  9. package/dist/cli-commands/command.js +33 -15
  10. package/dist/cli-commands/command.js.map +1 -1
  11. package/dist/cli-commands/init-discovery.d.ts +11 -0
  12. package/dist/cli-commands/init-discovery.js +185 -0
  13. package/dist/cli-commands/init-discovery.js.map +1 -0
  14. package/dist/cli-commands/init-help.d.ts +3 -0
  15. package/dist/cli-commands/init-help.js +41 -0
  16. package/dist/cli-commands/init-help.js.map +1 -0
  17. package/dist/cli-commands/init-skill.d.ts +10 -0
  18. package/dist/cli-commands/init-skill.js +171 -0
  19. package/dist/cli-commands/init-skill.js.map +1 -0
  20. package/dist/cli-commands/instances.d.ts +4 -0
  21. package/dist/cli-commands/instances.js +90 -0
  22. package/dist/cli-commands/instances.js.map +1 -0
  23. package/dist/cli-commands/package.d.ts +2 -2
  24. package/dist/cli-commands/package.js +97 -130
  25. package/dist/cli-commands/package.js.map +1 -1
  26. package/dist/cli-commands/skill-installer.d.ts +29 -13
  27. package/dist/cli-commands/skill-installer.js +127 -88
  28. package/dist/cli-commands/skill-installer.js.map +1 -1
  29. package/dist/cli-commands/types.d.ts +4 -0
  30. package/dist/cli.d.ts +1 -1
  31. package/dist/cli.js +46 -37
  32. package/dist/cli.js.map +1 -1
  33. package/dist/contracts/command-contract.js +1 -0
  34. package/dist/contracts/command-contract.js.map +1 -1
  35. package/dist/doctor.js +1 -1
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/instances.d.ts +1 -1
  38. package/dist/instances.js +63 -38
  39. package/dist/instances.js.map +1 -1
  40. package/dist/output.d.ts +4 -4
  41. package/dist/output.js +3 -4
  42. package/dist/output.js.map +1 -1
  43. package/dist/package-manager.d.ts +47 -2
  44. package/dist/package-manager.js +267 -16
  45. package/dist/package-manager.js.map +1 -1
  46. package/dist/tools/.generated-schemas.js +2 -2
  47. package/dist/tools/.generated-schemas.js.map +1 -1
  48. package/dist/tools/commands.js +11 -4
  49. package/dist/tools/commands.js.map +1 -1
  50. package/dist/tools/dispatcher.js +1 -1
  51. package/dist/tools/dispatcher.js.map +1 -1
  52. package/dist/tools/tools/group-list.d.ts +4 -8
  53. package/dist/tools/tools/group-list.js +12 -13
  54. package/dist/tools/tools/group-list.js.map +1 -1
  55. package/dist/tools/tools/pagination.d.ts +17 -0
  56. package/dist/tools/tools/pagination.js +28 -0
  57. package/dist/tools/tools/pagination.js.map +1 -0
  58. package/dist/tools/tools/search.d.ts +3 -5
  59. package/dist/tools/tools/search.js +12 -12
  60. package/dist/tools/tools/search.js.map +1 -1
  61. package/dist/unity-project-processes.d.ts +7 -0
  62. package/dist/unity-project-processes.js +54 -0
  63. package/dist/unity-project-processes.js.map +1 -1
  64. package/docs/README.md +7 -4
  65. package/docs/architecture.md +103 -0
  66. package/docs/availability-differences.md +1 -1
  67. package/docs/command-execution.md +6 -1
  68. package/docs/development.md +91 -0
  69. package/docs/error-codes.md +21 -5
  70. package/docs/extensions.md +13 -13
  71. package/docs/target-and-instance.md +6 -4
  72. package/docs/tool-discovery.md +12 -10
  73. package/docs/tool-execution.md +3 -2
  74. package/package.json +10 -4
  75. package/schemas/init.schema.json +209 -0
  76. package/schemas/instances.schema.json +104 -0
  77. package/skills/unitycli/SKILL.md +18 -6
  78. package/skills/unitycli/manifest.json +2 -2
  79. package/skills/unitycli/references/custom-tools.md +3 -3
  80. package/skills/unitycli/references/operation-protocol.md +6 -4
  81. package/skills/unitycli/reports/output_quality_scorecard.md +1 -1
  82. package/skills/unitycli/reports/trust-report.md +1 -1
  83. package/unitypkg/Editor/Bridge/BridgeCommandRegistry.cs +1 -0
  84. package/unitypkg/Editor/Bridge/BridgeWorkQueue.cs +18 -6
  85. package/unitypkg/Editor/Infrastructure/CodeExecSupport.cs +8 -77
  86. package/unitypkg/Editor/Infrastructure/UnityCliInstanceCleanup.cs +295 -0
  87. package/unitypkg/Editor/Infrastructure/UnityCliInstanceRegistry.cs +166 -10
  88. package/unitypkg/Editor/Infrastructure/UnityCliOperationCoordinator.cs +1 -0
  89. package/unitypkg/Editor/UnityCliBridge.cs +5 -1
  90. package/unitypkg/Tests/Editor/UnityCliInstanceCleanupTests.cs +232 -0
  91. package/unitypkg/Tests/Editor/UnityCliInstancePublicationRetryTests.cs +143 -0
  92. package/unitypkg/UnityCli.Editor.asmdef +1 -5
  93. package/unitypkg/package.json +1 -1
  94. package/unitypkg/Editor/Roslyn/LICENSE.txt +0 -23
  95. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.CSharp.dll +0 -0
  96. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.dll +0 -0
  97. package/unitypkg/Editor/Roslyn/System.Collections.Immutable.dll +0 -0
  98. 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>在 native current-user DACL named mutex 内执行临界区;abandoned 表示已取得所有权。</summary>
186
- private static void WithCurrentUserMutex(string name, int timeoutMs, Action action)
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) throw new TimeoutException("Timed out waiting for current-user mutex.");
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>写入或更新当前 editor session claim;同 identity 的其他存活 claim 必须保留以形成 conflict。</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 = ReadCurrentRegistry();
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
- WriteLog("instance_register_failed " + exception);
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 == null
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
- if (!RegisterInstance("ready")) throw new InvalidOperationException("Initial instance registry publication failed.");
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