com.wallstop-studios.dxmessaging 3.2.0 → 3.2.1

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 (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/Editor/Analyzers/DxMessagingConsoleHarvester.cs +8 -8
  3. package/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs +171 -13
  4. package/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +391 -96
  5. package/Editor/CustomEditors/MessageAwareComponentInspectorView.cs +279 -0
  6. package/Editor/CustomEditors/MessageAwareComponentInspectorView.cs.meta +11 -0
  7. package/Editor/DxMessagingEditorPalette.cs +66 -0
  8. package/Editor/DxMessagingEditorPalette.cs.meta +11 -0
  9. package/Editor/DxMessagingEditorTheme.cs +200 -0
  10. package/Editor/DxMessagingEditorTheme.cs.meta +11 -0
  11. package/Editor/Icons/dxmessaging-icon-256.png +0 -0
  12. package/Editor/Icons/dxmessaging-icon-256.png.meta +156 -0
  13. package/Editor/Icons/dxmessaging-icon-32.png +0 -0
  14. package/Editor/Icons/dxmessaging-icon-32.png.meta +156 -0
  15. package/Editor/Icons/dxmessaging-icon-48.png +0 -0
  16. package/Editor/Icons/dxmessaging-icon-48.png.meta +156 -0
  17. package/Editor/Icons.meta +8 -0
  18. package/Editor/Settings/DxMessagingSettingsProvider.cs +368 -33
  19. package/Editor/Testing/MessagingComponentEditorHarness.cs +11 -4
  20. package/Editor/Theme/DxMessagingTheme.uss +255 -0
  21. package/Editor/Theme/DxMessagingTheme.uss.meta +11 -0
  22. package/Editor/Theme/DxTokens.uss +90 -0
  23. package/Editor/Theme/DxTokens.uss.meta +11 -0
  24. package/Editor/Theme.meta +8 -0
  25. package/Editor/Windows/DxMessagingFlowGraphWindow.cs +5513 -0
  26. package/Editor/Windows/DxMessagingFlowGraphWindow.cs.meta +11 -0
  27. package/Editor/Windows/DxMessagingMessageMonitorWindow.cs +2288 -0
  28. package/Editor/Windows/DxMessagingMessageMonitorWindow.cs.meta +11 -0
  29. package/Editor/Windows.meta +8 -0
  30. package/README.md +84 -35
  31. package/Runtime/AssemblyInfo.cs +1 -0
  32. package/Runtime/Core/Diagnostics/MessageEmissionData.cs +29 -1
  33. package/Runtime/Core/Extensions/MessageExtensions.cs +4 -5
  34. package/Runtime/Core/Helper/MessageCache.cs +56 -23
  35. package/Runtime/Core/Internal/FlatDispatch.cs +32 -0
  36. package/Runtime/Core/Internal/TypedSlots.cs +3 -7
  37. package/Runtime/Core/MessageBus/MessageBus.cs +577 -142
  38. package/Runtime/Core/MessageBus/RegistrationLog.cs +25 -12
  39. package/Runtime/Core/MessageHandler.cs +774 -786
  40. package/Runtime/Core/MessageRegistrationHandle.cs +19 -5
  41. package/Runtime/Core/MessageRegistrationToken.cs +1080 -520
  42. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs +173 -0
  43. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs.meta +11 -0
  44. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.unity +424 -0
  45. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.unity.meta +7 -0
  46. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs +185 -0
  47. package/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs.meta +11 -0
  48. package/Samples~/Diagnostics Tooling Exerciser/Messages.cs +46 -0
  49. package/Samples~/Diagnostics Tooling Exerciser/Messages.cs.meta +11 -0
  50. package/Samples~/Diagnostics Tooling Exerciser/README.md +53 -0
  51. package/Samples~/Diagnostics Tooling Exerciser/README.md.meta +7 -0
  52. package/Samples~/Diagnostics Tooling Exerciser/WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample.asmdef +13 -0
  53. package/Samples~/Diagnostics Tooling Exerciser/WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample.asmdef.meta +7 -0
  54. package/Samples~/Diagnostics Tooling Exerciser.meta +8 -0
  55. package/Samples~/Mini Combat/README.md +1 -1
  56. package/Samples~/Mini Combat/Walkthrough.md +3 -3
  57. package/docs/images/DxMessaging-banner.svg +79 -0
  58. package/docs/images/DxMessaging-banner.svg.meta +53 -0
  59. package/docs/images.meta +8 -0
  60. package/docs.meta +8 -0
  61. package/package.json +13 -4
@@ -59,6 +59,7 @@ namespace DxMessaging.Core.MessageBus
59
59
  /// </summary>
60
60
  public long EmissionId => 0 < _dispatchDepth ? _scopedEmissionId : _emissionId;
61
61
  internal long TickCounter => _tickCounter;
62
+ internal bool IsDispatching => _dispatchDepth > 0;
62
63
 
63
64
  private const long DefaultIdleEvictionTicks = 30;
64
65
  private const double DefaultEvictionTickIntervalSeconds = 5d;
@@ -173,18 +174,74 @@ namespace DxMessaging.Core.MessageBus
173
174
 
174
175
  private static CollectionPool<
175
176
  Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
176
- > ContextHandlerByTargetDicts => ContextHandlerByTargetDictPoolHolder.Instance;
177
+ > ContextHandlerByTargetDictsOverride;
178
+
179
+ private static CollectionPool<
180
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
181
+ > ContextHandlerByTargetDicts =>
182
+ ContextHandlerByTargetDictsOverride ?? ContextHandlerByTargetDictPoolHolder.Instance;
183
+
184
+ private static CollectionPool<
185
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
186
+ > CreateContextHandlerByTargetPool(int maxRetained, bool useLru)
187
+ {
188
+ return new CollectionPool<Dictionary<InstanceId, HandlerCache<int, HandlerCache>>>(
189
+ maxRetained,
190
+ useLru,
191
+ factory: static () => CreateFreshContextHandlerMap(),
192
+ onRecycled: static dict => dict.Clear()
193
+ );
194
+ }
195
+
196
+ private static Dictionary<
197
+ InstanceId,
198
+ HandlerCache<int, HandlerCache>
199
+ > CreateFreshContextHandlerMap()
200
+ {
201
+ return new Dictionary<InstanceId, HandlerCache<int, HandlerCache>>();
202
+ }
203
+
204
+ internal static object CreateContextMapSeedForBenchmark()
205
+ {
206
+ return new HandlerCache<int, HandlerCache>();
207
+ }
208
+
209
+ internal static object CreatePopulatedContextMapForBenchmark(InstanceId[] keys, object seed)
210
+ {
211
+ if (keys == null)
212
+ {
213
+ throw new ArgumentNullException(nameof(keys));
214
+ }
215
+ HandlerCache<int, HandlerCache> typedSeed =
216
+ seed as HandlerCache<int, HandlerCache>
217
+ ?? throw new ArgumentException("Unexpected context-map seed type.", nameof(seed));
218
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>> map =
219
+ CreateFreshContextHandlerMap();
220
+ for (int index = 0; index < keys.Length; ++index)
221
+ {
222
+ map[keys[index]] = typedSeed;
223
+ }
224
+ return map;
225
+ }
226
+
227
+ internal static void ObserveContextMapForBenchmark(
228
+ object opaqueMap,
229
+ out int count,
230
+ out int capacity
231
+ )
232
+ {
233
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>> map =
234
+ opaqueMap as Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
235
+ ?? throw new ArgumentException("Unexpected context-map type.", nameof(opaqueMap));
236
+ count = map.Count;
237
+ capacity = map.EnsureCapacity(0);
238
+ }
177
239
 
178
240
  private static class ContextHandlerByTargetDictPoolHolder
179
241
  {
180
242
  public static readonly CollectionPool<
181
243
  Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
182
- > Instance = new(
183
- maxRetained: 512,
184
- useLru: true,
185
- factory: static () => new Dictionary<InstanceId, HandlerCache<int, HandlerCache>>(),
186
- onRecycled: static dict => dict.Clear()
187
- );
244
+ > Instance = CreateContextHandlerByTargetPool(maxRetained: 512, useLru: true);
188
245
  }
189
246
 
190
247
  internal static int ResetStaticPools()
@@ -192,11 +249,10 @@ namespace DxMessaging.Core.MessageBus
192
249
  return ContextHandlerByTargetDicts.Trim(0);
193
250
  }
194
251
 
195
- // One bucket entry per registered MessageHandler. Entry arrays are
196
- // populated only for the GLOBAL accept-all snapshots (the only
197
- // remaining bucket-walking dispatch path); non-global snapshots carry
198
- // count-only buckets (see BuildDispatchSnapshot) because their
199
- // dispatch happens exclusively through the resolved flat arrays.
252
+ // One bucket entry per registered MessageHandler. Bucket arrays are
253
+ // populated only for GLOBAL accept-all snapshots (the only remaining
254
+ // bucket-walking dispatch path). Non-global snapshots carry a resolved
255
+ // flat array and its entry count directly.
200
256
  internal readonly struct DispatchEntry
201
257
  {
202
258
  public DispatchEntry(MessageHandler handler)
@@ -227,11 +283,6 @@ namespace DxMessaging.Core.MessageBus
227
283
  public int entryCount;
228
284
  public bool pooledEntries;
229
285
 
230
- public static DispatchBucket CreateEmpty(int priority)
231
- {
232
- return new DispatchBucket(priority, Array.Empty<DispatchEntry>(), 0, false);
233
- }
234
-
235
286
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
236
287
  public void ReleaseEntries()
237
288
  {
@@ -253,18 +304,42 @@ namespace DxMessaging.Core.MessageBus
253
304
  public static readonly DispatchSnapshot Empty = new DispatchSnapshot(
254
305
  Array.Empty<DispatchBucket>(),
255
306
  0,
307
+ 0,
308
+ false,
256
309
  false
257
310
  );
258
311
 
259
- public DispatchSnapshot(DispatchBucket[] buckets, int count, bool pooled)
312
+ public DispatchSnapshot(
313
+ DispatchBucket[] buckets,
314
+ int bucketCount,
315
+ int entryCount,
316
+ bool hasRegistrations,
317
+ bool pooled
318
+ )
260
319
  {
261
320
  this.buckets = buckets;
262
- bucketCount = count;
321
+ this.bucketCount = bucketCount;
322
+ this.entryCount = entryCount;
323
+ this.hasRegistrations = hasRegistrations;
263
324
  _pooled = pooled;
325
+ _pooledBuckets = pooled;
326
+ }
327
+
328
+ public DispatchSnapshot(FlatDispatchArray flat, bool hasRegistrations)
329
+ {
330
+ buckets = Array.Empty<DispatchBucket>();
331
+ bucketCount = 0;
332
+ entryCount = flat?.Count ?? 0;
333
+ this.hasRegistrations = hasRegistrations;
334
+ this.flat = flat;
335
+ _pooled = true;
336
+ _pooledBuckets = false;
264
337
  }
265
338
 
266
339
  public DispatchBucket[] buckets;
267
340
  public int bucketCount;
341
+ public int entryCount;
342
+ public bool hasRegistrations;
268
343
 
269
344
  // Resolved flat dispatch entries for every flattened slot kind:
270
345
  // untargeted handle/post, targeted/broadcast Default handle/post
@@ -278,8 +353,9 @@ namespace DxMessaging.Core.MessageBus
278
353
  // Release().
279
354
  public FlatDispatchArray flat;
280
355
  private bool _pooled;
356
+ private readonly bool _pooledBuckets;
281
357
 
282
- public bool IsEmpty => bucketCount == 0;
358
+ public bool IsInitialized => !ReferenceEquals(this, Empty);
283
359
 
284
360
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
285
361
  public void Release()
@@ -292,15 +368,21 @@ namespace DxMessaging.Core.MessageBus
292
368
  flat?.Release();
293
369
  flat = null;
294
370
 
295
- for (int i = 0; i < bucketCount; ++i)
371
+ if (_pooledBuckets)
296
372
  {
297
- buckets[i].ReleaseEntries();
373
+ for (int i = 0; i < bucketCount; ++i)
374
+ {
375
+ buckets[i].ReleaseEntries();
376
+ }
377
+
378
+ Array.Clear(buckets, 0, bucketCount);
379
+ DispatchBucketPool.Return(buckets);
298
380
  }
299
381
 
300
- Array.Clear(buckets, 0, bucketCount);
301
- DispatchBucketPool.Return(buckets);
302
382
  buckets = Array.Empty<DispatchBucket>();
303
383
  bucketCount = 0;
384
+ entryCount = 0;
385
+ hasRegistrations = false;
304
386
  _pooled = false;
305
387
  }
306
388
  }
@@ -375,15 +457,12 @@ namespace DxMessaging.Core.MessageBus
375
457
  {
376
458
  public readonly Dictionary<TKey, TValue> handlers = new();
377
459
  public readonly List<TKey> order = new();
378
- public readonly List<KeyValuePair<TKey, TValue>> cache = new();
379
460
  public long version;
380
- public long lastSeenVersion = -1;
381
- public long lastSeenEmissionId = -1;
382
461
  public long lastTouchTicks;
383
462
  public DispatchState dispatchState;
384
463
 
385
464
  /// <summary>
386
- /// Clears all cached handler references and resets the version tracking metadata.
465
+ /// Clears all handler references and resets the mutation version.
387
466
  /// </summary>
388
467
  public void Clear()
389
468
  {
@@ -392,10 +471,7 @@ namespace DxMessaging.Core.MessageBus
392
471
  // is handled by sweep-driven slot reset paths.
393
472
  handlers.Clear();
394
473
  order.Clear();
395
- cache.Clear();
396
474
  version = 0;
397
- lastSeenVersion = -1;
398
- lastSeenEmissionId = -1;
399
475
  dispatchState?.Reset();
400
476
  dispatchState = null;
401
477
  }
@@ -404,13 +480,11 @@ namespace DxMessaging.Core.MessageBus
404
480
  private sealed class InterceptorCache<TValue>
405
481
  {
406
482
  public readonly SortedList<int, List<TValue>> handlers = new();
407
- public long lastSeenEmissionId = -1;
408
483
  public long lastTouchTicks;
409
484
 
410
485
  public void Clear()
411
486
  {
412
487
  handlers.Clear();
413
- lastSeenEmissionId = -1;
414
488
  lastTouchTicks = 0;
415
489
  }
416
490
  }
@@ -489,16 +563,14 @@ namespace DxMessaging.Core.MessageBus
489
563
  // across components. Invariants: contains exactly the keys of
490
564
  // <see cref="handlers"/>; a key is appended on its FIRST
491
565
  // registration only (refcount increments do not move it) and
492
- // removed when its refcount drops to zero. Mirrors the
493
- // MessageHandler-side HandlerActionCache.insertionOrder design.
566
+ // removed when its refcount drops to zero. The MessageHandler-side
567
+ // cache keeps this invariant inside one ordered container; the
568
+ // bus-side map remains a separate candidate.
494
569
  public readonly List<MessageHandler> insertionOrder = new();
495
- public readonly List<MessageHandler> cache = new();
496
570
  public long version;
497
- public long lastSeenVersion = -1;
498
- public long lastSeenEmissionId = -1;
499
571
 
500
572
  /// <summary>
501
- /// Clears all cached handler references and resets the version tracking metadata.
573
+ /// Clears all handler references and resets the mutation version.
502
574
  /// </summary>
503
575
  public void Clear()
504
576
  {
@@ -507,10 +579,7 @@ namespace DxMessaging.Core.MessageBus
507
579
  // is handled by sweep-driven slot reset paths.
508
580
  handlers.Clear();
509
581
  insertionOrder.Clear();
510
- cache.Clear();
511
582
  version = 0;
512
- lastSeenVersion = -1;
513
- lastSeenEmissionId = -1;
514
583
  }
515
584
  }
516
585
 
@@ -784,7 +853,7 @@ namespace DxMessaging.Core.MessageBus
784
853
  public static bool SourcedRegistered;
785
854
  }
786
855
 
787
- public RegistrationLog Log => _log;
856
+ public RegistrationLog Log => _log ??= new RegistrationLog();
788
857
 
789
858
  // Storage trio for typed and global dispatch. _scalarSinks and
790
859
  // _contextSinks are SlotKey-indexed arrays of MessageCache (call sites
@@ -829,6 +898,127 @@ namespace DxMessaging.Core.MessageBus
829
898
  private readonly MessageCache<DispatchPlan> _targetedDispatchPlans = new();
830
899
  private readonly MessageCache<DispatchPlan> _broadcastDispatchPlans = new();
831
900
 
901
+ internal readonly struct FlatSnapshotStorageObservation
902
+ {
903
+ internal FlatSnapshotStorageObservation(
904
+ int entryCount,
905
+ int arrayCapacity,
906
+ int emptyHolderPoolCount
907
+ )
908
+ {
909
+ EntryCount = entryCount;
910
+ ArrayCapacity = arrayCapacity;
911
+ EmptyHolderPoolCount = emptyHolderPoolCount;
912
+ }
913
+
914
+ internal int EntryCount { get; }
915
+
916
+ internal int ArrayCapacity { get; }
917
+
918
+ /// <summary>
919
+ /// Released holder objects currently pooled with empty entry arrays. This does not
920
+ /// measure arrays or retained bytes; ArrayPool memory retention requires an external
921
+ /// memory measurement.
922
+ /// </summary>
923
+ internal int EmptyHolderPoolCount { get; }
924
+ }
925
+
926
+ internal readonly struct PriorityStorageObservation
927
+ {
928
+ internal PriorityStorageObservation(int entries, int mapCapacity, int orderCapacity)
929
+ {
930
+ Entries = entries;
931
+ MapCapacity = mapCapacity;
932
+ OrderCapacity = orderCapacity;
933
+ }
934
+
935
+ internal int Entries { get; }
936
+
937
+ internal int MapCapacity { get; }
938
+
939
+ internal int OrderCapacity { get; }
940
+ }
941
+
942
+ internal bool TryObserveUntargetedPriorityStorageForBenchmark<TMessage>(
943
+ out PriorityStorageObservation observation
944
+ )
945
+ where TMessage : IUntargetedMessage
946
+ {
947
+ if (
948
+ !_scalarSinks[BusSinkIndex.UntargetedHandleDefault]
949
+ .TryGetValue<TMessage>(out HandlerCache<int, HandlerCache> handlers)
950
+ )
951
+ {
952
+ observation = default;
953
+ return false;
954
+ }
955
+
956
+ observation = ObservePriorityStorage(handlers);
957
+ return true;
958
+ }
959
+
960
+ internal static object CreatePriorityStorageOwnerForBenchmark()
961
+ {
962
+ return new HandlerCache<int, HandlerCache>();
963
+ }
964
+
965
+ internal static bool TryObservePriorityStorageOwnerForBenchmark(
966
+ object owner,
967
+ out PriorityStorageObservation observation
968
+ )
969
+ {
970
+ if (owner is not HandlerCache<int, HandlerCache> handlers)
971
+ {
972
+ observation = default;
973
+ return false;
974
+ }
975
+
976
+ observation = ObservePriorityStorage(handlers);
977
+ return true;
978
+ }
979
+
980
+ private static PriorityStorageObservation ObservePriorityStorage(
981
+ HandlerCache<int, HandlerCache> handlers
982
+ )
983
+ {
984
+ return new PriorityStorageObservation(
985
+ handlers.handlers.Count,
986
+ handlers.handlers.EnsureCapacity(0),
987
+ handlers.order.Capacity
988
+ );
989
+ }
990
+
991
+ /// <summary>
992
+ /// Read-only benchmark telemetry for the active untargeted flat snapshot of
993
+ /// <typeparamref name="TMessage"/>. This method does not acquire, build, or mutate a
994
+ /// snapshot, so observing storage cannot alter the path being measured.
995
+ /// </summary>
996
+ internal bool TryObserveUntargetedFlatSnapshotStorageForBenchmark<TMessage>(
997
+ out FlatSnapshotStorageObservation observation
998
+ )
999
+ where TMessage : IUntargetedMessage
1000
+ {
1001
+ if (
1002
+ !_scalarSinks[BusSinkIndex.UntargetedHandleDefault]
1003
+ .TryGetValue<TMessage>(out HandlerCache<int, HandlerCache> handlers)
1004
+ || handlers.dispatchState == null
1005
+ || ReferenceEquals(handlers.dispatchState.active, DispatchSnapshot.Empty)
1006
+ || handlers.dispatchState.active.flat == null
1007
+ )
1008
+ {
1009
+ observation = default;
1010
+ return false;
1011
+ }
1012
+
1013
+ FlatDispatchArray flat = handlers.dispatchState.active.flat;
1014
+ observation = new FlatSnapshotStorageObservation(
1015
+ flat.Count,
1016
+ flat.Capacity,
1017
+ flat.EmptyHolderPoolCount
1018
+ );
1019
+ return true;
1020
+ }
1021
+
832
1022
  // Bumped by every mutation that can change what a DispatchPlan
833
1023
  // caches or decides. Plans compare their stamp against this value at
834
1024
  // every emission; the emit shells also re-compare mid-emission to
@@ -923,7 +1113,9 @@ namespace DxMessaging.Core.MessageBus
923
1113
  }
924
1114
 
925
1115
  #if UNITY_2021_3_OR_NEWER
926
- private static readonly List<WeakReference<MessageBus>> IdleSweepBuses = new();
1116
+ private static List<WeakReference<MessageBus>> IdleSweepBuses = new();
1117
+ private static IdleSweepRegistryBenchmarkScope ActiveIdleSweepRegistryBenchmarkScope;
1118
+ private static ContextMapPoolBenchmarkScope ActiveContextMapPoolBenchmarkScope;
927
1119
  private static bool RuntimeSettingsSubscribed;
928
1120
 
929
1121
  private static void RegisterForIdleSweeps(MessageBus bus)
@@ -997,6 +1189,206 @@ namespace DxMessaging.Core.MessageBus
997
1189
  ResetStaticPools();
998
1190
  }
999
1191
 
1192
+ internal static IDisposable IsolateIdleSweepRegistryForBenchmark()
1193
+ {
1194
+ return new IdleSweepRegistryBenchmarkScope();
1195
+ }
1196
+
1197
+ internal static int IdleSweepRegistryCountForBenchmark => IdleSweepBuses.Count;
1198
+
1199
+ internal static object IdleSweepRegistryIdentityForBenchmark => IdleSweepBuses;
1200
+
1201
+ internal static IDisposable IsolateContextMapPoolForBenchmark()
1202
+ {
1203
+ return new ContextMapPoolBenchmarkScope();
1204
+ }
1205
+
1206
+ internal static bool ContextMapPoolOverrideActiveForBenchmark =>
1207
+ ContextHandlerByTargetDictsOverride != null;
1208
+
1209
+ internal static ContextMapPoolBenchmarkObservation ObserveContextMapPoolForBenchmark()
1210
+ {
1211
+ CollectionPool<Dictionary<InstanceId, HandlerCache<int, HandlerCache>>> current =
1212
+ ContextHandlerByTargetDicts;
1213
+ return new ContextMapPoolBenchmarkObservation(
1214
+ current,
1215
+ current.UseLru,
1216
+ current.MaxRetained
1217
+ );
1218
+ }
1219
+
1220
+ internal static void ConfigureContextMapPoolForBenchmark(bool useLru, int maxRetained)
1221
+ {
1222
+ ContextHandlerByTargetDicts.UseLru = useLru;
1223
+ ContextHandlerByTargetDicts.MaxRetained = maxRetained;
1224
+ }
1225
+
1226
+ private sealed class IdleSweepRegistryBenchmarkScope : IDisposable
1227
+ {
1228
+ private readonly List<WeakReference<MessageBus>> _saved;
1229
+ private readonly IdleSweepRegistryBenchmarkScope _parent;
1230
+ private readonly int _ownerThreadId;
1231
+ private bool _disposed;
1232
+
1233
+ internal IdleSweepRegistryBenchmarkScope()
1234
+ {
1235
+ _ownerThreadId = Environment.CurrentManagedThreadId;
1236
+ lock (typeof(IdleSweepRegistryBenchmarkScope))
1237
+ {
1238
+ if (
1239
+ ActiveIdleSweepRegistryBenchmarkScope != null
1240
+ && ActiveIdleSweepRegistryBenchmarkScope._ownerThreadId != _ownerThreadId
1241
+ )
1242
+ {
1243
+ throw new InvalidOperationException(
1244
+ "Nested idle-sweep benchmark scopes must share one owning thread."
1245
+ );
1246
+ }
1247
+
1248
+ _parent = ActiveIdleSweepRegistryBenchmarkScope;
1249
+ _saved = IdleSweepBuses;
1250
+ ActiveIdleSweepRegistryBenchmarkScope = this;
1251
+ IdleSweepBuses = new List<WeakReference<MessageBus>>();
1252
+ }
1253
+ }
1254
+
1255
+ public void Dispose()
1256
+ {
1257
+ lock (typeof(IdleSweepRegistryBenchmarkScope))
1258
+ {
1259
+ if (_disposed)
1260
+ {
1261
+ return;
1262
+ }
1263
+ if (Environment.CurrentManagedThreadId != _ownerThreadId)
1264
+ {
1265
+ throw new InvalidOperationException(
1266
+ "Idle-sweep benchmark scopes must be disposed on their owning thread."
1267
+ );
1268
+ }
1269
+
1270
+ _disposed = true;
1271
+ while (
1272
+ ActiveIdleSweepRegistryBenchmarkScope != null
1273
+ && ActiveIdleSweepRegistryBenchmarkScope._disposed
1274
+ )
1275
+ {
1276
+ IdleSweepRegistryBenchmarkScope completed =
1277
+ ActiveIdleSweepRegistryBenchmarkScope;
1278
+ IdleSweepBuses = completed._saved;
1279
+ ActiveIdleSweepRegistryBenchmarkScope = completed._parent;
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ private sealed class ContextMapPoolBenchmarkScope : IDisposable
1286
+ {
1287
+ private readonly CollectionPool<
1288
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
1289
+ > _savedOverride;
1290
+ private readonly CollectionPool<
1291
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
1292
+ > _savedPool;
1293
+ private readonly CollectionPool<
1294
+ Dictionary<InstanceId, HandlerCache<int, HandlerCache>>
1295
+ > _isolated;
1296
+ private readonly ContextMapPoolBenchmarkScope _parent;
1297
+ private readonly int _ownerThreadId;
1298
+ private readonly bool _initialUseLru;
1299
+ private readonly int _initialMaxRetained;
1300
+ private bool _disposed;
1301
+
1302
+ internal ContextMapPoolBenchmarkScope()
1303
+ {
1304
+ _ownerThreadId = Environment.CurrentManagedThreadId;
1305
+ lock (typeof(ContextMapPoolBenchmarkScope))
1306
+ {
1307
+ if (
1308
+ ActiveContextMapPoolBenchmarkScope != null
1309
+ && ActiveContextMapPoolBenchmarkScope._ownerThreadId != _ownerThreadId
1310
+ )
1311
+ {
1312
+ throw new InvalidOperationException(
1313
+ "Nested context-map benchmark scopes must share one owning thread."
1314
+ );
1315
+ }
1316
+
1317
+ _parent = ActiveContextMapPoolBenchmarkScope;
1318
+ _savedOverride = ContextHandlerByTargetDictsOverride;
1319
+ _savedPool = ContextHandlerByTargetDicts;
1320
+ _initialUseLru = _savedPool.UseLru;
1321
+ _initialMaxRetained = _savedPool.MaxRetained;
1322
+ _isolated = CreateContextHandlerByTargetPool(
1323
+ _initialMaxRetained,
1324
+ _initialUseLru
1325
+ );
1326
+ ActiveContextMapPoolBenchmarkScope = this;
1327
+ ContextHandlerByTargetDictsOverride = _isolated;
1328
+ }
1329
+ }
1330
+
1331
+ public void Dispose()
1332
+ {
1333
+ lock (typeof(ContextMapPoolBenchmarkScope))
1334
+ {
1335
+ if (_disposed)
1336
+ {
1337
+ return;
1338
+ }
1339
+
1340
+ if (Environment.CurrentManagedThreadId != _ownerThreadId)
1341
+ {
1342
+ throw new InvalidOperationException(
1343
+ "Context-map benchmark scopes must be disposed on their owning thread."
1344
+ );
1345
+ }
1346
+ if (!ReferenceEquals(ActiveContextMapPoolBenchmarkScope, this))
1347
+ {
1348
+ throw new InvalidOperationException(
1349
+ "Context-map benchmark scopes must be disposed in strict LIFO order."
1350
+ );
1351
+ }
1352
+
1353
+ bool effectiveUseLru = _isolated.UseLru;
1354
+ int effectiveMaxRetained = _isolated.MaxRetained;
1355
+ if (effectiveUseLru != _initialUseLru)
1356
+ {
1357
+ _savedPool.UseLru = effectiveUseLru;
1358
+ }
1359
+ if (effectiveMaxRetained != _initialMaxRetained)
1360
+ {
1361
+ _savedPool.MaxRetained = effectiveMaxRetained;
1362
+ }
1363
+
1364
+ _disposed = true;
1365
+ _ = _isolated.Trim(0);
1366
+ ContextHandlerByTargetDictsOverride = _savedOverride;
1367
+ ActiveContextMapPoolBenchmarkScope = _parent;
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ internal readonly struct ContextMapPoolBenchmarkObservation
1373
+ {
1374
+ internal ContextMapPoolBenchmarkObservation(
1375
+ object identity,
1376
+ bool useLru,
1377
+ int maxRetained
1378
+ )
1379
+ {
1380
+ Identity = identity;
1381
+ UseLru = useLru;
1382
+ MaxRetained = maxRetained;
1383
+ }
1384
+
1385
+ internal object Identity { get; }
1386
+
1387
+ internal bool UseLru { get; }
1388
+
1389
+ internal int MaxRetained { get; }
1390
+ }
1391
+
1000
1392
  private void ApplyRuntimeSettings(DxMessagingRuntimeSettings settings)
1001
1393
  {
1002
1394
  if (settings == null)
@@ -1011,7 +1403,8 @@ namespace DxMessaging.Core.MessageBus
1011
1403
  {
1012
1404
  IMessageBus.GlobalMessageBufferSize = Math.Max(0, settings.MessageBufferSize);
1013
1405
  }
1014
- _emissionBuffer.Resize(Math.Max(0, IMessageBus.GlobalMessageBufferSize));
1406
+ _emissionBufferCapacity = Math.Max(0, IMessageBus.GlobalMessageBufferSize);
1407
+ _emissionBufferBacking?.Resize(_emissionBufferCapacity);
1015
1408
  _idleEvictionTicks = ComputeIdleEvictionTicks(settings.IdleEvictionSeconds);
1016
1409
  _evictionTickIntervalSeconds = Math.Max(0d, settings.EvictionTickIntervalSeconds);
1017
1410
  _idleEvictionEnabled = settings.EvictionEnabled;
@@ -1169,10 +1562,13 @@ namespace DxMessaging.Core.MessageBus
1169
1562
  private readonly List<MonoBehaviour> _componentCache = new();
1170
1563
  #endif
1171
1564
 
1172
- private readonly RegistrationLog _log = new();
1173
- internal readonly CyclicBuffer<MessageEmissionData> _emissionBuffer = new(
1174
- GlobalMessageBufferSize
1175
- );
1565
+ private RegistrationLog _log;
1566
+ private CyclicBuffer<MessageEmissionData> _emissionBufferBacking;
1567
+ private int _emissionBufferCapacity = GlobalMessageBufferSize;
1568
+ internal CyclicBuffer<MessageEmissionData> _emissionBuffer =>
1569
+ _emissionBufferBacking ??= new CyclicBuffer<MessageEmissionData>(
1570
+ _emissionBufferCapacity
1571
+ );
1176
1572
 
1177
1573
  private bool _diagnosticsMode = ShouldEnableDiagnostics();
1178
1574
  private bool _loggedReflexiveWarning;
@@ -1696,7 +2092,7 @@ namespace DxMessaging.Core.MessageBus
1696
2092
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
1697
2093
  private bool HasActiveDispatchSnapshot(DispatchState state)
1698
2094
  {
1699
- return _dispatchDepth > 0 && state != null && !state.active.IsEmpty;
2095
+ return _dispatchDepth > 0 && state != null && state.active.IsInitialized;
1700
2096
  }
1701
2097
 
1702
2098
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -1966,6 +2362,87 @@ namespace DxMessaging.Core.MessageBus
1966
2362
  return ContextHandlerByTargetDicts.Snapshot();
1967
2363
  }
1968
2364
 
2365
+ internal int SweepDirtyTargetSlotForBenchmark<TMessage>(InstanceId target)
2366
+ where TMessage : IMessage
2367
+ {
2368
+ int typeIndex = MessageHelperIndexer<TMessage>.SequentialId;
2369
+ int evicted = 0;
2370
+ for (int sinkIndex = 0; sinkIndex < _contextSinks.Length; ++sinkIndex)
2371
+ {
2372
+ MessageCache<Dictionary<InstanceId, HandlerCache<int, HandlerCache>>> sink =
2373
+ _contextSinks[sinkIndex];
2374
+ if (
2375
+ sink == null
2376
+ || !sink.TryGetValueAtIndex(
2377
+ typeIndex,
2378
+ out Dictionary<InstanceId, HandlerCache<int, HandlerCache>> handlersByTarget
2379
+ )
2380
+ || !handlersByTarget.TryGetValue(
2381
+ target,
2382
+ out HandlerCache<int, HandlerCache> handlers
2383
+ )
2384
+ || handlers.handlers.Count != 0
2385
+ || HasActiveDispatchSnapshot(handlers.dispatchState)
2386
+ )
2387
+ {
2388
+ continue;
2389
+ }
2390
+
2391
+ handlers.Clear();
2392
+ _ = handlersByTarget.Remove(target);
2393
+ evicted++;
2394
+ }
2395
+
2396
+ if (
2397
+ _dirtyTargets.TryGetValue(typeIndex, out List<InstanceId> targets)
2398
+ && _dirtyTargetSets.TryGetValue(typeIndex, out HashSet<InstanceId> targetSet)
2399
+ && targetSet.Remove(target)
2400
+ )
2401
+ {
2402
+ for (int index = targets.Count - 1; index >= 0; --index)
2403
+ {
2404
+ if (targets[index] != target)
2405
+ {
2406
+ continue;
2407
+ }
2408
+
2409
+ int lastIndex = targets.Count - 1;
2410
+ targets[index] = targets[lastIndex];
2411
+ targets.RemoveAt(lastIndex);
2412
+ break;
2413
+ }
2414
+ }
2415
+
2416
+ // Deliberately retain the empty per-type context map and dirty-candidate
2417
+ // collections. Returning and immediately renting them would make this row measure
2418
+ // shared pools in addition to the target map. ResetState returns them at teardown.
2419
+ return evicted;
2420
+ }
2421
+
2422
+ internal bool TryObserveTargetedHandleMapStorageForBenchmark<TMessage>(
2423
+ out int entries,
2424
+ out int capacity
2425
+ )
2426
+ where TMessage : IMessage
2427
+ {
2428
+ MessageCache<Dictionary<InstanceId, HandlerCache<int, HandlerCache>>> sink =
2429
+ _contextSinks[BusContextIndex.TargetedHandleDefault];
2430
+ if (
2431
+ !sink.TryGetValue<TMessage>(
2432
+ out Dictionary<InstanceId, HandlerCache<int, HandlerCache>> handlersByTarget
2433
+ )
2434
+ )
2435
+ {
2436
+ entries = 0;
2437
+ capacity = 0;
2438
+ return false;
2439
+ }
2440
+
2441
+ entries = handlersByTarget.Count;
2442
+ capacity = handlersByTarget.EnsureCapacity(0);
2443
+ return true;
2444
+ }
2445
+
1969
2446
  private Dictionary<InstanceId, HandlerCache<int, HandlerCache>> GetOrRentContextMap<T>(
1970
2447
  MessageCache<Dictionary<InstanceId, HandlerCache<int, HandlerCache>>> sinks
1971
2448
  )
@@ -2130,7 +2607,7 @@ namespace DxMessaging.Core.MessageBus
2130
2607
  {
2131
2608
  DispatchSnapshot displaced = snapshot;
2132
2609
  snapshot = DispatchSnapshot.Empty;
2133
- if (displaced == null || displaced.IsEmpty)
2610
+ if (displaced == null || !displaced.IsInitialized)
2134
2611
  {
2135
2612
  return;
2136
2613
  }
@@ -2474,11 +2951,10 @@ namespace DxMessaging.Core.MessageBus
2474
2951
  _componentCache.Clear();
2475
2952
  #endif
2476
2953
 
2477
- bool enabled = _log.Enabled;
2478
- _log.Clear();
2479
- _log.Enabled = enabled;
2480
- _emissionBuffer.Resize(GlobalMessageBufferSize);
2481
- _emissionBuffer.Clear();
2954
+ _log?.Clear();
2955
+ _emissionBufferCapacity = GlobalMessageBufferSize;
2956
+ _emissionBufferBacking?.Resize(_emissionBufferCapacity);
2957
+ _emissionBufferBacking?.Clear();
2482
2958
  }
2483
2959
 
2484
2960
  private void ResetTypedSlotsForReferencedHandlers()
@@ -2664,7 +3140,7 @@ namespace DxMessaging.Core.MessageBus
2664
3140
  {
2665
3141
  _globalSlots.liveCount++;
2666
3142
  }
2667
- _log.Log(
3143
+ _log?.Log(
2668
3144
  new MessagingRegistration(
2669
3145
  messageHandler.owner,
2670
3146
  type,
@@ -2728,7 +3204,7 @@ namespace DxMessaging.Core.MessageBus
2728
3204
  long deregisterTouchTick = AdvanceTick();
2729
3205
  InvalidateDispatchPlans();
2730
3206
  _globalSlots.version++;
2731
- _log.Log(
3207
+ _log?.Log(
2732
3208
  new MessagingRegistration(
2733
3209
  messageHandler.owner,
2734
3210
  type,
@@ -2831,7 +3307,7 @@ namespace DxMessaging.Core.MessageBus
2831
3307
  priorityCount[priority] = count + 1;
2832
3308
 
2833
3309
  Type type = typeof(T);
2834
- _log.Log(
3310
+ _log?.Log(
2835
3311
  new MessagingRegistration(
2836
3312
  InstanceId.EmptyId,
2837
3313
  type,
@@ -2902,7 +3378,7 @@ namespace DxMessaging.Core.MessageBus
2902
3378
  priorityCount[priority] = count + 1;
2903
3379
 
2904
3380
  Type type = typeof(T);
2905
- _log.Log(
3381
+ _log?.Log(
2906
3382
  new MessagingRegistration(
2907
3383
  InstanceId.EmptyId,
2908
3384
  type,
@@ -2973,7 +3449,7 @@ namespace DxMessaging.Core.MessageBus
2973
3449
  priorityCount[priority] = count + 1;
2974
3450
 
2975
3451
  Type type = typeof(T);
2976
- _log.Log(
3452
+ _log?.Log(
2977
3453
  new MessagingRegistration(
2978
3454
  InstanceId.EmptyId,
2979
3455
  type,
@@ -3170,7 +3646,7 @@ namespace DxMessaging.Core.MessageBus
3170
3646
  long touchTick = AdvanceTick();
3171
3647
  if (_diagnosticsMode)
3172
3648
  {
3173
- _emissionBuffer.Add(new MessageEmissionData(typedMessage));
3649
+ _emissionBuffer.Add(new MessageEmissionData(typedMessage, emissionId));
3174
3650
  }
3175
3651
 
3176
3652
  if (plan.fastPath)
@@ -3305,7 +3781,7 @@ namespace DxMessaging.Core.MessageBus
3305
3781
  }
3306
3782
 
3307
3783
  Touch(sortedHandlers, touchTick);
3308
- DispatchSnapshot snapshot = prefrozenSnapshot.IsEmpty
3784
+ DispatchSnapshot snapshot = !prefrozenSnapshot.IsInitialized
3309
3785
  ? AcquireDispatchSnapshotFast<TMessage>(
3310
3786
  this,
3311
3787
  sortedHandlers,
@@ -3412,7 +3888,7 @@ namespace DxMessaging.Core.MessageBus
3412
3888
  long touchTick = AdvanceTick();
3413
3889
  if (_diagnosticsMode)
3414
3890
  {
3415
- _emissionBuffer.Add(new MessageEmissionData(typedMessage, target));
3891
+ _emissionBuffer.Add(new MessageEmissionData(typedMessage, target, emissionId));
3416
3892
  }
3417
3893
 
3418
3894
  // Fast lane: no interceptors, no global accept-all, no
@@ -3893,7 +4369,7 @@ namespace DxMessaging.Core.MessageBus
3893
4369
  target
3894
4370
  );
3895
4371
  }
3896
- else if (targetedPostSnapshot.IsEmpty)
4372
+ else if (!targetedPostSnapshot.IsInitialized)
3897
4373
  {
3898
4374
  snapshot = AcquireDispatchSnapshotFast<TMessage>(
3899
4375
  this,
@@ -3919,7 +4395,7 @@ namespace DxMessaging.Core.MessageBus
3919
4395
  && postTwt.handlers.Count > 0
3920
4396
  )
3921
4397
  {
3922
- DispatchSnapshot snapshot = targetedWithoutTargetingPostSnapshot.IsEmpty
4398
+ DispatchSnapshot snapshot = !targetedWithoutTargetingPostSnapshot.IsInitialized
3923
4399
  ? AcquireDispatchSnapshotFast<TMessage>(
3924
4400
  this,
3925
4401
  postTwt,
@@ -4041,7 +4517,7 @@ namespace DxMessaging.Core.MessageBus
4041
4517
  long touchTick = AdvanceTick();
4042
4518
  if (_diagnosticsMode)
4043
4519
  {
4044
- _emissionBuffer.Add(new MessageEmissionData(typedMessage, source));
4520
+ _emissionBuffer.Add(new MessageEmissionData(typedMessage, source, emissionId));
4045
4521
  }
4046
4522
 
4047
4523
  // Fast lane: no interceptors, no global accept-all, no
@@ -4298,15 +4774,15 @@ namespace DxMessaging.Core.MessageBus
4298
4774
  }
4299
4775
  }
4300
4776
 
4301
- if (!broadcastPostSnapshot.IsEmpty)
4777
+ if (broadcastPostSnapshot.IsInitialized)
4302
4778
  {
4303
- // Legacy reporting: a non-empty pre-frozen snapshot counts as
4304
- // "found" even if every frozen delegate is skipped below.
4779
+ // Legacy reporting: an initialized pre-frozen snapshot counts as
4780
+ // "found" even when it owns zero resolved delegates.
4305
4781
  foundAnyHandlers = true;
4306
4782
  _ = DispatchFlatSnapshot(broadcastPostSnapshot, ref typedMessage);
4307
4783
  }
4308
4784
 
4309
- if (!broadcastWithoutSourcePostSnapshot.IsEmpty)
4785
+ if (broadcastWithoutSourcePostSnapshot.IsInitialized)
4310
4786
  {
4311
4787
  if (
4312
4788
  DispatchContextFlatSnapshot(
@@ -4895,7 +5371,7 @@ namespace DxMessaging.Core.MessageBus
4895
5371
  // Empty), acquire lazily here: the legacy path took its first
4896
5372
  // freeze at this point in that case, so a registration made
4897
5373
  // earlier in this emission into a previously-empty sink fires.
4898
- DispatchSnapshot snapshot = prefrozenSnapshot.IsEmpty
5374
+ DispatchSnapshot snapshot = !prefrozenSnapshot.IsInitialized
4899
5375
  ? AcquireDispatchSnapshotFast<TMessage>(
4900
5376
  this,
4901
5377
  sortedHandlers,
@@ -4960,7 +5436,7 @@ namespace DxMessaging.Core.MessageBus
4960
5436
  }
4961
5437
  StageDispatchSnapshot<T>(this, capturedHandlers, slotKey);
4962
5438
  Type type = typeof(T);
4963
- _log.Log(
5439
+ _log?.Log(
4964
5440
  new MessagingRegistration(
4965
5441
  handlerOwnerId,
4966
5442
  type,
@@ -5029,7 +5505,7 @@ namespace DxMessaging.Core.MessageBus
5029
5505
  && cache.handlers.TryGetValue(messageHandler, out int count)
5030
5506
  )
5031
5507
  {
5032
- _log.Log(
5508
+ _log?.Log(
5033
5509
  new MessagingRegistration(
5034
5510
  handlerOwnerId,
5035
5511
  type,
@@ -5052,7 +5528,6 @@ namespace DxMessaging.Core.MessageBus
5052
5528
  // insertionOrder tradeoff.
5053
5529
  _ = cache.insertionOrder.Remove(messageHandler);
5054
5530
  MarkDirtyHandler(messageHandler);
5055
- // do not mutate cache.cache here; let next read rebuild from handlers
5056
5531
 
5057
5532
  if (handler.Count == 0)
5058
5533
  {
@@ -5175,7 +5650,7 @@ namespace DxMessaging.Core.MessageBus
5175
5650
  }
5176
5651
 
5177
5652
  Type type = typeof(T);
5178
- _log.Log(
5653
+ _log?.Log(
5179
5654
  new MessagingRegistration(
5180
5655
  context,
5181
5656
  type,
@@ -5239,7 +5714,7 @@ namespace DxMessaging.Core.MessageBus
5239
5714
  && cache.handlers.TryGetValue(messageHandler, out int count)
5240
5715
  )
5241
5716
  {
5242
- _log.Log(
5717
+ _log?.Log(
5243
5718
  new MessagingRegistration(
5244
5719
  context,
5245
5720
  type,
@@ -5257,7 +5732,6 @@ namespace DxMessaging.Core.MessageBus
5257
5732
  // DeregisterScalarHandler.
5258
5733
  _ = cache.insertionOrder.Remove(messageHandler);
5259
5734
  MarkDirtyHandler(messageHandler);
5260
- // do not mutate cache.cache here; let next read rebuild from handlers
5261
5735
  if (handler.Count == 0)
5262
5736
  {
5263
5737
  capturedHandlers.version++;
@@ -5378,7 +5852,7 @@ namespace DxMessaging.Core.MessageBus
5378
5852
  InvalidateDispatchPlans();
5379
5853
  capturedInterceptors.lastTouchTicks = _tickCounter;
5380
5854
  MarkDirtyType<T>();
5381
- _log.Log(
5855
+ _log?.Log(
5382
5856
  new MessagingRegistration(
5383
5857
  InstanceId.EmptyId,
5384
5858
  typeof(T),
@@ -5661,7 +6135,7 @@ namespace DxMessaging.Core.MessageBus
5661
6135
  {
5662
6136
  DebugAssertAcquireFastPrecondition(handlers);
5663
6137
  DispatchState state = handlers.dispatchState;
5664
- if (state != null && !state.hasPending && !state.active.IsEmpty)
6138
+ if (state != null && !state.hasPending && state.active.IsInitialized)
5665
6139
  {
5666
6140
  handlers.lastTouchTicks = messageBus._tickCounter;
5667
6141
  state.snapshotEmissionId = emissionId;
@@ -5719,7 +6193,7 @@ namespace DxMessaging.Core.MessageBus
5719
6193
 
5720
6194
  if (state.hasPending)
5721
6195
  {
5722
- if (state.pendingDirty || (hasHandlers && state.pending.IsEmpty))
6196
+ if (state.pendingDirty || (hasHandlers && !state.pending.IsInitialized))
5723
6197
  {
5724
6198
  ReleaseSnapshot(ref state.pending);
5725
6199
  state.pending = hasHandlers
@@ -5729,7 +6203,7 @@ namespace DxMessaging.Core.MessageBus
5729
6203
  state.pendingDirty = false;
5730
6204
  }
5731
6205
  }
5732
- else if (state.active.IsEmpty && hasHandlers)
6206
+ else if (!state.active.IsInitialized && hasHandlers)
5733
6207
  {
5734
6208
  ReleaseSnapshot(ref state.pending);
5735
6209
  state.pending = BuildDispatchSnapshot<TMessage>(
@@ -5752,7 +6226,7 @@ namespace DxMessaging.Core.MessageBus
5752
6226
  // ReleaseDisplacedSnapshot defers the release to the
5753
6227
  // outermost dispatch-lease exit when one is in flight.
5754
6228
  messageBus.ReleaseDisplacedSnapshot(ref state.active);
5755
- if (state.pendingDirty || (hasHandlers && state.pending.IsEmpty))
6229
+ if (state.pendingDirty || (hasHandlers && !state.pending.IsInitialized))
5756
6230
  {
5757
6231
  ReleaseSnapshot(ref state.pending);
5758
6232
  state.pending = hasHandlers
@@ -5772,7 +6246,7 @@ namespace DxMessaging.Core.MessageBus
5772
6246
  state.hasPending = false;
5773
6247
  state.pendingDirty = false;
5774
6248
  }
5775
- else if (!hasHandlers && !state.active.IsEmpty)
6249
+ else if (!hasHandlers && state.active.IsInitialized)
5776
6250
  {
5777
6251
  messageBus.ReleaseDisplacedSnapshot(ref state.active);
5778
6252
  }
@@ -5796,46 +6270,13 @@ namespace DxMessaging.Core.MessageBus
5796
6270
  return DispatchSnapshot.Empty;
5797
6271
  }
5798
6272
 
5799
- List<int> orderedPriorities = handlers.order;
5800
- int priorityCount = orderedPriorities.Count;
5801
- DispatchBucket[] buckets = DispatchBucketPool.Rent(priorityCount);
5802
-
5803
- for (int i = 0; i < priorityCount; ++i)
5804
- {
5805
- int priority = orderedPriorities[i];
5806
- if (
5807
- !handlers.handlers.TryGetValue(priority, out HandlerCache cache)
5808
- || cache == null
5809
- )
5810
- {
5811
- buckets[i] = DispatchBucket.CreateEmpty(priority);
5812
- continue;
5813
- }
5814
-
5815
- Dictionary<MessageHandler, int> handlerLookup = cache.handlers;
5816
- if (handlerLookup == null || handlerLookup.Count == 0)
5817
- {
5818
- buckets[i] = DispatchBucket.CreateEmpty(priority);
5819
- continue;
5820
- }
5821
-
5822
- // Count-only bucket: non-global dispatch walks the resolved
5823
- // flat array exclusively, so the bucket carries the handler
5824
- // count (consumed by IsEmpty / HasAnyDispatchEntries for the
5825
- // legacy "found any handlers" reporting) without renting or
5826
- // filling a DispatchEntry array.
5827
- DebugAssertBusInsertionOrderInSync(cache);
5828
- buckets[i] = new DispatchBucket(
5829
- priority,
5830
- Array.Empty<DispatchEntry>(),
5831
- handlerLookup.Count,
5832
- pooledEntries: false
5833
- );
5834
- }
5835
-
5836
- DispatchSnapshot snapshot = new DispatchSnapshot(buckets, priorityCount, pooled: true);
5837
- snapshot.flat = BuildFlatDispatch<TMessage>(messageBus, handlers, slotKey, context);
5838
- return snapshot;
6273
+ FlatDispatchArray flat = BuildFlatDispatch<TMessage>(
6274
+ messageBus,
6275
+ handlers,
6276
+ slotKey,
6277
+ context
6278
+ );
6279
+ return new DispatchSnapshot(flat, hasRegistrations: true);
5839
6280
  }
5840
6281
 
5841
6282
  /// <summary>
@@ -6362,24 +6803,12 @@ namespace DxMessaging.Core.MessageBus
6362
6803
  );
6363
6804
  }
6364
6805
 
6365
- // IL2CPP check elision: buckets[0..bucketCount) is non-null by snapshot
6366
- // construction; same justification as DispatchFlatSnapshot.
6367
6806
  [Il2CppSetOption(Option.NullChecks, false)]
6368
6807
  [Il2CppSetOption(Option.ArrayBoundsChecks, false)]
6369
6808
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
6370
6809
  private static bool HasAnyDispatchEntries(DispatchSnapshot snapshot)
6371
6810
  {
6372
- DispatchBucket[] buckets = snapshot.buckets;
6373
- int bucketCount = snapshot.bucketCount;
6374
- for (int i = 0; i < bucketCount; ++i)
6375
- {
6376
- if (0 < buckets[i].entryCount)
6377
- {
6378
- return true;
6379
- }
6380
- }
6381
-
6382
- return false;
6811
+ return snapshot.hasRegistrations;
6383
6812
  }
6384
6813
 
6385
6814
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -6404,7 +6833,7 @@ namespace DxMessaging.Core.MessageBus
6404
6833
 
6405
6834
  if (state.hasPending)
6406
6835
  {
6407
- if (state.pendingDirty || (hasHandlers && state.pending.IsEmpty))
6836
+ if (state.pendingDirty || (hasHandlers && !state.pending.IsInitialized))
6408
6837
  {
6409
6838
  ReleaseSnapshot(ref state.pending);
6410
6839
  if (hasHandlers)
@@ -6423,7 +6852,7 @@ namespace DxMessaging.Core.MessageBus
6423
6852
  state.pendingDirty = false;
6424
6853
  }
6425
6854
  }
6426
- else if (state.active.IsEmpty && hasHandlers)
6855
+ else if (!state.active.IsInitialized && hasHandlers)
6427
6856
  {
6428
6857
  ReleaseSnapshot(ref state.pending);
6429
6858
  state.pending = BuildGlobalDispatchSnapshot<TMessage>(messageBus, handlers, kind);
@@ -6439,7 +6868,7 @@ namespace DxMessaging.Core.MessageBus
6439
6868
  // snapshot may still be iterated by an outer emission, so
6440
6869
  // its release is deferred while a dispatch lease is live.
6441
6870
  messageBus.ReleaseDisplacedSnapshot(ref state.active);
6442
- if (state.pendingDirty || (hasHandlers && state.pending.IsEmpty))
6871
+ if (state.pendingDirty || (hasHandlers && !state.pending.IsInitialized))
6443
6872
  {
6444
6873
  ReleaseSnapshot(ref state.pending);
6445
6874
  state.pending = hasHandlers
@@ -6454,7 +6883,7 @@ namespace DxMessaging.Core.MessageBus
6454
6883
  state.hasPending = false;
6455
6884
  state.pendingDirty = false;
6456
6885
  }
6457
- else if (!hasHandlers && !state.active.IsEmpty)
6886
+ else if (!hasHandlers && state.active.IsInitialized)
6458
6887
  {
6459
6888
  messageBus.ReleaseDisplacedSnapshot(ref state.active);
6460
6889
  }
@@ -6488,7 +6917,13 @@ namespace DxMessaging.Core.MessageBus
6488
6917
  }
6489
6918
 
6490
6919
  buckets[0] = new DispatchBucket(0, entries, entryCount, pooledEntries: true);
6491
- return new DispatchSnapshot(buckets, 1, pooled: true);
6920
+ return new DispatchSnapshot(
6921
+ buckets,
6922
+ 1,
6923
+ entryCount,
6924
+ hasRegistrations: true,
6925
+ pooled: true
6926
+ );
6492
6927
  }
6493
6928
 
6494
6929
  [MethodImpl(MethodImplOptions.AggressiveInlining)]