From 29a53385f419e5904edc3647f7a483e82932a55c Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Mon, 4 May 2026 14:28:41 -0700 Subject: [PATCH 01/16] First pass garbage collection --- .cspell.json | 1 + .gitignore | 4 +- .llm/context.md | 6 + .llm/skills/index.md | 10 +- .llm/skills/performance/memory-reclamation.md | 184 ++ .../skills/testing/memory-reclaim-coverage.md | 183 ++ CHANGELOG.md | 1 + .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 25088 -> 25088 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 33280 bytes Runtime/AssemblyInfo.cs | 1 + Runtime/Core/Configuration.meta | 9 + .../DxMessagingRuntimeSettings.cs | 190 ++ .../DxMessagingRuntimeSettings.cs.meta | 11 + .../DxMessagingRuntimeSettingsProvider.cs | 179 ++ ...DxMessagingRuntimeSettingsProvider.cs.meta | 11 + Runtime/Core/DataStructure/CyclicBuffer.cs | 4 +- Runtime/Core/Helper/MessageCache.cs | 32 + Runtime/Core/Internal.meta | 9 + .../Core/Internal/TypedDispatchLinkIndex.cs | 51 + .../Internal/TypedDispatchLinkIndex.cs.meta | 11 + Runtime/Core/Internal/TypedGlobalSlotIndex.cs | 38 + .../Internal/TypedGlobalSlotIndex.cs.meta | 11 + Runtime/Core/Internal/TypedSlotIndex.cs | 81 + Runtime/Core/Internal/TypedSlotIndex.cs.meta | 11 + Runtime/Core/Internal/TypedSlots.cs | 615 +++++ Runtime/Core/Internal/TypedSlots.cs.meta | 11 + Runtime/Core/MessageBus/IMessageBus.cs | 52 + Runtime/Core/MessageBus/Internal.meta | 9 + .../MessageBus/Internal/BusContextIndex.cs | 16 + .../Internal/BusContextIndex.cs.meta | 11 + .../Core/MessageBus/Internal/BusSinkIndex.cs | 40 + .../MessageBus/Internal/BusSinkIndex.cs.meta | 11 + Runtime/Core/MessageBus/Internal/BusSlots.cs | 731 ++++++ .../Core/MessageBus/Internal/BusSlots.cs.meta | 11 + .../Core/MessageBus/Internal/DispatchKind.cs | 38 + .../MessageBus/Internal/DispatchKind.cs.meta | 11 + .../Core/MessageBus/Internal/DispatchPhase.cs | 20 + .../MessageBus/Internal/DispatchPhase.cs.meta | 11 + .../MessageBus/Internal/DispatchVariant.cs | 28 + .../Internal/DispatchVariant.cs.meta | 11 + .../MessageBus/Internal/IEvictableSlot.cs | 49 + .../Internal/IEvictableSlot.cs.meta | 11 + .../Core/MessageBus/Internal/ISweepable.cs | 15 + .../MessageBus/Internal/ISweepable.cs.meta | 11 + .../Internal/RegistrationMethodAxes.cs | 222 ++ .../Internal/RegistrationMethodAxes.cs.meta | 11 + Runtime/Core/MessageBus/Internal/SlotKey.cs | 192 ++ .../Core/MessageBus/Internal/SlotKey.cs.meta | 11 + Runtime/Core/MessageBus/MessageBus.cs | 2230 ++++++++++++++--- Runtime/Core/MessageHandler.cs | 2218 +++++++++++----- Runtime/Core/Pooling.meta | 9 + Runtime/Core/Pooling/CollectionPool.cs | 266 ++ Runtime/Core/Pooling/CollectionPool.cs.meta | 11 + .../Core/Pooling/CollectionPoolDiagnostics.cs | 30 + .../Pooling/CollectionPoolDiagnostics.cs.meta | 11 + Runtime/Core/Pooling/DxPools.cs | 135 + Runtime/Core/Pooling/DxPools.cs.meta | 11 + .../Core/Pooling/EvictionPlayerLoopHook.cs | 106 + .../Pooling/EvictionPlayerLoopHook.cs.meta | 11 + Runtime/Core/Pooling/IDxMessagingClock.cs | 18 + .../Core/Pooling/IDxMessagingClock.cs.meta | 11 + .../Core/Pooling/PoolDiagnosticsSnapshot.cs | 45 + .../Pooling/PoolDiagnosticsSnapshot.cs.meta | 11 + Runtime/Core/Pooling/StopwatchClock.cs | 22 + Runtime/Core/Pooling/StopwatchClock.cs.meta | 11 + Runtime/Core/Pooling/UnityRealtimeClock.cs | 26 + .../Core/Pooling/UnityRealtimeClock.cs.meta | 11 + .../Allocations/AllocationMatrixTests.cs | 139 +- Tests/Editor/Contract.meta | 9 + .../Contract/BusGlobalSlotLiveCountTests.cs | 256 ++ .../BusGlobalSlotLiveCountTests.cs.meta | 11 + .../Editor/Contract/CounterBasedTouchTests.cs | 555 ++++ .../Contract/CounterBasedTouchTests.cs.meta | 11 + .../Contract/EvictionSweepContractTests.cs | 806 ++++++ .../EvictionSweepContractTests.cs.meta | 11 + .../Contract/MessageBusInvariantTests.cs | 450 ++++ .../Contract/MessageBusInvariantTests.cs.meta | 11 + .../RegistrationMethodAxisCoverageTests.cs | 297 +++ ...egistrationMethodAxisCoverageTests.cs.meta | 11 + .../Contract/TypedSlotIndexCoverageTests.cs | 924 +++++++ .../TypedSlotIndexCoverageTests.cs.meta | 11 + Tests/Editor/Contract/TypedSlotShapeTests.cs | 603 +++++ .../Contract/TypedSlotShapeTests.cs.meta | 11 + Tests/Runtime/Core/DiagnosticsTests.cs | 36 + Tests/Runtime/Core/LeakWatcherSelfTests.cs | 247 ++ .../Core/MessageHandlerGlobalBusTests.cs | 40 +- .../Runtime/Core/Snapshots/public-surface.txt | 6 + Tests/Runtime/Core/SuiteSpeedBudgetTest.cs | 25 +- .../Runtime/Core/SuiteWallClockBudgetTest.cs | 15 +- Tests/Runtime/MemoryReclaim.meta | 8 + .../MemoryReclaim/MemoryReclamationTests.cs | 807 ++++++ .../MemoryReclamationTests.cs.meta | 11 + Tests/Runtime/TestUtilities/FakeClock.cs | 51 + Tests/Runtime/TestUtilities/FakeClock.cs.meta | 11 + Tests/Runtime/TestUtilities/LeakWatcher.cs | 123 +- llms.txt | 4 +- 96 files changed, 12837 insertions(+), 1042 deletions(-) create mode 100644 .llm/skills/performance/memory-reclamation.md create mode 100644 .llm/skills/testing/memory-reclaim-coverage.md create mode 100644 Runtime/Core/Configuration.meta create mode 100644 Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs create mode 100644 Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs.meta create mode 100644 Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs create mode 100644 Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs.meta create mode 100644 Runtime/Core/Internal.meta create mode 100644 Runtime/Core/Internal/TypedDispatchLinkIndex.cs create mode 100644 Runtime/Core/Internal/TypedDispatchLinkIndex.cs.meta create mode 100644 Runtime/Core/Internal/TypedGlobalSlotIndex.cs create mode 100644 Runtime/Core/Internal/TypedGlobalSlotIndex.cs.meta create mode 100644 Runtime/Core/Internal/TypedSlotIndex.cs create mode 100644 Runtime/Core/Internal/TypedSlotIndex.cs.meta create mode 100644 Runtime/Core/Internal/TypedSlots.cs create mode 100644 Runtime/Core/Internal/TypedSlots.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal.meta create mode 100644 Runtime/Core/MessageBus/Internal/BusContextIndex.cs create mode 100644 Runtime/Core/MessageBus/Internal/BusContextIndex.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/BusSinkIndex.cs create mode 100644 Runtime/Core/MessageBus/Internal/BusSinkIndex.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/BusSlots.cs create mode 100644 Runtime/Core/MessageBus/Internal/BusSlots.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/DispatchKind.cs create mode 100644 Runtime/Core/MessageBus/Internal/DispatchKind.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/DispatchPhase.cs create mode 100644 Runtime/Core/MessageBus/Internal/DispatchPhase.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/DispatchVariant.cs create mode 100644 Runtime/Core/MessageBus/Internal/DispatchVariant.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/IEvictableSlot.cs create mode 100644 Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/ISweepable.cs create mode 100644 Runtime/Core/MessageBus/Internal/ISweepable.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs create mode 100644 Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs.meta create mode 100644 Runtime/Core/MessageBus/Internal/SlotKey.cs create mode 100644 Runtime/Core/MessageBus/Internal/SlotKey.cs.meta create mode 100644 Runtime/Core/Pooling.meta create mode 100644 Runtime/Core/Pooling/CollectionPool.cs create mode 100644 Runtime/Core/Pooling/CollectionPool.cs.meta create mode 100644 Runtime/Core/Pooling/CollectionPoolDiagnostics.cs create mode 100644 Runtime/Core/Pooling/CollectionPoolDiagnostics.cs.meta create mode 100644 Runtime/Core/Pooling/DxPools.cs create mode 100644 Runtime/Core/Pooling/DxPools.cs.meta create mode 100644 Runtime/Core/Pooling/EvictionPlayerLoopHook.cs create mode 100644 Runtime/Core/Pooling/EvictionPlayerLoopHook.cs.meta create mode 100644 Runtime/Core/Pooling/IDxMessagingClock.cs create mode 100644 Runtime/Core/Pooling/IDxMessagingClock.cs.meta create mode 100644 Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs create mode 100644 Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs.meta create mode 100644 Runtime/Core/Pooling/StopwatchClock.cs create mode 100644 Runtime/Core/Pooling/StopwatchClock.cs.meta create mode 100644 Runtime/Core/Pooling/UnityRealtimeClock.cs create mode 100644 Runtime/Core/Pooling/UnityRealtimeClock.cs.meta create mode 100644 Tests/Editor/Contract.meta create mode 100644 Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs create mode 100644 Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs.meta create mode 100644 Tests/Editor/Contract/CounterBasedTouchTests.cs create mode 100644 Tests/Editor/Contract/CounterBasedTouchTests.cs.meta create mode 100644 Tests/Editor/Contract/EvictionSweepContractTests.cs create mode 100644 Tests/Editor/Contract/EvictionSweepContractTests.cs.meta create mode 100644 Tests/Editor/Contract/MessageBusInvariantTests.cs create mode 100644 Tests/Editor/Contract/MessageBusInvariantTests.cs.meta create mode 100644 Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs create mode 100644 Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs.meta create mode 100644 Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs create mode 100644 Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs.meta create mode 100644 Tests/Editor/Contract/TypedSlotShapeTests.cs create mode 100644 Tests/Editor/Contract/TypedSlotShapeTests.cs.meta create mode 100644 Tests/Runtime/MemoryReclaim.meta create mode 100644 Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs create mode 100644 Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs.meta create mode 100644 Tests/Runtime/TestUtilities/FakeClock.cs create mode 100644 Tests/Runtime/TestUtilities/FakeClock.cs.meta diff --git a/.cspell.json b/.cspell.json index 5880e66b..f990f692 100644 --- a/.cspell.json +++ b/.cspell.json @@ -244,6 +244,7 @@ "Ldstr", "materialised", "misaligning", + "monomorphization", "normalise", "Normalise", "normalises", diff --git a/.gitignore b/.gitignore index 1e151631..65a0d07c 100644 --- a/.gitignore +++ b/.gitignore @@ -357,4 +357,6 @@ SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta Temp Temp.meta -failed-tests.txt* \ No newline at end of file +failed-tests.txt* + +PERF-PLAN.md* \ No newline at end of file diff --git a/.llm/context.md b/.llm/context.md index f75a5583..9b5fc69c 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -110,7 +110,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - Tests that exercise dispatch across more than one of `Untargeted`/`Targeted`/`Broadcast` MUST be parameterized via `MessageScenarios.AllKinds`; see [Tests Must Be Parameterized by Message Kind](./skills/testing/tests-must-be-parameterized-by-message-kind.md). - Bus dispatch-path changes must be covered by the canonical lifecycle edge-case set (scene unload mid-dispatch, DDOL transitions, prefab pooling churn, token disable / re-enable, post-Reset emit, OnApplicationQuit drain, cross-kind reentrancy); see [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md). - Tests that create and tear down message registrations should bracket the work in a `LeakWatcher` to assert no registrations survive; see [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md). +- Tests for memory holders keyed by message type or `InstanceId` must prove forced trim, idle sweep, slot-count recovery, and stale deregistration behavior; see [Memory Reclaim Coverage](./skills/testing/memory-reclaim-coverage.md). - Benchmark and performance/allocation tests must stay isolated under `Tests/Runtime/Benchmarks` in asmdef `WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks`; `.00` is a lexical prefix convention so the benchmark assembly sorts before peer test assemblies in Unity Test Runner. Keep `BenchmarkAssemblyContractTests` green when adding or moving perf tests. +- When adding a `MessageCache<>` storage field to `MessageBus`, update `MessageBus.ExpectedMessageCacheFieldCount`, add the field to `MessageBus.SweepableTypeCaches`, and add reclamation coverage; see [DxMessaging Memory Reclamation](./skills/performance/memory-reclamation.md). ## Documentation Expectations @@ -133,8 +135,10 @@ This file is intentionally concise. It contains only critical, high-signal guida Use the index above and then select the most relevant skill pages. Frequently useful entries include: - Documentation and changelog guidance under `./skills/documentation/` +- Memory reclamation guidance under `./skills/performance/memory-reclamation.md` - Script reliability and parsing guidance under `./skills/scripting/` - Test quality and investigation guidance under `./skills/testing/` +- Memory reclaim testing guidance under `./skills/testing/memory-reclaim-coverage.md` - Workflow robustness under `./skills/github-actions/` ## Split File Maintenance @@ -154,5 +158,7 @@ Use the index above and then select the most relevant skill pages. Frequently us - [Test Failure Investigation and Zero-Flaky Policy](./skills/testing/test-failure-investigation.md) - [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md) - [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md) +- [Memory Reclaim Coverage](./skills/testing/memory-reclaim-coverage.md) +- [DxMessaging Memory Reclamation](./skills/performance/memory-reclamation.md) - [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md) - [Git Hook Performance Budget](./skills/performance/git-hook-performance.md) diff --git a/.llm/skills/index.md b/.llm/skills/index.md index fc61f152..4d134229 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-05-03. Do not edit manually. +> **Auto-generated** on 2026-05-04. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,7 +9,7 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 144 | +| Total Skills | 146 | | Categories | 8 | --- @@ -19,10 +19,10 @@ - [Documentation](#documentation) (27) - [GitHub Actions](#github-actions) (5) - [Packaging](#packaging) (2) -- [Performance](#performance) (42) +- [Performance](#performance) (43) - [Scripting](#scripting) (15) - [Solid](#solid) (15) -- [Testing](#testing) (37) +- [Testing](#testing) (38) - [Unity](#unity) (1) --- @@ -96,6 +96,7 @@ | [Collection Pooling with RAII Pattern](./performance/collection-pooling.md) | [draft] 119 | [intermediate] | [stable] | [risk: high] | memory, allocation | | [Collection Pooling with RAII Pattern Part 1](./performance/collection-pooling-part-1.md) | [ok] 206 | [intermediate] | [stable] | [risk: low] | migration, split | | [Collection Pooling with RAII Pattern Part 2](./performance/collection-pooling-part-2.md) | [draft] 57 | [intermediate] | [stable] | [risk: low] | migration, split | +| [DxMessaging Memory Reclamation](./performance/memory-reclamation.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | memory, reclamation | | [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 299 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [Git Hook Performance: Stages and Tooling](./performance/git-hook-performance-tooling.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [High-Performance Cache with Eviction Policies](./performance/cache-eviction-policies.md) | [ok] 177 | [advanced] | [stable] | [risk: high] | caching, memory | @@ -177,6 +178,7 @@ | [Inspector Overlay Invariants for MessageAwareComponent](./testing/inspector-overlay-invariants.md) | [ok] 153 | [intermediate] | [stable] | [risk: low] | testing, editor | | [LeakWatcher: Detecting Registration Leaks in Tests](./testing/leak-watcher-usage.md) | [ok] 260 | [basic] | [stable] | [risk: low] | testing, leaks | | [Lifecycle Edge-Case Test Coverage](./testing/lifecycle-edge-coverage.md) | [ok] 249 | [intermediate] | [stable] | [risk: none] | testing, lifecycle | +| [Memory Reclaim Coverage](./testing/memory-reclaim-coverage.md) | [ok] 184 | [intermediate] | [stable] | [risk: high] | testing, memory | | [Script Test Coverage Requirements](./testing/script-test-coverage.md) | [ok] 260 | [intermediate] | [stable] | [risk: none] | testing, scripts | | [Shared Fixtures: Generic Base](./testing/shared-test-fixtures-generic-base.md) | [ok] 186 | [advanced] | [stable] | [risk: high] | testing, fixtures | | [Shared Fixtures: Reference Counting](./testing/shared-test-fixtures-reference-counting.md) | [ok] 253 | [advanced] | [stable] | [risk: high] | testing, fixtures | diff --git a/.llm/skills/performance/memory-reclamation.md b/.llm/skills/performance/memory-reclamation.md new file mode 100644 index 00000000..51b59e9d --- /dev/null +++ b/.llm/skills/performance/memory-reclamation.md @@ -0,0 +1,184 @@ +--- +title: "DxMessaging Memory Reclamation" +id: "memory-reclamation" +category: "performance" +version: "1.0.0" +created: "2026-05-04" +updated: "2026-05-04" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Runtime/Core/MessageBus/MessageBus.cs" + - path: "Runtime/Core/MessageBus/IMessageBus.cs" + - path: "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs" + - path: "Runtime/Core/Pooling/DxPools.cs" + - path: "Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs" + - path: "Tests/Editor/Contract/MessageBusInvariantTests.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "memory" + - "reclamation" + - "eviction" + - "pooling" + - "messaging" + +complexity: + level: "advanced" + reasoning: "Requires understanding DxMessaging's per-type caches, target/source slots, typed-handler slots, and dispatch snapshot lifetime." + +impact: + performance: + rating: "critical" + details: "Keeps long-lived sessions from retaining every message type and InstanceId ever touched while preserving zero-allocation dispatch." + maintainability: + rating: "high" + details: "Central registry guardrails force new message caches to declare their sweep behavior." + testability: + rating: "high" + details: "MemoryReclaim tests, LeakWatcher slot checks, allocation budgets, and reflection contracts pin the behavior." + +prerequisites: + - "cache-eviction-policies" + - "collection-pooling" + - "allocation-coverage-required-for-dispatch" + +dependencies: + packages: [] + skills: + - "cache-eviction-policies" + - "cache-eviction-builder" + - "array-pooling" + - "collection-pooling" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - ".NET" + versions: + unity: ">=2021.3" + dotnet: ">=netstandard2.0" + +aliases: + - "DxMessaging trim" + - "MemoryReclaim" + - "idle eviction" + +related: + - "cache-eviction-policies" + - "cache-eviction-builder" + - "array-pooling" + - "collection-pooling" + - "memory-reclaim-coverage" + +status: "stable" +--- + +# DxMessaging Memory Reclamation + +> **One-line summary**: Empty DxMessaging slots are reclaimed by a +> counter-based idle policy, explicit `Trim`, and shared pool caps without +> adding allocations to dispatch. + +## Overview + +DxMessaging stores dispatch state by message type, by priority, and, for +targeted and broadcast paths, by `InstanceId`. That shape is required for fast +lookups, but a long-running process can otherwise retain slots for every type or +entity ever touched. Memory reclamation keeps those empty slots bounded. + +The runtime uses two reclamation paths. Idle sweeps run from emit calls and the +Unity PlayerLoop when `DxMessagingRuntimeSettings.EvictionEnabled` is true. +Explicit `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` give +tests, scene transitions, and maintenance windows a synchronous reclaim point. + +Only empty slots are reset. Active registrations are never evicted because a +long-lived listener is valid game state, not stale cache state. + +## Solution + +Treat memory reclamation as an owned registry problem. Every cache that can hold +per-type or per-context state must be inventoried, dirty-tracked, and connected +to either the sweepable bus registry, typed-handler sweep dispatch, or the shared +pool trim path. + +## Inventoried Memory Holders + +| Holder | Key | Reclaimed By | +| ----------------------- | ------------------------------------------------- | --------------------------------------------- | +| Bus scalar sinks | message type | `MessageBus.SweepableTypeCaches` | +| Bus context sinks | message type and `InstanceId` | `MessageBus.SweepableTypeCaches` | +| Interceptor caches | message type | `MessageBus.SweepableTypeCaches` | +| Typed handler slots | message type, handler, priority, optional context | `MessageHandler.ResetEmptyTypedSlotsForSweep` | +| Global accept-all slot | global handler delegates | `MessageBus.SweepGlobalSlot` | +| Shared collection pools | pooled dictionaries, lists, stacks, sets | `DxPools.TrimAll` | + +Any new holder keyed by message type or `InstanceId` must have an explicit row +in tests and, if it is a `MessageCache<>` field, an entry in the sweepable +registry. + +## Eviction Policy + +The idle policy is counter-based, not wall-clock based. `MessageBus` increments +its tick counter on emit, register, and deregister operations, then stamps +touched slots with that counter. A slot becomes eligible when it is empty and +its touch age exceeds the configured idle threshold. + +Wall-clock time controls sweep cadence only. `IDxMessagingClock` decides when +enough seconds have elapsed to run another idle sweep; tests inject `FakeClock` +so cadence checks remain deterministic. Force trims ignore idle age and reclaim +all empty candidates immediately. + +Sweep candidates are dirty-tracked. The bus does not scan every possible +message type each frame; it revisits the types, targets, interceptors, and +handlers touched since the previous sweep. + +## Pool Layer + +`CollectionPool` backs the internal reusable collections. `DxPools` +centralizes the pools for `InstanceId` dictionaries, typed-handler context +dictionaries, typed-handler priority dictionaries, object lists, object stacks, +and integer sets. + +`DxMessagingRuntimeSettings.BufferMaxDistinctEntries` controls the retained +entry cap for each pool. `BufferUseLruEviction` chooses between LRU retention +and bounded LIFO behavior. `DxPools.Configure(settings)` hot-reloads both the +cap and retention mode without recreating buses. + +## Adding a MessageCache + +When adding a new `MessageCache<>` storage field to `MessageBus`: + +1. Bump `MessageBus.ExpectedMessageCacheFieldCount`. +1. Add a matching row to `MessageBus.SweepableTypeCaches`. +1. Add or update `MessageBusInvariantTests` coverage for the field. +1. Add a `MemoryReclamationTests` fixture row that proves the new cache trims. +1. Update `LeakWatcher` if the cache introduces a new public leak counter. +1. Keep stale deregistration closures safe after sweep; a stale closure must + not remove a later registration that reused the same slot. + +## Performance Notes + +- Dispatch remains zero-allocation; sweep work is outside the hot handler loop. +- Touching a slot is a single counter write on register, deregister, or emit. +- Forced trim may allocate a small bounded amount during measurement setup; the + suite pins this through `AllocationMatrixTests.TrimIsBoundedAlloc`. +- Active dispatch snapshots are leased so forced trim cannot return arrays that + are still being iterated. + +## See Also + +- [Memory Reclaim Coverage](../testing/memory-reclaim-coverage.md) +- [High-Performance Cache with Eviction Policies](./cache-eviction-policies.md) +- [Cache Builder Configuration](./cache-eviction-builder.md) +- [Array Pooling](./array-pooling.md) +- [Collection Pooling](./collection-pooling.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-04 | Initial version | diff --git a/.llm/skills/testing/memory-reclaim-coverage.md b/.llm/skills/testing/memory-reclaim-coverage.md new file mode 100644 index 00000000..52a5086f --- /dev/null +++ b/.llm/skills/testing/memory-reclaim-coverage.md @@ -0,0 +1,183 @@ +--- +title: "Memory Reclaim Coverage" +id: "memory-reclaim-coverage" +category: "testing" +version: "1.0.0" +created: "2026-05-04" +updated: "2026-05-04" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs" + - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" + - path: "Tests/Editor/Allocations/AllocationMatrixTests.cs" + - path: "Tests/Editor/Contract/MessageBusInvariantTests.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "memory" + - "reclamation" + - "allocation" + - "leaks" + +complexity: + level: "intermediate" + reasoning: "Tests are mechanical once the memory holder is identified, but they must cover slot counts, stale deregistration, and allocation budgets." + +impact: + performance: + rating: "high" + details: "Prevents regressions that would reintroduce unbounded retained slots or dispatch allocations." + maintainability: + rating: "high" + details: "Gives future cache additions a required test checklist." + testability: + rating: "critical" + details: "Defines the coverage expected for every message-type and InstanceId memory holder." + +prerequisites: + - "leak-watcher-usage" + - "allocation-coverage-required-for-dispatch" + - "tests-must-be-parameterized-by-message-kind" + +dependencies: + packages: [] + skills: + - "memory-reclamation" + - "leak-watcher-usage" + - "allocation-coverage-required-for-dispatch" + - "tests-must-be-parameterized-by-message-kind" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "MemoryReclaim tests" + - "slot leak tests" + - "trim coverage" + +related: + - "memory-reclamation" + - "leak-watcher-usage" + - "allocation-coverage-required-for-dispatch" + - "tests-must-be-parameterized-by-message-kind" + +status: "stable" +--- + +# Memory Reclaim Coverage + +> **One-line summary**: Every DxMessaging memory holder keyed by message type or +> `InstanceId` must have explicit trim, idle-sweep, slot-count, and allocation +> coverage. + +## Overview + +Memory reclamation is a runtime guarantee, not an implementation detail. A new +dictionary, list, stack, or cache keyed by message type or context can create a +session-length retention bug unless tests prove it empties and trims. + +Use the `MemoryReclaim` category for direct reclamation tests. Use +`LeakWatcher.WatchWithSlots` when a test expects slot counts to return to the +baseline. Use `AllocationMatrixTests` when the reclaim path could affect +zero-allocation dispatch or bounded trim budgets. + +## Solution + +Start each change by naming the holder that can retain memory, then write the +smallest test that proves it becomes empty, is swept, and leaves future +registrations safe after stale teardown callbacks run. + +## Coverage Rule + +Every memory holder gets a reclamation test. The minimum proof is: + +1. Create the holder through public registration or emit APIs. +1. Deregister or otherwise make the holder empty. +1. Assert `OccupiedTypeSlots` or `OccupiedTargetSlots` increased before trim. +1. Run `Trim(force: true)` or age the slot and trigger an idle sweep. +1. Assert slot counts return to the pre-test baseline. +1. Assert a stale deregistration closure is a no-op after sweep when the holder + has deregistration handles. + +## LeakWatcher Slots + +`LeakWatcher.Watch()` checks registration counters only. Use +`LeakWatcher.WatchWithSlots()` for tests where trim is part of the expected +cleanup. + +```csharp +using (LeakWatcher watcher = LeakWatcher.WatchWithSlots(label: scenario.DisplayName)) +{ + IMessageBus bus = MessageHandler.MessageBus; + int baseline = bus.OccupiedTypeSlots; + MessageRegistrationHandle handle = RegisterSomething(scenario); + + handle.Deregister(); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, baseline + 1); + + _ = bus.Trim(force: true); +} +``` + +Slot deltas are compared to the watcher's starting snapshot. Tests do not need +the whole bus to be empty; they need the watched region to return to its own +baseline. + +## MemoryReclaim Category + +Place direct reclamation fixtures under `Tests/Runtime/MemoryReclaim` and mark +the fixture or tests with `[Category("MemoryReclaim")]`. This category is +opt-in because it can create many message types or `InstanceId` values and can +run longer than the default suite budget. + +The current runtime budget tests treat `MemoryReclaim` like `Stress`, +`Performance`, and `Allocation`: when it is selected, the default-suite +wall-clock assertion is skipped. If a Unity Test Runner workflow adds category +matrices, include an explicit `MemoryReclaim` leg. + +## Allocation Budget Pattern + +`AllocationMatrixTests` owns dispatch allocation guarantees. Reclamation work +that changes trim or post-trim emit behavior needs allocation coverage: + +- Forced trim should remain bounded by `TrimAllocBudget`. +- Emitting after a partial trim should remain zero-allocation. +- Allocation tests that exercise multiple message kinds must use + `MessageScenarios.AllKinds`. + +## Adding a Holder + +When adding a dictionary, list, stack, pool, or cache keyed by message type or +`InstanceId`: + +1. Identify whether it contributes to type-slot or target-slot occupancy. +1. Add it to `OccupiedTypeSlots` or `OccupiedTargetSlots` if users need to see + the footprint. +1. Add a forced-trim test in `MemoryReclamationTests`. +1. Add an idle-sweep test if the holder is eligible for idle reclamation. +1. Add stale-deregistration coverage when handles can outlive the slot. +1. Add allocation coverage when the holder participates in emit or trim paths. +1. Add `MessageBusInvariantTests` coverage when the holder is a + `MessageCache<>` field. + +## See Also + +- [DxMessaging Memory Reclamation](../performance/memory-reclamation.md) +- [LeakWatcher: Detecting Registration Leaks in Tests](./leak-watcher-usage.md) +- [Allocation Coverage Required for Dispatch](./allocation-coverage-required-for-dispatch.md) +- [Tests Must Be Parameterized by Message Kind](./tests-must-be-parameterized-by-message-kind.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-04 | Initial version | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac58512..b631529c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SuiteSpeedBudgetTest` as a default-suite speed guard rail. - `TestAttributeContractTests` extensions enforcing kind-parameterization and allocation coverage discipline. - Three new public read-only registration counters on `IMessageBus`: `RegisteredInterceptors`, `RegisteredPostProcessors`, and `RegisteredGlobalAcceptAll`. Lets diagnostic and leak-check tooling distinguish interceptor / post-processor / global accept-all leaks from regular handler leaks, and lets external monitors aggregate the bus's registration footprint without reflecting on internals. `MessageBus` aggregates the counters on each read by walking the per-message-type caches; consumers polling these properties in tight loops should snapshot at region boundaries. +- Runtime memory-reclamation foundations: `DxMessagingRuntimeSettings` loads from `Resources/DxMessagingRuntimeSettings` and hot-reloads eviction cadence, enablement, trim opt-out, and pool-cap changes without recreating the bus. Pooled internal collections and typed/bus slot registries preserve existing dispatch APIs while making empty handler and interceptor slots reclaimable. `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` reset dirty empty slots and trim shared pools on demand, `OccupiedTypeSlots` / `OccupiedTargetSlots` expose the retained bus and dirty typed-handler slot footprint for diagnostics, and idle sweeps run from emits and Unity's PlayerLoop. ### Fixed diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index d1b10666fcaa54599e4c02eb115b447731e7960e..1f5b0d5fe0921a544a740f38a48ca8ef31a10ecc 100644 GIT binary patch delta 236 zcmZoT!q{+xaY84vX2GP5Jx9C*u4X4MU9^MGf6e6+Cw%?$%r_@^U*ys-GEFo|Nls2O zPcu!iOfpM0G)qZLGBPwWG%+?cvPeubOtwf$GBHn?92r*6^1GyU#pF9-0RkBsYgRdV zNBGPO)9F7Id~D@poA3h)Fu^#eAV~FhsT&G6wIn8-km?fLEE92u)!&H0lp&G9gdv3? z83>aY%o);vA}I`(K(-lB#1KfP097P07y;QJY{Fm+RAIzm0TfLG@{@tABp@^a>P^|~ I9=DSj0M^}0=>Px# delta 236 zcmZoT!q{+xaY85a0iLNFdyaSsu(7r@W$s(;)5zQS&%kDF+U5lBi(DFpX_kh`mdTdp z$wro@$z~Qti77@#DJE$a2Ihu|W`@SbCT58iX=z52Bg5)hq8>%Qo_r@PK;RhTksnvL z#`|ntS3PC+o{W&mHsJ>ppn@@_P(hGtv4!Q0vzb)SnK&v>*enxqht=PZA&tS3!H^*t zNP@6AkTe3)ra;<^!2-xmWJm#$MnKX8sMdnP0I0?gC~5{Ije*bv$Vvo?rvahSX7{+A F%m8|lOTz#F diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index 1d42f65e9b9710f5a26f232a38bb9dd23bd4e829..b02ef6b06b7ea38fc888de18394b5816e9bcb316 100644 GIT binary patch delta 237 zcmZo@VQOe$n$W?bvGmCAjXff%0&Fo)Jvo`)xa|G;TEcOCyv^nfsr7;yMy81-Dapx6 z=4qxWmPuyGhGr>=Nk)c7h9<_QMiz-_hRGI5Nhanglf%pRvN$eZ`fajAMSwthoW#yF z+e6-q8t*1^Ts?Goaz(`j1*l;3Bd8!yb(v}Xj1_JvQh&Uc>&@7lQTc$y--yAKA(6p^ zA%!6s2$LAh8Pb3vDGZiCwi!^w5J;u~RU|PO0ofpI!e9(kVZ>ko6ioy2lYy)xAT$B$ LP1!uXE|VDmAInX7 delta 237 zcmZo@VQOe$n$W>w)5ddnV~HUDStf6LduFmkMSuW%?$^q< zH5Hxqw)cZzac{!gC&C@ zLo$#AVRIm91f)%Yv>Ag1ke$eo0wj%qqzO>11%m-ljUiCf3`iOSp$U+c2oz5PLZi*o H>oS=EmYz(z diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs index 3446fa8f..308797d2 100644 --- a/Runtime/AssemblyInfo.cs +++ b/Runtime/AssemblyInfo.cs @@ -10,3 +10,4 @@ [assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Runtime.VContainer")] [assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Runtime.Zenject")] [assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor")] +[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor.Allocations")] diff --git a/Runtime/Core/Configuration.meta b/Runtime/Core/Configuration.meta new file mode 100644 index 00000000..eb7ac2e0 --- /dev/null +++ b/Runtime/Core/Configuration.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 0c5cb60b0129e6df3a4455a62ec7ffb9 +timeCreated: 1777840186 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs new file mode 100644 index 00000000..db895060 --- /dev/null +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -0,0 +1,190 @@ +namespace DxMessaging.Core.Configuration +{ + using System; + using MessageBus; +#if UNITY_2021_3_OR_NEWER + using UnityEngine; + + /// + /// Runtime-loaded settings asset that controls memory-reclamation policy and + /// pool sizing for DxMessaging. Loaded at first bus construction via + /// Resources.Load<DxMessagingRuntimeSettings>("DxMessagingRuntimeSettings"); + /// when the asset is absent a defaulted instance is used so the package works + /// out-of-the-box. + /// + /// + /// To customize, create the asset via Assets > Create > Wallstop > + /// DxMessaging > Runtime Settings and place it under any + /// Resources/ folder named DxMessagingRuntimeSettings.asset. + /// Field changes raise ; consumers should + /// re-read derived state on the event. + /// + [CreateAssetMenu(fileName = ResourceName, menuName = "Wallstop/DxMessaging/Runtime Settings")] + public sealed class DxMessagingRuntimeSettings : ScriptableObject + { + /// Resource name (no extension) used by Resources.Load. + public const string ResourceName = "DxMessagingRuntimeSettings"; + + /// Default soft cap on per-pool retained entries. + public const int DefaultBufferMaxDistinctEntries = 512; + + /// Default idle threshold in seconds before an empty slot is eligible for eviction. + public const float DefaultIdleEvictionSeconds = 30f; + + /// Default minimum interval between idle sweeps, in seconds. + public const float DefaultEvictionTickIntervalSeconds = 5f; + + [SerializeField] + [Tooltip( + "Idle threshold in seconds. Empty per-message-type slots are evicted only after going at least this long without a register/deregister/dispatch touch. See IdleEvictionSeconds." + )] + [Min(0f)] + internal float _idleEvictionSeconds = DefaultIdleEvictionSeconds; + + [SerializeField] + [Tooltip( + "Soft cap on the number of distinct entries each shared collection pool will retain. Excess entries are evicted (LRU or LIFO depending on BufferUseLruEviction)." + )] + [Min(0)] + internal int _bufferMaxDistinctEntries = DefaultBufferMaxDistinctEntries; + + [SerializeField] + [Tooltip( + "When true, shared collection pools use LRU eviction; otherwise pools behave as a bounded LIFO stack. See BufferUseLruEviction." + )] + internal bool _bufferUseLruEviction = true; + + [SerializeField] + [Tooltip( + "When true, IMessageBus.Trim performs its work; when false it is a no-op returning default. Lets shipped titles disable on-demand reclamation. See EnableTrimApi." + )] + internal bool _enableTrimApi = true; + + [SerializeField] + [Tooltip( + "Minimum interval in seconds between idle sweeps. The bus checks the clock at the top of each Emit and only sweeps when this much wall time has elapsed since the last sweep. See EvictionTickIntervalSeconds." + )] + [Min(0f)] + internal float _evictionTickIntervalSeconds = DefaultEvictionTickIntervalSeconds; + + [SerializeField] + [Tooltip( + "Master switch for idle-time eviction. When false neither inline emit-time sweeps nor PlayerLoop sweeps run; explicit Trim still works (gated by EnableTrimApi). See EvictionEnabled." + )] + internal bool _evictionEnabled = true; + + [SerializeField] + [Tooltip( + "Diagnostic message buffer size used when the bus is constructed. Mirrors IMessageBus.DefaultMessageBufferSize so the runtime asset can override the global default without touching code. See MessageBufferSize." + )] + [Min(0)] + internal int _messageBufferSize = IMessageBus.DefaultMessageBufferSize; + + private bool _isFallbackInstance; + + /// Idle threshold in seconds. See _idleEvictionSeconds. + public float IdleEvictionSeconds => _idleEvictionSeconds; + + /// Per-pool retained-entry cap. See _bufferMaxDistinctEntries. + public int BufferMaxDistinctEntries => _bufferMaxDistinctEntries; + + /// True when shared pools use LRU eviction. + public bool BufferUseLruEviction => _bufferUseLruEviction; + + /// True when explicit Trim APIs perform work. + public bool EnableTrimApi => _enableTrimApi; + + /// Minimum interval between idle sweeps, in seconds. + public float EvictionTickIntervalSeconds => _evictionTickIntervalSeconds; + + /// Master switch for idle-time eviction. + public bool EvictionEnabled => _evictionEnabled; + + /// Diagnostic message buffer size. + public int MessageBufferSize => _messageBufferSize; + + internal bool IsFallbackInstance => _isFallbackInstance; + + /// + /// Raised when this asset is mutated in the editor or via test-only override. + /// Subscribers should be small and re-entrancy-safe; the event is invoked + /// synchronously from OnValidate. + /// + public static event Action SettingsChanged; + + /// + /// Used by to push + /// a test-supplied override and notify subscribers. + /// + internal static void RaiseSettingsChanged(DxMessagingRuntimeSettings settings) + { + SettingsChanged?.Invoke(settings); + } + + internal void MarkAsFallbackInstance() + { + _isFallbackInstance = true; + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ClearSubscribersOnLoad() + { + SettingsChanged = null; + } + +#if UNITY_EDITOR + private const string ResourceFolder = "Assets/Resources"; + private const string ResourceAssetPath = ResourceFolder + "/" + ResourceName + ".asset"; + + [UnityEditor.MenuItem("Assets/Create/Wallstop/DxMessaging/Runtime Settings (in Resources)")] + private static void CreateAssetInResources() + { + if (!UnityEditor.AssetDatabase.IsValidFolder(ResourceFolder)) + { + UnityEditor.AssetDatabase.CreateFolder("Assets", "Resources"); + } + string targetPath = UnityEditor.AssetDatabase.GenerateUniqueAssetPath( + ResourceAssetPath + ); + DxMessagingRuntimeSettings asset = + ScriptableObject.CreateInstance(); + UnityEditor.AssetDatabase.CreateAsset(asset, targetPath); + UnityEditor.AssetDatabase.SaveAssets(); + UnityEditor.EditorGUIUtility.PingObject(asset); + } + + private void OnValidate() + { + if (_idleEvictionSeconds < 0f) + { + _idleEvictionSeconds = 0f; + } + if (_bufferMaxDistinctEntries < 0) + { + _bufferMaxDistinctEntries = 0; + } + if (_evictionTickIntervalSeconds < 0f) + { + _evictionTickIntervalSeconds = 0f; + } + if (_messageBufferSize < 0) + { + _messageBufferSize = 0; + } + string assetPath = UnityEditor.AssetDatabase.GetAssetPath(this); + if ( + !string.IsNullOrEmpty(assetPath) + && assetPath.IndexOf("/Resources/", StringComparison.OrdinalIgnoreCase) < 0 + ) + { + Debug.LogWarning( + "[DxMessaging] Runtime settings asset is not under a Resources/ folder; Resources.Load will not find it. Move it under Assets/Resources/ or use the 'Assets/Create/Wallstop/DxMessaging/Runtime Settings (in Resources)' menu.", + this + ); + } + RaiseSettingsChanged(this); + } +#endif + } +#endif +} diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs.meta b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs.meta new file mode 100644 index 00000000..f5d11035 --- /dev/null +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f98fd11ff90d4d813cabc6bc2a77a72 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs new file mode 100644 index 00000000..afe1cdb7 --- /dev/null +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs @@ -0,0 +1,179 @@ +namespace DxMessaging.Core.Configuration +{ + using System; + using System.Threading; + using MessageBus; +#if UNITY_2021_3_OR_NEWER + using UnityEngine; +#endif + +#if UNITY_2021_3_OR_NEWER + /// + /// Lazy provider that hands out the active + /// instance. Tries Resources.Load<DxMessagingRuntimeSettings>("DxMessagingRuntimeSettings") + /// once per AppDomain; on miss, returns a defaulted in-memory instance so the + /// runtime always has a usable settings object. + /// + /// + /// Tests inject a fake via , which returns an + /// that restores the prior current settings. Override + /// raises on push and + /// pop so subscribed buses re-apply caps. + /// + public static class DxMessagingRuntimeSettingsProvider + { + private static DxMessagingRuntimeSettings _cached; + private static readonly object _gate = new(); + + /// + /// Returns the active settings instance. Loads the asset on first call; + /// subsequent calls return the cached reference (or a test override if + /// is active). + /// + /// + /// In non-Unity builds (where UNITY_2021_3_OR_NEWER is not defined) + /// this property returns null because ScriptableObject is + /// unavailable. Callers must tolerate a null result outside Unity. + /// + public static DxMessagingRuntimeSettings Current + { + get + { + DxMessagingRuntimeSettings local = Volatile.Read(ref _cached); + if (local != null) + { + return local; + } + lock (_gate) + { + local = _cached; + if (local != null) + { + return local; + } + local = LoadOrCreate(); + Volatile.Write(ref _cached, local); + return local; + } + } + } + + /// + /// Pushes a test-supplied settings instance as the active + /// value and raises . + /// Disposing the returned token restores the previous instance and raises + /// the event again so subscribers re-apply the original caps. + /// + public static IDisposable Override(DxMessagingRuntimeSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + DxMessagingRuntimeSettings previous; + int previousGlobalMessageBufferSize = IMessageBus.GlobalMessageBufferSize; + lock (_gate) + { + previous = _cached; + _cached = settings; + } + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + return new OverrideToken(settings, previous, previousGlobalMessageBufferSize); + } + + /// + /// Clears the cached reference, forcing the next + /// access to reload from Resources. Test-only. + /// + internal static void ResetForTests() + { + lock (_gate) + { + _cached = null; + } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void OnSubsystemRegistration() + { + ResetForTests(); + } + + private static DxMessagingRuntimeSettings LoadOrCreate() + { + DxMessagingRuntimeSettings asset = null; + try + { + asset = Resources.Load( + DxMessagingRuntimeSettings.ResourceName + ); + } + catch + { + asset = null; + } + if (asset != null) + { + return asset; + } + DxMessagingRuntimeSettings fallback = + ScriptableObject.CreateInstance(); + fallback.MarkAsFallbackInstance(); + fallback.hideFlags = HideFlags.HideAndDontSave; + fallback.name = DxMessagingRuntimeSettings.ResourceName + " (Default)"; + return fallback; + } + + private sealed class OverrideToken : IDisposable + { + private readonly DxMessagingRuntimeSettings _installed; + private readonly int _previousGlobalMessageBufferSize; + private DxMessagingRuntimeSettings _previous; + private bool _disposed; + + public OverrideToken( + DxMessagingRuntimeSettings installed, + DxMessagingRuntimeSettings previous, + int previousGlobalMessageBufferSize + ) + { + _installed = installed; + _previous = previous; + _previousGlobalMessageBufferSize = previousGlobalMessageBufferSize; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + DxMessagingRuntimeSettings restored = null; + bool didRestore = false; + lock (_gate) + { + // Only restore if our install is still the active one. + // If a deeper Override was pushed on top, this Dispose is a no-op + // so the LIFO stack is honored. + if (ReferenceEquals(_cached, _installed)) + { + _cached = _previous; + restored = _previous; + didRestore = true; + } + _previous = null; + } + if (didRestore) + { + if (restored == null || restored.IsFallbackInstance) + { + IMessageBus.GlobalMessageBufferSize = _previousGlobalMessageBufferSize; + } + DxMessagingRuntimeSettings.RaiseSettingsChanged(restored); + } + } + } + } +#endif +} diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs.meta b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs.meta new file mode 100644 index 00000000..a7aa2c37 --- /dev/null +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b242396a6d286a776885179f9908a593 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/DataStructure/CyclicBuffer.cs b/Runtime/Core/DataStructure/CyclicBuffer.cs index fe322f91..85c160d5 100644 --- a/Runtime/Core/DataStructure/CyclicBuffer.cs +++ b/Runtime/Core/DataStructure/CyclicBuffer.cs @@ -15,7 +15,7 @@ namespace DxMessaging.Core.DataStructure [Serializable] internal sealed class CyclicBuffer : IReadOnlyList { - internal struct CyclicBufferEnumerator : IEnumerator + public struct CyclicBufferEnumerator : IEnumerator { private readonly CyclicBuffer _buffer; @@ -123,7 +123,7 @@ public CyclicBuffer(int capacity, IEnumerable initialContents = null) /// /// Creates an enumerator that iterates from the oldest element to the most recently added. /// - internal CyclicBufferEnumerator GetEnumerator() + public CyclicBufferEnumerator GetEnumerator() { return new CyclicBufferEnumerator(this); } diff --git a/Runtime/Core/Helper/MessageCache.cs b/Runtime/Core/Helper/MessageCache.cs index ff8f8d53..a4e59271 100644 --- a/Runtime/Core/Helper/MessageCache.cs +++ b/Runtime/Core/Helper/MessageCache.cs @@ -155,6 +155,25 @@ public bool TryGetValue(out TValue value) return false; } + /// + /// Attempts to get the value at an already-resolved message type index. + /// + /// Index previously assigned by . + /// Out parameter receiving the value if present. + /// True if a non-null value was present. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryGetValueAtIndex(int index, out TValue value) + { + if (0 <= index && index < _values.Count) + { + value = _values[index]; + return value != null; + } + + value = default; + return false; + } + /// /// Removes the value for the given key. /// @@ -170,6 +189,19 @@ public void Remove() } } + /// + /// Removes the value at an already-resolved message type index. + /// + /// Index previously assigned by . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void RemoveAtIndex(int index) + { + if (0 <= index && index < _values.Count) + { + _values[index] = null; + } + } + /// /// Returns an enumerator iterating over non-null entries in insertion order. /// diff --git a/Runtime/Core/Internal.meta b/Runtime/Core/Internal.meta new file mode 100644 index 00000000..21390a5a --- /dev/null +++ b/Runtime/Core/Internal.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 705ffeb04a4b4d8fbbdbab114cbc560f +timeCreated: 1777840186 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Internal/TypedDispatchLinkIndex.cs b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs new file mode 100644 index 00000000..933737fd --- /dev/null +++ b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs @@ -0,0 +1,51 @@ +namespace DxMessaging.Core.Internal +{ + /// + /// Const-int positions into TypedHandler<T>._dispatchLinks[]. + /// Indices are hand-written so call sites inline as immediate operands. + /// Array length and per-index null-ness are validated in DEBUG + /// builds via TypedHandler<T>.ValidateSlotArrays(). + /// + /// + /// Positions are laid out in lex-(Kind, Phase, Variant) order: + /// Untargeted -> Targeted -> Broadcast within Kind, Handle before + /// PostProcess within Phase, and with-context before WithoutContext + /// within Variant. The xmldoc on each constant names the legacy + /// TypedHandler<T> dispatch-link field whose storage role + /// the slot will assume in the P3.3 storage migration. + /// + internal static class TypedDispatchLinkIndex + { + /// Legacy field: _untargetedLink. + public const int UntargetedHandle = 0; + + /// Legacy field: _untargetedPostLink. + public const int UntargetedPostProcess = 1; + + /// Legacy field: _targetedLink. + public const int TargetedHandle = 2; + + /// Legacy field: _targetedWithoutTargetingLink. + public const int TargetedHandleWithoutContext = 3; + + /// Legacy field: _targetedPostLink. + public const int TargetedPostProcess = 4; + + /// Legacy field: _targetedWithoutTargetingPostLink. + public const int TargetedPostProcessWithoutContext = 5; + + /// Legacy field: _broadcastLink. + public const int BroadcastHandle = 6; + + /// Legacy field: _broadcastWithoutSourceLink. + public const int BroadcastHandleWithoutContext = 7; + + /// Legacy field: _broadcastPostLink. + public const int BroadcastPostProcess = 8; + + /// Legacy field: _broadcastWithoutSourcePostLink. + public const int BroadcastPostProcessWithoutContext = 9; + + public const int Length = 10; + } +} diff --git a/Runtime/Core/Internal/TypedDispatchLinkIndex.cs.meta b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs.meta new file mode 100644 index 00000000..bb5a082a --- /dev/null +++ b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 031ffa9bfbf249728da382bb9e03b017 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Internal/TypedGlobalSlotIndex.cs b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs new file mode 100644 index 00000000..c35b53c9 --- /dev/null +++ b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs @@ -0,0 +1,38 @@ +namespace DxMessaging.Core.Internal +{ + /// + /// Const-int positions into TypedHandler<T>._globalSlots[]. + /// Indices are hand-written so call sites inline as immediate operands. + /// Array length and per-index null-ness are validated in DEBUG + /// builds via TypedHandler<T>.ValidateSlotArrays(). + /// + /// + /// Positions are laid out in lex-(Kind, Variant) order: + /// Untargeted -> Targeted -> Broadcast within Kind, Default before + /// Fast within Variant. The xmldoc on each constant names the legacy + /// TypedHandler<T> field whose storage role the slot will + /// assume in the P3.3 storage migration. + /// + internal static class TypedGlobalSlotIndex + { + /// Legacy field: _globalUntargetedHandlers. + public const int UntargetedDefault = 0; + + /// Legacy field: _globalUntargetedFastHandlers. + public const int UntargetedFast = 1; + + /// Legacy field: _globalTargetedHandlers. + public const int TargetedDefault = 2; + + /// Legacy field: _globalTargetedFastHandlers. + public const int TargetedFast = 3; + + /// Legacy field: _globalBroadcastHandlers. + public const int BroadcastDefault = 4; + + /// Legacy field: _globalBroadcastFastHandlers. + public const int BroadcastFast = 5; + + public const int Length = 6; + } +} diff --git a/Runtime/Core/Internal/TypedGlobalSlotIndex.cs.meta b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs.meta new file mode 100644 index 00000000..634cc4b2 --- /dev/null +++ b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1d15b0be71e49828b55dc1654748f4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Internal/TypedSlotIndex.cs b/Runtime/Core/Internal/TypedSlotIndex.cs new file mode 100644 index 00000000..29699be2 --- /dev/null +++ b/Runtime/Core/Internal/TypedSlotIndex.cs @@ -0,0 +1,81 @@ +namespace DxMessaging.Core.Internal +{ + /// + /// Const-int positions into TypedHandler<T>._slots[]. Indices + /// are hand-written so call sites inline as immediate operands. Array + /// length and per-index null-ness are validated in DEBUG builds via + /// TypedHandler<T>.ValidateSlotArrays(). + /// + /// + /// Positions are laid out in lex-(Kind, Phase, Variant) order: + /// Untargeted -> Targeted -> Broadcast within Kind; Handle before + /// PostProcess within Phase; and Default -> Fast -> WithoutContext -> + /// WithoutContextFast within Variant. The xmldoc on each constant names + /// the legacy TypedHandler<T> field whose storage role the + /// slot will assume in the P3.3 storage migration. + /// + internal static class TypedSlotIndex + { + /// Legacy field: _untargetedHandlers. + public const int UntargetedHandleDefault = 0; + + /// Legacy field: _untargetedFastHandlers. + public const int UntargetedHandleFast = 1; + + /// Legacy field: _untargetedPostProcessingHandlers. + public const int UntargetedPostProcessDefault = 2; + + /// Legacy field: _untargetedPostProcessingFastHandlers. + public const int UntargetedPostProcessFast = 3; + + /// Legacy field: _targetedHandlers. + public const int TargetedHandleDefault = 4; + + /// Legacy field: _targetedFastHandlers. + public const int TargetedHandleFast = 5; + + /// Legacy field: _targetedWithoutTargetingHandlers. + public const int TargetedHandleWithoutContext = 6; + + /// Legacy field: _fastTargetedWithoutTargetingHandlers. + public const int TargetedHandleWithoutContextFast = 7; + + /// Legacy field: _targetedPostProcessingHandlers. + public const int TargetedPostProcessDefault = 8; + + /// Legacy field: _targetedPostProcessingFastHandlers. + public const int TargetedPostProcessFast = 9; + + /// Legacy field: _targetedWithoutTargetingPostProcessingHandlers. + public const int TargetedPostProcessWithoutContext = 10; + + /// Legacy field: _fastTargetedWithoutTargetingPostProcessingHandlers. + public const int TargetedPostProcessWithoutContextFast = 11; + + /// Legacy field: _broadcastHandlers. + public const int BroadcastHandleDefault = 12; + + /// Legacy field: _broadcastFastHandlers. + public const int BroadcastHandleFast = 13; + + /// Legacy field: _broadcastWithoutSourceHandlers. + public const int BroadcastHandleWithoutContext = 14; + + /// Legacy field: _fastBroadcastWithoutSourceHandlers. + public const int BroadcastHandleWithoutContextFast = 15; + + /// Legacy field: _broadcastPostProcessingHandlers. + public const int BroadcastPostProcessDefault = 16; + + /// Legacy field: _broadcastPostProcessingFastHandlers. + public const int BroadcastPostProcessFast = 17; + + /// Legacy field: _broadcastWithoutSourcePostProcessingHandlers. + public const int BroadcastPostProcessWithoutContext = 18; + + /// Legacy field: _fastBroadcastWithoutSourcePostProcessingHandlers. + public const int BroadcastPostProcessWithoutContextFast = 19; + + public const int Length = 20; + } +} diff --git a/Runtime/Core/Internal/TypedSlotIndex.cs.meta b/Runtime/Core/Internal/TypedSlotIndex.cs.meta new file mode 100644 index 00000000..b041d970 --- /dev/null +++ b/Runtime/Core/Internal/TypedSlotIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef97ae16e3954ebba9ff6f003592d2da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Internal/TypedSlots.cs b/Runtime/Core/Internal/TypedSlots.cs new file mode 100644 index 00000000..6a1c5954 --- /dev/null +++ b/Runtime/Core/Internal/TypedSlots.cs @@ -0,0 +1,615 @@ +namespace DxMessaging.Core.Internal +{ + using System.Collections.Generic; + using System.Runtime.CompilerServices; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus.Internal; + using DxMessaging.Core.Pooling; + + /// + /// Non-generic erasure interface over the per-delegate-shape + /// HandlerActionCache<TDelegate> type currently nested in + /// . Exposes only the metadata the staged + /// dispatch + eviction layers need so the typed-handler-side slot grid + /// (, ) can hold + /// caches polymorphically across the four typed delegate shapes + /// (Action<T>, FastHandler<T>, + /// Action<InstanceId, T>, + /// FastHandlerWithContext<T>) plus the six global + /// non-generic shapes (3 kinds {IUntargetedMessage, + /// ITargetedMessage, IBroadcastMessage} times 2 variants + /// {Default, Fast}), 10 distinct shapes total. + /// + /// + /// + /// HandlerActionCache<TDelegate> implements this interface + /// explicitly as of P3.2 -- the explicit form keeps the public field + /// shape on the nested cache type unchanged, so the public dispatch + /// surface picks up no new members from the interface retrofit. + /// + /// + /// Deliberately a thin, marker-style surface: only the six members that + /// staged dispatch (, , + /// , + /// ) and eviction + /// (, ) require. The + /// entries dictionary and cache list are NOT exposed + /// because their generic shape is the very thing this interface erases; + /// dispatchers that need the typed cache down-cast at the call site. + /// + /// + internal interface IHandlerActionCache + { + /// + /// Strictly monotonic version counter for the cache's structural + /// state. Mirrors HandlerActionCache<TDelegate>.version. + /// Read-only on this surface; bumped internally by the cache's own + /// register / deregister sites and by . + /// + long Version { get; } + + /// + /// The value observed by the most recent + /// dispatcher snapshot. Mirrors + /// HandlerActionCache<TDelegate>.lastSeenVersion and is + /// mutated by the staged dispatch path to detect when the flat cache + /// list needs to be re-materialised. + /// + long LastSeenVersion { get; set; } + + /// + /// The bus emission id of the most recent dispatch that consumed + /// this cache. Mirrors + /// HandlerActionCache<TDelegate>.lastSeenEmissionId. + /// Used by the staged dispatch staleness check. + /// + long LastSeenEmissionId { get; set; } + + /// + /// Number of invocations observed during the prefreeze window for + /// the most recent dispatch. Mirrors + /// HandlerActionCache<TDelegate>.prefreezeInvocationCount. + /// Read-only on this surface; the cache's own dispatchers maintain + /// the value. + /// + int PrefreezeInvocationCount { get; } + + /// + /// True iff the cache currently retains zero entries. Cheap (single + /// integer compare against entries.Count); used by the + /// eviction sweep so empty caches can be reclaimed without walking + /// inner state. + /// + bool IsEmpty { get; } + + /// + /// Eviction-driven full clear. Empties the entries dictionary and + /// the flat cache list, resets / + /// / prefreezeInvocationCount, + /// and bumps as the LAST step so any captured + /// dispatch closure that observed the prior version detects + /// invalidation (PLAN Risk Register R3). Idempotent. + /// + void Reset(); + } + + /// + /// Non-generic sweep surface for MessageHandler.TypedHandler<T>. + /// The owning stores typed handlers in a + /// MessageCache<object>, so external reclamation code needs an + /// erased entry point that can reset empty typed slots without reflection. + /// + internal interface ITypedHandlerSlotSweeper + { + /// + /// Resets every empty typed or typed-global slot and removes it from + /// the handler's slot arrays. + /// + /// Number of slots reset. + int ResetEmptySlotsForSweep(); + + /// + /// Resets every typed or typed-global slot and removes it from the + /// handler's slot arrays. + /// + /// Number of slots reset. + int ResetAllSlotsForBusReset(); + + /// + /// Counts empty typed or typed-global slots still occupying memory and + /// eligible for a sweep reset. + /// + /// Number of empty slots still allocated. + int CountEmptySlotsForSweep(); + } + + /// + /// Per-message-type, per- dispatch slot on the + /// typed-handler side. Mirrors the role of + /// on the bus side: holds a priority-keyed map + /// of s plus the snapshot-friendly + /// ordered-priority list, and tracks the staged-dispatch / eviction + /// counters. + /// + /// + /// + /// PLAN section 2.3 sketched this type as abstract. We chose + /// sealed here because there is no concrete subclass to + /// introduce per delegate variant without speculatively enumerating the + /// variants the storage migration will need. If delegate-variant + /// specialisation becomes necessary in P3.3 (for example, to encode a + /// non-generic dispatch fast path per shape), the class can be promoted + /// to abstract at that point with the concrete subclasses + /// introduced in the same change. Promoting now would commit to a + /// specific subclass layout the migration may not actually need. + /// + /// + /// PLAN section 2.3 also sketched RequiresContext as an abstract + /// property. Because this class is sealed, the property collapses to a + /// readonly field () set via the + /// constructor. The semantic is identical: the field is true for + /// slots whose resolves to a + /// that carries an + /// recipient or source (Targeted / Broadcast, + /// excluding the WithoutContext variants), and false + /// otherwise. + /// + /// + /// routes storage through this slot; + /// P3.3 deleted the legacy named fields and made the + /// _slots[] array the + /// storage owner. + /// + /// + /// PLAN section 2.3 also calls for a + /// _dispatchLinks[] + /// array on . That array is a plain + /// object[] field on the handler, not a slot type; P3.3 + /// deleted the named dispatch-link fields. + /// + /// + /// + /// The strongly-typed message contract this slot's parent + /// binds to. The slot itself does not + /// reference directly today (the type-erased + /// handles the per-delegate generic + /// shapes) -- the parameter is carried so the P3.3 storage migration + /// can add a concrete cache reference here without an additional + /// generic re-parameterization. + /// + internal sealed class TypedSlot : IEvictableSlot + where T : IMessage + { + /// + /// Per-priority handler caches keyed by priority value. + /// routes non-context storage through + /// this slot. + /// + public readonly Dictionary byPriority = new(); + + /// + /// Insertion-ordered list of priority keys present in + /// . Mirrors the legacy ordered-priority + /// list used by the staged dispatch snapshot pattern. + /// + public readonly List orderedPriorities = new(); + + /// Monotonic version counter for the slot's structural state. + public long version; + + /// + /// The value observed by the most recent + /// dispatcher snapshot. Used to decide whether the cache list needs + /// to be re-materialised before the next dispatch. Forward-compat + /// plumbing; not yet read by the typed-handler hot path. + /// + public long lastSeenVersion = -1; + + /// + /// The bus emission id of the most recent dispatch that consumed + /// this slot. Used by the staged dispatch staleness check. + /// Forward-compat plumbing; not yet read by the typed-handler hot + /// path. + /// + public long lastSeenEmissionId; + + /// + /// Bus tick counter value at the most recent register / deregister / + /// emit that touched this slot. Will be maintained by P4's touch + /// hook; preserved across and + /// so the sweep can distinguish freshly-reset slots from + /// never-touched slots. + /// + public long lastTouchTicks; + + /// + /// Reserved live-handler counter intended to mirror the unique + /// (handler, priority) pair count across every entry in + /// ; for context-bound slots, the SUM of + /// (handler, priority) pair counts across every (InstanceId, + /// priority) leaf in -- matching + /// semantics so eviction logic + /// does not diverge between bus-side and handler-side. + /// is a single integer compare. The typed + /// handler maintains this counter on first-registration and + /// final-deregistration transitions so + /// reflects whether the slot still owns live handlers. + /// + public int liveCount; + + /// + /// True iff this slot's resolves to a + /// dispatch variant that carries an + /// recipient or source (the non-WithoutContext Targeted and + /// Broadcast variants). When true, the storage migration + /// will populate ; when false, + /// storage flows through directly. + /// + /// + /// PLAN section 2.3 sketched this as an abstract RequiresContext + /// property; collapsed to a readonly field here because + /// is sealed (see class remarks). + /// + public readonly bool requiresContext; + + /// + /// Inner per-context map for context-bound slots. Null unless + /// is true AND at least one + /// context has been registered. Forward-compat plumbing. + /// + /// + /// + /// Lifetime semantic for the storage migration: + /// and return the outer context dictionary and + /// every inner priority dictionary to before + /// nulling the field. + /// + /// + /// Unlike the bus-side , which + /// is rented from DxPools.InstanceIdDicts as a + /// Dictionary<InstanceId, object> (boxed + /// ) for cross-message-type pool sharing, + /// the typed-handler-side equivalent here is a strongly-typed + /// Dictionary<InstanceId, Dictionary<int, IHandlerActionCache>>. + /// Both the outer context dictionary and the inner priority + /// dictionaries are rented from typed-handler-specific + /// pools. + /// + /// + /// Shape: InstanceId -> (priority -> IHandlerActionCache), + /// with the leaf cache type-erased to . + /// The inner dictionary is keyed by priority, matching the legacy + /// Dictionary<InstanceId, Dictionary<int, HandlerActionCache<TDelegate>>> + /// layout on MessageHandler.TypedHandler<T>. The flat + /// 3-level shape was chosen over the alternatives (extend + /// with per-priority buckets, or + /// recurse with Dictionary<InstanceId, TypedSlot<T>>) + /// because it preserves the legacy storage layout exactly -- + /// minimising the per-call-site rewrite the P3.3 storage migration + /// has to perform. PLAN Risk Register R3 informs the + /// monotonic-version drain contract on : every + /// inner cache is drained through + /// before the outer + /// container is cleared. + /// + /// + public Dictionary> byContext; + + /// + /// Constructs a with the supplied + /// context-binding flag. All other fields take their default + /// initial values. + /// + /// + /// Value for ; see that + /// field's remarks for the semantic. + /// + public TypedSlot(bool requiresContext) + { + this.requiresContext = requiresContext; + } + + /// + public long LastTouchTicks + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastTouchTicks; + } + + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => liveCount == 0; + } + + /// + public long Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// + /// Full-reset semantic. Empties and + /// , nulls out + /// , and resets the staged-dispatch + /// counters. Resets to 0; this is NOT + /// monotonic and is intended only for the typed-handler analog of + /// MessageBus.ResetState() if and when that code path is + /// wired up. Use for sweep-driven slot + /// reclamation. + /// + /// + /// + /// Mirrors . + /// returns and its inner priority + /// dictionaries to . + /// + /// + /// Unlike which drains inner buckets, + /// this drops references only -- + /// is intended for the typed-handler analog of + /// MessageBus.ResetState() where the entire + /// graph is being torn down, so per-cache + /// drain would be redundant work. (eviction-driven) + /// DOES drain inner caches via + /// because outer-version invalidation alone is insufficient when the + /// slot is being re-used after sweep. + /// + /// + public void Clear() + { + byPriority.Clear(); + orderedPriorities.Clear(); + ReturnContextDictionaries(); + byContext = null; + version = 0; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + } + + /// + /// Eviction-driven reset. Drains every inner + /// held by + /// and through + /// first, then clears the + /// outer containers, then bumps as the LAST + /// step so any captured dispatch closure that observed the prior + /// version detects invalidation. + /// is intentionally preserved so the + /// sweep can distinguish freshly-reset slots from never-touched + /// ones. + /// + /// + /// Drain order is load-bearing per PLAN Risk Register R3: inner + /// caches must be reset (and their own monotonic versions bumped) + /// BEFORE the outer container is cleared, so any captured dispatch + /// closure observing an inner cache detects invalidation regardless + /// of whether the outer reference is still reachable. The outer + /// bump is the LAST statement in the method + /// for the same reason at the slot level. + /// + public void Reset() + { + // Inline the structural-clear body of Clear(); do NOT call + // Clear() because that resets version=0 and would break the + // monotonic invariant the eviction layer depends on (PLAN Risk + // Register R3: stale deregister closures captured before reset + // must observe a strictly larger version after reset and skip + // their work). + // Per-cache drain BEFORE the structural clear: every + // IHandlerActionCache.Reset() bumps its own version internally, + // so closures captured against the inner cache also detect + // invalidation -- not just closures captured against the slot. + foreach (KeyValuePair kv in byPriority) + { + kv.Value?.Reset(); + } + if (byContext != null) + { + foreach ( + KeyValuePair> ctx in byContext + ) + { + if (ctx.Value == null) + { + continue; + } + foreach (KeyValuePair kv in ctx.Value) + { + kv.Value?.Reset(); + } + } + } + byPriority.Clear(); + orderedPriorities.Clear(); + ReturnContextDictionaries(); + byContext = null; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + unchecked + { + ++version; + } + } + + private void ReturnContextDictionaries() + { + if (byContext == null) + { + return; + } + + foreach ( + KeyValuePair> ctx in byContext + ) + { + DxPools.TypedHandlerPriorityDicts.Return(ctx.Value); + } + DxPools.TypedHandlerContextDicts.Return(byContext); + } + } + + /// + /// Per-message-type accept-all slot on the typed-handler side. Mirrors + /// the role of on the bus side: holds a + /// single type-erased cache for the slot's + /// (, ) + /// coordinate and the staged-dispatch / eviction counters. + /// + /// + /// + /// Per PLAN section 2.3 the typed handler holds an array of 6 + /// . The per-(, + /// ) indexing scheme that maps the six + /// global flavours (3 kinds {Untargeted, Targeted, + /// Broadcast} times 2 variants {Default, Fast}) to + /// array slots is committed in + /// (sibling file in the same folder). This type defines the per-slot + /// shape; the index file owns the layout decision. + /// routes global storage through this type. + /// + /// + /// Non-generic by design: the typed handler's six legacy global + /// fields each carry a different non-generic delegate shape + /// (Action<IUntargetedMessage>, + /// FastHandler<IUntargetedMessage>, + /// Action<InstanceId, ITargetedMessage>, etc.), and the + /// bus-side mirrors this. The single + /// field holds an erased + /// whose concrete generic shape is + /// determined by the slot's coordinate; dispatchers down-cast at the + /// call site. + /// + /// + /// Single field intentionally -- not three like + /// 's + /// untargetedDispatchState / targetedDispatchState / + /// broadcastDispatchState trio. The typed-handler-side global + /// array is the per-kind-and-variant fan-out (six slots), so each + /// slot already corresponds to a single kind+variant coordinate and + /// holds exactly one cache. + /// + /// + internal sealed class TypedGlobalSlot : IEvictableSlot + { + /// + /// Type-erased handler cache for this slot's + /// (, ) + /// coordinate. Lazy alloc on first registration; nulled by + /// and . + /// + public IHandlerActionCache cache; + + /// Monotonic version counter for the slot's structural state. + public long version; + + /// + /// The value observed by the most recent + /// dispatcher snapshot. Forward-compat plumbing; not yet read by + /// the typed-handler hot path. + /// + public long lastSeenVersion = -1; + + /// + /// The bus emission id of the most recent dispatch that consumed + /// this slot. Forward-compat plumbing; not yet read by the + /// typed-handler hot path. + /// + public long lastSeenEmissionId; + + /// + /// Bus tick counter value at the most recent register / deregister / + /// emit that touched this slot. Will be maintained by P4's touch + /// hook; preserved across and + /// . + /// + public long lastTouchTicks; + + /// + /// Reserved live-handler counter intended to mirror the entry count + /// of at every stable observation point so + /// is a single integer compare rather than a + /// dispatch through the type-erased + /// property. The typed + /// handler maintains this counter on first-registration and + /// final-deregistration transitions so + /// reflects whether the slot still owns live global handlers. + /// + public int liveCount; + + /// + public long LastTouchTicks + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastTouchTicks; + } + + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => liveCount == 0; + } + + /// + public long Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// + /// Full-reset semantic. Nulls out and resets + /// the staged-dispatch counters. Resets to + /// 0; this is NOT monotonic and is intended only for the + /// typed-handler analog of MessageBus.ResetState() if and + /// when that code path is wired up. Use for + /// sweep-driven slot reclamation. + /// + public void Clear() + { + cache = null; + version = 0; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + } + + /// + /// Eviction-driven reset. Drains through + /// first, then nulls the + /// reference, then bumps as the LAST step so + /// any captured dispatch closure that observed the prior version + /// detects invalidation. + /// is intentionally preserved. + /// + /// + /// Drain order is load-bearing per PLAN Risk Register R3: the + /// inner cache's own monotonic version is bumped BEFORE the slot + /// drops the reference, so closures captured against the inner + /// cache also detect invalidation. The outer + /// bump is the LAST statement in the method for the same reason at + /// the slot level. + /// + public void Reset() + { + // Inline the structural-clear body of Clear(); do NOT call + // Clear() because that resets version=0 and would break the + // monotonic invariant the eviction layer depends on (PLAN Risk + // Register R3). + cache?.Reset(); + cache = null; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + unchecked + { + ++version; + } + } + } +} diff --git a/Runtime/Core/Internal/TypedSlots.cs.meta b/Runtime/Core/Internal/TypedSlots.cs.meta new file mode 100644 index 00000000..dea5008d --- /dev/null +++ b/Runtime/Core/Internal/TypedSlots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fbabf0ef529c40d6a613536b5a2e185e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index 1d660d64..2d72c289 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -63,6 +63,16 @@ static bool GlobalDiagnosticsMode long EmissionId { get; } + /// + /// Reclaim empty message slots and pooled collections owned by this bus. + /// + /// + /// When true, ignores idle-age thresholds and drains shared pools to zero. + /// When false, only slots past the configured idle threshold are eligible. + /// + /// Counts describing what was reclaimed. + TrimResult Trim(bool force = false); + /// /// Default buffer size for message emission history. /// @@ -119,6 +129,48 @@ internal static bool ShouldEnableDiagnostics() bool DiagnosticsMode { get; } int RegisteredGlobalSequentialIndex { get; } + + /// + /// Number of currently occupied per-message-type slots on this bus. + /// + int OccupiedTypeSlots { get; } + + /// + /// Number of currently occupied per-context target/source slots on this bus. + /// + int OccupiedTargetSlots { get; } + + /// + /// Result returned by . + /// + readonly struct TrimResult + { + public TrimResult( + int typeSlotsEvicted, + int targetSlotsEvicted, + int pooledCollectionsEvicted, + int liveTypeSlotsRemaining + ) + { + TypeSlotsEvicted = typeSlotsEvicted; + TargetSlotsEvicted = targetSlotsEvicted; + PooledCollectionsEvicted = pooledCollectionsEvicted; + LiveTypeSlotsRemaining = liveTypeSlotsRemaining; + } + + /// Number of typed-handler slots reset. + public int TypeSlotsEvicted { get; } + + /// Number of bus target/source context entries removed. + public int TargetSlotsEvicted { get; } + + /// Number of pooled collections dropped from shared pools. + public int PooledCollectionsEvicted { get; } + + /// Number of occupied type slots remaining after trim. + public int LiveTypeSlotsRemaining { get; } + } + int RegisteredBroadcast { get; } int RegisteredTargeted { get; } diff --git a/Runtime/Core/MessageBus/Internal.meta b/Runtime/Core/MessageBus/Internal.meta new file mode 100644 index 00000000..b3131517 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 7e00519606dec26c529ad6c7904e0467 +timeCreated: 1777840186 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/BusContextIndex.cs b/Runtime/Core/MessageBus/Internal/BusContextIndex.cs new file mode 100644 index 00000000..8b946943 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusContextIndex.cs @@ -0,0 +1,16 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// Const-int positions into MessageBus._contextSinks[]. Every + /// position is populated; there are no reserved slots. + /// + internal static class BusContextIndex + { + public const int TargetedHandleDefault = 0; + public const int BroadcastHandleDefault = 1; + public const int TargetedPostProcessDefault = 2; + public const int BroadcastPostProcessDefault = 3; + + public const int Length = 4; + } +} diff --git a/Runtime/Core/MessageBus/Internal/BusContextIndex.cs.meta b/Runtime/Core/MessageBus/Internal/BusContextIndex.cs.meta new file mode 100644 index 00000000..69e0e9c0 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusContextIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0cf95cdd0a324d99ab8acde42ea96193 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs b/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs new file mode 100644 index 00000000..98433a3e --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs @@ -0,0 +1,40 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// Const-int positions into MessageBus._scalarSinks[]. Indices are + /// hand-written so call sites inline as immediate operands. Array lengths, + /// populated-non-null slot identities, and reserved-null slot identities + /// are validated in DEBUG builds via MessageBus.ValidateSinkArrays(). + /// + /// + /// Slot 0 () holds the + /// RegisterUntargeted Handle-phase cache. + /// Slot 1 () holds the + /// RegisterSourcedBroadcastWithoutSource Handle-phase cache. + /// Slot 2 () holds the + /// RegisterTargetedWithoutTargeting Handle-phase cache. + /// Slot 3 () holds the + /// RegisterUntargetedPostProcessor PostProcess-phase cache. + /// Slot 4 () holds the + /// RegisterTargetedWithoutTargetingPostProcessor PostProcess-phase cache. + /// Slot 5 () holds the + /// RegisterBroadcastWithoutSourcePostProcessor PostProcess-phase cache. + /// Slots 6-7 (, ) are permanent + /// future-expansion stubs and remain null. + /// + internal static class BusSinkIndex + { + // "WithoutContext" unifies the legacy "WithoutTargeting" (Targeted) and + // "WithoutSource" (Broadcast) per-axis variants -- both lack an InstanceId. + public const int UntargetedHandleDefault = 0; + public const int BroadcastHandleWithoutContext = 1; + public const int TargetedHandleWithoutContext = 2; + public const int UntargetedPostProcessDefault = 3; + public const int TargetedPostProcessWithoutContext = 4; + public const int BroadcastPostProcessWithoutContext = 5; + public const int Reserved6 = 6; + public const int Reserved7 = 7; + + public const int Length = 8; + } +} diff --git a/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs.meta b/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs.meta new file mode 100644 index 00000000..4c3d7682 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusSinkIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91d17e8c1a5d49ff8a5eeb05f0bf45f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs b/Runtime/Core/MessageBus/Internal/BusSlots.cs new file mode 100644 index 00000000..00d458f0 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusSlots.cs @@ -0,0 +1,731 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + using System.Collections.Generic; + using System.Diagnostics; + using System.Runtime.CompilerServices; + using DxMessaging.Core; + using DxMessaging.Core.Pooling; + + /// + /// Per-priority leaf storage for a single dispatch slot. Mirrors the + /// per-priority bucket previously held inside the legacy nested + /// HandlerCache type previously declared in : + /// a handler set keyed by with insertion-order + /// tracking via the integer payload (priority slot index), plus a flat + /// cache list for snapshot-friendly iteration. + /// + /// + /// + /// is intentionally NOT an + /// : it is only ever owned by a + /// and is reclaimed transitively when its parent + /// is reset. Eviction never targets a bucket directly. + /// + /// + /// The / / + /// triple is the same staged-dispatch + /// snapshot mechanism used by the legacy HandlerCache -- structural + /// mutations bump , dispatchers compare against + /// to decide whether to re-snapshot. + /// + /// + internal sealed class BusPriorityBucket + { + /// + /// Live handlers in this priority bucket. Value is the priority slot + /// index used by the staged dispatch snapshot pattern (matches the + /// legacy HandlerCache.handlers layout). + /// + public readonly Dictionary handlers = new(); + + /// + /// Flat snapshot-friendly cache of keys; rebuilt + /// lazily by the dispatcher when changes. + /// + public readonly List cache = new(); + + /// Monotonic version counter for the bucket contents. + public long version; + + /// + /// The value observed by the most recent + /// dispatcher snapshot. Used to decide whether needs + /// to be re-materialized before the next dispatch. + /// + public long lastSeenVersion = -1; + + /// + /// The bus emission id of the most recent dispatch that consumed this + /// bucket. Used by the staged dispatch staleness check. + /// + public long lastSeenEmissionId; + + /// + /// Clear all bucket state. Mirrors the legacy + /// HandlerCache.Clear() body -- empties handlers and cache and + /// resets the dispatch-snapshot counters. Resets + /// to 0; this is the legacy "full reset" semantic and is NOT + /// monotonic. Eviction-driven reset semantics live on the parent + /// , which preserves monotonicity by + /// bumping the parent slot's version after clearing buckets. + /// + public void Clear() + { + handlers.Clear(); + cache.Clear(); + version = 0; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + } + } + + /// + /// Per-message-type, per-context dispatch slot. Replaces the inner + /// HandlerCache<int, HandlerCache> type previously declared in + /// . Holds the priority-keyed map of + /// s and the flat ordered-priority list used + /// by the staged dispatch snapshot pattern. + /// + /// + /// + /// In the new layout each belongs to either the + /// scalar slot grid (WithoutContext variants -- no + /// hash on the hot path) or the inner map of a + /// (variants that carry an + /// recipient or source). + /// + /// + /// The field carries the staged Stage/Acquire + /// snapshot for this slot. It is forward-compatible plumbing -- BusSinkSlot + /// is not yet the storage type wired into the hot dispatch path; that + /// wiring lands in a future P3 / storage-migration session. + /// + /// + internal sealed class BusSinkSlot : IEvictableSlot + { + /// + /// Per-priority handler buckets, keyed by priority value. + /// + public readonly Dictionary handlersByPriority = new(); + + /// + /// Insertion-ordered list of priority keys present in + /// . Mirrors the legacy + /// HandlerCache.order field. + /// + public readonly List orderedPriorities = new(); + + /// + /// Flat snapshot-friendly cache of + /// entries; rebuilt lazily by the dispatcher when + /// changes. Mirrors the legacy HandlerCache.cache field. + /// + public readonly List> cache = new(); + + /// Monotonic version counter for the slot's structural state. + public long version; + + /// + /// The value observed by the most recent + /// dispatcher snapshot. Used to decide whether + /// needs to be re-materialized before the next dispatch. + /// + public long lastSeenVersion = -1; + + /// + /// The bus emission id of the most recent dispatch that consumed this + /// slot. Used by the staged dispatch staleness check. + /// + public long lastSeenEmissionId; + + /// + /// Bus tick counter value at the most recent register / deregister / + /// emit that touched this slot. Maintained by P4's touch hook; + /// preserved across and so the + /// sweep can distinguish never-touched slots from freshly-reset slots. + /// + public long lastTouchTicks; + + /// + /// + /// Reserved live-handler counter intended to mirror the unique + /// (, priority) pair count across every + /// entry in , so + /// becomes a single integer compare rather than a walk over priority + /// buckets. The bus does not yet maintain this counter; the wiring + /// lands when becomes the storage type + /// backing the typed-sink hot dispatch path (the same deferred phase + /// described on ). + /// currently returns true at all times because no writer + /// increments ; eviction will only consult + /// once the bus-side counter wiring lands. + /// + /// + /// Intended transitions once wired: re-registration of an existing + /// pair will be a no-op on this counter; only newly-inserted pairs + /// will increment it, and only the final removal of a pair will + /// decrement it. + /// + /// + public int liveCount; + + /// + /// Per-slot dispatch state for the staged Stage/Acquire snapshot + /// pattern. Single field per slot -- the previous per-slot-key + /// dictionary keyed by dispatch slot is unnecessary once + /// each slot maps 1:1 to a . Lazy alloc on + /// first Stage/Acquire; null after Reset(). This field is + /// forward-compatible plumbing. Wiring lands when + /// becomes the storage type backing the + /// typed-sink hot dispatch path (replacing the current legacy + /// HandlerCache<int, HandlerCache> generic outer + + /// non-generic inner pair). The intermediate phases that retire + /// the legacy category enum and split the Handle-phase variants + /// do NOT touch this field; they continue working through the + /// legacy storage type's dispatchState. + /// + public MessageBus.DispatchState dispatchState; + + /// + public long LastTouchTicks + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastTouchTicks; + } + + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => liveCount == 0; + } + + /// + public long Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// + /// Full-reset semantic that mirrors the legacy + /// HandlerCache<TKey, TValue>.Clear() body. Clears all + /// priority buckets and the outer maps, and resets the + /// staged-dispatch snapshot counters. + /// Resets to 0; this is NOT monotonic and + /// is intended only for the bus-wide + /// MessageBus.ResetState() code path. Use + /// for sweep-driven slot reclamation. + /// + public void Clear() + { + foreach (BusPriorityBucket bucket in handlersByPriority.Values) + { + bucket.Clear(); + } + handlersByPriority.Clear(); + orderedPriorities.Clear(); + cache.Clear(); + dispatchState?.Reset(); + dispatchState = null; + version = 0; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + } + + /// + /// Eviction-driven reset. Clears all structural state without touching + /// , then bumps as the LAST + /// step so any captured dispatch closure that observed the prior + /// version detects invalidation. is + /// intentionally preserved so the sweep can distinguish freshly-reset + /// slots from never-touched ones. + /// + public void Reset() + { + // Inline the structural-clear body of Clear(); do NOT call Clear() + // because that resets version=0 and would break the monotonic + // invariant the eviction layer depends on (PLAN Risk Register R3: + // stale deregister closures captured before reset must observe a + // strictly larger version after reset and skip their work). + foreach (BusPriorityBucket bucket in handlersByPriority.Values) + { + bucket.Clear(); + } + handlersByPriority.Clear(); + orderedPriorities.Clear(); + cache.Clear(); + dispatchState?.Reset(); + dispatchState = null; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + unchecked + { + ++version; + } + } + } + + /// + /// Per-message-type context-bound dispatch slot. Owns the + /// -keyed map of inner s + /// for one message type's targeted or broadcast variants. Replaces the + /// outer per-message-type dictionaries previously held in the + /// targeted/broadcast sink fields on . + /// + /// + /// + /// The inner map is rented from + /// . The pool stores + /// Dictionary<InstanceId, object> -- generic-erased to share a + /// single pool across every message-type instantiation. Each value is a + /// , accessed via + /// ; the class is sealed and only inserted + /// from this type's own methods, so the cast cannot encounter a foreign + /// runtime type. DEBUG builds verify the invariant at every + /// cast site (PLAN Risk Register R4). + /// + /// + /// The map is left null until first registration so empty slots cost only + /// the field set itself. empties the map in place but + /// does NOT return it to the pool; returns the map to + /// the pool and nulls the field. + /// + /// + internal sealed class BusContextSlot : IEvictableSlot + { + /// + /// Inner per-context map. Null until first registration. Values are + /// instances stored as + /// so the underlying dictionary can be pooled in the shared + /// pool. + /// + public Dictionary byContext; + + /// Monotonic version counter for the slot's structural state. + public long version; + + /// + /// Bus tick counter value at the most recent register / deregister / + /// emit that touched this slot. Maintained by P4's touch hook; + /// preserved across and . + /// + public long lastTouchTicks; + + /// + /// + /// Reserved live-context counter intended to mirror the count of + /// keys in that + /// currently retain at least one live handler, so + /// becomes a single integer compare rather than a recursive walk over + /// the inner per-context slots. The bus does not yet maintain this + /// counter; the wiring lands when becomes + /// the storage type backing the typed-sink hot dispatch path (the same + /// deferred phase described on ). + /// currently returns true at all times + /// because no writer increments ; eviction will + /// only consult once the bus-side counter wiring + /// lands. + /// + /// + /// Intended transitions once wired: the bus will increment by 1 when + /// a context goes from zero handlers to one, and decrement by 1 when + /// a context drops back to zero handlers (and is removed via + /// ); registering or deregistering inside + /// an already-live context will not adjust this counter. + /// ( is the per-context handler + /// count; this is the per-slot context count.) + /// + /// + public int liveCount; + + /// + public long LastTouchTicks + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastTouchTicks; + } + + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => liveCount == 0; + } + + /// + public long Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// + /// Look up the inner for the supplied + /// context. Returns false when is null + /// or the context is not present. + /// + /// The context key. + /// + /// The inner slot when present; null otherwise. + /// + /// true when a slot was found. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetSlot(InstanceId context, out BusSinkSlot slot) + { + Dictionary map = byContext; + if (map == null) + { + slot = null; + return false; + } + if (!map.TryGetValue(context, out object boxed)) + { + slot = null; + return false; + } + DebugAssertSlot(boxed); + slot = Unsafe.As(boxed); + return true; + } + + /// + /// Look up or create the inner for the + /// supplied context. Lazily rents the inner map from + /// on first use. + /// + /// The context key. + /// + /// The existing or freshly-allocated for + /// . + /// + public BusSinkSlot GetOrAddSlot(InstanceId context) + { + Dictionary map = byContext; + if (map == null) + { + map = DxPools.InstanceIdDicts.Rent(); + byContext = map; + } + if (map.TryGetValue(context, out object boxed)) + { + DebugAssertSlot(boxed); + return Unsafe.As(boxed); + } + BusSinkSlot slot = new BusSinkSlot(); + map[context] = slot; + return slot; + } + + /// + /// Remove the inner slot for the supplied context, if present. Returns + /// true when an entry was removed. The removed inner slot is NOT + /// reset by this method -- callers wanting to reclaim it should call + /// on the returned reference before + /// dropping it. + /// + /// The context key. + /// + /// true when the context was present in . + /// + /// + /// The caller (the bus) is responsible for adjusting + /// after a successful removal. This method + /// intentionally does not touch so the bus can + /// decide the right semantic at the call site ( + /// keys vs handler sum -- see the field + /// docstring). + /// + public bool RemoveContext(InstanceId context) + { + Dictionary map = byContext; + if (map == null) + { + return false; + } + return map.Remove(context); + } + + /// + /// Full-reset semantic. Recursively clears every inner + /// in place via + /// (deeper than the legacy + /// HandlerCache<TKey, TValue>.Clear() body, which relied + /// on GC of dropped entries) and empties the outer map without + /// returning it to the pool. Resets to 0; + /// this is NOT + /// monotonic and is intended only for the bus-wide + /// MessageBus.ResetState() code path. Use + /// for sweep-driven slot reclamation. + /// + public void Clear() + { + Dictionary map = byContext; + if (map != null) + { + foreach (object boxed in map.Values) + { + if (boxed == null) + { + continue; + } + DebugAssertSlot(boxed); + Unsafe.As(boxed).Clear(); + } + map.Clear(); + } + version = 0; + liveCount = 0; + } + + /// + /// Eviction-driven reset. Walks every inner + /// and calls on each (PLAN Risk + /// Register R3: inner pooled state must be drained BEFORE the outer + /// map is recycled), then returns to the + /// shared pool and nulls the + /// field. Bumps as the LAST step so any captured + /// dispatch closure that observed the prior version detects + /// invalidation. is intentionally + /// preserved. + /// + public void Reset() + { + Dictionary map = byContext; + if (map != null) + { + foreach (object boxed in map.Values) + { + if (boxed == null) + { + continue; + } + DebugAssertSlot(boxed); + Unsafe.As(boxed).Reset(); + } + // Pool's onRecycled callback clears the dictionary before re-use. + DxPools.InstanceIdDicts.Return(map); + byContext = null; + } + liveCount = 0; + unchecked + { + ++version; + } + } + + [Conditional("DEBUG")] + private static void DebugAssertSlot(object boxed) + { + Debug.Assert( + boxed is BusSinkSlot, + "BusContextSlot.byContext must only contain BusSinkSlot values; " + + "Unsafe.As would otherwise produce undefined behavior." + ); + } + } + + /// + /// Per-bus global accept-all slot. Replaces the legacy non-generic + /// HandlerCache previously declared in -- + /// the slot that holds the "subscribe to every emit" handlers. + /// + /// + /// + /// PLAN.md section 2.5 Option G2 chooses to model the legacy + /// global-handlers triple-category share as one slot with a shared + /// handler set ( / ) + /// and three separate per-kind dispatch state fields + /// (, + /// , + /// ). The discrete fields keep the + /// per-emission slot select branch-free under JIT monomorphization, + /// avoiding the dictionary lookup the legacy non-generic + /// HandlerCache imposed. + /// + /// + internal sealed class BusGlobalSlot : IEvictableSlot + { + /// + /// Live global handlers, keyed by handler with insertion order tracked + /// via the integer payload. Mirrors the legacy non-generic + /// HandlerCache.handlers field. + /// + public readonly Dictionary sharedHandlers = new(); + + /// + /// Reserved for future global-slot snapshot iteration. Mirrors the + /// legacy non-generic HandlerCache.cache field, which was likewise + /// allocated for parity but never populated or read by any dispatch path. + /// Cleared by and so adding a writer + /// in a later phase requires no extra lifecycle plumbing. + /// + public readonly List sharedCache = new(); + + /// Monotonic version counter for the slot's structural state. + public long version; + + /// + /// Reserved counter intended to record the value + /// observed by the most recent dispatcher snapshot. The global path + /// does not yet read this field; it is allocated for parity with the + /// per-cache contract so the + /// staged-dispatch staleness check can adopt it without an additional + /// lifecycle change. + /// + public long lastSeenVersion = -1; + + /// + /// Reserved counter intended to record the bus emission id of the + /// most recent dispatch that consumed this slot. The global path + /// does not yet read this field; it is allocated for parity with the + /// per-cache contract. + /// + public long lastSeenEmissionId; + + /// + /// Bus tick counter value at the most recent register / deregister / + /// emit that touched this slot. Maintained by P4's touch hook; + /// preserved across and . + /// + public long lastTouchTicks; + + /// + /// + /// Live-handler counter that mirrors sharedHandlers.Count at + /// every stable observation point. Maintained by the bus at the + /// register / deregister sites for RegisterGlobalAcceptAll so + /// is a single integer compare rather than a + /// dictionary-count read. + /// + /// + /// The invariant is liveCount == sharedHandlers.Count: only the + /// per-handler refcount's 0 -> 1 transition (newly-inserted + /// handler) increments , and only the + /// 1 -> 0 transition (final removal of a handler) decrements + /// it. Re-registering an already-present handler (refcount + /// n -> n+1 for n >= 1) leaves the counter alone, + /// matching the dictionary's behaviour. Over-deregistration is a + /// no-op for both fields. DEBUG builds verify the invariant + /// after every register / deregister via + /// MessageBus.DebugAssertGlobalLiveCount and + /// . + /// + /// + public int liveCount; + + /// + /// Dispatch state for the Untargeted-global emission path. One of the + /// three discrete per-kind fields per Option G2 of PLAN section 2.5 -- + /// separate slots over a per-kind dictionary keep the per-emission + /// select branch-free under JIT monomorphization. Lazy alloc on first + /// Stage/Acquire; null after Reset(). + /// + public MessageBus.DispatchState untargetedDispatchState; + + /// + /// Dispatch state for the Targeted-global emission path. Sibling of + /// ; same lifetime semantics. + /// + public MessageBus.DispatchState targetedDispatchState; + + /// + /// Dispatch state for the Broadcast-global emission path. Sibling of + /// ; same lifetime semantics. + /// + public MessageBus.DispatchState broadcastDispatchState; + + /// + public long LastTouchTicks + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastTouchTicks; + } + + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => liveCount == 0; + } + + /// + public long Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// + /// Full-reset semantic that mirrors the legacy non-generic + /// HandlerCache.Clear() body. Clears + /// and + /// and resets the dispatch-snapshot counters. Resets + /// to 0; this is NOT monotonic and is + /// intended only for the bus-wide MessageBus.ResetState() code + /// path. Use for sweep-driven slot reclamation. + /// + public void Clear() + { + sharedHandlers.Clear(); + sharedCache.Clear(); + untargetedDispatchState?.Reset(); + untargetedDispatchState = null; + targetedDispatchState?.Reset(); + targetedDispatchState = null; + broadcastDispatchState?.Reset(); + broadcastDispatchState = null; + version = 0; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + } + + /// + /// Eviction-driven reset. Clears all structural state without touching + /// , then bumps as the LAST + /// step so any captured dispatch closure that observed the prior + /// version detects invalidation. is + /// intentionally preserved. + /// + public void Reset() + { + // Inline the structural-clear body of Clear(); do NOT call Clear() + // because that resets version=0 and would break the monotonic + // invariant the eviction layer depends on (PLAN Risk Register R3). + sharedHandlers.Clear(); + sharedCache.Clear(); + untargetedDispatchState?.Reset(); + untargetedDispatchState = null; + targetedDispatchState?.Reset(); + targetedDispatchState = null; + broadcastDispatchState?.Reset(); + broadcastDispatchState = null; + lastSeenVersion = -1; + lastSeenEmissionId = 0; + liveCount = 0; + unchecked + { + ++version; + } + } + + /// + /// Defensive DEBUG-only assertion that + /// equals sharedHandlers.Count. Provided so contract tests can + /// pin the invariant without exposing private bus state. Stripped in + /// Release builds via . + /// + [Conditional("DEBUG")] + internal void DebugAssertLiveCountInvariant() + { + Debug.Assert( + liveCount == sharedHandlers.Count, + "BusGlobalSlot.liveCount must mirror sharedHandlers.Count at every " + + "stable observation point." + ); + } + } +} diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs.meta b/Runtime/Core/MessageBus/Internal/BusSlots.cs.meta new file mode 100644 index 00000000..baae2638 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/BusSlots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a6e7732d5e265d107e748fd469684ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/DispatchKind.cs b/Runtime/Core/MessageBus/Internal/DispatchKind.cs new file mode 100644 index 00000000..dc473406 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchKind.cs @@ -0,0 +1,38 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// First axis of the dispatch slot key. Identifies the structural shape of a + /// registration: untargeted scalar, targeted (per-recipient), broadcast + /// (per-source), or global (catch-all that subscribes to every emit of a + /// registered base shape). + /// + /// + /// Encoded into in the high nibble (bits 4-7). + /// Packed value range is 0..3, leaving room for two additional kinds before + /// the nibble overflows. + /// + internal enum DispatchKind : byte + { + /// + /// Untargeted dispatch. No recipient or source + /// carried; the message itself is the entire payload. + /// + Untargeted = 0, + + /// Targeted dispatch. Message carries an recipient. + Targeted = 1, + + /// Broadcast dispatch. Message carries an source. + Broadcast = 2, + + /// + /// Reserved for the global-dispatch axis. Currently unreferenced -- the + /// global dispatch path uses directly with the + /// per-message-shape (, + /// , or ) rather than this + /// value. Kept to lock the bit-packing contract documented on + /// . + /// + Global = 3, + } +} diff --git a/Runtime/Core/MessageBus/Internal/DispatchKind.cs.meta b/Runtime/Core/MessageBus/Internal/DispatchKind.cs.meta new file mode 100644 index 00000000..f15244ad --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchKind.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12bef51a14bab4e9ae9346090dc6811b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/DispatchPhase.cs b/Runtime/Core/MessageBus/Internal/DispatchPhase.cs new file mode 100644 index 00000000..11f44b5c --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchPhase.cs @@ -0,0 +1,20 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// Second axis of the dispatch slot key. Distinguishes the two phases of + /// message processing: the primary handle phase and the post-process phase + /// that runs after every primary handler has returned. + /// + /// + /// Encoded into at bit 3. Packed value range is + /// 0..1. + /// + internal enum DispatchPhase : byte + { + /// Primary handler phase. + Handle = 0, + + /// Post-processor phase. Runs after the handle phase completes. + PostProcess = 1, + } +} diff --git a/Runtime/Core/MessageBus/Internal/DispatchPhase.cs.meta b/Runtime/Core/MessageBus/Internal/DispatchPhase.cs.meta new file mode 100644 index 00000000..3c4cce86 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchPhase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08f0e660e197a432ea316eb28bf2ef68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/DispatchVariant.cs b/Runtime/Core/MessageBus/Internal/DispatchVariant.cs new file mode 100644 index 00000000..37e3aa2a --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchVariant.cs @@ -0,0 +1,28 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// Third axis of the dispatch slot key. Captures handler-shape variants that + /// share a but differ in delegate signature or + /// fast-path eligibility. + /// + /// + /// Encoded into in the low three bits (bits + /// 0-2). Packed value range is 0..3 in current usage; the three-bit slot + /// leaves room for four additional variants without disturbing the packed + /// layout. + /// + internal enum DispatchVariant : byte + { + /// Default handler shape (full delegate signature with context). + Default = 0, + + /// Fast-path handler shape (specialized signature). + Fast = 1, + + /// Handler shape that elides the context argument. + WithoutContext = 2, + + /// Fast-path handler shape that elides the context argument. + WithoutContextFast = 3, + } +} diff --git a/Runtime/Core/MessageBus/Internal/DispatchVariant.cs.meta b/Runtime/Core/MessageBus/Internal/DispatchVariant.cs.meta new file mode 100644 index 00000000..1aad977f --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/DispatchVariant.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45b82de5651aee6cdbd5279bf3fb423d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs new file mode 100644 index 00000000..77007998 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs @@ -0,0 +1,49 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + /// + /// Marker interface for bus-level slot containers that the eviction layer + /// (PLAN Phase P4) can sweep. Each slot tracks its last-touch tick so the + /// sweep can decide whether to reclaim it, and exposes a monotonic + /// so that staged dispatch closures captured before + /// eviction can detect they have been invalidated. + /// + /// + /// In P2.1 these contracts are declared but not yet swept -- P2.5 wires + /// to return inner pooled collections to + /// DxMessaging.Core.Pooling.DxPools, and P4 implements the sweep + /// policy that calls on idle empty slots. + /// + internal interface IEvictableSlot + { + /// + /// The bus tick counter value at the most recent register / deregister / + /// emit operation that touched this slot. Used by the sweep to decide + /// whether the slot has been idle for long enough to evict. + /// + long LastTouchTicks { get; } + + /// + /// True iff the slot currently retains zero live registrations. Cheap + /// (single integer compare); maintained at register / deregister sites. + /// Stale-but-non-empty slots are NOT eviction candidates -- only + /// idle AND empty slots are reclaimed. + /// + bool IsEmpty { get; } + + /// + /// Strictly monotonic version counter. Bumped by + /// (and by registration-time mutations on the slot). Allows captured + /// dispatch closures to detect post-eviction invalidation. + /// + long Version { get; } + + /// + /// Reclaim this slot: clear inner state, return any pooled inner + /// collections to DxPools, and bump . + /// Idempotent. is intentionally preserved + /// across so the sweep can distinguish freshly-reset + /// slots from never-touched ones. + /// + void Reset(); + } +} diff --git a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta new file mode 100644 index 00000000..b76c6908 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0e14e71e59310d9473809487e65bd0f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/ISweepable.cs b/Runtime/Core/MessageBus/Internal/ISweepable.cs new file mode 100644 index 00000000..a680ec78 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/ISweepable.cs @@ -0,0 +1,15 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + using System; + + /// + /// Describes a cache storage field that + /// participates in explicit or idle sweep coverage. + /// + internal interface ISweepable + { + string StorageFieldName { get; } + Type StorageFieldType { get; } + int Sweep(DxMessaging.Core.MessageBus.MessageBus bus, bool force); + } +} diff --git a/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta b/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta new file mode 100644 index 00000000..0ebc0628 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d1e4e9a7e52243f7b7d42a1ef4f54152 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs b/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs new file mode 100644 index 00000000..5d0df5a8 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs @@ -0,0 +1,222 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + using System; + using System.Runtime.CompilerServices; + + /// + /// Static lookup that translates a enum + /// value into the coordinate that identifies the + /// dispatch slot it targets. Replaces the legacy 14-case category switch + /// with a data-driven table. + /// + /// + /// + /// Two registration methods -- + /// and + /// -- map to + /// . Interceptor caches are deliberately out of + /// scope for the slot grid (their machinery is separate); global accept-all + /// is structurally a multi-slot registration handled by dedicated bus + /// state. + /// + /// + /// The table is validated once at type-init: every value of + /// must have an entry, otherwise an + /// is thrown. This guarantees that + /// adding a new registration method without wiring its axis is a + /// load-time failure, not a silent dispatch hole. + /// + /// + internal static class RegistrationMethodAxes + { + private static readonly SlotKey[] Table = BuildAndValidateTable(); + + /// + /// Returns the for the supplied registration + /// method. Returns when the method is + /// out-of-range or maps to the multi-slot / out-of-scope sentinel. + /// + /// The registration method to translate. + /// + /// The mapped , or when + /// the method has no single-slot mapping. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SlotKey GetSlotKey(RegistrationMethod method) + { + uint index = (uint)(int)method; + if (index >= (uint)Table.Length) + { + return SlotKey.None; + } + return Table[index]; + } + + private static SlotKey[] BuildAndValidateTable() + { + Array values = Enum.GetValues(typeof(RegistrationMethod)); + int max = -1; + for (int i = 0; i < values.Length; i++) + { + int raw = (int)values.GetValue(i); + if (raw < 0) + { + throw new InvalidOperationException( + "RegistrationMethod enum contains a negative value: " + + values.GetValue(i) + + ". RegistrationMethodAxes assumes non-negative ordinals." + ); + } + if (raw > max) + { + max = raw; + } + } + + SlotKey[] table = new SlotKey[max + 1]; + for (int i = 0; i <= max; i++) + { + table[i] = SlotKey.None; + } + bool[] assigned = new bool[max + 1]; + + void Assign(RegistrationMethod method, SlotKey key) + { + int idx = (int)method; + if (assigned[idx]) + { + throw new InvalidOperationException( + "RegistrationMethodAxes table assigned twice for " + + method + + ". Mapping must be unique." + ); + } + table[idx] = key; + assigned[idx] = true; + } + + // Handle phase, default variant. + Assign( + RegistrationMethod.Targeted, + new SlotKey(DispatchKind.Targeted, DispatchPhase.Handle, DispatchVariant.Default) + ); + Assign( + RegistrationMethod.Untargeted, + new SlotKey(DispatchKind.Untargeted, DispatchPhase.Handle, DispatchVariant.Default) + ); + Assign( + RegistrationMethod.Broadcast, + new SlotKey(DispatchKind.Broadcast, DispatchPhase.Handle, DispatchVariant.Default) + ); + + // Handle phase, without-context variant. + Assign( + RegistrationMethod.BroadcastWithoutSource, + new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.Handle, + DispatchVariant.WithoutContext + ) + ); + Assign( + RegistrationMethod.TargetedWithoutTargeting, + new SlotKey( + DispatchKind.Targeted, + DispatchPhase.Handle, + DispatchVariant.WithoutContext + ) + ); + + // Sentinel mappings: multi-slot / out-of-scope. + Assign(RegistrationMethod.GlobalAcceptAll, SlotKey.None); + Assign(RegistrationMethod.Interceptor, SlotKey.None); + + // Post-process phase, default variant. + Assign( + RegistrationMethod.UntargetedPostProcessor, + new SlotKey( + DispatchKind.Untargeted, + DispatchPhase.PostProcess, + DispatchVariant.Default + ) + ); + Assign( + RegistrationMethod.TargetedPostProcessor, + new SlotKey( + DispatchKind.Targeted, + DispatchPhase.PostProcess, + DispatchVariant.Default + ) + ); + Assign( + RegistrationMethod.BroadcastPostProcessor, + new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.PostProcess, + DispatchVariant.Default + ) + ); + + // Post-process phase, without-context variant. + Assign( + RegistrationMethod.TargetedWithoutTargetingPostProcessor, + new SlotKey( + DispatchKind.Targeted, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ) + ); + Assign( + RegistrationMethod.BroadcastWithoutSourcePostProcessor, + new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ) + ); + + // Validate that every enum value received an explicit mapping. + for (int i = 0; i < values.Length; i++) + { + int raw = (int)values.GetValue(i); + if (!assigned[raw]) + { + throw new InvalidOperationException( + "RegistrationMethodAxes is missing a mapping for " + + (RegistrationMethod)raw + + " (ordinal " + + raw + + "). Every RegistrationMethod must map to a SlotKey or SlotKey.None." + ); + } + } + + // Tighten validation: walk every ordinal in [0..max]. Any unassigned + // index that does NOT correspond to a defined enum value is a gap + // (e.g. left behind by an [Obsolete]-removed member or sparse enum + // numbering) and would otherwise silently route into the Untargeted + // slot via default(SlotKey). SlotKey.None is the only safe sentinel + // for unmapped ordinals; gap ordinals must fail at type-init. + bool[] defined = new bool[max + 1]; + for (int i = 0; i < values.Length; i++) + { + defined[(int)values.GetValue(i)] = true; + } + for (int i = 0; i <= max; i++) + { + if (!assigned[i] && !defined[i]) + { + throw new InvalidOperationException( + "RegistrationMethodAxes ordinal " + + i + + " between defined values is unassigned. " + + "Sparse RegistrationMethod ordinals are not supported; " + + "every ordinal in [0..max] must map to a SlotKey or SlotKey.None." + ); + } + } + + return table; + } + } +} diff --git a/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs.meta b/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs.meta new file mode 100644 index 00000000..8206d946 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/RegistrationMethodAxes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1688b433c2e4b5cc0410a6748b51b793 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/Internal/SlotKey.cs b/Runtime/Core/MessageBus/Internal/SlotKey.cs new file mode 100644 index 00000000..94f86ff5 --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/SlotKey.cs @@ -0,0 +1,192 @@ +namespace DxMessaging.Core.MessageBus.Internal +{ + using System; + using System.Runtime.CompilerServices; + + /// + /// Bit-packed coordinate that identifies a single dispatch slot along three + /// axes: , , and + /// . The byte-sized layout lets the bus index + /// into a small fixed-size array of slots without per-axis branching on the + /// hot dispatch path. + /// + /// + /// + /// Bit layout of : KKKK P VVV where K is the + /// 4-bit , P is the 1-bit + /// , and V is the 3-bit + /// . The exact formula is + /// packed = (kind << 4) | (phase << 3) | variant. + /// + /// + /// The sentinel value uses packed = 0xFF and + /// represents "no axis applies" -- it is returned for registration methods + /// (such as + /// and ) + /// whose dispatch is multi-slot or otherwise outside the axis grid. + /// + /// + /// default(SlotKey) is bit-identical to + /// new SlotKey(DispatchKind.Untargeted, DispatchPhase.Handle, DispatchVariant.Default). + /// This is intentional -- uninitialized fields decode + /// to a real, valid slot. Use (packed = 0xFF) as the + /// sentinel for "no slot applies"; never use default(SlotKey) as a + /// sentinel. + /// + /// + internal readonly struct SlotKey : IEquatable + { + private const int KindShift = 4; + private const int PhaseShift = 3; + private const byte KindMask = 0x0F; + private const byte PhaseMask = 0x01; + private const byte VariantMask = 0x07; + + private const byte NonePacked = 0xFF; + + /// + /// Sentinel slot key that represents "no axis applies". Distinct from + /// default(SlotKey); equality with default is false. + /// + /// + /// 0xFF is unreachable from the public ctor by construction; the ctor explicitly rejects the (15, 1, 7) triple. + /// + public static readonly SlotKey None = new SlotKey(unpacked: NonePacked); + + /// + /// The packed byte representation. Layout is + /// (kind << 4) | (phase << 3) | variant; the sentinel + /// uses 0xFF. + /// + public readonly byte Packed; + + /// + /// Constructs a slot key from its three component axes. + /// + /// Dispatch kind. Must fit in 4 bits (0..15). + /// Dispatch phase. Must fit in 1 bit (0..1). + /// Dispatch variant. Must fit in 3 bits (0..7). + /// + /// Thrown when any axis exceeds the bits allotted for it. + /// + /// + /// Thrown when the (kind, phase, variant) triple packs to the + /// reserved sentinel bit pattern (0xFF), + /// i.e. (15, 1, 7). + /// + public SlotKey(DispatchKind kind, DispatchPhase phase, DispatchVariant variant) + { + byte k = (byte)kind; + byte p = (byte)phase; + byte v = (byte)variant; + if (k > KindMask) + { + throw new ArgumentOutOfRangeException( + nameof(kind), + k, + "DispatchKind must fit in 4 bits (0..15)." + ); + } + if (p > PhaseMask) + { + throw new ArgumentOutOfRangeException( + nameof(phase), + p, + "DispatchPhase must fit in 1 bit (0..1)." + ); + } + if (v > VariantMask) + { + throw new ArgumentOutOfRangeException( + nameof(variant), + v, + "DispatchVariant must fit in 3 bits (0..7)." + ); + } + byte packed = (byte)((k << KindShift) | (p << PhaseShift) | v); + if (packed == NonePacked) + { + throw new ArgumentException( + "(kind, phase, variant) triple packs to the SlotKey.None sentinel. " + + "The bit pattern 0xFF is reserved for SlotKey.None and cannot be " + + "constructed via the public ctor, i.e. (kind=15, phase=1, variant=7).", + nameof(variant) + ); + } + Packed = packed; + } + + private SlotKey(byte unpacked) + { + Packed = unpacked; + } + + /// The decoded axis. + public DispatchKind Kind + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (DispatchKind)((Packed >> KindShift) & KindMask); + } + + /// The decoded axis. + public DispatchPhase Phase + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (DispatchPhase)((Packed >> PhaseShift) & PhaseMask); + } + + /// The decoded axis. + public DispatchVariant Variant + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (DispatchVariant)(Packed & VariantMask); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(SlotKey other) + { + return Packed == other.Packed; + } + + /// + public override bool Equals(object obj) + { + return obj is SlotKey other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Packed; + } + + /// Equality operator. Compares the packed byte value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(SlotKey left, SlotKey right) + { + return left.Packed == right.Packed; + } + + /// Inequality operator. Compares the packed byte value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(SlotKey left, SlotKey right) + { + return left.Packed != right.Packed; + } + + /// + /// Returns "None" for the sentinel; otherwise + /// returns a slash-delimited triple of the form + /// "{Kind}/{Phase}/{Variant}". + /// + public override string ToString() + { + if (Packed == NonePacked) + { + return "None"; + } + return Kind + "/" + Phase + "/" + Variant; + } + } +} diff --git a/Runtime/Core/MessageBus/Internal/SlotKey.cs.meta b/Runtime/Core/MessageBus/Internal/SlotKey.cs.meta new file mode 100644 index 00000000..d1c4c0ea --- /dev/null +++ b/Runtime/Core/MessageBus/Internal/SlotKey.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4aaa36a723296eeb3849d434a3954d5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 3027dbd2..738acc30 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -3,6 +3,7 @@ namespace DxMessaging.Core.MessageBus using System; using System.Buffers; using System.Collections.Generic; + using System.Diagnostics; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; @@ -11,9 +12,12 @@ namespace DxMessaging.Core.MessageBus using DxMessaging.Core; using Extensions; using Helper; + using Internal; using Messages; + using Pooling; using static IMessageBus; #if UNITY_2021_3_OR_NEWER + using Configuration; using UnityEngine; #endif @@ -24,8 +28,9 @@ public sealed class MessageBus : IMessageBus { private long _emissionId; public long EmissionId => _emissionId; + internal long TickCounter => _tickCounter; - private readonly struct PrefreezeDescriptor + internal readonly struct PrefreezeDescriptor { public PrefreezeDescriptor(byte kind, int priority) { @@ -38,37 +43,97 @@ public PrefreezeDescriptor(byte kind, int priority) public readonly int priority; } - private enum DispatchCategory : byte - { - None = 0, - Untargeted = 1, - UntargetedPost = 2, - Targeted = 3, - TargetedPost = 4, - TargetedWithoutTargeting = 5, - TargetedWithoutTargetingPost = 6, - Broadcast = 7, - BroadcastPost = 8, - BroadcastWithoutSource = 9, - BroadcastWithoutSourcePost = 10, - GlobalUntargeted = 11, - GlobalTargeted = 12, - GlobalBroadcast = 13, - } - private const byte PrefreezeKindNone = 0; private const byte PrefreezeKindTargetedWithoutTargetingHandlers = 1; private const byte PrefreezeKindBroadcastWithoutSourceHandlers = 2; private const byte PrefreezeKindGlobalUntargetedHandlers = 3; private const byte PrefreezeKindGlobalTargetedHandlers = 4; private const byte PrefreezeKindGlobalBroadcastHandlers = 5; + private const long DefaultIdleEvictionTicks = 30; + private const double DefaultEvictionTickIntervalSeconds = 5d; + + private static readonly SlotKey UntargetedHandleSlot = new SlotKey( + DispatchKind.Untargeted, + DispatchPhase.Handle, + DispatchVariant.Default + ); + private static readonly SlotKey UntargetedPostSlot = new SlotKey( + DispatchKind.Untargeted, + DispatchPhase.PostProcess, + DispatchVariant.Default + ); + private static readonly SlotKey TargetedHandleSlot = new SlotKey( + DispatchKind.Targeted, + DispatchPhase.Handle, + DispatchVariant.Default + ); + private static readonly SlotKey TargetedWithoutContextHandleSlot = new SlotKey( + DispatchKind.Targeted, + DispatchPhase.Handle, + DispatchVariant.WithoutContext + ); + private static readonly SlotKey TargetedPostSlot = new SlotKey( + DispatchKind.Targeted, + DispatchPhase.PostProcess, + DispatchVariant.Default + ); + private static readonly SlotKey TargetedWithoutContextPostSlot = new SlotKey( + DispatchKind.Targeted, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ); + private static readonly SlotKey BroadcastPostSlot = new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.PostProcess, + DispatchVariant.Default + ); + private static readonly SlotKey BroadcastWithoutContextPostSlot = new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ); + internal const int ExpectedMessageCacheFieldCount = 5; + + private static readonly ISweepable[] SweepableTypeCacheRegistry = + { + new SweepableTypeCache( + nameof(_scalarSinks), + typeof(MessageCache>[]), + static (bus, force) => bus.SweepDirtyScalarTypeSlots(force) + ), + new SweepableTypeCache( + nameof(_contextSinks), + typeof(MessageCache>>[]), + static (bus, force) => bus.SweepDirtyTargetSlots(force) + ), + new SweepableTypeCache( + nameof(_untargetedInterceptsByType), + typeof(MessageCache>), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._untargetedInterceptsByType, force) + ), + new SweepableTypeCache( + nameof(_targetedInterceptsByType), + typeof(MessageCache>), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._targetedInterceptsByType, force) + ), + new SweepableTypeCache( + nameof(_broadcastInterceptsByType), + typeof(MessageCache>), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._broadcastInterceptsByType, force) + ), + }; + + internal static IReadOnlyList SweepableTypeCaches => SweepableTypeCacheRegistry; private static readonly ArrayPool DispatchBucketPool = ArrayPool.Shared; private static readonly ArrayPool DispatchEntryPool = ArrayPool.Shared; - private readonly struct DispatchEntry + internal readonly struct DispatchEntry { public DispatchEntry( MessageHandler handler, @@ -86,7 +151,7 @@ PrefreezeDescriptor prefreeze public readonly PrefreezeDescriptor prefreeze; } - private struct DispatchBucket + internal struct DispatchBucket { public DispatchBucket( int priority, @@ -127,7 +192,7 @@ public void ReleaseEntries() } } - private sealed class DispatchSnapshot + internal sealed class DispatchSnapshot { public static readonly DispatchSnapshot Empty = new DispatchSnapshot( Array.Empty(), @@ -169,34 +234,35 @@ public void Release() } } - private sealed class HandlerCache + internal sealed class DispatchState { - internal sealed class DispatchState - { - public DispatchSnapshot active = DispatchSnapshot.Empty; - public DispatchSnapshot pending = DispatchSnapshot.Empty; - public bool hasPending; - public bool pendingDirty; - public long snapshotEmissionId = -1; + public DispatchSnapshot active = DispatchSnapshot.Empty; + public DispatchSnapshot pending = DispatchSnapshot.Empty; + public bool hasPending; + public bool pendingDirty; + public long snapshotEmissionId = -1; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Reset() - { - ReleaseSnapshot(ref active); - ReleaseSnapshot(ref pending); - hasPending = false; - pendingDirty = false; - snapshotEmissionId = -1; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + ReleaseSnapshot(ref active); + ReleaseSnapshot(ref pending); + hasPending = false; + pendingDirty = false; + snapshotEmissionId = -1; } + } + private sealed class HandlerCache + { public readonly Dictionary handlers = new(); public readonly List order = new(); public readonly List> cache = new(); public long version; public long lastSeenVersion = -1; public long lastSeenEmissionId; - private readonly Dictionary _dispatchStates = new(); + public long lastTouchTicks; + public DispatchState dispatchState; /// /// Clears all cached handler references and resets the version tracking metadata. @@ -209,26 +275,8 @@ public void Clear() version = 0; lastSeenVersion = -1; lastSeenEmissionId = 0; - if (_dispatchStates.Count > 0) - { - foreach (DispatchState state in _dispatchStates.Values) - { - state.Reset(); - } - _dispatchStates.Clear(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DispatchState GetOrCreateDispatchState(DispatchCategory category) - { - if (!_dispatchStates.TryGetValue(category, out DispatchState state)) - { - state = new DispatchState(); - _dispatchStates[category] = state; - } - - return state; + dispatchState?.Reset(); + dispatchState = null; } } @@ -236,41 +284,68 @@ private sealed class InterceptorCache { public readonly SortedList> handlers = new(); public long lastSeenEmissionId; + public long lastTouchTicks; public void Clear() { handlers.Clear(); lastSeenEmissionId = 0; + lastTouchTicks = 0; } } - private sealed class HandlerCache + private sealed class SweepableTypeCache : ISweepable { - internal sealed class DispatchState + private readonly Func _sweep; + + public SweepableTypeCache( + string storageFieldName, + Type storageFieldType, + Func sweep + ) { - public DispatchSnapshot active = DispatchSnapshot.Empty; - public DispatchSnapshot pending = DispatchSnapshot.Empty; - public bool hasPending; - public bool pendingDirty; - public long snapshotEmissionId = -1; + StorageFieldName = storageFieldName; + StorageFieldType = storageFieldType; + _sweep = sweep; + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Reset() + public string StorageFieldName { get; } + public Type StorageFieldType { get; } + + public int Sweep(MessageBus bus, bool force) + { + if (bus == null) { - ReleaseSnapshot(ref active); - ReleaseSnapshot(ref pending); - hasPending = false; - pendingDirty = false; - snapshotEmissionId = -1; + throw new ArgumentNullException(nameof(bus)); } + + return _sweep(bus, force); + } + } + + private readonly struct DispatchLease : IDisposable + { + private readonly MessageBus _bus; + + public DispatchLease(MessageBus bus) + { + _bus = bus; + _bus._dispatchDepth++; + } + + public void Dispose() + { + _bus._dispatchDepth--; } + } + private sealed class HandlerCache + { public readonly Dictionary handlers = new(); public readonly List cache = new(); public long version; public long lastSeenVersion = -1; public long lastSeenEmissionId; - private readonly Dictionary _dispatchStates = new(); /// /// Clears all cached handler references and resets the version tracking metadata. @@ -282,57 +357,110 @@ public void Clear() version = 0; lastSeenVersion = -1; lastSeenEmissionId = 0; - if (_dispatchStates.Count > 0) + } + } + + public int RegisteredTargeted + { + get + { + int count = 0; + count += SumTargetedSinks(_contextSinks[BusContextIndex.TargetedHandleDefault]); + foreach ( + HandlerCache entry in _scalarSinks[ + BusSinkIndex.TargetedHandleWithoutContext + ] + ) { - foreach (DispatchState state in _dispatchStates.Values) - { - state.Reset(); - } - _dispatchStates.Clear(); + count += entry?.handlers?.Count ?? 0; } + + return count; } + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DispatchState GetOrCreateDispatchState(DispatchCategory category) + public int RegisteredGlobalSequentialIndex { get; } = GenerateNewGlobalSequentialIndex(); + + public int OccupiedTypeSlots + { + get { - if (!_dispatchStates.TryGetValue(category, out DispatchState state)) + int count = 0; + for (int i = 0; i < _scalarSinks.Length; ++i) + { + MessageCache> sink = _scalarSinks[i]; + if (sink == null) + { + continue; + } + + foreach (HandlerCache _ in sink) + { + count++; + } + } + + for (int i = 0; i < _contextSinks.Length; ++i) { - state = new DispatchState(); - _dispatchStates[category] = state; + foreach ( + Dictionary> _ in _contextSinks[ + i + ] + ) + { + count++; + } } - return state; + return count + OccupiedInterceptorTypeSlots + CountDirtyEmptyTypedHandlerSlots(); } } - public int RegisteredTargeted + private int OccupiedInterceptorTypeSlots + { + get + { + return CountOccupiedInterceptorTypeSlots(_untargetedInterceptsByType) + + CountOccupiedInterceptorTypeSlots(_targetedInterceptsByType) + + CountOccupiedInterceptorTypeSlots(_broadcastInterceptsByType); + } + } + + public int OccupiedTargetSlots { get { int count = 0; - foreach ( - Dictionary> entry in _targetedSinks - ) + for (int i = 0; i < _contextSinks.Length; ++i) { - count += entry?.Count ?? 0; + foreach ( + Dictionary< + InstanceId, + HandlerCache + > byTarget in _contextSinks[i] + ) + { + count += byTarget?.Count ?? 0; + } } return count; } } - public int RegisteredGlobalSequentialIndex { get; } = GenerateNewGlobalSequentialIndex(); - public int RegisteredBroadcast { get { int count = 0; + count += SumTargetedSinks(_contextSinks[BusContextIndex.BroadcastHandleDefault]); foreach ( - Dictionary> entry in _broadcastSinks + HandlerCache entry in _scalarSinks[ + BusSinkIndex.BroadcastHandleWithoutContext + ] ) { - count += entry?.Count ?? 0; + count += entry?.handlers?.Count ?? 0; } return count; @@ -344,7 +472,11 @@ public int RegisteredUntargeted get { int count = 0; - foreach (HandlerCache entry in _sinks) + foreach ( + HandlerCache entry in _scalarSinks[ + BusSinkIndex.UntargetedHandleDefault + ] + ) { count += entry?.handlers?.Count ?? 0; } @@ -370,26 +502,32 @@ public int RegisteredPostProcessors get { int count = 0; - foreach (HandlerCache entry in _postProcessingSinks) + foreach ( + HandlerCache entry in _scalarSinks[ + BusSinkIndex.UntargetedPostProcessDefault + ] + ) { count += entry?.handlers?.Count ?? 0; } - count += SumTargetedSinks(_postProcessingTargetedSinks); - count += SumTargetedSinks(_postProcessingBroadcastSinks); + count += SumTargetedSinks( + _contextSinks[BusContextIndex.TargetedPostProcessDefault] + ); + count += SumTargetedSinks( + _contextSinks[BusContextIndex.BroadcastPostProcessDefault] + ); foreach ( - HandlerCache< - int, - HandlerCache - > entry in _postProcessingTargetedWithoutTargetingSinks + HandlerCache entry in _scalarSinks[ + BusSinkIndex.TargetedPostProcessWithoutContext + ] ) { count += entry?.handlers?.Count ?? 0; } foreach ( - HandlerCache< - int, - HandlerCache - > entry in _postProcessingBroadcastWithoutSourceSinks + HandlerCache entry in _scalarSinks[ + BusSinkIndex.BroadcastPostProcessWithoutContext + ] ) { count += entry?.handlers?.Count ?? 0; @@ -398,7 +536,7 @@ public int RegisteredPostProcessors } } - public int RegisteredGlobalAcceptAll => _globalSinks.handlers.Count; + public int RegisteredGlobalAcceptAll => _globalSlots.sharedHandlers.Count; private static int SumInterceptorCache(MessageCache> cache) { @@ -462,27 +600,295 @@ private delegate void FastSourcedBroadcast(ref InstanceId target, ref T messa public RegistrationLog Log => _log; - private readonly MessageCache> _sinks = new(); - private readonly MessageCache< - Dictionary> - > _targetedSinks = new(); - private readonly MessageCache< - Dictionary> - > _broadcastSinks = new(); - private readonly MessageCache> _postProcessingSinks = new(); + // Storage trio for typed and global dispatch. _scalarSinks and + // _contextSinks are SlotKey-indexed arrays of MessageCache (call sites + // index by BusSinkIndex / BusContextIndex constants; reserved-null + // entries are documented in BusSinkIndex.cs). _globalSlots is a single + // BusGlobalSlot -- the global accept-all slot is single-cardinality, so + // there is no array to index, but it is grouped here because it shares + // the lifecycle of the typed sinks (cleared together in ResetState, + // touched together by the eviction layer in P4). + private readonly MessageCache>[] _scalarSinks = + new MessageCache>[BusSinkIndex.Length] + { + /* [0] UntargetedHandleDefault */new(), + /* [1] BroadcastHandleWithoutContext */new(), + /* [2] TargetedHandleWithoutContext */new(), + /* [3] UntargetedPostProcessDefault */new(), + /* [4] TargetedPostProcessWithoutContext */new(), + /* [5] BroadcastPostProcessWithoutContext */new(), + /* [6] Reserved6 */null, + /* [7] Reserved7 */null, + }; + private readonly MessageCache< Dictionary> - > _postProcessingTargetedSinks = new(); - private readonly MessageCache< + >[] _contextSinks = new MessageCache< Dictionary> - > _postProcessingBroadcastSinks = new(); - private readonly MessageCache< - HandlerCache - > _postProcessingTargetedWithoutTargetingSinks = new(); - private readonly MessageCache< - HandlerCache - > _postProcessingBroadcastWithoutSourceSinks = new(); - private readonly HandlerCache _globalSinks = new(); + >[BusContextIndex.Length] + { + /* [0] TargetedHandleDefault */new(), + /* [1] BroadcastHandleDefault */new(), + /* [2] TargetedPostProcessDefault */new(), + /* [3] BroadcastPostProcessDefault */new(), + }; + + private readonly BusGlobalSlot _globalSlots = new(); + + public MessageBus() + : this(StopwatchClock.Instance, DefaultIdleEvictionTicks, applyRuntimeSettings: true) + { } + + internal MessageBus(IDxMessagingClock clock) + : this(clock, DefaultIdleEvictionTicks, applyRuntimeSettings: true) { } + + internal MessageBus(IDxMessagingClock clock, long idleEvictionTicks) + : this(clock, idleEvictionTicks, applyRuntimeSettings: false) { } + + internal MessageBus( + IDxMessagingClock clock, + long idleEvictionTicks, + double evictionTickIntervalSeconds, + bool idleEvictionEnabled, + bool trimApiEnabled + ) + : this(clock, idleEvictionTicks, applyRuntimeSettings: false) + { + _evictionTickIntervalSeconds = Math.Max(0d, evictionTickIntervalSeconds); + _idleEvictionEnabled = idleEvictionEnabled; + _trimApiEnabled = trimApiEnabled; + } + + private MessageBus( + IDxMessagingClock clock, + long idleEvictionTicks, + bool applyRuntimeSettings + ) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _idleEvictionTicks = Math.Max(0, idleEvictionTicks); + _evictionTickIntervalSeconds = DefaultEvictionTickIntervalSeconds; + _lastSweepSeconds = _clock.NowSeconds; +#if UNITY_2021_3_OR_NEWER + RegisterForIdleSweeps(this); + EnsureRuntimeSettingsSubscription(); + if (applyRuntimeSettings) + { + ApplyRuntimeSettings(DxMessagingRuntimeSettingsProvider.Current); + } +#endif + ValidateSinkArrays(); + } + +#if UNITY_2021_3_OR_NEWER + private static readonly List> IdleSweepBuses = new(); + private static bool RuntimeSettingsSubscribed; + + private static void RegisterForIdleSweeps(MessageBus bus) + { + for (int i = IdleSweepBuses.Count - 1; i >= 0; --i) + { + if (!IdleSweepBuses[i].TryGetTarget(out MessageBus existing)) + { + IdleSweepBuses.RemoveAt(i); + continue; + } + if (ReferenceEquals(existing, bus)) + { + return; + } + } + + IdleSweepBuses.Add(new WeakReference(bus)); + } + + private static void EnsureRuntimeSettingsSubscription() + { + if (RuntimeSettingsSubscribed) + { + return; + } + + DxMessagingRuntimeSettings.SettingsChanged += HandleRuntimeSettingsChanged; + RuntimeSettingsSubscribed = true; + } + + private static void HandleRuntimeSettingsChanged(DxMessagingRuntimeSettings settings) + { + if (settings == null) + { + settings = DxMessagingRuntimeSettingsProvider.Current; + } + + for (int i = IdleSweepBuses.Count - 1; i >= 0; --i) + { + if (IdleSweepBuses[i].TryGetTarget(out MessageBus bus)) + { + bus.ApplyRuntimeSettings(settings); + continue; + } + + IdleSweepBuses.RemoveAt(i); + } + } + + internal static void SweepIdleBusesFromPlayerLoop() + { + for (int i = IdleSweepBuses.Count - 1; i >= 0; --i) + { + if (IdleSweepBuses[i].TryGetTarget(out MessageBus bus)) + { + bus.TrySweepIdle(advanceTickForIdleAging: true); + continue; + } + + IdleSweepBuses.RemoveAt(i); + } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetIdleSweepRegistry() + { + DxMessagingRuntimeSettings.SettingsChanged -= HandleRuntimeSettingsChanged; + IdleSweepBuses.Clear(); + RuntimeSettingsSubscribed = false; + } + + private void ApplyRuntimeSettings(DxMessagingRuntimeSettings settings) + { + if (settings == null) + { + return; + } + + DxPools.Configure(settings); + if (!settings.IsFallbackInstance) + { + IMessageBus.GlobalMessageBufferSize = Math.Max(0, settings.MessageBufferSize); + } + _emissionBuffer.Resize(Math.Max(0, IMessageBus.GlobalMessageBufferSize)); + _idleEvictionTicks = ComputeIdleEvictionTicks(settings.IdleEvictionSeconds); + _evictionTickIntervalSeconds = Math.Max(0d, settings.EvictionTickIntervalSeconds); + _idleEvictionEnabled = settings.EvictionEnabled; + _trimApiEnabled = settings.EnableTrimApi; + } +#endif + + private static long ComputeIdleEvictionTicks(float idleEvictionSeconds) + { + if (idleEvictionSeconds <= 0f) + { + return 0; + } + + return (long)Math.Ceiling(idleEvictionSeconds); + } + + [Conditional("DEBUG")] + private void ValidateSinkArrays() + { + if (_scalarSinks.Length != BusSinkIndex.Length) + { + throw new InvalidOperationException( + $"_scalarSinks length is {_scalarSinks.Length} but BusSinkIndex.Length is {BusSinkIndex.Length}." + ); + } + if (_contextSinks.Length != BusContextIndex.Length) + { + throw new InvalidOperationException( + $"_contextSinks length is {_contextSinks.Length} but BusContextIndex.Length is {BusContextIndex.Length}." + ); + } + if (_scalarSinks[BusSinkIndex.Reserved6] != null) + { + throw new InvalidOperationException( + "_scalarSinks[Reserved6] is a permanent future-expansion stub and must be null." + ); + } + if (_scalarSinks[BusSinkIndex.Reserved7] != null) + { + throw new InvalidOperationException( + "_scalarSinks[Reserved7] is a permanent future-expansion stub and must be null." + ); + } + if (_scalarSinks[BusSinkIndex.UntargetedHandleDefault] == null) + { + throw new InvalidOperationException( + "_scalarSinks[UntargetedHandleDefault] must be non-null." + ); + } + if (_scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext] == null) + { + throw new InvalidOperationException( + "_scalarSinks[BroadcastHandleWithoutContext] must be non-null." + ); + } + if (_scalarSinks[BusSinkIndex.TargetedHandleWithoutContext] == null) + { + throw new InvalidOperationException( + "_scalarSinks[TargetedHandleWithoutContext] must be non-null." + ); + } + if (_scalarSinks[BusSinkIndex.UntargetedPostProcessDefault] == null) + { + throw new InvalidOperationException( + "_scalarSinks[UntargetedPostProcessDefault] must be non-null." + ); + } + if (_scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext] == null) + { + throw new InvalidOperationException( + "_scalarSinks[TargetedPostProcessWithoutContext] must be non-null." + ); + } + if (_scalarSinks[BusSinkIndex.BroadcastPostProcessWithoutContext] == null) + { + throw new InvalidOperationException( + "_scalarSinks[BroadcastPostProcessWithoutContext] must be non-null." + ); + } + if (_contextSinks[BusContextIndex.TargetedHandleDefault] == null) + { + throw new InvalidOperationException( + "_contextSinks[TargetedHandleDefault] must be non-null." + ); + } + if (_contextSinks[BusContextIndex.BroadcastHandleDefault] == null) + { + throw new InvalidOperationException( + "_contextSinks[BroadcastHandleDefault] must be non-null." + ); + } + if (_contextSinks[BusContextIndex.TargetedPostProcessDefault] == null) + { + throw new InvalidOperationException( + "_contextSinks[TargetedPostProcessDefault] must be non-null." + ); + } + if (_contextSinks[BusContextIndex.BroadcastPostProcessDefault] == null) + { + throw new InvalidOperationException( + "_contextSinks[BroadcastPostProcessDefault] must be non-null." + ); + } + } + + // Asserts BusGlobalSlot.liveCount remains in lockstep with + // _globalSlots.sharedHandlers.Count after every register / deregister. + // Stripped in Release builds via [Conditional("DEBUG")] -- zero + // hot-path cost. Kept separate from ValidateSinkArrays (which runs + // once at construction) because this invariant must hold across + // mutations, not only at startup. + [Conditional("DEBUG")] + private void DebugAssertGlobalLiveCount() + { + System.Diagnostics.Debug.Assert( + _globalSlots.liveCount == _globalSlots.sharedHandlers.Count, + "BusGlobalSlot.liveCount must mirror sharedHandlers.Count at every " + + "stable observation point. Drift indicates a missed register / " + + "deregister wiring point or an unexpected mutation path." + ); + } // Interceptors split by category to avoid mixing types private readonly MessageCache> _untargetedInterceptsByType = new(); @@ -511,54 +917,793 @@ private readonly Dictionary< private bool _diagnosticsMode = ShouldEnableDiagnostics(); private bool _loggedReflexiveWarning; + private long _tickCounter; + private readonly IDxMessagingClock _clock; + private long _idleEvictionTicks = DefaultIdleEvictionTicks; + private double _evictionTickIntervalSeconds = DefaultEvictionTickIntervalSeconds; + private bool _idleEvictionEnabled = true; + private bool _trimApiEnabled = true; + private double _lastSweepSeconds; + private readonly List _dirtyTypes = new(); + private readonly Dictionary> _dirtyTargets = new(); + private readonly HashSet _dirtyTypeSet = new(); + private readonly Dictionary> _dirtyTargetSets = new(); + private readonly List _dirtyHandlers = new(); + private readonly HashSet _dirtyHandlerSet = new(); + private readonly Dictionary _dirtyHandlerTicks = new(); + private bool _globalSlotSweepCandidate; + private long _globalSlotSweepGeneration; + private int _lastContextTypeSlotsEvicted; + private int _dispatchDepth; + + // Bumped by ResetState. Deregister closures captured before the bump + // compare their captured generation to this field and silently skip + // when they no longer match, so a deferred Object.Destroy that lands + // after a Reset cannot log spurious over-deregistration errors. + private long _resetGeneration; + + /// + /// Bumps the internal reset generation counter without clearing any registrations or sinks. + /// + /// + /// + /// Deregister closures returned by the registration entry points capture the value of the + /// reset generation at registration time and silently no-op when the captured value differs + /// from the bus's current value. Calling this method invalidates every previously-issued + /// deregister closure for this bus, which is the desired behaviour after a logical "wipe" + /// performed by external state-management code (for example, a custom domain-reload-disabled + /// reset utility) that does not wish to clear registrations via . + /// + /// + /// uses this method to extend the destroy-then-Reset + /// race-safety guarantee to user-installed custom global buses without clobbering their state. + /// + /// + public void BumpResetGeneration() + { + unchecked + { + _resetGeneration++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static long GetCurrentTouchTick(IMessageBus messageBus) + { + return messageBus is MessageBus bus ? bus._tickCounter : messageBus?.EmissionId ?? 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static long GetResetGeneration(IMessageBus messageBus) + { + return messageBus is MessageBus bus ? bus._resetGeneration : 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsResetGenerationCurrent(IMessageBus messageBus, long generation) + { + return messageBus is not MessageBus bus || bus._resetGeneration == generation; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private long AdvanceTick() + { + unchecked + { + _tickCounter++; + } + + return _tickCounter; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Touch(HandlerCache handlers, long tick) + { + if (handlers != null) + { + handlers.lastTouchTicks = tick; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void MarkDirtyType() + where TMessage : IMessage + { + int typeIndex = MessageHelperIndexer.SequentialId; + if (0 <= typeIndex && _dirtyTypeSet.Add(typeIndex)) + { + _dirtyTypes.Add(typeIndex); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void MarkDirtyTarget(InstanceId target) + where TMessage : IMessage + { + int typeIndex = MessageHelperIndexer.SequentialId; + if (typeIndex < 0) + { + return; + } + + if (!_dirtyTargets.TryGetValue(typeIndex, out List targets)) + { + targets = new List(); + _dirtyTargets[typeIndex] = targets; + } + + if (!_dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet)) + { + targetSet = new HashSet(); + _dirtyTargetSets[typeIndex] = targetSet; + } + + if (targetSet.Add(target)) + { + targets.Add(target); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void MarkDirtyHandler(MessageHandler handler) + { + if (handler == null) + { + return; + } + + _dirtyHandlerTicks[handler] = _tickCounter; + if (_dirtyHandlerSet.Add(handler)) + { + _dirtyHandlers.Add(handler); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private DispatchLease EnterDispatch() + { + return new DispatchLease(this); + } + + public TrimResult Trim(bool force = false) + { + if (!_trimApiEnabled) + { + return default; + } + + return Sweep(force); + } + + internal TrimResult Sweep(bool force) + { + int typeSlotsEvicted = SweepableTypeCacheRegistry[0].Sweep(this, force); + _lastContextTypeSlotsEvicted = 0; + int targetSlotsEvicted = SweepableTypeCacheRegistry[1].Sweep(this, force); + typeSlotsEvicted += _lastContextTypeSlotsEvicted; + typeSlotsEvicted += SweepableTypeCacheRegistry[2].Sweep(this, force); + typeSlotsEvicted += SweepableTypeCacheRegistry[3].Sweep(this, force); + typeSlotsEvicted += SweepableTypeCacheRegistry[4].Sweep(this, force); + typeSlotsEvicted += SweepGlobalSlot(force); + typeSlotsEvicted += SweepDirtyTypedHandlerSlots(force); + int pooledCollectionsEvicted = DxPools.TrimAll(force); + if (force) + { + ClearDirtySweepCandidates(); + } + else + { + PruneDirtySweepCandidates(); + } + _lastSweepSeconds = _clock.NowSeconds; + + return new TrimResult( + typeSlotsEvicted, + targetSlotsEvicted, + pooledCollectionsEvicted, + OccupiedTypeSlots + ); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void TrySweepIdle(bool advanceTickForIdleAging = false) + { + if (!_idleEvictionEnabled) + { + return; + } + + double nowSeconds = _clock.NowSeconds; + if (nowSeconds - _lastSweepSeconds < _evictionTickIntervalSeconds) + { + return; + } + + if (advanceTickForIdleAging) + { + _ = AdvanceTick(); + } + + _ = Sweep(force: false); + } + + private int SweepDirtyScalarTypeSlots(bool force) + { + int evicted = 0; + for (int i = 0; i < _dirtyTypes.Count; ++i) + { + int typeIndex = _dirtyTypes[i]; + for (int sinkIndex = 0; sinkIndex < _scalarSinks.Length; ++sinkIndex) + { + MessageCache> sink = _scalarSinks[sinkIndex]; + if ( + sink == null + || !sink.TryGetValueAtIndex( + typeIndex, + out HandlerCache handlers + ) + || handlers.handlers.Count != 0 + || HasActiveDispatchSnapshot(handlers.dispatchState) + || !IsIdleForSweep(handlers.lastTouchTicks, force) + ) + { + continue; + } + + handlers.Clear(); + sink.RemoveAtIndex(typeIndex); + evicted++; + } + } + + return evicted; + } + + private int SweepDirtyInterceptorTypeSlots( + MessageCache> interceptorsByType, + bool force + ) + { + int evicted = 0; + for (int i = 0; i < _dirtyTypes.Count; ++i) + { + int typeIndex = _dirtyTypes[i]; + if ( + !interceptorsByType.TryGetValueAtIndex( + typeIndex, + out InterceptorCache interceptors + ) + || interceptors.handlers.Count != 0 + || !IsIdleForSweep(interceptors.lastTouchTicks, force) + ) + { + continue; + } + + interceptors.Clear(); + interceptorsByType.RemoveAtIndex(typeIndex); + evicted++; + } + + return evicted; + } + + private int SweepDirtyTargetSlots(bool force) + { + int evicted = 0; + foreach (KeyValuePair> dirtyTargetEntry in _dirtyTargets) + { + int typeIndex = dirtyTargetEntry.Key; + List targets = dirtyTargetEntry.Value; + for (int sinkIndex = 0; sinkIndex < _contextSinks.Length; ++sinkIndex) + { + MessageCache>> sink = + _contextSinks[sinkIndex]; + if ( + sink == null + || !sink.TryGetValueAtIndex( + typeIndex, + out Dictionary< + InstanceId, + HandlerCache + > handlersByTarget + ) + ) + { + continue; + } + + for (int targetIndex = 0; targetIndex < targets.Count; ++targetIndex) + { + InstanceId target = targets[targetIndex]; + if ( + !handlersByTarget.TryGetValue( + target, + out HandlerCache handlers + ) + || handlers.handlers.Count != 0 + || HasActiveDispatchSnapshot(handlers.dispatchState) + || !IsIdleForSweep(handlers.lastTouchTicks, force) + ) + { + continue; + } + + handlers.Clear(); + _ = handlersByTarget.Remove(target); + evicted++; + } + + if (handlersByTarget.Count == 0) + { + sink.RemoveAtIndex(typeIndex); + _lastContextTypeSlotsEvicted++; + } + } + } + + return evicted; + } + + private int SweepGlobalSlot(bool force) + { + if ( + !_globalSlotSweepCandidate + || !_globalSlots.IsEmpty + || HasActiveGlobalDispatchSnapshot() + || !IsIdleForSweep(_globalSlots.lastTouchTicks, force) + ) + { + return 0; + } + + _globalSlots.Reset(); + unchecked + { + _globalSlotSweepGeneration++; + } + _globalSlotSweepCandidate = false; + return 1; + } + + private int SweepDirtyTypedHandlerSlots(bool force) + { + int evicted = 0; + if (_dispatchDepth > 0) + { + return evicted; + } + + for (int i = 0; i < _dirtyHandlers.Count; ++i) + { + MessageHandler handler = _dirtyHandlers[i]; + if ( + !force + && ( + !_dirtyHandlerTicks.TryGetValue(handler, out long lastTouchTicks) + || !IsIdleForSweep(lastTouchTicks, force: false) + ) + ) + { + continue; + } + + evicted += handler.ResetEmptyTypedSlotsForSweep(this); + } + + return evicted; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsIdleForSweep(long lastTouchTicks, bool force) + { + return force || unchecked(_tickCounter - lastTouchTicks) > _idleEvictionTicks; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasActiveDispatchSnapshot(DispatchState state) + { + return _dispatchDepth > 0 && state != null && !state.active.IsEmpty; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasActiveGlobalDispatchSnapshot() + { + return HasActiveDispatchSnapshot(_globalSlots.untargetedDispatchState) + || HasActiveDispatchSnapshot(_globalSlots.targetedDispatchState) + || HasActiveDispatchSnapshot(_globalSlots.broadcastDispatchState); + } + + private void PruneDirtySweepCandidates() + { + PruneDirtyScalarTypeCandidates(); + PruneDirtyTargetCandidates(); + PruneDirtyHandlerCandidates(); + } + + private void PruneDirtyScalarTypeCandidates() + { + int write = 0; + for (int i = 0; i < _dirtyTypes.Count; ++i) + { + int typeIndex = _dirtyTypes[i]; + if ( + HasFreshEmptyScalarTypeCandidate(typeIndex) + || HasFreshEmptyInterceptorTypeCandidate(typeIndex) + ) + { + _dirtyTypes[write++] = typeIndex; + continue; + } + + _dirtyTypeSet.Remove(typeIndex); + } + + if (write < _dirtyTypes.Count) + { + _dirtyTypes.RemoveRange(write, _dirtyTypes.Count - write); + } + } + + private bool HasFreshEmptyScalarTypeCandidate(int typeIndex) + { + for (int sinkIndex = 0; sinkIndex < _scalarSinks.Length; ++sinkIndex) + { + MessageCache> sink = _scalarSinks[sinkIndex]; + if ( + sink != null + && sink.TryGetValueAtIndex( + typeIndex, + out HandlerCache handlers + ) + && handlers.handlers.Count == 0 + && !IsIdleForSweep(handlers.lastTouchTicks, force: false) + ) + { + return true; + } + } + + return false; + } + + private bool HasFreshEmptyInterceptorTypeCandidate(int typeIndex) + { + return HasFreshEmptyInterceptorTypeCandidate(_untargetedInterceptsByType, typeIndex) + || HasFreshEmptyInterceptorTypeCandidate(_targetedInterceptsByType, typeIndex) + || HasFreshEmptyInterceptorTypeCandidate(_broadcastInterceptsByType, typeIndex); + } + + private bool HasFreshEmptyInterceptorTypeCandidate( + MessageCache> interceptorsByType, + int typeIndex + ) + { + return interceptorsByType.TryGetValueAtIndex( + typeIndex, + out InterceptorCache interceptors + ) + && interceptors.handlers.Count == 0 + && !IsIdleForSweep(interceptors.lastTouchTicks, force: false); + } + + private void PruneDirtyTargetCandidates() + { + List emptyTypeKeys = null; + foreach (KeyValuePair> entry in _dirtyTargets) + { + int typeIndex = entry.Key; + List targets = entry.Value; + _dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet); + int write = 0; + for (int i = 0; i < targets.Count; ++i) + { + InstanceId target = targets[i]; + if (HasFreshEmptyTargetCandidate(typeIndex, target)) + { + targets[write++] = target; + continue; + } + + targetSet?.Remove(target); + } + + if (write < targets.Count) + { + targets.RemoveRange(write, targets.Count - write); + } + + if (targets.Count == 0) + { + (emptyTypeKeys ??= new List()).Add(typeIndex); + } + } + + if (emptyTypeKeys == null) + { + return; + } + + for (int i = 0; i < emptyTypeKeys.Count; ++i) + { + int typeIndex = emptyTypeKeys[i]; + _dirtyTargets.Remove(typeIndex); + _dirtyTargetSets.Remove(typeIndex); + } + } + + private bool HasFreshEmptyTargetCandidate(int typeIndex, InstanceId target) + { + for (int sinkIndex = 0; sinkIndex < _contextSinks.Length; ++sinkIndex) + { + MessageCache>> sink = + _contextSinks[sinkIndex]; + if ( + sink == null + || !sink.TryGetValueAtIndex( + typeIndex, + out Dictionary> handlersByTarget + ) + || !handlersByTarget.TryGetValue( + target, + out HandlerCache handlers + ) + ) + { + continue; + } + + if ( + handlers.handlers.Count == 0 + && !IsIdleForSweep(handlers.lastTouchTicks, force: false) + ) + { + return true; + } + } + + return false; + } + + private void PruneDirtyHandlerCandidates() + { + int write = 0; + for (int i = 0; i < _dirtyHandlers.Count; ++i) + { + MessageHandler handler = _dirtyHandlers[i]; + if ( + handler != null + && _dirtyHandlerSet.Contains(handler) + && _dirtyHandlerTicks.TryGetValue(handler, out long lastTouchTicks) + && handler.CountEmptyTypedSlotsForSweep(this) > 0 + && !IsIdleForSweep(lastTouchTicks, force: false) + ) + { + _dirtyHandlers[write++] = handler; + continue; + } + + _dirtyHandlerSet.Remove(handler); + _dirtyHandlerTicks.Remove(handler); + } + + if (write < _dirtyHandlers.Count) + { + _dirtyHandlers.RemoveRange(write, _dirtyHandlers.Count - write); + } + } + + private void ClearDirtySweepCandidates() + { + ClearDirtyTypeCandidatesWithoutEmptySlots(); + ClearDirtyTargetCandidatesWithoutEmptySlots(); + ClearDirtyHandlerCandidatesWithoutEmptySlots(); + } + + private void ClearDirtyTypeCandidatesWithoutEmptySlots() + { + int write = 0; + for (int i = 0; i < _dirtyTypes.Count; ++i) + { + int typeIndex = _dirtyTypes[i]; + if (HasEmptyScalarTypeCandidate(typeIndex)) + { + _dirtyTypes[write++] = typeIndex; + continue; + } + + _dirtyTypeSet.Remove(typeIndex); + } + + if (write < _dirtyTypes.Count) + { + _dirtyTypes.RemoveRange(write, _dirtyTypes.Count - write); + } + } + + private bool HasEmptyScalarTypeCandidate(int typeIndex) + { + for (int sinkIndex = 0; sinkIndex < _scalarSinks.Length; ++sinkIndex) + { + MessageCache> sink = _scalarSinks[sinkIndex]; + if ( + sink != null + && sink.TryGetValueAtIndex( + typeIndex, + out HandlerCache handlers + ) + && handlers.handlers.Count == 0 + ) + { + return true; + } + } + + return HasEmptyInterceptorTypeCandidate(_untargetedInterceptsByType, typeIndex) + || HasEmptyInterceptorTypeCandidate(_targetedInterceptsByType, typeIndex) + || HasEmptyInterceptorTypeCandidate(_broadcastInterceptsByType, typeIndex); + } + + private static bool HasEmptyInterceptorTypeCandidate( + MessageCache> interceptorsByType, + int typeIndex + ) + { + return interceptorsByType.TryGetValueAtIndex( + typeIndex, + out InterceptorCache interceptors + ) + && interceptors.handlers.Count == 0; + } + + private void ClearDirtyTargetCandidatesWithoutEmptySlots() + { + List emptyTypeKeys = null; + foreach (KeyValuePair> entry in _dirtyTargets) + { + int typeIndex = entry.Key; + List targets = entry.Value; + _dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet); + int write = 0; + for (int i = 0; i < targets.Count; ++i) + { + InstanceId target = targets[i]; + if (HasEmptyTargetCandidate(typeIndex, target)) + { + targets[write++] = target; + continue; + } + + targetSet?.Remove(target); + } + + if (write < targets.Count) + { + targets.RemoveRange(write, targets.Count - write); + } + + if (targets.Count == 0) + { + (emptyTypeKeys ??= new List()).Add(typeIndex); + } + } + + if (emptyTypeKeys == null) + { + return; + } + + for (int i = 0; i < emptyTypeKeys.Count; ++i) + { + int typeIndex = emptyTypeKeys[i]; + _dirtyTargets.Remove(typeIndex); + _dirtyTargetSets.Remove(typeIndex); + } + } + + private bool HasEmptyTargetCandidate(int typeIndex, InstanceId target) + { + for (int sinkIndex = 0; sinkIndex < _contextSinks.Length; ++sinkIndex) + { + MessageCache>> sink = + _contextSinks[sinkIndex]; + if ( + sink != null + && sink.TryGetValueAtIndex( + typeIndex, + out Dictionary> handlersByTarget + ) + && handlersByTarget.TryGetValue( + target, + out HandlerCache handlers + ) + && handlers.handlers.Count == 0 + ) + { + return true; + } + } + + return false; + } + + private void ClearDirtyHandlerCandidatesWithoutEmptySlots() + { + int write = 0; + for (int i = 0; i < _dirtyHandlers.Count; ++i) + { + MessageHandler handler = _dirtyHandlers[i]; + if ( + handler != null + && _dirtyHandlerSet.Contains(handler) + && handler.CountEmptyTypedSlotsForSweep(this) > 0 + ) + { + _dirtyHandlers[write++] = handler; + continue; + } + + _dirtyHandlerSet.Remove(handler); + _dirtyHandlerTicks.Remove(handler); + } + + if (write < _dirtyHandlers.Count) + { + _dirtyHandlers.RemoveRange(write, _dirtyHandlers.Count - write); + } + } + + private int CountDirtyEmptyTypedHandlerSlots() + { + int count = 0; + for (int i = 0; i < _dirtyHandlers.Count; ++i) + { + MessageHandler handler = _dirtyHandlers[i]; + if (handler != null && _dirtyHandlerSet.Contains(handler)) + { + count += handler.CountEmptyTypedSlotsForSweep(this); + } + } - // Bumped by ResetState. Deregister closures captured before the bump - // compare their captured generation to this field and silently skip - // when they no longer match, so a deferred Object.Destroy that lands - // after a Reset cannot log spurious over-deregistration errors. - private long _resetGeneration; + return count; + } - /// - /// Bumps the internal reset generation counter without clearing any registrations or sinks. - /// - /// - /// - /// Deregister closures returned by the registration entry points capture the value of the - /// reset generation at registration time and silently no-op when the captured value differs - /// from the bus's current value. Calling this method invalidates every previously-issued - /// deregister closure for this bus, which is the desired behaviour after a logical "wipe" - /// performed by external state-management code (for example, a custom domain-reload-disabled - /// reset utility) that does not wish to clear registrations via . - /// - /// - /// uses this method to extend the destroy-then-Reset - /// race-safety guarantee to user-installed custom global buses without clobbering their state. - /// - /// - public void BumpResetGeneration() + private static int CountOccupiedInterceptorTypeSlots( + MessageCache> cache + ) { - unchecked + int count = 0; + foreach (InterceptorCache entry in cache) { - _resetGeneration++; + if (entry != null) + { + count++; + } } + + return count; } internal void ResetState() { + ResetTypedSlotsForReferencedHandlers(); _emissionId = 0; + _tickCounter = 0; _diagnosticsMode = ShouldEnableDiagnostics(); _loggedReflexiveWarning = false; BumpResetGeneration(); - _sinks.Clear(); - _targetedSinks.Clear(); - _broadcastSinks.Clear(); - _postProcessingSinks.Clear(); - _postProcessingTargetedSinks.Clear(); - _postProcessingBroadcastSinks.Clear(); - _postProcessingTargetedWithoutTargetingSinks.Clear(); - _postProcessingBroadcastWithoutSourceSinks.Clear(); - _globalSinks.Clear(); + _scalarSinks[BusSinkIndex.UntargetedHandleDefault].Clear(); + _scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext].Clear(); + _scalarSinks[BusSinkIndex.TargetedHandleWithoutContext].Clear(); + _contextSinks[BusContextIndex.TargetedHandleDefault].Clear(); + _contextSinks[BusContextIndex.BroadcastHandleDefault].Clear(); + _scalarSinks[BusSinkIndex.UntargetedPostProcessDefault].Clear(); + _contextSinks[BusContextIndex.TargetedPostProcessDefault].Clear(); + _contextSinks[BusContextIndex.BroadcastPostProcessDefault].Clear(); + _scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext].Clear(); + _scalarSinks[BusSinkIndex.BroadcastPostProcessWithoutContext].Clear(); + _globalSlots.Clear(); _untargetedInterceptsByType.Clear(); _targetedInterceptsByType.Clear(); @@ -567,6 +1712,15 @@ internal void ResetState() _broadcastMethodsByType.Clear(); _innerInterceptorsStack.Clear(); _methodCache.Clear(); + _dirtyTypes.Clear(); + _dirtyTargets.Clear(); + _dirtyTypeSet.Clear(); + _dirtyTargetSets.Clear(); + _dirtyHandlers.Clear(); + _dirtyHandlerSet.Clear(); + _dirtyHandlerTicks.Clear(); + _globalSlotSweepCandidate = false; + _lastSweepSeconds = _clock.NowSeconds; #if UNITY_2021_3_OR_NEWER _recipientCache.Clear(); @@ -580,13 +1734,93 @@ internal void ResetState() _emissionBuffer.Clear(); } + private void ResetTypedSlotsForReferencedHandlers() + { + HashSet handlers = new HashSet(); + AddHandlersFromScalarSinks(handlers); + AddHandlersFromContextSinks(handlers); + + foreach (MessageHandler handler in _globalSlots.sharedHandlers.Keys) + { + handlers.Add(handler); + } + + foreach (MessageHandler handler in handlers) + { + handler.ResetAllTypedSlotsForBusReset(this); + } + } + + private void AddHandlersFromScalarSinks(HashSet handlers) + { + foreach (MessageCache> sink in _scalarSinks) + { + if (sink == null) + { + continue; + } + + foreach (HandlerCache handlersByPriority in sink) + { + AddHandlersFromPriorityCache(handlersByPriority, handlers); + } + } + } + + private void AddHandlersFromContextSinks(HashSet handlers) + { + foreach ( + MessageCache< + Dictionary> + > sink in _contextSinks + ) + { + foreach ( + Dictionary< + InstanceId, + HandlerCache + > handlersByContext in sink + ) + { + foreach ( + HandlerCache< + int, + HandlerCache + > handlersByPriority in handlersByContext.Values + ) + { + AddHandlersFromPriorityCache(handlersByPriority, handlers); + } + } + } + } + + private static void AddHandlersFromPriorityCache( + HandlerCache handlersByPriority, + HashSet handlers + ) + { + if (handlersByPriority == null) + { + return; + } + + foreach (HandlerCache cache in handlersByPriority.handlers.Values) + { + foreach (MessageHandler handler in cache.handlers.Keys) + { + handlers.Add(handler); + } + } + } + /// public Action RegisterUntargeted(MessageHandler messageHandler, int priority = 0) where T : IUntargetedMessage { return InternalRegisterUntargeted( messageHandler, - _sinks, + _scalarSinks[BusSinkIndex.UntargetedHandleDefault], RegistrationMethod.Untargeted, priority ); @@ -603,7 +1837,7 @@ public Action RegisterTargeted( return InternalRegisterWithContext( target, messageHandler, - _targetedSinks, + _contextSinks[BusContextIndex.TargetedHandleDefault], RegistrationMethod.Targeted, priority ); @@ -620,7 +1854,7 @@ public Action RegisterSourcedBroadcast( return InternalRegisterWithContext( source, messageHandler, - _broadcastSinks, + _contextSinks[BusContextIndex.BroadcastHandleDefault], RegistrationMethod.Broadcast, priority ); @@ -635,7 +1869,7 @@ public Action RegisterSourcedBroadcastWithoutSource( { return InternalRegisterUntargeted( messageHandler, - _sinks, + _scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext], RegistrationMethod.BroadcastWithoutSource, priority ); @@ -650,7 +1884,7 @@ public Action RegisterTargetedWithoutTargeting( { return InternalRegisterUntargeted( messageHandler, - _sinks, + _scalarSinks[BusSinkIndex.TargetedHandleWithoutContext], RegistrationMethod.TargetedWithoutTargeting, priority ); @@ -659,11 +1893,21 @@ public Action RegisterTargetedWithoutTargeting( /// public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) { - _globalSinks.version++; - int count = _globalSinks.handlers.GetValueOrDefault(messageHandler, 0); + long touchTick = AdvanceTick(); + _globalSlots.lastTouchTicks = touchTick; + _globalSlots.version++; + int count = _globalSlots.sharedHandlers.GetValueOrDefault(messageHandler, 0); Type type = typeof(IMessage); - _globalSinks.handlers[messageHandler] = count + 1; + _globalSlots.sharedHandlers[messageHandler] = count + 1; + // liveCount mirrors sharedHandlers.Count at every stable + // observation point; only newly-inserted handlers (the 0 -> 1 + // transition in the per-handler refcount) advance it. See + // BusGlobalSlot.liveCount xmldoc for the full invariant. + if (count == 0) + { + _globalSlots.liveCount++; + } _log.Log( new MessagingRegistration( messageHandler.owner, @@ -675,31 +1919,37 @@ public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalUntargeted + _globalSlots, + DispatchKind.Untargeted ); StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalTargeted + _globalSlots, + DispatchKind.Targeted ); StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalBroadcast + _globalSlots, + DispatchKind.Broadcast ); + DebugAssertGlobalLiveCount(); long capturedGeneration = _resetGeneration; + long capturedSweepGeneration = _globalSlotSweepGeneration; return () => { // Generation guard: see InternalRegisterUntargeted for the // rationale. Skip silently when the closure outlived a Reset. - if (capturedGeneration != _resetGeneration) + if ( + capturedGeneration != _resetGeneration + || capturedSweepGeneration != _globalSlotSweepGeneration + ) { return; } - _globalSinks.version++; + long deregisterTouchTick = AdvanceTick(); + _globalSlots.version++; _log.Log( new MessagingRegistration( messageHandler.owner, @@ -708,7 +1958,7 @@ public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) RegistrationMethod.GlobalAcceptAll ) ); - if (!_globalSinks.handlers.TryGetValue(messageHandler, out count)) + if (!_globalSlots.sharedHandlers.TryGetValue(messageHandler, out count)) { if (MessagingDebug.enabled) { @@ -722,30 +1972,39 @@ public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) return; } + _globalSlots.lastTouchTicks = deregisterTouchTick; if (count <= 1) { - _ = _globalSinks.handlers.Remove(messageHandler); + _ = _globalSlots.sharedHandlers.Remove(messageHandler); + MarkDirtyHandler(messageHandler); + _globalSlotSweepCandidate = true; + // Final-removal of this handler from sharedHandlers is the + // 1 -> 0 transition that mirrors back into liveCount. + // Partial deregistration (count > 1) leaves liveCount + // alone -- the dictionary entry is still present. + _globalSlots.liveCount--; } else { - _globalSinks.handlers[messageHandler] = count - 1; + _globalSlots.sharedHandlers[messageHandler] = count - 1; } StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalUntargeted + _globalSlots, + DispatchKind.Untargeted ); StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalTargeted + _globalSlots, + DispatchKind.Targeted ); StageGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalBroadcast + _globalSlots, + DispatchKind.Broadcast ); + DebugAssertGlobalLiveCount(); }; } @@ -756,8 +2015,12 @@ public Action RegisterUntargetedInterceptor( ) where T : IUntargetedMessage { + _ = AdvanceTick(); InterceptorCache prioritizedInterceptors = _untargetedInterceptsByType.GetOrAdd(); + InterceptorCache capturedInterceptors = prioritizedInterceptors; + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); if ( !_uniqueInterceptorsAndPriorities.TryGetValue( @@ -808,7 +2071,19 @@ out List interceptors { return; } + if ( + IsStaleInterceptorDeregisterAfterSweep( + _untargetedInterceptsByType, + capturedInterceptors + ) + ) + { + return; + } + _ = AdvanceTick(); + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -886,8 +2161,12 @@ public Action RegisterTargetedInterceptor( ) where T : ITargetedMessage { + _ = AdvanceTick(); InterceptorCache prioritizedInterceptors = _targetedInterceptsByType.GetOrAdd(); + InterceptorCache capturedInterceptors = prioritizedInterceptors; + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); if ( !_uniqueInterceptorsAndPriorities.TryGetValue( @@ -938,7 +2217,19 @@ out List interceptors { return; } + if ( + IsStaleInterceptorDeregisterAfterSweep( + _targetedInterceptsByType, + capturedInterceptors + ) + ) + { + return; + } + _ = AdvanceTick(); + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -1016,8 +2307,12 @@ public Action RegisterBroadcastInterceptor( ) where T : IBroadcastMessage { + _ = AdvanceTick(); InterceptorCache prioritizedInterceptors = _broadcastInterceptsByType.GetOrAdd(); + InterceptorCache capturedInterceptors = prioritizedInterceptors; + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); if ( !_uniqueInterceptorsAndPriorities.TryGetValue( @@ -1068,7 +2363,19 @@ out List interceptors { return; } + if ( + IsStaleInterceptorDeregisterAfterSweep( + _broadcastInterceptsByType, + capturedInterceptors + ) + ) + { + return; + } + _ = AdvanceTick(); + prioritizedInterceptors.lastTouchTicks = _tickCounter; + MarkDirtyType(); _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -1139,6 +2446,17 @@ out List interceptors }; } + private bool IsStaleInterceptorDeregisterAfterSweep( + MessageCache> interceptorsByType, + InterceptorCache capturedInterceptors + ) + where T : IMessage + { + return !interceptorsByType.TryGetValue( + out InterceptorCache currentInterceptors + ) || !ReferenceEquals(currentInterceptors, capturedInterceptors); + } + /// public Action RegisterUntargetedPostProcessor( MessageHandler messageHandler, @@ -1148,7 +2466,7 @@ public Action RegisterUntargetedPostProcessor( { return InternalRegisterUntargeted( messageHandler, - _postProcessingSinks, + _scalarSinks[BusSinkIndex.UntargetedPostProcessDefault], RegistrationMethod.UntargetedPostProcessor, priority ); @@ -1165,7 +2483,7 @@ public Action RegisterTargetedPostProcessor( return InternalRegisterWithContext( target, messageHandler, - _postProcessingTargetedSinks, + _contextSinks[BusContextIndex.TargetedPostProcessDefault], RegistrationMethod.TargetedPostProcessor, priority ); @@ -1180,7 +2498,7 @@ public Action RegisterTargetedWithoutTargetingPostProcessor( { return InternalRegisterUntargeted( messageHandler, - _postProcessingTargetedWithoutTargetingSinks, + _scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext], RegistrationMethod.TargetedWithoutTargetingPostProcessor, priority ); @@ -1197,7 +2515,7 @@ public Action RegisterBroadcastPostProcessor( return InternalRegisterWithContext( source, messageHandler, - _postProcessingBroadcastSinks, + _contextSinks[BusContextIndex.BroadcastPostProcessDefault], RegistrationMethod.BroadcastPostProcessor, priority ); @@ -1212,7 +2530,7 @@ public Action RegisterBroadcastWithoutSourcePostProcessor( { return InternalRegisterUntargeted( messageHandler, - _postProcessingBroadcastWithoutSourceSinks, + _scalarSinks[BusSinkIndex.BroadcastPostProcessWithoutContext], RegistrationMethod.BroadcastWithoutSourcePostProcessor, priority ); @@ -1254,10 +2572,13 @@ public void UntypedUntargetedBroadcast(IUntargetedMessage typedMessage) public void UntargetedBroadcast(ref TMessage typedMessage) where TMessage : IUntargetedMessage { + TrySweepIdle(); + using DispatchLease dispatchLease = EnterDispatch(); unchecked { _emissionId++; } + long touchTick = AdvanceTick(); if (_diagnosticsMode) { _emissionBuffer.Add(new MessageEmissionData(typedMessage)); @@ -1267,16 +2588,18 @@ public void UntargetedBroadcast(ref TMessage typedMessage) // handlers/post-processors are not observed until the next emission. DispatchSnapshot untargetedPostSnapshot = DispatchSnapshot.Empty; if ( - _postProcessingSinks.TryGetValue( - out HandlerCache untargetedPostHandlers - ) + _scalarSinks[BusSinkIndex.UntargetedPostProcessDefault] + .TryGetValue( + out HandlerCache untargetedPostHandlers + ) && untargetedPostHandlers.handlers.Count > 0 ) { + Touch(untargetedPostHandlers, touchTick); untargetedPostSnapshot = AcquireDispatchSnapshot( this, untargetedPostHandlers, - DispatchCategory.UntargetedPost, + UntargetedPostSlot, _emissionId ); PrefreezeUntargetedPostSnapshot(untargetedPostSnapshot); @@ -1287,7 +2610,7 @@ out HandlerCache untargetedPostHandlers return; } - if (0 < _globalSinks.handlers.Count) + if (0 < _globalSlots.sharedHandlers.Count) { IUntargetedMessage untargetedMessage = typedMessage; BroadcastGlobalUntargeted(ref untargetedMessage); @@ -1296,17 +2619,17 @@ out HandlerCache untargetedPostHandlers bool foundAnyHandlers = InternalUntargetedBroadcast(ref typedMessage); if ( - _postProcessingSinks.TryGetValue( - out HandlerCache sortedHandlers - ) + _scalarSinks[BusSinkIndex.UntargetedPostProcessDefault] + .TryGetValue(out HandlerCache sortedHandlers) && 0 < sortedHandlers.handlers.Count ) { + Touch(sortedHandlers, touchTick); DispatchSnapshot snapshot = untargetedPostSnapshot.IsEmpty ? AcquireDispatchSnapshot( this, sortedHandlers, - DispatchCategory.UntargetedPost, + UntargetedPostSlot, _emissionId ) : untargetedPostSnapshot; @@ -1414,10 +2737,13 @@ public void UntypedTargetedBroadcast(InstanceId target, ITargetedMessage typedMe public void TargetedBroadcast(ref InstanceId target, ref TMessage typedMessage) where TMessage : ITargetedMessage { + TrySweepIdle(); + using DispatchLease dispatchLease = EnterDispatch(); unchecked { _emissionId++; } + long touchTick = AdvanceTick(); if (_diagnosticsMode) { _emissionBuffer.Add(new MessageEmissionData(typedMessage, target)); @@ -1427,9 +2753,13 @@ public void TargetedBroadcast(ref InstanceId target, ref TMessage type DispatchSnapshot targetedPostSnapshot = DispatchSnapshot.Empty; DispatchSnapshot targetedWithoutTargetingPostSnapshot = DispatchSnapshot.Empty; if ( - _postProcessingTargetedSinks.TryGetValue( - out Dictionary> targetedPostHandlers - ) + _contextSinks[BusContextIndex.TargetedPostProcessDefault] + .TryGetValue( + out Dictionary< + InstanceId, + HandlerCache + > targetedPostHandlers + ) && targetedPostHandlers.TryGetValue( target, out HandlerCache targetedPostByPriority @@ -1437,25 +2767,28 @@ out HandlerCache targetedPostByPriority && targetedPostByPriority.handlers.Count > 0 ) { + Touch(targetedPostByPriority, touchTick); targetedPostSnapshot = AcquireDispatchSnapshot( this, targetedPostByPriority, - DispatchCategory.TargetedPost, + TargetedPostSlot, _emissionId ); PrefreezeTargetedPostSnapshot(ref target, targetedPostSnapshot); } if ( - _postProcessingTargetedWithoutTargetingSinks.TryGetValue( - out HandlerCache targetedWithoutTargetingHandlers - ) + _scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext] + .TryGetValue( + out HandlerCache targetedWithoutTargetingHandlers + ) && targetedWithoutTargetingHandlers.handlers.Count > 0 ) { + Touch(targetedWithoutTargetingHandlers, touchTick); targetedWithoutTargetingPostSnapshot = AcquireDispatchSnapshot( this, targetedWithoutTargetingHandlers, - DispatchCategory.TargetedWithoutTargetingPost, + TargetedWithoutContextPostSlot, _emissionId ); PrefreezeTargetedWithoutTargetingPostSnapshot( @@ -1468,7 +2801,7 @@ out HandlerCache targetedWithoutTargetingHandlers return; } - if (0 < _globalSinks.handlers.Count) + if (0 < _globalSlots.sharedHandlers.Count) { ITargetedMessage targetedMessage = typedMessage; BroadcastGlobalTargeted(ref target, ref targetedMessage); @@ -1685,9 +3018,10 @@ ref typedMessage } if ( - _targetedSinks.TryGetValue( - out Dictionary> targetedHandlers - ) + _contextSinks[BusContextIndex.TargetedHandleDefault] + .TryGetValue( + out Dictionary> targetedHandlers + ) && targetedHandlers.TryGetValue( target, out HandlerCache sortedHandlers @@ -1695,10 +3029,11 @@ out HandlerCache sortedHandlers && sortedHandlers.handlers.Count > 0 ) { + Touch(sortedHandlers, touchTick); DispatchSnapshot snapshot = AcquireDispatchSnapshot( this, sortedHandlers, - DispatchCategory.Targeted, + TargetedHandleSlot, _emissionId ); // Pre-freeze the typed-handler caches across every priority bucket so @@ -1776,7 +3111,8 @@ out HandlerCache sortedHandlers } if ( - _postProcessingTargetedSinks.TryGetValue(out targetedHandlers) + _contextSinks[BusContextIndex.TargetedPostProcessDefault] + .TryGetValue(out targetedHandlers) && targetedHandlers.TryGetValue(target, out sortedHandlers) && sortedHandlers.handlers.Count > 0 ) @@ -1785,7 +3121,7 @@ out HandlerCache sortedHandlers ? AcquireDispatchSnapshot( this, sortedHandlers, - DispatchCategory.TargetedPost, + TargetedPostSlot, _emissionId ) : targetedPostSnapshot; @@ -1930,9 +3266,8 @@ out HandlerCache sortedHandlers } if ( - _postProcessingTargetedWithoutTargetingSinks.TryGetValue( - out HandlerCache postTwt - ) + _scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext] + .TryGetValue(out HandlerCache postTwt) && postTwt.handlers.Count > 0 ) { @@ -1940,7 +3275,7 @@ out HandlerCache postTwt ? AcquireDispatchSnapshot( this, postTwt, - DispatchCategory.TargetedWithoutTargetingPost, + TargetedWithoutContextPostSlot, _emissionId ) : targetedWithoutTargetingPostSnapshot; @@ -2425,10 +3760,13 @@ public void UntypedSourcedBroadcast(InstanceId source, IBroadcastMessage typedMe public void SourcedBroadcast(ref InstanceId source, ref TMessage typedMessage) where TMessage : IBroadcastMessage { + TrySweepIdle(); + using DispatchLease dispatchLease = EnterDispatch(); unchecked { _emissionId++; } + long touchTick = AdvanceTick(); if (_diagnosticsMode) { _emissionBuffer.Add(new MessageEmissionData(typedMessage, source)); @@ -2438,12 +3776,13 @@ public void SourcedBroadcast(ref InstanceId source, ref TMessage typed DispatchSnapshot broadcastPostSnapshot = DispatchSnapshot.Empty; DispatchSnapshot broadcastWithoutSourcePostSnapshot = DispatchSnapshot.Empty; if ( - _postProcessingBroadcastSinks.TryGetValue( - out Dictionary< - InstanceId, - HandlerCache - > broadcastPostHandlers - ) + _contextSinks[BusContextIndex.BroadcastPostProcessDefault] + .TryGetValue( + out Dictionary< + InstanceId, + HandlerCache + > broadcastPostHandlers + ) && broadcastPostHandlers.TryGetValue( source, out HandlerCache broadcastPostByPriority @@ -2451,25 +3790,28 @@ out HandlerCache broadcastPostByPriority && broadcastPostByPriority.handlers.Count > 0 ) { + Touch(broadcastPostByPriority, touchTick); broadcastPostSnapshot = AcquireDispatchSnapshot( this, broadcastPostByPriority, - DispatchCategory.BroadcastPost, + BroadcastPostSlot, _emissionId ); PrefreezeBroadcastPostSnapshot(ref source, broadcastPostSnapshot); } if ( - _postProcessingBroadcastWithoutSourceSinks.TryGetValue( - out HandlerCache broadcastWithoutSourceHandlers - ) + _scalarSinks[BusSinkIndex.BroadcastPostProcessWithoutContext] + .TryGetValue( + out HandlerCache broadcastWithoutSourceHandlers + ) && broadcastWithoutSourceHandlers.handlers.Count > 0 ) { + Touch(broadcastWithoutSourceHandlers, touchTick); broadcastWithoutSourcePostSnapshot = AcquireDispatchSnapshot( this, broadcastWithoutSourceHandlers, - DispatchCategory.BroadcastWithoutSourcePost, + BroadcastWithoutContextPostSlot, _emissionId ); PrefreezeBroadcastWithoutSourcePostSnapshot( @@ -2482,7 +3824,7 @@ out HandlerCache broadcastWithoutSourceHandlers return; } - if (0 < _globalSinks.handlers.Count) + if (0 < _globalSlots.sharedHandlers.Count) { IBroadcastMessage broadcastMessage = typedMessage; BroadcastGlobalSourcedBroadcast(ref source, ref broadcastMessage); @@ -2493,10 +3835,12 @@ out HandlerCache broadcastWithoutSourceHandlers // bucket with at most one MessageHandler entry; see the rationale on // the snapshot-level Prefreeze*Snapshot fast-path short-circuit. if ( - _sinks.TryGetValue(out HandlerCache bwsHandlers) + _scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext] + .TryGetValue(out HandlerCache bwsHandlers) && bwsHandlers.handlers.Count > 0 ) { + Touch(bwsHandlers, touchTick); List> frozen = GetOrAddMessageHandlerStack( bwsHandlers, _emissionId @@ -2535,9 +3879,10 @@ out HandlerCache broadcastWithoutSourceHandlers } bool foundAnyHandlers = false; - _ = _broadcastSinks.TryGetValue( - out Dictionary> broadcastHandlers - ); + _ = _contextSinks[BusContextIndex.BroadcastHandleDefault] + .TryGetValue( + out Dictionary> broadcastHandlers + ); if ( broadcastHandlers != null && broadcastHandlers.TryGetValue( @@ -2547,6 +3892,7 @@ out HandlerCache sortedHandlers && 0 < sortedHandlers.handlers.Count ) { + Touch(sortedHandlers, touchTick); foundAnyHandlers = true; List> handlerList = GetOrAddMessageHandlerStack( sortedHandlers, @@ -2670,21 +4016,11 @@ out HandlerCache sortedHandlers bool bwsFound = InternalBroadcastWithoutSource(ref source, ref typedMessage); - if ( - _postProcessingBroadcastSinks.TryGetValue(out broadcastHandlers) - && broadcastHandlers.TryGetValue(source, out sortedHandlers) - && 0 < sortedHandlers.handlers.Count - ) + if (!broadcastPostSnapshot.IsEmpty) { foundAnyHandlers = true; - DispatchSnapshot snapshot = AcquireDispatchSnapshot( - this, - sortedHandlers, - DispatchCategory.BroadcastPost, - _emissionId - ); - DispatchBucket[] buckets = snapshot.buckets; - int bucketCount = snapshot.bucketCount; + DispatchBucket[] buckets = broadcastPostSnapshot.buckets; + int bucketCount = broadcastPostSnapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) { DispatchBucket bucket = buckets[bucketIndex]; @@ -2823,19 +4159,10 @@ out HandlerCache sortedHandlers } } - if ( - _postProcessingBroadcastWithoutSourceSinks.TryGetValue(out sortedHandlers) - && 0 < sortedHandlers.handlers.Count - ) + if (!broadcastWithoutSourcePostSnapshot.IsEmpty) { - DispatchSnapshot snapshot = AcquireDispatchSnapshot( - this, - sortedHandlers, - DispatchCategory.BroadcastWithoutSourcePost, - _emissionId - ); - DispatchBucket[] buckets = snapshot.buckets; - int bucketCount = snapshot.bucketCount; + DispatchBucket[] buckets = broadcastWithoutSourcePostSnapshot.buckets; + int bucketCount = broadcastWithoutSourcePostSnapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) { DispatchBucket bucket = buckets[bucketIndex]; @@ -3227,8 +4554,8 @@ private void BroadcastGlobalUntargeted(ref IUntargetedMessage message) { DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalUntargeted, + _globalSlots, + DispatchKind.Untargeted, _emissionId ); DispatchBucket[] buckets = snapshot.buckets; @@ -3298,8 +4625,8 @@ private void BroadcastGlobalTargeted(ref InstanceId target, ref ITargetedMessage { DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalTargeted, + _globalSlots, + DispatchKind.Targeted, _emissionId ); DispatchBucket[] buckets = snapshot.buckets; @@ -3372,8 +4699,8 @@ ref IBroadcastMessage message { DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, - _globalSinks, - DispatchCategory.GlobalBroadcast, + _globalSlots, + DispatchKind.Broadcast, _emissionId ); DispatchBucket[] buckets = snapshot.buckets; @@ -3653,7 +4980,8 @@ private bool InternalUntargetedBroadcast(ref TMessage message) where TMessage : IUntargetedMessage { if ( - !_sinks.TryGetValue(out HandlerCache sortedHandlers) + !_scalarSinks[BusSinkIndex.UntargetedHandleDefault] + .TryGetValue(out HandlerCache sortedHandlers) || sortedHandlers.handlers.Count == 0 ) { @@ -3663,7 +4991,7 @@ private bool InternalUntargetedBroadcast(ref TMessage message) DispatchSnapshot snapshot = AcquireDispatchSnapshot( this, sortedHandlers, - DispatchCategory.Untargeted, + UntargetedHandleSlot, _emissionId ); DispatchBucket[] buckets = snapshot.buckets; @@ -3881,7 +5209,8 @@ ref TMessage message where TMessage : ITargetedMessage { if ( - !_sinks.TryGetValue(out HandlerCache sortedHandlers) + !_scalarSinks[BusSinkIndex.TargetedHandleWithoutContext] + .TryGetValue(out HandlerCache sortedHandlers) || sortedHandlers.handlers.Count == 0 ) { @@ -3891,7 +5220,7 @@ ref TMessage message DispatchSnapshot snapshot = AcquireDispatchSnapshot( this, sortedHandlers, - DispatchCategory.TargetedWithoutTargeting, + TargetedWithoutContextHandleSlot, _emissionId ); DispatchBucket[] buckets = snapshot.buckets; @@ -4181,7 +5510,8 @@ ref TMessage message where TMessage : IBroadcastMessage { if ( - !_sinks.TryGetValue(out HandlerCache sortedHandlers) + !_scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext] + .TryGetValue(out HandlerCache sortedHandlers) || sortedHandlers.handlers.Count == 0 ) { @@ -4471,10 +5801,12 @@ int priority throw new ArgumentNullException(nameof(messageHandler)); } + long touchTick = AdvanceTick(); InstanceId handlerOwnerId = messageHandler.owner; HandlerCache handlers = sinks.GetOrAdd(); + Touch(handlers, touchTick); HandlerCache capturedHandlers = handlers; - DispatchCategory dispatchCategory = GetDispatchCategory(registrationMethod); + SlotKey slotKey = RegistrationMethodAxes.GetSlotKey(registrationMethod); if (!handlers.handlers.TryGetValue(priority, out HandlerCache cache)) { @@ -4496,7 +5828,7 @@ int priority int count = handler.GetValueOrDefault(messageHandler, 0); handler[messageHandler] = count + 1; - StageDispatchSnapshot(this, capturedHandlers, dispatchCategory); + StageDispatchSnapshot(this, capturedHandlers, slotKey); Type type = typeof(T); _log.Log( new MessagingRegistration( @@ -4519,21 +5851,23 @@ int priority return; } + long deregisterTouchTick = AdvanceTick(); cache.version++; - _log.Log( - new MessagingRegistration( - handlerOwnerId, - type, - RegistrationType.Deregister, - registrationMethod - ) - ); if ( !sinks.TryGetValue(out handlers) + || !ReferenceEquals(handlers, capturedHandlers) || !handlers.handlers.TryGetValue(priority, out cache) || !cache.handlers.TryGetValue(messageHandler, out count) ) { + if ( + capturedHandlers.handlers.Count == 0 + && !ReferenceEquals(handlers, capturedHandlers) + ) + { + return; + } + if (MessagingDebug.enabled) { MessagingDebug.Log( @@ -4547,11 +5881,21 @@ int priority return; } + _log.Log( + new MessagingRegistration( + handlerOwnerId, + type, + RegistrationType.Deregister, + registrationMethod + ) + ); + Touch(handlers, deregisterTouchTick); handlers.version++; handler = cache.handlers; if (count <= 1) { bool complete = handler.Remove(messageHandler); + MarkDirtyHandler(messageHandler); cache.version++; // do not mutate cache.cache here; let next read rebuild from handlers @@ -4569,7 +5913,7 @@ int priority if (handlers.handlers.Count == 0) { - sinks.Remove(); + MarkDirtyType(); } if (!complete && MessagingDebug.enabled) @@ -4586,7 +5930,7 @@ int priority { handler[messageHandler] = count - 1; } - StageDispatchSnapshot(this, handlers, dispatchCategory); + StageDispatchSnapshot(this, handlers, slotKey); }; } @@ -4604,9 +5948,12 @@ int priority throw new ArgumentNullException(nameof(messageHandler)); } + long touchTick = AdvanceTick(); Dictionary> broadcastHandlers = sinks.GetOrAdd(); - DispatchCategory dispatchCategory = GetDispatchCategory(registrationMethod); + Dictionary> capturedBroadcastHandlers = + broadcastHandlers; + SlotKey slotKey = RegistrationMethodAxes.GetSlotKey(registrationMethod); if ( !broadcastHandlers.TryGetValue( @@ -4618,6 +5965,8 @@ out HandlerCache handlers handlers = new HandlerCache(); broadcastHandlers[context] = handlers; } + Touch(handlers, touchTick); + HandlerCache capturedHandlers = handlers; if (!handlers.handlers.TryGetValue(priority, out HandlerCache cache)) { @@ -4649,7 +5998,7 @@ out HandlerCache handlers registrationMethod ) ); - StageDispatchSnapshot(this, handlers, dispatchCategory); + StageDispatchSnapshot(this, handlers, slotKey); long capturedGeneration = _resetGeneration; return () => @@ -4661,22 +6010,22 @@ out HandlerCache handlers return; } + long deregisterTouchTick = AdvanceTick(); cache.version++; - _log.Log( - new MessagingRegistration( - context, - type, - RegistrationType.Deregister, - registrationMethod - ) - ); if ( !sinks.TryGetValue(out broadcastHandlers) + || !ReferenceEquals(broadcastHandlers, capturedBroadcastHandlers) || !broadcastHandlers.TryGetValue(context, out handlers) + || !ReferenceEquals(handlers, capturedHandlers) || !handlers.handlers.TryGetValue(priority, out cache) || !cache.handlers.TryGetValue(messageHandler, out count) ) { + if (IsStaleContextDeregisterAfterSweep(sinks, context, capturedHandlers)) + { + return; + } + if (MessagingDebug.enabled) { MessagingDebug.Log( @@ -4690,10 +6039,20 @@ out HandlerCache handlers return; } + _log.Log( + new MessagingRegistration( + context, + type, + RegistrationType.Deregister, + registrationMethod + ) + ); + Touch(handlers, deregisterTouchTick); handler = cache.handlers; if (count <= 1) { bool complete = handler.Remove(messageHandler); + MarkDirtyHandler(messageHandler); cache.version++; // do not mutate cache.cache here; let next read rebuild from handlers if (handler.Count == 0) @@ -4711,12 +6070,7 @@ out HandlerCache handlers if (handlers.handlers.Count == 0) { - _ = broadcastHandlers.Remove(context); - } - - if (broadcastHandlers.Count == 0) - { - sinks.Remove(); + MarkDirtyTarget(context); } if (!complete && MessagingDebug.enabled) @@ -4733,26 +6087,44 @@ out HandlerCache handlers { handler[messageHandler] = count - 1; } - StageDispatchSnapshot(this, handlers, dispatchCategory); + StageDispatchSnapshot(this, handlers, slotKey); }; } + private static bool IsStaleContextDeregisterAfterSweep( + MessageCache>> sinks, + InstanceId context, + HandlerCache capturedHandlers + ) + where T : IMessage + { + return capturedHandlers.handlers.Count == 0 + && ( + !sinks.TryGetValue( + out Dictionary> currentByContext + ) + || !currentByContext.TryGetValue( + context, + out HandlerCache currentHandlers + ) + || !ReferenceEquals(currentHandlers, capturedHandlers) + ); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void StageDispatchSnapshot( MessageBus messageBus, HandlerCache handlers, - DispatchCategory category + SlotKey slotKey ) where TMessage : IMessage { - if (handlers == null || category == DispatchCategory.None) + if (handlers == null || slotKey == SlotKey.None) { return; } - HandlerCache.DispatchState state = handlers.GetOrCreateDispatchState( - category - ); + DispatchState state = handlers.dispatchState ??= new DispatchState(); if (state.hasPending) { ReleaseSnapshot(ref state.pending); @@ -4764,17 +6136,23 @@ DispatchCategory category [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void StageGlobalDispatchSnapshot( MessageBus messageBus, - HandlerCache handlers, - DispatchCategory category + BusGlobalSlot handlers, + DispatchKind kind ) where TMessage : IMessage { - if (handlers == null || category == DispatchCategory.None) + // DispatchKind has no None sentinel; the bus only reaches this path + // through register sites that pass a valid kind, so the legacy + // category-None short-circuit is no longer needed -- the + // `handlers == null` guard alone suffices. + if (handlers == null) { return; } - HandlerCache.DispatchState state = handlers.GetOrCreateDispatchState(category); + ref DispatchState slotState = ref SelectGlobalDispatchState(handlers, kind); + slotState ??= new DispatchState(); + DispatchState state = slotState; if (state.hasPending) { ReleaseSnapshot(ref state.pending); @@ -4784,6 +6162,29 @@ DispatchCategory category state.pendingDirty = true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ref DispatchState SelectGlobalDispatchState( + BusGlobalSlot slot, + DispatchKind kind + ) + { + switch (kind) + { + case DispatchKind.Untargeted: + return ref slot.untargetedDispatchState; + case DispatchKind.Targeted: + return ref slot.targetedDispatchState; + case DispatchKind.Broadcast: + return ref slot.broadcastDispatchState; + default: + throw new ArgumentOutOfRangeException( + nameof(kind), + kind, + "SelectGlobalDispatchState only supports Untargeted, Targeted, Broadcast." + ); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void ReleaseSnapshot(ref DispatchSnapshot snapshot) { @@ -4800,7 +6201,7 @@ private static void ReleaseSnapshot(ref DispatchSnapshot snapshot) private static DispatchSnapshot AcquireDispatchSnapshot( MessageBus messageBus, HandlerCache handlers, - DispatchCategory category, + SlotKey slotKey, long emissionId ) where TMessage : IMessage @@ -4810,14 +6211,13 @@ long emissionId return DispatchSnapshot.Empty; } - if (category == DispatchCategory.None) + if (slotKey == SlotKey.None) { return DispatchSnapshot.Empty; } - HandlerCache.DispatchState state = handlers.GetOrCreateDispatchState( - category - ); + Touch(handlers, messageBus._tickCounter); + DispatchState state = handlers.dispatchState ??= new DispatchState(); bool hasHandlers = handlers.handlers.Count > 0; @@ -4827,7 +6227,7 @@ long emissionId { ReleaseSnapshot(ref state.pending); state.pending = hasHandlers - ? BuildDispatchSnapshot(messageBus, handlers, category) + ? BuildDispatchSnapshot(messageBus, handlers, slotKey) : DispatchSnapshot.Empty; state.pendingDirty = false; @@ -4836,7 +6236,7 @@ long emissionId else if (state.active.IsEmpty && hasHandlers) { ReleaseSnapshot(ref state.pending); - state.pending = BuildDispatchSnapshot(messageBus, handlers, category); + state.pending = BuildDispatchSnapshot(messageBus, handlers, slotKey); state.hasPending = true; state.pendingDirty = false; } @@ -4850,7 +6250,7 @@ long emissionId { ReleaseSnapshot(ref state.pending); state.pending = hasHandlers - ? BuildDispatchSnapshot(messageBus, handlers, category) + ? BuildDispatchSnapshot(messageBus, handlers, slotKey) : DispatchSnapshot.Empty; state.pendingDirty = false; @@ -4876,7 +6276,7 @@ long emissionId private static DispatchSnapshot BuildDispatchSnapshot( MessageBus messageBus, HandlerCache handlers, - DispatchCategory category + SlotKey slotKey ) where TMessage : IMessage { @@ -4913,7 +6313,7 @@ DispatchCategory category FillDispatchEntries( messageBus, handlerLookup, - category, + slotKey, priority, entries ); @@ -4926,7 +6326,7 @@ DispatchCategory category private static void FillDispatchEntries( MessageBus messageBus, Dictionary handlerLookup, - DispatchCategory category, + SlotKey slotKey, int priority, DispatchEntry[] entries ) @@ -4937,12 +6337,12 @@ DispatchEntry[] entries return; } - PrefreezeDescriptor prefreeze = CreatePrefreezeDescriptor(category, priority); + PrefreezeDescriptor prefreeze = CreatePrefreezeDescriptor(slotKey, priority); int index = 0; foreach (KeyValuePair kvp in handlerLookup) { MessageHandler messageHandler = kvp.Key; - object dispatch = GetDispatchLink(messageBus, messageHandler, category); + object dispatch = GetDispatchLink(messageBus, messageHandler, slotKey); entries[index++] = new DispatchEntry(messageHandler, dispatch, prefreeze); } if (index < entries.Length) @@ -4954,19 +6354,22 @@ DispatchEntry[] entries [MethodImpl(MethodImplOptions.AggressiveInlining)] private static DispatchSnapshot AcquireGlobalDispatchSnapshot( MessageBus messageBus, - HandlerCache handlers, - DispatchCategory category, + BusGlobalSlot handlers, + DispatchKind kind, long emissionId ) where TMessage : IMessage { - if (handlers == null || category == DispatchCategory.None) + if (handlers == null) { return DispatchSnapshot.Empty; } - HandlerCache.DispatchState state = handlers.GetOrCreateDispatchState(category); - bool hasHandlers = handlers.handlers.Count > 0; + handlers.lastTouchTicks = messageBus._tickCounter; + ref DispatchState slotState = ref SelectGlobalDispatchState(handlers, kind); + slotState ??= new DispatchState(); + DispatchState state = slotState; + bool hasHandlers = handlers.sharedHandlers.Count > 0; if (state.hasPending) { @@ -4978,7 +6381,7 @@ long emissionId state.pending = BuildGlobalDispatchSnapshot( messageBus, handlers, - category + kind ); } else @@ -4992,11 +6395,7 @@ long emissionId else if (state.active.IsEmpty && hasHandlers) { ReleaseSnapshot(ref state.pending); - state.pending = BuildGlobalDispatchSnapshot( - messageBus, - handlers, - category - ); + state.pending = BuildGlobalDispatchSnapshot(messageBus, handlers, kind); state.hasPending = true; state.pendingDirty = false; } @@ -5010,7 +6409,7 @@ long emissionId { ReleaseSnapshot(ref state.pending); state.pending = hasHandlers - ? BuildGlobalDispatchSnapshot(messageBus, handlers, category) + ? BuildGlobalDispatchSnapshot(messageBus, handlers, kind) : DispatchSnapshot.Empty; state.pendingDirty = false; @@ -5035,26 +6434,31 @@ long emissionId private static DispatchSnapshot BuildGlobalDispatchSnapshot( MessageBus messageBus, - HandlerCache handlers, - DispatchCategory category + BusGlobalSlot handlers, + DispatchKind kind ) where TMessage : IMessage { - if (handlers == null || handlers.handlers.Count == 0) + if (handlers == null || handlers.sharedHandlers.Count == 0) { return DispatchSnapshot.Empty; } DispatchBucket[] buckets = DispatchBucketPool.Rent(1); - Dictionary handlerLookup = handlers.handlers; + Dictionary handlerLookup = handlers.sharedHandlers; int entryCount = handlerLookup.Count; DispatchEntry[] entries = DispatchEntryPool.Rent(entryCount); - PrefreezeDescriptor prefreeze = CreatePrefreezeDescriptor(category, 0); + PrefreezeDescriptor prefreeze = CreateGlobalPrefreezeDescriptor(kind, 0); int index = 0; foreach (KeyValuePair kvp in handlerLookup) { MessageHandler messageHandler = kvp.Key; - object dispatch = GetDispatchLink(messageBus, messageHandler, category); + // Global dispatch paths intentionally pass null for the + // dispatch-link argument. GetDispatchLink is no longer reached + // from this code path; inlining null here matches what the + // legacy switch returned for all three Global cases and avoids + // a per-entry call. + object dispatch = null; entries[index++] = new DispatchEntry(messageHandler, dispatch, prefreeze); } @@ -5063,108 +6467,100 @@ DispatchCategory category } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static PrefreezeDescriptor CreatePrefreezeDescriptor( - DispatchCategory category, - int priority - ) + private static PrefreezeDescriptor CreatePrefreezeDescriptor(SlotKey slotKey, int priority) { - switch (category) + if ( + slotKey.Phase != DispatchPhase.Handle + || slotKey.Variant != DispatchVariant.WithoutContext + ) + { + return PrefreezeDescriptor.Empty; + } + switch (slotKey.Kind) { - case DispatchCategory.TargetedWithoutTargeting: + case DispatchKind.Targeted: return new PrefreezeDescriptor( PrefreezeKindTargetedWithoutTargetingHandlers, priority ); - case DispatchCategory.BroadcastWithoutSource: + case DispatchKind.Broadcast: return new PrefreezeDescriptor( PrefreezeKindBroadcastWithoutSourceHandlers, priority ); - case DispatchCategory.GlobalUntargeted: + default: + return PrefreezeDescriptor.Empty; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static PrefreezeDescriptor CreateGlobalPrefreezeDescriptor( + DispatchKind kind, + int priority + ) + { + switch (kind) + { + case DispatchKind.Untargeted: return new PrefreezeDescriptor(PrefreezeKindGlobalUntargetedHandlers, priority); - case DispatchCategory.GlobalTargeted: + case DispatchKind.Targeted: return new PrefreezeDescriptor(PrefreezeKindGlobalTargetedHandlers, priority); - case DispatchCategory.GlobalBroadcast: + case DispatchKind.Broadcast: return new PrefreezeDescriptor(PrefreezeKindGlobalBroadcastHandlers, priority); default: - return PrefreezeDescriptor.Empty; + throw new ArgumentOutOfRangeException( + nameof(kind), + kind, + "CreateGlobalPrefreezeDescriptor only supports Untargeted, Targeted, Broadcast." + ); } } private static object GetDispatchLink( MessageBus messageBus, MessageHandler handler, - DispatchCategory category + SlotKey slotKey ) where TMessage : IMessage { - switch (category) - { - case DispatchCategory.Untargeted: - return handler.GetOrCreateUntargetedDispatchLink(messageBus); - case DispatchCategory.UntargetedPost: - return handler.GetOrCreateUntargetedPostDispatchLink(messageBus); - case DispatchCategory.Targeted: - return handler.GetOrCreateTargetedDispatchLink(messageBus); - case DispatchCategory.TargetedPost: - return handler.GetOrCreateTargetedPostDispatchLink(messageBus); - case DispatchCategory.TargetedWithoutTargeting: - return handler.GetOrCreateTargetedWithoutTargetingDispatchLink( - messageBus - ); - case DispatchCategory.TargetedWithoutTargetingPost: - return handler.GetOrCreateTargetedWithoutTargetingPostDispatchLink( - messageBus - ); - case DispatchCategory.Broadcast: - return handler.GetOrCreateBroadcastDispatchLink(messageBus); - case DispatchCategory.BroadcastPost: - return handler.GetOrCreateBroadcastPostDispatchLink(messageBus); - case DispatchCategory.BroadcastWithoutSource: - return handler.GetOrCreateBroadcastWithoutSourceDispatchLink( - messageBus - ); - case DispatchCategory.BroadcastWithoutSourcePost: - return handler.GetOrCreateBroadcastWithoutSourcePostDispatchLink( - messageBus - ); - case DispatchCategory.GlobalUntargeted: - case DispatchCategory.GlobalTargeted: - case DispatchCategory.GlobalBroadcast: - return null; - default: - return handler.GetOrCreateUntargetedDispatchLink(messageBus); + DispatchKind kind = slotKey.Kind; + DispatchPhase phase = slotKey.Phase; + DispatchVariant variant = slotKey.Variant; + if (kind == DispatchKind.Untargeted) + { + return phase == DispatchPhase.PostProcess + ? handler.GetOrCreateUntargetedPostDispatchLink(messageBus) + : handler.GetOrCreateUntargetedDispatchLink(messageBus); } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static DispatchCategory GetDispatchCategory(RegistrationMethod registrationMethod) - { - switch (registrationMethod) - { - case RegistrationMethod.Untargeted: - return DispatchCategory.Untargeted; - case RegistrationMethod.UntargetedPostProcessor: - return DispatchCategory.UntargetedPost; - case RegistrationMethod.Targeted: - return DispatchCategory.Targeted; - case RegistrationMethod.TargetedPostProcessor: - return DispatchCategory.TargetedPost; - case RegistrationMethod.TargetedWithoutTargeting: - return DispatchCategory.TargetedWithoutTargeting; - case RegistrationMethod.TargetedWithoutTargetingPostProcessor: - return DispatchCategory.TargetedWithoutTargetingPost; - case RegistrationMethod.Broadcast: - return DispatchCategory.Broadcast; - case RegistrationMethod.BroadcastPostProcessor: - return DispatchCategory.BroadcastPost; - case RegistrationMethod.BroadcastWithoutSource: - return DispatchCategory.BroadcastWithoutSource; - case RegistrationMethod.BroadcastWithoutSourcePostProcessor: - return DispatchCategory.BroadcastWithoutSourcePost; - default: - return DispatchCategory.None; + if (kind == DispatchKind.Targeted) + { + if (phase == DispatchPhase.PostProcess) + { + return variant == DispatchVariant.WithoutContext + ? handler.GetOrCreateTargetedWithoutTargetingPostDispatchLink( + messageBus + ) + : handler.GetOrCreateTargetedPostDispatchLink(messageBus); + } + return variant == DispatchVariant.WithoutContext + ? handler.GetOrCreateTargetedWithoutTargetingDispatchLink(messageBus) + : handler.GetOrCreateTargetedDispatchLink(messageBus); + } + if (kind == DispatchKind.Broadcast) + { + if (phase == DispatchPhase.PostProcess) + { + return variant == DispatchVariant.WithoutContext + ? handler.GetOrCreateBroadcastWithoutSourcePostDispatchLink( + messageBus + ) + : handler.GetOrCreateBroadcastPostDispatchLink(messageBus); + } + return variant == DispatchVariant.WithoutContext + ? handler.GetOrCreateBroadcastWithoutSourceDispatchLink(messageBus) + : handler.GetOrCreateBroadcastDispatchLink(messageBus); } + return handler.GetOrCreateUntargetedDispatchLink(messageBus); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index be0d6a31..986b8b9b 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -2,10 +2,13 @@ namespace DxMessaging.Core { using System; using System.Collections.Generic; + using System.Diagnostics; using System.Runtime.CompilerServices; + using DxMessaging.Core.Internal; using Helper; using MessageBus; using Messages; + using Pooling; /// /// Per-owner handler that executes registered message callbacks. @@ -34,6 +37,76 @@ public sealed class MessageHandler IComparable, IComparable { + private static void PrefreezePriorityCache( + TypedHandler handler, + int slotIndex, + int priority, + long emissionId + ) + where TMessage : IMessage + { + Dictionary byPriority = handler.GetPriorityHandlers( + slotIndex + ); + if ( + byPriority != null + && byPriority.TryGetValue(priority, out IHandlerActionCache erasedCache) + && erasedCache is HandlerActionCache cache + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); + cache.prefreezeInvocationCount++; + } + } + + private static void PrefreezeContextCache( + TypedHandler handler, + int slotIndex, + InstanceId context, + int priority, + long emissionId + ) + where TMessage : IMessage + { + Dictionary> byContext = + handler.GetContextHandlers(slotIndex); + if ( + byContext != null + && byContext.TryGetValue( + context, + out Dictionary byPriority + ) + && byPriority.TryGetValue(priority, out IHandlerActionCache erasedCache) + && erasedCache is HandlerActionCache cache + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); + cache.prefreezeInvocationCount++; + } + } + + private static int GetPriorityPrefreezeInvocationCount( + TypedHandler handler, + int slotIndex, + int priority + ) + where TMessage : IMessage + { + Dictionary byPriority = handler.GetPriorityHandlers( + slotIndex + ); + if ( + byPriority != null + && byPriority.TryGetValue(priority, out IHandlerActionCache erasedCache) + && erasedCache is HandlerActionCache cache + ) + { + return cache.prefreezeInvocationCount; + } + + return 0; + } + /// /// Pre-freezes this handler's broadcast post-processor caches for the given message type, source, and priority /// for the specified emission id, so registrations during the same emission are not observed. @@ -56,32 +129,20 @@ IMessageBus messageBus return; } - if ( - handler._broadcastPostProcessingFastHandlers != null - && handler._broadcastPostProcessingFastHandlers.TryGetValue( - source, - out Dictionary>> fastByPriority - ) - && fastByPriority.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._broadcastPostProcessingHandlers != null - && handler._broadcastPostProcessingHandlers.TryGetValue( - source, - out Dictionary>> byPriority - ) - && byPriority.TryGetValue(priority, out HandlerActionCache> cache) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezeContextCache>( + handler, + TypedSlotIndex.BroadcastPostProcessFast, + source, + priority, + emissionId + ); + PrefreezeContextCache>( + handler, + TypedSlotIndex.BroadcastPostProcessDefault, + source, + priority, + emissionId + ); } /// @@ -106,32 +167,20 @@ IMessageBus messageBus return; } - if ( - handler._targetedPostProcessingFastHandlers != null - && handler._targetedPostProcessingFastHandlers.TryGetValue( - target, - out Dictionary>> fastByPriority - ) - && fastByPriority.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._targetedPostProcessingHandlers != null - && handler._targetedPostProcessingHandlers.TryGetValue( - target, - out Dictionary>> byPriority - ) - && byPriority.TryGetValue(priority, out HandlerActionCache> cache) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezeContextCache>( + handler, + TypedSlotIndex.TargetedPostProcessFast, + target, + priority, + emissionId + ); + PrefreezeContextCache>( + handler, + TypedSlotIndex.TargetedPostProcessDefault, + target, + priority, + emissionId + ); } /// @@ -150,27 +199,18 @@ IMessageBus messageBus return; } - if ( - handler._fastTargetedWithoutTargetingHandlers != null - && handler._fastTargetedWithoutTargetingHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._targetedWithoutTargetingHandlers != null - && handler._targetedWithoutTargetingHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.TargetedHandleWithoutContextFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.TargetedHandleWithoutContext, + priority, + emissionId + ); } /// @@ -188,27 +228,18 @@ IMessageBus messageBus return; } - if ( - handler._fastTargetedWithoutTargetingPostProcessingHandlers != null - && handler._fastTargetedWithoutTargetingPostProcessingHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._targetedWithoutTargetingPostProcessingHandlers != null - && handler._targetedWithoutTargetingPostProcessingHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.TargetedPostProcessWithoutContextFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.TargetedPostProcessWithoutContext, + priority, + emissionId + ); } /// @@ -226,27 +257,18 @@ IMessageBus messageBus return; } - if ( - handler._untargetedPostProcessingFastHandlers != null - && handler._untargetedPostProcessingFastHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._untargetedPostProcessingHandlers != null - && handler._untargetedPostProcessingHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.UntargetedPostProcessFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.UntargetedPostProcessDefault, + priority, + emissionId + ); } /// @@ -264,27 +286,18 @@ IMessageBus messageBus return; } - if ( - handler._fastBroadcastWithoutSourcePostProcessingHandlers != null - && handler._fastBroadcastWithoutSourcePostProcessingHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._broadcastWithoutSourcePostProcessingHandlers != null - && handler._broadcastWithoutSourcePostProcessingHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.BroadcastPostProcessWithoutContextFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.BroadcastPostProcessWithoutContext, + priority, + emissionId + ); } /// @@ -307,27 +320,18 @@ IMessageBus messageBus return; } - if ( - handler._fastBroadcastWithoutSourceHandlers != null - && handler._fastBroadcastWithoutSourceHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._broadcastWithoutSourceHandlers != null - && handler._broadcastWithoutSourceHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.BroadcastHandleWithoutContextFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.BroadcastHandleWithoutContext, + priority, + emissionId + ); } /// @@ -350,27 +354,18 @@ IMessageBus messageBus return; } - if ( - handler._untargetedFastHandlers != null - && handler._untargetedFastHandlers.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._untargetedHandlers != null - && handler._untargetedHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezePriorityCache>( + handler, + TypedSlotIndex.UntargetedHandleFast, + priority, + emissionId + ); + PrefreezePriorityCache>( + handler, + TypedSlotIndex.UntargetedHandleDefault, + priority, + emissionId + ); } /// @@ -395,32 +390,20 @@ IMessageBus messageBus return; } - if ( - handler._targetedFastHandlers != null - && handler._targetedFastHandlers.TryGetValue( - target, - out Dictionary>> fastByPriority - ) - && fastByPriority.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._targetedHandlers != null - && handler._targetedHandlers.TryGetValue( - target, - out Dictionary>> byPriority - ) - && byPriority.TryGetValue(priority, out HandlerActionCache> cache) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezeContextCache>( + handler, + TypedSlotIndex.TargetedHandleFast, + target, + priority, + emissionId + ); + PrefreezeContextCache>( + handler, + TypedSlotIndex.TargetedHandleDefault, + target, + priority, + emissionId + ); } /// @@ -445,32 +428,20 @@ IMessageBus messageBus return; } - if ( - handler._broadcastFastHandlers != null - && handler._broadcastFastHandlers.TryGetValue( - source, - out Dictionary>> fastByPriority - ) - && fastByPriority.TryGetValue( - priority, - out HandlerActionCache> fastCache - ) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); - } - - if ( - handler._broadcastHandlers != null - && handler._broadcastHandlers.TryGetValue( - source, - out Dictionary>> byPriority - ) - && byPriority.TryGetValue(priority, out HandlerActionCache> cache) - ) - { - _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); - } + PrefreezeContextCache>( + handler, + TypedSlotIndex.BroadcastHandleFast, + source, + priority, + emissionId + ); + PrefreezeContextCache>( + handler, + TypedSlotIndex.BroadcastHandleDefault, + source, + priority, + emissionId + ); } /// @@ -521,6 +492,19 @@ static MessageHandler() ResetStatics(); } + /// + /// Reclaims empty slots and pooled collections owned by the current global message bus. + /// + /// + /// When true, ignores idle-age thresholds and drains shared pools to zero. + /// When false, only slots past the configured idle threshold are eligible. + /// + /// Counts describing what was reclaimed. + public static IMessageBus.TrimResult TrimAll(bool force = false) + { + return MessageBus.Trim(force); + } + /// /// Replaces the global instance returned by . /// @@ -1071,19 +1055,19 @@ internal void PrefreezeGlobalUntargetedForEmission(long emissionId, IMessageBus return; } - if (handler._globalUntargetedFastHandlers != null) + HandlerActionCache> fastCache = handler.GetGlobalCache< + FastHandler + >(TypedGlobalSlotIndex.UntargetedFast); + if (fastCache != null) { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalUntargetedFastHandlers, - emissionId - ); + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); } - if (handler._globalUntargetedHandlers != null) + HandlerActionCache> cache = handler.GetGlobalCache< + Action + >(TypedGlobalSlotIndex.UntargetedDefault); + if (cache != null) { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalUntargetedHandlers, - emissionId - ); + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); } } @@ -1097,19 +1081,20 @@ internal void PrefreezeGlobalTargetedForEmission(long emissionId, IMessageBus me return; } - if (handler._globalTargetedFastHandlers != null) - { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalTargetedFastHandlers, - emissionId + HandlerActionCache> fastCache = + handler.GetGlobalCache>( + TypedGlobalSlotIndex.TargetedFast ); + if (fastCache != null) + { + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); } - if (handler._globalTargetedHandlers != null) + HandlerActionCache> cache = handler.GetGlobalCache< + Action + >(TypedGlobalSlotIndex.TargetedDefault); + if (cache != null) { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalTargetedHandlers, - emissionId - ); + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); } } @@ -1123,19 +1108,21 @@ internal void PrefreezeGlobalBroadcastForEmission(long emissionId, IMessageBus m return; } - if (handler._globalBroadcastFastHandlers != null) - { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalBroadcastFastHandlers, - emissionId + HandlerActionCache> fastCache = + handler.GetGlobalCache>( + TypedGlobalSlotIndex.BroadcastFast ); - } - if (handler._globalBroadcastHandlers != null) + if (fastCache != null) { - _ = TypedHandler.GetOrAddNewHandlerStack( - handler._globalBroadcastHandlers, - emissionId + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); + } + HandlerActionCache> cache = + handler.GetGlobalCache>( + TypedGlobalSlotIndex.BroadcastDefault ); + if (cache != null) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); } } @@ -1214,25 +1201,28 @@ public Action RegisterGlobalAcceptAll( Action untargetedDeregistration = typedHandler.AddGlobalUntargetedHandler( originalUntargetedMessageHandler, untargetedMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); Action targetedDeregistration = typedHandler.AddGlobalTargetedHandler( originalTargetedMessageHandler, targetedMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); Action broadcastDeregistration = typedHandler.AddGlobalBroadcastHandler( originalBroadcastMessageHandler, broadcastMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); return () => { + messageBusDeregistration?.Invoke(); untargetedDeregistration(); targetedDeregistration(); broadcastDeregistration(); - messageBusDeregistration?.Invoke(); }; void NullDeregistration() @@ -1266,25 +1256,28 @@ public Action RegisterGlobalAcceptAll( Action untargetedDeregistration = typedHandler.AddGlobalUntargetedHandler( originalUntargetedMessageHandler, untargetedMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); Action targetedDeregistration = typedHandler.AddGlobalTargetedHandler( originalTargetedMessageHandler, targetedMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); Action broadcastDeregistration = typedHandler.AddGlobalBroadcastHandler( originalBroadcastMessageHandler, broadcastMessageHandler, - NullDeregistration + NullDeregistration, + messageBus ); return () => { + messageBusDeregistration?.Invoke(); untargetedDeregistration(); targetedDeregistration(); broadcastDeregistration(); - messageBusDeregistration?.Invoke(); }; void NullDeregistration() @@ -1324,7 +1317,7 @@ public Action RegisterTargetedMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1359,7 +1352,7 @@ public Action RegisterTargetedMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1394,7 +1387,7 @@ public Action RegisterTargetedPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1429,7 +1422,7 @@ public Action RegisterTargetedPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1461,7 +1454,7 @@ public Action RegisterTargetedWithoutTargetingPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1493,7 +1486,7 @@ public Action RegisterTargetedWithoutTargetingPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1524,7 +1517,7 @@ public Action RegisterTargetedWithoutTargeting( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1555,7 +1548,7 @@ public Action RegisterTargetedWithoutTargeting( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1586,7 +1579,7 @@ public Action RegisterUntargetedMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1617,7 +1610,7 @@ public Action RegisterUntargetedMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1648,7 +1641,7 @@ public Action RegisterUntargetedPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1679,7 +1672,7 @@ public Action RegisterUntargetedPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1715,7 +1708,7 @@ public Action RegisterSourcedBroadcastMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1750,7 +1743,7 @@ public Action RegisterSourcedBroadcastMessageHandler( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1781,7 +1774,7 @@ public Action RegisterSourcedBroadcastWithoutSource( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1812,7 +1805,7 @@ public Action RegisterSourcedBroadcastWithoutSource( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1847,7 +1840,7 @@ public Action RegisterSourcedBroadcastPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1882,7 +1875,7 @@ public Action RegisterSourcedBroadcastPostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1914,7 +1907,7 @@ public Action RegisterSourcedBroadcastWithoutSourcePostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -1946,7 +1939,7 @@ public Action RegisterSourcedBroadcastWithoutSourcePostProcessor( messageHandler, messageBusDeregistration, priority, - messageBus.EmissionId + messageBus ); } @@ -2139,31 +2132,95 @@ out TypedHandler existingTypedHandler return false; } - internal int GetUntargetedPostProcessingPrefreezeCount( - IMessageBus messageBus, - int priority - ) - where T : IMessage - { - if ( - !GetHandlerForType(messageBus, out TypedHandler handler) - || handler._untargetedPostProcessingFastHandlers == null - ) + /// + /// Resets empty typed-handler slots associated with + /// . P4's eviction layer calls through + /// this erased surface after bus-side slots prove idle and empty. + /// + /// + /// Bus whose typed-handler cache should be swept. Null resolves to + /// this handler's default bus. + /// + /// Number of typed or typed-global slots reset. + internal int ResetEmptyTypedSlotsForSweep(IMessageBus messageBus = null) + { + messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; + if (messageBusIndex < 0 || _handlersByTypeByMessageBus.Count <= messageBusIndex) { return 0; } - if ( - handler._untargetedPostProcessingFastHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) + int resetCount = 0; + foreach (object untypedHandler in _handlersByTypeByMessageBus[messageBusIndex]) { - return cache.prefreezeInvocationCount; + if (untypedHandler is ITypedHandlerSlotSweeper sweeper) + { + resetCount += sweeper.ResetEmptySlotsForSweep(); + } } - return 0; + return resetCount; + } + + internal int ResetAllTypedSlotsForBusReset(IMessageBus messageBus = null) + { + messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; + if (messageBusIndex < 0 || _handlersByTypeByMessageBus.Count <= messageBusIndex) + { + return 0; + } + + int resetCount = 0; + foreach (object untypedHandler in _handlersByTypeByMessageBus[messageBusIndex]) + { + if (untypedHandler is ITypedHandlerSlotSweeper sweeper) + { + resetCount += sweeper.ResetAllSlotsForBusReset(); + } + } + + return resetCount; + } + + internal int CountEmptyTypedSlotsForSweep(IMessageBus messageBus = null) + { + messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; + if (messageBusIndex < 0 || _handlersByTypeByMessageBus.Count <= messageBusIndex) + { + return 0; + } + + int count = 0; + foreach (object untypedHandler in _handlersByTypeByMessageBus[messageBusIndex]) + { + if (untypedHandler is ITypedHandlerSlotSweeper sweeper) + { + count += sweeper.CountEmptySlotsForSweep(); + } + } + + return count; + } + + internal int GetUntargetedPostProcessingPrefreezeCount( + IMessageBus messageBus, + int priority + ) + where T : IMessage + { + if (!GetHandlerForType(messageBus, out TypedHandler handler)) + { + return 0; + } + + return GetPriorityPrefreezeInvocationCount>( + handler, + TypedSlotIndex.UntargetedPostProcessFast, + priority + ); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2264,7 +2321,7 @@ IMessageBus messageBus return typedHandler.GetOrCreateBroadcastWithoutSourcePostLink(); } - internal sealed class HandlerActionCache + internal sealed class HandlerActionCache : DxMessaging.Core.Internal.IHandlerActionCache { internal readonly struct Entry { @@ -2289,6 +2346,62 @@ public Entry(T handler, int count) public long lastSeenVersion = -1; public long lastSeenEmissionId; internal int prefreezeInvocationCount; + + /// Monotonic version field, read-only on the interface surface. + long DxMessaging.Core.Internal.IHandlerActionCache.Version + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => version; + } + + /// Most recent dispatcher-observed version; mutable through the staged dispatch path. + long DxMessaging.Core.Internal.IHandlerActionCache.LastSeenVersion + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastSeenVersion; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => lastSeenVersion = value; + } + + /// Most recent dispatcher-observed bus emission id. + long DxMessaging.Core.Internal.IHandlerActionCache.LastSeenEmissionId + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lastSeenEmissionId; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => lastSeenEmissionId = value; + } + + /// Prefreeze invocation counter mirror; maintained by the dispatchers. + int DxMessaging.Core.Internal.IHandlerActionCache.PrefreezeInvocationCount + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => prefreezeInvocationCount; + } + + /// True iff the entries dictionary holds zero handlers. + bool DxMessaging.Core.Internal.IHandlerActionCache.IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => entries.Count == 0; + } + + /// + /// Eviction-driven full clear; bumps as the LAST step + /// to invalidate any captured dispatch closure (PLAN Risk Register R3). + /// + void DxMessaging.Core.Internal.IHandlerActionCache.Reset() + { + entries.Clear(); + cache.Clear(); + lastSeenVersion = -1; + lastSeenEmissionId = 0; + prefreezeInvocationCount = 0; + unchecked + { + ++version; + } + } } internal sealed class UntargetedDispatchLink @@ -2563,124 +2676,272 @@ long emissionId /// One-size-fits-all wrapper around all possible Messaging sinks for a particular MessageHandler & MessageType. /// /// Message type that this Handler exists to serve. - internal sealed class TypedHandler + internal sealed class TypedHandler : ITypedHandlerSlotSweeper where T : IMessage { + // P3.3 storage: 20 typed slots + 6 global slots + 10 dispatch + // links. The legacy named fields were deleted so new handler + // variants must pick an explicit axis-indexed slot. + internal readonly TypedSlot[] _slots = new TypedSlot[TypedSlotIndex.Length]; + internal readonly TypedGlobalSlot[] _globalSlots = new TypedGlobalSlot[ + TypedGlobalSlotIndex.Length + ]; + internal readonly object[] _dispatchLinks = new object[TypedDispatchLinkIndex.Length]; + + // Constructor exists solely so the [Conditional("DEBUG")] + // validator below runs at construction time. In Release builds + // the Conditional attribute strips the call site, leaving an + // empty constructor body that the JIT collapses to the + // equivalent of the implicit default. Mirrors the + // MessageBus.ValidateSinkArrays() pattern. + internal TypedHandler() + { + ValidateSlotArrays(); + } + + [Conditional("DEBUG")] + private void ValidateSlotArrays() + { + if (_slots.Length != TypedSlotIndex.Length) + { + throw new InvalidOperationException( + $"_slots length is {_slots.Length} but TypedSlotIndex.Length is {TypedSlotIndex.Length}." + ); + } + if (_globalSlots.Length != TypedGlobalSlotIndex.Length) + { + throw new InvalidOperationException( + $"_globalSlots length is {_globalSlots.Length} but TypedGlobalSlotIndex.Length is {TypedGlobalSlotIndex.Length}." + ); + } + if (_dispatchLinks.Length != TypedDispatchLinkIndex.Length) + { + throw new InvalidOperationException( + $"_dispatchLinks length is {_dispatchLinks.Length} but TypedDispatchLinkIndex.Length is {TypedDispatchLinkIndex.Length}." + ); + } + // P3.3 wires lazy registration writers; this assertion still + // holds at construction (slots populate on first register, + // not on construction). The invariant flips meaning -- not + // the message -- when writers land. + for (int i = 0; i < _slots.Length; ++i) + { + if (_slots[i] != null) + { + throw new InvalidOperationException( + $"_slots[{i}] is non-null at construction; expected null per TypedSlotIndex because slots populate lazily on first registration." + ); + } + } + for (int i = 0; i < _globalSlots.Length; ++i) + { + if (_globalSlots[i] != null) + { + throw new InvalidOperationException( + $"_globalSlots[{i}] is non-null at construction; expected null per TypedGlobalSlotIndex because slots populate lazily on first registration." + ); + } + } + for (int i = 0; i < _dispatchLinks.Length; ++i) + { + if (_dispatchLinks[i] != null) + { + throw new InvalidOperationException( + $"_dispatchLinks[{i}] is non-null at construction; expected null per TypedDispatchLinkIndex because links populate lazily on first dispatch-link request." + ); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TypedSlot GetOrCreateSlot(int index, bool requiresContext) + { + TypedSlot slot = _slots[index]; + if (slot == null) + { + slot = new TypedSlot(requiresContext); + _slots[index] = slot; + } + + return slot; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Dictionary GetOrCreatePriorityHandlers( + int index, + bool requiresContext + ) + { + return GetOrCreateSlot(index, requiresContext).byPriority; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Dictionary GetPriorityHandlers(int index) + { + return _slots[index]?.byPriority; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Dictionary< InstanceId, - Dictionary>> - > _targetedHandlers; - internal Dictionary>> _untargetedHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _broadcastHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _targetedPostProcessingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _untargetedPostProcessingHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _broadcastPostProcessingHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _targetedFastHandlers; - internal Dictionary>> _untargetedFastHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _broadcastFastHandlers; - internal Dictionary< - InstanceId, - Dictionary>> - > _targetedPostProcessingFastHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _untargetedPostProcessingFastHandlers; + Dictionary + > GetOrCreateContextHandlers(int index) + { + TypedSlot slot = GetOrCreateSlot(index, requiresContext: true); + slot.byContext ??= DxPools.TypedHandlerContextDicts.Rent(); + return slot.byContext; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Dictionary< InstanceId, - Dictionary>> - > _broadcastPostProcessingFastHandlers; + Dictionary + > GetContextHandlers(int index) + { + return _slots[index]?.byContext; + } - internal HandlerActionCache> _globalUntargetedHandlers; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal TypedGlobalSlot GetOrCreateGlobalSlot(int index) + { + TypedGlobalSlot slot = _globalSlots[index]; + if (slot == null) + { + slot = new TypedGlobalSlot(); + _globalSlots[index] = slot; + } - internal HandlerActionCache< - Action - > _globalTargetedHandlers; + return slot; + } - internal HandlerActionCache< - Action - > _globalBroadcastHandlers; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal HandlerActionCache GetGlobalCache(int index) + { + return _globalSlots[index]?.cache as HandlerActionCache; + } - internal HandlerActionCache< - FastHandler - > _globalUntargetedFastHandlers; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TypedSlot FindPrioritySlot(Dictionary handlers) + { + for (int i = 0; i < _slots.Length; ++i) + { + TypedSlot slot = _slots[i]; + if (slot != null && ReferenceEquals(slot.byPriority, handlers)) + { + return slot; + } + } - internal HandlerActionCache< - FastHandlerWithContext - > _globalTargetedFastHandlers; + return null; + } - internal HandlerActionCache< - FastHandlerWithContext - > _globalBroadcastFastHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _targetedWithoutTargetingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _fastTargetedWithoutTargetingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _broadcastWithoutSourceHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _fastBroadcastWithoutSourceHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _targetedWithoutTargetingPostProcessingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _fastTargetedWithoutTargetingPostProcessingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _broadcastWithoutSourcePostProcessingHandlers; - internal Dictionary< - int, - HandlerActionCache> - > _fastBroadcastWithoutSourcePostProcessingHandlers; - private UntargetedDispatchLink _untargetedLink; - private object _untargetedPostLink; - private object _targetedLink; - private object _targetedPostLink; - private object _targetedWithoutTargetingLink; - private object _targetedWithoutTargetingPostLink; - private object _broadcastLink; - private object _broadcastPostLink; - private object _broadcastWithoutSourceLink; - private object _broadcastWithoutSourcePostLink; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TypedSlot FindContextSlot( + Dictionary> handlersByContext + ) + { + for (int i = 0; i < _slots.Length; ++i) + { + TypedSlot slot = _slots[i]; + if (slot != null && ReferenceEquals(slot.byContext, handlersByContext)) + { + return slot; + } + } + + return null; + } + + int ITypedHandlerSlotSweeper.ResetEmptySlotsForSweep() + { + int resetCount = 0; + for (int i = 0; i < _slots.Length; ++i) + { + TypedSlot slot = _slots[i]; + if (slot != null && slot.IsEmpty) + { + slot.Reset(); + _slots[i] = null; + resetCount++; + } + } + + for (int i = 0; i < _globalSlots.Length; ++i) + { + TypedGlobalSlot slot = _globalSlots[i]; + if (slot != null && slot.IsEmpty) + { + slot.Reset(); + _globalSlots[i] = null; + resetCount++; + } + } + + return resetCount; + } + + int ITypedHandlerSlotSweeper.ResetAllSlotsForBusReset() + { + int resetCount = 0; + for (int i = 0; i < _slots.Length; ++i) + { + TypedSlot slot = _slots[i]; + if (slot != null) + { + slot.Reset(); + _slots[i] = null; + resetCount++; + } + } + + for (int i = 0; i < _globalSlots.Length; ++i) + { + TypedGlobalSlot slot = _globalSlots[i]; + if (slot != null) + { + slot.Reset(); + _globalSlots[i] = null; + resetCount++; + } + } + + return resetCount; + } + + int ITypedHandlerSlotSweeper.CountEmptySlotsForSweep() + { + int count = 0; + for (int i = 0; i < _slots.Length; ++i) + { + TypedSlot slot = _slots[i]; + if (slot != null && slot.IsEmpty) + { + count++; + } + } + + for (int i = 0; i < _globalSlots.Length; ++i) + { + TypedGlobalSlot slot = _globalSlots[i]; + if (slot != null && slot.IsEmpty) + { + count++; + } + } + + return count; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] internal UntargetedDispatchLink GetOrCreateUntargetedLink() { - UntargetedDispatchLink link = _untargetedLink; + UntargetedDispatchLink link = + _dispatchLinks[TypedDispatchLinkIndex.UntargetedHandle] + as UntargetedDispatchLink; if (link == null) { link = new UntargetedDispatchLink(this); - _untargetedLink = link; + _dispatchLinks[TypedDispatchLinkIndex.UntargetedHandle] = link; } return link; @@ -2690,11 +2951,12 @@ internal UntargetedDispatchLink GetOrCreateUntargetedLink() internal UntargetedPostDispatchLink GetOrCreateUntargetedPostLink() { UntargetedPostDispatchLink link = - _untargetedPostLink as UntargetedPostDispatchLink; + _dispatchLinks[TypedDispatchLinkIndex.UntargetedPostProcess] + as UntargetedPostDispatchLink; if (link == null) { link = new UntargetedPostDispatchLink(this); - _untargetedPostLink = link; + _dispatchLinks[TypedDispatchLinkIndex.UntargetedPostProcess] = link; } return link; @@ -2703,11 +2965,13 @@ internal UntargetedPostDispatchLink GetOrCreateUntargetedPostLink() [MethodImpl(MethodImplOptions.AggressiveInlining)] internal TargetedDispatchLink GetOrCreateTargetedLink() { - TargetedDispatchLink link = _targetedLink as TargetedDispatchLink; + TargetedDispatchLink link = + _dispatchLinks[TypedDispatchLinkIndex.TargetedHandle] + as TargetedDispatchLink; if (link == null) { link = new TargetedDispatchLink(this); - _targetedLink = link; + _dispatchLinks[TypedDispatchLinkIndex.TargetedHandle] = link; } return link; @@ -2716,11 +2980,13 @@ internal TargetedDispatchLink GetOrCreateTargetedLink() [MethodImpl(MethodImplOptions.AggressiveInlining)] internal TargetedPostDispatchLink GetOrCreateTargetedPostLink() { - TargetedPostDispatchLink link = _targetedPostLink as TargetedPostDispatchLink; + TargetedPostDispatchLink link = + _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcess] + as TargetedPostDispatchLink; if (link == null) { link = new TargetedPostDispatchLink(this); - _targetedPostLink = link; + _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcess] = link; } return link; @@ -2730,11 +2996,12 @@ internal TargetedPostDispatchLink GetOrCreateTargetedPostLink() internal TargetedWithoutTargetingDispatchLink GetOrCreateTargetedWithoutTargetingLink() { TargetedWithoutTargetingDispatchLink link = - _targetedWithoutTargetingLink as TargetedWithoutTargetingDispatchLink; + _dispatchLinks[TypedDispatchLinkIndex.TargetedHandleWithoutContext] + as TargetedWithoutTargetingDispatchLink; if (link == null) { link = new TargetedWithoutTargetingDispatchLink(this); - _targetedWithoutTargetingLink = link; + _dispatchLinks[TypedDispatchLinkIndex.TargetedHandleWithoutContext] = link; } return link; @@ -2744,12 +3011,12 @@ internal TargetedWithoutTargetingDispatchLink GetOrCreateTargetedWithoutTarge internal TargetedWithoutTargetingPostDispatchLink GetOrCreateTargetedWithoutTargetingPostLink() { TargetedWithoutTargetingPostDispatchLink link = - _targetedWithoutTargetingPostLink + _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcessWithoutContext] as TargetedWithoutTargetingPostDispatchLink; if (link == null) { link = new TargetedWithoutTargetingPostDispatchLink(this); - _targetedWithoutTargetingPostLink = link; + _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcessWithoutContext] = link; } return link; @@ -2758,11 +3025,13 @@ internal TargetedWithoutTargetingPostDispatchLink GetOrCreateTargetedWithoutT [MethodImpl(MethodImplOptions.AggressiveInlining)] internal BroadcastDispatchLink GetOrCreateBroadcastLink() { - BroadcastDispatchLink link = _broadcastLink as BroadcastDispatchLink; + BroadcastDispatchLink link = + _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandle] + as BroadcastDispatchLink; if (link == null) { link = new BroadcastDispatchLink(this); - _broadcastLink = link; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandle] = link; } return link; @@ -2772,11 +3041,12 @@ internal BroadcastDispatchLink GetOrCreateBroadcastLink() internal BroadcastPostDispatchLink GetOrCreateBroadcastPostLink() { BroadcastPostDispatchLink link = - _broadcastPostLink as BroadcastPostDispatchLink; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcess] + as BroadcastPostDispatchLink; if (link == null) { link = new BroadcastPostDispatchLink(this); - _broadcastPostLink = link; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcess] = link; } return link; @@ -2786,11 +3056,12 @@ internal BroadcastPostDispatchLink GetOrCreateBroadcastPostLink() internal BroadcastWithoutSourceDispatchLink GetOrCreateBroadcastWithoutSourceLink() { BroadcastWithoutSourceDispatchLink link = - _broadcastWithoutSourceLink as BroadcastWithoutSourceDispatchLink; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandleWithoutContext] + as BroadcastWithoutSourceDispatchLink; if (link == null) { link = new BroadcastWithoutSourceDispatchLink(this); - _broadcastWithoutSourceLink = link; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandleWithoutContext] = link; } return link; @@ -2800,11 +3071,13 @@ internal BroadcastWithoutSourceDispatchLink GetOrCreateBroadcastWithoutSource internal BroadcastWithoutSourcePostDispatchLink GetOrCreateBroadcastWithoutSourcePostLink() { BroadcastWithoutSourcePostDispatchLink link = - _broadcastWithoutSourcePostLink as BroadcastWithoutSourcePostDispatchLink; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcessWithoutContext] + as BroadcastWithoutSourcePostDispatchLink; if (link == null) { link = new BroadcastWithoutSourcePostDispatchLink(this); - _broadcastWithoutSourcePostLink = link; + _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcessWithoutContext] = + link; } return link; @@ -2817,19 +3090,18 @@ internal BroadcastWithoutSourcePostDispatchLink GetOrCreateBroadcastWithoutSo /// Priority at which to run the handlers. public void HandleUntargeted(ref T message, int priority, long emissionId) { - PrefreezeHandlersForEmission( - _untargetedPostProcessingFastHandlers, + RunFastHandlers( + GetPriorityHandlers(TypedSlotIndex.UntargetedHandleFast), + ref message, priority, emissionId ); - PrefreezeHandlersForEmission( - _untargetedPostProcessingHandlers, + RunHandlers( + GetPriorityHandlers(TypedSlotIndex.UntargetedHandleDefault), + ref message, priority, emissionId ); - - RunFastHandlers(_untargetedFastHandlers, ref message, priority, emissionId); - RunHandlers(_untargetedHandlers, ref message, priority, emissionId); } /// @@ -2847,14 +3119,14 @@ long emissionId { RunFastHandlersWithContext( ref target, - _targetedFastHandlers, + GetContextHandlers(TypedSlotIndex.TargetedHandleFast), ref message, priority, emissionId ); RunHandlersWithContext( ref target, - _targetedHandlers, + GetContextHandlers(TypedSlotIndex.TargetedHandleDefault), ref message, priority, emissionId @@ -2876,14 +3148,14 @@ long emissionId { RunFastHandlers( ref target, - _fastTargetedWithoutTargetingHandlers, + GetPriorityHandlers(TypedSlotIndex.TargetedHandleWithoutContextFast), ref message, priority, emissionId ); RunHandlers( ref target, - _targetedWithoutTargetingHandlers, + GetPriorityHandlers(TypedSlotIndex.TargetedHandleWithoutContext), ref message, priority, emissionId @@ -2905,14 +3177,14 @@ long emissionId { RunFastHandlersWithContext( ref source, - _broadcastFastHandlers, + GetContextHandlers(TypedSlotIndex.BroadcastHandleFast), ref message, priority, emissionId ); RunHandlersWithContext( ref source, - _broadcastHandlers, + GetContextHandlers(TypedSlotIndex.BroadcastHandleDefault), ref message, priority, emissionId @@ -2934,14 +3206,14 @@ long emissionId { RunFastHandlers( ref source, - _fastBroadcastWithoutSourceHandlers, + GetPriorityHandlers(TypedSlotIndex.BroadcastHandleWithoutContextFast), ref message, priority, emissionId ); RunHandlers( ref source, - _broadcastWithoutSourceHandlers, + GetPriorityHandlers(TypedSlotIndex.BroadcastHandleWithoutContext), ref message, priority, emissionId @@ -2954,14 +3226,20 @@ long emissionId /// Message to emit. public void HandleGlobalUntargeted(ref IUntargetedMessage message, long emissionId) { - RunFastHandlers(_globalUntargetedFastHandlers, ref message, emissionId); - if (_globalUntargetedHandlers?.entries is not { Count: > 0 }) + HandlerActionCache> fastCache = GetGlobalCache< + FastHandler + >(TypedGlobalSlotIndex.UntargetedFast); + RunFastHandlers(fastCache, ref message, emissionId); + HandlerActionCache> cache = GetGlobalCache< + Action + >(TypedGlobalSlotIndex.UntargetedDefault); + if (cache?.entries is not { Count: > 0 }) { return; } List> handlers = GetOrAddNewHandlerStack( - _globalUntargetedHandlers, + cache, emissionId ); int handlersCount = handlers.Count; @@ -2982,15 +3260,22 @@ public void HandleGlobalTargeted( long emissionId ) { - RunFastHandlers(ref target, _globalTargetedFastHandlers, ref message, emissionId); + HandlerActionCache> fastCache = + GetGlobalCache>( + TypedGlobalSlotIndex.TargetedFast + ); + RunFastHandlers(ref target, fastCache, ref message, emissionId); - if (_globalTargetedHandlers?.entries is not { Count: > 0 }) + HandlerActionCache> cache = GetGlobalCache< + Action + >(TypedGlobalSlotIndex.TargetedDefault); + if (cache?.entries is not { Count: > 0 }) { return; } List> handlers = GetOrAddNewHandlerStack( - _globalTargetedHandlers, + cache, emissionId ); int handlersCount = handlers.Count; @@ -3011,15 +3296,22 @@ public void HandleGlobalBroadcast( long emissionId ) { - RunFastHandlers(ref source, _globalBroadcastFastHandlers, ref message, emissionId); + HandlerActionCache> fastCache = + GetGlobalCache>( + TypedGlobalSlotIndex.BroadcastFast + ); + RunFastHandlers(ref source, fastCache, ref message, emissionId); - if (_globalBroadcastHandlers?.entries is not { Count: > 0 }) + HandlerActionCache> cache = GetGlobalCache< + Action + >(TypedGlobalSlotIndex.BroadcastDefault); + if (cache?.entries is not { Count: > 0 }) { return; } List> handlers = GetOrAddNewHandlerStack( - _globalBroadcastHandlers, + cache, emissionId ); int handlersCount = handlers.Count; @@ -3077,12 +3369,17 @@ long emissionId public void HandleUntargetedPostProcessing(ref T message, int priority, long emissionId) { RunFastHandlers( - _untargetedPostProcessingFastHandlers, + GetPriorityHandlers(TypedSlotIndex.UntargetedPostProcessFast), + ref message, + priority, + emissionId + ); + RunHandlers( + GetPriorityHandlers(TypedSlotIndex.UntargetedPostProcessDefault), ref message, priority, emissionId ); - RunHandlers(_untargetedPostProcessingHandlers, ref message, priority, emissionId); } /// @@ -3101,14 +3398,14 @@ long emissionId { RunFastHandlersWithContext( ref target, - _targetedPostProcessingFastHandlers, + GetContextHandlers(TypedSlotIndex.TargetedPostProcessFast), ref message, priority, emissionId ); RunHandlersWithContext( ref target, - _targetedPostProcessingHandlers, + GetContextHandlers(TypedSlotIndex.TargetedPostProcessDefault), ref message, priority, emissionId @@ -3131,14 +3428,14 @@ long emissionId { RunFastHandlersWithContext( ref target, - _fastTargetedWithoutTargetingPostProcessingHandlers, + GetPriorityHandlers(TypedSlotIndex.TargetedPostProcessWithoutContextFast), ref message, priority, emissionId ); RunHandlers( ref target, - _targetedWithoutTargetingPostProcessingHandlers, + GetPriorityHandlers(TypedSlotIndex.TargetedPostProcessWithoutContext), ref message, priority, emissionId @@ -3161,14 +3458,14 @@ long emissionId { RunFastHandlersWithContext( ref source, - _broadcastPostProcessingFastHandlers, + GetContextHandlers(TypedSlotIndex.BroadcastPostProcessFast), ref message, priority, emissionId ); RunHandlersWithContext( ref source, - _broadcastPostProcessingHandlers, + GetContextHandlers(TypedSlotIndex.BroadcastPostProcessDefault), ref message, priority, emissionId @@ -3191,14 +3488,14 @@ long emissionId { RunFastHandlersWithContext( ref source, - _fastBroadcastWithoutSourcePostProcessingHandlers, + GetPriorityHandlers(TypedSlotIndex.BroadcastPostProcessWithoutContextFast), ref message, priority, emissionId ); RunHandlers( ref source, - _broadcastWithoutSourcePostProcessingHandlers, + GetPriorityHandlers(TypedSlotIndex.BroadcastPostProcessWithoutContext), ref message, priority, emissionId @@ -3219,17 +3516,17 @@ public Action AddTargetedHandler( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( target, - ref _targetedHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.TargetedHandleDefault), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3247,17 +3544,17 @@ public Action AddTargetedHandler( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( target, - ref _targetedFastHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.TargetedHandleFast), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3273,16 +3570,19 @@ public Action AddTargetedWithoutTargetingHandler( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _targetedWithoutTargetingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.TargetedHandleWithoutContext, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3298,16 +3598,19 @@ public Action AddTargetedWithoutTargetingHandler( FastHandlerWithContext handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _fastTargetedWithoutTargetingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.TargetedHandleWithoutContextFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3323,16 +3626,19 @@ public Action AddUntargetedHandler( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _untargetedHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.UntargetedHandleDefault, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3348,16 +3654,19 @@ public Action AddUntargetedHandler( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _untargetedFastHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.UntargetedHandleFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3375,17 +3684,17 @@ public Action AddSourcedBroadcastHandler( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( source, - ref _broadcastHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.BroadcastHandleDefault), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3403,17 +3712,17 @@ public Action AddSourcedBroadcastHandler( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( source, - ref _broadcastFastHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.BroadcastHandleFast), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3429,17 +3738,20 @@ public Action AddSourcedBroadcastWithoutSourceHandler( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { // Preserve the priority bucket during the current emission so frozen snapshots remain valid return AddHandlerPreservingPriorityKey( - ref _broadcastWithoutSourceHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.BroadcastHandleWithoutContext, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3455,17 +3767,20 @@ public Action AddSourcedBroadcastWithoutSourceHandler( FastHandlerWithContext handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { // Preserve the priority bucket during the current emission so frozen snapshots remain valid return AddHandlerPreservingPriorityKey( - ref _fastBroadcastWithoutSourceHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.BroadcastHandleWithoutContextFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3478,14 +3793,16 @@ long emissionId public Action AddGlobalUntargetedHandler( Action originalHandler, Action handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalUntargetedHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.UntargetedDefault), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3498,14 +3815,16 @@ Action deregistration public Action AddGlobalUntargetedHandler( FastHandler originalHandler, FastHandler handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalUntargetedFastHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.UntargetedFast), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3518,14 +3837,16 @@ Action deregistration public Action AddGlobalTargetedHandler( Action originalHandler, Action handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalTargetedHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.TargetedDefault), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3538,14 +3859,16 @@ Action deregistration public Action AddGlobalTargetedHandler( FastHandlerWithContext originalHandler, FastHandlerWithContext handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalTargetedFastHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.TargetedFast), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3558,14 +3881,16 @@ Action deregistration public Action AddGlobalBroadcastHandler( Action originalHandler, Action handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalBroadcastHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.BroadcastDefault), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3578,14 +3903,16 @@ Action deregistration public Action AddGlobalBroadcastHandler( FastHandlerWithContext originalHandler, FastHandlerWithContext handler, - Action deregistration + Action deregistration, + IMessageBus messageBus ) { return AddHandler( - ref _globalBroadcastFastHandlers, + GetOrCreateGlobalSlot(TypedGlobalSlotIndex.BroadcastFast), originalHandler, handler, - deregistration + deregistration, + messageBus ); } @@ -3601,16 +3928,19 @@ public Action AddUntargetedPostProcessor( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _untargetedPostProcessingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.UntargetedPostProcessDefault, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3626,16 +3956,19 @@ public Action AddUntargetedPostProcessor( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _untargetedPostProcessingFastHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.UntargetedPostProcessFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3653,17 +3986,17 @@ public Action AddTargetedPostProcessor( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( target, - ref _targetedPostProcessingHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.TargetedPostProcessDefault), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3681,17 +4014,17 @@ public Action AddTargetedPostProcessor( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( target, - ref _targetedPostProcessingFastHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.TargetedPostProcessFast), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3707,16 +4040,19 @@ public Action AddTargetedWithoutTargetingPostProcessor( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _targetedWithoutTargetingPostProcessingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.TargetedPostProcessWithoutContext, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3732,16 +4068,19 @@ public Action AddTargetedWithoutTargetingPostProcessor( FastHandlerWithContext handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _fastTargetedWithoutTargetingPostProcessingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.TargetedPostProcessWithoutContextFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3759,17 +4098,17 @@ public Action AddBroadcastPostProcessor( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( source, - ref _broadcastPostProcessingHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.BroadcastPostProcessDefault), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3787,17 +4126,17 @@ public Action AddBroadcastPostProcessor( FastHandler handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( source, - ref _broadcastPostProcessingFastHandlers, + GetOrCreateContextHandlers(TypedSlotIndex.BroadcastPostProcessFast), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3813,16 +4152,19 @@ public Action AddBroadcastWithoutSourcePostProcessor( Action handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _broadcastWithoutSourcePostProcessingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.BroadcastPostProcessWithoutContext, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3838,16 +4180,19 @@ public Action AddBroadcastWithoutSourcePostProcessor( FastHandlerWithContext handler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { return AddHandlerPreservingPriorityKey( - ref _fastBroadcastWithoutSourcePostProcessingHandlers, + GetOrCreatePriorityHandlers( + TypedSlotIndex.BroadcastPostProcessWithoutContextFast, + requiresContext: false + ), originalHandler, handler, deregistration, priority, - emissionId + messageBus ); } @@ -3866,6 +4211,150 @@ long emissionId // MessageHandlers or routing through AddSourcedBroadcastWithoutSourceHandler / // AddTargetedWithoutTargetingHandler to avoid the per-(context,priority) // outer-dictionary growth. + private Action AddHandlerPreservingPriorityKey( + InstanceId context, + Dictionary> handlersByContext, + TU originalHandler, + TU augmentedHandler, + Action deregistration, + int priority, + IMessageBus messageBus + ) + { + if ( + !handlersByContext.TryGetValue( + context, + out Dictionary sortedHandlers + ) + ) + { + sortedHandlers = DxPools.TypedHandlerPriorityDicts.Rent(); + handlersByContext[context] = sortedHandlers; + } + + if ( + !sortedHandlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache cache + ) + { + cache = new HandlerActionCache(); + sortedHandlers[priority] = cache; + } + + if ( + !cache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry entry + ) + ) + { + entry = new HandlerActionCache.Entry(augmentedHandler, 0); + } + + bool firstRegistration = entry.count == 0; + entry = firstRegistration + ? new HandlerActionCache.Entry(augmentedHandler, 1) + : new HandlerActionCache.Entry(entry.handler, entry.count + 1); + + cache.entries[originalHandler] = entry; + cache.version++; + TypedSlot slot = FindContextSlot(handlersByContext); + if (slot != null) + { + slot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( + messageBus + ); + } + if (firstRegistration && slot != null) + { + slot.liveCount++; + } + + Dictionary< + InstanceId, + Dictionary + > localHandlersByContext = handlersByContext; + TypedSlot localSlot = slot; + long localSlotVersion = slot?.version ?? 0; + long localResetGeneration = + global::DxMessaging.Core.MessageBus.MessageBus.GetResetGeneration(messageBus); + + return () => + { + if ( + !global::DxMessaging.Core.MessageBus.MessageBus.IsResetGenerationCurrent( + messageBus, + localResetGeneration + ) + ) + { + return; + } + + if (localSlot != null && localSlot.version != localSlotVersion) + { + return; + } + + if (!localHandlersByContext.TryGetValue(context, out sortedHandlers)) + { + return; + } + + if ( + !sortedHandlers.TryGetValue( + priority, + out IHandlerActionCache localErasedCache + ) || localErasedCache is not HandlerActionCache localCache + ) + { + return; + } + + if ( + !localCache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry localEntry + ) + ) + { + return; + } + + localCache.version++; + + deregistration?.Invoke(); + if (localSlot != null) + { + localSlot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( + messageBus + ); + } + + if (localEntry.count <= 1) + { + _ = localCache.entries.Remove(originalHandler); + localCache.version++; + if (localSlot != null) + { + localSlot.liveCount--; + } + // Deliberately keep the priority and context mappings to preserve + // frozen snapshots for the current emission. + return; + } + + localEntry = new HandlerActionCache.Entry( + localEntry.handler, + localEntry.count - 1 + ); + + localCache.entries[originalHandler] = localEntry; + }; + } + private static Action AddHandlerPreservingPriorityKey( InstanceId context, ref Dictionary< @@ -3876,7 +4365,7 @@ ref Dictionary< TU augmentedHandler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { handlersByContext ??= @@ -3968,6 +4457,41 @@ out HandlerActionCache.Entry localEntry }; } + private static void RunFastHandlersWithContext( + ref InstanceId context, + Dictionary fastHandlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + RunFastHandlers(ref context, fastHandlers, ref message, priority, emissionId); + } + + private static void RunFastHandlersWithContext( + ref InstanceId context, + Dictionary> fastHandlersByContext, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + if ( + fastHandlersByContext is not { Count: > 0 } + || !fastHandlersByContext.TryGetValue( + context, + out Dictionary cache + ) + ) + { + return; + } + + RunFastHandlers(cache, ref message, priority, emissionId); + } + private static void RunFastHandlersWithContext( ref InstanceId context, Dictionary< @@ -4021,7 +4545,7 @@ out Dictionary>> cache } private static void RunFastHandlers( - Dictionary>> fastHandlers, + Dictionary fastHandlers, ref TMessage message, int priority, long emissionId @@ -4034,10 +4558,8 @@ long emissionId } if ( - !fastHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) + !fastHandlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache> cache ) { return; @@ -4091,21 +4613,31 @@ out HandlerActionCache> cache } } - private static void RunFastHandlers( - HandlerActionCache> cache, + private static void RunFastHandlers( + Dictionary>> fastHandlers, ref TMessage message, + int priority, long emissionId ) where TMessage : IMessage - where TU : IMessage { - if (cache?.entries is not { Count: > 0 }) + if (fastHandlers is not { Count: > 0 }) { return; } - ref TU typedMessage = ref Unsafe.As(ref message); - List> handlers = GetOrAddNewHandlerStack(cache, emissionId); + if ( + !fastHandlers.TryGetValue( + priority, + out HandlerActionCache> cache + ) + ) + { + return; + } + + ref T typedMessage = ref Unsafe.As(ref message); + List> handlers = GetOrAddNewHandlerStack(cache, emissionId); int handlersCount = handlers.Count; switch (handlersCount) { @@ -4153,8 +4685,7 @@ long emissionId } private static void RunFastHandlers( - ref InstanceId context, - HandlerActionCache> cache, + HandlerActionCache> cache, ref TMessage message, long emissionId ) @@ -4167,77 +4698,190 @@ long emissionId } ref TU typedMessage = ref Unsafe.As(ref message); - List> handlers = GetOrAddNewHandlerStack( - cache, - emissionId - ); + List> handlers = GetOrAddNewHandlerStack(cache, emissionId); int handlersCount = handlers.Count; switch (handlersCount) { case 1: { - handlers[0](ref context, ref typedMessage); + handlers[0](ref typedMessage); return; } case 2: { - handlers[0](ref context, ref typedMessage); - handlers[1](ref context, ref typedMessage); + handlers[0](ref typedMessage); + handlers[1](ref typedMessage); return; } case 3: { - handlers[0](ref context, ref typedMessage); - handlers[1](ref context, ref typedMessage); - handlers[2](ref context, ref typedMessage); + handlers[0](ref typedMessage); + handlers[1](ref typedMessage); + handlers[2](ref typedMessage); return; } case 4: { - handlers[0](ref context, ref typedMessage); - handlers[1](ref context, ref typedMessage); - handlers[2](ref context, ref typedMessage); - handlers[3](ref context, ref typedMessage); + handlers[0](ref typedMessage); + handlers[1](ref typedMessage); + handlers[2](ref typedMessage); + handlers[3](ref typedMessage); return; } case 5: { - handlers[0](ref context, ref typedMessage); - handlers[1](ref context, ref typedMessage); - handlers[2](ref context, ref typedMessage); - handlers[3](ref context, ref typedMessage); - handlers[4](ref context, ref typedMessage); + handlers[0](ref typedMessage); + handlers[1](ref typedMessage); + handlers[2](ref typedMessage); + handlers[3](ref typedMessage); + handlers[4](ref typedMessage); return; } } for (int i = 0; i < handlersCount; ++i) { - handlers[i](ref context, ref typedMessage); + handlers[i](ref typedMessage); } } private static void RunFastHandlers( ref InstanceId context, - Dictionary>> fastHandlers, + HandlerActionCache> cache, ref TMessage message, - int priority, long emissionId ) where TMessage : IMessage where TU : IMessage { - if (fastHandlers is not { Count: > 0 }) - { - return; - } - - if ( - !fastHandlers.TryGetValue( - priority, - out HandlerActionCache> cache - ) - ) + if (cache?.entries is not { Count: > 0 }) + { + return; + } + + ref TU typedMessage = ref Unsafe.As(ref message); + List> handlers = GetOrAddNewHandlerStack( + cache, + emissionId + ); + int handlersCount = handlers.Count; + switch (handlersCount) + { + case 1: + { + handlers[0](ref context, ref typedMessage); + return; + } + case 2: + { + handlers[0](ref context, ref typedMessage); + handlers[1](ref context, ref typedMessage); + return; + } + case 3: + { + handlers[0](ref context, ref typedMessage); + handlers[1](ref context, ref typedMessage); + handlers[2](ref context, ref typedMessage); + return; + } + case 4: + { + handlers[0](ref context, ref typedMessage); + handlers[1](ref context, ref typedMessage); + handlers[2](ref context, ref typedMessage); + handlers[3](ref context, ref typedMessage); + return; + } + case 5: + { + handlers[0](ref context, ref typedMessage); + handlers[1](ref context, ref typedMessage); + handlers[2](ref context, ref typedMessage); + handlers[3](ref context, ref typedMessage); + handlers[4](ref context, ref typedMessage); + return; + } + } + + for (int i = 0; i < handlersCount; ++i) + { + handlers[i](ref context, ref typedMessage); + } + } + + private static void RunFastHandlers( + ref InstanceId context, + Dictionary fastHandlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + if (fastHandlers is not { Count: > 0 }) + { + return; + } + + if ( + !fastHandlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache> cache + ) + { + return; + } + + RunFastHandlers(ref context, cache, ref message, emissionId); + } + + private static void RunFastHandlers( + ref InstanceId context, + Dictionary fastHandlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + where TU : IMessage + { + if (fastHandlers is not { Count: > 0 }) + { + return; + } + + if ( + !fastHandlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache> cache + ) + { + return; + } + + RunFastHandlers(ref context, cache, ref message, emissionId); + } + + private static void RunFastHandlers( + ref InstanceId context, + Dictionary>> fastHandlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + where TU : IMessage + { + if (fastHandlers is not { Count: > 0 }) + { + return; + } + + if ( + !fastHandlers.TryGetValue( + priority, + out HandlerActionCache> cache + ) + ) { return; } @@ -4293,6 +4937,29 @@ out HandlerActionCache> cache } } + private static void RunHandlersWithContext( + ref InstanceId context, + Dictionary> handlersByContext, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + if ( + handlersByContext is not { Count: > 0 } + || !handlersByContext.TryGetValue( + context, + out Dictionary cache + ) + ) + { + return; + } + + RunHandlers(cache, ref message, priority, emissionId); + } + private static void RunHandlersWithContext( ref InstanceId context, Dictionary< @@ -4319,6 +4986,75 @@ out Dictionary>> cache RunHandlers(cache, ref message, priority, emissionId); } + private static void RunHandlers( + Dictionary sortedHandlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + if (sortedHandlers is not { Count: > 0 }) + { + return; + } + + if ( + !sortedHandlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache> cache + ) + { + return; + } + + List> handlers = GetOrAddNewHandlerStack(cache, emissionId); + ref T typedMessage = ref Unsafe.As(ref message); + int handlersCount = handlers.Count; + switch (handlersCount) + { + case 1: + { + handlers[0](typedMessage); + return; + } + case 2: + { + handlers[0](typedMessage); + handlers[1](typedMessage); + return; + } + case 3: + { + handlers[0](typedMessage); + handlers[1](typedMessage); + handlers[2](typedMessage); + return; + } + case 4: + { + handlers[0](typedMessage); + handlers[1](typedMessage); + handlers[2](typedMessage); + handlers[3](typedMessage); + return; + } + case 5: + { + handlers[0](typedMessage); + handlers[1](typedMessage); + handlers[2](typedMessage); + handlers[3](typedMessage); + handlers[4](typedMessage); + return; + } + } + + for (int i = 0; i < handlersCount; ++i) + { + handlers[i](typedMessage); + } + } + private static void RunHandlers( Dictionary>> sortedHandlers, ref TMessage message, @@ -4385,6 +5121,79 @@ long emissionId } } + private static void RunHandlers( + ref InstanceId context, + Dictionary handlers, + ref TMessage message, + int priority, + long emissionId + ) + where TMessage : IMessage + { + if (handlers is not { Count: > 0 }) + { + return; + } + + if ( + !handlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache> cache + ) + { + return; + } + + List> typedHandlers = GetOrAddNewHandlerStack( + cache, + emissionId + ); + ref T typedMessage = ref Unsafe.As(ref message); + int handlersCount = typedHandlers.Count; + switch (handlersCount) + { + case 1: + { + typedHandlers[0](context, typedMessage); + return; + } + case 2: + { + typedHandlers[0](context, typedMessage); + typedHandlers[1](context, typedMessage); + return; + } + case 3: + { + typedHandlers[0](context, typedMessage); + typedHandlers[1](context, typedMessage); + typedHandlers[2](context, typedMessage); + return; + } + case 4: + { + typedHandlers[0](context, typedMessage); + typedHandlers[1](context, typedMessage); + typedHandlers[2](context, typedMessage); + typedHandlers[3](context, typedMessage); + return; + } + case 5: + { + typedHandlers[0](context, typedMessage); + typedHandlers[1](context, typedMessage); + typedHandlers[2](context, typedMessage); + typedHandlers[3](context, typedMessage); + typedHandlers[4](context, typedMessage); + return; + } + } + + for (int i = 0; i < handlersCount; ++i) + { + typedHandlers[i](context, typedMessage); + } + } + private static void RunHandlers( ref InstanceId context, Dictionary>> handlers, @@ -4487,6 +5296,23 @@ long emissionId return actionCache.cache; } + private static void PrefreezeHandlersForEmission( + Dictionary handlers, + int priority, + long emissionId + ) + { + if ( + handlers != null + && handlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + && erasedCache is HandlerActionCache cache + ) + { + cache.prefreezeInvocationCount++; + _ = GetOrAddNewHandlerStack(cache, emissionId); + } + } + private static void PrefreezeHandlersForEmission( Dictionary> handlers, int priority, @@ -4503,6 +5329,102 @@ long emissionId } } + private static Action AddHandler( + TypedGlobalSlot slot, + TU originalHandler, + TU augmentedHandler, + Action deregistration, + IMessageBus messageBus + ) + { + slot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick(messageBus); + HandlerActionCache cache = slot.cache as HandlerActionCache; + if (cache == null) + { + cache = new HandlerActionCache(); + slot.cache = cache; + } + + if ( + !cache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry entry + ) + ) + { + entry = new HandlerActionCache.Entry(augmentedHandler, 0); + } + + bool firstRegistration = entry.count == 0; + entry = firstRegistration + ? new HandlerActionCache.Entry(augmentedHandler, 1) + : new HandlerActionCache.Entry(entry.handler, entry.count + 1); + + cache.entries[originalHandler] = entry; + cache.version++; + if (firstRegistration) + { + slot.liveCount++; + } + + HandlerActionCache localCache = cache; + TypedGlobalSlot localSlot = slot; + long localSlotVersion = slot.version; + long localResetGeneration = + global::DxMessaging.Core.MessageBus.MessageBus.GetResetGeneration(messageBus); + + return () => + { + if ( + !global::DxMessaging.Core.MessageBus.MessageBus.IsResetGenerationCurrent( + messageBus, + localResetGeneration + ) + ) + { + return; + } + + if (localSlot.version != localSlotVersion) + { + return; + } + + if ( + !localCache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry localEntry + ) + ) + { + return; + } + + localCache.version++; + + deregistration?.Invoke(); + localSlot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( + messageBus + ); + + if (localEntry.count <= 1) + { + _ = localCache.entries.Remove(originalHandler); + localCache.version++; + localSlot.liveCount--; + return; + } + + localEntry = new HandlerActionCache.Entry( + localEntry.handler, + localEntry.count - 1 + ); + localCache.entries[originalHandler] = localEntry; + }; + } + private static Action AddHandler( InstanceId context, ref Dictionary< @@ -4513,7 +5435,7 @@ ref Dictionary< TU augmentedHandler, Action deregistration, int priority, - long emissionId + IMessageBus messageBus ) { handlersByContext ??= @@ -4754,6 +5676,134 @@ out HandlerActionCache.Entry localEntry // Variant of AddHandler that preserves the priority key in the dictionary when the last entry is removed. // This ensures that during an in-flight emission (where handler stacks are already frozen), // subsequent removals do not cause lookups to fail for the current pass. + private Action AddHandlerPreservingPriorityKey( + Dictionary handlers, + TU originalHandler, + TU augmentedHandler, + Action deregistration, + int priority, + IMessageBus messageBus + ) + { + if ( + !handlers.TryGetValue(priority, out IHandlerActionCache erasedCache) + || erasedCache is not HandlerActionCache cache + ) + { + cache = new HandlerActionCache(); + handlers[priority] = cache; + } + + if ( + !cache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry entry + ) + ) + { + entry = new HandlerActionCache.Entry(augmentedHandler, 0); + } + + bool firstRegistration = entry.count == 0; + entry = firstRegistration + ? new HandlerActionCache.Entry(augmentedHandler, 1) + : new HandlerActionCache.Entry(entry.handler, entry.count + 1); + + cache.entries[originalHandler] = entry; + cache.version++; + TypedSlot slot = FindPrioritySlot(handlers); + if (slot != null) + { + slot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( + messageBus + ); + } + if (slot != null && !slot.orderedPriorities.Contains(priority)) + { + slot.orderedPriorities.Add(priority); + } + if (firstRegistration && slot != null) + { + slot.liveCount++; + } + + Dictionary localHandlers = handlers; + TypedSlot localSlot = slot; + long localSlotVersion = slot?.version ?? 0; + long localResetGeneration = + global::DxMessaging.Core.MessageBus.MessageBus.GetResetGeneration(messageBus); + + return () => + { + if ( + !global::DxMessaging.Core.MessageBus.MessageBus.IsResetGenerationCurrent( + messageBus, + localResetGeneration + ) + ) + { + return; + } + + if (localSlot != null && localSlot.version != localSlotVersion) + { + return; + } + + if ( + !localHandlers.TryGetValue( + priority, + out IHandlerActionCache localErasedCache + ) || localErasedCache is not HandlerActionCache localCache + ) + { + return; + } + + if ( + !localCache.entries.TryGetValue( + originalHandler, + out HandlerActionCache.Entry localEntry + ) + ) + { + return; + } + + localCache.version++; + + deregistration?.Invoke(); + if (localSlot != null) + { + localSlot.lastTouchTicks = + global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( + messageBus + ); + } + + if (localEntry.count <= 1) + { + _ = localCache.entries.Remove(originalHandler); + localCache.version++; + if (localSlot != null) + { + localSlot.liveCount--; + } + // Intentionally DO NOT remove the priority key here to preserve + // the cache handle during an in-flight emission. + return; + } + + localEntry = new HandlerActionCache.Entry( + localEntry.handler, + localEntry.count - 1 + ); + + localCache.entries[originalHandler] = localEntry; + }; + } + private static Action AddHandlerPreservingPriorityKey( ref Dictionary> handlers, TU originalHandler, diff --git a/Runtime/Core/Pooling.meta b/Runtime/Core/Pooling.meta new file mode 100644 index 00000000..d51f4af9 --- /dev/null +++ b/Runtime/Core/Pooling.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: fc38a1d26fb3ceb14f2c2fa031b313a1 +timeCreated: 1777840186 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/CollectionPool.cs b/Runtime/Core/Pooling/CollectionPool.cs new file mode 100644 index 00000000..91918ed8 --- /dev/null +++ b/Runtime/Core/Pooling/CollectionPool.cs @@ -0,0 +1,266 @@ +namespace DxMessaging.Core.Pooling +{ + using System; + using System.Collections.Generic; + + /// + /// Single-threaded object pool with bounded capacity and either LRU or LIFO + /// recycle order. Used by to recycle the dictionaries, + /// lists, sets, and stacks that DxMessaging slots rent and return on slot + /// reset. Not thread-safe by design -- the entire dispatch path is single- + /// threaded. + /// + /// + /// Adapted from https://github.com/wallstop/unity-helpers/blob/de22dcd22fd98d4fe8c7aa8e70814496698681f7/Runtime/Utils/Buffers.cs + /// (single-threaded portion only -- the unity-helpers version supports + /// thread-static caches we do not need here). + /// + internal sealed class CollectionPool + where T : class, new() + { + private readonly Func _factory; + private readonly Action _onRecycled; // called once when a returned entry is accepted into the pool + private readonly Action _onEvicted; // called when an entry is dropped (cap overflow or trim) + private bool _useLru; + private int _maxRetained; + + // LRU state -- Queue is FIFO with O(1) Enqueue/Dequeue (amortized) + // and avoids the per-node allocation that LinkedList incurs. + private readonly Queue _lruQueue; + private readonly HashSet _lruMembership; + + // LIFO state + private readonly Stack _stack; + private readonly HashSet _stackMembership; + + private readonly int _ownerThreadId = Environment.CurrentManagedThreadId; + + private long _hits; + private long _misses; + private long _evictions; + + public CollectionPool( + int maxRetained, + bool useLru, + Func factory, + Action onRecycled = null, + Action onEvicted = null + ) + { + if (maxRetained < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxRetained)); + } + _maxRetained = maxRetained; + _useLru = useLru; + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _onRecycled = onRecycled; + _onEvicted = onEvicted; + int initialCapacity = Math.Min(maxRetained, 32); + _lruQueue = new Queue(initialCapacity); + _lruMembership = new HashSet(); + _stack = new Stack(initialCapacity); + _stackMembership = new HashSet(); + } + + /// Current cached count. + public int Count => _useLru ? _lruQueue.Count : _stack.Count; + + /// Whether this pool evicts oldest returned entries first. + public bool UseLru + { + get => _useLru; + set + { + AssertOwnerThread(); + if (_useLru == value) + { + return; + } + if (value) + { + ConvertStackToLru(); + } + else + { + ConvertLruToStack(); + } + _useLru = value; + } + } + + /// Soft cap on retained entries. Mutating this may evict. + public int MaxRetained + { + get => _maxRetained; + set + { + AssertOwnerThread(); + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _maxRetained = value; + EvictDownTo(value); + } + } + + public T Rent() + { + AssertOwnerThread(); + if (_useLru) + { + if (_lruQueue.Count > 0) + { + T pooled = _lruQueue.Dequeue(); + _lruMembership.Remove(pooled); + _hits++; + return pooled; + } + } + else if (_stack.Count > 0) + { + T pooled = _stack.Pop(); + _stackMembership.Remove(pooled); + _hits++; + return pooled; + } + _misses++; + T fresh = _factory(); + if (fresh == null) + { + throw new InvalidOperationException("CollectionPool factory returned null."); + } + return fresh; + } + + public void Return(T value) + { + AssertOwnerThread(); + if (value == null) + { + return; + } + if (_maxRetained == 0) + { + _evictions++; + _onEvicted?.Invoke(value); + return; + } + if (_useLru) + { + if (_lruMembership.Contains(value)) + { + return; // already pooled; ignore double-return + } + if (_lruQueue.Count >= _maxRetained) + { + T head = _lruQueue.Dequeue(); + _lruMembership.Remove(head); + _evictions++; + _onEvicted?.Invoke(head); + } + _onRecycled?.Invoke(value); + _lruQueue.Enqueue(value); + _lruMembership.Add(value); + } + else + { + if (_stackMembership.Contains(value)) + { + return; // already pooled; ignore double-return + } + if (_stack.Count >= _maxRetained) + { + _evictions++; + _onEvicted?.Invoke(value); + return; + } + _onRecycled?.Invoke(value); + _stack.Push(value); + _stackMembership.Add(value); + } + } + + /// Trim the pool to . Returns count evicted. + public int Trim(int targetSize) + { + AssertOwnerThread(); + if (targetSize < 0) + { + targetSize = 0; + } + return EvictDownTo(targetSize); + } + + public CollectionPoolDiagnostics Snapshot() + { + return new CollectionPoolDiagnostics(Count, _hits, _misses, _evictions); + } + + private int EvictDownTo(int targetSize) + { + int evicted = 0; + if (_useLru) + { + while (_lruQueue.Count > targetSize) + { + T head = _lruQueue.Dequeue(); + _lruMembership.Remove(head); + _evictions++; + evicted++; + _onEvicted?.Invoke(head); + } + } + else + { + while (_stack.Count > targetSize) + { + T item = _stack.Pop(); + _stackMembership.Remove(item); + _evictions++; + evicted++; + _onEvicted?.Invoke(item); + } + } + return evicted; + } + + private void ConvertLruToStack() + { + while (_lruQueue.Count > 0) + { + T item = _lruQueue.Dequeue(); + _lruMembership.Remove(item); + _stack.Push(item); + _stackMembership.Add(item); + } + } + + private void ConvertStackToLru() + { + T[] items = _stack.ToArray(); + _stack.Clear(); + _stackMembership.Clear(); + for (int index = items.Length - 1; index >= 0; index--) + { + T item = items[index]; + _lruQueue.Enqueue(item); + _lruMembership.Add(item); + } + } + + [System.Diagnostics.Conditional("DEBUG")] + private void AssertOwnerThread() + { + if (Environment.CurrentManagedThreadId != _ownerThreadId) + { + throw new InvalidOperationException( + "CollectionPool<" + + typeof(T).Name + + "> is single-threaded; accessed from foreign thread." + ); + } + } + } +} diff --git a/Runtime/Core/Pooling/CollectionPool.cs.meta b/Runtime/Core/Pooling/CollectionPool.cs.meta new file mode 100644 index 00000000..c37c05ac --- /dev/null +++ b/Runtime/Core/Pooling/CollectionPool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 61e4386511c8d9d20ab1c8be9cf95805 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs b/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs new file mode 100644 index 00000000..24cb8c86 --- /dev/null +++ b/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs @@ -0,0 +1,30 @@ +namespace DxMessaging.Core.Pooling +{ + /// + /// Snapshot of a single pool's lifetime counters and current cached count. + /// All values are point-in-time; reading them does not reset the underlying + /// counters. Useful for leak-watcher style assertions and for debug overlays. + /// + internal readonly struct CollectionPoolDiagnostics + { + /// Number of entries currently retained by the pool. + public readonly int Cached; + + /// Lifetime count of Rent calls that returned a pooled entry. + public readonly long Hits; + + /// Lifetime count of Rent calls that allocated a fresh entry. + public readonly long Misses; + + /// Lifetime count of pooled entries dropped due to cap or LRU eviction. + public readonly long Evictions; + + internal CollectionPoolDiagnostics(int cached, long hits, long misses, long evictions) + { + Cached = cached; + Hits = hits; + Misses = misses; + Evictions = evictions; + } + } +} diff --git a/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs.meta b/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs.meta new file mode 100644 index 00000000..a87a5f5d --- /dev/null +++ b/Runtime/Core/Pooling/CollectionPoolDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 852381a5e8ddb842edb761718afc1f9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/DxPools.cs b/Runtime/Core/Pooling/DxPools.cs new file mode 100644 index 00000000..41f20d10 --- /dev/null +++ b/Runtime/Core/Pooling/DxPools.cs @@ -0,0 +1,135 @@ +namespace DxMessaging.Core.Pooling +{ + using System.Collections.Generic; + using DxMessaging.Core.Internal; +#if UNITY_2021_3_OR_NEWER + using Configuration; +#endif + + /// + /// Static, single-threaded pool registry shared by the bus and handler + /// subsystems. Instantiated lazily on first use; reconfigured by + /// when the runtime settings asset changes. + /// + /// + /// Each pool returns the per-entry recycle action that clears the entry + /// before re-use, so callers do not need to remember to Clear() + /// before Return. OnEvicted currently does nothing -- entries + /// are dropped on cap overflow and the GC reclaims them. + /// + internal static class DxPools + { + // Mirrors DxMessagingRuntimeSettings.DefaultBufferMaxDistinctEntries; updated by Configure(). + // Kept as a local constant so DxPools' field initializers don't depend on Unity types. + private const int DefaultMaxRetained = 512; + + internal static readonly CollectionPool> InstanceIdDicts = + new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new Dictionary(), + onRecycled: dict => dict.Clear() + ); + + internal static readonly CollectionPool> ObjectLists = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new List(), + onRecycled: list => list.Clear() + ); + + internal static readonly CollectionPool> ObjectStacks = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new Stack(), + onRecycled: stack => stack.Clear() + ); + + internal static readonly CollectionPool> IntSets = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new HashSet(), + onRecycled: set => set.Clear() + ); + + internal static readonly CollectionPool< + Dictionary> + > TypedHandlerContextDicts = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new Dictionary>(), + onRecycled: dict => dict.Clear() + ); + + internal static readonly CollectionPool< + Dictionary + > TypedHandlerPriorityDicts = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new Dictionary(), + onRecycled: dict => dict.Clear() + ); + + /// + /// Trim every pool. drains pools to zero; + /// otherwise each pool is trimmed to its current cap (a no-op unless the + /// cap was lowered). Returns total evicted count. + /// + internal static int TrimAll(bool force) + { + int evicted = 0; + evicted += InstanceIdDicts.Trim(force ? 0 : InstanceIdDicts.MaxRetained); + evicted += ObjectLists.Trim(force ? 0 : ObjectLists.MaxRetained); + evicted += ObjectStacks.Trim(force ? 0 : ObjectStacks.MaxRetained); + evicted += IntSets.Trim(force ? 0 : IntSets.MaxRetained); + evicted += TypedHandlerContextDicts.Trim( + force ? 0 : TypedHandlerContextDicts.MaxRetained + ); + evicted += TypedHandlerPriorityDicts.Trim( + force ? 0 : TypedHandlerPriorityDicts.MaxRetained + ); + return evicted; + } + +#if UNITY_2021_3_OR_NEWER + /// + /// Re-apply caps from the supplied settings. Lowering a cap immediately + /// trims; raising a cap takes effect on subsequent returns. + /// + internal static void Configure(DxMessagingRuntimeSettings settings) + { + if (settings == null) + { + throw new System.ArgumentNullException(nameof(settings)); + } + int cap = settings.BufferMaxDistinctEntries; + bool useLru = settings.BufferUseLruEviction; + InstanceIdDicts.UseLru = useLru; + ObjectLists.UseLru = useLru; + ObjectStacks.UseLru = useLru; + IntSets.UseLru = useLru; + TypedHandlerContextDicts.UseLru = useLru; + TypedHandlerPriorityDicts.UseLru = useLru; + InstanceIdDicts.MaxRetained = cap; + ObjectLists.MaxRetained = cap; + ObjectStacks.MaxRetained = cap; + IntSets.MaxRetained = cap; + TypedHandlerContextDicts.MaxRetained = cap; + TypedHandlerPriorityDicts.MaxRetained = cap; + } +#endif + + /// Aggregate snapshot of every pool's diagnostics. + internal static PoolDiagnosticsSnapshot DescribeAll() + { + return new PoolDiagnosticsSnapshot( + InstanceIdDicts.Snapshot(), + ObjectLists.Snapshot(), + ObjectStacks.Snapshot(), + IntSets.Snapshot(), + TypedHandlerContextDicts.Snapshot(), + TypedHandlerPriorityDicts.Snapshot() + ); + } + } +} diff --git a/Runtime/Core/Pooling/DxPools.cs.meta b/Runtime/Core/Pooling/DxPools.cs.meta new file mode 100644 index 00000000..1f22bf0f --- /dev/null +++ b/Runtime/Core/Pooling/DxPools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c15b462050f33ab6fd3c588a3cec8943 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs b/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs new file mode 100644 index 00000000..5d0baeb8 --- /dev/null +++ b/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs @@ -0,0 +1,106 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Core.Pooling +{ + using System; + using MessageBus; + using UnityEngine; + using UnityEngine.LowLevel; + using UnityEngine.PlayerLoop; + + /// + /// Unity PlayerLoop bridge that gives idle eviction a cadence even when no + /// messages are emitted. + /// + internal static class EvictionPlayerLoopHook + { + private static readonly Type HookType = typeof(EvictionPlayerLoopHook); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Install() + { + PlayerLoopSystem root = PlayerLoop.GetCurrentPlayerLoop(); + if (InstallInto(ref root)) + { + PlayerLoop.SetPlayerLoop(root); + } + } + + internal static bool InstallInto(ref PlayerLoopSystem root) + { + if (ContainsHook(root)) + { + return false; + } + + PlayerLoopSystem hook = new PlayerLoopSystem + { + type = HookType, + updateDelegate = SweepIdleBuses, + }; + + return InsertUnder(ref root, hook); + } + + private static void SweepIdleBuses() + { + MessageBus.SweepIdleBusesFromPlayerLoop(); + } + + internal static bool ContainsHook(PlayerLoopSystem system) + { + if (system.type == HookType) + { + return true; + } + + PlayerLoopSystem[] subsystems = system.subSystemList; + if (subsystems == null) + { + return false; + } + + for (int i = 0; i < subsystems.Length; ++i) + { + if (ContainsHook(subsystems[i])) + { + return true; + } + } + + return false; + } + + private static bool InsertUnder(ref PlayerLoopSystem system, PlayerLoopSystem hook) + { + if (system.type == typeof(TTarget)) + { + PlayerLoopSystem[] oldList = + system.subSystemList ?? Array.Empty(); + PlayerLoopSystem[] newList = new PlayerLoopSystem[oldList.Length + 1]; + Array.Copy(oldList, newList, oldList.Length); + newList[oldList.Length] = hook; + system.subSystemList = newList; + return true; + } + + PlayerLoopSystem[] subsystems = system.subSystemList; + if (subsystems == null) + { + return false; + } + + for (int i = 0; i < subsystems.Length; ++i) + { + PlayerLoopSystem child = subsystems[i]; + if (InsertUnder(ref child, hook)) + { + subsystems[i] = child; + return true; + } + } + + return false; + } + } +} +#endif diff --git a/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs.meta b/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs.meta new file mode 100644 index 00000000..822efb06 --- /dev/null +++ b/Runtime/Core/Pooling/EvictionPlayerLoopHook.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 602373063d7d7c9ffa4ad551b255fa20 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/IDxMessagingClock.cs b/Runtime/Core/Pooling/IDxMessagingClock.cs new file mode 100644 index 00000000..3554c492 --- /dev/null +++ b/Runtime/Core/Pooling/IDxMessagingClock.cs @@ -0,0 +1,18 @@ +namespace DxMessaging.Core.Pooling +{ + /// + /// Abstraction over a monotonic wall-clock used by the eviction sweeper to + /// decide whether enough time has elapsed since the last sweep. Implementations + /// must be cheap (single field read or call) because every Emit consults the + /// clock. + /// + public interface IDxMessagingClock + { + /// + /// Current time in seconds. Must be non-decreasing within a single + /// AppDomain. Implementations may have frame-grained or millisecond-grained + /// resolution; the eviction sweeper tolerates either. + /// + double NowSeconds { get; } + } +} diff --git a/Runtime/Core/Pooling/IDxMessagingClock.cs.meta b/Runtime/Core/Pooling/IDxMessagingClock.cs.meta new file mode 100644 index 00000000..98346b01 --- /dev/null +++ b/Runtime/Core/Pooling/IDxMessagingClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4281977f55958ae6077358d9bd484a8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs new file mode 100644 index 00000000..0540fd2e --- /dev/null +++ b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs @@ -0,0 +1,45 @@ +namespace DxMessaging.Core.Pooling +{ + /// + /// Aggregate snapshot of every pool. Returned by + /// . Pool names are stable, human-readable + /// strings safe to log. + /// + internal readonly struct PoolDiagnosticsSnapshot + { + /// Dictionary<InstanceId, object> pool diagnostics. + public readonly CollectionPoolDiagnostics InstanceIdDicts; + + /// List<object> pool diagnostics. + public readonly CollectionPoolDiagnostics ObjectLists; + + /// Stack<object> pool diagnostics. + public readonly CollectionPoolDiagnostics ObjectStacks; + + /// HashSet<int> pool diagnostics. + public readonly CollectionPoolDiagnostics IntSets; + + /// Typed handler InstanceId -> priority-cache dictionary pool diagnostics. + public readonly CollectionPoolDiagnostics TypedHandlerContextDicts; + + /// Typed handler priority-cache dictionary pool diagnostics. + public readonly CollectionPoolDiagnostics TypedHandlerPriorityDicts; + + internal PoolDiagnosticsSnapshot( + CollectionPoolDiagnostics instanceIdDicts, + CollectionPoolDiagnostics objectLists, + CollectionPoolDiagnostics objectStacks, + CollectionPoolDiagnostics intSets, + CollectionPoolDiagnostics typedHandlerContextDicts, + CollectionPoolDiagnostics typedHandlerPriorityDicts + ) + { + InstanceIdDicts = instanceIdDicts; + ObjectLists = objectLists; + ObjectStacks = objectStacks; + IntSets = intSets; + TypedHandlerContextDicts = typedHandlerContextDicts; + TypedHandlerPriorityDicts = typedHandlerPriorityDicts; + } + } +} diff --git a/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs.meta b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs.meta new file mode 100644 index 00000000..c3087b6e --- /dev/null +++ b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6ccd3d03e5816684d483eff0c333db9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/StopwatchClock.cs b/Runtime/Core/Pooling/StopwatchClock.cs new file mode 100644 index 00000000..aa571b45 --- /dev/null +++ b/Runtime/Core/Pooling/StopwatchClock.cs @@ -0,0 +1,22 @@ +namespace DxMessaging.Core.Pooling +{ + using System.Diagnostics; + + /// + /// Default backed by a process-lifetime + /// . Monotonic and Unity-agnostic so it works in + /// EditMode, PlayMode, and standalone test harnesses. + /// + public sealed class StopwatchClock : IDxMessagingClock + { + /// Shared instance. Stopwatch is lock-free and safe to share. + public static readonly StopwatchClock Instance = new(); + + private static readonly double TicksToSeconds = 1.0 / Stopwatch.Frequency; + + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + + /// + public double NowSeconds => _stopwatch.ElapsedTicks * TicksToSeconds; + } +} diff --git a/Runtime/Core/Pooling/StopwatchClock.cs.meta b/Runtime/Core/Pooling/StopwatchClock.cs.meta new file mode 100644 index 00000000..cba95202 --- /dev/null +++ b/Runtime/Core/Pooling/StopwatchClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75463706ed3c6bed1cd6ad8c4a6a2540 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Pooling/UnityRealtimeClock.cs b/Runtime/Core/Pooling/UnityRealtimeClock.cs new file mode 100644 index 00000000..92f822f5 --- /dev/null +++ b/Runtime/Core/Pooling/UnityRealtimeClock.cs @@ -0,0 +1,26 @@ +namespace DxMessaging.Core.Pooling +{ +#if UNITY_2021_3_OR_NEWER + using UnityEngine; + + /// + /// Unity-only backed by + /// . Use this when sweep cadence should + /// follow Unity wall time rather than the AppDomain Stopwatch (Stopwatch keeps + /// running across editor pause; Time.realtimeSinceStartup also runs across + /// pause but is the canonical Unity clock). + /// + /// + /// Must be invoked from the Unity main thread; the underlying Time + /// API throws when called from worker threads. + /// + public sealed class UnityRealtimeClock : IDxMessagingClock + { + /// Shared instance. + public static readonly UnityRealtimeClock Instance = new(); + + /// + public double NowSeconds => Time.realtimeSinceStartupAsDouble; + } +#endif +} diff --git a/Runtime/Core/Pooling/UnityRealtimeClock.cs.meta b/Runtime/Core/Pooling/UnityRealtimeClock.cs.meta new file mode 100644 index 00000000..72468b8f --- /dev/null +++ b/Runtime/Core/Pooling/UnityRealtimeClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df867cc00ae023604ee7f8b6a7aa5c7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Allocations/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs index f8edb041..670e479e 100644 --- a/Tests/Editor/Allocations/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -6,6 +6,7 @@ namespace DxMessaging.Tests.Editor.Allocations using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Pooling; using DxMessaging.Tests.Editor.Benchmarks; using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Messages; @@ -102,6 +103,14 @@ public sealed class AllocationMatrixTests : BenchmarkTestBase /// private const long PerDeregistrationByteBudget = 256L; + /// + /// Cumulative allocation budget for 32 trim calls after warm-up. Trim + /// can perform small fixed bookkeeping work while walking dirty + /// candidates, but repeated calls must stay bounded and independent of + /// normal dispatch hot-path allocations. + /// + private const long TrimAllocBudget = 4 * 1024L; + /// /// Per-call allocation budget for a single registration on the /// diagnostics-augmented path. The closure inside @@ -529,6 +538,102 @@ MessageScenario scenario ); } + /// + /// Pins explicit forced trim to a small bounded allocation budget + /// across the same 32-iteration measurement window used by the emit + /// allocation assertions. The setup creates a fresh dirty empty slot + /// for the selected message kind and prewarms one trim call so any + /// one-time bookkeeping does not contaminate the measured loop. + /// + [Test] + [Category("Allocation")] + public void TrimIsBoundedAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + _ = bus.Trim(force: true); + CreateFreshTrimCandidate(scenario, token, emit); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetAllocatedBytesForCurrentThread(); + IMessageBus.TrimResult result = default; + int evictedSlots = 0; + for (int i = 0; i < AllocationAssertions.DefaultMeasuredIterations; ++i) + { + result = bus.Trim(force: true); + evictedSlots += result.TypeSlotsEvicted + result.TargetSlotsEvicted; + } + long after = GC.GetAllocatedBytesForCurrentThread(); + long delta = after - before; + long perTrim = delta / AllocationAssertions.DefaultMeasuredIterations; + + Assert.That( + delta, + Is.LessThanOrEqualTo(TrimAllocBudget), + $"Trim-{scenario.Kind} allocated {delta} bytes; " + + $"({perTrim} avg/trim) across " + + $"{AllocationAssertions.DefaultMeasuredIterations} trims; " + + $"budget is {TrimAllocBudget} bytes. " + + $"Result: type evicted={result.TypeSlotsEvicted}, " + + $"target evicted={result.TargetSlotsEvicted}, " + + $"pooled evicted={result.PooledCollectionsEvicted}, " + + $"live type slots={result.LiveTypeSlotsRemaining}." + ); + Assert.Greater( + evictedSlots, + 0, + $"Trim-{scenario.Kind} must reclaim at least one slot during the measured loop." + ); + } + ); + } + + /// + /// Pins zero-allocation emission after registering several handlers, + /// deregistering half of them, and running a non-force trim. This + /// covers the handoff where partial trim bookkeeping observes dirty + /// candidates while the remaining live routes must still emit on the + /// hot path without allocating. + /// + [Test] + [Category("Allocation")] + public void EmitAfterPartialTrimIsZeroAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + List handles = RegisterManyHandlers( + scenario, + token, + count: 8 + ); + for (int i = 0; i < handles.Count / 2; ++i) + { + token.RemoveRegistration(handles[i]); + } + + _ = bus.Trim(force: false); + + AllocationAssertions.AssertNoAllocations( + $"EmitAfterPartialTrim-{scenario.Kind}", + emit + ); + } + ); + } + public static IEnumerable DiagnosticsOnScenarios { get @@ -582,7 +687,13 @@ Action body throw new ArgumentNullException(nameof(body)); } - MessageBus bus = new MessageBus(); + MessageBus bus = new MessageBus( + StopwatchClock.Instance, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: double.PositiveInfinity, + idleEvictionEnabled: false, + trimApiEnabled: true + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); try @@ -597,6 +708,32 @@ Action body } } + private static void CreateFreshTrimCandidate( + MessageScenario scenario, + MessageRegistrationToken token, + Action emit + ) + { + MessageRegistrationHandle handle = RegisterHandler(scenario, token); + emit(); + token.RemoveRegistration(handle); + } + + private static List RegisterManyHandlers( + MessageScenario scenario, + MessageRegistrationToken token, + int count + ) + { + List handles = new List(count); + for (int i = 0; i < count; ++i) + { + handles.Add(RegisterHandler(scenario, token, priority: i)); + } + + return handles; + } + private static MessageRegistrationHandle RegisterHandler( MessageScenario scenario, MessageRegistrationToken token, diff --git a/Tests/Editor/Contract.meta b/Tests/Editor/Contract.meta new file mode 100644 index 00000000..bdbda6b2 --- /dev/null +++ b/Tests/Editor/Contract.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 004fd5146702794306fcb9df62e9323c +timeCreated: 1777840186 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs new file mode 100644 index 00000000..9092e520 --- /dev/null +++ b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs @@ -0,0 +1,256 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.MessageBus.Internal; + using NUnit.Framework; + + /// + /// Contract guardrails for the + /// invariant wired in Phase P2.5 of the memory reclamation plan. The + /// counter must remain in lockstep with + /// BusGlobalSlot.sharedHandlers.Count across every register and + /// deregister flow exercised through + /// , so that + /// can be a single integer compare + /// without losing fidelity for re-registration / partial-deregistration + /// / over-deregistration cases. + /// + /// + /// The bus's _globalSlots field is private readonly; the + /// fixture reads it via reflection so the slot's + /// getter can be exercised directly + /// in addition to the public + /// count + /// (InternalsVisibleTo on the runtime asmdef makes + /// itself reachable). Reflection is also + /// used so the contract holds even when the public getter contract is + /// later refactored. + /// + [TestFixture] + [Category("Contract")] + public sealed class BusGlobalSlotLiveCountTests + { + private static readonly InstanceId HandlerOwnerA = new InstanceId(0x4E61_5841); + private static readonly InstanceId HandlerOwnerB = new InstanceId(0x4E61_5842); + + private static readonly FieldInfo GlobalSlotsField = typeof(MessageBus).GetField( + "_globalSlots", + BindingFlags.Instance | BindingFlags.NonPublic + ); + + [Test] + public void LiveCountStartsAtZero() + { + MessageBus bus = new MessageBus(); + Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + + [Test] + public void LiveCountIncrementsOnFirstRegistration() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + _ = bus.RegisterGlobalAcceptAll(handler); + + Assert.AreEqual(1, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(1, slot.liveCount); + Assert.IsFalse(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void LiveCountStaysOneOnDuplicateRegistrationFromSameHandler() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + _ = bus.RegisterGlobalAcceptAll(handler); + _ = bus.RegisterGlobalAcceptAll(handler); + + // The dictionary refcount goes 0 -> 1 -> 2; only the 0 -> 1 + // transition advances liveCount. + Assert.AreEqual(1, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(1, slot.liveCount); + Assert.IsFalse(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void LiveCountIncrementsForDistinctHandlers() + { + MessageBus bus = new MessageBus(); + MessageHandler handlerA = new MessageHandler(HandlerOwnerA, bus) { active = true }; + MessageHandler handlerB = new MessageHandler(HandlerOwnerB, bus) { active = true }; + try + { + _ = bus.RegisterGlobalAcceptAll(handlerA); + _ = bus.RegisterGlobalAcceptAll(handlerB); + + Assert.AreEqual(2, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(2, slot.liveCount); + Assert.IsFalse(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handlerA.active = false; + handlerB.active = false; + } + } + + [Test] + public void LiveCountDecrementsOnFinalDeregistration() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + Action dereg = bus.RegisterGlobalAcceptAll(handler); + Assert.AreEqual(1, bus.RegisteredGlobalAcceptAll); + + dereg(); + + Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void LiveCountStaysAfterPartialDeregistration() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + Action dereg1 = bus.RegisterGlobalAcceptAll(handler); + _ = bus.RegisterGlobalAcceptAll(handler); + + // Refcount: 0 -> 1 -> 2; liveCount went 0 -> 1, stays 1. + Assert.AreEqual(1, bus.RegisteredGlobalAcceptAll); + + dereg1(); + + // Refcount: 2 -> 1; the dictionary entry is still present, so + // liveCount must stay 1 (only the final 1 -> 0 transition + // decrements it). + Assert.AreEqual(1, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(1, slot.liveCount); + Assert.IsFalse(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void LiveCountUnchangedOnOverDeregistration() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + Action previousLog = MessagingDebug.LogFunction; + try + { + MessagingDebug.LogFunction = (_, _) => { }; + + Action dereg = bus.RegisterGlobalAcceptAll(handler); + dereg(); + + Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.AreEqual(0, slot.liveCount); + slot.DebugAssertLiveCountInvariant(); + + // Second invocation is over-deregistration: the early-exit + // branch must NOT decrement liveCount. + dereg(); + + Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); + slot = ReadGlobalSlot(bus); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + MessagingDebug.LogFunction = previousLog; + handler.active = false; + } + } + + [Test] + public void IsEmptyTracksLiveCount() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + BusGlobalSlot slot = ReadGlobalSlot(bus); + Assert.IsTrue(slot.IsEmpty, "Fresh slot must report IsEmpty."); + + Action dereg = bus.RegisterGlobalAcceptAll(handler); + slot = ReadGlobalSlot(bus); + Assert.IsFalse( + slot.IsEmpty, + "After registration liveCount > 0, IsEmpty must be false." + ); + slot.DebugAssertLiveCountInvariant(); + + dereg(); + slot = ReadGlobalSlot(bus); + Assert.IsTrue( + slot.IsEmpty, + "After final deregistration liveCount == 0, IsEmpty must be true." + ); + slot.DebugAssertLiveCountInvariant(); + } + finally + { + handler.active = false; + } + } + + private static BusGlobalSlot ReadGlobalSlot(MessageBus bus) + { + Assert.IsNotNull( + GlobalSlotsField, + "Could not locate the private '_globalSlots' field on MessageBus via reflection." + ); + object value = GlobalSlotsField.GetValue(bus); + Assert.IsInstanceOf(value); + return (BusGlobalSlot)value; + } + } +} diff --git a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs.meta b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs.meta new file mode 100644 index 00000000..b9e0ae26 --- /dev/null +++ b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72682e19efe6473c9475ff40ffc1aaa2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/CounterBasedTouchTests.cs b/Tests/Editor/Contract/CounterBasedTouchTests.cs new file mode 100644 index 00000000..2f3c3034 --- /dev/null +++ b/Tests/Editor/Contract/CounterBasedTouchTests.cs @@ -0,0 +1,555 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.MessageBus.Internal; + using DxMessaging.Core.Messages; + using NUnit.Framework; + + /// + /// Reflection-based contract tests for PLAN P4.1 counter-based slot touch + /// wiring. These intentionally pin likely private/internal names so the + /// tests compile before the runtime touch hook exists, while failing with + /// focused messages until the implementation lands. + /// + [TestFixture] + [Category("Contract")] + public sealed class CounterBasedTouchTests + { + private static readonly InstanceId HandlerOwner = new InstanceId(0x5044_1001); + + private static readonly string[] TickMemberNames = + { + "_tickCounter", + "_touchTicks", + "_currentTouchTicks", + "_currentTouchTick", + "_slotTouchTicks", + "_tick", + "_ticks", + "_currentTick", + "TickCounter", + "TouchTicks", + "CurrentTouchTicks", + "CurrentTouchTick", + "Tick", + "Ticks", + "CurrentTick", + }; + + private readonly struct ProbeMessage : IUntargetedMessage { } + + private readonly struct TargetedProbeMessage : ITargetedMessage { } + + [Test] + public void TickStartsAtZero() + { + MessageBus bus = new MessageBus(); + + Assert.AreEqual(0, ReadBusTick(bus)); + } + + [Test] + public void TypedRegisterUpdatesTouchedSlotLastTouchTicks() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + + IEvictableSlot slot = ReadTypedSlot( + handler, + bus, + "UntargetedHandleDefault" + ); + long tick = ReadBusTick(bus); + + Assert.Greater(tick, 0); + Assert.AreEqual(tick, slot.LastTouchTicks); + + deregistration(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void GlobalRegisterUpdatesTouchedSlotLastTouchTicks() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action deregistration = bus.RegisterGlobalAcceptAll(handler); + IEvictableSlot slot = ReadGlobalSlot(bus); + long tick = ReadBusTick(bus); + + Assert.Greater(tick, 0); + Assert.AreEqual(tick, slot.LastTouchTicks); + + deregistration(); + } + finally + { + handler.active = false; + } + } + + [Test] + public void TypedDeregisterUpdatesTouchedSlotLastTouchTicks() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + IEvictableSlot slot = ReadTypedSlot( + handler, + bus, + "UntargetedHandleDefault" + ); + long registerTick = ReadBusTick(bus); + + deregistration(); + long deregisterTick = ReadBusTick(bus); + + Assert.Greater(deregisterTick, registerTick); + Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + } + finally + { + handler.active = false; + } + } + + [Test] + public void GlobalDeregisterUpdatesTouchedSlotLastTouchTicks() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action deregistration = bus.RegisterGlobalAcceptAll(handler); + IEvictableSlot slot = ReadGlobalSlot(bus); + long registerTick = ReadBusTick(bus); + + deregistration(); + long deregisterTick = ReadBusTick(bus); + + Assert.Greater(deregisterTick, registerTick); + Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleGlobalDeregisterAfterResetStateDoesNotTouchSlot() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action staleDeregistration = bus.RegisterGlobalAcceptAll(handler); + IEvictableSlot slot = ReadGlobalSlot(bus); + + InvokeResetState(bus); + long tickAfterReset = ReadBusTick(bus); + long touchAfterReset = slot.LastTouchTicks; + + staleDeregistration(); + + Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); + Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleTypedDeregisterAfterResetStateDoesNotTouchSlot() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + int staleCallCount = 0; + int currentCallCount = 0; + Action callback = _ => { }; + Action staleCallback = _ => + { + staleCallCount++; + }; + Action currentCallback = _ => + { + currentCallCount++; + }; + Action staleDeregistration = handler.RegisterUntargetedMessageHandler( + callback, + staleCallback, + priority: 17, + messageBus: bus + ); + IEvictableSlot slot = ReadTypedSlot( + handler, + bus, + "UntargetedHandleDefault" + ); + + InvokeResetState(bus); + long tickAfterReset = ReadBusTick(bus); + long touchAfterReset = slot.LastTouchTicks; + + staleDeregistration(); + + Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); + Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); + Assert.IsTrue(slot.IsEmpty); + + _ = handler.RegisterUntargetedMessageHandler( + currentCallback, + currentCallback, + priority: 17, + messageBus: bus + ); + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(0, staleCallCount); + Assert.AreEqual(1, currentCallCount); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleTargetedContextDeregisterAfterResetStateDoesNotTouchSlot() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + InstanceId target = new InstanceId(0x5044_2002); + try + { + int staleCallCount = 0; + int currentCallCount = 0; + Action callback = _ => { }; + Action staleCallback = _ => + { + staleCallCount++; + }; + Action currentCallback = _ => + { + currentCallCount++; + }; + Action staleDeregistration = handler.RegisterTargetedMessageHandler( + target, + callback, + staleCallback, + priority: 17, + messageBus: bus + ); + IEvictableSlot slot = ReadTypedSlot( + handler, + bus, + "TargetedHandleDefault" + ); + + InvokeResetState(bus); + long tickAfterReset = ReadBusTick(bus); + long touchAfterReset = slot.LastTouchTicks; + + staleDeregistration(); + + Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); + Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); + Assert.IsTrue(slot.IsEmpty); + + _ = handler.RegisterTargetedMessageHandler( + target, + currentCallback, + currentCallback, + priority: 17, + messageBus: bus + ); + TargetedProbeMessage message = new TargetedProbeMessage(); + bus.TargetedBroadcast(ref target, ref message); + + Assert.AreEqual(0, staleCallCount); + Assert.AreEqual(1, currentCallCount); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleTypedGlobalDeregisterAfterResetStateDoesNotTouchSlot() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + int staleCallCount = 0; + int currentCallCount = 0; + Action originalUntargeted = _ => { }; + Action staleUntargeted = _ => + { + staleCallCount++; + }; + Action currentUntargeted = _ => + { + currentCallCount++; + }; + Action targeted = (_, _) => { }; + Action broadcast = (_, _) => { }; + Action staleDeregistration = handler.RegisterGlobalAcceptAll( + originalUntargeted, + staleUntargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ); + IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); + + InvokeResetState(bus); + long tickAfterReset = ReadBusTick(bus); + long touchAfterReset = slot.LastTouchTicks; + + staleDeregistration(); + + Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); + Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); + Assert.IsTrue(slot.IsEmpty); + + _ = handler.RegisterGlobalAcceptAll( + currentUntargeted, + currentUntargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ); + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(0, staleCallCount); + Assert.AreEqual(1, currentCallCount); + } + finally + { + handler.active = false; + } + } + + [Test] + public void TypedGlobalDeregisterUsesAdvancedBusTick() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action untargeted = _ => { }; + Action targeted = (_, _) => { }; + Action broadcast = (_, _) => { }; + Action deregistration = handler.RegisterGlobalAcceptAll( + untargeted, + untargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ); + IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); + long registerTick = ReadBusTick(bus); + + deregistration(); + long deregisterTick = ReadBusTick(bus); + + Assert.Greater(deregisterTick, registerTick); + Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + } + finally + { + handler.active = false; + } + } + + [Test] + public void UntypedUntargetedBroadcastIncrementsOnce() + { + MessageBus bus = new MessageBus(); + ProbeMessage message = new ProbeMessage(); + long before = ReadBusTick(bus); + + bus.UntypedUntargetedBroadcast(message); + + long after = ReadBusTick(bus); + Assert.AreEqual(before + 1, after); + } + + private static long ReadBusTick(MessageBus bus) + { + Type busType = typeof(MessageBus); + const BindingFlags Flags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + foreach (string name in TickMemberNames) + { + FieldInfo field = busType.GetField(name, Flags); + if (field != null && IsLongLike(field.FieldType)) + { + return Convert.ToInt64(field.GetValue(bus)); + } + + PropertyInfo property = busType.GetProperty(name, Flags); + if ( + property != null + && property.GetIndexParameters().Length == 0 + && property.GetMethod != null + && IsLongLike(property.PropertyType) + ) + { + return Convert.ToInt64(property.GetValue(bus)); + } + } + + Assert.Fail( + "Could not locate the MessageBus touch tick counter. Tried: " + + string.Join(", ", TickMemberNames) + + ". Update CounterBasedTouchTests.TickMemberNames if P4.1 uses a different name." + ); + return 0; + } + + private static bool IsLongLike(Type type) + { + return type == typeof(long) + || type == typeof(ulong) + || type == typeof(int) + || type == typeof(uint); + } + + private static IEvictableSlot ReadGlobalSlot(MessageBus bus) + { + FieldInfo field = typeof(MessageBus).GetField( + "_globalSlots", + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "MessageBus must retain private field '_globalSlots'."); + object value = field.GetValue(bus); + Assert.IsInstanceOf(value); + return (IEvictableSlot)value; + } + + private static IEvictableSlot ReadTypedSlot( + MessageHandler handler, + IMessageBus bus, + string typedSlotIndexName + ) + where TMessage : IMessage + { + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + int index = ReadIndexConstant( + "DxMessaging.Core.Internal.TypedSlotIndex", + typedSlotIndexName + ); + object slot = slots.GetValue(index); + Assert.IsInstanceOf(slot); + return (IEvictableSlot)slot; + } + + private static IEvictableSlot ReadTypedGlobalSlot( + MessageHandler handler, + IMessageBus bus, + string typedGlobalSlotIndexName + ) + { + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_globalSlots"); + int index = ReadIndexConstant( + "DxMessaging.Core.Internal.TypedGlobalSlotIndex", + typedGlobalSlotIndexName + ); + object slot = slots.GetValue(index); + Assert.IsInstanceOf(slot); + return (IEvictableSlot)slot; + } + + private static object ReadTypedHandler(MessageHandler handler, IMessageBus bus) + where TMessage : IMessage + { + Assert.Less( + bus.RegisteredGlobalSequentialIndex, + handler._handlersByTypeByMessageBus.Count + ); + bool exists = handler + ._handlersByTypeByMessageBus[bus.RegisteredGlobalSequentialIndex] + .TryGetValue(out object typedHandler); + Assert.IsTrue(exists, "Typed handler for " + typeof(TMessage).Name + " must exist."); + Assert.IsNotNull(typedHandler); + return typedHandler; + } + + private static Array ReadArrayField(object owner, string name) + { + FieldInfo field = owner + .GetType() + .GetField( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, owner.GetType().Name + " must declare field '" + name + "'."); + object value = field.GetValue(owner); + Assert.IsNotNull(value, owner.GetType().Name + "." + name + " must be non-null."); + return (Array)value; + } + + private static int ReadIndexConstant(string fullTypeName, string name) + { + Type type = typeof(MessageHandler).Assembly.GetType(fullTypeName); + Assert.IsNotNull(type, "Could not locate index type " + fullTypeName + "."); + FieldInfo field = type.GetField(name, BindingFlags.Public | BindingFlags.Static); + Assert.IsNotNull(field, fullTypeName + "." + name + " must exist."); + return (int)field.GetRawConstantValue(); + } + + private static void InvokeResetState(MessageBus bus) + { + MethodInfo method = typeof(MessageBus).GetMethod( + "ResetState", + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(method, "MessageBus.ResetState must exist for stale dereg tests."); + method.Invoke(bus, Array.Empty()); + } + } +} diff --git a/Tests/Editor/Contract/CounterBasedTouchTests.cs.meta b/Tests/Editor/Contract/CounterBasedTouchTests.cs.meta new file mode 100644 index 00000000..0785040a --- /dev/null +++ b/Tests/Editor/Contract/CounterBasedTouchTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97d820335e1f4a69a4fd05fdfb852ad1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs b/Tests/Editor/Contract/EvictionSweepContractTests.cs new file mode 100644 index 00000000..03ddf4c8 --- /dev/null +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs @@ -0,0 +1,806 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.Internal; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.MessageBus.Internal; + using DxMessaging.Core.Messages; + using DxMessaging.Core.Pooling; + using NUnit.Framework; +#if UNITY_2021_3_OR_NEWER + using DxMessaging.Core.Configuration; + using UnityEngine; + using UnityEngine.LowLevel; + using UnityEngine.PlayerLoop; +#endif + + [TestFixture] + [Category("Contract")] + public sealed class EvictionSweepContractTests + { + private static readonly InstanceId HandlerOwner = new InstanceId(0x5044_4201); + + private readonly struct ProbeMessage : IUntargetedMessage { } + + private readonly struct OtherProbeMessage : IUntargetedMessage { } + + private readonly struct TargetedProbeMessage : ITargetedMessage { } + + private readonly struct BroadcastProbeMessage : IBroadcastMessage { } + + [Test] + public void ForceSweepResetsDirtyEmptyTypedSlotsAndTrimsPools() + { + ManualClock clock = new ManualClock(10d); + MessageBus bus = new MessageBus(clock, idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + int slotIndex = TypedSlotIndex.UntargetedHandleDefault; + Assert.IsInstanceOf(slots.GetValue(slotIndex)); + + System.Collections.Generic.List pooledList = DxPools.ObjectLists.Rent(); + DxPools.ObjectLists.Return(pooledList); + Assert.Greater(DxPools.DescribeAll().ObjectLists.Cached, 0); + + clock.SetTo(12d); + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.IsNull(slots.GetValue(slotIndex)); + Assert.GreaterOrEqual(result.TypeSlotsEvicted, 1); + Assert.Greater(result.PooledCollectionsEvicted, 0); + Assert.AreEqual(0, DxPools.DescribeAll().ObjectLists.Cached); + Assert.AreEqual(12d, ReadDoubleField(bus, "_lastSweepSeconds")); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyTypes")); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyTargets")); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyHandlers")); + } + finally + { + handler.active = false; + } + } + + [Test] + public void NonForceSweepRetainsFreshDirtyTypedSlotsUntilIdle() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus(clock, idleEvictionTicks: 1); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + int slotIndex = TypedSlotIndex.UntargetedHandleDefault; + + IMessageBus.TrimResult freshResult = bus.Trim(force: false); + + Assert.IsInstanceOf(slots.GetValue(slotIndex)); + Assert.AreEqual(0, freshResult.TypeSlotsEvicted); + Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyTypes")); + Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyHandlers")); + + OtherProbeMessage other = new OtherProbeMessage(); + bus.UntargetedBroadcast(ref other); + bus.UntargetedBroadcast(ref other); + IMessageBus.TrimResult idleResult = bus.Trim(force: false); + + Assert.IsNull(slots.GetValue(slotIndex)); + Assert.GreaterOrEqual(idleResult.TypeSlotsEvicted, 1); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyTypes")); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyHandlers")); + } + finally + { + handler.active = false; + } + } + + [Test] + public void ForceSweepPreservesActiveTypedRegistration() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + int calls = 0; + Action callback = _ => + { + calls++; + }; + _ = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + + _ = bus.Trim(force: true); + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(1, calls); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleDeregisterAfterEmptySweepDoesNotRemoveReRegisteredHandler() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + int staleCalls = 0; + int currentCalls = 0; + Action original = _ => { }; + Action stale = _ => + { + staleCalls++; + }; + Action current = _ => + { + currentCalls++; + }; + + Action staleDeregistration = handler.RegisterUntargetedMessageHandler( + original, + stale, + priority: 17, + messageBus: bus + ); + staleDeregistration(); + _ = bus.Trim(force: true); + + _ = handler.RegisterUntargetedMessageHandler( + current, + current, + priority: 17, + messageBus: bus + ); + staleDeregistration(); + + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(0, staleCalls); + Assert.AreEqual(1, currentCalls); + } + finally + { + handler.active = false; + } + } + + [Test] + public void TrimReclaimsBusSlotAfterDispatchThenDeregister() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + deregistration(); + + Assert.GreaterOrEqual( + bus.OccupiedTypeSlots, + 1, + "Final deregistration must leave empty bus-side slots reachable for Trim." + ); + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.GreaterOrEqual(result.TypeSlotsEvicted, 1); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(0, result.LiveTypeSlotsRemaining); + } + finally + { + handler.active = false; + } + } + + [Test] + public void ForceTrimOnFreshBusDoesNotReportGlobalSlotEviction() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.AreEqual(0, result.TypeSlotsEvicted); + Assert.AreEqual(0, result.TargetSlotsEvicted); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(0, bus.OccupiedTargetSlots); + } + + [Test] + public void StaleScalarDeregisterAfterSweepDoesNotLogOverDeregistration() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + Action previousLog = MessagingDebug.LogFunction; + bool previousEnabled = MessagingDebug.enabled; + System.Collections.Generic.List logs = + new System.Collections.Generic.List(); + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + _ = bus.Trim(force: true); + + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (_, message) => logs.Add(message); + deregistration(); + + Assert.AreEqual(0, logs.Count); + } + finally + { + MessagingDebug.enabled = previousEnabled; + MessagingDebug.LogFunction = previousLog; + handler.active = false; + } + } + + [Test] + public void StaleContextDeregisterAfterSweepDoesNotLogOverDeregistration() + { + MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + Action previousLog = MessagingDebug.LogFunction; + bool previousEnabled = MessagingDebug.enabled; + System.Collections.Generic.List logs = + new System.Collections.Generic.List(); + try + { + InstanceId context = new InstanceId(0x5044_4203); + Action callback = _ => { }; + Action deregistration = handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + _ = bus.Trim(force: true); + + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (_, message) => logs.Add(message); + deregistration(); + + Assert.AreEqual(0, logs.Count); + } + finally + { + MessagingDebug.enabled = previousEnabled; + MessagingDebug.LogFunction = previousLog; + handler.active = false; + } + } + + [Test] + public void EmitTimeSweepReclaimsIdleDirtySlotsWhenCadenceHasElapsed() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + + OtherProbeMessage other = new OtherProbeMessage(); + bus.UntargetedBroadcast(ref other); + Assert.GreaterOrEqual( + bus.OccupiedTypeSlots, + 1, + "The emit that checks cadence runs before AdvanceTick, so it only ages the candidate for the next emit." + ); + + bus.UntargetedBroadcast(ref other); + + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyTypes")); + Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyHandlers")); + } + finally + { + handler.active = false; + } + } + + [Test] + public void EmitTimeSweepWaitsForCadenceInterval() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 10d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + OtherProbeMessage other = new OtherProbeMessage(); + + clock.SetTo(9d); + bus.UntargetedBroadcast(ref other); + bus.UntargetedBroadcast(ref other); + + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + + clock.SetTo(10d); + bus.UntargetedBroadcast(ref other); + + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(10d, ReadDoubleField(bus, "_lastSweepSeconds")); + } + finally + { + handler.active = false; + } + } + + [Test] + public void EmitTimeSweepPreservesActiveRegistration() + { + MessageBus bus = new MessageBus( + new ManualClock(), + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + int calls = 0; + Action callback = _ => + { + calls++; + }; + _ = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(2, calls); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + } + finally + { + handler.active = false; + } + } + + [Test] + public void DisabledIdleEvictionPreventsEmitTimeSweep() + { + MessageBus bus = new MessageBus( + new ManualClock(), + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: false, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + + OtherProbeMessage other = new OtherProbeMessage(); + bus.UntargetedBroadcast(ref other); + bus.UntargetedBroadcast(ref other); + + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyTypes")); + Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyHandlers")); + } + finally + { + handler.active = false; + } + } + + [Test] + public void TrimReturnsDefaultAndLeavesStateWhenTrimApiIsDisabled() + { + MessageBus bus = new MessageBus( + new ManualClock(), + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: false + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + System.Collections.Generic.List pooledList = DxPools.ObjectLists.Rent(); + DxPools.ObjectLists.Return(pooledList); + int cachedBefore = DxPools.DescribeAll().ObjectLists.Cached; + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.AreEqual(0, result.TypeSlotsEvicted); + Assert.AreEqual(0, result.TargetSlotsEvicted); + Assert.AreEqual(0, result.PooledCollectionsEvicted); + Assert.AreEqual(0, result.LiveTypeSlotsRemaining); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + Assert.AreEqual(cachedBefore, DxPools.DescribeAll().ObjectLists.Cached); + } + finally + { + handler.active = false; + } + } + + [Test] + public void LifoCollectionPoolIgnoresDuplicateReturns() + { + CollectionPool> pool = + new CollectionPool>( + maxRetained: 2, + useLru: false, + factory: () => new System.Collections.Generic.List(), + onRecycled: list => list.Clear() + ); + System.Collections.Generic.List item = + new System.Collections.Generic.List(); + + pool.Return(item); + pool.Return(item); + + Assert.AreEqual(1, pool.Count); + Assert.AreSame(item, pool.Rent()); + Assert.AreNotSame(item, pool.Rent()); + } + + [Test] + public void EmitTimeSweepRunsForTargetedAndBroadcastTypedEntryPoints() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + InstanceId context = new InstanceId(0x5044_4202); + Action targetedCallback = _ => { }; + Action broadcastCallback = _ => { }; + Action deregisterTargeted = handler.RegisterTargetedMessageHandler( + context, + targetedCallback, + targetedCallback, + priority: 17, + messageBus: bus + ); + Action deregisterBroadcast = handler.RegisterSourcedBroadcastMessageHandler( + context, + broadcastCallback, + broadcastCallback, + priority: 17, + messageBus: bus + ); + deregisterTargeted(); + deregisterBroadcast(); + Assert.GreaterOrEqual(bus.OccupiedTargetSlots, 2); + + TargetedProbeMessage targeted = new TargetedProbeMessage(); + bus.TargetedBroadcast(ref context, ref targeted); + bus.TargetedBroadcast(ref context, ref targeted); + + Assert.LessOrEqual(bus.OccupiedTargetSlots, 1); + + BroadcastProbeMessage broadcast = new BroadcastProbeMessage(); + bus.SourcedBroadcast(ref context, ref broadcast); + bus.SourcedBroadcast(ref context, ref broadcast); + + Assert.AreEqual(0, bus.OccupiedTargetSlots); + } + finally + { + handler.active = false; + } + } + +#if UNITY_2021_3_OR_NEWER + [Test] + public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus(clock); + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + try + { + System.Collections.Generic.List pooledList = DxPools.ObjectLists.Rent(); + DxPools.ObjectLists.Return(pooledList); + Assert.Greater(DxPools.DescribeAll().ObjectLists.Cached, 0); + + settings._idleEvictionSeconds = 7f; + settings._evictionTickIntervalSeconds = 3f; + settings._evictionEnabled = false; + settings._enableTrimApi = false; + settings._bufferMaxDistinctEntries = 0; + settings._bufferUseLruEviction = false; + + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + + Assert.AreEqual(7L, ReadLongField(bus, "_idleEvictionTicks")); + Assert.AreEqual(3d, ReadDoubleField(bus, "_evictionTickIntervalSeconds")); + Assert.IsFalse(ReadBoolField(bus, "_idleEvictionEnabled")); + Assert.IsFalse(ReadBoolField(bus, "_trimApiEnabled")); + Assert.AreEqual(0, DxPools.ObjectLists.MaxRetained); + Assert.IsFalse(DxPools.ObjectLists.UseLru); + Assert.AreEqual(0, DxPools.DescribeAll().ObjectLists.Cached); + + DxMessagingRuntimeSettingsProvider.ResetForTests(); + using (DxMessagingRuntimeSettingsProvider.Override(settings)) { } + + Assert.AreEqual(30L, ReadLongField(bus, "_idleEvictionTicks")); + Assert.AreEqual(5d, ReadDoubleField(bus, "_evictionTickIntervalSeconds")); + Assert.IsTrue(ReadBoolField(bus, "_idleEvictionEnabled")); + Assert.IsTrue(ReadBoolField(bus, "_trimApiEnabled")); + Assert.AreEqual( + DxMessagingRuntimeSettings.DefaultBufferMaxDistinctEntries, + DxPools.ObjectLists.MaxRetained + ); + Assert.IsTrue(DxPools.ObjectLists.UseLru); + } + finally + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + + [Test] + public void PlayerLoopHookInstallsOnceUnderUpdate() + { + PlayerLoopSystem root = new PlayerLoopSystem + { + type = typeof(EvictionSweepContractTests), + subSystemList = new[] { new PlayerLoopSystem { type = typeof(Update) } }, + }; + + bool firstInstall = EvictionPlayerLoopHook.InstallInto(ref root); + bool secondInstall = EvictionPlayerLoopHook.InstallInto(ref root); + + Assert.IsTrue(firstInstall); + Assert.IsFalse(secondInstall); + Assert.IsTrue(EvictionPlayerLoopHook.ContainsHook(root)); + Assert.AreEqual(1, CountPlayerLoopHook(root)); + } + + [Test] + public void PlayerLoopSweepAgesIdleCandidatesWithoutEmit() + { + ManualClock clock = new ManualClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + try + { + Action callback = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + deregistration(); + + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + + MessageBus.SweepIdleBusesFromPlayerLoop(); + + Assert.AreEqual(0, bus.OccupiedTypeSlots); + } + finally + { + handler.active = false; + } + } +#endif + + private static object ReadTypedHandler(MessageHandler handler, IMessageBus bus) + where TMessage : IMessage + { + Assert.Less( + bus.RegisteredGlobalSequentialIndex, + handler._handlersByTypeByMessageBus.Count + ); + bool exists = handler + ._handlersByTypeByMessageBus[bus.RegisteredGlobalSequentialIndex] + .TryGetValue(out object typedHandler); + Assert.IsTrue(exists, "Typed handler for " + typeof(TMessage).Name + " must exist."); + Assert.IsNotNull(typedHandler); + return typedHandler; + } + + private static Array ReadArrayField(object owner, string name) + { + FieldInfo field = owner + .GetType() + .GetField( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, owner.GetType().Name + " must declare field '" + name + "'."); + object value = field.GetValue(owner); + Assert.IsNotNull(value, owner.GetType().Name + "." + name + " must be non-null."); + return (Array)value; + } + + private static double ReadDoubleField(MessageBus bus, string name) + { + FieldInfo field = typeof(MessageBus).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "MessageBus must declare field '" + name + "'."); + return (double)field.GetValue(bus); + } + + private static long ReadLongField(MessageBus bus, string name) + { + FieldInfo field = typeof(MessageBus).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "MessageBus must declare field '" + name + "'."); + return (long)field.GetValue(bus); + } + + private static bool ReadBoolField(MessageBus bus, string name) + { + FieldInfo field = typeof(MessageBus).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "MessageBus must declare field '" + name + "'."); + return (bool)field.GetValue(bus); + } + + private static int ReadCollectionCount(MessageBus bus, string name) + { + FieldInfo field = typeof(MessageBus).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "MessageBus must declare field '" + name + "'."); + object value = field.GetValue(bus); + PropertyInfo count = value.GetType().GetProperty("Count"); + Assert.IsNotNull(count, name + " must expose Count."); + return (int)count.GetValue(value); + } + +#if UNITY_2021_3_OR_NEWER + private static int CountPlayerLoopHook(PlayerLoopSystem system) + { + int count = system.type == typeof(EvictionPlayerLoopHook) ? 1 : 0; + PlayerLoopSystem[] subsystems = system.subSystemList; + if (subsystems == null) + { + return count; + } + + for (int i = 0; i < subsystems.Length; ++i) + { + count += CountPlayerLoopHook(subsystems[i]); + } + + return count; + } +#endif + + private sealed class ManualClock : IDxMessagingClock + { + private double _now; + + public ManualClock(double nowSeconds = 0d) + { + _now = nowSeconds; + } + + public double NowSeconds => _now; + + public void SetTo(double nowSeconds) + { + _now = nowSeconds; + } + } + } +} diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs.meta b/Tests/Editor/Contract/EvictionSweepContractTests.cs.meta new file mode 100644 index 00000000..040e6bb8 --- /dev/null +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a1b1a6d9b374c1fa2cdd2af0ad697a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/MessageBusInvariantTests.cs b/Tests/Editor/Contract/MessageBusInvariantTests.cs new file mode 100644 index 00000000..3904509d --- /dev/null +++ b/Tests/Editor/Contract/MessageBusInvariantTests.cs @@ -0,0 +1,450 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.Helper; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.MessageBus.Internal; + using DxMessaging.Core.Messages; + using NUnit.Framework; + + /// + /// Contract guardrails for cache storage fields that + /// must stay registered with the memory-reclamation sweep table. + /// + [TestFixture] + [Category("Contract")] + public sealed class MessageBusInvariantTests + { + private static readonly InstanceId HandlerOwnerA = new InstanceId(0x4D42_4901); + private static readonly InstanceId HandlerOwnerB = new InstanceId(0x4D42_4902); + private static readonly InstanceId Target = new InstanceId(0x4D42_4903); + + private const BindingFlags DeclaredInstanceFields = + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly; + + [Test] + public void MessageCacheFieldCountMatchesExpected() + { + FieldInfo[] fields = GetMessageCacheStorageFields(); + + Assert.AreEqual( + MessageBus.ExpectedMessageCacheFieldCount, + fields.Length, + "MessageBus MessageCache storage field count changed. If the new cache owns " + + "per-message-type state, register it in MessageBus.SweepableTypeCaches and " + + "then update ExpectedMessageCacheFieldCount." + ); + } + + [Test] + public void MessageCacheArrayFieldsCountAsStorageFields() + { + string[] fieldNames = GetMessageCacheStorageFields() + .Select(field => field.Name) + .ToArray(); + + CollectionAssert.Contains(fieldNames, "_scalarSinks"); + CollectionAssert.Contains(fieldNames, "_contextSinks"); + } + + [Test] + public void EveryMessageCacheFieldHasSweepableRegistryEntry() + { + string[] fieldNames = GetMessageCacheStorageFields() + .Select(field => field.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + string[] registeredNames = MessageBus + .SweepableTypeCaches.Select(sweepable => sweepable.StorageFieldName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + CollectionAssert.AreEqual( + fieldNames, + registeredNames, + "Every MessageBus MessageCache storage field must have exactly one sweep registry row." + ); + } + + [Test] + public void SweepableRegistryDoesNotContainUnknownFields() + { + HashSet fieldNames = new( + GetMessageCacheStorageFields().Select(field => field.Name), + StringComparer.Ordinal + ); + + foreach (ISweepable sweepable in MessageBus.SweepableTypeCaches) + { + Assert.IsTrue( + fieldNames.Contains(sweepable.StorageFieldName), + $"Sweep registry row '{sweepable.StorageFieldName}' does not match a MessageBus MessageCache field." + ); + } + } + + [Test] + public void SweepableRegistryFieldNamesAreUnique() + { + string[] duplicateNames = MessageBus + .SweepableTypeCaches.GroupBy( + sweepable => sweepable.StorageFieldName, + StringComparer.Ordinal + ) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + + CollectionAssert.IsEmpty( + duplicateNames, + "Sweep registry rows must be one-to-one with MessageBus MessageCache storage fields." + ); + } + + [Test] + public void SweepableRegistryFieldTypesMatchDeclaredFields() + { + Dictionary fieldsByName = GetMessageCacheStorageFields() + .ToDictionary(field => field.Name, StringComparer.Ordinal); + + foreach (ISweepable sweepable in MessageBus.SweepableTypeCaches) + { + Assert.IsTrue( + fieldsByName.TryGetValue(sweepable.StorageFieldName, out FieldInfo field), + $"Sweep registry row '{sweepable.StorageFieldName}' does not match a MessageBus MessageCache field." + ); + Assert.AreEqual( + field.FieldType, + sweepable.StorageFieldType, + $"Sweep registry row '{sweepable.StorageFieldName}' has a stale field type." + ); + } + } + + [Test] + public void ContextSweepCountsRemovedTypeAndTargetSlots() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + Action deregister = bus.RegisterTargeted(Target, handler); + deregister(); + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.AreEqual( + 1, + result.TargetSlotsEvicted, + "Context sweep should report the reclaimed target slot." + ); + Assert.AreEqual( + 1, + result.TypeSlotsEvicted, + "Removing the final target dictionary also removes the context-backed type slot." + ); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(0, bus.OccupiedTargetSlots); + } + finally + { + handler.active = false; + } + } + + [Test] + public void InterceptorMessageCachesAreSweptThroughRegistry() + { + MessageBus bus = new MessageBus(); + IMessageBus.UntargetedInterceptor interceptor = ( + ref InterceptorProbeMessage _ + ) => true; + + Action deregister = bus.RegisterUntargetedInterceptor(interceptor); + Assert.AreEqual(1, bus.RegisteredInterceptors); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + + deregister(); + Assert.AreEqual(0, bus.RegisteredInterceptors); + + ISweepable sweepable = MessageBus.SweepableTypeCaches.Single(row => + row.StorageFieldName == "_untargetedInterceptsByType" + ); + int evicted = sweepable.Sweep(bus, force: true); + + Assert.AreEqual(1, evicted); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + } + + [Test] + public void TrimDuringActiveDispatchAfterDeregisterPreservesSnapshot() + { + MessageBus bus = new MessageBus(); + MessageHandler first = new MessageHandler(HandlerOwnerA, bus) { active = true }; + MessageHandler second = new MessageHandler(HandlerOwnerB, bus) { active = true }; + Action firstDeregister = null; + Action secondDeregister = null; + int secondCalls = 0; + try + { + firstDeregister = + first.RegisterUntargetedMessageHandler( + input => + { + firstDeregister(); + secondDeregister(); + _ = bus.Trim(force: true); + }, + input => + { + firstDeregister(); + secondDeregister(); + _ = bus.Trim(force: true); + }, + priority: 0, + messageBus: bus + ); + secondDeregister = + second.RegisterUntargetedMessageHandler( + _ => secondCalls++, + _ => secondCalls++, + priority: 0, + messageBus: bus + ); + + UntargetedDispatchProbeMessage message = new UntargetedDispatchProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual( + 1, + secondCalls, + "Trim must not release the active dispatch snapshot while the current emission is still iterating it." + ); + + _ = bus.Trim(force: true); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + } + finally + { + first.active = false; + second.active = false; + } + } + + [Test] + public void StaleInterceptorDeregisterAfterSweepCannotRemoveReRegisteredInterceptor() + { + MessageBus bus = new MessageBus(); + IMessageBus.UntargetedInterceptor interceptor = ( + ref InterceptorProbeMessage _ + ) => true; + + Action staleDeregister = bus.RegisterUntargetedInterceptor(interceptor); + staleDeregister(); + _ = bus.Trim(force: true); + + _ = bus.RegisterUntargetedInterceptor(interceptor); + staleDeregister(); + + Assert.AreEqual( + 1, + bus.RegisteredInterceptors, + "A stale deregister closure from an evicted interceptor cache must not remove a later registration." + ); + } + + [Test] + public void StaleGlobalAcceptAllDeregisterAfterSweepCannotRemoveReRegisteredHandler() + { + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(0x4D42_0001), bus) + { + active = true, + }; + try + { + Action staleDeregister = bus.RegisterGlobalAcceptAll(handler); + staleDeregister(); + _ = bus.Trim(force: true); + + Action currentDeregister = bus.RegisterGlobalAcceptAll(handler); + staleDeregister(); + + Assert.AreEqual( + 1, + bus.RegisteredGlobalAcceptAll, + "A stale GlobalAcceptAll deregister closure from an evicted global slot must not remove a later registration." + ); + + currentDeregister(); + Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleScalarDeregisterAfterSweepDoesNotWriteDiagnosticsLog() + { + MessageBus bus = new MessageBus(); + bus.Log.Enabled = true; + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + Action staleDeregister = bus.RegisterUntargeted( + handler + ); + staleDeregister(); + Assert.AreEqual(2, bus.Log.Registrations.Count); + + _ = bus.Trim(force: true); + staleDeregister(); + + Assert.AreEqual( + 2, + bus.Log.Registrations.Count, + "A stale scalar deregister closure after sweep must be silent in diagnostics." + ); + } + finally + { + handler.active = false; + } + } + + [Test] + public void StaleContextDeregisterAfterSweepDoesNotWriteDiagnosticsLog() + { + MessageBus bus = new MessageBus(); + bus.Log.Enabled = true; + MessageHandler handler = new MessageHandler(HandlerOwnerA, bus) { active = true }; + try + { + Action staleDeregister = bus.RegisterTargeted( + Target, + handler + ); + staleDeregister(); + Assert.AreEqual(2, bus.Log.Registrations.Count); + + _ = bus.Trim(force: true); + staleDeregister(); + + Assert.AreEqual( + 2, + bus.Log.Registrations.Count, + "A stale context deregister closure after sweep must be silent in diagnostics." + ); + } + finally + { + handler.active = false; + } + } + + [Test] + public void TrimDuringInFlightDispatchAfterDeregisteringCurrentSlotDefersSnapshotRelease() + { + MessageBus bus = new MessageBus(); + MessageHandler firstHandler = new MessageHandler(new InstanceId(0x4D42_0002), bus) + { + active = true, + }; + MessageHandler secondHandler = new MessageHandler(new InstanceId(0x4D42_0003), bus) + { + active = true, + }; + List calls = new List(); + IMessageBus.TrimResult inFlightTrimResult = default; + Action firstDeregister = null; + Action secondDeregister = null; + try + { + Action first = _ => + { + calls.Add("first"); + firstDeregister(); + secondDeregister(); + inFlightTrimResult = bus.Trim(force: true); + }; + Action second = _ => + { + calls.Add("second"); + }; + + firstDeregister = firstHandler.RegisterUntargetedMessageHandler( + first, + first, + priority: 0, + messageBus: bus + ); + secondDeregister = secondHandler.RegisterUntargetedMessageHandler( + second, + second, + priority: 1, + messageBus: bus + ); + + DispatchTrimProbeMessage message = new DispatchTrimProbeMessage(); + bus.UntargetedBroadcast(ref message); + + CollectionAssert.AreEqual(new[] { "first", "second" }, calls); + Assert.AreEqual( + 0, + inFlightTrimResult.TypeSlotsEvicted, + "Trim must not release the active dispatch snapshot while the dispatch loop is still reading it." + ); + } + finally + { + firstHandler.active = false; + secondHandler.active = false; + } + } + + private static FieldInfo[] GetMessageCacheStorageFields() + { + return typeof(MessageBus) + .GetFields(DeclaredInstanceFields) + .Where(field => IsMessageCacheStorageField(field.FieldType)) + .OrderBy(field => field.Name, StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsMessageCacheStorageField(Type fieldType) + { + if (IsClosedMessageCache(fieldType)) + { + return true; + } + + return fieldType.IsArray + && fieldType.GetArrayRank() == 1 + && IsClosedMessageCache(fieldType.GetElementType()); + } + + private static bool IsClosedMessageCache(Type type) + { + return type != null + && type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(MessageCache<>); + } + + private readonly struct InterceptorProbeMessage : IUntargetedMessage { } + + private readonly struct DispatchTrimProbeMessage : IUntargetedMessage { } + + private readonly struct TargetedProbeMessage : ITargetedMessage { } + + private readonly struct UntargetedDispatchProbeMessage : IUntargetedMessage { } + } +} diff --git a/Tests/Editor/Contract/MessageBusInvariantTests.cs.meta b/Tests/Editor/Contract/MessageBusInvariantTests.cs.meta new file mode 100644 index 00000000..ef4bd8fa --- /dev/null +++ b/Tests/Editor/Contract/MessageBusInvariantTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3791f70b57440f28a1fd23c79723d63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs new file mode 100644 index 00000000..fef0599e --- /dev/null +++ b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs @@ -0,0 +1,297 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.MessageBus.Internal; + using NUnit.Framework; + + /// + /// Contract guardrails for the table. + /// These tests are the structural backstop for Phase P1 of the memory + /// reclamation plan: they assert that every + /// has an explicit + /// mapping and that real (non-sentinel) mappings are unique. + /// + [TestFixture] + [Category("Contract")] + public sealed class RegistrationMethodAxisCoverageTests + { + /// + /// Asserts returns a + /// value for every defined enum value: each lookup either yields a + /// non- real slot key OR is a member of the + /// documented sentinel set + /// (, + /// ). + /// + [Test] + public void EveryRegistrationMethodHasExplicitMapping() + { + HashSet sentinels = new() + { + RegistrationMethod.GlobalAcceptAll, + RegistrationMethod.Interceptor, + }; + + foreach (RegistrationMethod method in EnumerateRegistrationMethods()) + { + SlotKey key = default; + Assert.DoesNotThrow( + () => key = RegistrationMethodAxes.GetSlotKey(method), + "GetSlotKey threw for {0}", + method + ); + if (key == SlotKey.None) + { + Assert.IsTrue( + sentinels.Contains(method), + "Method {0} mapped to SlotKey.None but is not in the documented sentinel set.", + method + ); + } + else + { + Assert.IsFalse( + sentinels.Contains(method), + "Method {0} is in the sentinel set but mapped to a real SlotKey {1}.", + method, + key + ); + } + } + } + + /// + /// Asserts 's static type + /// initializer (which builds and validates the lookup table) does not + /// throw. Exercises the validation gate from a clean state. + /// + [Test] + public void RegistrationMethodAxesTypeInitDoesNotThrow() + { + Assert.DoesNotThrow(() => + RuntimeHelpers.RunClassConstructor(typeof(RegistrationMethodAxes).TypeHandle) + ); + } + + /// + /// Asserts safely + /// returns for cast values that fall + /// outside the lookup table's bounds (both above and below the + /// defined ordinal range). + /// + [Test] + public void GetSlotKeyForOutOfRangeMethodReturnsNone() + { + Assert.AreEqual( + SlotKey.None, + RegistrationMethodAxes.GetSlotKey((RegistrationMethod)int.MaxValue) + ); + Assert.AreEqual( + SlotKey.None, + RegistrationMethodAxes.GetSlotKey((RegistrationMethod)(-1)) + ); + } + + /// + /// Pins the human-readable format used + /// by diagnostics and test assertion messages. + /// + [Test] + public void SlotKeyToStringFormat() + { + Assert.AreEqual("None", SlotKey.None.ToString()); + Assert.AreEqual( + "Targeted/PostProcess/WithoutContext", + new SlotKey( + DispatchKind.Targeted, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ).ToString() + ); + } + + /// + /// Asserts the constructor accepts the maximum legal value on every + /// axis (kind=15, phase=1, variant=3 -- the largest defined variant). + /// Pins that SlotKey.None == 0xFF is the only byte value + /// unreachable from any defined (kind, phase, variant) triple. + /// + [Test] + public void SlotKeyAcceptsMaxLegalAxisValues() + { + // (15 << 4) | (1 << 3) | 3 = 240 | 8 | 3 = 251 = 0xFB + SlotKey edge = new SlotKey((DispatchKind)15, (DispatchPhase)1, (DispatchVariant)3); + Assert.AreEqual(0xFB, edge.Packed); + Assert.AreEqual((DispatchKind)15, edge.Kind); + Assert.AreEqual((DispatchPhase)1, edge.Phase); + Assert.AreEqual((DispatchVariant)3, edge.Variant); + Assert.AreNotEqual(SlotKey.None, edge); + } + + /// + /// Pins the deliberate aliasing between default(SlotKey) and + /// new SlotKey(Untargeted, Handle, Default). This is intentional + /// -- uninitialized fields decode to a real, valid slot. The "no slot + /// applies" sentinel is , never + /// default(SlotKey). + /// + [Test] + public void DefaultSlotKeyAliasesUntargetedHandleDefault() + { + SlotKey untargeted = new SlotKey( + DispatchKind.Untargeted, + DispatchPhase.Handle, + DispatchVariant.Default + ); + Assert.AreEqual(default(SlotKey), untargeted); + Assert.AreEqual(0, default(SlotKey).Packed); + Assert.AreNotEqual(SlotKey.None, default(SlotKey)); + } + + /// + /// Asserts that no two real (non-) + /// registration methods collide on the same . + /// + [Test] + public void RealRegistrationMethodsMapToUniqueSlotKeys() + { + Dictionary> groups = new(); + foreach (RegistrationMethod method in EnumerateRegistrationMethods()) + { + SlotKey key = RegistrationMethodAxes.GetSlotKey(method); + if (key == SlotKey.None) + { + continue; + } + if (!groups.TryGetValue(key, out List list)) + { + list = new List(); + groups[key] = list; + } + list.Add(method); + } + + foreach (KeyValuePair> entry in groups) + { + Assert.AreEqual( + 1, + entry.Value.Count, + "SlotKey {0} is shared by multiple methods: {1}", + entry.Key, + string.Join(", ", entry.Value) + ); + } + } + + /// + /// Asserts the only registration methods that map to + /// are + /// and + /// . + /// + [Test] + public void OnlySentinelMethodsMapToNone() + { + HashSet expectedSentinels = new() + { + RegistrationMethod.GlobalAcceptAll, + RegistrationMethod.Interceptor, + }; + + foreach (RegistrationMethod method in EnumerateRegistrationMethods()) + { + SlotKey key = RegistrationMethodAxes.GetSlotKey(method); + bool mapsToNone = key == SlotKey.None; + bool isSentinel = expectedSentinels.Contains(method); + Assert.AreEqual( + isSentinel, + mapsToNone, + "Method {0} -> SlotKey.None mismatch (expected sentinel: {1}, actual mapsToNone: {2}).", + method, + isSentinel, + mapsToNone + ); + } + } + + /// + /// Asserts the packed encoding of a representative + /// matches the documented bit layout and that decoded properties + /// round-trip the constructor inputs. + /// + [Test] + public void SlotKeyPackedEncodingIsCorrect() + { + SlotKey key = new SlotKey( + DispatchKind.Broadcast, + DispatchPhase.PostProcess, + DispatchVariant.WithoutContext + ); + int expectedPacked = + ((int)DispatchKind.Broadcast << 4) + | ((int)DispatchPhase.PostProcess << 3) + | (int)DispatchVariant.WithoutContext; + Assert.AreEqual((byte)expectedPacked, key.Packed); + Assert.AreEqual(DispatchKind.Broadcast, key.Kind); + Assert.AreEqual(DispatchPhase.PostProcess, key.Phase); + Assert.AreEqual(DispatchVariant.WithoutContext, key.Variant); + } + + /// + /// Asserts is distinct from + /// default(SlotKey), ensuring an unset + /// field is never accidentally interpreted as + /// "no axis applies". + /// + [Test] + public void SlotKeyNoneIsDistinctFromDefault() + { + SlotKey defaultKey = default; + Assert.AreNotEqual(SlotKey.None, defaultKey); + Assert.IsTrue(SlotKey.None != defaultKey); + Assert.IsFalse(SlotKey.None == defaultKey); + Assert.AreNotEqual(SlotKey.None.Packed, defaultKey.Packed); + } + + /// + /// Asserts the constructor throws when an axis + /// value exceeds its allotted bit width. + /// + [Test] + public void SlotKeyOutOfRangeArgumentsThrow() + { + Assert.Throws(() => + _ = new SlotKey((DispatchKind)16, DispatchPhase.Handle, DispatchVariant.Default) + ); + Assert.Throws(() => + _ = new SlotKey(DispatchKind.Untargeted, (DispatchPhase)2, DispatchVariant.Default) + ); + Assert.Throws(() => + _ = new SlotKey(DispatchKind.Untargeted, DispatchPhase.Handle, (DispatchVariant)8) + ); + } + + /// + /// Asserts the constructor rejects the + /// (15, 1, 7) triple, which would otherwise pack to + /// 0xFF -- the bit pattern reserved for + /// . + /// + [Test] + public void SlotKeyConstructorRejectsNoneAlias() + { + Assert.Throws(() => + new SlotKey((DispatchKind)15, (DispatchPhase)1, (DispatchVariant)7) + ); + } + + private static IEnumerable EnumerateRegistrationMethods() + { + return Enum.GetValues(typeof(RegistrationMethod)).Cast(); + } + } +} diff --git a/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs.meta b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs.meta new file mode 100644 index 00000000..8a6c5be0 --- /dev/null +++ b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97b5aa3971c5cbd6f6e13242a319a34d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs new file mode 100644 index 00000000..7d6df600 --- /dev/null +++ b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs @@ -0,0 +1,924 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.Internal; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Core.Pooling; + using NUnit.Framework; + + /// + /// Contract guardrails for the per-message-type slot-index tables wired in + /// PLAN Phase P3.2: (20-slot + /// TypedSlot<T>[] on TypedHandler<T>), + /// (6-slot + /// TypedGlobalSlot[]), and + /// (10-slot object[] for the per-kind dispatch links). + /// + /// + /// + /// Each test pins one structural invariant the P3.3 storage migration + /// depends on so the migration can land without revisiting the per-axis + /// layout. The tests reflect over a freshly-instantiated typed handler + /// rather than asserting against a hand-written shape so accidental + /// future field additions / index renumbering surface here BEFORE they + /// drift away from ValidateSlotArrays(). + /// + /// + /// TypedHandler<T> is internal; the + /// InternalsVisibleTo declarations on + /// WallstopStudios.DxMessaging.Tests.Editor let the tests reach + /// it directly. + /// + /// + [TestFixture] + [Category("Contract")] + public sealed class TypedSlotIndexCoverageTests + { + private readonly struct ProbeMessage : IUntargetedMessage { } + + private readonly struct ProbeTargetedMessage : ITargetedMessage { } + + // The expected legacy field name -> slot-index constant map. P3.3 + // deletes the fields; the names stay here as a migration ledger so + // new variants must still pick an explicit axis-indexed slot. + private static readonly (string FieldName, string ConstantName)[] LegacySlotMap = + { + ("_untargetedHandlers", nameof(TypedSlotIndex.UntargetedHandleDefault)), + ("_untargetedFastHandlers", nameof(TypedSlotIndex.UntargetedHandleFast)), + ( + "_untargetedPostProcessingHandlers", + nameof(TypedSlotIndex.UntargetedPostProcessDefault) + ), + ( + "_untargetedPostProcessingFastHandlers", + nameof(TypedSlotIndex.UntargetedPostProcessFast) + ), + ("_targetedHandlers", nameof(TypedSlotIndex.TargetedHandleDefault)), + ("_targetedFastHandlers", nameof(TypedSlotIndex.TargetedHandleFast)), + ( + "_targetedWithoutTargetingHandlers", + nameof(TypedSlotIndex.TargetedHandleWithoutContext) + ), + ( + "_fastTargetedWithoutTargetingHandlers", + nameof(TypedSlotIndex.TargetedHandleWithoutContextFast) + ), + ("_targetedPostProcessingHandlers", nameof(TypedSlotIndex.TargetedPostProcessDefault)), + ("_targetedPostProcessingFastHandlers", nameof(TypedSlotIndex.TargetedPostProcessFast)), + ( + "_targetedWithoutTargetingPostProcessingHandlers", + nameof(TypedSlotIndex.TargetedPostProcessWithoutContext) + ), + ( + "_fastTargetedWithoutTargetingPostProcessingHandlers", + nameof(TypedSlotIndex.TargetedPostProcessWithoutContextFast) + ), + ("_broadcastHandlers", nameof(TypedSlotIndex.BroadcastHandleDefault)), + ("_broadcastFastHandlers", nameof(TypedSlotIndex.BroadcastHandleFast)), + ( + "_broadcastWithoutSourceHandlers", + nameof(TypedSlotIndex.BroadcastHandleWithoutContext) + ), + ( + "_fastBroadcastWithoutSourceHandlers", + nameof(TypedSlotIndex.BroadcastHandleWithoutContextFast) + ), + ( + "_broadcastPostProcessingHandlers", + nameof(TypedSlotIndex.BroadcastPostProcessDefault) + ), + ( + "_broadcastPostProcessingFastHandlers", + nameof(TypedSlotIndex.BroadcastPostProcessFast) + ), + ( + "_broadcastWithoutSourcePostProcessingHandlers", + nameof(TypedSlotIndex.BroadcastPostProcessWithoutContext) + ), + ( + "_fastBroadcastWithoutSourcePostProcessingHandlers", + nameof(TypedSlotIndex.BroadcastPostProcessWithoutContextFast) + ), + }; + + private static readonly string[] LegacyGlobalFieldNames = + { + "_globalUntargetedHandlers", + "_globalUntargetedFastHandlers", + "_globalTargetedHandlers", + "_globalTargetedFastHandlers", + "_globalBroadcastHandlers", + "_globalBroadcastFastHandlers", + }; + + private static readonly string[] LegacyDispatchLinkFieldNames = + { + "_untargetedLink", + "_untargetedPostLink", + "_targetedLink", + "_targetedPostLink", + "_targetedWithoutTargetingLink", + "_targetedWithoutTargetingPostLink", + "_broadcastLink", + "_broadcastPostLink", + "_broadcastWithoutSourceLink", + "_broadcastWithoutSourcePostLink", + }; + + [Test] + public void SlotIndexLengthMatchesArrayLengthOnFreshTypedHandler() + { + object handler = MakeFreshTypedHandler(); + Array slots = ReadArrayField(handler, "_slots"); + Assert.AreEqual( + TypedSlotIndex.Length, + slots.Length, + "_slots.Length must equal TypedSlotIndex.Length so every " + + "constant indexes a valid slot." + ); + } + + [Test] + public void GlobalSlotIndexLengthMatchesArrayLengthOnFreshTypedHandler() + { + object handler = MakeFreshTypedHandler(); + Array slots = ReadArrayField(handler, "_globalSlots"); + Assert.AreEqual(TypedGlobalSlotIndex.Length, slots.Length); + } + + [Test] + public void DispatchLinkIndexLengthMatchesArrayLengthOnFreshTypedHandler() + { + object handler = MakeFreshTypedHandler(); + Array slots = ReadArrayField(handler, "_dispatchLinks"); + Assert.AreEqual(TypedDispatchLinkIndex.Length, slots.Length); + } + + [Test] + public void TypedSlotIndexConstantsAreUniqueAndContiguousZeroBased() + { + int[] values = ReadConstantValues(typeof(TypedSlotIndex)); + Assert.AreEqual( + values.Length, + values.Distinct().Count(), + $"TypedSlotIndex constants must have unique values; got [{string.Join(",", values)}]" + ); + int[] expected = Enumerable.Range(0, TypedSlotIndex.Length).ToArray(); + CollectionAssert.AreEqual( + expected, + values, + "TypedSlotIndex constants (excluding Length) must be the " + + "contiguous zero-based range [0, Length)." + ); + } + + [Test] + public void TypedGlobalSlotIndexConstantsAreUniqueAndContiguousZeroBased() + { + int[] values = ReadConstantValues(typeof(TypedGlobalSlotIndex)); + Assert.AreEqual( + values.Length, + values.Distinct().Count(), + $"TypedGlobalSlotIndex constants must have unique values; got [{string.Join(",", values)}]" + ); + int[] expected = Enumerable.Range(0, TypedGlobalSlotIndex.Length).ToArray(); + CollectionAssert.AreEqual(expected, values); + } + + [Test] + public void TypedDispatchLinkIndexConstantsAreUniqueAndContiguousZeroBased() + { + int[] values = ReadConstantValues(typeof(TypedDispatchLinkIndex)); + Assert.AreEqual( + values.Length, + values.Distinct().Count(), + $"TypedDispatchLinkIndex constants must have unique values; got [{string.Join(",", values)}]" + ); + int[] expected = Enumerable.Range(0, TypedDispatchLinkIndex.Length).ToArray(); + CollectionAssert.AreEqual(expected, values); + } + + [Test] + public void AllTypedSlotsAreNullOnFreshTypedHandler() + { + object handler = MakeFreshTypedHandler(); + + Array slots = ReadArrayField(handler, "_slots"); + for (int i = 0; i < slots.Length; ++i) + { + Assert.IsNull( + slots.GetValue(i), + "_slots[" + + i + + "] must be null on a fresh TypedHandler; slots populate lazily " + + "on first registration." + ); + } + + Array globalSlots = ReadArrayField(handler, "_globalSlots"); + for (int i = 0; i < globalSlots.Length; ++i) + { + Assert.IsNull(globalSlots.GetValue(i), "_globalSlots[" + i + "] must be null."); + } + + Array dispatchLinks = ReadArrayField(handler, "_dispatchLinks"); + for (int i = 0; i < dispatchLinks.Length; ++i) + { + Assert.IsNull(dispatchLinks.GetValue(i), "_dispatchLinks[" + i + "] must be null."); + } + } + + [Test] + public void TypedHandlerImplementsExternalSweepSurface() + { + object handler = MakeFreshTypedHandler(); + + Assert.IsInstanceOf( + handler, + "TypedHandler must expose an erased sweep surface so " + + "MessageCache callers can reset empty typed slots without reflection." + ); + } + + [Test] + public void LegacyNamedFieldsAreDeletedAfterSlotMigration() + { + Type typedHandlerOpen = typeof(MessageHandler).GetNestedType( + "TypedHandler`1", + BindingFlags.NonPublic + ); + Assert.IsNotNull( + typedHandlerOpen, + "MessageHandler.TypedHandler nested type must exist." + ); + Type closed = typedHandlerOpen.MakeGenericType(typeof(ProbeMessage)); + + FieldInfo[] declaredFields = closed.GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + + HashSet declaredFieldNames = new(declaredFields.Select(f => f.Name)); + HashSet indexConstantNames = new( + typeof(TypedSlotIndex) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => + f.IsLiteral && !f.IsInitOnly && f.Name != nameof(TypedSlotIndex.Length) + ) + .Select(f => f.Name) + ); + + Assert.AreEqual( + LegacySlotMap.Length, + indexConstantNames.Count, + "TypedSlotIndex constant count drift detected. Update LegacySlotMap " + + "in this test file in lockstep with the TypedSlotIndex table." + ); + Assert.AreEqual( + TypedSlotIndex.Length, + LegacySlotMap.Length, + "LegacySlotMap row count must equal TypedSlotIndex.Length." + ); + Assert.AreEqual( + LegacySlotMap.Length, + LegacySlotMap.Select(x => x.FieldName).Distinct().Count(), + "LegacySlotMap must not duplicate FieldName." + ); + Assert.AreEqual( + LegacySlotMap.Length, + LegacySlotMap.Select(x => x.ConstantName).Distinct().Count(), + "LegacySlotMap must not duplicate ConstantName." + ); + + foreach ((string fieldName, string constantName) in LegacySlotMap) + { + Assert.IsFalse( + declaredFieldNames.Contains(fieldName), + "TypedHandler must not redeclare legacy typed field '" + + fieldName + + "' after P3.3 storage migration." + ); + Assert.IsTrue( + indexConstantNames.Contains(constantName), + "TypedSlotIndex must declare constant '" + constantName + "'." + ); + FieldInfo constant = typeof(TypedSlotIndex).GetField( + constantName, + BindingFlags.Public | BindingFlags.Static + ); + Assert.AreEqual( + Array.IndexOf(LegacySlotMap, (fieldName, constantName)), + (int)constant.GetRawConstantValue(), + "TypedSlotIndex." + constantName + " must keep its documented numeric slot." + ); + } + + foreach (string fieldName in LegacyGlobalFieldNames) + { + Assert.IsFalse( + declaredFieldNames.Contains(fieldName), + "TypedHandler must not redeclare legacy global field '" + + fieldName + + "' after P3.3 storage migration." + ); + } + + foreach (string fieldName in LegacyDispatchLinkFieldNames) + { + Assert.IsFalse( + declaredFieldNames.Contains(fieldName), + "TypedHandler must not redeclare legacy dispatch-link field '" + + fieldName + + "' after P3.3 storage migration." + ); + } + } + + [Test] + public void UntargetedRegistrationTracksLiveCountAndOrderedPriorities() + { + object handler = MakeFreshTypedHandler(); + Type handlerType = handler.GetType(); + MethodInfo addMethod = handlerType.GetMethod( + "AddUntargetedHandler", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(Action), + typeof(Action), + typeof(Action), + typeof(int), + typeof(IMessageBus), + }, + modifiers: null + ); + Assert.IsNotNull(addMethod, "AddUntargetedHandler(Action) must exist."); + + MessageBus bus = new MessageBus(); + Action original = _ => { }; + Action augmented = _ => { }; + Action firstDeregistration = (Action) + addMethod.Invoke(handler, new object[] { original, augmented, null, 17, bus }); + Action secondDeregistration = (Action) + addMethod.Invoke(handler, new object[] { original, augmented, null, 17, bus }); + Action otherOriginal = _ => { }; + Action distinctDeregistration = (Action) + addMethod.Invoke(handler, new object[] { otherOriginal, augmented, null, 19, bus }); + + Array slots = ReadArrayField(handler, "_slots"); + object populated = slots.GetValue(TypedSlotIndex.UntargetedHandleDefault); + Assert.IsNotNull(populated, "Untargeted default registration must populate its slot."); + TypedSlot slot = (TypedSlot)populated; + Assert.IsFalse(slot.requiresContext); + Assert.AreEqual(2, slot.liveCount); + Assert.IsTrue(slot.byPriority.ContainsKey(17)); + Assert.IsTrue(slot.byPriority.ContainsKey(19)); + CollectionAssert.AreEqual(new[] { 17, 19 }, slot.orderedPriorities); + + Assert.IsNull(slots.GetValue(TypedSlotIndex.UntargetedHandleFast)); + Assert.IsNull(slots.GetValue(TypedSlotIndex.TargetedHandleDefault)); + + firstDeregistration(); + Assert.AreEqual( + 2, + slot.liveCount, + "Partial deregistration of a duplicate handler must not decrement liveCount." + ); + secondDeregistration(); + Assert.AreEqual(1, slot.liveCount); + secondDeregistration(); + Assert.AreEqual(1, slot.liveCount, "Over-deregistration must not underflow liveCount."); + distinctDeregistration(); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + } + + [Test] + public void ExternalSweepResetsEmptyUntargetedSlotAndInvalidatesStaleDeregistration() + { + DxMessaging.Core.MessageBus.MessageBus bus = + new DxMessaging.Core.MessageBus.MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(0x5033_0101), bus) + { + active = true, + }; + Action original = _ => { }; + Action deregistration = handler.RegisterUntargetedMessageHandler( + original, + original, + priority: 17, + messageBus: bus + ); + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + TypedSlot slot = + (TypedSlot)slots.GetValue(TypedSlotIndex.UntargetedHandleDefault); + + deregistration(); + long versionBeforeReset = slot.version; + int resetCount = handler.ResetEmptyTypedSlotsForSweep(bus); + + Assert.AreEqual(1, resetCount); + Assert.IsNull(slots.GetValue(TypedSlotIndex.UntargetedHandleDefault)); + Assert.Greater( + slot.version, + versionBeforeReset, + "External sweep must call Reset(), not Clear(), so stale deregistration " + + "closures observe a monotonic slot version bump." + ); + + Action newDeregistration = handler.RegisterUntargetedMessageHandler( + original, + original, + priority: 17, + messageBus: bus + ); + TypedSlot newSlot = + (TypedSlot)slots.GetValue(TypedSlotIndex.UntargetedHandleDefault); + Assert.AreEqual(1, newSlot.liveCount); + + deregistration(); + Assert.AreEqual( + 1, + newSlot.liveCount, + "A stale deregistration captured before external sweep must not affect " + + "a later registration that reused the same typed handler." + ); + + newDeregistration(); + Assert.AreEqual(0, newSlot.liveCount); + } + + [Test] + public void ExternalSweepPreservesActiveUntargetedSlotAndDispatch() + { + DxMessaging.Core.MessageBus.MessageBus bus = + new DxMessaging.Core.MessageBus.MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(0x5033_0102), bus) + { + active = true, + }; + int handled = 0; + Action callback = _ => handled++; + Action deregistration = handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ); + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + TypedSlot slot = + (TypedSlot)slots.GetValue(TypedSlotIndex.UntargetedHandleDefault); + + int resetCount = handler.ResetEmptyTypedSlotsForSweep(bus); + ProbeMessage message = new ProbeMessage(); + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual(0, resetCount); + Assert.AreSame(slot, slots.GetValue(TypedSlotIndex.UntargetedHandleDefault)); + Assert.AreEqual(1, slot.liveCount); + Assert.AreEqual(1, handled); + + deregistration(); + } + + [Test] + public void ContextRegistrationReturnsContextDictionariesToPoolsOnReset() + { + _ = DxPools.TrimAll(force: true); + + object handler = MakeFreshTypedHandler(); + Type handlerType = handler.GetType(); + MethodInfo addMethod = handlerType.GetMethod( + "AddTargetedHandler", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(InstanceId), + typeof(Action), + typeof(Action), + typeof(Action), + typeof(int), + typeof(IMessageBus), + }, + modifiers: null + ); + Assert.IsNotNull(addMethod, "AddTargetedHandler(Action) must exist."); + + MessageBus bus = new MessageBus(); + InstanceId target = new InstanceId(0x5033_0001); + Action original = _ => { }; + Action augmented = _ => { }; + _ = (Action) + addMethod.Invoke( + handler, + new object[] { target, original, augmented, null, 17, bus } + ); + + Array slots = ReadArrayField(handler, "_slots"); + TypedSlot slot = + (TypedSlot)slots.GetValue(TypedSlotIndex.TargetedHandleDefault); + Assert.IsNotNull(slot); + Dictionary> outer = slot.byContext; + Dictionary inner = outer[target]; + + slot.Reset(); + Assert.IsNull(slot.byContext); + + Dictionary> rentedAgainOuter = + DxPools.TypedHandlerContextDicts.Rent(); + Dictionary rentedAgainInner = + DxPools.TypedHandlerPriorityDicts.Rent(); + try + { + Assert.AreSame(outer, rentedAgainOuter); + Assert.AreSame(inner, rentedAgainInner); + } + finally + { + DxPools.TypedHandlerContextDicts.Return(rentedAgainOuter); + DxPools.TypedHandlerPriorityDicts.Return(rentedAgainInner); + } + } + + [Test] + public void ExternalSweepResetsEmptyContextSlotAndReturnsDictionariesToPools() + { + _ = DxPools.TrimAll(force: true); + + DxMessaging.Core.MessageBus.MessageBus bus = + new DxMessaging.Core.MessageBus.MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(0x5033_0103), bus) + { + active = true, + }; + InstanceId target = new InstanceId(0x5033_0104); + Action original = _ => { }; + Action deregistration = handler.RegisterTargetedMessageHandler( + target, + original, + original, + priority: 17, + messageBus: bus + ); + object typedHandler = ReadTypedHandler(handler, bus); + Array slots = ReadArrayField(typedHandler, "_slots"); + TypedSlot slot = + (TypedSlot) + slots.GetValue(TypedSlotIndex.TargetedHandleDefault); + Dictionary> outer = slot.byContext; + Dictionary inner = outer[target]; + + deregistration(); + int resetCount = handler.ResetEmptyTypedSlotsForSweep(bus); + + Assert.AreEqual(1, resetCount); + Assert.IsNull(slots.GetValue(TypedSlotIndex.TargetedHandleDefault)); + Assert.IsNull(slot.byContext); + + Dictionary> rentedAgainOuter = + DxPools.TypedHandlerContextDicts.Rent(); + Dictionary rentedAgainInner = + DxPools.TypedHandlerPriorityDicts.Rent(); + try + { + Assert.AreSame(outer, rentedAgainOuter); + Assert.AreSame(inner, rentedAgainInner); + } + finally + { + DxPools.TypedHandlerContextDicts.Return(rentedAgainOuter); + DxPools.TypedHandlerPriorityDicts.Return(rentedAgainInner); + } + } + + [Test] + public void StaleContextDeregistrationAfterResetDoesNotTouchPooledDictionaryReuse() + { + _ = DxPools.TrimAll(force: true); + + MethodInfo addMethod = FindTargetedActionRegisterMethod( + MakeFreshTypedHandler().GetType() + ); + InstanceId target = new InstanceId(0x5033_0002); + MessageBus bus = new MessageBus(); + Action original = _ => { }; + Action augmented = _ => { }; + + object oldHandler = MakeFreshTypedHandler(); + Action oldDeregistration = (Action) + addMethod.Invoke( + oldHandler, + new object[] { target, original, augmented, null, 17, bus } + ); + TypedSlot oldSlot = + (TypedSlot) + ReadArrayField(oldHandler, "_slots") + .GetValue(TypedSlotIndex.TargetedHandleDefault); + oldSlot.Reset(); + + object newHandler = MakeFreshTypedHandler(); + Action newDeregistration = (Action) + addMethod.Invoke( + newHandler, + new object[] { target, original, augmented, null, 17, bus } + ); + TypedSlot newSlot = + (TypedSlot) + ReadArrayField(newHandler, "_slots") + .GetValue(TypedSlotIndex.TargetedHandleDefault); + Assert.AreEqual(1, newSlot.liveCount); + + oldDeregistration(); + Assert.AreEqual( + 1, + newSlot.liveCount, + "A stale deregistration captured before Reset() must not remove handlers " + + "from a later slot that rented the same dictionaries." + ); + + newDeregistration(); + Assert.AreEqual(0, newSlot.liveCount); + Assert.IsTrue(newSlot.IsEmpty); + } + + [Test] + public void GlobalRegistrationPopulatesExpectedGlobalSlot() + { + object handler = MakeFreshTypedHandler(typeof(IMessage)); + Type handlerType = handler.GetType(); + MethodInfo addMethod = handlerType.GetMethod( + "AddGlobalUntargetedHandler", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(Action), + typeof(Action), + typeof(Action), + typeof(IMessageBus), + }, + modifiers: null + ); + Assert.IsNotNull( + addMethod, + "AddGlobalUntargetedHandler(Action) must exist." + ); + + MessageBus bus = new MessageBus(); + Action original = _ => { }; + Action augmented = _ => { }; + Action deregistration = (Action) + addMethod.Invoke(handler, new object[] { original, augmented, null, bus }); + + Array globalSlots = ReadArrayField(handler, "_globalSlots"); + object populated = globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedDefault); + Assert.IsNotNull( + populated, + "Global untargeted default registration must populate its slot." + ); + TypedGlobalSlot slot = (TypedGlobalSlot)populated; + Assert.AreEqual(1, slot.liveCount); + Assert.IsNotNull(slot.cache); + + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedFast)); + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.TargetedDefault)); + + deregistration(); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + } + + [Test] + public void ExternalSweepResetsEmptyGlobalSlots() + { + DxMessaging.Core.MessageBus.MessageBus bus = + new DxMessaging.Core.MessageBus.MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(0x5033_0105), bus) + { + active = true, + }; + Action untargeted = _ => { }; + Action targeted = (_, _) => { }; + Action broadcast = (_, _) => { }; + Action deregistration = handler.RegisterGlobalAcceptAll( + untargeted, + untargeted, + targeted, + targeted, + broadcast, + broadcast, + messageBus: bus + ); + object typedHandler = ReadTypedHandler(handler, bus); + Array globalSlots = ReadArrayField(typedHandler, "_globalSlots"); + TypedGlobalSlot untargetedSlot = (TypedGlobalSlot) + globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedDefault); + TypedGlobalSlot targetedSlot = (TypedGlobalSlot) + globalSlots.GetValue(TypedGlobalSlotIndex.TargetedDefault); + TypedGlobalSlot broadcastSlot = (TypedGlobalSlot) + globalSlots.GetValue(TypedGlobalSlotIndex.BroadcastDefault); + + deregistration(); + int resetCount = handler.ResetEmptyTypedSlotsForSweep(bus); + + Assert.AreEqual(3, resetCount); + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedDefault)); + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.TargetedDefault)); + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.BroadcastDefault)); + Assert.Greater(untargetedSlot.version, 0); + Assert.Greater(targetedSlot.version, 0); + Assert.Greater(broadcastSlot.version, 0); + } + + [Test] + public void ExternalSweepInvalidatesStaleTypedGlobalDeregistration() + { + object typedHandler = MakeFreshTypedHandler(typeof(IMessage)); + Type handlerType = typedHandler.GetType(); + MethodInfo addMethod = handlerType.GetMethod( + "AddGlobalUntargetedHandler", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(Action), + typeof(Action), + typeof(Action), + typeof(IMessageBus), + }, + modifiers: null + ); + Assert.IsNotNull( + addMethod, + "AddGlobalUntargetedHandler(Action) must exist." + ); + MessageBus bus = new MessageBus(); + Action original = _ => { }; + Action augmented = _ => { }; + Action staleDeregistration = (Action) + addMethod.Invoke(typedHandler, new object[] { original, augmented, null, bus }); + Array globalSlots = ReadArrayField(typedHandler, "_globalSlots"); + + staleDeregistration(); + int resetCount = ((ITypedHandlerSlotSweeper)typedHandler).ResetEmptySlotsForSweep(); + Assert.AreEqual(1, resetCount); + Assert.IsNull(globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedDefault)); + + Action newDeregistration = (Action) + addMethod.Invoke(typedHandler, new object[] { original, augmented, null, bus }); + TypedGlobalSlot newUntargetedSlot = (TypedGlobalSlot) + globalSlots.GetValue(TypedGlobalSlotIndex.UntargetedDefault); + Assert.AreEqual(1, newUntargetedSlot.liveCount); + + staleDeregistration(); + Assert.AreEqual( + 1, + newUntargetedSlot.liveCount, + "A stale global deregistration captured before external sweep must not " + + "decrement a later TypedGlobalSlot registration." + ); + + newDeregistration(); + Assert.AreEqual(0, newUntargetedSlot.liveCount); + } + + [Test] + public void DispatchLinksPopulateExpectedSlots() + { + object handler = MakeFreshTypedHandler(); + Type handlerType = handler.GetType(); + (string MethodName, int Index)[] links = + { + ("GetOrCreateUntargetedLink", TypedDispatchLinkIndex.UntargetedHandle), + ("GetOrCreateUntargetedPostLink", TypedDispatchLinkIndex.UntargetedPostProcess), + ("GetOrCreateTargetedLink", TypedDispatchLinkIndex.TargetedHandle), + ("GetOrCreateTargetedPostLink", TypedDispatchLinkIndex.TargetedPostProcess), + ( + "GetOrCreateTargetedWithoutTargetingLink", + TypedDispatchLinkIndex.TargetedHandleWithoutContext + ), + ( + "GetOrCreateTargetedWithoutTargetingPostLink", + TypedDispatchLinkIndex.TargetedPostProcessWithoutContext + ), + ("GetOrCreateBroadcastLink", TypedDispatchLinkIndex.BroadcastHandle), + ("GetOrCreateBroadcastPostLink", TypedDispatchLinkIndex.BroadcastPostProcess), + ( + "GetOrCreateBroadcastWithoutSourceLink", + TypedDispatchLinkIndex.BroadcastHandleWithoutContext + ), + ( + "GetOrCreateBroadcastWithoutSourcePostLink", + TypedDispatchLinkIndex.BroadcastPostProcessWithoutContext + ), + }; + + Array dispatchLinks = ReadArrayField(handler, "_dispatchLinks"); + foreach ((string methodName, int index) in links) + { + MethodInfo method = handlerType.GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.IsNotNull(method, methodName + " must exist."); + + object first = method.Invoke(handler, Array.Empty()); + object second = method.Invoke(handler, Array.Empty()); + + Assert.AreSame(first, second, methodName + " must return a stable link."); + Assert.AreSame( + first, + dispatchLinks.GetValue(index), + methodName + " must store the link in its indexed slot." + ); + } + } + + private static object MakeFreshTypedHandler() + { + return MakeFreshTypedHandler(typeof(ProbeMessage)); + } + + private static object MakeFreshTypedHandler(Type messageType) + { + Type typedHandlerOpen = typeof(MessageHandler).GetNestedType( + "TypedHandler`1", + BindingFlags.NonPublic + ); + Assert.IsNotNull( + typedHandlerOpen, + "MessageHandler.TypedHandler nested type must exist." + ); + Type closed = typedHandlerOpen.MakeGenericType(messageType); + return Activator.CreateInstance(closed, nonPublic: true); + } + + private static MethodInfo FindTargetedActionRegisterMethod(Type handlerType) + { + MethodInfo addMethod = handlerType.GetMethod( + "AddTargetedHandler", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(InstanceId), + typeof(Action), + typeof(Action), + typeof(Action), + typeof(int), + typeof(IMessageBus), + }, + modifiers: null + ); + Assert.IsNotNull(addMethod, "AddTargetedHandler(Action) must exist."); + return addMethod; + } + + private static Array ReadArrayField(object handler, string name) + { + FieldInfo field = handler + .GetType() + .GetField( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + Assert.IsNotNull(field, "TypedHandler must declare field '" + name + "'."); + object value = field.GetValue(handler); + Assert.IsNotNull(value, "TypedHandler.{0} must be non-null on construction.", name); + return (Array)value; + } + + private static object ReadTypedHandler(MessageHandler handler, IMessageBus bus) + where TMessage : IMessage + { + Assert.Less( + bus.RegisteredGlobalSequentialIndex, + handler._handlersByTypeByMessageBus.Count + ); + bool exists = handler + ._handlersByTypeByMessageBus[bus.RegisteredGlobalSequentialIndex] + .TryGetValue(out object typedHandler); + Assert.IsTrue(exists, "Typed handler for " + typeof(TMessage).Name + " must exist."); + Assert.IsNotNull(typedHandler); + return typedHandler; + } + + private static int[] ReadConstantValues(Type indexType) + { + return indexType + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.Name != "Length") + .Select(f => (int)f.GetRawConstantValue()) + .OrderBy(v => v) + .ToArray(); + } + } +} diff --git a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs.meta b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs.meta new file mode 100644 index 00000000..1ae2dd90 --- /dev/null +++ b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f0faf49824c4edc9645b2333dfbbd68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs b/Tests/Editor/Contract/TypedSlotShapeTests.cs new file mode 100644 index 00000000..daae777b --- /dev/null +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -0,0 +1,603 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.Internal; + using DxMessaging.Core.MessageBus.Internal; + using DxMessaging.Core.Messages; + using NUnit.Framework; + + /// + /// Contract guardrails for the typed-handler-side slot grid introduced in + /// PLAN Phase P3.1: (the non-generic + /// erasure of the per-delegate-shape cache type), + /// (the per-message-type, per- + /// dispatch slot), and (the per-message-type + /// accept-all slot). + /// + /// + /// + /// These types are forward-compat plumbing in P3.1 -- no writer populates + /// them yet -- but the eviction-driven monotonic Reset() contract + /// (PLAN Risk Register R3) and the shape + /// must be locked in before the storage migration in P3.2 / P3.3 starts + /// reading them. Each test below pins one structural invariant so the + /// migration can land without revisiting the per-slot lifecycle. + /// + /// + /// The test reflects over + /// the interface's declared members and is the structural backstop for + /// P3.2: when the storage migration retrofits + /// HandlerActionCache<TDelegate> to implement + /// , any silent member add or remove + /// here will fail this test until reviewers update both the interface + /// and its expected-shape list in lockstep. + /// + /// + [TestFixture] + [Category("Contract")] + public sealed class TypedSlotShapeTests + { + private readonly struct ProbeMessage : IUntargetedMessage { } + + /// + /// Trivial in-test stub for . Used so + /// the slot tests can populate + /// and without depending on the + /// real HandlerActionCache<TDelegate> implementation + /// (which, in P3.1, does not yet implement the interface). + /// + private sealed class StubCache : IHandlerActionCache + { + public long Version { get; set; } + + public long LastSeenVersion { get; set; } = -1; + + public long LastSeenEmissionId { get; set; } + + public int PrefreezeInvocationCount { get; set; } + + public bool IsEmpty { get; set; } = true; + + public int ResetCallCount { get; private set; } + + public void Reset() + { + ++ResetCallCount; + } + } + + /// + /// Probe stub for the drain-BEFORE-clear ordering tests (PLAN Risk + /// Register R3). is invoked from + /// and the observed value pinned in + /// ; the slot tests set + /// to read the size of the outer + /// container so a non-zero observation proves the inner drain ran + /// while the outer dict still held the entry. + /// + private sealed class OrderingProbeCache : IHandlerActionCache + { + public Func ProbeOuterCount = () => -1; + public int ObservedOuterCountAtReset = -2; + + public long Version => 0; + + public long LastSeenVersion { get; set; } + + public long LastSeenEmissionId { get; set; } + + public int PrefreezeInvocationCount => 0; + + public bool IsEmpty => true; + + public void Reset() + { + ObservedOuterCountAtReset = ProbeOuterCount(); + } + } + + [Test] + public void TypedSlotConstructorRespectsRequiresContextFlag() + { + TypedSlot contextless = new TypedSlot( + requiresContext: false + ); + Assert.IsFalse(contextless.requiresContext); + + TypedSlot withContext = new TypedSlot( + requiresContext: true + ); + Assert.IsTrue(withContext.requiresContext); + } + + [Test] + public void TypedSlotIsEmptyWhenLiveCountZero() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + + slot.liveCount = 1; + Assert.IsFalse(slot.IsEmpty); + + slot.liveCount = 0; + Assert.IsTrue(slot.IsEmpty); + } + + [Test] + public void TypedSlotResetBumpsVersionMonotonically() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + long previous = slot.version; + for (int i = 0; i < 8; ++i) + { + slot.Reset(); + Assert.Greater( + slot.version, + previous, + "Reset() must bump version strictly monotonically (PLAN Risk R3)." + ); + previous = slot.version; + } + } + + [Test] + public void TypedSlotResetPreservesLastTouchTicks() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + slot.lastTouchTicks = 42; + slot.Reset(); + Assert.AreEqual( + 42, + slot.lastTouchTicks, + "Reset() must preserve lastTouchTicks so the sweep can distinguish " + + "freshly-reset slots from never-touched slots." + ); + } + + /// + /// Pins the per-cache drain wired in P3.2: + /// must invoke on every + /// held by + /// BEFORE clearing the + /// container. Drain order is load-bearing per PLAN Risk Register R3 + /// so that closures captured against the inner cache also detect + /// invalidation. The earlier P3.1 placeholder pin asserted the + /// inverse (ResetCallCount == 0) and was flipped here in + /// lockstep with the wiring. + /// + [Test] + public void TypedSlotResetDrainsHeldCachesViaIHandlerActionCache() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + StubCache child = new StubCache(); + slot.byPriority[0] = child; + + slot.Reset(); + + Assert.AreEqual( + 1, + child.ResetCallCount, + "P3.2 wires Reset() to drain every IHandlerActionCache held by " + + "byPriority via IHandlerActionCache.Reset() BEFORE the structural " + + "clear (PLAN Risk Register R3). Re-check the xmldoc on " + + "TypedSlot.Reset() if this assertion needs to change." + ); + } + + /// + /// Companion to : + /// pins that every inner cache held by + /// is also drained on + /// . Walks both axes of the flat + /// 3-level InstanceId -> (priority -> IHandlerActionCache) + /// shape committed in P3.2. + /// + [Test] + public void TypedSlotResetDrainsByContextHeldCachesViaIHandlerActionCache() + { + TypedSlot slot = new TypedSlot(requiresContext: true); + StubCache a = new StubCache(); + StubCache b = new StubCache(); + slot.byContext = new Dictionary> + { + { + new InstanceId(1), + new Dictionary { { 0, a } } + }, + { + new InstanceId(2), + new Dictionary { { 5, b } } + }, + }; + + slot.Reset(); + + Assert.AreEqual(1, a.ResetCallCount); + Assert.AreEqual(1, b.ResetCallCount); + } + + /// + /// Pins the drain-BEFORE-clear ordering on + /// (PLAN Risk Register R3). + /// The probe cache reads byPriority.Count at the moment its + /// fires; a value of 1 + /// proves the drain ran while the outer dict still held the entry. + /// A value of 0 would indicate the outer clear ran first. + /// + [Test] + public void TypedSlotResetDrainsBeforeClearingByPriority() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + OrderingProbeCache probe = new OrderingProbeCache(); + probe.ProbeOuterCount = () => slot.byPriority.Count; + slot.byPriority[0] = probe; + + slot.Reset(); + + Assert.AreEqual( + 1, + probe.ObservedOuterCountAtReset, + "Reset() must drain inner caches BEFORE clearing byPriority " + + "(PLAN Risk Register R3)." + ); + } + + /// + /// Companion to : + /// pins the drain-BEFORE-clear ordering on + /// . The probe reads + /// byContext.Count from inside its + /// ; a value of 1 proves the + /// drain ran while the outer dict still held the entry. + /// + [Test] + public void TypedSlotResetDrainsBeforeClearingByContext() + { + TypedSlot slot = new TypedSlot(requiresContext: true); + OrderingProbeCache probe = new OrderingProbeCache(); + probe.ProbeOuterCount = () => slot.byContext.Count; + slot.byContext = new Dictionary> + { + { + new InstanceId(1), + new Dictionary { { 0, probe } } + }, + }; + + slot.Reset(); + + Assert.AreEqual( + 1, + probe.ObservedOuterCountAtReset, + "Reset() must drain inner caches BEFORE clearing byContext " + + "(PLAN Risk Register R3)." + ); + } + + /// + /// Pins that MessageHandler.HandlerActionCache<T> implements + /// after P3.2 (Task 1). The interface + /// is implemented explicitly so the public-facing field shape on the + /// nested cache type is unchanged; this test exercises the six + /// interface members through an interface-typed reference to confirm + /// they all dispatch without exception. + /// + [Test] + public void HandlerActionCacheImplementsIHandlerActionCache() + { + System.Type nested = typeof(DxMessaging.Core.MessageHandler).GetNestedType( + "HandlerActionCache`1", + BindingFlags.NonPublic + ); + Assert.IsNotNull( + nested, + "MessageHandler.HandlerActionCache nested type must exist." + ); + System.Type closed = nested.MakeGenericType(typeof(System.Action)); + Assert.IsTrue( + typeof(IHandlerActionCache).IsAssignableFrom(closed), + "HandlerActionCache must implement IHandlerActionCache after P3.2." + ); + + object instance = System.Activator.CreateInstance(closed, nonPublic: true); + IHandlerActionCache view = (IHandlerActionCache)instance; + + // Exercise every interface member; failure indicates a misapplied + // explicit-interface implementation or accidental shadowing. + long _ = view.Version; + view.LastSeenVersion = 7; + Assert.AreEqual(7, view.LastSeenVersion); + view.LastSeenEmissionId = 13; + Assert.AreEqual(13, view.LastSeenEmissionId); + int prefreeze = view.PrefreezeInvocationCount; + Assert.AreEqual(0, prefreeze); + Assert.IsTrue( + view.IsEmpty, + "Freshly-constructed HandlerActionCache must report IsEmpty == true." + ); + + // Pre-populate entries + cache via reflection so Reset() has + // observable inner state to drain. Both fields are public + // readonly; reflection over the closed generic returns the same + // collection instances the cache holds, so direct mutation + // populates the cache. + FieldInfo entriesField = closed.GetField( + "entries", + BindingFlags.Public | BindingFlags.Instance + ); + FieldInfo cacheField = closed.GetField( + "cache", + BindingFlags.Public | BindingFlags.Instance + ); + Assert.IsNotNull(entriesField, "HandlerActionCache.entries must exist."); + Assert.IsNotNull(cacheField, "HandlerActionCache.cache must exist."); + System.Collections.IDictionary entries = (System.Collections.IDictionary) + entriesField.GetValue(instance); + System.Collections.IList cacheList = (System.Collections.IList) + cacheField.GetValue(instance); + // Entry is a non-generic struct nested inside HandlerActionCache; + // GetNestedType on the closed generic returns the per-T concrete + // Entry type directly (no further MakeGenericType needed). + System.Type entryType = closed.GetNestedType("Entry", BindingFlags.NonPublic); + Assert.IsNotNull(entryType, "HandlerActionCache.Entry nested type must exist."); + System.Action handler = _ignored => { }; + object entry = System.Activator.CreateInstance(entryType, handler, 1); + entries[handler] = entry; + cacheList.Add(handler); + Assert.AreEqual(1, entries.Count); + Assert.AreEqual(1, cacheList.Count); + Assert.IsFalse( + view.IsEmpty, + "After populating entries the cache must report IsEmpty == false." + ); + + long beforeReset = view.Version; + view.Reset(); + Assert.Greater( + view.Version, + beforeReset, + "Reset() must bump version monotonically (PLAN Risk Register R3)." + ); + Assert.AreEqual(-1, view.LastSeenVersion, "Reset() must restore lastSeenVersion = -1."); + Assert.AreEqual(0, view.LastSeenEmissionId); + Assert.AreEqual(0, entries.Count, "Reset() must empty entries."); + Assert.AreEqual(0, cacheList.Count, "Reset() must empty cache."); + Assert.IsTrue(view.IsEmpty, "After Reset() the cache must report IsEmpty == true."); + } + + /// + /// Pins that is the flat + /// 3-level Dictionary<InstanceId, Dictionary<int, IHandlerActionCache>> + /// shape committed in P3.2 (option (2) from the P3.1 enumeration). + /// + [Test] + public void TypedSlotByContextShapeIsFlatThreeLevelDictionary() + { + FieldInfo field = typeof(TypedSlot).GetField( + "byContext", + BindingFlags.Public | BindingFlags.Instance + ); + Assert.IsNotNull(field, "TypedSlot.byContext field must exist."); + System.Type expected = typeof(Dictionary< + InstanceId, + Dictionary + >); + Assert.AreEqual( + expected, + field.FieldType, + "TypedSlot.byContext must be the flat 3-level shape " + + "Dictionary>." + ); + } + + [Test] + public void TypedSlotResetClearsByPriorityAndOrderedPriorities() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + slot.byPriority[0] = new StubCache(); + slot.byPriority[5] = new StubCache(); + slot.orderedPriorities.Add(0); + slot.orderedPriorities.Add(5); + + slot.Reset(); + + Assert.AreEqual(0, slot.byPriority.Count); + Assert.AreEqual(0, slot.orderedPriorities.Count); + } + + [Test] + public void TypedSlotResetNullsOutByContext() + { + TypedSlot slot = new TypedSlot(requiresContext: true); + slot.byContext = new Dictionary> + { + { + new InstanceId(1), + new Dictionary { { 0, new StubCache() } } + }, + { + new InstanceId(2), + new Dictionary { { 0, new StubCache() } } + }, + }; + + slot.Reset(); + + Assert.IsNull( + slot.byContext, + "Reset() must null out byContext after returning typed-handler-side context " + + "dictionaries to DxPools." + ); + } + + [Test] + public void TypedSlotClearResetsVersionToZero() + { + TypedSlot slot = new TypedSlot(requiresContext: false); + // Drive version above zero via repeated Reset() so the test does + // not depend on internal field write access for setup. + slot.Reset(); + slot.Reset(); + slot.Reset(); + Assert.Greater(slot.version, 0); + + slot.Clear(); + + Assert.AreEqual( + 0, + slot.version, + "Clear() is the legacy 'full reset' semantic and must reset " + + "version to 0; eviction-driven monotonicity belongs to Reset()." + ); + Assert.AreEqual(-1, slot.lastSeenVersion); + Assert.AreEqual(0, slot.lastSeenEmissionId); + Assert.AreEqual(0, slot.liveCount); + Assert.AreEqual(0, slot.byPriority.Count); + Assert.AreEqual(0, slot.orderedPriorities.Count); + Assert.IsNull(slot.byContext); + } + + [Test] + public void TypedGlobalSlotResetBumpsVersionMonotonically() + { + TypedGlobalSlot slot = new TypedGlobalSlot(); + long previous = slot.version; + for (int i = 0; i < 8; ++i) + { + slot.Reset(); + Assert.Greater( + slot.version, + previous, + "Reset() must bump version strictly monotonically (PLAN Risk R3)." + ); + previous = slot.version; + } + } + + [Test] + public void TypedGlobalSlotIsEmptyWhenLiveCountZero() + { + TypedGlobalSlot slot = new TypedGlobalSlot(); + Assert.AreEqual(0, slot.liveCount); + Assert.IsTrue(slot.IsEmpty); + + slot.liveCount = 3; + Assert.IsFalse(slot.IsEmpty); + + slot.liveCount = 0; + Assert.IsTrue(slot.IsEmpty); + } + + [Test] + public void TypedGlobalSlotResetClearsCache() + { + TypedGlobalSlot slot = new TypedGlobalSlot(); + slot.cache = new StubCache(); + Assert.IsNotNull(slot.cache); + + slot.Reset(); + + Assert.IsNull(slot.cache); + } + + [Test] + public void TypedGlobalSlotResetPreservesLastTouchTicks() + { + TypedGlobalSlot slot = new TypedGlobalSlot(); + slot.lastTouchTicks = 99; + slot.Reset(); + Assert.AreEqual(99, slot.lastTouchTicks); + } + + [Test] + public void TypedGlobalSlotClearResetsVersionToZero() + { + TypedGlobalSlot slot = new TypedGlobalSlot(); + slot.Reset(); + slot.Reset(); + Assert.Greater(slot.version, 0); + + slot.Clear(); + + Assert.AreEqual(0, slot.version); + Assert.AreEqual(-1, slot.lastSeenVersion); + Assert.AreEqual(0, slot.lastSeenEmissionId); + Assert.AreEqual(0, slot.liveCount); + Assert.IsNull(slot.cache); + } + + [Test] + public void TypedSlotImplementsIEvictableSlot() + { + Assert.IsTrue( + typeof(IEvictableSlot).IsAssignableFrom(typeof(TypedSlot)), + "TypedSlot must implement IEvictableSlot so the sweep can reclaim it." + ); + } + + [Test] + public void TypedGlobalSlotImplementsIEvictableSlot() + { + Assert.IsTrue( + typeof(IEvictableSlot).IsAssignableFrom(typeof(TypedGlobalSlot)), + "TypedGlobalSlot must implement IEvictableSlot so the sweep can reclaim it." + ); + } + + /// + /// Reflection-based shape pin for . + /// Asserts the interface declares exactly the six members the staged + /// dispatch + eviction layers require: , + /// , + /// , + /// , + /// , and + /// . Adding or removing a + /// member breaks this test until reviewers update the expected list, + /// providing a structural backstop for P3.2 (where + /// HandlerActionCache<TDelegate> retroactively implements + /// the interface). + /// + [Test] + public void IHandlerActionCacheInterfaceShape() + { + string[] expected = + { + "Version", + "LastSeenVersion", + "LastSeenEmissionId", + "PrefreezeInvocationCount", + "IsEmpty", + "Reset", + }; + + // GetMembers on an interface reports declared members directly. + // Property accessors and event accessors are filtered out by + // selecting only properties + methods that are NOT special-name. + string[] actual = typeof(IHandlerActionCache) + .GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => + m.MemberType == MemberTypes.Property + || (m.MemberType == MemberTypes.Method && !((MethodInfo)m).IsSpecialName) + ) + .Select(m => m.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + string[] sortedExpected = expected.OrderBy(n => n, StringComparer.Ordinal).ToArray(); + + CollectionAssert.AreEqual( + sortedExpected, + actual, + "IHandlerActionCache must expose exactly the documented member set. " + + "Adding or removing a member requires updating both the interface " + + "and this test in lockstep." + ); + } + } +} diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs.meta b/Tests/Editor/Contract/TypedSlotShapeTests.cs.meta new file mode 100644 index 00000000..8e84e418 --- /dev/null +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 953d4008ab9e46a0b66832e79d15e104 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/DiagnosticsTests.cs b/Tests/Runtime/Core/DiagnosticsTests.cs index a72ba207..1f8b495d 100644 --- a/Tests/Runtime/Core/DiagnosticsTests.cs +++ b/Tests/Runtime/Core/DiagnosticsTests.cs @@ -1,9 +1,11 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using System.Collections.Generic; using DxMessaging.Core; + using DxMessaging.Core.Configuration; using DxMessaging.Core.DataStructure; using DxMessaging.Core.Diagnostics; using DxMessaging.Core.Extensions; @@ -128,6 +130,40 @@ public void GlobalMessageBufferSizeDefaultMatchesConstant() ); } + [Test] + public void RuntimeSettingsMessageBufferSizeResizesExistingAndNewBuses() + { + int originalBufferSize = IMessageBus.GlobalMessageBufferSize; + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + IDisposable overrideToken = null; + try + { + MessageBus existingBus = new MessageBus(new FakeClock()); + settings._messageBufferSize = 2; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + + Assert.AreEqual(2, IMessageBus.GlobalMessageBufferSize); + Assert.AreEqual(2, GetEmissionBuffer(existingBus).Capacity); + + MessageBus newBus = new MessageBus(new FakeClock()); + Assert.AreEqual(2, GetEmissionBuffer(newBus).Capacity); + + settings._messageBufferSize = 1; + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + + Assert.AreEqual(1, IMessageBus.GlobalMessageBufferSize); + Assert.AreEqual(1, GetEmissionBuffer(existingBus).Capacity); + Assert.AreEqual(1, GetEmissionBuffer(newBus).Capacity); + } + finally + { + overrideToken?.Dispose(); + UnityEngine.Object.DestroyImmediate(settings); + IMessageBus.GlobalMessageBufferSize = originalBufferSize; + } + } + [UnityTest] public IEnumerator ZeroBufferSizeDiscardsEmissions() { diff --git a/Tests/Runtime/Core/LeakWatcherSelfTests.cs b/Tests/Runtime/Core/LeakWatcherSelfTests.cs index ba594a49..9d1fd3e7 100644 --- a/Tests/Runtime/Core/LeakWatcherSelfTests.cs +++ b/Tests/Runtime/Core/LeakWatcherSelfTests.cs @@ -1,8 +1,10 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using DxMessaging.Core; + using DxMessaging.Core.Configuration; using DxMessaging.Core.MessageBus; using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; @@ -153,6 +155,206 @@ MessageScenario scenario yield break; } + [UnityTest] + public IEnumerator WatcherWithSlotsPassesAfterExplicitTrim( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherWithSlotsPassesAfterExplicitTrim) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + using IDisposable settingsOverride = ForceTrimEnabledSettings(); + IMessageBus bus = MessageHandler.MessageBus; + + using (LeakWatcher watcher = LeakWatcher.WatchWithSlots(label: scenario.DisplayName)) + { + int initialSlots = watcher.InitialSlotSnapshot; + MessageRegistrationHandle handle = RegisterCountingHandler(scenario, token, hostId); + Assert.GreaterOrEqual( + watcher.SlotSnapshot, + initialSlots + 1, + "[{0}] Watcher.SlotSnapshot must reflect occupied slots while registered.", + scenario.Kind + ); + + token.RemoveRegistration(handle); + _ = bus.Trim(force: true); + + Assert.AreEqual( + initialSlots, + watcher.SlotSnapshot, + "[{0}] Watcher.SlotSnapshot must return to the initial value after trim.", + scenario.Kind + ); + } + + yield break; + } + + [UnityTest] + public IEnumerator WatcherWithSlotsDetectsUnreclaimedSlotWhenNotThrowing( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherWithSlotsDetectsUnreclaimedSlotWhenNotThrowing) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + using IDisposable settingsOverride = ForceTrimEnabledSettings(); + IMessageBus bus = MessageHandler.MessageBus; + + int leakedTypeSlots; + int leakedTargetSlots; + int leakedSlots; + int leakedRegistrations; + string deltaDescription; + try + { + using ( + LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + throwOnLeak: false, + label: scenario.DisplayName + ) + ) + { + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId + ); + token.RemoveRegistration(handle); + + leakedRegistrations = watcher.LeakedRegistrations; + leakedTypeSlots = watcher.LeakedTypeSlots; + leakedTargetSlots = watcher.LeakedTargetSlots; + leakedSlots = watcher.LeakedSlots; + deltaDescription = watcher.DescribeDelta(); + } + + Assert.AreEqual( + 0, + leakedRegistrations, + "[{0}] WatchWithSlots must keep registration and slot leak accounting separate.", + scenario.Kind + ); + Assert.GreaterOrEqual( + leakedSlots, + 1, + "[{0}] WatchWithSlots must detect occupied slots left behind after deregistration without trim.", + scenario.Kind + ); + Assert.AreEqual(leakedSlots, leakedTypeSlots + leakedTargetSlots); + StringAssert.Contains("TypeSlots", deltaDescription); + StringAssert.Contains("TargetSlots", deltaDescription); + } + finally + { + _ = bus.Trim(force: true); + } + + yield break; + } + + [UnityTest] + public IEnumerator WatcherWithSlotsThrowsOnSlotOnlyLeak( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherWithSlotsThrowsOnSlotOnlyLeak) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + using IDisposable settingsOverride = ForceTrimEnabledSettings(); + IMessageBus bus = MessageHandler.MessageBus; + LeakWatcher watcher = LeakWatcher.WatchWithSlots(label: scenario.DisplayName); + + try + { + MessageRegistrationHandle handle = RegisterCountingHandler(scenario, token, hostId); + token.RemoveRegistration(handle); + + AssertionException exception = Assert.Throws( + watcher.Dispose, + "[{0}] WatchWithSlots must fail when registrations drain but occupied slots remain.", + scenario.Kind + ); + StringAssert.Contains("type slot delta", exception.Message); + } + finally + { + _ = bus.Trim(force: true); + } + + yield break; + } + + [UnityTest] + public IEnumerator DefaultWatcherIgnoresSlotOnlyFootprint( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DefaultWatcherIgnoresSlotOnlyFootprint) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + using IDisposable settingsOverride = ForceTrimEnabledSettings(); + IMessageBus bus = MessageHandler.MessageBus; + + try + { + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId + ); + token.RemoveRegistration(handle); + + Assert.GreaterOrEqual( + watcher.LeakedSlots, + 1, + "[{0}] The default watcher should still report slot drift.", + scenario.Kind + ); + Assert.AreEqual( + 0, + watcher.LeakedRegistrations, + "[{0}] Registration counters must be clean before default watcher disposal.", + scenario.Kind + ); + } + } + finally + { + _ = bus.Trim(force: true); + } + + yield break; + } + private static MessageRegistrationHandle RegisterCountingHandler( MessageScenario scenario, MessageRegistrationToken token, @@ -197,6 +399,51 @@ InstanceId target } } } + + private static IDisposable ForceTrimEnabledSettings() + { + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + settings._enableTrimApi = true; + settings._evictionEnabled = true; + settings._idleEvictionSeconds = 0f; + settings._evictionTickIntervalSeconds = 0f; + return new RuntimeSettingsScope( + DxMessagingRuntimeSettingsProvider.Override(settings), + settings + ); + } + + private sealed class RuntimeSettingsScope : IDisposable + { + private readonly IDisposable _overrideToken; + private readonly DxMessagingRuntimeSettings _settings; + private bool _disposed; + + public RuntimeSettingsScope( + IDisposable overrideToken, + DxMessagingRuntimeSettings settings + ) + { + _overrideToken = overrideToken; + _settings = settings; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _overrideToken.Dispose(); + if (_settings != null) + { + UnityEngine.Object.DestroyImmediate(_settings); + } + } + } } } #endif diff --git a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs index 74206bb3..1b10dd06 100644 --- a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs +++ b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs @@ -55,6 +55,19 @@ public void SetGlobalMessageBusAcceptsInterfaceImplementation() Assert.AreSame(wrapper, MessageHandler.MessageBus); } + [Test] + public void TrimAllUsesCurrentGlobalMessageBus() + { + CountingTrimMessageBus wrapper = new CountingTrimMessageBus(new GlobalMessageBus()); + MessageHandler.SetGlobalMessageBus(wrapper); + + IMessageBus.TrimResult result = MessageHandler.TrimAll(force: true); + + Assert.AreEqual(1, wrapper.TrimCallCount); + Assert.IsTrue(wrapper.LastForce); + Assert.AreEqual(default(IMessageBus.TrimResult), result); + } + [Test] public void OverrideGlobalMessageBusScopeRestoresPreviousBus() { @@ -70,9 +83,9 @@ public void OverrideGlobalMessageBusScopeRestoresPreviousBus() Assert.AreSame(primary, MessageHandler.MessageBus); } - private sealed class WrapperMessageBus : IMessageBus + private class WrapperMessageBus : IMessageBus { - private readonly IMessageBus _inner; + protected readonly IMessageBus _inner; public WrapperMessageBus(IMessageBus inner) { @@ -83,6 +96,10 @@ public WrapperMessageBus(IMessageBus inner) public int RegisteredGlobalSequentialIndex => _inner.RegisteredGlobalSequentialIndex; + public int OccupiedTypeSlots => _inner.OccupiedTypeSlots; + + public int OccupiedTargetSlots => _inner.OccupiedTargetSlots; + public int RegisteredBroadcast => _inner.RegisteredBroadcast; public int RegisteredTargeted => _inner.RegisteredTargeted; @@ -99,6 +116,8 @@ public WrapperMessageBus(IMessageBus inner) public long EmissionId => _inner.EmissionId; + public virtual IMessageBus.TrimResult Trim(bool force = false) => _inner.Trim(force); + public Action RegisterUntargeted(MessageHandler messageHandler, int priority = 0) where T : IUntargetedMessage => _inner.RegisterUntargeted(messageHandler, priority); @@ -221,5 +240,22 @@ public void SourcedBroadcast(ref InstanceId source, ref TMessage typed where TMessage : IBroadcastMessage => _inner.SourcedBroadcast(ref source, ref typedMessage); } + + private sealed class CountingTrimMessageBus : WrapperMessageBus + { + public CountingTrimMessageBus(IMessageBus inner) + : base(inner) { } + + public int TrimCallCount { get; private set; } + + public bool LastForce { get; private set; } + + public override IMessageBus.TrimResult Trim(bool force = false) + { + TrimCallCount++; + LastForce = force; + return base.Trim(force); + } + } } } diff --git a/Tests/Runtime/Core/Snapshots/public-surface.txt b/Tests/Runtime/Core/Snapshots/public-surface.txt index 6c8aa016..266d52b1 100644 --- a/Tests/Runtime/Core/Snapshots/public-surface.txt +++ b/Tests/Runtime/Core/Snapshots/public-surface.txt @@ -4,6 +4,8 @@ DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute DxMessaging.Core.Attributes.DxOptionalParameterAttribute DxMessaging.Core.Attributes.DxTargetedMessageAttribute DxMessaging.Core.Attributes.DxUntargetedMessageAttribute +DxMessaging.Core.Configuration.DxMessagingRuntimeSettings +DxMessaging.Core.Configuration.DxMessagingRuntimeSettingsProvider DxMessaging.Core.Diagnostics.MessageEmissionData DxMessaging.Core.Diagnostics.MessageRegistrationMetadata DxMessaging.Core.Diagnostics.MessageRegistrationType @@ -23,6 +25,7 @@ DxMessaging.Core.MessageBus.GlobalMessageBusProvider DxMessaging.Core.MessageBus.IMessageBus DxMessaging.Core.MessageBus.IMessageBus+BroadcastInterceptor`1 DxMessaging.Core.MessageBus.IMessageBus+TargetedInterceptor`1 +DxMessaging.Core.MessageBus.IMessageBus+TrimResult DxMessaging.Core.MessageBus.IMessageBus+UntargetedInterceptor`1 DxMessaging.Core.MessageBus.IMessageBusProvider DxMessaging.Core.MessageBus.IMessageRegistrationBuilder @@ -56,3 +59,6 @@ DxMessaging.Core.Messages.ReflexiveSendMode DxMessaging.Core.Messages.SourcedStringMessage DxMessaging.Core.Messages.StringMessage DxMessaging.Core.MessagingDebug +DxMessaging.Core.Pooling.IDxMessagingClock +DxMessaging.Core.Pooling.StopwatchClock +DxMessaging.Core.Pooling.UnityRealtimeClock diff --git a/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs index f1353475..343e9aef 100644 --- a/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs +++ b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs @@ -13,14 +13,12 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine.TestTools; /// - /// In-default-suite speed guard rail. The Unity Edit + Play mode test run - /// is supposed to finish in under a minute once stress, performance, and - /// allocation tests are filtered out. Verifying the wall-clock total of - /// every fixture from inside a single test is impossible (NUnit fixtures - /// do not compose), so this test instead measures a representative unit - /// of work that mirrors the per-test load of the default suite. If this - /// proxy regresses, the full default suite is almost certainly going to - /// breach the 60-second budget too. + /// In-default-suite speed guard rail. The default Unity Edit + Play mode + /// test run is supposed to finish in under a minute once the gated suites + /// are filtered out. The companion + /// fixture measures the runtime assembly wall clock directly; this test + /// keeps a small representative workload in the default suite so local + /// per-test regressions fail close to the changed code. /// /// /// This test runs as part of the default Unity test suite - it is a fast @@ -31,9 +29,11 @@ namespace DxMessaging.Tests.Runtime.Core /// Stress - high-volume registration / emission tests. /// Performance - throughput / latency benchmarks. /// Allocation - the zero-GC matrix. + /// MemoryReclaim - explicit trim and idle-sweep reclamation tests. + /// UnityRuntime - Unity-only runtime lifecycle tests. /// - /// CI runs the default suite (uncategorized tests, including this guard - /// rail) on every PR; the other categories are opt-in. + /// CI runs the default suite (tests outside the gated categories, including + /// this guard rail) on every PR; the gated categories are opt-in. /// public sealed class SuiteSpeedBudgetTest : MessagingTestBase { @@ -41,9 +41,8 @@ public sealed class SuiteSpeedBudgetTest : MessagingTestBase private static readonly TimeSpan RepresentativeBudget = TimeSpan.FromSeconds(5); /// - /// This test runs as part of the default Unity test suite - it is a - /// fast guard rail that fails when local performance regresses below - /// the 60-second whole-suite budget. + /// Measures a representative default-suite registration / emit / + /// deregistration workload under a small per-test budget. /// [UnityTest] public IEnumerator RepresentativeSubsetCompletesUnderBudget() diff --git a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs index c0295842..db0cf1a3 100644 --- a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs +++ b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs @@ -14,8 +14,8 @@ namespace DxMessaging.Tests.Runtime /// (Core, Integrations, Unity) within the runtime test assembly. The /// default Unity Edit + Play mode test run is supposed to finish in /// under 60 seconds once the Stress, Allocation, - /// Performance, and UnityRuntime categories are filtered - /// out. This setup fixture captures a timestamp at suite start (via + /// Performance, MemoryReclaim, and UnityRuntime + /// categories are filtered out. This setup fixture captures a timestamp at suite start (via /// ) and asserts the elapsed wall /// clock at suite end is below a soft and hard budget. /// @@ -48,9 +48,9 @@ namespace DxMessaging.Tests.Runtime /// defined by NUnit's internal PropertyNames table) and forwards /// each one to . /// If any test in the session is in the gated set - /// (Stress, Performance, Allocation, or UnityRuntime), - /// the wall-clock budget assertion is skipped because the gated suites - /// have their own CI-side timing budgets. + /// (Stress, Performance, Allocation, + /// MemoryReclaim, or UnityRuntime), the wall-clock budget assertion is skipped + /// because the gated suites have their own CI-side timing budgets. /// /// /// Mechanism alternative: a CI-side timer (e.g. measuring @@ -81,6 +81,7 @@ public sealed class SuiteWallClockBudgetTest "Stress", "Performance", "Allocation", + "MemoryReclaim", "UnityRuntime", }; @@ -123,7 +124,7 @@ public void EndSuiteTimer() if (_gatedCategoryDetected) { UnityEngine.Debug.Log( - "Skipping default-suite wall-clock assertion: a Stress/Performance/Allocation/UnityRuntime " + "Skipping default-suite wall-clock assertion: a Stress/Performance/Allocation/MemoryReclaim/UnityRuntime " + "test was observed in this run." ); return; @@ -134,7 +135,7 @@ public void EndSuiteTimer() Assert.Fail( $"Default suite wall clock ({elapsed.TotalSeconds:0.00}s) exceeded the hard budget " + $"({HardBudget.TotalSeconds:0.0}s). Reduce iteration counts or move offending tests " - + "behind a gated category (Stress/Performance/Allocation/UnityRuntime)." + + "behind a gated category (Stress/Performance/Allocation/MemoryReclaim/UnityRuntime)." ); } else if (elapsed > SoftBudget) diff --git a/Tests/Runtime/MemoryReclaim.meta b/Tests/Runtime/MemoryReclaim.meta new file mode 100644 index 00000000..f3efb8ba --- /dev/null +++ b/Tests/Runtime/MemoryReclaim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2b41bb743c0d49c2a1b4d1982d2ceac1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs new file mode 100644 index 00000000..c87b6335 --- /dev/null +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs @@ -0,0 +1,807 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.MemoryReclaim +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.Configuration; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Core.Pooling; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Core; + using NUnit.Framework; + using UnityEngine; + + [TestFixture] + [Category("MemoryReclaim")] + public sealed class MemoryReclamationTests : MessagingTestBase + { + private const int DistinctTargetCount = 1024; + private static readonly InstanceId HandlerOwner = new InstanceId(0x5A17_0001); + private static readonly InstanceId DefaultContext = new InstanceId(0x5A17_0002); + + [Test] + public void TrimEvictsEmptyTypeSlots( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + int baseline = bus.OccupiedTypeSlots; + + MessageRegistrationHandle first = RegisterFirst(scenario, token, DefaultContext); + MessageRegistrationHandle second = RegisterSecond(scenario, token, DefaultContext); + MessageRegistrationHandle third = RegisterThird(scenario, token, DefaultContext); + + token.RemoveRegistration(first); + token.RemoveRegistration(second); + token.RemoveRegistration(third); + + Assert.GreaterOrEqual( + bus.OccupiedTypeSlots, + baseline + 3, + "[{0}] deregistered distinct message types must remain occupied until trim.", + scenario.Kind + ); + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.GreaterOrEqual( + result.TypeSlotsEvicted, + 3, + "[{0}] trim must evict every empty distinct type slot.", + scenario.Kind + ); + Assert.AreEqual( + baseline, + bus.OccupiedTypeSlots, + "[{0}] occupied type slots must return to the pre-test baseline.", + scenario.Kind + ); + } + + [Test] + public void TrimEvictsEmptyTargetSlots( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.KindsWithComponentTarget) + )] + MessageScenario scenario + ) + { + MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + + List handles = new List( + DistinctTargetCount + ); + for (int i = 0; i < DistinctTargetCount; ++i) + { + handles.Add(RegisterFirst(scenario, token, new InstanceId(0x5A18_0000 + i))); + } + + foreach (MessageRegistrationHandle handle in handles) + { + token.RemoveRegistration(handle); + } + + Assert.GreaterOrEqual( + bus.OccupiedTargetSlots, + DistinctTargetCount, + "[{0}] every deregistered context must remain visible until trim.", + scenario.Kind + ); + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.GreaterOrEqual(result.TargetSlotsEvicted, DistinctTargetCount); + Assert.AreEqual( + 0, + bus.OccupiedTargetSlots, + "[{0}] trim must reclaim every empty target/source slot.", + scenario.Kind + ); + } + + [Test] + public void IdleEvictionFiresAfterInterval( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + FakeClock clock = new FakeClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 1d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst(scenario, token, DefaultContext); + token.RemoveRegistration(handle); + EmitSweepProbe(bus); + + Assert.GreaterOrEqual( + bus.OccupiedTypeSlots + bus.OccupiedTargetSlots, + 1, + "[{0}] the fresh empty slot must not be reclaimed before cadence elapses.", + scenario.Kind + ); + + clock.Advance(1d); + EmitSweepProbe(bus); + + Assert.AreEqual( + 0, + bus.OccupiedTypeSlots, + "[{0}] idle sweep must reclaim empty type slots after cadence.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.OccupiedTargetSlots, + "[{0}] idle sweep must reclaim empty target/source slots after cadence.", + scenario.Kind + ); + } + + [Test] + public void IdleEvictionLeavesNonEmptySlotsAlone( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + FakeClock clock = new FakeClock(); + MessageBus bus = new MessageBus( + clock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0d, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + int calls = 0; + + MessageRegistrationHandle handle = RegisterCountingFirst( + scenario, + token, + DefaultContext, + () => calls++ + ); + try + { + clock.Advance(3600d); + EmitSweepProbe(bus); + EmitSweepProbe(bus); + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual( + 1, + calls, + "[{0}] idle sweep must not remove live registrations.", + scenario.Kind + ); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + } + finally + { + token.RemoveRegistration(handle); + } + } + + [Test] + public void TrimDoesNotDisturbActiveDispatch( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + int trimmingHandlerCalls = 0; + int trailingHandlerCalls = 0; + + MessageRegistrationHandle trimmingHandle = RegisterCountingFirst( + scenario, + token, + DefaultContext, + () => + { + trimmingHandlerCalls++; + _ = bus.Trim(force: true); + }, + priority: 0 + ); + MessageRegistrationHandle trailingHandle = RegisterCountingFirst( + scenario, + token, + DefaultContext, + () => trailingHandlerCalls++, + priority: 1 + ); + try + { + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual(1, trimmingHandlerCalls); + Assert.AreEqual( + 1, + trailingHandlerCalls, + "[{0}] trim during dispatch must not disturb the active snapshot.", + scenario.Kind + ); + } + finally + { + token.RemoveRegistration(trimmingHandle); + token.RemoveRegistration(trailingHandle); + } + } + + [Test] + public void RuntimeSettingsHotReloadAppliesCaps() + { + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + IDisposable overrideToken = null; + try + { + settings._bufferMaxDistinctEntries = 4; + settings._bufferUseLruEviction = true; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + MessageBus bus = new MessageBus(new FakeClock()); + + List pooled = DxPools.ObjectLists.Rent(); + DxPools.ObjectLists.Return(pooled); + Assert.Greater(DxPools.DescribeAll().ObjectLists.Cached, 0); + + settings._bufferMaxDistinctEntries = 0; + settings._bufferUseLruEviction = false; + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + + Assert.AreEqual(0, DxPools.ObjectLists.MaxRetained); + Assert.IsFalse(DxPools.ObjectLists.UseLru); + Assert.AreEqual(0, DxPools.DescribeAll().ObjectLists.Cached); + GC.KeepAlive(bus); + } + finally + { + overrideToken?.Dispose(); + UnityEngine.Object.DestroyImmediate(settings); + _ = DxPools.TrimAll(force: true); + } + } + + [Test] + public void RuntimeSettingsHotReloadUpdatesTrimAndIdleGates() + { + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + IDisposable overrideToken = null; + try + { + settings._enableTrimApi = true; + settings._evictionEnabled = false; + settings._idleEvictionSeconds = 30f; + settings._evictionTickIntervalSeconds = 60f; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + FakeClock clock = new FakeClock(); + MessageBus bus = new MessageBus(clock); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst( + MessageScenario.Untargeted(), + token, + DefaultContext + ); + token.RemoveRegistration(handle); + + clock.Advance(60d); + EmitSweepProbe(bus); + EmitSweepProbe(bus); + Assert.GreaterOrEqual( + bus.OccupiedTypeSlots, + 1, + "Initial disabled idle eviction setting must prevent emit-time sweep." + ); + + settings._enableTrimApi = false; + settings._evictionEnabled = true; + settings._idleEvictionSeconds = 0f; + settings._evictionTickIntervalSeconds = 0f; + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + Assert.AreEqual(default(IMessageBus.TrimResult), bus.Trim(force: true)); + + EmitSweepProbe(bus); + EmitSweepProbe(bus); + Assert.AreEqual( + 0, + bus.OccupiedTypeSlots, + "Hot-reloaded idle settings must enable emit-time reclamation on an existing bus." + ); + } + finally + { + overrideToken?.Dispose(); + UnityEngine.Object.DestroyImmediate(settings); + _ = DxPools.TrimAll(force: true); + } + } + + [Test] + public void TrimRespectsEnableTrimApiFlag() + { + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + IDisposable overrideToken = null; + try + { + settings._enableTrimApi = false; + settings._evictionEnabled = true; + settings._bufferMaxDistinctEntries = 4; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + MessageBus bus = new MessageBus(new FakeClock()); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst( + MessageScenario.Untargeted(), + token, + DefaultContext + ); + token.RemoveRegistration(handle); + List pooled = DxPools.ObjectLists.Rent(); + DxPools.ObjectLists.Return(pooled); + int cachedBefore = DxPools.DescribeAll().ObjectLists.Cached; + + IMessageBus.TrimResult result = bus.Trim(force: true); + + Assert.AreEqual(default(IMessageBus.TrimResult), result); + Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); + Assert.AreEqual(cachedBefore, DxPools.DescribeAll().ObjectLists.Cached); + } + finally + { + overrideToken?.Dispose(); + UnityEngine.Object.DestroyImmediate(settings); + _ = DxPools.TrimAll(force: true); + } + } + + [Test] + public void TrimAfterDeregisterReclaimsHandlerCache( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.KindsWithComponentTarget) + )] + MessageScenario scenario + ) + { + DxMessagingRuntimeSettings settings = + ScriptableObject.CreateInstance(); + IDisposable overrideToken = null; + try + { + settings._bufferMaxDistinctEntries = 4; + settings._bufferUseLruEviction = true; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + _ = DxPools.TrimAll(force: true); + MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst(scenario, token, DefaultContext); + token.RemoveRegistration(handle); + EmitSweepProbe(bus); + int contextDictionariesBefore = DxPools + .DescribeAll() + .TypedHandlerContextDicts.Cached; + int priorityDictionariesBefore = DxPools + .DescribeAll() + .TypedHandlerPriorityDicts.Cached; + + IMessageBus.TrimResult result = bus.Trim(force: false); + + Assert.GreaterOrEqual( + result.TypeSlotsEvicted, + 1, + "[{0}] non-force trim must reclaim idle empty typed-handler slots.", + scenario.Kind + ); + Assert.Greater( + DxPools.DescribeAll().TypedHandlerContextDicts.Cached, + contextDictionariesBefore, + "[{0}] trim must return the typed-handler context dictionary to the pool.", + scenario.Kind + ); + Assert.Greater( + DxPools.DescribeAll().TypedHandlerPriorityDicts.Cached, + priorityDictionariesBefore, + "[{0}] trim must return the typed-handler priority dictionary to the pool.", + scenario.Kind + ); + } + finally + { + overrideToken?.Dispose(); + UnityEngine.Object.DestroyImmediate(settings); + _ = DxPools.TrimAll(force: true); + } + } + + [Test] + public void ResetGenerationBumpInvalidatesPostEvictDeregister( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + int currentCalls = 0; + int staleCalls = 0; + List logs = new List(); + Action previousLogFunction = MessagingDebug.LogFunction; + bool previousMessagingDebugEnabled = MessagingDebug.enabled; + Action currentDeregister = null; + + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + try + { + Action staleDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => staleCalls++ + ); + staleDeregister(); + _ = bus.Trim(force: true); + + currentDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => currentCalls++ + ); + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (_, message) => logs.Add(message); + staleDeregister(); + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual( + 0, + staleCalls, + "[{0}] stale deregistration must not revive or invoke the old handler.", + scenario.Kind + ); + Assert.AreEqual( + 1, + currentCalls, + "[{0}] stale deregistration must not remove a later registration.", + scenario.Kind + ); + Assert.AreEqual( + 0, + logs.Count, + "[{0}] stale deregistration must not log diagnostics.", + scenario.Kind + ); + currentDeregister(); + currentDeregister = null; + _ = bus.Trim(force: true); + } + finally + { + MessagingDebug.enabled = previousMessagingDebugEnabled; + MessagingDebug.LogFunction = previousLogFunction; + currentDeregister?.Invoke(); + _ = bus.Trim(force: true); + } + } + + private static MessageRegistrationToken CreateEnabledToken(MessageBus bus) + { + MessageHandler handler = CreateActiveHandler(bus); + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + return token; + } + + private static MessageHandler CreateActiveHandler(MessageBus bus) + { + return new MessageHandler(HandlerOwner, bus) { active = true }; + } + + private static IDisposable ForceTrimCleanup(MessageBus bus) + { + return new CleanupScope(() => + { + _ = bus.Trim(force: true); + _ = DxPools.TrimAll(force: true); + }); + } + + private static MessageRegistrationHandle RegisterFirst( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId context + ) + { + return RegisterCountingFirst(scenario, token, context, () => { }); + } + + private static MessageRegistrationHandle RegisterSecond( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId context + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargeted((ref UntargetedTwo _) => { }); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted(context, (ref TargetedTwo _) => { }); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + context, + (ref BroadcastTwo _) => { } + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static MessageRegistrationHandle RegisterThird( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId context + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargeted( + (ref UntargetedThree _) => { } + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + context, + (ref TargetedThree _) => { } + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + context, + (ref BroadcastThree _) => { } + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static MessageRegistrationHandle RegisterCountingFirst( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId context, + Action onMessage, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargeted( + (ref UntargetedOne _) => onMessage(), + priority: priority + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + context, + (ref TargetedOne _) => onMessage(), + priority: priority + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + context, + (ref BroadcastOne _) => onMessage(), + priority: priority + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static Action RegisterDirect( + MessageScenario scenario, + MessageHandler handler, + MessageBus bus, + InstanceId context, + Action onMessage + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + Action callback = _ => onMessage(); + return handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Targeted: + { + Action callback = _ => onMessage(); + return handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Broadcast: + { + Action callback = _ => onMessage(); + return handler.RegisterSourcedBroadcastMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static void EmitFirst(MessageScenario scenario, MessageBus bus, InstanceId context) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + UntargetedOne message = new UntargetedOne(); + bus.UntargetedBroadcast(ref message); + return; + } + case MessageKind.Targeted: + { + TargetedOne message = new TargetedOne(); + bus.TargetedBroadcast(ref context, ref message); + return; + } + case MessageKind.Broadcast: + { + BroadcastOne message = new BroadcastOne(); + bus.SourcedBroadcast(ref context, ref message); + return; + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static void EmitSweepProbe(MessageBus bus) + { + SweepProbeMessage message = new SweepProbeMessage(); + bus.UntargetedBroadcast(ref message); + } + + private static ArgumentOutOfRangeException UnsupportedScenario(MessageScenario scenario) + { + return new ArgumentOutOfRangeException( + nameof(scenario), + scenario?.Kind, + "Unsupported message kind." + ); + } + + private sealed class CleanupScope : IDisposable + { + private readonly Action _cleanup; + private bool _disposed; + + public CleanupScope(Action cleanup) + { + _cleanup = cleanup; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _cleanup(); + } + } + + private readonly struct UntargetedOne : IUntargetedMessage { } + + private readonly struct UntargetedTwo : IUntargetedMessage { } + + private readonly struct UntargetedThree : IUntargetedMessage { } + + private readonly struct TargetedOne : ITargetedMessage { } + + private readonly struct TargetedTwo : ITargetedMessage { } + + private readonly struct TargetedThree : ITargetedMessage { } + + private readonly struct BroadcastOne : IBroadcastMessage { } + + private readonly struct BroadcastTwo : IBroadcastMessage { } + + private readonly struct BroadcastThree : IBroadcastMessage { } + + private readonly struct SweepProbeMessage : IUntargetedMessage { } + } +} +#endif diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs.meta b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs.meta new file mode 100644 index 00000000..4cbc76b5 --- /dev/null +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f7d896d6c404f57bb22acb78842e5df +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/TestUtilities/FakeClock.cs b/Tests/Runtime/TestUtilities/FakeClock.cs new file mode 100644 index 00000000..8ad8c88b --- /dev/null +++ b/Tests/Runtime/TestUtilities/FakeClock.cs @@ -0,0 +1,51 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using DxMessaging.Core.Pooling; + + /// + /// Manually advanced for deterministic + /// eviction tests. Tests construct a FakeClock, inject it into the system + /// under test, and call to push the apparent time + /// forward without sleeping. + /// + public sealed class FakeClock : IDxMessagingClock + { + private double _now; + + public FakeClock(double initialSeconds = 0d) + { + _now = initialSeconds; + } + + /// + public double NowSeconds => _now; + + /// Advance the clock by the given number of seconds. + public void Advance(double seconds) + { + if (seconds < 0d) + { + throw new System.ArgumentOutOfRangeException( + nameof(seconds), + "FakeClock.Advance does not accept negative deltas." + ); + } + _now += seconds; + } + + /// Set the clock to an absolute value. Must be non-decreasing. + public void SetTo(double seconds) + { + if (seconds < _now) + { + throw new System.ArgumentOutOfRangeException( + nameof(seconds), + "FakeClock is monotonic; cannot rewind." + ); + } + _now = seconds; + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/FakeClock.cs.meta b/Tests/Runtime/TestUtilities/FakeClock.cs.meta new file mode 100644 index 00000000..317d9812 --- /dev/null +++ b/Tests/Runtime/TestUtilities/FakeClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: faed8fe63a3d2ef866b1e732f0c16fcf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/TestUtilities/LeakWatcher.cs b/Tests/Runtime/TestUtilities/LeakWatcher.cs index 8c4baa6a..2351ef54 100644 --- a/Tests/Runtime/TestUtilities/LeakWatcher.cs +++ b/Tests/Runtime/TestUtilities/LeakWatcher.cs @@ -26,6 +26,11 @@ namespace DxMessaging.Tests.Runtime /// the public surface, so the watcher does not lift any internal field. If a /// future API adds a seventh registration kind, and /// need to be extended in lock-step. + /// Slot occupancy counters ( and + /// ) are also captured and + /// reported. They are enforced only by so the + /// default watcher remains suitable for registration-only leak checks that + /// do not force a trim inside the watched region. /// /// /// Allocation note: the getter and the @@ -59,6 +64,9 @@ public sealed class LeakWatcher : IDisposable private readonly int _initialInterceptors; private readonly int _initialPostProcessors; private readonly int _initialGlobalAcceptAll; + private readonly int _initialTypeSlotCount; + private readonly int _initialTargetSlotCount; + private readonly bool _watchSlots; private bool _disposed; private int _finalUntargeted; @@ -67,6 +75,8 @@ public sealed class LeakWatcher : IDisposable private int _finalInterceptors; private int _finalPostProcessors; private int _finalGlobalAcceptAll; + private int _finalTypeSlotCount; + private int _finalTargetSlotCount; /// /// Captures the initial registration counts on the supplied @@ -85,7 +95,12 @@ public sealed class LeakWatcher : IDisposable /// Optional label included in the failure message. Useful for /// distinguishing multiple watchers in a single test. /// - public LeakWatcher(IMessageBus bus = null, bool throwOnLeak = true, string label = null) + public LeakWatcher( + IMessageBus bus = null, + bool throwOnLeak = true, + string label = null, + bool watchSlots = false + ) { _bus = bus ?? MessageHandler.MessageBus; if (_bus == null) @@ -98,12 +113,15 @@ public LeakWatcher(IMessageBus bus = null, bool throwOnLeak = true, string label _throwOnLeak = throwOnLeak; _label = label; + _watchSlots = watchSlots; _initialUntargeted = _bus.RegisteredUntargeted; _initialTargeted = _bus.RegisteredTargeted; _initialBroadcast = _bus.RegisteredBroadcast; _initialInterceptors = _bus.RegisteredInterceptors; _initialPostProcessors = _bus.RegisteredPostProcessors; _initialGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + _initialTypeSlotCount = _bus.OccupiedTypeSlots; + _initialTargetSlotCount = _bus.OccupiedTargetSlots; } /// @@ -116,6 +134,36 @@ public static LeakWatcher Watch(string label = null) return new LeakWatcher(bus: null, throwOnLeak: true, label: label); } + /// + /// Convenience factory that also asserts per-type and per-target slot + /// occupancy returns to the starting counts. Use when the watched + /// region performs an explicit trim or otherwise expects all empty + /// memory-reclamation slots to be reclaimed before disposal. + /// + public static LeakWatcher WatchWithSlots(string label = null) + { + return new LeakWatcher(bus: null, throwOnLeak: true, label: label, watchSlots: true); + } + + /// + /// Convenience factory for a specific bus that also asserts + /// per-type and per-target slot occupancy returns to the starting + /// counts. + /// + public static LeakWatcher WatchWithSlots( + IMessageBus bus, + bool throwOnLeak = true, + string label = null + ) + { + return new LeakWatcher( + bus: bus, + throwOnLeak: throwOnLeak, + label: label, + watchSlots: true + ); + } + /// /// The total registration count read from the live bus across all /// six counter kinds (handler counts plus interceptor, post-processor, @@ -153,6 +201,19 @@ public int Snapshot + _initialPostProcessors + _initialGlobalAcceptAll; + /// + /// The live occupied-slot count across per-message-type and + /// per-target/source slots. Updates on each read until disposal. + /// + public int SlotSnapshot => + (_disposed ? _finalTypeSlotCount : _bus.OccupiedTypeSlots) + + (_disposed ? _finalTargetSlotCount : _bus.OccupiedTargetSlots); + + /// + /// The occupied-slot count captured at construction. + /// + public int InitialSlotSnapshot => _initialTypeSlotCount + _initialTargetSlotCount; + /// /// Number of additional registrations leaked relative to the initial /// snapshot. Negative values indicate a regression where the watched @@ -191,6 +252,28 @@ public int LeakedRegistrations } } + /// + /// Number of occupied per-message-type slots leaked relative to the + /// initial snapshot. Negative values indicate the watched region + /// reclaimed slots it did not create. + /// + public int LeakedTypeSlots => + (_disposed ? _finalTypeSlotCount : _bus.OccupiedTypeSlots) - _initialTypeSlotCount; + + /// + /// Number of occupied per-target/source slots leaked relative to the + /// initial snapshot. Negative values indicate the watched region + /// reclaimed slots it did not create. + /// + public int LeakedTargetSlots => + (_disposed ? _finalTargetSlotCount : _bus.OccupiedTargetSlots) + - _initialTargetSlotCount; + + /// + /// Total occupied slot drift relative to the initial snapshot. + /// + public int LeakedSlots => LeakedTypeSlots + LeakedTargetSlots; + /// /// Returns a one-line per-counter description of the delta between the /// initial snapshot and the current (or final, post-disposal) bus @@ -215,6 +298,10 @@ public string DescribeDelta() int currentGlobalAcceptAll = _disposed ? _finalGlobalAcceptAll : _bus.RegisteredGlobalAcceptAll; + int currentTypeSlotCount = _disposed ? _finalTypeSlotCount : _bus.OccupiedTypeSlots; + int currentTargetSlotCount = _disposed + ? _finalTargetSlotCount + : _bus.OccupiedTargetSlots; int delta = TotalDelta( currentUntargeted, @@ -230,7 +317,7 @@ public string DescribeDelta() CultureInfo.InvariantCulture, "LeakWatcher{0}: delta={1} (Untargeted {2}->{3}, Targeted {4}->{5}, " + "Broadcast {6}->{7}, Interceptors {8}->{9}, PostProcessors {10}->{11}, " - + "GlobalAcceptAll {12}->{13}).", + + "GlobalAcceptAll {12}->{13}, TypeSlots {14}->{15}, TargetSlots {16}->{17}).", scope, delta, _initialUntargeted, @@ -244,7 +331,11 @@ public string DescribeDelta() _initialPostProcessors, currentPostProcessors, _initialGlobalAcceptAll, - currentGlobalAcceptAll + currentGlobalAcceptAll, + _initialTypeSlotCount, + currentTypeSlotCount, + _initialTargetSlotCount, + currentTargetSlotCount ); } @@ -268,6 +359,8 @@ public void Dispose() _finalInterceptors = _bus.RegisteredInterceptors; _finalPostProcessors = _bus.RegisteredPostProcessors; _finalGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + _finalTypeSlotCount = _bus.OccupiedTypeSlots; + _finalTargetSlotCount = _bus.OccupiedTargetSlots; int delta = TotalDelta( _finalUntargeted, @@ -277,7 +370,10 @@ public void Dispose() _finalPostProcessors, _finalGlobalAcceptAll ); - if (delta == 0) + int typeSlotDelta = _finalTypeSlotCount - _initialTypeSlotCount; + int targetSlotDelta = _finalTargetSlotCount - _initialTargetSlotCount; + bool slotDeltaIsClean = !_watchSlots || (typeSlotDelta == 0 && targetSlotDelta == 0); + if (delta == 0 && slotDeltaIsClean) { return; } @@ -287,17 +383,20 @@ public void Dispose() return; } - Assert.Fail(BuildFailureMessage(delta)); + Assert.Fail(BuildFailureMessage(delta, typeSlotDelta, targetSlotDelta)); } - private string BuildFailureMessage(int delta) + private string BuildFailureMessage(int delta, int typeSlotDelta, int targetSlotDelta) { string scope = string.IsNullOrEmpty(_label) ? string.Empty : $" ({_label})"; return string.Format( CultureInfo.InvariantCulture, - "LeakWatcher{0}: registration count changed by {1} during the watched region. " + "LeakWatcher{0}: watched counts changed during the region. " + + "Registration delta={1}; " + + "type slot delta={14}, target slot delta={15}. " + "Untargeted {2}->{3}, Targeted {4}->{5}, Broadcast {6}->{7}, " - + "Interceptors {8}->{9}, PostProcessors {10}->{11}, GlobalAcceptAll {12}->{13}.", + + "Interceptors {8}->{9}, PostProcessors {10}->{11}, GlobalAcceptAll {12}->{13}, " + + "TypeSlots {16}->{17}, TargetSlots {18}->{19}.", scope, delta, _initialUntargeted, @@ -311,7 +410,13 @@ private string BuildFailureMessage(int delta) _initialPostProcessors, _finalPostProcessors, _initialGlobalAcceptAll, - _finalGlobalAcceptAll + _finalGlobalAcceptAll, + typeSlotDelta, + targetSlotDelta, + _initialTypeSlotCount, + _finalTypeSlotCount, + _initialTargetSlotCount, + _finalTargetSlotCount ); } diff --git a/llms.txt b/llms.txt index 9d6502c9..857c19f5 100644 --- a/llms.txt +++ b/llms.txt @@ -208,7 +208,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 144+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 146+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -287,5 +287,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-03 +**Last Updated:** 2026-05-04 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata From f3c04f033f129a6ec624c9a591f0f69d6e873f67 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Mon, 4 May 2026 18:02:56 -0700 Subject: [PATCH 02/16] Test stabilization --- .llm/skills/index.md | 2 +- ...location-coverage-required-for-dispatch.md | 35 +- CHANGELOG.md | 21 +- Editor.meta | 8 - .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 25088 -> 26112 bytes Runtime/AssemblyInfo.cs | 3 + Runtime/Core/DataStructure/CyclicBuffer.cs | 2 +- Runtime/Core/MessageBus/MessageBus.cs | 72 +- Runtime/Core/MessageHandler.cs | 52 +- .../Reflex/ReflexRegistrationInstaller.cs | 73 ++ .../VContainerRegistrationExtensions.cs | 110 ++- .../Zenject/ZenjectRegistrationInstaller.cs | 80 +- Samples~/DI/Reflex/SampleInstaller.cs | 7 + Samples~/DI/VContainer/SampleLifetimeScope.cs | 7 +- Samples~/DI/Zenject/SampleInstaller.cs | 8 + .../Allocations/AllocationMatrixTests.cs | 101 ++- .../Contract/EvictionSweepContractTests.cs | 50 +- Tests/Editor/Contract/ReflectionHelpers.cs | 373 +++++++++ .../Editor/Contract/ReflectionHelpers.cs.meta | 11 + Tests/Editor/Contract/TypedSlotShapeTests.cs | 333 +++++++- Tests/Runtime/Core/BaseCallContractTests.cs | 85 +- Tests/Runtime/Core/DiagnosticsTests.cs | 4 +- ...utationPostProcessorAcrossHandlersTests.cs | 731 ++++++++++++++++-- Tests/Runtime/Core/NominalTests.cs | 196 ++++- .../Core/PublicSurfaceContractTests.cs | 360 ++++++++- .../Core/RegistrationCountAssertionsTests.cs | 297 +++++++ .../RegistrationCountAssertionsTests.cs.meta | 11 + .../Core/TestAttributeContractTests.cs | 17 +- .../Reflex/ReflexIntegrationTests.cs | 169 ++++ .../VContainer/VContainerIntegrationTests.cs | 364 ++++++++- .../Zenject/ZenjectIntegrationTests.cs | 163 ++++ .../MemoryReclaim/MemoryReclamationTests.cs | 23 +- .../Components/SimpleMessageAwareComponent.cs | 2 + Tests/Runtime/TestUtilities/FakeClock.cs | 14 +- Tests/Runtime/TestUtilities/MessageKind.cs | 15 +- .../Runtime/TestUtilities/MessageScenario.cs | 10 + .../Runtime/TestUtilities/MessageScenarios.cs | 44 ++ .../RegistrationCountAssertions.cs | 261 +++++++ .../RegistrationCountAssertions.cs.meta | 11 + docs/architecture/comparisons.md | 8 +- docs/architecture/performance.md | 22 +- docs/integrations/vcontainer.md | 16 +- llms.txt | 2 +- 43 files changed, 3837 insertions(+), 336 deletions(-) delete mode 100644 Editor.meta create mode 100644 Tests/Editor/Contract/ReflectionHelpers.cs create mode 100644 Tests/Editor/Contract/ReflectionHelpers.cs.meta create mode 100644 Tests/Runtime/Core/RegistrationCountAssertionsTests.cs create mode 100644 Tests/Runtime/Core/RegistrationCountAssertionsTests.cs.meta create mode 100644 Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs create mode 100644 Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs.meta diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 4d134229..8c8022c3 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -168,7 +168,7 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ------------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ---------------- | ---------------------------- | -| [Allocation Coverage Required for Dispatch](./testing/allocation-coverage-required-for-dispatch.md) | [ok] 259 | [intermediate] | [stable] | [risk: critical] | testing, allocation | +| [Allocation Coverage Required for Dispatch](./testing/allocation-coverage-required-for-dispatch.md) | [warn] 262 | [intermediate] | [stable] | [risk: critical] | testing, allocation | | [Data-Driven Coverage Patterns](./testing/test-coverage-data-driven.md) | [ok] 173 | [intermediate] | [stable] | [risk: none] | testing, data-driven | | [Data-Driven Test Sources](./testing/data-driven-tests-sources.md) | [ok] 256 | [intermediate] | [stable] | [risk: none] | testing, parameterized | | [Data-Driven Test Usage Patterns](./testing/data-driven-tests-usage.md) | [draft] 108 | [intermediate] | [stable] | [risk: none] | testing, parameterized | diff --git a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md index 5bdc001c..f873e85c 100644 --- a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md +++ b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md @@ -9,7 +9,7 @@ updated: "2026-05-01" source: repository: "wallstop/DxMessaging" files: - - path: "Tests/Runtime/Benchmarks/AllocationMatrixTests.cs" + - path: "Tests/Editor/Allocations/AllocationMatrixTests.cs" - path: "Tests/Runtime/TestUtilities/AllocationAssertions.cs" - path: "Tests/Runtime/TestUtilities/MessageScenarios.cs" url: "https://github.com/wallstop/DxMessaging" @@ -82,7 +82,7 @@ DxMessaging promises zero managed allocations on the steady-state dispatch path. A regression there is silent: messages still flow, callers still receive them, only the GC profile gets worse - and only at scale. The defense is a matrix of allocation tests pinned in -`Tests/Runtime/Benchmarks/AllocationMatrixTests.cs` that asserts byte budgets +`Tests/Editor/Allocations/AllocationMatrixTests.cs` that asserts byte budgets on the bare register / emit / deregister surface across every dispatch axis (kind, interceptor presence, post-processor presence, diagnostics, priority). @@ -115,27 +115,30 @@ budget blown in production. Two requirements stack: 1. Every dispatch path with a stable signature must have an - `AllocationMatrixTests` row that exercises it via the parameterized - `MessageScenarios` source. Use `AllocationAssertions.AssertNoAllocations` + `AllocationMatrixTests` row that exercises it via the appropriate + parameterized `MessageScenarios` source. Use `AllocationAssertions.AssertNoAllocations` for paths that must allocate exactly zero managed bytes per call, and a hand-rolled `GC.GetTotalAllocatedBytes(precise: true)` delta with an explicit `Is.LessThanOrEqualTo(byteBudget)` for paths where a small, documented ceiling is intentional (for example registration and deregistration). -1. Every `MessageKind` value must appear in `MessageScenarios.AllKinds`. - Anything driven by `[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))]` - automatically picks up the new kind once it lands there. +1. Every `MessageKind` value must appear in + `MessageScenarios.AllKindsIncludingWithoutContext`. Anything driven by + `[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKindsIncludingWithoutContext))]` + automatically picks up the new kind once it lands there. Tests that + intentionally cover only the context-bound surfaces should use + `MessageScenarios.AllKinds`. ### Adding a Zero-Allocation Row Patterned after `EmitIsZeroAlloc` in -`Tests/Runtime/Benchmarks/AllocationMatrixTests.cs`: +`Tests/Editor/Allocations/AllocationMatrixTests.cs`: ```csharp [Test] [Category("Allocation")] public void EmitWithMetadataIsZeroAlloc( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKindsIncludingWithoutContext))] MessageScenario scenario ) { @@ -173,7 +176,7 @@ state, and assert against an explicit byte budget: [Test] [Category("Allocation")] public void RegisterIsZeroAllocSteadyState( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKindsIncludingWithoutContext))] MessageScenario scenario ) { @@ -210,9 +213,9 @@ pay for, so reviewers can audit relaxations. `Tests/Runtime/Core/TestAttributeContractTests.cs` contains `EveryEmitPathHasAllocationCoverage`. The test enumerates every `MessageKind` value via reflection and asserts that -`MessageScenarios.AllKinds` yields a scenario for each. Adding a new kind -without updating the source - and therefore the allocation matrix that -consumes it - fails the build. +`MessageScenarios.AllKindsIncludingWithoutContext` yields a scenario for each. +Adding a new kind without updating the full-surface source - and therefore the +tests that consume it - fails the build. The contract pin is intentionally narrow (kind enumeration). It cannot prove that every individual `Emit*` method is covered, but it does guarantee the @@ -227,8 +230,8 @@ common drift point. dispatch path. - Tag every allocation test with `[Category("Allocation")]` so the default-suite speed budget skips them. -- Use `MessageScenarios.AllKinds` (or a narrower source) so the row - automatically expands when a new kind is added. +- Use `MessageScenarios.AllKindsIncludingWithoutContext` for full dispatch-surface + rows, or a narrower source when the test intentionally covers only a subset. - Build emit closures outside the assertion zone. ### Don't @@ -236,7 +239,7 @@ common drift point. - Don't measure inside `[SetUp]` / `[TearDown]`; the harness state is not guaranteed stable. - Don't add a kind to `MessageKind` without adding it to - `MessageScenarios.AllKinds`; the contract test will fail. + `MessageScenarios.AllKindsIncludingWithoutContext`; the contract test will fail. - Don't relax a budget without explaining the new ceiling in the test's XML doc comment. diff --git a/CHANGELOG.md b/CHANGELOG.md index b631529c..43b88f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,20 +25,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TestAttributeContractTests` extensions enforcing kind-parameterization and allocation coverage discipline. - Three new public read-only registration counters on `IMessageBus`: `RegisteredInterceptors`, `RegisteredPostProcessors`, and `RegisteredGlobalAcceptAll`. Lets diagnostic and leak-check tooling distinguish interceptor / post-processor / global accept-all leaks from regular handler leaks, and lets external monitors aggregate the bus's registration footprint without reflecting on internals. `MessageBus` aggregates the counters on each read by walking the per-message-type caches; consumers polling these properties in tight loops should snapshot at region boundaries. - Runtime memory-reclamation foundations: `DxMessagingRuntimeSettings` loads from `Resources/DxMessagingRuntimeSettings` and hot-reloads eviction cadence, enablement, trim opt-out, and pool-cap changes without recreating the bus. Pooled internal collections and typed/bus slot registries preserve existing dispatch APIs while making empty handler and interceptor slots reclaimable. `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` reset dirty empty slots and trim shared pools on demand, `OccupiedTypeSlots` / `OccupiedTargetSlots` expose the retained bus and dirty typed-handler slot footprint for diagnostics, and idle sweeps run from emits and Unity's PlayerLoop. - -### Fixed - -- Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). Two related defects are fixed in the same change: the sourced-broadcast and broadcast-without-source dispatch loops short-circuited on the live `cache.handlers.Count == 0` even when the per-emission snapshot still held the deregistered handler, and post-processor prefreeze took a single-bucket/single-entry fast-path that skipped pre-freezing per-MessageHandler post-processor caches -- a regular handler that registered a new post-processor on the same MessageHandler+priority during its own callback would then see the new post-processor fire on the in-flight emission instead of waiting for the next one. The bus now pre-freezes every priority bucket's typed-handler caches up-front for every dispatch surface (sourced-broadcast, broadcast-without-source, targeted-without-targeting), uses the per-emission snapshot count for the dispatch-loop early-out, and unconditionally pre-freezes post-processor caches; removals and post-processor registrations performed mid-emit now consistently only take effect on the next emission. -- `DxMessagingStaticState.Reset` is now race-safe against deferred deregistrations. Previously, when a message-aware component was destroyed but its disable callback had not yet run (Unity defers Object.Destroy to end of frame) and Reset ran in between, the deferred token teardown would log spurious "Received over-deregistration of {type} for {handler}" errors against the user's Unity console. The bus now stamps each captured deregister closure with a generation counter and silently no-ops closures captured before a Reset. Applied uniformly across every register entry point (untargeted, targeted, broadcast, GlobalAcceptAll, and all three interceptor kinds). The same race-safety guarantee is now propagated to user-installed custom global buses via `MessageBus.BumpResetGeneration()`, which `DxMessagingStaticState.Reset` invokes on the active global bus when it differs from the built-in default; the custom bus's sinks are intentionally left intact to avoid clobbering state the user installed it to preserve. User code is unaffected except that previously-spurious error logs disappear. -- `MessageRegistrationToken.RemoveRegistration(handle)` no longer leaks the staged registration entry, so a `Disable()`/`Enable()` cycle after `RemoveRegistration` no longer silently re-registers the removed handler. The fix also drops the matching metadata and call-count entries so diagnostic mode does not accumulate stale handles. +- New explicit-factory registration helpers across all three DI integrations: `VContainerRegistrationExtensions.RegisterDxMessagingBus`, `ReflexRegistrationExtensions.AddDxMessagingBus`, and `ZenjectRegistrationExtensions.BindDxMessagingBus`. Each helper exposes the bus under both the concrete `MessageBus` contract and the `IMessageBus` interface, accepts an overloadable lifetime where the container supports it, accepts a user-supplied `Func` factory, and accepts an `IDxMessagingClock` overload that constructs the bus through the new internal-only `MessageBus.CreateForInternalUse` factory so test-side clocks (for example `FakeClock`) can be injected through the container. The VContainer helper registers both contracts in one registration call, avoiding VContainer environments where chained `.AsSelf().As()` drops the concrete contract and fails with `No such registration of type: DxMessaging.Core.MessageBus.MessageBus`; the DI samples either call the helper directly or document the corresponding helper preference for their container shape. ### Changed +- `MessageBus`'s clock-taking constructors are no longer publicly visible. The four prior `internal MessageBus(IDxMessagingClock, ...)` overloads collapse into a single `internal static MessageBus CreateForInternalUse(IDxMessagingClock clock, long? idleEvictionTicks = null, double? evictionTickIntervalSeconds = null, bool? idleEvictionEnabled = null, bool? trimApiEnabled = null)` factory; the constructor surface now exposes only the public parameterless `MessageBus()`. This stops reflection-driven DI containers from latching onto a clock-taking overload they cannot satisfy via `InternalsVisibleTo`, and makes the public API contract obvious. Internal callers in the test suite (`AllocationMatrixTests`, `EvictionSweepContractTests`, `MemoryReclamationTests`, `DiagnosticsTests`) were migrated to the factory. - Mutation tests now exercise every messaging kind (Untargeted/Targeted/Broadcast) via a single parameterized fixture (`[ValueSource(MessageScenarios.AllKinds)]`) across `MutationDuringEmissionTests`, `MutationInterceptorTests`, and `MutationDestructionTests`. Users get tighter cross-kind parity guarantees; no runtime API change. (~720 lines of duplication removed; test count preserved.) - `MessagingTestBase` now reseeds a deterministic `Random` with a logged seed (env var `DXMESSAGING_TEST_SEED` to override), polls a generous 1.5-second timeout for handler cleanup, drains the prior test's deferred destroy queue before resetting `DxMessagingStaticState` in `[UnitySetUp]`, and asserts the bus returns to a fresh state at the end of `[UnityTearDown]`. - Renamed `UntargetedTests`, `TargetedTests`, `BroadcastTests` to `EmitUntargetedSpecificTests`, `EmitTargetedSpecificTests`, `EmitBroadcastSpecificTests` to clarify that kind-common tests live in `EmitTests` and kind-specific tests live in the renamed files. (Test-suite hardening is test-only; no `Runtime/` behavior was modified.) - Documentation now warns up front that `MessageAwareComponent` subclasses must call `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, and `base.RegisterMessageHandlers()` from any override; admonitions added to the Quick Start, Getting Started Guide, Visual Guide, README, FAQ, and Troubleshooting pages all link to [`DXMSG006`](docs/reference/analyzers.md#dxmsg006-missing-base-call) (issue #195). +### Fixed + +- Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. + - Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. + - This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). + - The bus now pre-freezes every priority bucket's typed-handler caches up-front for every dispatch surface (sourced-broadcast, broadcast-without-source, targeted-without-targeting) and uses the per-emission snapshot count for the dispatch-loop early-out. + - The sourced-broadcast and broadcast-without-source dispatch loops also no longer short-circuit on the live `cache.handlers.Count == 0` when the per-emission snapshot still holds the deregistered handler. + - Post-processor prefreeze no longer takes a single-bucket/single-entry fast-path that skipped pre-freezing per-MessageHandler post-processor caches; a regular handler that registers a new post-processor on the same MessageHandler+priority during its own callback now sees the new post-processor on the next emission, not the in-flight one. + - The same fix extends to cross-`MessageHandler` post-processor dispatch: the inner per-handler `RunFastHandlers` overload used by `TargetedWithoutTargeting`/`BroadcastWithoutSource` post-processors now consults the per-emission snapshot list directly instead of bailing on the live `cache.entries` count, so a sibling `MessageHandler` removing a not-yet-dispatched post-processor no longer silently skips the snapshot-pinned invocation. + - `RegisterGlobalAcceptAll` (`HandleGlobalUntargeted`/`HandleGlobalTargeted`/`HandleGlobalBroadcast`) is intentionally NOT covered by this fix. The bus's global accept-all dispatch path prefreezes lazily per-entry inside the dispatch loop, so a sibling `MessageHandler` that removes another's global registration mid-emit causes the removed handler to be skipped on the in-flight emission. The behavior is pinned by `MutationPostProcessorAcrossHandlersTests.RemoveOtherGlobalAcceptAllAcrossHandlersDuringDispatch`; if a future change introduces upfront global-handler prefreeze, that test must be updated to expect the snapshot semantics that the per-kind paths already provide. +- `DxMessagingStaticState.Reset` is now race-safe against deferred deregistrations. Previously, when a message-aware component was destroyed but its disable callback had not yet run (Unity defers Object.Destroy to end of frame) and Reset ran in between, the deferred token teardown would log spurious "Received over-deregistration of {type} for {handler}" errors against the user's Unity console. The bus now stamps each captured deregister closure with a generation counter and silently no-ops closures captured before a Reset. Applied uniformly across every register entry point (untargeted, targeted, broadcast, GlobalAcceptAll, and all three interceptor kinds). The same race-safety guarantee is now propagated to user-installed custom global buses via `MessageBus.BumpResetGeneration()`, which `DxMessagingStaticState.Reset` invokes on the active global bus when it differs from the built-in default; the custom bus's sinks are intentionally left intact to avoid clobbering state the user installed it to preserve. User code is unaffected except that previously-spurious error logs disappear. +- `MessageRegistrationToken.RemoveRegistration(handle)` no longer leaks the staged registration entry, so a `Disable()`/`Enable()` cycle after `RemoveRegistration` no longer silently re-registers the removed handler. The fix also drops the matching metadata and call-count entries so diagnostic mode does not accumulate stale handles. + ## [2.2.0] ### Fixed diff --git a/Editor.meta b/Editor.meta deleted file mode 100644 index 2b59d97f..00000000 --- a/Editor.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 41dce8be641afe34ca8363865b8cd5d9 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 1f5b0d5fe0921a544a740f38a48ca8ef31a10ecc..35356752f4b1352f7042bbc2f2247d3041ae040d 100644 GIT binary patch delta 8267 zcmb7I3w%`7ng7mx-#asR=H5G*ykU|M7%~YYgg^p_#0L)*5qSnd2_b3}xG(`V*fKMU z6|DJLI<76O{;VS1+O2hI)UI7=iLLfU-P*ctSuIs7Wo=8VXltz+_J7VD5NLn<+a1Vv zzW4dQ?|kPmllvaa9b%u`QTz1Ex8Fy_&udhh46$aSm0%bF)7`r3t4r;J14IirBS*AD z*;V6(zeE%Uz2g?5#f2KFvCzfFS!FB0O5$&2;VzbDR~MdQMF- zQW>V%2!a*z%coAOl~OSxiyCGY5lQ|uBT^cm?KLaH3baE~e1wJcMw7NV&=0347dASHt{Miu2h`I1xR8?3P}wZg%`la?b*cpH6sWXCl3&VoTq#wCG1Pt@*e*+zgNRU+ zr7D1siRnDe(Hb>suY!WOW6M?99NIx6>J zI0ly-hJ+l|9|e&=n#;}he*()jQ%O!)r)PGpkp#4uU&Hw!CzPt?NW_UW%9Osz3Hf!L z4m)9gOcq$9?SvD4J+RSrBdC3;XFE1my)0cqPB?QuB2A5f*dHtGPVp-IaooCu`vLNa z-iVZD2`}nIn+|FarXU>4;~WoU+WeCzPacW7=12lbvaFAFvglP_zcd$rbL8Yn&2I$i z#nR-&8i-+eCAycsbvcXj8hFhr&B7>)MNKt9HCWaOz{2SUsci64c&<;3p7=n0>FAXY z_!A+pw8W&4q?IlgNu-$+PGgBDWc$%fR!-p!GfKJ@OQbY-%^G#`nfOOe_$|;N*`Fka zZ797`-{g&rk7y>=vcDDV`W)w$4$c?zCjS}re!xfDq$%h^oA&b=c)(FgwZUQwioz@{ zo*j|->X$ere2wpcisny&+)KEL)Knm$ZRk)%_uGMU%~Ge7YxY7Dt~$WgFiyjmnhp>) z;(^C9r;O{J2R*}|0ZBwOWVut$*<-;rQx;K~#+_C;6`bpc>Cc1m-B%WVuauR~1X`a% z8!0JNJMAf&UzAp>r=-ZWC{<5OFIUU5fh{_%DAf#q7Th(iGJF)*YFc9W7hv=%-OAL3 zT+q1<8?cyZW6L?x($>+Ra1yDDVBlZO!EAtP-n9udE^j;CQJO-IS~#k@!9SJ+J!qX;85B?RB4+9`@De_{#DaYm$=goE zvBlbYqn**`M3Qup|DQOy z?+hRRCl}MW3(>rFPF>>?93S2oXG~*u4zDuK%5(jd|4o(khwIC5Z1FZOR{pGt{}=XY zaY?-BIZNinw&CPfbl3GShk28pxdU}dOOBiMoD?o5%W<5jp1J~<=joaI84f!vs?}(i zDI4p?JEz{MZ=6Y)Ly%>T061RiN~n~Um6m4y%ndjd&s;4*6pP2>sYTq|*eE$;gRV$V z{~P17?Eh{Zhjl!#4h|7nT%))@aFzLsF@cqC!)dVuSV%v+wQv|aGHt9#@!KVAG^$h@ zNhh?xiZmP%xPr1u({Lh^CbI!Ys!TVah#Gee?@PH)oXE?>EVEroE$5DKl5}AVP7>T< z{EirZPFG{eV+-;S#KBpN;sVoO&1JA)MG2o8@uf_^8xnsFfTz2U$|{8 zZ2NlwEXltXL#X`^Tu&EGTi>LNji*(yR{Vq(4}&b8BBh?*{sz>HbJs6op0R@B2@ohh zDSTds2|UGBQrC0Dbj+fwFF=~`RDz_BORV%<*Gge!@eCj*!4Hu_FS;=ZYihzqfd~OBK$R7)WbNhLrn|TG@%ad7mF6=MGM|jc!%IT$9szRwvBBF15upk6ClSTmJC#<_2H>=)}})}r`cd~ zEy_AJc7WF`(E^mj5<6fEE_D7Z2%4L>u!#lL+9yCm^ZTI|(jgSDMGwuj865un)u3Yu zoh{&;dNl^NCs3Vu80K8$-vr=USj?NV95d5($om$K@(xKJtR+efa1w2S^qf$buf3Vt z3SQGoqIY@Xf$J~Awc+0ik=T&p-5A45_l&U8Hyi$^xIgSv9yxERYp_QCr^WoP>!!9r zTsp$|vY=xyDL)`OxeB*~Mk2*^x17&c6@6E%j^}tq-4Z9}=b%xnL|QGLFudU;8igQ3 zL3J+>)oqMu$(C9w2@*x4hSG%QgtAJVg|6@gWw*gu@#$ugCcyzd)q`^jkB8^5xrO(` z=Xc>2u^zz_p%QO^N(J|VqS40wPq<~7Ce9tNpT#ZDbaL(<<1B84xOFn67Uvf3iImiQ z;-@NeE4Nq`InyFlQg|*hk?kn_DbfI#v^l)go&dPjo{-&v*mC@0ZJ5v>M|0$G4JZxx zqO>!}i&rIyjIhU@ejEszrOBU0nG|hA!~L%H-vzz0erzl26RX+Mtz0E_H@N(S*5Diu z3M~guXE>iEC&xStR%(!2gW7v0@sFp8!t`kUc^k1Vby)saG0t^?FWQ{x+i)HFoM;SY zn}xr+C5ve73?g(n)y-Kl4^KbZ4Em$O1JQ9cH-N%vLUs55u&4T;b^oz z`b9RhCwCUgJkc}&0(Fw#jjYivBu+s(@EA z5P|+8^d_V|D7Zye!JA97MKaq3{CQ+BV$+brLtn^tGn?KO_%jX<`kuKW%RA%{){$td z=tV{t@TzdM!AJ-2fLep*;*>-=$P%IX_Qk+uVxYbenqL@pJF~wwA2t`|ZdDerg}E;~ z3s?(%UET+dCoN!$a?ddi|0v)b!I=oSFsHj5eqC7IC*UCgFBkAnGM65)IUH0MuvVHA zdCF|1c0WT780$>e2E&bS>LN~FO%mmW-1+qWlyahaE z9T)uffj82JfXfBc*@xy8NnV!El${7^u<1HY@uI8~RDpUV4foqYP`CWP5DZB-9S;im=cnyf}kr|*pmsOiyG%b;W` zpzhbEStfn4NI6y~dhLqwkY!=m8=Z&u=yu#0+|5&JE57O7n+~Ww>JlqPZ#M?icZ`*m zLsd1njaUbHK5{THS_6jCx;DK{P1Mt;aiVH&*4-(8{drXWz>|#L5xAb zMJmS&LUoqR%{Jm@VAKK*$9e1S6=su#<`?kog3(`v{tcbMg{OndlX=|Q3tr)NU<4oy)*?@-;T~; z3#Fr~hRHZSgWi&7L8s2;9%f*(4$IX}6faI8m*#N6ikFlLnvwfUhCOqtxjyoT- zdF)YX2H-ma&a_LV#l%BjOnc1n(k7;;6Qzfw3$tpQ)W=k7x-`HR8pw#ftIm}=*b{6i z;QNtP(thbVdkvtaTraJp_w)g2Sh_{MP3n}4=nlZ|Mm{gSD2IeRfy7J7ypTj?s@1_=a2D@8Kc5krKY_0Qq`Cc*EcCkRF zTyHlh_Y0jzxK5S^*iG^b_J~mWA^S1DNWUm1cI3yRcPQoZcKfr+*I@9V@`P|&BmYYJ z25=tAVWEGRJ?lK9yw0AopH-frLbQTCBZ~Zi@H1Y1SN)}8qHQ&_g z8|gctz0^;?34K@aU!yEn{k0%u#_>7`7apWE1?r<)Se6{=X<97H?6-=}ULX^H5jI_ovKNLvsQ6g*F6v#g<+|&7GZ$q1 zoBO-htm|F0xo{%6DsmQosI%s$p`mC~=cw3AR62R&SIaJWczxf?Z^Unp7gk?dTc#x$ zb8@>JT`qAKvJ!U*vn37-`AZkg9Irbi?kZ-NIK9m6D{;C3-9Fo0FG-TK#Tl?|Ai@U8 zx%rY!voP~ zCc!))sz;G(F(==4p+3&dJ!HGNeQxeiOvgVUf@y8=AG0~!FFCm>UK zij(QZ5qWuJhzS-7KTCwq$B?P*=Ci?k``z3iqOPvu`6D<);?5WK>oSte&38HZRXQfz zDcQD>lq8R09ux9z{N@5by>?ZVN)p6)GP(IayM=fGP=$P-lV6Yjk^z)UFpmgbj9KJh z7afOm73B;nTCO90EO2f(NMjgSAUU1ka4|zZ3eC|vDg=LS?uQtE!bkD{Mbw-xL_8VP z3}qLU#3*H@i0BVdN;kg=RfG+eKk&mRFCIL_t{bm(z6cht?j<>rnH_zsW0#L0bt z8INQ96Cd%Pv)$asTm)90Nh0RAL@5R>-gtqkaS9RHzzwfn(VdAN;d$iSgz-C-r|t5r zXqRW2F1dMADm=TOIlUV#r6(o$@K{Yubp1}W`zX{6OH|_IcNZz_?dmJaTh~Ls+Q#AJ zR#_XLLtc%bLnSMElzsCH8(vs^bNc!gekV=5+0WeAzp=Zgw|`<`#j>P3ago0z z)6+YrcSCQcd$XSb``(ycX|)7V4m0;2n4&Oa`_VcX2CC0X`M-snDMc-Bw82X%e(r0lAMh W>KU4I^)Qp-(}tYYe`LFw_5T6;=*Wlw delta 7425 zcmb7J4RlmRmcI3V`@Qado%g!a2_!%V2)rcSkRN^$gpq%66(IzKNI>!@Ai{$rFe+oZ zd;LckjjgUY;t|9>n_2LP7)M-Z797E!h1tVVT-i|+N8M!{*I`}UMR#Dos_p>loU><| z^jG)Zy7$(-x9U~ZYf?v8>NWP@y+iZM);&bI&qp*g=4X{eiy`o3g=;tV{QNqrcq7qV zE=UpGtMrtF15XhJz`xT)v>;O=#WRg;q}g>HL@|69XEw76c75hWHj*vRoMb}>%!3F1 znT@YRDosofJvNJoWlEaHmkgHN5Rq9vA=^sw?Tkn%|4d3~-Fdttvso%i+Mr5PgV5tS zAiXf}fxn_knMT3@f3_Qe&;?atN?sInz>(YnwEo(&P<5DF2qqX}?f|q#-A51_-ayc< zN~Cqba$S*I1itPN?~=V@&|$?^>XLmB*oub$Om8rksiH-FWnLL%?;~v#%A9cF{DljeR{^q|7z!)V z8rr5Y3t3ROQl+tIB_t`iap=UgZp@NdE){OYw6SDqbjaxKr3FKpwtJ%>H??TBDQN|Z zMX=>k`KZp(5T_JAb%Ui%D56sSwH)=ZqDyv*@5vfyJ`d}`j4$6Tu zcrV0SlklY{9t`{xEF)eQG)NTS{~Kw%dS+Cedy zmj+G#Wuvn1jcWViV-YeQ{r^8?{qk^aMHm(&|8h$+ag`m2PsgxWNZ%iJzz-^=?<_5} z{bGgYs&`q{jAT|8w!T;KaHu;U-gU?J=Ae8Dz2h#VULo0bIM2Nn5;?C!8q9L-Q z*~$EYZQ-A@y<{4OX*g@j_Z^w=XcC@sTk;y<+N_9c-U1NcUl1L3Ql@nS#r7PR3h?|3 zbuDo$itUX%!rH;%!W}Jyy&wA?jwCp&bjV!<%EQryO&}ig{lItgcU(ii5vqyTqviZ~ zSqr}!--Dz`)Vm%XF2b$wnn0NqQ#5ZW2yYo+LlsuOUymnr?*`5;0UM9$-g2(c4B+Px z->l?Z8cIR%WK{E7AWHn=q^ZR8z-VYQ+% ziBo13Xh%PvkJFXmOuGrJ*pkDVt;IvOCc6Q?qSS)0Qei-UH53xBV>?k5y*^s5XT#Lv z0ZsRAM&E>aII#-K`Sd-9(0zRROp(P8S3#2RCmok!o{)xx*6~{;;jKXzuP)}w^j9pz z+BR<3M)`5*rGDM1D6_JUol!_sal1)m>C8dd^_?1NCB|x{x;k{^i5?tXDq+Q$z`XhlX?QMV{ ztT~#ykrQe~G2;6%Cvky@9l$Z--U2QeS8Y8Y%6co<#BVXHQ5|jNusW?H~$9!R#_&)a{0J15Px??C)T-ED&LMP9WDd7N@|_= zp`);KGw<$Kn<;JL3hw<7^1VTWGaNHoDt5WEYB{zO0(T2H1@knR$VKjF7rQ7^;FL~U z;3pb}c{zYKf~(E2hOUlPYgb?y;o^$?IPJudJ{ZDAlpAM>Y|Zr6UVYn0Zfni`$Z zI6f+HpTLzu-XQ4z(YS|zz-RUKhDMzh$B`Px4+K4d?FyK5EVvSy#MUPbi~gC8$QZyl zp)Vpo613<`NLr~6v`KG;mIO`O8+s&U(gO35P-jm5pUNYlt7$3r2$LfEN;qBPUm37y zIXfbobkxtSJ`ZvEDWF4d<;i&_o$+ybp~3N8VA7(~{ulB4~#u3Z1C}`?yY$HlopJ z7x5C+Fpfk0>rs_YEMAp87)&GA$K{Wi91qAGKLl;kWR%gN&!j%nq^A(HLpy?frbCCt z@E?oeSEGI!^~rDZi4&m*M9{NTo+FbsE{5)JY3#YCK z_iz2r!>URYxeV?ciin)%3)~T060|67^91VIHoR0E6!d1BhyH*_^)C4Y&Mlfqw~6(~ z_$wPuxNi_u9q24qiN(k{iBjnM#4NIAfR>4YOZkg3z4=p;dySz+Lu!-aviek)?XoI* zRxX7kEV-;9b%=5NQs68h84avY9fxLp>RDm+sK6rvuN8P!=DoWuj$2ii)lhzLoKZuI zRgO1H90$sij4Jv#>7R)v9^g1tpd;{qAupl*($&T|+Q=3dQKYdL+3&Vjf&SW9Bji5@ z9j8se#R7G<$(R@8&*R;N2mBhhxybRNtPos=TBW-UM!UdaWA33Z)VmFt{=Ect;5I7{ z87h@wGX__9*#9>ecC6qI`|c0?4BVtFN59dZ0r!)vCVgw{HgtM$MAnND+GqIay&QLw z_OfA6<;ZN`hsHsyzE?Px#DF!SH;n*2>}ES(RQDMnnpc+P9#@YWc~n`R<+l3XH*8uU zxNL?FJ(cYffjabjTxjOFUkR>X^RVFVzytp~!=X5E+Fopidl>AhXzX)e=ntzv`ik*kiw|Euhjlc%n zHyJex)ya5|!8IBCKcfp!xJ1Eq1kTp-ZzB)Zb5GnbUzx{?-|)ZjRpWsDg)+G zmB2cI$tj|_N?^UfMuE)&R{^oN0`u|I9Pk&>dLapr<+v09J(-H>kbWHvrbX$fLcqMbhXP)%lK6nbZ?JM#qwLBJ{6xg52OKItA6w z=rmM6r~4=>lp5qeLiHAd8_e>Z1)dI`1CI8c2hR7&Y$C0cY<8NqgbG-l^r@<0FiuaT z({d3jlg`-O1HuLUoL#|+MFJ(PBUr^sSh5>i)^w3a2`e+Furl_zd=IN)VgFXv$X*E@ z#jjJ31#QTm@MVCv8{1g3^r7!bwu+UgyV-i_eDHa8jqs3`{=t5g)v;a)p|CRo1J?U& z0rAim&`RS!STj@9|6)DTH1!+!e>-WAw2BS!X;MA=N)1Y7>;+Z?JQN%%?T|9oNT8`C zq$YY*|BzfmIJm%|y#Ncoh+D{)%lB|Rgt(0ut$ z@_Wkn1Vz4{K1f7$<3cf5qA#`>LomFh3 zT##hDgwkHN%~qBDVqhk33i%a7o^RR8ZWxp*F9-u${!pp~%|kgT^bfKJ?WxL}>`rU8 z@(MMF&eAKQ$fty#0rFRBi(+8eV*N{BsT&lIf33WR1Rhn&u$UeI#^@>FaM}xW=~ZBy zP5>*()hsN^9l%<82RM;r-J;2KlWrxk_)@-+Mm{b#DJnJ_q6Y$GN_c0KX(%$AN$vC^9VS1k zWiPSM*f{BB=~YRU3*|-fCixEekbG1wRz@og%1@Q|6(z+ttjj;MlenZwmq`?L7Ir@V zGaWX%+$V@bos-z~E`Flwkw2r?XL1D%zX0g?`e0$;TS2|F0yl}<>0R~@>aON}w#AX$qsW~WPi&;*fsBvi>H7-yM5gGn{fT{c=Ta8lbW zC$-&4?U1q`Fp>q)GZx2QJGDndliCYLjggZ&4uq7`>4XBhF-WqLN^S@1q{rHgLTd-t zN>4&oX;p%{6wYIk1a3w)epMPw5|1La$B0pG2tC&#hcNlX2v-R|bA-=lkd@`6>&1Az zPHHQn98}CxMQllg;>;11s`teh)a>*^J>N;)hje@~NeWY5n2G*IgB18#Y84l&B%vFZ z8z0opX39{doYj)y}z|`epMYq?+EL1zMaIxrS@kN*bm{wtoG-! z;;qoJbp__Xg!(}zb%@s{63bQw#TE5JD@j2y-J)JjdKF3qeLkNvIKjPhA1G7KSMs2! z(K%7I^}}^8e-o;hT0Xnkz>Z4U7>;7v?M6j&(zjmJGf_vlx8YNG+I}&Eqqz)t%QHBt zqrSMdHjdDB;hGDRec2)UoYb+LT5e3c5w3HcR?byl&Q;%N-E>kXoYV(S>Xg7w^X=3Z z@OKLBY2J#zGdb?82<-!r+n&| zikbXVW>rHa>&ZOcFmMs-oNuRluuKPM%QBc0ldqLLMtFciF@xa=vG^*ntU;EE(rt(0 zLS_YH;!)d<5bX30h46&%2r$uzU|-&XwMtk#ld|RvH#ITg=*#ph9#PwamB=4gG>CXd zw&~MF{M#%)$&hZh@&MVcZ8I99^_a9ZQW`2j}y{PvsMn>*X_e_~*& zMpg2Egcmy+C76opvCU)JT3cJj-#E6du4PdcgQ!dTGW*@ZWKg=J=6Kdt)PuDkw@*qnKM(>;o>i=T`551RcSzCVZY<(Kob z=sUY8^Q-HJj#}yN{`u6a7hm(_b@2z@YB=1>)uvBqn&!2&H_dBqUERE*z4H?B(2|(b z)ZlICXlzP7!ixy$S5Y(g}}nLm#36*qY;H#OxlZCcyj)!Eg&wyn9Nt$I{tzmie6 zuC0X{W>2XeJC6DXUzQu3=kVVH-;13nFMRNgft&5=_@%4=vutDLH%kUe^EU4L{gOA> zK5N+@OhZ!qzc#ujTWi~&A(%)1{&2kt(&1+1)W;- awm?3f+V;(AImo1S : IReadOnlyList { - public struct CyclicBufferEnumerator : IEnumerator + internal struct CyclicBufferEnumerator : IEnumerator { private readonly CyclicBuffer _buffer; diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 738acc30..1f81e9db 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -635,28 +635,70 @@ private readonly MessageCache< private readonly BusGlobalSlot _globalSlots = new(); + /// + /// Constructs a using the default + /// and runtime-settings provided eviction cadence. This is the only public constructor; DI + /// containers that scan constructors reflectively (for example VContainer, which inspects + /// both public and private constructors) must be configured with an explicit factory -- + /// see the integration helpers under Runtime/Unity/Integrations. + /// public MessageBus() : this(StopwatchClock.Instance, DefaultIdleEvictionTicks, applyRuntimeSettings: true) { } - internal MessageBus(IDxMessagingClock clock) - : this(clock, DefaultIdleEvictionTicks, applyRuntimeSettings: true) { } - - internal MessageBus(IDxMessagingClock clock, long idleEvictionTicks) - : this(clock, idleEvictionTicks, applyRuntimeSettings: false) { } - - internal MessageBus( + /// + /// Internal factory used by tests and integration assemblies to construct a + /// with an injected and optional + /// eviction overrides. Lives behind an internal static entry point so the public + /// surface exposes only the parameterless constructor; this keeps reflection-based DI + /// containers from latching onto a clock-taking overload they cannot satisfy. + /// + /// Clock implementation. Must not be null. + /// Optional idle-eviction tick budget; falls back to when null. + /// Optional sweep cadence in seconds. + /// Optional opt-out for idle eviction. + /// Optional opt-out for the trim API. + /// Configured instance. + internal static MessageBus CreateForInternalUse( IDxMessagingClock clock, - long idleEvictionTicks, - double evictionTickIntervalSeconds, - bool idleEvictionEnabled, - bool trimApiEnabled + long? idleEvictionTicks = null, + double? evictionTickIntervalSeconds = null, + bool? idleEvictionEnabled = null, + bool? trimApiEnabled = null ) - : this(clock, idleEvictionTicks, applyRuntimeSettings: false) { - _evictionTickIntervalSeconds = Math.Max(0d, evictionTickIntervalSeconds); - _idleEvictionEnabled = idleEvictionEnabled; - _trimApiEnabled = trimApiEnabled; + if (clock == null) + { + throw new ArgumentNullException(nameof(clock)); + } + + long resolvedIdleEvictionTicks = idleEvictionTicks ?? DefaultIdleEvictionTicks; + bool applyRuntimeSettings = + idleEvictionTicks == null + && evictionTickIntervalSeconds == null + && idleEvictionEnabled == null + && trimApiEnabled == null; + + MessageBus bus = new MessageBus( + clock, + resolvedIdleEvictionTicks, + applyRuntimeSettings: applyRuntimeSettings + ); + + if (evictionTickIntervalSeconds.HasValue) + { + bus._evictionTickIntervalSeconds = Math.Max(0d, evictionTickIntervalSeconds.Value); + } + if (idleEvictionEnabled.HasValue) + { + bus._idleEvictionEnabled = idleEvictionEnabled.Value; + } + if (trimApiEnabled.HasValue) + { + bus._trimApiEnabled = trimApiEnabled.Value; + } + + return bus; } private MessageBus( diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index 986b8b9b..edcd554c 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -2323,6 +2323,10 @@ IMessageBus messageBus internal sealed class HandlerActionCache : DxMessaging.Core.Internal.IHandlerActionCache { + // Uses outer T as a field type -- reflection callers must close + // via MakeGenericType(outer.GetGenericArguments()) before passing + // this type to Activator.CreateInstance. See + // Tests/Editor/Contract/ReflectionHelpers.cs::CloseNestedGeneric. internal readonly struct Entry { /// @@ -3233,6 +3237,18 @@ public void HandleGlobalUntargeted(ref IUntargetedMessage message, long emission HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.UntargetedDefault); + // Live-count fast path. Cross-handler in-flight snapshot + // semantics do not apply to the global accept-all path: the + // bus dispatch loop calls PrefreezeGlobalUntargetedForEmission + // lazily per-entry inside InvokeGlobalUntargetedEntry, after + // earlier-priority handlers have already run. A sibling + // MessageHandler that removes this handler's entry mid-emit + // drains cache.entries before the lazy prefreeze can capture + // a snapshot, so cache.cache rebuilds from the now-empty + // entries. Bailing on cache.entries.Count == 0 is therefore + // equivalent to bailing after GetOrAddNewHandlerStack would + // return an empty list, and is documented behavior for the + // global path. if (cache?.entries is not { Count: > 0 }) { return; @@ -3269,6 +3285,9 @@ long emissionId HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.TargetedDefault); + // Live-count fast path. See comment in HandleGlobalUntargeted + // for why the global accept-all path bails on + // cache.entries.Count == 0 rather than reading the snapshot. if (cache?.entries is not { Count: > 0 }) { return; @@ -3305,6 +3324,9 @@ long emissionId HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.BroadcastDefault); + // Live-count fast path. See comment in HandleGlobalUntargeted + // for why the global accept-all path bails on + // cache.entries.Count == 0 rather than reading the snapshot. if (cache?.entries is not { Count: > 0 }) { return; @@ -4692,7 +4714,22 @@ long emissionId where TMessage : IMessage where TU : IMessage { - if (cache?.entries is not { Count: > 0 }) + // Snapshot semantics: do not bail on the live entries dictionary + // count. A mid-emit removal can drain entries while the pinned + // emission snapshot in cache.cache still holds the handlers we + // must invoke. Read the snapshot first and bail only if the + // snapshot itself is empty. + // + // Perf note: GetOrAddNewHandlerStack is now invoked on every + // call (including for empty caches that the previous fast-path + // would have skipped). The cost is one dictionary + // emission-id/version compare and -- only when the per-emission + // snapshot has not been pinned yet -- a single pass over + // cache.entries to materialise an empty list. The win is + // correctness across cross-handler mid-emit removals where the + // pinned snapshot in cache.cache still holds handlers the live + // entries dictionary no longer reaches. + if (cache == null) { return; } @@ -4700,6 +4737,10 @@ long emissionId ref TU typedMessage = ref Unsafe.As(ref message); List> handlers = GetOrAddNewHandlerStack(cache, emissionId); int handlersCount = handlers.Count; + if (handlersCount == 0) + { + return; + } switch (handlersCount) { case 1: @@ -4754,7 +4795,10 @@ long emissionId where TMessage : IMessage where TU : IMessage { - if (cache?.entries is not { Count: > 0 }) + // Snapshot semantics: see comment on the FastHandler overload. + // The pinned emission snapshot may still hold handlers even when + // the live entries dictionary has been drained mid-emit. + if (cache == null) { return; } @@ -4765,6 +4809,10 @@ long emissionId emissionId ); int handlersCount = handlers.Count; + if (handlersCount == 0) + { + return; + } switch (handlersCount) { case 1: diff --git a/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs b/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs index 8afdd842..b16960e7 100644 --- a/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs +++ b/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs @@ -2,7 +2,9 @@ namespace DxMessaging.Unity.Integrations.Reflex { #if REFLEX_PRESENT + using System; using Core.MessageBus; + using Core.Pooling; using global::Reflex.Attributes; using global::Reflex.Core; @@ -87,6 +89,77 @@ public IMessageBus Resolve() } } } + + /// + /// Provides convenience helpers for wiring a into Reflex containers. + /// Reflex's container builder defaults to public-only constructor scanning, so today the + /// bare Bind<MessageBus>().AsSingleton() pattern resolves through the public + /// parameterless constructor. This is fragile against future Reflex versions that broaden + /// scanning to non-public constructors -- always prefer the helper below for clarity and + /// forward compatibility. + /// + public static class ReflexRegistrationExtensions + { + /// + /// Registers a singleton exposed as + /// using an explicit factory. + /// + /// Container builder receiving the registration. + public static void AddDxMessagingBus(this ContainerBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + builder.AddSingleton(_ => new MessageBus(), typeof(MessageBus), typeof(IMessageBus)); + } + + /// + /// Registers a singleton exposed as + /// using the supplied factory. Allows callers to inject a custom + /// via . + /// + /// Container builder receiving the registration. + /// Delegate that constructs the instance using the resolver. + public static void AddDxMessagingBus( + this ContainerBuilder builder, + Func factory + ) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + builder.AddSingleton(factory, typeof(MessageBus), typeof(IMessageBus)); + } + + /// + /// Registers a singleton exposed as + /// using the supplied . + /// + /// Container builder receiving the registration. + /// Clock implementation injected into the bus. Must not be null. + public static void AddDxMessagingBus(this ContainerBuilder builder, IDxMessagingClock clock) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (clock == null) + { + throw new ArgumentNullException(nameof(clock)); + } + builder.AddSingleton( + _ => MessageBus.CreateForInternalUse(clock), + typeof(MessageBus), + typeof(IMessageBus) + ); + } + } #endif } #endif diff --git a/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs b/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs index f7dea30e..9a6eb1e3 100644 --- a/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs +++ b/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs @@ -2,22 +2,127 @@ namespace DxMessaging.Unity.Integrations.VContainer { #if VCONTAINER_PRESENT + using System; using global::VContainer; using Core.MessageBus; + using Core.Pooling; /// - /// Provides convenience extension methods for wiring inside VContainer scopes. + /// Provides convenience extension methods for wiring DxMessaging services inside + /// VContainer scopes. The helper covers the bus registration as well as the + /// binding so consumers do not have to + /// drive VContainer reflection-based constructor selection on internal types. /// public static class VContainerRegistrationExtensions { + /// + /// Registers a exposed as itself and + /// using an explicit factory backed by the + /// parameterless constructor. VContainer's + /// TypeAnalyzer scans both public and non-public constructors using + /// BindingFlags.Public | BindingFlags.NonPublic, so the bare + /// Register<MessageBus>(Lifetime.Singleton).As<IMessageBus>() + /// pattern would still latch onto a clock-taking private overload whose + /// dependency is not registered. Always prefer this + /// helper. Calling this helper alongside a bare bus registration raises a + /// VContainerException at time. + /// + /// Container builder receiving the registration. + /// Lifetime to assign to the registration. Defaults to . + /// The underlying registration builder for further chaining. + public static RegistrationBuilder RegisterDxMessagingBus( + this IContainerBuilder builder, + Lifetime lifetime = Lifetime.Singleton + ) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder + .Register(CreateMessageBus, lifetime) + .As(typeof(MessageBus), typeof(IMessageBus)); + } + + /// + /// Registers a exposed as itself and + /// using the supplied factory. Use this overload when + /// callers need to inject a custom (for example a + /// deterministic test clock) or configure eviction options through + /// . + /// + /// Container builder receiving the registration. + /// Delegate that constructs the instance using the resolver. + /// Lifetime to assign to the registration. Defaults to . + /// The underlying registration builder for further chaining. + public static RegistrationBuilder RegisterDxMessagingBus( + this IContainerBuilder builder, + Func factory, + Lifetime lifetime = Lifetime.Singleton + ) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + return builder + .Register(factory, lifetime) + .As(typeof(MessageBus), typeof(IMessageBus)); + } + + /// + /// Registers a exposed as itself and + /// using the supplied . + /// Builds the bus through , which + /// is visible to the integration assembly via InternalsVisibleTo. + /// + /// Container builder receiving the registration. + /// Clock implementation injected into the bus. Must not be null. + /// Lifetime to assign to the registration. Defaults to . + /// The underlying registration builder for further chaining. + public static RegistrationBuilder RegisterDxMessagingBus( + this IContainerBuilder builder, + IDxMessagingClock clock, + Lifetime lifetime = Lifetime.Singleton + ) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (clock == null) + { + throw new ArgumentNullException(nameof(clock)); + } + return builder + .Register(_ => MessageBus.CreateForInternalUse(clock), lifetime) + .As(typeof(MessageBus), typeof(IMessageBus)); + } + /// /// Registers as a transient service backed by the scoped message bus. /// + /// Container builder receiving the registration. public static void RegisterMessageRegistrationBuilder(this IContainerBuilder builder) { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + builder.Register(CreateBuilder, Lifetime.Transient); } + private static MessageBus CreateMessageBus(IObjectResolver resolver) + { + return new MessageBus(); + } + private static IMessageRegistrationBuilder CreateBuilder(IObjectResolver resolver) { if (resolver.TryResolve(out IMessageBusProvider provider)) @@ -28,6 +133,9 @@ private static IMessageRegistrationBuilder CreateBuilder(IObjectResolver resolve return new MessageRegistrationBuilder(new ResolverMessageBusProvider(resolver)); } + /// + /// Wraps a VContainer as an . + /// public sealed class ResolverMessageBusProvider : IMessageBusProvider { private readonly IObjectResolver _resolver; diff --git a/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs b/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs index 608561f1..7e3106b4 100644 --- a/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs +++ b/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs @@ -2,8 +2,10 @@ namespace DxMessaging.Unity.Integrations.Zenject { #if ZENJECT_PRESENT - using global::Zenject; + using System; using Core.MessageBus; + using Core.Pooling; + using global::Zenject; /// /// Optional installer that exposes using the scoped Zenject container. @@ -64,6 +66,82 @@ public IMessageBus Resolve() } } } + + /// + /// Provides convenience helpers for wiring a into Zenject containers. + /// Zenject's BindInterfacesAndSelfTo defaults to selecting the public parameterless + /// constructor, so today the bare Container.BindInterfacesAndSelfTo<MessageBus>().AsSingle() + /// pattern resolves correctly. Behaviour is version-sensitive: future Zenject releases that + /// broaden constructor scanning could pick a non-public clock-taking overload whose + /// dependency is not registered, mirroring the VContainer failure mode -- always prefer the + /// helper below for clarity and forward compatibility. Calling this helper alongside an + /// existing bare bind raises a Zenject binding-conflict exception when the container is + /// validated. + /// + public static class ZenjectRegistrationExtensions + { + /// + /// Binds a singleton exposed as using + /// an explicit method, sidestepping reflection-based constructor selection. + /// + /// Container receiving the registration. + public static void BindDxMessagingBus(this DiContainer container) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + container + .BindInterfacesAndSelfTo() + .FromMethod(_ => new MessageBus()) + .AsSingle(); + } + + /// + /// Binds a singleton exposed as using + /// the supplied factory. Allows callers to inject a custom + /// via . + /// + /// Container receiving the registration. + /// Delegate that constructs the instance using the inject context. + public static void BindDxMessagingBus( + this DiContainer container, + Func factory + ) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + container.BindInterfacesAndSelfTo().FromMethod(factory).AsSingle(); + } + + /// + /// Binds a singleton exposed as using + /// the supplied . + /// + /// Container receiving the registration. + /// Clock implementation injected into the bus. Must not be null. + public static void BindDxMessagingBus(this DiContainer container, IDxMessagingClock clock) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + if (clock == null) + { + throw new ArgumentNullException(nameof(clock)); + } + container + .BindInterfacesAndSelfTo() + .FromMethod(_ => MessageBus.CreateForInternalUse(clock)) + .AsSingle(); + } + } #endif } #endif diff --git a/Samples~/DI/Reflex/SampleInstaller.cs b/Samples~/DI/Reflex/SampleInstaller.cs index 0597dcf3..1ddc6b44 100644 --- a/Samples~/DI/Reflex/SampleInstaller.cs +++ b/Samples~/DI/Reflex/SampleInstaller.cs @@ -17,6 +17,13 @@ public sealed class SampleInstaller : Installer { protected override void InstallBindings() { + // Reflex today selects the public parameterless MessageBus constructor, so the bare + // Container.Bind().AsSingleton() pattern works -- but it is fragile + // against future Reflex releases that broaden constructor scanning to non-public + // constructors (which would surface a clock-taking overload whose IDxMessagingClock + // dependency is not registered). When using the Reflex 8.0+ ContainerBuilder API, + // prefer ReflexRegistrationExtensions.AddDxMessagingBus, which uses an explicit + // factory that sidesteps reflection-based constructor selection entirely. Container.Bind().AsSingleton(); Container.Bind().FromContainer(); diff --git a/Samples~/DI/VContainer/SampleLifetimeScope.cs b/Samples~/DI/VContainer/SampleLifetimeScope.cs index d93bc38a..88b0ae3c 100644 --- a/Samples~/DI/VContainer/SampleLifetimeScope.cs +++ b/Samples~/DI/VContainer/SampleLifetimeScope.cs @@ -18,7 +18,12 @@ public sealed class SampleLifetimeScope : LifetimeScope { protected override void Configure(IContainerBuilder builder) { - builder.Register(Lifetime.Singleton).As(); + // Always register MessageBus through RegisterDxMessagingBus. The bare pattern + // builder.Register(Lifetime.Singleton).As() fails at + // resolution time because VContainer's TypeAnalyzer scans both public and private + // constructors and prefers the one with the most parameters; that overload takes + // an IDxMessagingClock that is not registered with the container. + builder.RegisterDxMessagingBus(); builder.RegisterMessageRegistrationBuilder(); builder.RegisterEntryPoint(Lifetime.Singleton); diff --git a/Samples~/DI/Zenject/SampleInstaller.cs b/Samples~/DI/Zenject/SampleInstaller.cs index 57187555..16275ece 100644 --- a/Samples~/DI/Zenject/SampleInstaller.cs +++ b/Samples~/DI/Zenject/SampleInstaller.cs @@ -15,6 +15,14 @@ public sealed class SampleInstaller : MonoInstaller { public override void InstallBindings() { + // The MessageBus is bound elsewhere (typically through + // ZenjectRegistrationExtensions.BindDxMessagingBus, which uses an explicit factory). + // Avoid the bare Container.Bind().AsSingle() pattern: Zenject today picks + // the public parameterless constructor, but its constructor-selection behaviour is + // version-sensitive, and a future release could broaden scanning to non-public + // constructors -- which would surface a clock-taking overload whose + // IDxMessagingClock dependency is not registered. The helper sidesteps that risk. + // Ensure the builder is available (provided by DxMessagingRegistrationInstaller). Container.BindInterfacesTo().AsSingle(); } diff --git a/Tests/Editor/Allocations/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs index 670e479e..fab562fd 100644 --- a/Tests/Editor/Allocations/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -31,9 +31,9 @@ namespace DxMessaging.Tests.Editor.Allocations /// Cross-product reduction. The matrix exercises EACH axis (kind, /// interceptor presence, post-processor presence, diagnostics on/off, /// multi-priority) independently. The full Cartesian product is intentionally - /// not tested because: (a) the test count would explode (3 kinds x 2 - /// interceptor x 2 post-processor x 2 diagnostics x 3 priority = 72 - /// permutations); (b) interaction effects are covered by + /// not tested because: (a) the test count would explode across the canonical + /// kinds, without-context dispatch surfaces, interceptor, post-processor, + /// diagnostics, and priority axes; (b) interaction effects are covered by /// , a single combinatorial test /// that exercises the realistic production setup (interceptor + /// post-processor + multi-priority handler chain); and (c) any specific @@ -156,13 +156,16 @@ public void RestoreDiagnosticsState() /// /// Pins zero-allocation emission for the bare register-one-handler-then-emit - /// path across all three message kinds. Closure under measurement is built + /// path across every dispatch surface. Closure under measurement is built /// once with stable captures so its allocation does not pollute the result. /// [Test] [Category("Allocation")] public void EmitIsZeroAlloc( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -225,7 +228,7 @@ MessageScenario scenario public void EmitIsZeroAllocAcrossPostProcessorPresence( [ValueSource( typeof(MessageScenarios), - nameof(MessageScenarios.WithAndWithoutPostProcessor) + nameof(MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext) )] MessageScenario scenario ) @@ -269,7 +272,10 @@ MessageScenario scenario [Test] [Category("Allocation")] public void EmitWithDiagnosticsEnabledIsBoundedAlloc( - [ValueSource(typeof(AllocationMatrixTests), nameof(DiagnosticsOnScenarios))] + [ValueSource( + typeof(AllocationMatrixTests), + nameof(DiagnosticsOnScenariosIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -335,7 +341,10 @@ MessageScenario scenario [Test] [Category("Allocation")] public void EmitWithMultiplePrioritiesIsZeroAlloc( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -463,7 +472,10 @@ public void DiagnosticsAugmentedHandlerAllocationCostIsBounded() [Test] [Category("Allocation")] public void RegisterIsZeroAllocSteadyState( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -506,7 +518,10 @@ MessageScenario scenario [Test] [Category("Allocation")] public void DeregisterIsZeroAllocSteadyState( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -548,7 +563,10 @@ MessageScenario scenario [Test] [Category("Allocation")] public void TrimIsBoundedAlloc( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -605,7 +623,10 @@ MessageScenario scenario [Test] [Category("Allocation")] public void EmitAfterPartialTrimIsZeroAlloc( - [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] MessageScenario scenario ) { @@ -634,11 +655,13 @@ MessageScenario scenario ); } - public static IEnumerable DiagnosticsOnScenarios + public static IEnumerable DiagnosticsOnScenariosIncludingWithoutContext { get { - foreach (MessageScenario scenario in MessageScenarios.WithDiagnosticsToggle) + foreach ( + MessageScenario scenario in MessageScenarios.WithDiagnosticsToggleIncludingWithoutContext + ) { if (scenario.DiagnosticsEnabled) { @@ -654,6 +677,16 @@ private static void NoOpTargeted(ref SimpleTargetedMessage message) { } private static void NoOpBroadcast(ref SimpleBroadcastMessage message) { } + private static void NoOpTargetedWithoutTargeting( + ref InstanceId target, + ref SimpleTargetedMessage message + ) { } + + private static void NoOpBroadcastWithoutSource( + ref InstanceId source, + ref SimpleBroadcastMessage message + ) { } + private static bool AllowUntargeted(ref SimpleUntargetedMessage message) { return true; @@ -687,7 +720,7 @@ Action body throw new ArgumentNullException(nameof(body)); } - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( StopwatchClock.Instance, idleEvictionTicks: 0, evictionTickIntervalSeconds: double.PositiveInfinity, @@ -771,6 +804,20 @@ private static MessageRegistrationHandle RegisterHandler( priority: priority ); } + case MessageKind.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargeting( + NoOpTargetedWithoutTargeting, + priority: priority + ); + } + case MessageKind.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSource( + NoOpBroadcastWithoutSource, + priority: priority + ); + } default: { throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); @@ -849,6 +896,18 @@ MessageRegistrationToken token NoOpBroadcast ); } + case MessageKind.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargetingPostProcessor( + NoOpTargetedWithoutTargeting + ); + } + case MessageKind.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSourcePostProcessor( + NoOpBroadcastWithoutSource + ); + } default: { throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); @@ -877,6 +936,18 @@ private static Action BuildEmitClosure(MessageScenario scenario, IMessageBus bus InstanceId source = StableSource; return () => broadcast.EmitBroadcast(source, bus); } + case MessageKind.TargetedWithoutTargeting: + { + SimpleTargetedMessage targeted = new SimpleTargetedMessage(); + InstanceId target = StableTarget; + return () => targeted.EmitTargeted(target, bus); + } + case MessageKind.BroadcastWithoutSource: + { + SimpleBroadcastMessage broadcast = new SimpleBroadcastMessage(); + InstanceId source = StableSource; + return () => broadcast.EmitBroadcast(source, bus); + } default: { throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs b/Tests/Editor/Contract/EvictionSweepContractTests.cs index 03ddf4c8..e60c3f79 100644 --- a/Tests/Editor/Contract/EvictionSweepContractTests.cs +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs @@ -34,7 +34,7 @@ public sealed class EvictionSweepContractTests public void ForceSweepResetsDirtyEmptyTypedSlotsAndTrimsPools() { ManualClock clock = new ManualClock(10d); - MessageBus bus = new MessageBus(clock, idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse(clock, idleEvictionTicks: 0); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { @@ -77,7 +77,7 @@ public void ForceSweepResetsDirtyEmptyTypedSlotsAndTrimsPools() public void NonForceSweepRetainsFreshDirtyTypedSlotsUntilIdle() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus(clock, idleEvictionTicks: 1); + MessageBus bus = MessageBus.CreateForInternalUse(clock, idleEvictionTicks: 1); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { @@ -119,7 +119,10 @@ public void NonForceSweepRetainsFreshDirtyTypedSlotsUntilIdle() [Test] public void ForceSweepPreservesActiveTypedRegistration() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { @@ -150,7 +153,10 @@ public void ForceSweepPreservesActiveTypedRegistration() [Test] public void StaleDeregisterAfterEmptySweepDoesNotRemoveReRegisteredHandler() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { @@ -198,7 +204,10 @@ public void StaleDeregisterAfterEmptySweepDoesNotRemoveReRegisteredHandler() [Test] public void TrimReclaimsBusSlotAfterDispatchThenDeregister() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { @@ -235,7 +244,10 @@ public void TrimReclaimsBusSlotAfterDispatchThenDeregister() [Test] public void ForceTrimOnFreshBusDoesNotReportGlobalSlotEviction() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); IMessageBus.TrimResult result = bus.Trim(force: true); @@ -248,7 +260,10 @@ public void ForceTrimOnFreshBusDoesNotReportGlobalSlotEviction() [Test] public void StaleScalarDeregisterAfterSweepDoesNotLogOverDeregistration() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; Action previousLog = MessagingDebug.LogFunction; bool previousEnabled = MessagingDebug.enabled; @@ -283,7 +298,10 @@ public void StaleScalarDeregisterAfterSweepDoesNotLogOverDeregistration() [Test] public void StaleContextDeregisterAfterSweepDoesNotLogOverDeregistration() { - MessageBus bus = new MessageBus(new ManualClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new ManualClock(), + idleEvictionTicks: 0 + ); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; Action previousLog = MessagingDebug.LogFunction; bool previousEnabled = MessagingDebug.enabled; @@ -321,7 +339,7 @@ public void StaleContextDeregisterAfterSweepDoesNotLogOverDeregistration() public void EmitTimeSweepReclaimsIdleDirtySlotsWhenCadenceHasElapsed() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -365,7 +383,7 @@ public void EmitTimeSweepReclaimsIdleDirtySlotsWhenCadenceHasElapsed() public void EmitTimeSweepWaitsForCadenceInterval() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 10d, @@ -406,7 +424,7 @@ public void EmitTimeSweepWaitsForCadenceInterval() [Test] public void EmitTimeSweepPreservesActiveRegistration() { - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( new ManualClock(), idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -444,7 +462,7 @@ public void EmitTimeSweepPreservesActiveRegistration() [Test] public void DisabledIdleEvictionPreventsEmitTimeSweep() { - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( new ManualClock(), idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -480,7 +498,7 @@ public void DisabledIdleEvictionPreventsEmitTimeSweep() [Test] public void TrimReturnsDefaultAndLeavesStateWhenTrimApiIsDisabled() { - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( new ManualClock(), idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -542,7 +560,7 @@ public void LifoCollectionPoolIgnoresDuplicateReturns() public void EmitTimeSweepRunsForTargetedAndBroadcastTypedEntryPoints() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -596,7 +614,7 @@ public void EmitTimeSweepRunsForTargetedAndBroadcastTypedEntryPoints() public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus(clock); + MessageBus bus = MessageBus.CreateForInternalUse(clock); DxMessagingRuntimeSettings settings = ScriptableObject.CreateInstance(); try @@ -663,7 +681,7 @@ public void PlayerLoopHookInstallsOnceUnderUpdate() public void PlayerLoopSweepAgesIdleCandidatesWithoutEmit() { ManualClock clock = new ManualClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, diff --git a/Tests/Editor/Contract/ReflectionHelpers.cs b/Tests/Editor/Contract/ReflectionHelpers.cs new file mode 100644 index 00000000..783fc789 --- /dev/null +++ b/Tests/Editor/Contract/ReflectionHelpers.cs @@ -0,0 +1,373 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Reflection; + + /// + /// Shared reflection helpers for contract tests that reach into nested + /// generic types in the production codebase. + /// + /// + /// + /// These helpers exist to prevent a recurring class of mistakes around + /// reflection on nested types declared inside generic outer types. When a + /// nested type uses one or more of the outer's generic parameters (or is + /// itself generic), + /// invoked on a closed outer generic returns the OPEN nested type -- + /// on the result is + /// still true, and + /// throws . + /// + /// + /// Callers must close the nested type with the outer's generic arguments + /// via before + /// constructing instances. This helper centralizes that contract so + /// future contract tests do not have to rediscover the open-vs-closed + /// distinction. + /// + /// + internal static class ReflectionHelpers + { + /// + /// Resolves a nested type declared inside a closed generic outer type + /// and returns it in fully-closed form so callers can pass the result + /// directly to . + /// + /// + /// This overload supports nested types whose only unbound generic + /// parameters are inherited from the outer type. If the nested type + /// declares ITS OWN generic parameters (for example + /// Entry<U> nested inside HandlerActionCache<T>), + /// this overload throws + /// with a message directing the caller to the + /// + /// overload that accepts the nested type's own arguments explicitly. + /// + /// + /// A fully-closed outer type whose nested type is being looked up. + /// + /// + /// The unqualified name of the nested type. For nested generic types + /// pass the arity-suffixed name (e.g. "Entry`1"). + /// + /// + /// used for the + /// lookup. Typical callers pass + /// for internal nested + /// types. + /// + /// + /// The nested type closed against 's + /// generic arguments when the nested type carries unbound generic + /// parameters; otherwise the nested type itself. The returned + /// always satisfies + /// !result.ContainsGenericParameters. + /// + /// + /// Thrown when or + /// is null. + /// + /// + /// Thrown when still contains unbound + /// generic parameters, when the nested type cannot be found, when + /// the nested type is generic but the outer has no generic arguments + /// to close it with, or when the nested type declares its own + /// generic parameters not inherited from the outer. + /// + public static Type CloseNestedGeneric( + Type closedOuter, + string nestedName, + BindingFlags flags + ) + { + return CloseNestedGeneric(closedOuter, nestedName, flags, Type.EmptyTypes); + } + + /// + /// Resolves a nested type declared inside a closed generic outer type + /// and returns it in fully-closed form, supplying explicit type + /// arguments for any generic parameters the nested type declares + /// itself (i.e. parameters not inherited from + /// ). + /// + /// + /// A fully-closed outer type whose nested type is being looked up. + /// + /// + /// The unqualified name of the nested type. For nested generic types + /// pass the arity-suffixed name (e.g. "Entry`1"). + /// + /// + /// used for the + /// lookup. Typical callers pass + /// for internal nested + /// types. + /// + /// + /// Type arguments supplied for any generic parameters the nested + /// type declares itself, in declaration order. Pass + /// (or rely on the three-argument + /// overload) when the nested type does not introduce its own + /// parameters. + /// + /// + /// The nested type closed against the outer's generic arguments + /// followed by . The returned + /// always satisfies + /// !result.ContainsGenericParameters. + /// + /// + /// Thrown when , + /// , or + /// is null. + /// + /// + /// Thrown when still contains unbound + /// generic parameters, when the nested type cannot be found, when + /// the nested type is generic but the outer has no generic arguments + /// to close it with, or when does + /// not match the count of generic parameters the nested type + /// declares itself. + /// + public static Type CloseNestedGeneric( + Type closedOuter, + string nestedName, + BindingFlags flags, + Type[] nestedOwnArgs + ) + { + if (closedOuter == null) + { + throw new ArgumentNullException(nameof(closedOuter)); + } + if (nestedName == null) + { + throw new ArgumentNullException(nameof(nestedName)); + } + if (nestedOwnArgs == null) + { + throw new ArgumentNullException(nameof(nestedOwnArgs)); + } + if (closedOuter.ContainsGenericParameters) + { + throw new InvalidOperationException( + "CloseNestedGeneric requires a fully-closed outer Type; received " + + closedOuter.FullName + + " which still contains unbound generic parameters." + ); + } + + Type nested = closedOuter.GetNestedType(nestedName, flags); + if (nested == null) + { + throw new InvalidOperationException( + "Nested type '" + + nestedName + + "' was not found on " + + closedOuter.FullName + + " with binding flags " + + flags + + "." + ); + } + + return Close(closedOuter, nested, nestedOwnArgs); + } + + /// + /// Closes against the generic arguments + /// of and returns the fully-closed + /// nested type. Use this overload when callers already hold the open + /// nested (for example obtained from a different + /// reflection lookup) and want to reuse the close-only logic without + /// redoing the name-based lookup. + /// + /// + /// The fully-closed outer type whose generic arguments will be used + /// to close any of 's generic + /// parameters that were inherited from the outer. + /// + /// + /// The nested type as returned by + /// + /// (i.e. potentially still open). + /// + /// + /// closed against + /// 's generic arguments when the + /// nested type carries unbound generic parameters; otherwise + /// itself. The returned + /// always satisfies + /// !result.ContainsGenericParameters. + /// + /// + /// Thrown when or + /// is null. + /// + /// + /// Thrown when still contains unbound + /// generic parameters, when the nested type is generic but the + /// outer has no generic arguments to close it with, or when the + /// nested type declares its own generic parameters not inherited + /// from the outer (use the + /// overload in that case). + /// + public static Type Close(Type closedOuter, Type openNested) + { + return Close(closedOuter, openNested, Type.EmptyTypes); + } + + /// + /// Closes against the generic arguments + /// of together with the explicit + /// arguments supplied for any generic parameters the nested type + /// declares itself. Use this overload when callers already hold the + /// open nested and the nested type introduces its + /// own generic parameters. + /// + /// + /// The fully-closed outer type whose generic arguments will be used + /// to close any of 's generic + /// parameters that were inherited from the outer. + /// + /// + /// The nested type as returned by + /// . + /// + /// + /// Type arguments supplied for any generic parameters the nested + /// type declares itself, in declaration order. Pass + /// when the nested type does not + /// introduce its own parameters. + /// + /// + /// The fully-closed nested type. The returned + /// always satisfies + /// !result.ContainsGenericParameters. + /// + /// + /// Thrown when any argument is null. + /// + /// + /// Thrown when still contains unbound + /// generic parameters, when the nested type is generic but the + /// outer has no generic arguments to close it with, or when + /// does not match the count of + /// generic parameters the nested type declares itself. + /// + public static Type Close(Type closedOuter, Type openNested, Type[] nestedOwnArgs) + { + if (closedOuter == null) + { + throw new ArgumentNullException(nameof(closedOuter)); + } + if (openNested == null) + { + throw new ArgumentNullException(nameof(openNested)); + } + if (nestedOwnArgs == null) + { + throw new ArgumentNullException(nameof(nestedOwnArgs)); + } + if (closedOuter.ContainsGenericParameters) + { + throw new InvalidOperationException( + "Close requires a fully-closed outer Type; received " + + closedOuter.FullName + + " which still contains unbound generic parameters." + ); + } + + // A non-generic nested type returned by GetNestedType has no + // generic parameters at all; nothing to close. + if (!openNested.ContainsGenericParameters) + { + if (nestedOwnArgs.Length != 0) + { + throw new InvalidOperationException( + "Nested type '" + + openNested.FullName + + "' is non-generic but " + + nestedOwnArgs.Length + + " nested own type argument(s) were supplied." + ); + } + return openNested; + } + + Type[] outerArgs = closedOuter.GetGenericArguments(); + // The nested type's full generic-argument list is laid out as: + // first the outer's generic parameters (inherited), then any + // generic parameters the nested type declares itself. Per .NET + // metadata layout the nested type's own arity is + // (totalArgs - outerArity); the arity-suffixed nested name + // (e.g. "Entry`1") encodes the same number. + Type[] nestedAllArgs = openNested.GetGenericArguments(); + int outerArity = outerArgs.Length; + int totalArity = nestedAllArgs.Length; + int nestedOwnArity = totalArity - outerArity; + + if (nestedOwnArity < 0) + { + throw new InvalidOperationException( + "Nested type '" + + openNested.FullName + + "' has fewer generic parameters (" + + totalArity + + ") than the outer type " + + closedOuter.FullName + + " supplies (" + + outerArity + + "); the nested type does not appear to be declared " + + "inside this outer." + ); + } + + if (totalArity > 0 && outerArity == 0 && nestedOwnArgs.Length == 0) + { + throw new InvalidOperationException( + "Nested type '" + + openNested.FullName + + "' has unbound generic parameters but the outer type " + + closedOuter.FullName + + " is non-generic; cannot close the nested type." + ); + } + + if (nestedOwnArity != nestedOwnArgs.Length) + { + throw new InvalidOperationException( + "Nested type '" + + openNested.FullName + + "' declares " + + nestedOwnArity + + " generic parameter(s) of its own (in addition to " + + outerArity + + " inherited from " + + closedOuter.FullName + + "); use the overload that accepts them explicitly " + + "and pass exactly " + + nestedOwnArity + + " argument(s) (received " + + nestedOwnArgs.Length + + ")." + ); + } + + Type[] composed = new Type[totalArity]; + for (int i = 0; i < outerArity; ++i) + { + composed[i] = outerArgs[i]; + } + for (int i = 0; i < nestedOwnArity; ++i) + { + composed[outerArity + i] = nestedOwnArgs[i]; + } + + Type definition = openNested.IsGenericTypeDefinition + ? openNested + : openNested.GetGenericTypeDefinition(); + return definition.MakeGenericType(composed); + } + } +} diff --git a/Tests/Editor/Contract/ReflectionHelpers.cs.meta b/Tests/Editor/Contract/ReflectionHelpers.cs.meta new file mode 100644 index 00000000..a7dfdbc7 --- /dev/null +++ b/Tests/Editor/Contract/ReflectionHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 69dbf9c7356948c4805c6b2b1e37ce09 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs b/Tests/Editor/Contract/TypedSlotShapeTests.cs index daae777b..cdb261dc 100644 --- a/Tests/Editor/Contract/TypedSlotShapeTests.cs +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -41,8 +41,69 @@ namespace DxMessaging.Tests.Editor.Contract [Category("Contract")] public sealed class TypedSlotShapeTests { + // Centralized string-named members so reviewers update them in one + // place when production renames land. + private const string HandlerActionCacheNestedName = "HandlerActionCache`1"; + private const string EntryNestedName = "Entry"; + private const string EntriesFieldName = "entries"; + private const string CacheFieldName = "cache"; + private const string CountFieldName = "count"; + private readonly struct ProbeMessage : IUntargetedMessage { } + // Fixture-private rename-stable probe for the open-vs-closed + // regression test below. Pinning the .NET reflection rule against + // this fixture-owned type (instead of the production + // HandlerActionCache.Entry) keeps the regression backstop alive + // even if the production type is renamed or restructured. + private sealed class ProbeOuter + { + internal readonly struct ProbeSlot + { + public ProbeSlot(T value, int count) + { + this.value = value; + this.count = count; + } + + public readonly T value; + public readonly int count; + } + } + + // Probe shape for the non-generic-outer-with-generic-nested + // diagnostic test. + private sealed class NonGenericProbeOuter + { + internal readonly struct GenericProbeSlot + { + public GenericProbeSlot(U value) + { + this.value = value; + } + + public readonly U value; + } + } + + // Probe shape for the HIGH-severity test that the helper rejects + // nested types declaring their own generic parameters under the + // three-arg overload, and accepts them under the four-arg overload. + private sealed class ProbeOuterWithOwnEntryArg + { + internal readonly struct OwnEntry + { + public OwnEntry(T outerValue, U ownValue) + { + this.outerValue = outerValue; + this.ownValue = ownValue; + } + + public readonly T outerValue; + public readonly U ownValue; + } + } + /// /// Trivial in-test stub for . Used so /// the slot tests can populate @@ -291,7 +352,7 @@ public void TypedSlotResetDrainsBeforeClearingByContext() public void HandlerActionCacheImplementsIHandlerActionCache() { System.Type nested = typeof(DxMessaging.Core.MessageHandler).GetNestedType( - "HandlerActionCache`1", + HandlerActionCacheNestedName, BindingFlags.NonPublic ); Assert.IsNotNull( @@ -327,11 +388,11 @@ public void HandlerActionCacheImplementsIHandlerActionCache() // collection instances the cache holds, so direct mutation // populates the cache. FieldInfo entriesField = closed.GetField( - "entries", + EntriesFieldName, BindingFlags.Public | BindingFlags.Instance ); FieldInfo cacheField = closed.GetField( - "cache", + CacheFieldName, BindingFlags.Public | BindingFlags.Instance ); Assert.IsNotNull(entriesField, "HandlerActionCache.entries must exist."); @@ -340,11 +401,28 @@ public void HandlerActionCacheImplementsIHandlerActionCache() entriesField.GetValue(instance); System.Collections.IList cacheList = (System.Collections.IList) cacheField.GetValue(instance); - // Entry is a non-generic struct nested inside HandlerActionCache; - // GetNestedType on the closed generic returns the per-T concrete - // Entry type directly (no further MakeGenericType needed). - System.Type entryType = closed.GetNestedType("Entry", BindingFlags.NonPublic); + // Entry is a struct nested inside HandlerActionCache that uses + // T as a field type. Per .NET reflection rules, GetNestedType + // invoked on a closed outer generic returns the OPEN nested type + // whenever the nested type uses the outer's generic parameters + // (ContainsGenericParameters == true); it must be re-closed with + // the outer's generic arguments before construction or + // Activator.CreateInstance throws ArgumentException. The + // CloseNestedGeneric helper centralizes that handshake; the + // companion test EntryNestedTypeRetainsGenericParameterFromOuter + // pins the underlying behavior so future refactors do not regress + // back to the naive direct-Activator pattern. + System.Type entryType = ReflectionHelpers.CloseNestedGeneric( + closed, + EntryNestedName, + BindingFlags.NonPublic + ); Assert.IsNotNull(entryType, "HandlerActionCache.Entry nested type must exist."); + Assert.IsFalse( + entryType.ContainsGenericParameters, + "Entry must be fully closed before Activator.CreateInstance; " + + "ReflectionHelpers.CloseNestedGeneric is responsible for closing it." + ); System.Action handler = _ignored => { }; object entry = System.Activator.CreateInstance(entryType, handler, 1); entries[handler] = entry; @@ -370,6 +448,247 @@ public void HandlerActionCacheImplementsIHandlerActionCache() Assert.IsTrue(view.IsEmpty, "After Reset() the cache must report IsEmpty == true."); } + /// + /// Regression backstop for the + /// test + /// and for . Pins + /// the .NET reflection rule that bit the original test: when a nested + /// type uses one of its outer's generic parameters, + /// + /// invoked on the closed outer returns the OPEN nested type (its + /// is still + /// true) and must be re-closed with the outer's generic + /// arguments via + /// before + /// will succeed. The probe deliberately uses the fixture-private + /// rather than the production + /// nested type so this canary remains intact across renames or + /// restructures of HandlerActionCache.Entry. + /// + [Test] + public void EntryNestedTypeRetainsGenericParameterFromOuter() + { + System.Type closed = typeof(ProbeOuter<>).MakeGenericType(typeof(System.Action)); + + System.Type openSlot = closed.GetNestedType( + nameof(ProbeOuter.ProbeSlot), + BindingFlags.NonPublic + ); + Assert.IsNotNull(openSlot, "ProbeOuter.ProbeSlot nested type must exist."); + Assert.IsTrue( + openSlot.ContainsGenericParameters, + "GetNestedType on a closed outer generic returns the OPEN nested type " + + "when the nested type uses the outer's generic parameter; reviewers " + + "must close it with MakeGenericType(closed.GetGenericArguments()) " + + "before constructing instances." + ); + // Type-specific Assert.Throws is intentional: + // this is the early-warning canary for the .NET reflection rule + // the helper exists to navigate. If a future runtime ever + // changes the exception type or wraps it, the failure message + // here surfaces the regression at the source rather than + // letting it silently propagate through CloseNestedGeneric. + Assert.Throws( + () => System.Activator.CreateInstance(openSlot, (System.Action)(x => { }), 1), + "Activator.CreateInstance must reject the OPEN nested type so the " + + "open-vs-closed mistake fails loudly instead of silently constructing." + ); + + System.Type closedSlot = ReflectionHelpers.CloseNestedGeneric( + closed, + nameof(ProbeOuter.ProbeSlot), + BindingFlags.NonPublic + ); + Assert.IsFalse( + closedSlot.ContainsGenericParameters, + "ReflectionHelpers.CloseNestedGeneric must produce a fully-closed nested type." + ); + object slot = System.Activator.CreateInstance( + closedSlot, + (System.Action)(x => { }), + 3 + ); + Assert.IsNotNull( + slot, + "Activator.CreateInstance must succeed on the closed nested type." + ); + FieldInfo countField = closedSlot.GetField( + CountFieldName, + BindingFlags.Public | BindingFlags.Instance + ); + Assert.IsNotNull(countField, "ProbeSlot.count field must exist."); + Assert.AreEqual(3, countField.GetValue(slot)); + } + + /// + /// Pins the contract for + /// : + /// passing null for the outer type throws + /// with a parameter-named + /// message. Refactors that drop the explicit null check fail this + /// test instead of surfacing a generic + /// at the call site. + /// + [Test] + public void CloseNestedGenericRejectsNullOuter() + { + System.ArgumentNullException ex = Assert.Throws(() => + ReflectionHelpers.CloseNestedGeneric(null, EntryNestedName, BindingFlags.NonPublic) + ); + StringAssert.Contains("closedOuter", ex.Message); + } + + /// + /// Pins the contract for + /// : + /// passing null for the nested name throws + /// with a parameter-named + /// message. Pairs with . + /// + [Test] + public void CloseNestedGenericRejectsNullName() + { + System.Type closed = typeof(ProbeOuter<>).MakeGenericType(typeof(System.Action)); + System.ArgumentNullException ex = Assert.Throws(() => + ReflectionHelpers.CloseNestedGeneric(closed, null, BindingFlags.NonPublic) + ); + StringAssert.Contains("nestedName", ex.Message); + } + + /// + /// Pins that + /// + /// rejects an OPEN outer generic type (one whose + /// is still + /// true). The diagnostic message must call out the + /// "fully-closed outer" requirement so reviewers do not have to + /// trace through the helper to understand the failure. + /// + [Test] + public void CloseNestedGenericRejectsOpenOuter() + { + System.Type openOuter = typeof(ProbeOuter<>); + System.InvalidOperationException ex = Assert.Throws( + () => + ReflectionHelpers.CloseNestedGeneric( + openOuter, + nameof(ProbeOuter.ProbeSlot), + BindingFlags.NonPublic + ) + ); + StringAssert.Contains("fully-closed outer", ex.Message); + } + + /// + /// Pins that + /// + /// throws a descriptive + /// -- not a silent + /// null -- when the requested nested name does not exist. + /// The message must include both the missing name and the outer's + /// fully-qualified name so reviewers can resolve typos quickly. + /// + [Test] + public void CloseNestedGenericRejectsMissingNestedName() + { + System.Type closed = typeof(ProbeOuter<>).MakeGenericType(typeof(System.Action)); + System.InvalidOperationException ex = Assert.Throws( + () => + ReflectionHelpers.CloseNestedGeneric( + closed, + "DoesNotExist", + BindingFlags.NonPublic + ) + ); + StringAssert.Contains("DoesNotExist", ex.Message); + StringAssert.Contains("not found", ex.Message); + } + + /// + /// Pins that + /// + /// throws -- never a + /// silent from + /// MakeGenericType -- when asked to close a generic nested + /// type whose outer is non-generic (so there are no inherited + /// generic arguments to supply). + /// + [Test] + public void CloseNestedGenericRejectsGenericNestedOnNonGenericOuter() + { + System.InvalidOperationException ex = Assert.Throws( + () => + ReflectionHelpers.CloseNestedGeneric( + typeof(NonGenericProbeOuter), + nameof(NonGenericProbeOuter.GenericProbeSlot) + "`1", + BindingFlags.NonPublic + ) + ); + StringAssert.Contains("non-generic", ex.Message); + } + + /// + /// Pins the HIGH-severity contract for the three-argument overload + /// of : + /// when the nested type declares its OWN generic parameters in + /// addition to those inherited from the outer, the helper must + /// throw with a + /// message directing the caller to the four-argument overload. + /// Without this guard the helper would forward only the outer's + /// arguments to MakeGenericType and surface a raw + /// from the runtime instead. + /// The companion test + /// + /// covers the success path on the same shape. + /// + [Test] + public void CloseNestedGenericRejectsNestedTypeWithOwnGenericParameters() + { + System.Type closed = typeof(ProbeOuterWithOwnEntryArg<>).MakeGenericType( + typeof(System.Action) + ); + System.InvalidOperationException ex = Assert.Throws( + () => + ReflectionHelpers.CloseNestedGeneric( + closed, + nameof(ProbeOuterWithOwnEntryArg.OwnEntry) + "`1", + BindingFlags.NonPublic + ) + ); + StringAssert.Contains("of its own", ex.Message); + StringAssert.Contains("overload", ex.Message); + } + + /// + /// Companion success path for + /// : + /// the four-argument overload accepts the explicit + /// nestedOwnArgs and produces a fully-closed type whose + /// inherited slot is taken from the outer and whose own slot is + /// taken from the supplied argument array. + /// + [Test] + public void CloseNestedGenericFourArgOverloadAcceptsNestedOwnArgs() + { + System.Type closed = typeof(ProbeOuterWithOwnEntryArg<>).MakeGenericType( + typeof(System.Action) + ); + System.Type closedNested = ReflectionHelpers.CloseNestedGeneric( + closed, + nameof(ProbeOuterWithOwnEntryArg.OwnEntry) + "`1", + BindingFlags.NonPublic, + new System.Type[] { typeof(string) } + ); + Assert.IsFalse( + closedNested.ContainsGenericParameters, + "Four-arg overload must produce a fully-closed nested type." + ); + System.Type[] genericArgs = closedNested.GetGenericArguments(); + Assert.AreEqual(2, genericArgs.Length); + Assert.AreEqual(typeof(System.Action), genericArgs[0]); + Assert.AreEqual(typeof(string), genericArgs[1]); + } + /// /// Pins that is the flat /// 3-level Dictionary<InstanceId, Dictionary<int, IHandlerActionCache>> diff --git a/Tests/Runtime/Core/BaseCallContractTests.cs b/Tests/Runtime/Core/BaseCallContractTests.cs index 61c9730e..524dade8 100644 --- a/Tests/Runtime/Core/BaseCallContractTests.cs +++ b/Tests/Runtime/Core/BaseCallContractTests.cs @@ -13,6 +13,7 @@ namespace DxMessaging.Tests.Runtime.Core using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; + using static DxMessaging.Tests.Runtime.RegistrationCountAssertions; /// /// Pins the runtime consequence of forgetting a base.X() call when @@ -130,20 +131,12 @@ MessageScenario scenario EmitDirectly(scenario, host); IMessageBus bus = MessageHandler.MessageBus; - Assert.Zero( - bus.RegisteredUntargeted, - "[{0}] No untargeted registrations should exist.", - scenario.Kind - ); - Assert.Zero( - bus.RegisteredTargeted, - "[{0}] No targeted registrations should exist.", - scenario.Kind - ); - Assert.Zero( - bus.RegisteredBroadcast, - "[{0}] No broadcast registrations should exist.", - scenario.Kind + AssertRegistrationCounts( + bus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: $"OmitBaseAwake[{scenario.Kind}] no registrations should exist" ); yield break; @@ -495,23 +488,13 @@ MessageScenario scenario // refactor that nets to zero by accidentally deregistering // unrelated registrations along with the user counter. IMessageBus bus = MessageHandler.MessageBus; - Assert.Zero( - bus.RegisteredUntargeted, - "[{0}] No untargeted registrations should remain after destroy. {1}", - scenario.Kind, - watcher.DescribeDelta() - ); - Assert.Zero( - bus.RegisteredTargeted, - "[{0}] No targeted registrations should remain after destroy. {1}", - scenario.Kind, - watcher.DescribeDelta() - ); - Assert.Zero( - bus.RegisteredBroadcast, - "[{0}] No broadcast registrations should remain after destroy. {1}", - scenario.Kind, - watcher.DescribeDelta() + AssertRegistrationCounts( + bus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: $"InheritedOnDisableMasksBrokenOnDestroy[{scenario.Kind}] " + + $"after destroy. {watcher.DescribeDelta()}" ); yield break; @@ -584,34 +567,18 @@ MessageScenario scenario string deltaDescription = watcher.DescribeDelta(); - Assert.AreEqual( - expectedTargeted, - bus.RegisteredTargeted, - "[{0}] Targeted leak must include the 2 default StringMessage " - + "handlers plus any counter for this scenario. Expected={1}. {2}", - scenario.Kind, - expectedTargeted, - deltaDescription - ); - Assert.AreEqual( - expectedUntargeted, - bus.RegisteredUntargeted, - "[{0}] Untargeted leak must include the 1 default " - + "GlobalStringMessage handler plus any counter for this " - + "scenario. Expected={1}. {2}", - scenario.Kind, - expectedUntargeted, - deltaDescription - ); - Assert.AreEqual( - expectedBroadcast, - bus.RegisteredBroadcast, - "[{0}] Broadcast leak must equal the scenario's counter " - + "contribution; the base class registers no default " - + "broadcast handlers. Expected={1}. {2}", - scenario.Kind, - expectedBroadcast, - deltaDescription + // Per-counter shape: the two default StringMessage handlers land on + // Targeted regardless of scenario, the default GlobalStringMessage + // handler lands on Untargeted, and the user counter lands on the + // bucket that matches scenario.Kind. Failure messages surface the + // diverging bucket(s) directly. + AssertRegistrationCounts( + bus, + untargeted: expectedUntargeted, + targeted: expectedTargeted, + broadcast: expectedBroadcast, + context: $"OmitBaseOnDisableAndOnDestroyLeaksDefaultHandlersToo[{scenario.Kind}] " + + $"after destroy. {deltaDescription}" ); // Drop the orphaned registrations so they cannot bleed into the diff --git a/Tests/Runtime/Core/DiagnosticsTests.cs b/Tests/Runtime/Core/DiagnosticsTests.cs index 1f8b495d..7d7a6ac6 100644 --- a/Tests/Runtime/Core/DiagnosticsTests.cs +++ b/Tests/Runtime/Core/DiagnosticsTests.cs @@ -139,14 +139,14 @@ public void RuntimeSettingsMessageBufferSizeResizesExistingAndNewBuses() IDisposable overrideToken = null; try { - MessageBus existingBus = new MessageBus(new FakeClock()); + MessageBus existingBus = MessageBus.CreateForInternalUse(new FakeClock()); settings._messageBufferSize = 2; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); Assert.AreEqual(2, IMessageBus.GlobalMessageBufferSize); Assert.AreEqual(2, GetEmissionBuffer(existingBus).Capacity); - MessageBus newBus = new MessageBus(new FakeClock()); + MessageBus newBus = MessageBus.CreateForInternalUse(new FakeClock()); Assert.AreEqual(2, GetEmissionBuffer(newBus).Capacity); settings._messageBufferSize = 1; diff --git a/Tests/Runtime/Core/MutationPostProcessorAcrossHandlersTests.cs b/Tests/Runtime/Core/MutationPostProcessorAcrossHandlersTests.cs index 0dd282b3..3eff8c80 100644 --- a/Tests/Runtime/Core/MutationPostProcessorAcrossHandlersTests.cs +++ b/Tests/Runtime/Core/MutationPostProcessorAcrossHandlersTests.cs @@ -1,10 +1,12 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; @@ -14,95 +16,708 @@ namespace DxMessaging.Tests.Runtime.Core public sealed class MutationPostProcessorAcrossHandlersTests : MessagingTestBase { [UnityTest] - public IEnumerator TargetedWithoutTargetingRemoveOtherAcrossHandlersDuringPostProcessing() + public IEnumerator RemoveOtherPostProcessorAcrossHandlersDuringDispatch( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) { - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = - new(); - for (int i = 0; i < 2; i++) - { - GameObject go = new($"TWT_PP_Rem_{i}", typeof(EmptyMessageAwareComponent)); - _spawned.Add(go); - EmptyMessageAwareComponent c = go.GetComponent(); - listeners.Add((c, GetToken(c))); - } + (EmptyMessageAwareComponent[] components, MessageRegistrationToken[] tokens) = + SpawnTwoListeners(scenario, "RemoveOtherPp_"); + + using LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); + + MessageRegistrationHandle[] pp = new MessageRegistrationHandle[2]; + int[] counts = new int[2]; + List order = new(); + InstanceId emissionTarget = ResolveEmissionTarget(scenario, components); + + // Ensure dispatch reaches the post-processor stage by registering a no-op handler + // on the same MessageHandler that owns pp[0]. + MessageRegistrationHandle noop = RegisterHandler( + scenario, + tokens[0], + emissionTarget, + () => { } + ); + + pp[0] = RegisterPostProcessor( + scenario, + tokens[0], + emissionTarget, + () => + { + counts[0]++; + order.Add(0); + tokens[1].RemoveRegistration(pp[1]); + } + ); + pp[1] = RegisterPostProcessor( + scenario, + tokens[1], + emissionTarget, + () => + { + counts[1]++; + order.Add(1); + } + ); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] pp[0] must run on the first emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] pp[1] was registered when emission started so it must still run on the first emission, even though pp[0] removed it mid-dispatch. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + CollectionAssert.AreEqual( + new List { 0, 1 }, + order, + "[{0}] pp[0] must run before pp[1] within the first emission.", + scenario + ); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 2, + counts[0], + "[{0}] pp[0] must run again on the second emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] pp[1] was removed during the first emission so it must not run on the second emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + tokens[0].RemoveRegistration(pp[0]); + tokens[0].RemoveRegistration(noop); + yield break; + } + + [UnityTest] + public IEnumerator RemoveSelfPostProcessorAcrossHandlersDuringDispatch( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + (EmptyMessageAwareComponent[] components, MessageRegistrationToken[] tokens) = + SpawnTwoListeners(scenario, "RemoveSelfPp_"); + + using LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); MessageRegistrationHandle[] pp = new MessageRegistrationHandle[2]; int[] counts = new int[2]; + InstanceId emissionTarget = ResolveEmissionTarget(scenario, components); + + MessageRegistrationHandle noop = RegisterHandler( + scenario, + tokens[0], + emissionTarget, + () => { } + ); + + pp[0] = RegisterPostProcessor( + scenario, + tokens[0], + emissionTarget, + () => + { + counts[0]++; + tokens[0].RemoveRegistration(pp[0]); + } + ); + pp[1] = RegisterPostProcessor(scenario, tokens[1], emissionTarget, () => counts[1]++); - // Ensure post-processing runs - _ = listeners[0] - .token.RegisterTargetedWithoutTargeting((_, _) => { }); + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] pp[0] must run once before removing itself. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] pp[1] on the sibling handler must still run on the first emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); - pp[0] = listeners[0] - .token.RegisterTargetedWithoutTargetingPostProcessor( - (ref InstanceId _, ref SimpleTargetedMessage __) => + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] pp[0] removed itself during the previous emission so it must not run again. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 2, + counts[1], + "[{0}] pp[1] must continue to run on subsequent emissions. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + tokens[1].RemoveRegistration(pp[1]); + tokens[0].RemoveRegistration(noop); + yield break; + } + + [UnityTest] + public IEnumerator RemoveAllPostProcessorsAcrossHandlersDuringDispatch( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + (EmptyMessageAwareComponent[] components, MessageRegistrationToken[] tokens) = + SpawnTwoListeners(scenario, "RemoveAllPp_"); + + using LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); + + MessageRegistrationHandle[] pp = new MessageRegistrationHandle[2]; + int[] counts = new int[2]; + InstanceId emissionTarget = ResolveEmissionTarget(scenario, components); + + MessageRegistrationHandle noop = RegisterHandler( + scenario, + tokens[0], + emissionTarget, + () => { } + ); + + pp[0] = RegisterPostProcessor( + scenario, + tokens[0], + emissionTarget, + () => + { + counts[0]++; + tokens[0].RemoveRegistration(pp[0]); + tokens[1].RemoveRegistration(pp[1]); + } + ); + pp[1] = RegisterPostProcessor(scenario, tokens[1], emissionTarget, () => counts[1]++); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] pp[0] must run on the first emission before tearing down both pp registrations. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] pp[1] was registered when emission started so the snapshot must still dispatch it on the first emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] pp[0] removed itself so it must not fire on the second emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] pp[1] was removed mid-emit during the first emission so it must not fire on the second emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + tokens[0].RemoveRegistration(noop); + yield break; + } + + [UnityTest] + public IEnumerator AddNewPostProcessorAcrossHandlersDuringDispatch( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + (EmptyMessageAwareComponent[] components, MessageRegistrationToken[] tokens) = + SpawnTwoListeners(scenario, "AddNewPp_"); + + using LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); + + int existingCount = 0; + int addedCount = 0; + bool added = false; + MessageRegistrationHandle addedHandle = default; + MessageRegistrationHandle existingHandle; + InstanceId emissionTarget = ResolveEmissionTarget(scenario, components); + + MessageRegistrationHandle noop = RegisterHandler( + scenario, + tokens[0], + emissionTarget, + () => { } + ); + existingHandle = RegisterPostProcessor( + scenario, + tokens[0], + emissionTarget, + () => + { + existingCount++; + if (!added) { - counts[0]++; - listeners[1].token.RemoveRegistration(pp[1]); + added = true; + addedHandle = RegisterPostProcessor( + scenario, + tokens[1], + emissionTarget, + () => addedCount++ + ); } - ); - pp[1] = listeners[1] - .token.RegisterTargetedWithoutTargetingPostProcessor( - (ref InstanceId _, ref SimpleTargetedMessage __) => counts[1]++ - ); + } + ); - GameObject target = new("TWT_PP_Target"); - _spawned.Add(target); + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + existingCount, + "[{0}] Existing post-processor must run on the first emission. existing={1}, added={2}.", + scenario, + existingCount, + addedCount + ); + Assert.AreEqual( + 0, + addedCount, + "[{0}] Cross-handler post-processor added during dispatch must not run on the in-flight emission. existing={1}, added={2}.", + scenario, + existingCount, + addedCount + ); - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(target); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 2, + existingCount, + "[{0}] Existing post-processor must run again on the second emission. existing={1}, added={2}.", + scenario, + existingCount, + addedCount + ); + Assert.AreEqual( + 1, + addedCount, + "[{0}] Cross-handler post-processor added during the first emission must start running on the second emission. existing={1}, added={2}.", + scenario, + existingCount, + addedCount + ); - msg.EmitGameObjectTargeted(target); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); + tokens[1].RemoveRegistration(addedHandle); + tokens[0].RemoveRegistration(existingHandle); + tokens[0].RemoveRegistration(noop); yield break; } [UnityTest] - public IEnumerator BroadcastWithoutSourceRemoveOtherAcrossHandlersDuringPostProcessing() + public IEnumerator RemoveOtherHandlerAcrossHandlersDuringDispatch( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) { - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = - new(); + (EmptyMessageAwareComponent[] components, MessageRegistrationToken[] tokens) = + SpawnTwoListeners(scenario, "RemoveOtherHandler_"); + + using LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); + + MessageRegistrationHandle[] handlers = new MessageRegistrationHandle[2]; + int[] counts = new int[2]; + InstanceId emissionTarget = ResolveEmissionTarget(scenario, components); + + handlers[0] = RegisterHandler( + scenario, + tokens[0], + emissionTarget, + () => + { + counts[0]++; + tokens[1].RemoveRegistration(handlers[1]); + } + ); + handlers[1] = RegisterHandler(scenario, tokens[1], emissionTarget, () => counts[1]++); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 1, + counts[0], + "[{0}] handlers[0] must run on the first emission. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] handlers[1] was registered at emission start so the snapshot must still dispatch it. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + EmitForScenario(scenario, components[0]); + Assert.AreEqual( + 2, + counts[0], + "[{0}] handlers[0] must continue running on subsequent emissions. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + Assert.AreEqual( + 1, + counts[1], + "[{0}] handlers[1] was removed mid-emit so subsequent emissions must skip it. counts=({1}, {2}).", + scenario, + counts[0], + counts[1] + ); + + tokens[0].RemoveRegistration(handlers[0]); + yield break; + } + + /// + /// Documents the global accept-all (RegisterGlobalAcceptAll) cross-handler + /// removal behavior. Unlike per-kind dispatch, the bus prefreezes the + /// global accept-all caches lazily per-entry inside the dispatch loop, + /// so a sibling MessageHandler that removes another's global registration + /// during the same emission causes the removed handler to be SKIPPED on + /// the in-flight emission. This contrasts with the in-flight snapshot + /// semantics observed by the per-kind dispatch surfaces. Pinning the + /// behavior here so a future change to the global dispatch model + /// (upfront prefreeze) cannot land silently. + /// + [UnityTest] + public IEnumerator RemoveOtherGlobalAcceptAllAcrossHandlersDuringDispatch() + { + EmptyMessageAwareComponent[] components = new EmptyMessageAwareComponent[2]; + MessageRegistrationToken[] tokens = new MessageRegistrationToken[2]; for (int i = 0; i < 2; i++) { - GameObject go = new($"BWO_PP_Rem_{i}", typeof(EmptyMessageAwareComponent)); + GameObject go = new( + "GlobalAcceptAll_RemoveOther_" + i, + typeof(EmptyMessageAwareComponent) + ); _spawned.Add(go); - EmptyMessageAwareComponent c = go.GetComponent(); - listeners.Add((c, GetToken(c))); + components[i] = go.GetComponent(); + tokens[i] = GetToken(components[i]); } - MessageRegistrationHandle[] pp = new MessageRegistrationHandle[2]; - int[] counts = new int[2]; + using LeakWatcher watcher = LeakWatcher.Watch(label: "GlobalAcceptAll"); - // Ensure post-processing runs - _ = listeners[0] - .token.RegisterBroadcastWithoutSource((_, _) => { }); + MessageRegistrationHandle[] global = new MessageRegistrationHandle[2]; + int[] counts = new int[2]; - pp[0] = listeners[0] - .token.RegisterBroadcastWithoutSourcePostProcessor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => + global[0] = tokens[0] + .RegisterGlobalAcceptAll( + acceptAllUntargeted: _ => { counts[0]++; - listeners[1].token.RemoveRegistration(pp[1]); - } + tokens[1].RemoveRegistration(global[1]); + }, + acceptAllTargeted: (_, _) => { }, + acceptAllBroadcast: (_, _) => { } ); - pp[1] = listeners[1] - .token.RegisterBroadcastWithoutSourcePostProcessor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => counts[1]++ + global[1] = tokens[1] + .RegisterGlobalAcceptAll( + acceptAllUntargeted: _ => counts[1]++, + acceptAllTargeted: (_, _) => { }, + acceptAllBroadcast: (_, _) => { } ); - SimpleBroadcastMessage msg = new(); - msg.EmitComponentBroadcast(listeners[0].comp); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); + SimpleUntargetedMessage msg = new(); + msg.EmitUntargeted(); + Assert.AreEqual( + 1, + counts[0], + "global[0] must fire on the first emission. counts=({0}, {1}).", + counts[0], + counts[1] + ); + // Documented behavior: the global accept-all path uses lazy + // per-entry prefreeze, so global[1] is dropped during the same + // emission that global[0] removes it. If a future change adds an + // upfront prefreeze for global handlers (mirroring the per-kind + // dispatch surfaces), this assertion must flip to expect counts[1] + // == 1 on the first emission. + Assert.AreEqual( + 0, + counts[1], + "global[1] is expected to be skipped on the first emission because the global accept-all path prefreezes lazily per-entry; if this assertion flips to 1, the bus has switched to upfront global prefreeze and the snapshot semantics now match the per-kind paths. counts=({0}, {1}).", + counts[0], + counts[1] + ); + + msg.EmitUntargeted(); + Assert.AreEqual( + 2, + counts[0], + "global[0] must fire on the second emission. counts=({0}, {1}).", + counts[0], + counts[1] + ); + Assert.AreEqual( + 0, + counts[1], + "global[1] was removed before the second emission so it must not fire. counts=({0}, {1}).", + counts[0], + counts[1] + ); - msg.EmitComponentBroadcast(listeners[0].comp); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); + tokens[0].RemoveRegistration(global[0]); yield break; } + + private ( + EmptyMessageAwareComponent[] components, + MessageRegistrationToken[] tokens + ) SpawnTwoListeners(MessageScenario scenario, string namePrefix) + { + EmptyMessageAwareComponent[] components = new EmptyMessageAwareComponent[2]; + MessageRegistrationToken[] tokens = new MessageRegistrationToken[2]; + for (int i = 0; i < 2; i++) + { + GameObject go = new( + namePrefix + scenario.Kind + "_" + i, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(go); + components[i] = go.GetComponent(); + tokens[i] = GetToken(components[i]); + } + return (components, tokens); + } + + private static InstanceId ResolveEmissionTarget( + MessageScenario scenario, + EmptyMessageAwareComponent[] components + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + case MessageKind.TargetedWithoutTargeting: + case MessageKind.BroadcastWithoutSource: + case MessageKind.Targeted: + case MessageKind.Broadcast: + { + return components[0]; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static MessageRegistrationHandle RegisterHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId source, + Action onInvoked, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + source, + (ref SimpleTargetedMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + source, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargeting( + (_, _) => onInvoked(), + priority: priority + ); + } + case MessageKind.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSource( + (_, _) => onInvoked(), + priority: priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static MessageRegistrationHandle RegisterPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId source, + Action onInvoked, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargetedPostProcessor( + (ref SimpleUntargetedMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargetedPostProcessor( + source, + (ref SimpleTargetedMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcastPostProcessor( + source, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority: priority + ); + } + case MessageKind.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargetingPostProcessor( + (ref InstanceId _, ref SimpleTargetedMessage __) => onInvoked(), + priority: priority + ); + } + case MessageKind.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSourcePostProcessor( + (ref InstanceId _, ref SimpleBroadcastMessage __) => onInvoked(), + priority: priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario( + MessageScenario scenario, + EmptyMessageAwareComponent component + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage untargeted = new(); + untargeted.EmitUntargeted(); + return; + } + case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: + { + SimpleTargetedMessage targeted = new(); + targeted.EmitComponentTargeted(component); + return; + } + case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: + { + SimpleBroadcastMessage broadcast = new(); + broadcast.EmitComponentBroadcast(component); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } } } diff --git a/Tests/Runtime/Core/NominalTests.cs b/Tests/Runtime/Core/NominalTests.cs index 936456e2..6ee42ba9 100644 --- a/Tests/Runtime/Core/NominalTests.cs +++ b/Tests/Runtime/Core/NominalTests.cs @@ -13,11 +13,24 @@ namespace DxMessaging.Tests.Runtime.Core using Scripts.Messages; using UnityEngine; using UnityEngine.TestTools; + using static DxMessaging.Tests.Runtime.RegistrationCountAssertions; using Object = UnityEngine.Object; [Category("Stress")] public sealed class NominalTests : MessagingTestBase { + // Per-shape registration entry counts for the Lifetime test. Hoisted to + // a single source of truth so the same expected shape used by every + // lifecycle transition cannot drift between call sites. + // SimpleMessageAwareComponent contributes the registration entries + // documented in the docblock on Lifetime. + private const int SingleComponentUntargeted = 1; + private const int SingleComponentTargeted = 5; + private const int SingleComponentBroadcast = 3; + private const int TwoComponentsUntargeted = 1; + private const int TwoComponentsTargeted = 7; + private const int TwoComponentsBroadcast = 4; + [SetUp] public override void Setup() { @@ -281,85 +294,168 @@ public IEnumerator Lifetime() IMessageBus messageBus = MessageHandler.MessageBus; Assert.IsNotNull(messageBus); + // Bracket the full lifecycle churn in a LeakWatcher. The + // per-counter assertions below only cover Untargeted/Targeted/ + // Broadcast handler counters; LeakWatcher additionally guards + // RegisteredInterceptors, RegisteredPostProcessors, and + // RegisteredGlobalAcceptAll, so a regression that leaks any of + // those auxiliary counters during create/teardown surfaces here + // even though the per-counter (0,0,0) assertion at the end would + // miss it. + using LeakWatcher watcher = new(messageBus, label: nameof(Lifetime)); + GameObject test = new(nameof(Lifetime), typeof(SimpleMessageAwareComponent)); _spawned.Add(test); SimpleMessageAwareComponent firstComponent = test.GetComponent(); - // One for the untargeted message, one for the targeted without targeting, one for broadcast without source - Assert.AreEqual(3, messageBus.RegisteredUntargeted); - // One for the game object, one for each targeted message type (simple + complex) - Assert.AreEqual(4, messageBus.RegisteredTargeted); - Assert.AreEqual(2, messageBus.RegisteredBroadcast); + // SimpleMessageAwareComponent registers per active component + // (entries are distinct (type, target, priority) registration + // entries; identical (type, target, priority) tuples collapse): + // Untargeted bucket (1 registration entry): + // - RegisterUntargeted + // Targeted bucket (5 registration entries): + // - RegisterGameObjectTargeted x2 (collapse to 1 entry) + // - RegisterGameObjectTargeted x2 (collapse to 1 entry) + // - RegisterComponentTargeted (1 entry, distinct context) + // - RegisterComponentTargeted (1 entry, distinct context) + // - RegisterTargetedWithoutTargeting (1 entry, scalar TargetedWithoutContext sink) + // Broadcast bucket (3 registration entries): + // - RegisterGameObjectBroadcast (1 entry) + // - RegisterComponentBroadcast (1 entry, distinct context) + // - RegisterBroadcastWithoutSource (1 entry, scalar BroadcastWithoutContext sink) + // Adding another component on the SAME GameObject reuses the + // (type, gameObject) and the scalar (type) registration entries, + // so those do NOT double; only the per-component context + // entries multiply, giving the +2 / +1 deltas seen below. + AssertRegistrationCounts( + messageBus, + untargeted: SingleComponentUntargeted, + targeted: SingleComponentTargeted, + broadcast: SingleComponentBroadcast, + context: "first component spawned" + ); yield return null; SimpleMessageAwareComponent secondComponent = test.AddComponent(); - Assert.AreEqual(3, messageBus.RegisteredUntargeted); - // One for the game object, one for the first component, one for the second component = 3 - Assert.AreEqual(6, messageBus.RegisteredTargeted); - Assert.AreEqual(3, messageBus.RegisteredBroadcast); + // Adding a second component contributes: + // Untargeted: +0 (same scalar (SimpleUntargetedMessage) entry) + // Targeted: +2 (new (SimpleTargetedMessage, second-component) + // and (ComplexTargetedMessage, second-component) + // context entries; gameObject + scalar entries + // reused) + // Broadcast: +1 (new (SimpleBroadcastMessage, second-component) + // context entry; gameObject + scalar entries + // reused) + AssertRegistrationCounts( + messageBus, + untargeted: TwoComponentsUntargeted, + targeted: TwoComponentsTargeted, + broadcast: TwoComponentsBroadcast, + context: "second component added" + ); secondComponent.enabled = false; yield return null; - // 3 - one component (disabled) - Assert.AreEqual(3, messageBus.RegisteredUntargeted); - Assert.AreEqual(4, messageBus.RegisteredTargeted); - Assert.AreEqual(2, messageBus.RegisteredBroadcast); + // Disabling the second component removes only its + // per-component context entries; gameObject + scalar entries + // stay because the first component still registers them. + AssertRegistrationCounts( + messageBus, + untargeted: SingleComponentUntargeted, + targeted: SingleComponentTargeted, + broadcast: SingleComponentBroadcast, + context: "second component disabled" + ); firstComponent.enabled = false; yield return null; - // No active scripts, no active handlers - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + // No active scripts, no active handlers. + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "first component disabled (no active scripts)" + ); test.SetActive(false); yield return null; - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "GameObject deactivated" + ); firstComponent.enabled = true; yield return null; - // Game object is still disabled - shouldn't have active child scripts - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + // Game object is still disabled - shouldn't have active child scripts. + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "first component enabled while GameObject inactive" + ); test.SetActive(true); yield return null; - Assert.AreEqual(3, messageBus.RegisteredUntargeted); - Assert.AreEqual(4, messageBus.RegisteredTargeted); - Assert.AreEqual(2, messageBus.RegisteredBroadcast); + // Only the first component is enabled - back to the single-component shape. + AssertRegistrationCounts( + messageBus, + untargeted: SingleComponentUntargeted, + targeted: SingleComponentTargeted, + broadcast: SingleComponentBroadcast, + context: "GameObject reactivated with first component only" + ); Object.Destroy(firstComponent); yield return null; - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "first component destroyed (second still disabled)" + ); secondComponent.enabled = true; yield return null; - Assert.AreEqual(3, messageBus.RegisteredUntargeted); - Assert.AreEqual(4, messageBus.RegisteredTargeted); - Assert.AreEqual(2, messageBus.RegisteredBroadcast); + // Re-enabling the surviving component yields the single-component shape again. + AssertRegistrationCounts( + messageBus, + untargeted: SingleComponentUntargeted, + targeted: SingleComponentTargeted, + broadcast: SingleComponentBroadcast, + context: "second component re-enabled" + ); Object.Destroy(test); yield return null; - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + // Belt-and-braces: the per-counter triple must read (0,0,0) before + // LeakWatcher.Dispose runs. The watcher additionally enforces that + // the interceptor/post-processor/global-accept-all counters did not + // drift during the lifecycle churn. + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "GameObject destroyed" + ); } [UnityTest] @@ -368,19 +464,35 @@ public IEnumerator NonMessagingObjects() IMessageBus messageBus = MessageHandler.MessageBus; Assert.IsNotNull(messageBus); + // Bracket the GameObject churn in a LeakWatcher even though we + // expect no registrations to be created - if a refactor of + // MessageHandler ever auto-registered something on construction + // for a bare GameObject, the watcher would catch the auxiliary + // counters (Interceptors, PostProcessors, GlobalAcceptAll) that + // the per-bucket assertions below do not cover. + using LeakWatcher watcher = new(messageBus, label: nameof(NonMessagingObjects)); + GameObject test1 = new("NonMessaging1"); _spawned.Add(test1); - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "plain GameObject (no MessageAware components)" + ); GameObject test2 = new("NonMessaging1", typeof(SpriteRenderer), typeof(MessageHandler)); _spawned.Add(test2); - Assert.AreEqual(0, messageBus.RegisteredUntargeted); - Assert.AreEqual(0, messageBus.RegisteredTargeted); - Assert.AreEqual(0, messageBus.RegisteredBroadcast); + AssertRegistrationCounts( + messageBus, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "GameObject with SpriteRenderer + bare MessageHandler" + ); yield break; } diff --git a/Tests/Runtime/Core/PublicSurfaceContractTests.cs b/Tests/Runtime/Core/PublicSurfaceContractTests.cs index c78d950b..1b7cdeeb 100644 --- a/Tests/Runtime/Core/PublicSurfaceContractTests.cs +++ b/Tests/Runtime/Core/PublicSurfaceContractTests.cs @@ -3,6 +3,7 @@ namespace DxMessaging.Tests.Runtime.Core { using System; using System.Collections.Generic; + using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -13,6 +14,7 @@ namespace DxMessaging.Tests.Runtime.Core using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; + using Debug = UnityEngine.Debug; /// /// Pins invariants on the DxMessaging public surface. The fixture is @@ -26,6 +28,13 @@ public sealed class PublicSurfaceContractTests private const string PublicSurfaceSnapshotFileName = "public-surface.txt"; private const string CoreNamespacePrefix = "DxMessaging.Core"; + private const string UnityNamespacePrefix = "DxMessaging.Unity"; + + private static readonly string[] EffectivelyPublicNamespacePrefixes = + { + CoreNamespacePrefix, + UnityNamespacePrefix, + }; /// /// Enumerates every public type in the @@ -105,6 +114,116 @@ public void PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot() ); } + /// + /// Asserts that every type considered "public" by the snapshot + /// enumerator has an effectively public access chain across both the + /// DxMessaging.Core and DxMessaging.Unity assemblies + /// (including DxMessaging.Unity.Integrations.Reflex, + /// DxMessaging.Unity.Integrations.VContainer, and + /// DxMessaging.Unity.Integrations.Zenject). A nested + /// public struct inside an internal outer class is + /// flagged by as visible, but its + /// effective accessibility is internal because the outer class clamps + /// it down. This test pins the invariant directly so the snapshot diff + /// only ever surfaces genuinely public-API drift, not declared-public + /// types that the CLR cannot actually surface to consumers. + /// + /// + /// Motivating bug: the original + /// DxMessaging.Core.DataStructure.CyclicBuffer<T>.CyclicBufferEnumerator + /// struct was declared public inside an internal sealed + /// class CyclicBuffer<T>. The CLR clamps the effective + /// accessibility of the nested struct to internal (matching the + /// outer), but reflection still reports IsNestedPublic == true, + /// so the snapshot enumerator and any other naive + /// IsPublic || IsNestedPublic filter would silently surface it + /// as part of the public API. The fix changed the nested struct to + /// internal; this test exists to make the bug class + /// undetectable-by-eye but always-detectable-by-CI. + /// + [Test] + public void NoEffectivelyInternalTypesLeakAsPublic() + { + HashSet offenders = new HashSet(StringComparer.Ordinal); + + foreach (string namespacePrefix in EffectivelyPublicNamespacePrefixes) + { + foreach (Type type in EnumerateNamespaceTypes(namespacePrefix)) + { + // Find types that LOOK public to a naive enumerator but + // are actually clamped down by an internal enclosing type. + bool looksPublic = type.IsPublic || type.IsNestedPublic; + if (!looksPublic) + { + continue; + } + + if (!IsEffectivelyPublic(type)) + { + string fullName = type.FullName ?? type.Name; + offenders.Add(fullName); + } + } + } + + List sorted = offenders.OrderBy(n => n, StringComparer.Ordinal).ToList(); + + Assert.That( + sorted, + Is.Empty, + "Found types declared 'public' inside a non-public enclosing type. The CLR exposes them " + + "as IsNestedPublic, but their effective accessibility is clamped down by the outer. " + + "Either mark the inner type 'internal' to match the enclosing scope, OR promote the " + + "enclosing type to 'public' if its surface is intended to be exposed:\n " + + string.Join("\n ", sorted) + ); + } + + /// + /// Pins the behavior of against a + /// matrix of synthetic types so that a future refactor cannot quietly + /// turn the helper into a constant-true/constant-false function and + /// silently disable . + /// + [Test] + public void IsEffectivelyPublicCorrectlyDetectsKnownPatterns() + { + // Top-level public type from the BCL. + Assert.IsTrue( + IsEffectivelyPublic(typeof(string)), + "string is a top-level public BCL type and must be effectively public." + ); + + // Top-level internal type declared in this assembly. + Assert.IsFalse( + IsEffectivelyPublic(typeof(SomeInternalClass)), + "SomeInternalClass is a top-level internal type and must NOT be effectively public." + ); + + // Nested public inside nested public inside top-level public => public. + Assert.IsTrue( + IsEffectivelyPublic(typeof(SyntheticOuterPublic.SyntheticInnerPublic)), + "Nested public inside top-level public must be effectively public." + ); + + // Nested public inside top-level internal outer => NOT public. + // This is the exact shape of the original + // CyclicBuffer.CyclicBufferEnumerator leak. + Assert.IsFalse( + IsEffectivelyPublic(typeof(SyntheticOuterInternal.SyntheticInnerPublic)), + "Nested public inside a top-level internal outer must NOT be effectively public." + ); + + // Three-level deep: outer public, middle non-public, inner public + // => NOT public. This exercises the loop's mid-chain break. + Assert.IsFalse( + IsEffectivelyPublic( + typeof(SyntheticOuterPublicLevel1.SyntheticMiddleInternal.SyntheticInnerPublic) + ), + "A 3-level chain where any middle rung is non-public must NOT be effectively public." + ); + } + /// /// Enumerates every method on and asserts /// each method name appears at least once as a textual token in the @@ -207,35 +326,73 @@ string file in Directory.EnumerateFiles( } /// - /// Tightens the existing - /// - /// invariant by enumerating directly and - /// asserting every value appears in - /// . The contract tests already - /// pin this; this test is an explicit duplicate so a mistake in one - /// location surfaces in the other. + /// Pins the canonical three-kind source used by tests that deliberately + /// cover only the context-bound dispatch surfaces. /// [Test] - public void EveryMessageKindAppearsInAllKinds() + public void EveryCanonicalMessageKindAppearsInAllKinds() { HashSet covered = new HashSet( MessageScenarios.AllKinds.Select(scenario => scenario.Kind) ); - List missing = new List(); - foreach (MessageKind kind in Enum.GetValues(typeof(MessageKind))) + MessageKind[] canonicalKinds = { - if (!covered.Contains(kind)) - { - missing.Add(kind.ToString()); - } - } + MessageKind.Untargeted, + MessageKind.Targeted, + MessageKind.Broadcast, + }; + + List missing = canonicalKinds + .Where(kind => !covered.Contains(kind)) + .Select(kind => kind.ToString()) + .ToList(); + List unexpected = covered + .Except(canonicalKinds) + .Select(kind => kind.ToString()) + .ToList(); Assert.That( missing, Is.Empty, - "MessageScenarios.AllKinds must yield every MessageKind. Missing: " + "MessageScenarios.AllKinds must yield the canonical context-bound MessageKind values. Missing: " + string.Join(", ", missing) + + ". Actual: " + + string.Join(", ", covered) + ); + Assert.That( + unexpected, + Is.Empty, + "MessageScenarios.AllKinds must stay limited to canonical context-bound MessageKind values. Unexpected: " + + string.Join(", ", unexpected) + + ". Use MessageScenarios.AllKindsIncludingWithoutContext for the full dispatch surface." + ); + } + + /// + /// Enumerates directly and asserts every value + /// appears in the source intended for full dispatch-surface coverage. + /// + [Test] + public void EveryMessageKindAppearsInAllKindsIncludingWithoutContext() + { + HashSet covered = new HashSet( + MessageScenarios.AllKindsIncludingWithoutContext.Select(scenario => scenario.Kind) + ); + + List missing = Enum.GetValues(typeof(MessageKind)) + .Cast() + .Where(kind => !covered.Contains(kind)) + .Select(kind => kind.ToString()) + .ToList(); + + Assert.That( + missing, + Is.Empty, + "MessageScenarios.AllKindsIncludingWithoutContext must yield every MessageKind. Missing: " + + string.Join(", ", missing) + + ". Actual: " + + string.Join(", ", covered) ); } @@ -244,6 +401,43 @@ private static List EnumeratePublicCoreTypeNames() HashSet seen = new HashSet(StringComparer.Ordinal); List names = new List(); + foreach (Type type in EnumerateNamespaceTypes(CoreNamespacePrefix)) + { + if (!IsEffectivelyPublic(type)) + { + continue; + } + + string fullName = type.FullName; + if (string.IsNullOrEmpty(fullName)) + { + continue; + } + + if (seen.Add(fullName)) + { + names.Add(fullName); + } + } + + names.Sort(StringComparer.Ordinal); + return names; + } + + /// + /// Yields every reflectable type in every loaded assembly whose + /// namespace is exactly or starts + /// with + ".". Types whose + /// declaring assembly throws + /// are partially recovered (non-null entries from + /// ); types with a null + /// namespace are skipped. Centralizing this loop eliminates duplicated + /// reflection try/catch blocks across the fixture. + /// + private static IEnumerable EnumerateNamespaceTypes(string namespacePrefix) + { + string prefixWithDot = namespacePrefix + "."; + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { Type[] types; @@ -258,42 +452,78 @@ private static List EnumeratePublicCoreTypeNames() foreach (Type type in types) { - if (!type.IsPublic && !type.IsNestedPublic) - { - continue; - } - if (type.Namespace == null) { continue; } if ( - !type.Namespace.Equals(CoreNamespacePrefix, StringComparison.Ordinal) - && !type.Namespace.StartsWith( - CoreNamespacePrefix + ".", - StringComparison.Ordinal - ) + !type.Namespace.Equals(namespacePrefix, StringComparison.Ordinal) + && !type.Namespace.StartsWith(prefixWithDot, StringComparison.Ordinal) ) { continue; } - string fullName = type.FullName; - if (string.IsNullOrEmpty(fullName)) - { - continue; - } + yield return type; + } + } + } - if (seen.Add(fullName)) - { - names.Add(fullName); - } + /// + /// Returns true if is effectively part of the + /// public surface, i.e. every enclosing type in the chain is itself + /// public. A nested-public type inside an internal outer is + /// not effectively public, even though + /// reports true. The CLR exposes the + /// declared accessibility on each rung of the nesting ladder, but the + /// effective accessibility of a type is the minimum of every rung. + /// + /// + /// Pinned by + /// so + /// future refactors of this helper cannot silently change behavior + /// and disable . + /// The motivating bug was the original + /// DxMessaging.Core.DataStructure.CyclicBuffer<T>.CyclicBufferEnumerator + /// leak; see for + /// the full historical context. + /// + private static bool IsEffectivelyPublic(Type type) + { + if (type == null) + { + return false; + } + + // Fast path: top-level internals and nested non-publics + // short-circuit immediately so the common case (most types in a + // closed-over assembly) does not pay for the walk-up loop. This + // restores parity with the original 'IsPublic || IsNestedPublic' + // pre-filter that this helper replaced. + if (!type.IsPublic && !type.IsNestedPublic) + { + return false; + } + + // Walk outward to the top-level type. Each non-top-level rung must + // be IsNestedPublic; the top-level rung must be IsPublic. + Type current = type; + while (current.IsNested) + { + if (!current.IsNestedPublic) + { + return false; } + + current = current.DeclaringType; + Debug.Assert( + current != null, + "A nested type must have a declaring type in a correct CLR; this is unreachable." + ); } - names.Sort(StringComparer.Ordinal); - return names; + return current.IsPublic; } private static string TryResolveSnapshotPath(out bool resolved) @@ -381,5 +611,61 @@ private static List ResolveTestSourceRoots() return roots; } } + + /// + /// Synthetic top-level internal sentinel used by + /// + /// to verify that the helper rejects top-level internal types. Lives in + /// the test asmdef so it does not affect the + /// DxMessaging.Core/DxMessaging.Unity public surface scan. + /// + internal sealed class SomeInternalClass { } + + /// + /// Synthetic top-level public outer used by + /// . + /// Lives in the test asmdef under + /// DxMessaging.Tests.Runtime.Core, which is not a scanned + /// production namespace, so adding it does NOT affect the snapshot. + /// + public static class SyntheticOuterPublic + { + /// Inner type for the all-public-chain assertion. + public class SyntheticInnerPublic { } + } + + /// + /// Synthetic top-level internal outer used by + /// . + /// Models the original CyclicBuffer<T>.CyclicBufferEnumerator + /// leak shape: a public nested type inside an internal + /// top-level outer. reports + /// IsNestedPublic == true, but its effective accessibility is + /// internal because the outer is internal. + /// + internal static class SyntheticOuterInternal + { + /// Inner type for the broken-chain assertion. + public class SyntheticInnerPublic { } + } + + /// + /// Synthetic three-level chain used by + /// . + /// Outer is public, middle is internal-nested, inner is nested-public; the + /// middle rung breaks the chain so the deepest type is NOT effectively + /// public. Pins the loop's mid-chain rejection branch. + /// + public static class SyntheticOuterPublicLevel1 + { + /// + /// Internal-nested middle rung that breaks the chain. + /// + internal static class SyntheticMiddleInternal + { + /// Deepest rung; declared public but unreachable. + public class SyntheticInnerPublic { } + } + } } #endif diff --git a/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs b/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs new file mode 100644 index 00000000..f8758f74 --- /dev/null +++ b/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs @@ -0,0 +1,297 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + + /// + /// Self-tests for . Confirms the + /// helper: + /// + /// returns silently when the bus matches the expected counts; + /// fails with a message that names the diverging bucket(s), + /// surfaces the per-bucket delta, includes the call site, and identifies + /// the bus expression captured by [CallerArgumentExpression]; + /// throws for a null bus; + /// honors the includeBucketingReminder opt-out flag. + /// + /// + /// + /// Uses a hand-rolled that returns + /// hardcoded counts. The stub throws + /// for every method the helper does not exercise so any future drift in + /// the helper that starts touching unrelated bus surface fails loudly. + /// + public sealed class RegistrationCountAssertionsTests + { + [Test] + public void HelperReturnsSilentlyWhenCountsMatch() + { + StubCountingMessageBus bus = new(untargeted: 3, targeted: 5, broadcast: 7); + + Assert.DoesNotThrow(() => + RegistrationCountAssertions.AssertRegistrationCounts( + bus, + untargeted: 3, + targeted: 5, + broadcast: 7, + context: "match path" + ) + ); + } + + [Test] + public void HelperFailsWithPerBucketDeltaCallSiteAndBusExpression() + { + StubCountingMessageBus expectedBusName = new(untargeted: 1, targeted: 4, broadcast: 3); + + AssertionException exception = Assert.Throws(() => + RegistrationCountAssertions.AssertRegistrationCounts( + expectedBusName, + untargeted: 1, + targeted: 5, + broadcast: 3, + context: "second component disabled" + ) + ); + + string message = exception.Message; + // Per-bucket delta line for the diverging bucket (Targeted), with + // the explicit signed delta and the actual/expected pair. + StringAssert.Contains("Targeted: expected 5, actual 4 (delta -1)", message); + // Buckets that match must NOT appear in the per-bucket diff. Cheap + // way to confirm: the matching buckets do not have a "(delta " suffix. + StringAssert.DoesNotContain("Untargeted: expected 1, actual 1", message); + StringAssert.DoesNotContain("Broadcast: expected 3, actual 3", message); + // The full triple appears as tail-line context. + StringAssert.Contains("Untargeted=1, Targeted=4, Broadcast=3", message); + // Caller-supplied label propagates. + StringAssert.Contains("second component disabled", message); + // CallerArgumentExpression must surface the local name passed for + // the bus parameter so multi-bus tests can attribute the failure. + StringAssert.Contains("expectedBusName", message); + // The call site is captured (file path + this test method name). + StringAssert.Contains( + nameof(HelperFailsWithPerBucketDeltaCallSiteAndBusExpression), + message + ); + StringAssert.Contains("RegistrationCountAssertionsTests.cs", message); + // Bucketing reminder is on by default. + StringAssert.Contains("Bucketing reminder", message); + } + + [Test] + public void HelperReportsAllDivergingBucketsWhenAllMismatch() + { + StubCountingMessageBus stub = new(untargeted: 2, targeted: 9, broadcast: 0); + + AssertionException exception = Assert.Throws(() => + RegistrationCountAssertions.AssertRegistrationCounts( + stub, + untargeted: 1, + targeted: 5, + broadcast: 3, + context: "all buckets diverge" + ) + ); + + string message = exception.Message; + StringAssert.Contains("Untargeted: expected 1, actual 2 (delta +1)", message); + StringAssert.Contains("Targeted: expected 5, actual 9 (delta +4)", message); + StringAssert.Contains("Broadcast: expected 3, actual 0 (delta -3)", message); + } + + [Test] + public void HelperOmitsBucketingReminderWhenOptedOut() + { + StubCountingMessageBus stub = new(untargeted: 0, targeted: 1, broadcast: 0); + + AssertionException exception = Assert.Throws(() => + RegistrationCountAssertions.AssertRegistrationCounts( + stub, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "no reminder", + includeBucketingReminder: false + ) + ); + + StringAssert.DoesNotContain("Bucketing reminder", exception.Message); + // The actionable per-bucket delta line is still present. + StringAssert.Contains("Targeted: expected 0, actual 1 (delta +1)", exception.Message); + } + + [Test] + public void HelperThrowsArgumentNullExceptionForNullBus() + { + ArgumentNullException exception = Assert.Throws(() => + RegistrationCountAssertions.AssertRegistrationCounts( + bus: null, + untargeted: 0, + targeted: 0, + broadcast: 0, + context: "null bus path" + ) + ); + + Assert.AreEqual("bus", exception.ParamName); + } + + /// + /// Minimal stub that returns hardcoded + /// counter values. Throws for + /// every method the helper does not invoke so a future refactor that + /// starts touching additional bus surface fails loudly instead of + /// silently passing the wrong default value through. + /// + private sealed class StubCountingMessageBus : IMessageBus + { + public StubCountingMessageBus(int untargeted, int targeted, int broadcast) + { + RegisteredUntargeted = untargeted; + RegisteredTargeted = targeted; + RegisteredBroadcast = broadcast; + } + + public int RegisteredUntargeted { get; } + + public int RegisteredTargeted { get; } + + public int RegisteredBroadcast { get; } + + public bool DiagnosticsMode => throw new NotImplementedException(); + + public int RegisteredGlobalSequentialIndex => throw new NotImplementedException(); + + public int OccupiedTypeSlots => throw new NotImplementedException(); + + public int OccupiedTargetSlots => throw new NotImplementedException(); + + public int RegisteredInterceptors => throw new NotImplementedException(); + + public int RegisteredPostProcessors => throw new NotImplementedException(); + + public int RegisteredGlobalAcceptAll => throw new NotImplementedException(); + + public RegistrationLog Log => throw new NotImplementedException(); + + public long EmissionId => throw new NotImplementedException(); + + public IMessageBus.TrimResult Trim(bool force = false) => + throw new NotImplementedException(); + + public Action RegisterUntargeted(MessageHandler messageHandler, int priority = 0) + where T : IUntargetedMessage => throw new NotImplementedException(); + + public Action RegisterUntargetedPostProcessor( + MessageHandler messageHandler, + int priority = 0 + ) + where T : IUntargetedMessage => throw new NotImplementedException(); + + public Action RegisterTargeted( + InstanceId target, + MessageHandler messageHandler, + int priority = 0 + ) + where T : ITargetedMessage => throw new NotImplementedException(); + + public Action RegisterTargetedPostProcessor( + InstanceId target, + MessageHandler messageHandler, + int priority = 0 + ) + where T : ITargetedMessage => throw new NotImplementedException(); + + public Action RegisterTargetedWithoutTargeting( + MessageHandler messageHandler, + int priority = 0 + ) + where T : ITargetedMessage => throw new NotImplementedException(); + + public Action RegisterTargetedWithoutTargetingPostProcessor( + MessageHandler messageHandler, + int priority = 0 + ) + where T : ITargetedMessage => throw new NotImplementedException(); + + public Action RegisterSourcedBroadcast( + InstanceId source, + MessageHandler messageHandler, + int priority = 0 + ) + where T : IBroadcastMessage => throw new NotImplementedException(); + + public Action RegisterBroadcastPostProcessor( + InstanceId source, + MessageHandler messageHandler, + int priority = 0 + ) + where T : IBroadcastMessage => throw new NotImplementedException(); + + public Action RegisterSourcedBroadcastWithoutSource( + MessageHandler messageHandler, + int priority = 0 + ) + where T : IBroadcastMessage => throw new NotImplementedException(); + + public Action RegisterBroadcastWithoutSourcePostProcessor( + MessageHandler messageHandler, + int priority = 0 + ) + where T : IBroadcastMessage => throw new NotImplementedException(); + + public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) => + throw new NotImplementedException(); + + public Action RegisterUntargetedInterceptor( + IMessageBus.UntargetedInterceptor interceptor, + int priority = 0 + ) + where T : IUntargetedMessage => throw new NotImplementedException(); + + public Action RegisterTargetedInterceptor( + IMessageBus.TargetedInterceptor interceptor, + int priority = 0 + ) + where T : ITargetedMessage => throw new NotImplementedException(); + + public Action RegisterBroadcastInterceptor( + IMessageBus.BroadcastInterceptor interceptor, + int priority = 0 + ) + where T : IBroadcastMessage => throw new NotImplementedException(); + + public void UntypedUntargetedBroadcast(IUntargetedMessage typedMessage) => + throw new NotImplementedException(); + + public void UntargetedBroadcast(ref TMessage typedMessage) + where TMessage : IUntargetedMessage => throw new NotImplementedException(); + + public void UntypedTargetedBroadcast( + InstanceId target, + ITargetedMessage typedMessage + ) => throw new NotImplementedException(); + + public void TargetedBroadcast( + ref InstanceId target, + ref TMessage typedMessage + ) + where TMessage : ITargetedMessage => throw new NotImplementedException(); + + public void UntypedSourcedBroadcast( + InstanceId source, + IBroadcastMessage typedMessage + ) => throw new NotImplementedException(); + + public void SourcedBroadcast(ref InstanceId source, ref TMessage typedMessage) + where TMessage : IBroadcastMessage => throw new NotImplementedException(); + } + } +} +#endif diff --git a/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs.meta b/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs.meta new file mode 100644 index 00000000..4e9a984a --- /dev/null +++ b/Tests/Runtime/Core/RegistrationCountAssertionsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2614a219ea24d45b395d2cc6bc8ff0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/TestAttributeContractTests.cs b/Tests/Runtime/Core/TestAttributeContractTests.cs index 0745a4c7..55bdece8 100644 --- a/Tests/Runtime/Core/TestAttributeContractTests.cs +++ b/Tests/Runtime/Core/TestAttributeContractTests.cs @@ -291,16 +291,17 @@ private static bool HasMessageScenarioParameter(MethodInfo method) /// /// Pins the allocation-coverage contract: every value of - /// must be represented in - /// . Adding a new kind without - /// updating the scenario source - and therefore the allocation matrix - /// that consumes it - will trip this guard. The contract pin lives in + /// must be represented in the scenario source + /// that covers the full dispatch surface. Adding a new kind without + /// updating the source will trip this guard. The contract pin lives in /// the allocation-coverage-required-for-dispatch skill. /// [Test] public void EveryEmitPathHasAllocationCoverage() { - HashSet covered = new(MessageScenarios.AllKinds.Select(s => s.Kind)); + HashSet covered = new( + MessageScenarios.AllKindsIncludingWithoutContext.Select(s => s.Kind) + ); List missing = new(); foreach (MessageKind kind in Enum.GetValues(typeof(MessageKind))) @@ -314,9 +315,11 @@ public void EveryEmitPathHasAllocationCoverage() Assert.That( missing, Is.Empty, - "MessageScenarios.AllKinds must yield every MessageKind so the allocation matrix " - + "and parameterized tests cover all dispatch paths. Missing kinds: " + "MessageScenarios.AllKindsIncludingWithoutContext must yield every MessageKind " + + "so full-surface parameterized tests cover all dispatch paths. Missing kinds: " + string.Join(", ", missing) + + ". Actual kinds: " + + string.Join(", ", covered) + ". See .llm/skills/testing/allocation-coverage-required-for-dispatch.md." ); } diff --git a/Tests/Runtime/Integrations/Reflex/ReflexIntegrationTests.cs b/Tests/Runtime/Integrations/Reflex/ReflexIntegrationTests.cs index 767a373f..fd7abed9 100644 --- a/Tests/Runtime/Integrations/Reflex/ReflexIntegrationTests.cs +++ b/Tests/Runtime/Integrations/Reflex/ReflexIntegrationTests.cs @@ -109,6 +109,175 @@ public void RegistrationInstallerExposesConcreteBuilder() ); } + [Test] + public void AddDxMessagingBusExposesBothContracts() + { + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(); + + Container container = TrackDisposable(builder.Build()); + MessageBus concrete = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreSame( + concrete, + iFace, + "Resolving MessageBus and IMessageBus through the helper should yield the same singleton." + ); + } + + [Test] + public void AddDxMessagingBusExposesBothContractsWhenInterfaceResolvesFirst() + { + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(); + + Container container = TrackDisposable(builder.Build()); + IMessageBus iFace = container.Resolve(); + MessageBus concrete = container.Resolve(); + + Assert.AreSame( + iFace, + concrete, + "Resolving IMessageBus before MessageBus through the helper should yield the same singleton." + ); + } + + [Test] + public void AddDxMessagingBusWithFactoryUsesProvidedInstance() + { + MessageBus expected = new MessageBus(); + int factoryCalls = 0; + bool containerWasProvided = false; + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(container => + { + ++factoryCalls; + containerWasProvided = container != null; + return expected; + }); + + Container container = TrackDisposable(builder.Build()); + MessageBus bus = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreSame( + expected, + bus, + "Factory overload should construct the bus exactly as the caller supplied." + ); + Assert.AreSame( + bus, + iFace, + "Factory overload should expose one singleton through MessageBus and IMessageBus." + ); + Assert.AreEqual( + 1, + factoryCalls, + "Singleton factory should run once even when both MessageBus and IMessageBus are resolved." + ); + Assert.IsTrue( + containerWasProvided, + "Factory overload should pass the active Reflex container to the caller." + ); + } + + [Test] + public void AddDxMessagingBusWithFactoryUsesProvidedInstanceWhenInterfaceResolvesFirst() + { + MessageBus expected = new MessageBus(); + int factoryCalls = 0; + bool containerWasProvided = false; + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(container => + { + ++factoryCalls; + containerWasProvided = container != null; + return expected; + }); + + Container container = TrackDisposable(builder.Build()); + IMessageBus iFace = container.Resolve(); + MessageBus bus = container.Resolve(); + + Assert.AreSame( + expected, + iFace, + "Factory overload should return the caller-supplied bus when IMessageBus resolves first." + ); + Assert.AreSame( + iFace, + bus, + "Factory overload should expose one singleton through IMessageBus and MessageBus." + ); + Assert.AreEqual( + 1, + factoryCalls, + "Singleton factory should run once even when IMessageBus resolves before MessageBus." + ); + Assert.IsTrue( + containerWasProvided, + "Factory overload should pass the active Reflex container to the caller." + ); + } + + [Test] + public void AddDxMessagingBusWithClockUsesInjectedClock() + { + FakeClock clock = new FakeClock(initialSeconds: 17d); + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(clock); + + Container container = TrackDisposable(builder.Build()); + MessageBus bus = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreEqual( + 17d, + clock.NowSeconds, + "Helper should construct the bus through CreateForInternalUse without advancing the clock." + ); + Assert.GreaterOrEqual( + clock.ReadCount, + 2, + "Resolving the bus should read the injected clock during MessageBus construction, and the assertion reads it once more for diagnostics." + ); + Assert.AreSame( + bus, + iFace, + "Clock overload should expose the same bus through MessageBus and IMessageBus." + ); + Assert.NotNull(bus); + } + + [Test] + public void AddDxMessagingBusWithClockUsesInjectedClockWhenInterfaceResolvesFirst() + { + FakeClock clock = new FakeClock(initialSeconds: 17d); + ContainerBuilder builder = new(); + builder.AddDxMessagingBus(clock); + + Container container = TrackDisposable(builder.Build()); + IMessageBus iFace = container.Resolve(); + MessageBus bus = container.Resolve(); + + Assert.AreSame( + iFace, + bus, + "Clock overload should expose the same bus through IMessageBus and MessageBus." + ); + Assert.AreEqual( + 17d, + clock.NowSeconds, + "Clock overload should construct the bus through CreateForInternalUse without advancing the clock." + ); + Assert.GreaterOrEqual( + clock.ReadCount, + 2, + "Resolving the bus should read the injected clock during MessageBus construction, and the assertion reads it once more for diagnostics." + ); + } + private sealed class DxMessagingInstaller : IInstaller { public void InstallBindings(ContainerBuilder containerBuilder) diff --git a/Tests/Runtime/Integrations/VContainer/VContainerIntegrationTests.cs b/Tests/Runtime/Integrations/VContainer/VContainerIntegrationTests.cs index f739fe19..ba6cf1e9 100644 --- a/Tests/Runtime/Integrations/VContainer/VContainerIntegrationTests.cs +++ b/Tests/Runtime/Integrations/VContainer/VContainerIntegrationTests.cs @@ -3,9 +3,11 @@ namespace DxMessaging.Tests.Runtime.VContainer { #if VCONTAINER_PRESENT using System; + using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Pooling; using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Messages; using DxMessaging.Unity; @@ -18,10 +20,10 @@ namespace DxMessaging.Tests.Runtime.VContainer public sealed class VContainerIntegrationTests : UnityFixtureBase { [Test] - public void ContainerInjectsMessagingComponentWithCustomBus() + public void ContainerInjectsMessagingComponentWithRegisteredBus() { ContainerBuilder builder = new(); - builder.Register(Lifetime.Singleton).As(); + builder.RegisterDxMessagingBus(); IObjectResolver resolver = TrackDisposable(builder.Build()); IMessageBus bus = resolver.Resolve(); @@ -29,7 +31,7 @@ public void ContainerInjectsMessagingComponentWithCustomBus() GameObject go = Track( new GameObject( - nameof(ContainerInjectsMessagingComponentWithCustomBus), + nameof(ContainerInjectsMessagingComponentWithRegisteredBus), typeof(MessagingComponent), typeof(VContainerConfiguredListener) ) @@ -50,10 +52,241 @@ public void ContainerInjectsMessagingComponentWithCustomBus() } [Test] - public void RegistrationExtensionsExposeBuilder() + public void ContainerInjectsMessagingComponentWithFactoryRegisteredBus() + { + ContainerBuilder builder = new(); + builder.RegisterDxMessagingBus(_ => new MessageBus()); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus bus = resolver.Resolve(); + Assert.NotNull(bus); + + GameObject go = Track( + new GameObject( + nameof(ContainerInjectsMessagingComponentWithFactoryRegisteredBus), + typeof(MessagingComponent), + typeof(VContainerConfiguredListener) + ) + ); + + resolver.InjectGameObject(go); + VContainerConfiguredListener listener = go.GetComponent(); + listener.Initialize(bus); + + SimpleUntargetedMessage message = new(); + message.EmitUntargeted(bus); + + Assert.AreEqual( + 1, + listener.ReceivedCount, + "Listener should receive messages emitted via the factory-registered bus." + ); + } + + [Test] + public void ContainerInjectsMessagingComponentWithInstanceRegisteredBus() + { + MessageBus instance = new MessageBus(); + ContainerBuilder builder = new(); + builder.RegisterInstance(instance).As(); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus bus = resolver.Resolve(); + Assert.AreSame(instance, bus, "Resolved bus should match registered instance."); + + GameObject go = Track( + new GameObject( + nameof(ContainerInjectsMessagingComponentWithInstanceRegisteredBus), + typeof(MessagingComponent), + typeof(VContainerConfiguredListener) + ) + ); + + resolver.InjectGameObject(go); + VContainerConfiguredListener listener = go.GetComponent(); + listener.Initialize(bus); + + SimpleUntargetedMessage message = new(); + message.EmitUntargeted(bus); + + Assert.AreEqual( + 1, + listener.ReceivedCount, + "Listener should receive messages emitted via the instance-registered bus." + ); + } + + [Test] + public void RegisterDxMessagingBusReturnsSingletonByDefault() + { + ContainerBuilder builder = new(); + builder.RegisterDxMessagingBus(); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus first = resolver.Resolve(); + IMessageBus second = resolver.Resolve(); + + Assert.AreSame( + first, + second, + "Repeated resolutions of IMessageBus should return the same singleton instance." + ); + } + + [Test] + public void RegisterDxMessagingBusHonoursTransientLifetime() + { + ContainerBuilder builder = new(); + builder.RegisterDxMessagingBus(Lifetime.Transient); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus first = resolver.Resolve(); + IMessageBus second = resolver.Resolve(); + + Assert.AreNotSame( + first, + second, + "Transient lifetime should produce a new bus per resolution." + ); + } + + [Test] + public void RegisterDxMessagingBusHonoursScopedLifetime() + { + ContainerBuilder builder = new(); + builder.RegisterDxMessagingBus(Lifetime.Scoped); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus first = resolver.Resolve(); + IMessageBus second = resolver.Resolve(); + + Assert.AreSame( + first, + second, + "Scoped lifetime should return the same bus within a single scope." + ); + } + + [TestCaseSource(nameof(RegisterDxMessagingBusContractCases))] + public void RegisterDxMessagingBusExposesSameInstanceAcrossContracts( + BusRegistrationCase registrationCase + ) + { + ContainerBuilder builder = new(); + registrationCase.Register(builder); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + MessageBus concrete = ResolveWithDiagnostic( + resolver, + registrationCase.DisplayName + ); + IMessageBus iFace = ResolveWithDiagnostic( + resolver, + registrationCase.DisplayName + ); + + Assert.AreSame( + concrete, + iFace, + $"{registrationCase.DisplayName}: resolving MessageBus and IMessageBus should yield the same singleton." + ); + registrationCase.Verify(concrete, iFace); + } + + [TestCaseSource(nameof(RegisterDxMessagingBusContractCases))] + public void RegisterDxMessagingBusExposesSameInstanceWhenInterfaceResolvesFirst( + BusRegistrationCase registrationCase + ) + { + ContainerBuilder builder = new(); + registrationCase.Register(builder); + + IObjectResolver resolver = TrackDisposable(builder.Build()); + IMessageBus iFace = ResolveWithDiagnostic( + resolver, + registrationCase.DisplayName + ); + MessageBus concrete = ResolveWithDiagnostic( + resolver, + registrationCase.DisplayName + ); + + Assert.AreSame( + iFace, + concrete, + $"{registrationCase.DisplayName}: resolving IMessageBus before MessageBus should still yield the same singleton." + ); + registrationCase.Verify(concrete, iFace); + } + + [Test] + public void BareRegisterMessageBusFailsBecauseClockIsUnregistered() { + // Pins the documented failure mode that motivates RegisterDxMessagingBus. VContainer's + // TypeAnalyzer scans both public and non-public constructors via + // BindingFlags.Public | BindingFlags.NonPublic, then prefers the constructor with the + // most parameters when no [Inject] is present. Even after the production change that + // demoted the IDxMessagingClock-taking ctor to private, the analyzer still latches onto + // it; the dependency is not registered with the container, so resolution throws. + // VContainer surfaces the failure either at Build time (graph validation) or at the + // first Resolve call depending on the version, so the test wraps the entire flow. ContainerBuilder builder = new(); builder.Register(Lifetime.Singleton).As(); + + Assert.Throws( + () => + { + IObjectResolver resolver = TrackDisposable(builder.Build()); + _ = resolver.Resolve(); + }, + "Bare MessageBus registration must fail because VContainer reflects onto a clock-taking ctor whose IDxMessagingClock is not registered." + ); + } + + [Test] + public void RegisterDxMessagingBusRejectsNullArguments() + { + IContainerBuilder nullBuilder = null; + + AssertArgumentNull( + () => nullBuilder.RegisterDxMessagingBus(), + "builder", + "Default overload should reject a null VContainer builder with the correct argument name." + ); + AssertArgumentNull( + () => nullBuilder.RegisterDxMessagingBus(_ => new MessageBus()), + "builder", + "Factory overload should reject a null VContainer builder with the correct argument name." + ); + AssertArgumentNull( + () => nullBuilder.RegisterDxMessagingBus(new FakeClock()), + "builder", + "Clock overload should reject a null VContainer builder with the correct argument name." + ); + AssertArgumentNull( + () => nullBuilder.RegisterMessageRegistrationBuilder(), + "builder", + "Builder-registration helper should reject a null VContainer builder with the correct argument name." + ); + + ContainerBuilder builder = new(); + AssertArgumentNull( + () => builder.RegisterDxMessagingBus((Func)null), + "factory", + "Factory overload should reject a null bus factory." + ); + AssertArgumentNull( + () => builder.RegisterDxMessagingBus((IDxMessagingClock)null), + "clock", + "Clock overload should reject a null clock." + ); + } + + [Test] + public void RegistrationExtensionsExposeBuilder() + { + ContainerBuilder builder = new(); + builder.RegisterDxMessagingBus(); builder.RegisterMessageRegistrationBuilder(); IObjectResolver resolver = TrackDisposable(builder.Build()); @@ -74,7 +307,7 @@ public void RegistrationExtensionsExposeBuilder() public void RegistrationExtensionsPreferResolvedProvider() { ContainerBuilder builder = new(); - builder.Register(Lifetime.Singleton).As(); + builder.RegisterDxMessagingBus(); MessageBus providerBus = new MessageBus(); builder @@ -136,6 +369,127 @@ public IMessageBus Resolve() return _bus; } } + + private static IEnumerable RegisterDxMessagingBusContractCases() + { + yield return new TestCaseData( + new BusRegistrationCase( + "default overload", + builder => builder.RegisterDxMessagingBus(), + (_, _) => { } + ) + ); + + FakeClock clock = new FakeClock(initialSeconds: 42d); + yield return new TestCaseData( + new BusRegistrationCase( + "clock overload", + builder => builder.RegisterDxMessagingBus(clock), + (_, _) => + { + Assert.AreEqual( + 42d, + clock.NowSeconds, + "Clock overload should construct the bus through CreateForInternalUse without advancing the clock." + ); + Assert.GreaterOrEqual( + clock.ReadCount, + 2, + "Resolving the bus should read the injected clock during MessageBus construction, and the assertion reads it once more for diagnostics." + ); + } + ) + ); + + MessageBus expected = null; + int factoryCalls = 0; + bool resolverWasProvided = false; + yield return new TestCaseData( + new BusRegistrationCase( + "factory overload", + builder => + { + expected = new MessageBus(); + factoryCalls = 0; + resolverWasProvided = false; + builder.RegisterDxMessagingBus(resolver => + { + ++factoryCalls; + resolverWasProvided = resolver != null; + return expected; + }); + }, + (concrete, _) => + { + Assert.AreSame( + expected, + concrete, + "Factory overload should construct the bus exactly as the caller supplied." + ); + Assert.AreEqual( + 1, + factoryCalls, + "Singleton factory should run once even when both MessageBus and IMessageBus are resolved." + ); + Assert.IsTrue( + resolverWasProvided, + "Factory overload should pass the active VContainer resolver to the caller." + ); + } + ) + ); + } + + private static T ResolveWithDiagnostic( + IObjectResolver resolver, + string registrationDisplayName + ) + { + try + { + return resolver.Resolve(); + } + catch (Exception exception) + { + Assert.Fail( + $"{registrationDisplayName}: RegisterDxMessagingBus should expose {typeof(T).FullName}, but VContainer threw {exception.GetType().FullName}: {exception.Message}" + ); + throw; + } + } + + private static void AssertArgumentNull( + TestDelegate action, + string expectedParameterName, + string message + ) + { + ArgumentNullException exception = Assert.Throws(action, message); + Assert.AreEqual(expectedParameterName, exception.ParamName, message); + } + + public sealed class BusRegistrationCase + { + public BusRegistrationCase( + string displayName, + Action register, + Action verify + ) + { + DisplayName = displayName; + Register = register; + Verify = verify; + } + + public string DisplayName { get; } + public Action Register { get; } + public Action Verify { get; } + + public override string ToString() + { + return DisplayName; + } + } } #endif } diff --git a/Tests/Runtime/Integrations/Zenject/ZenjectIntegrationTests.cs b/Tests/Runtime/Integrations/Zenject/ZenjectIntegrationTests.cs index 95a4dd99..115a45c8 100644 --- a/Tests/Runtime/Integrations/Zenject/ZenjectIntegrationTests.cs +++ b/Tests/Runtime/Integrations/Zenject/ZenjectIntegrationTests.cs @@ -95,6 +95,169 @@ public void RegistrationInstallerPrefersBoundProvider() ); } + [Test] + public void BindDxMessagingBusExposesBothContracts() + { + DiContainer container = new(); + container.BindDxMessagingBus(); + + MessageBus concrete = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreSame( + concrete, + iFace, + "Resolving MessageBus and IMessageBus through the helper should yield the same singleton." + ); + } + + [Test] + public void BindDxMessagingBusExposesBothContractsWhenInterfaceResolvesFirst() + { + DiContainer container = new(); + container.BindDxMessagingBus(); + + IMessageBus iFace = container.Resolve(); + MessageBus concrete = container.Resolve(); + + Assert.AreSame( + iFace, + concrete, + "Resolving IMessageBus before MessageBus through the helper should yield the same singleton." + ); + } + + [Test] + public void BindDxMessagingBusWithFactoryUsesProvidedInstance() + { + MessageBus expected = new MessageBus(); + int factoryCalls = 0; + bool contextWasProvided = false; + DiContainer container = new(); + container.BindDxMessagingBus(context => + { + ++factoryCalls; + contextWasProvided = context != null; + return expected; + }); + + MessageBus bus = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreSame( + expected, + bus, + "Factory overload should construct the bus exactly as the caller supplied." + ); + Assert.AreSame( + bus, + iFace, + "Factory overload should expose one singleton through MessageBus and IMessageBus." + ); + Assert.AreEqual( + 1, + factoryCalls, + "Singleton factory should run once even when both MessageBus and IMessageBus are resolved." + ); + Assert.IsTrue( + contextWasProvided, + "Factory overload should pass the active Zenject inject context to the caller." + ); + } + + [Test] + public void BindDxMessagingBusWithFactoryUsesProvidedInstanceWhenInterfaceResolvesFirst() + { + MessageBus expected = new MessageBus(); + int factoryCalls = 0; + bool contextWasProvided = false; + DiContainer container = new(); + container.BindDxMessagingBus(context => + { + ++factoryCalls; + contextWasProvided = context != null; + return expected; + }); + + IMessageBus iFace = container.Resolve(); + MessageBus bus = container.Resolve(); + + Assert.AreSame( + expected, + iFace, + "Factory overload should return the caller-supplied bus when IMessageBus resolves first." + ); + Assert.AreSame( + iFace, + bus, + "Factory overload should expose one singleton through IMessageBus and MessageBus." + ); + Assert.AreEqual( + 1, + factoryCalls, + "Singleton factory should run once even when IMessageBus resolves before MessageBus." + ); + Assert.IsTrue( + contextWasProvided, + "Factory overload should pass the active Zenject inject context to the caller." + ); + } + + [Test] + public void BindDxMessagingBusWithClockUsesInjectedClock() + { + FakeClock clock = new FakeClock(initialSeconds: 5d); + DiContainer container = new(); + container.BindDxMessagingBus(clock); + + MessageBus bus = container.Resolve(); + IMessageBus iFace = container.Resolve(); + + Assert.AreEqual( + 5d, + clock.NowSeconds, + "Helper should construct the bus through CreateForInternalUse without advancing the clock." + ); + Assert.GreaterOrEqual( + clock.ReadCount, + 2, + "Resolving the bus should read the injected clock during MessageBus construction, and the assertion reads it once more for diagnostics." + ); + Assert.AreSame( + bus, + iFace, + "Clock overload should expose the same bus through MessageBus and IMessageBus." + ); + Assert.NotNull(bus); + } + + [Test] + public void BindDxMessagingBusWithClockUsesInjectedClockWhenInterfaceResolvesFirst() + { + FakeClock clock = new FakeClock(initialSeconds: 5d); + DiContainer container = new(); + container.BindDxMessagingBus(clock); + + IMessageBus iFace = container.Resolve(); + MessageBus bus = container.Resolve(); + + Assert.AreSame( + iFace, + bus, + "Clock overload should expose the same bus through IMessageBus and MessageBus." + ); + Assert.AreEqual( + 5d, + clock.NowSeconds, + "Clock overload should construct the bus through CreateForInternalUse without advancing the clock." + ); + Assert.GreaterOrEqual( + clock.ReadCount, + 2, + "Resolving the bus should read the injected clock during MessageBus construction, and the assertion reads it once more for diagnostics." + ); + } + private sealed class ZenjectConfiguredListener : MonoBehaviour { private IMessageBus _messageBus; diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs index c87b6335..067bc4be 100644 --- a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs @@ -27,7 +27,7 @@ public void TrimEvictsEmptyTypeSlots( MessageScenario scenario ) { - MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); using LeakWatcher watcher = LeakWatcher.WatchWithSlots( bus, label: scenario.DisplayName @@ -76,7 +76,7 @@ public void TrimEvictsEmptyTargetSlots( MessageScenario scenario ) { - MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); using LeakWatcher watcher = LeakWatcher.WatchWithSlots( bus, label: scenario.DisplayName @@ -122,7 +122,7 @@ MessageScenario scenario ) { FakeClock clock = new FakeClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 1d, @@ -170,7 +170,7 @@ MessageScenario scenario ) { FakeClock clock = new FakeClock(); - MessageBus bus = new MessageBus( + MessageBus bus = MessageBus.CreateForInternalUse( clock, idleEvictionTicks: 0, evictionTickIntervalSeconds: 0d, @@ -218,7 +218,7 @@ public void TrimDoesNotDisturbActiveDispatch( MessageScenario scenario ) { - MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); using LeakWatcher watcher = LeakWatcher.WatchWithSlots( bus, label: scenario.DisplayName @@ -276,7 +276,7 @@ public void RuntimeSettingsHotReloadAppliesCaps() settings._bufferMaxDistinctEntries = 4; settings._bufferUseLruEviction = true; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); - MessageBus bus = new MessageBus(new FakeClock()); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock()); List pooled = DxPools.ObjectLists.Rent(); DxPools.ObjectLists.Return(pooled); @@ -313,7 +313,7 @@ public void RuntimeSettingsHotReloadUpdatesTrimAndIdleGates() settings._evictionTickIntervalSeconds = 60f; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); FakeClock clock = new FakeClock(); - MessageBus bus = new MessageBus(clock); + MessageBus bus = MessageBus.CreateForInternalUse(clock); using IDisposable cleanup = ForceTrimCleanup(bus); MessageRegistrationToken token = CreateEnabledToken(bus); MessageRegistrationHandle handle = RegisterFirst( @@ -367,7 +367,7 @@ public void TrimRespectsEnableTrimApiFlag() settings._evictionEnabled = true; settings._bufferMaxDistinctEntries = 4; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); - MessageBus bus = new MessageBus(new FakeClock()); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock()); MessageRegistrationToken token = CreateEnabledToken(bus); MessageRegistrationHandle handle = RegisterFirst( MessageScenario.Untargeted(), @@ -411,7 +411,10 @@ MessageScenario scenario settings._bufferUseLruEviction = true; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); _ = DxPools.TrimAll(force: true); - MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse( + new FakeClock(), + idleEvictionTicks: 0 + ); using LeakWatcher watcher = LeakWatcher.WatchWithSlots( bus, label: scenario.DisplayName @@ -463,7 +466,7 @@ public void ResetGenerationBumpInvalidatesPostEvictDeregister( MessageScenario scenario ) { - MessageBus bus = new MessageBus(new FakeClock(), idleEvictionTicks: 0); + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); using IDisposable cleanup = ForceTrimCleanup(bus); MessageHandler handler = CreateActiveHandler(bus); int currentCalls = 0; diff --git a/Tests/Runtime/Scripts/Components/SimpleMessageAwareComponent.cs b/Tests/Runtime/Scripts/Components/SimpleMessageAwareComponent.cs index aff5d0be..efbdacbe 100644 --- a/Tests/Runtime/Scripts/Components/SimpleMessageAwareComponent.cs +++ b/Tests/Runtime/Scripts/Components/SimpleMessageAwareComponent.cs @@ -46,6 +46,8 @@ public bool FastComplexTargetingEnabled private MessageRegistrationHandle? _slowComplexTargetingHandle; private MessageRegistrationHandle? _fastComplexTargetingHandle; + protected override bool RegisterForStringMessages => false; + protected override void RegisterMessageHandlers() { _ = _messageRegistrationToken.RegisterUntargeted( diff --git a/Tests/Runtime/TestUtilities/FakeClock.cs b/Tests/Runtime/TestUtilities/FakeClock.cs index 8ad8c88b..30c23026 100644 --- a/Tests/Runtime/TestUtilities/FakeClock.cs +++ b/Tests/Runtime/TestUtilities/FakeClock.cs @@ -18,8 +18,20 @@ public FakeClock(double initialSeconds = 0d) _now = initialSeconds; } + /// + /// Number of times has been read. + /// + public int ReadCount { get; private set; } + /// - public double NowSeconds => _now; + public double NowSeconds + { + get + { + ++ReadCount; + return _now; + } + } /// Advance the clock by the given number of seconds. public void Advance(double seconds) diff --git a/Tests/Runtime/TestUtilities/MessageKind.cs b/Tests/Runtime/TestUtilities/MessageKind.cs index 03d6f14d..cb202e16 100644 --- a/Tests/Runtime/TestUtilities/MessageKind.cs +++ b/Tests/Runtime/TestUtilities/MessageKind.cs @@ -2,14 +2,27 @@ namespace DxMessaging.Tests.Runtime { /// - /// Identifies one of the three DxMessaging dispatch categories. Used by the + /// Identifies one of the DxMessaging dispatch categories. Used by the /// parameterized test harness so a single test method can cover all kinds. + /// + /// , , and + /// are the three canonical dispatch kinds and are + /// the only values that emits. + /// and + /// describe the same wire-level message types as + /// and but exercise the dispatch-time codepaths + /// that intentionally drop the per-target / per-source binding. Tests that + /// need to cover the without-context dispatch dimensions use the extended + /// source. + /// /// public enum MessageKind { Untargeted, Targeted, Broadcast, + TargetedWithoutTargeting, + BroadcastWithoutSource, } } #endif diff --git a/Tests/Runtime/TestUtilities/MessageScenario.cs b/Tests/Runtime/TestUtilities/MessageScenario.cs index baa13011..ae1746f0 100644 --- a/Tests/Runtime/TestUtilities/MessageScenario.cs +++ b/Tests/Runtime/TestUtilities/MessageScenario.cs @@ -57,6 +57,16 @@ public static MessageScenario Broadcast() return new MessageScenario(MessageKind.Broadcast); } + public static MessageScenario TargetedWithoutTargeting() + { + return new MessageScenario(MessageKind.TargetedWithoutTargeting); + } + + public static MessageScenario BroadcastWithoutSource() + { + return new MessageScenario(MessageKind.BroadcastWithoutSource); + } + public MessageScenario WithInterceptor(bool useInterceptor) { return new MessageScenario( diff --git a/Tests/Runtime/TestUtilities/MessageScenarios.cs b/Tests/Runtime/TestUtilities/MessageScenarios.cs index 7878c8cf..24969d52 100644 --- a/Tests/Runtime/TestUtilities/MessageScenarios.cs +++ b/Tests/Runtime/TestUtilities/MessageScenarios.cs @@ -32,6 +32,26 @@ public static IEnumerable AllKinds } } + /// + /// Five-kind source covering the three canonical dispatch kinds plus + /// the targeted-without-targeting and broadcast-without-source + /// dispatch surfaces. Use this source for tests that need to assert + /// behavior across the without-context dispatch codepaths in addition + /// to the canonical three. Tests consuming this source must handle + /// every value of . + /// + public static IEnumerable AllKindsIncludingWithoutContext + { + get + { + yield return MessageScenario.Untargeted(); + yield return MessageScenario.Targeted(); + yield return MessageScenario.Broadcast(); + yield return MessageScenario.TargetedWithoutTargeting(); + yield return MessageScenario.BroadcastWithoutSource(); + } + } + public static IEnumerable KindsWithComponentTarget { get @@ -65,6 +85,18 @@ public static IEnumerable WithAndWithoutPostProcessor } } + public static IEnumerable WithAndWithoutPostProcessorIncludingWithoutContext + { + get + { + foreach (MessageScenario scenario in AllKindsIncludingWithoutContext) + { + yield return scenario.WithPostProcessor(false); + yield return scenario.WithPostProcessor(true); + } + } + } + public static IEnumerable WithDiagnosticsToggle { get @@ -77,6 +109,18 @@ public static IEnumerable WithDiagnosticsToggle } } + public static IEnumerable WithDiagnosticsToggleIncludingWithoutContext + { + get + { + foreach (MessageScenario scenario in AllKindsIncludingWithoutContext) + { + yield return scenario.WithDiagnostics(false); + yield return scenario.WithDiagnostics(true); + } + } + } + /// /// Cross-product of interceptor x post-processor presence per kind, /// minus the (false, false) baseline. That row was removed for two diff --git a/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs b/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs new file mode 100644 index 00000000..87516644 --- /dev/null +++ b/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs @@ -0,0 +1,261 @@ +#if UNITY_2021_3_OR_NEWER +// CallerArgumentExpressionAttribute polyfill for build environments whose +// BCL predates .NET 6 (Unity 2021.3 ships a Mono runtime without this +// attribute). Roslyn recognizes the attribute by full name regardless of +// origin, so a hand-rolled type in System.Runtime.CompilerServices is +// sufficient. The polyfill is wrapped in a #if so newer runtimes that ship +// the attribute do not see a duplicate definition. +#if !NET6_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + internal sealed class CallerArgumentExpressionAttribute : Attribute + { + public CallerArgumentExpressionAttribute(string parameterName) + { + ParameterName = parameterName; + } + + public string ParameterName { get; } + } +} +#endif + +namespace DxMessaging.Tests.Runtime +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Runtime.CompilerServices; + using System.Text; + using DxMessaging.Core.MessageBus; + using NUnit.Framework; + + /// + /// Asserts the public per-kind registration counters on an + /// in a single call, surfacing the diverging + /// buckets on failure so a stale assertion can be diagnosed in seconds + /// rather than minutes. The helper pairs every check with a diagnostic + /// message that includes: + /// + /// a per-bucket "expected vs actual (delta)" line for every counter + /// that diverged, listed first so the actionable diff is unmissable; + /// the call site (file path + line number) supplied by + /// [CallerFilePath] / [CallerLineNumber]; + /// the full expected vs actual triple on a tail line for context; + /// the bus expression captured by + /// [CallerArgumentExpression] so failures involving multiple buses + /// in one test can be attributed to the specific bus that failed; + /// any caller-supplied label so a single + /// fixture can call this multiple times without ambiguity. + /// + /// + /// + /// + /// Bucketing reminder: + /// counts only the pure untargeted scalar sink. Registrations created via + /// RegisterTargetedWithoutTargeting bucket under + /// , and + /// RegisterBroadcastWithoutSource registrations bucket under + /// . The helper does not + /// special-case these; it simply reads the public counters and reports + /// drift against the supplied expected values. Callers can suppress the + /// reminder via when the + /// expected failure mode has nothing to do with bucketing. + /// + /// + /// This helper is intentionally bus-agnostic. Pass any + /// instance; tests that operate on the global + /// bus typically pass MessageHandler.MessageBus. + /// + /// + public static class RegistrationCountAssertions + { + /// + /// Asserts that the bus reports exactly the supplied per-kind counts. + /// On any mismatch, the failure message lists the diverging buckets + /// first (with explicit deltas), then the call site, then the full + /// triple plus the caller-supplied context label and bus expression. + /// + /// Bus to inspect. Must not be null. + /// Expected + /// . + /// Expected + /// . + /// Expected + /// . + /// + /// Required label embedded in the failure message describing the + /// lifecycle moment being asserted (for example + /// "after second component disabled"). Tests typically call this + /// helper across several lifecycle transitions, and a missing label + /// makes the resulting failure much harder to triage; making the + /// parameter mandatory prevents future drift where call sites lose + /// their lifecycle context. + /// + /// + /// When true (default) the failure message ends with the + /// TargetedWithoutTargeting / BroadcastWithoutSource bucketing + /// reminder. Pass in fixtures whose expected + /// failure mode has nothing to do with bucketing so the message + /// stays focused on the actual delta. + /// + /// Auto-supplied by the compiler from the + /// expression passed for . Surfaces in the + /// failure message so tests that operate on multiple buses can + /// identify which one diverged. + /// Auto-supplied by the compiler. + /// Auto-supplied by the compiler. + /// Auto-supplied by the compiler. + public static void AssertRegistrationCounts( + IMessageBus bus, + int untargeted, + int targeted, + int broadcast, + string context, + bool includeBucketingReminder = true, + [CallerArgumentExpression("bus")] string busExpression = null, + [CallerFilePath] string callerFilePath = null, + [CallerLineNumber] int callerLineNumber = 0, + [CallerMemberName] string callerMemberName = null + ) + { + if (bus == null) + { + throw new ArgumentNullException(nameof(bus)); + } + + int actualUntargeted = bus.RegisteredUntargeted; + int actualTargeted = bus.RegisteredTargeted; + int actualBroadcast = bus.RegisteredBroadcast; + + if ( + actualUntargeted == untargeted + && actualTargeted == targeted + && actualBroadcast == broadcast + ) + { + return; + } + + string message = BuildFailureMessage( + expectedUntargeted: untargeted, + expectedTargeted: targeted, + expectedBroadcast: broadcast, + actualUntargeted: actualUntargeted, + actualTargeted: actualTargeted, + actualBroadcast: actualBroadcast, + context: context, + includeBucketingReminder: includeBucketingReminder, + busExpression: busExpression, + callerFilePath: callerFilePath, + callerLineNumber: callerLineNumber, + callerMemberName: callerMemberName + ); + Assert.Fail(message); + } + + private static string BuildFailureMessage( + int expectedUntargeted, + int expectedTargeted, + int expectedBroadcast, + int actualUntargeted, + int actualTargeted, + int actualBroadcast, + string context, + bool includeBucketingReminder, + string busExpression, + string callerFilePath, + int callerLineNumber, + string callerMemberName + ) + { + string contextSuffix = string.IsNullOrEmpty(context) ? string.Empty : $" [{context}]"; + string busLabel = string.IsNullOrEmpty(busExpression) ? "" : busExpression; + string location = string.Format( + CultureInfo.InvariantCulture, + "{0}:{1} ({2})", + callerFilePath ?? "", + callerLineNumber, + callerMemberName ?? "" + ); + string expected = string.Format( + CultureInfo.InvariantCulture, + "Untargeted={0}, Targeted={1}, Broadcast={2}", + expectedUntargeted, + expectedTargeted, + expectedBroadcast + ); + string actual = string.Format( + CultureInfo.InvariantCulture, + "Untargeted={0}, Targeted={1}, Broadcast={2}", + actualUntargeted, + actualTargeted, + actualBroadcast + ); + + List mismatches = new(3); + AppendMismatch(mismatches, "Untargeted", expectedUntargeted, actualUntargeted); + AppendMismatch(mismatches, "Targeted", expectedTargeted, actualTargeted); + AppendMismatch(mismatches, "Broadcast", expectedBroadcast, actualBroadcast); + + StringBuilder builder = new(); + builder.AppendFormat( + CultureInfo.InvariantCulture, + "Registration counts mismatch on bus '{0}'{1}.", + busLabel, + contextSuffix + ); + foreach (string line in mismatches) + { + builder.Append(' '); + builder.Append(line); + } + builder.AppendFormat( + CultureInfo.InvariantCulture, + " At {0}. Full triple: expected ({1}); actual ({2}).", + location, + expected, + actual + ); + if (includeBucketingReminder) + { + builder.Append( + " Bucketing reminder: TargetedWithoutTargeting registrations land " + + "under RegisteredTargeted; BroadcastWithoutSource registrations " + + "land under RegisteredBroadcast." + ); + } + + return builder.ToString(); + } + + private static void AppendMismatch( + List mismatches, + string bucket, + int expected, + int actual + ) + { + if (expected == actual) + { + return; + } + + int delta = actual - expected; + string sign = delta >= 0 ? "+" : string.Empty; + mismatches.Add( + string.Format( + CultureInfo.InvariantCulture, + "{0}: expected {1}, actual {2} (delta {3}{4}).", + bucket, + expected, + actual, + sign, + delta + ) + ); + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs.meta b/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs.meta new file mode 100644 index 00000000..c21e000a --- /dev/null +++ b/Tests/Runtime/TestUtilities/RegistrationCountAssertions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 22664213fcc8428da35464affe945e92 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 4a03eeca..05ec2882 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 19,199,890 | No | -| UniRx MessageBroker | 17,347,999 | No | -| MessagePipe (Global) | 97,265,117 | No | -| Zenject SignalBus | 2,347,720 | Yes | +| DxMessaging (Untargeted) - No-Copy | 13,341,719 | No | +| UniRx MessageBroker | 18,003,801 | No | +| MessagePipe (Global) | 97,678,389 | No | +| Zenject SignalBus | 2,210,499 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index cc36979b..f5b45b2b 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -32,17 +32,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,432,732 | Yes | -| DxMessaging (GameObject) - Normal | 9,714,000 | No | -| DxMessaging (Component) - Normal | 9,990,055 | No | -| DxMessaging (GameObject) - No-Copy | 11,310,039 | No | -| DxMessaging (Component) - No-Copy | 8,592,726 | No | -| DxMessaging (Untargeted) - No-Copy | 18,977,437 | No | -| DxMessaging (Untargeted) - Interceptors | 7,651,342 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,490,342 | No | -| Reflexive (One Argument) | 2,811,971 | No | -| Reflexive (Two Arguments) | 2,372,448 | No | -| Reflexive (Three Arguments) | 2,345,943 | No | +| Unity | 2,475,429 | Yes | +| DxMessaging (GameObject) - Normal | 7,740,844 | No | +| DxMessaging (Component) - Normal | 7,843,782 | No | +| DxMessaging (GameObject) - No-Copy | 8,814,737 | No | +| DxMessaging (Component) - No-Copy | 7,295,605 | No | +| DxMessaging (Untargeted) - No-Copy | 13,221,685 | No | +| DxMessaging (Untargeted) - Interceptors | 6,416,263 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,197,189 | No | +| Reflexive (One Argument) | 2,595,323 | No | +| Reflexive (Two Arguments) | 2,119,428 | No | +| Reflexive (Three Arguments) | 2,132,994 | No | ## macOS diff --git a/docs/integrations/vcontainer.md b/docs/integrations/vcontainer.md index 7734d5f2..a9603b12 100644 --- a/docs/integrations/vcontainer.md +++ b/docs/integrations/vcontainer.md @@ -28,7 +28,7 @@ ```csharp using DxMessaging.Core.MessageBus; -using DxMessaging.Unity.Integrations.VContainer; // Required for RegisterMessageRegistrationBuilder() +using DxMessaging.Unity.Integrations.VContainer; // Required for RegisterDxMessagingBus() using UnityEngine; using VContainer; using VContainer.Unity; @@ -37,8 +37,8 @@ public sealed class MessagingLifetimeScope : LifetimeScope { protected override void Configure(IContainerBuilder builder) { - // Register MessageBus as both concrete and interface - builder.Register(Lifetime.Singleton).As(); + // Register MessageBus as both concrete and interface through the safe helper. + builder.RegisterDxMessagingBus(); // Optional: Enable automatic IMessageRegistrationBuilder binding // Requires VCONTAINER_PRESENT define (auto-added by DxMessaging when VContainer detected) @@ -49,7 +49,7 @@ public sealed class MessagingLifetimeScope : LifetimeScope } ``` -**Note:** You must import the `DxMessaging.Unity.Integrations.VContainer` namespace to access the `RegisterMessageRegistrationBuilder()` extension method. +**Note:** You must import the `DxMessaging.Unity.Integrations.VContainer` namespace to access the registration extension methods. #### Add to your scene @@ -125,7 +125,7 @@ using DxMessaging.Unity.Integrations.VContainer; // Required for extension metho protected override void Configure(IContainerBuilder builder) { - builder.Register(Lifetime.Singleton).As(); + builder.RegisterDxMessagingBus(); #if VCONTAINER_PRESENT builder.RegisterMessageRegistrationBuilder(); #endif @@ -222,7 +222,7 @@ public sealed class LevelLoader // Create a child scope with its own MessageBus return _parentScope.CreateChildFromPrefab(lifetimeScopePrefab, builder => { - builder.Register(Lifetime.Singleton).As(); + builder.RegisterDxMessagingBus(); #if VCONTAINER_PRESENT builder.RegisterMessageRegistrationBuilder(); #endif @@ -288,7 +288,7 @@ public IEnumerator PlayMode_MessageBusIsolation() // Create isolated scope for this test var scope = LifetimeScope.Create(builder => { - builder.Register(Lifetime.Singleton).As(); + builder.RegisterDxMessagingBus(); }); var bus = scope.Container.Resolve(); @@ -306,7 +306,7 @@ public IEnumerator PlayMode_MessageBusIsolation() ### Initial Setup - [ ] Install DxMessaging and VContainer -- [ ] Create `MessagingLifetimeScope` with `builder.Register().As()` +- [ ] Create `MessagingLifetimeScope` with `builder.RegisterDxMessagingBus()` - [ ] Add scope to your scene as a GameObject component - [ ] Import `DxMessaging.Unity.Integrations.VContainer` namespace for extension methods - [ ] Add `#if VCONTAINER_PRESENT` check and call `builder.RegisterMessageRegistrationBuilder()` diff --git a/llms.txt b/llms.txt index 857c19f5..4f3c7056 100644 --- a/llms.txt +++ b/llms.txt @@ -287,5 +287,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-04 +**Last Updated:** 2026-05-05 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata From 30a9f2b7ea87755d1d1aa96157063758013293f4 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Mon, 4 May 2026 21:37:08 -0700 Subject: [PATCH 03/16] Perf updates --- .github/pull_request_template.md | 26 + .github/workflows/perf-numbers-check.yml | 175 ++++ .llm/skills/index.md | 8 +- .llm/skills/performance/dispatch-hot-path.md | 212 +++++ .../performance/sweep-gate-must-be-cheap.md | 184 ++++ CHANGELOG.md | 1 + Editor.meta | 8 + .../DxMessagingRuntimeSettings.cs | 2 +- Runtime/Core/MessageBus/MessageBus.cs | 7 + .../Allocations/EmitGateClockReadIsRare.cs | 352 ++++++++ .../EmitGateClockReadIsRare.cs.meta | 11 + Tests/Editor/Benchmarks/AssemblyInfo.cs | 2 +- .../Benchmarks/PerfRegressionSmokeTests.cs | 266 ++++++ .../PerfRegressionSmokeTests.cs.meta | 11 + ...essaging.Tests.00.Editor.Benchmarks.asmdef | 1 + ...saging.Tests.00.Editor.Comparisons.asmdef} | 2 +- ...g.Tests.00.Editor.Comparisons.asmdef.meta} | 0 .../Contract/EvictionSweepContractTests.cs | 72 +- Tests/Runtime/Benchmarks.meta | 8 + .../DispatchThroughputBenchmarks.cs | 790 ++++++++++++++++++ .../DispatchThroughputBenchmarks.cs.meta | 11 + ...ssaging.Tests.00.Runtime.Benchmarks.asmdef | 19 + ...ng.Tests.00.Runtime.Benchmarks.asmdef.meta | 7 + .../MemoryReclaim/MemoryReclamationTests.cs | 22 +- docs/architecture/comparisons.md | 8 +- docs/architecture/performance.md | 196 ++++- llms.txt | 2 +- 27 files changed, 2346 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/perf-numbers-check.yml create mode 100644 .llm/skills/performance/dispatch-hot-path.md create mode 100644 .llm/skills/performance/sweep-gate-must-be-cheap.md create mode 100644 Editor.meta create mode 100644 Tests/Editor/Allocations/EmitGateClockReadIsRare.cs create mode 100644 Tests/Editor/Allocations/EmitGateClockReadIsRare.cs.meta create mode 100644 Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs create mode 100644 Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs.meta rename Tests/Editor/Comparisons/{WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef => WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef} (94%) rename Tests/Editor/Comparisons/{WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta => WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef.meta} (100%) create mode 100644 Tests/Runtime/Benchmarks.meta create mode 100644 Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs create mode 100644 Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs.meta create mode 100644 Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef create mode 100644 Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef.meta diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3c33ac78..5e95d83c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,3 +28,29 @@ Fixes # - [ ] I have updated the documentation accordingly - [ ] I have updated the [CHANGELOG](../CHANGELOG.md) - [ ] My changes do not introduce breaking changes, or breaking changes are documented + +### Performance numbers + + diff --git a/.github/workflows/perf-numbers-check.yml b/.github/workflows/perf-numbers-check.yml new file mode 100644 index 00000000..b69519fb --- /dev/null +++ b/.github/workflows/perf-numbers-check.yml @@ -0,0 +1,175 @@ +name: Performance Numbers Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + paths: + - "Runtime/Core/MessageBus/MessageBus.cs" + - "Runtime/Core/MessageHandler.cs" + - "Runtime/Core/Pooling/**" + - ".github/workflows/perf-numbers-check.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + perf-numbers: + name: Validate performance numbers + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Validate PR performance section + uses: actions/github-script@v9 + with: + script: | + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + core.info("No pull request payload found; skipping."); + return; + } + + const body = pullRequest.body ?? ""; + const heading = /^### Performance numbers\s*$/gim; + const match = heading.exec(body); + if (!match) { + core.setFailed( + "Hot-path PRs must include a ### Performance numbers section." + ); + return; + } + + const rest = body.slice(match.index + match[0].length); + const nextHeading = rest.search(/^#{1,3}\s+/m); + const rawSection = + nextHeading === -1 ? rest : rest.slice(0, nextHeading); + const section = rawSection + .replace(//g, "") + .trim(); + + if (!section) { + core.setFailed( + "The ### Performance numbers section must contain before/after numbers or an accepted N/A line." + ); + return; + } + + const lines = section + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const acceptedRefactor = /^N\/A - refactor only\s+\S.+$/i; + const acceptedNonHotPath = + /^N\/A - non-hot-path edit only\s+\S.+$/i; + + if ( + lines.length === 1 && + (acceptedRefactor.test(lines[0]) || + acceptedNonHotPath.test(lines[0])) + ) { + core.info("Performance numbers section uses an accepted N/A form."); + return; + } + + if (lines.some((line) => /^N\/A\b/i.test(line))) { + core.setFailed( + "N/A performance sections must use one accepted one-line form: " + + "N/A - refactor only or " + + "N/A - non-hot-path edit only ." + ); + return; + } + + const expectedHeaders = [ + "scenario", + "baseline (commit 25a4dcc)", + "this pr", + "delta", + ]; + + function splitMarkdownRow(line) { + if (!line.startsWith("|") || !line.endsWith("|")) { + return null; + } + + return line + .slice(1, -1) + .split("|") + .map((cell) => cell.trim()); + } + + function normalizeHeader(cell) { + return cell.toLowerCase().replace(/\s+/g, " ").trim(); + } + + function isSeparatorRow(cells) { + return ( + cells.length === expectedHeaders.length && + cells.every((cell) => /^:?-{3,}:?$/.test(cell)) + ); + } + + function isPlaceholderCell(cell) { + const normalized = cell + .replace(/`/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + return ( + normalized === "" || + normalized === "-" || + normalized === "n/a" || + normalized === "todo" || + normalized === "tbd" || + normalized.includes("todo") || + normalized.includes("to be measured") || + /(^|\s)[+-]?[xyz](?:\.[xyz]+)?(?:\s|$|%)/i.test(normalized) + ); + } + + const rows = lines.map(splitMarkdownRow); + if (rows.some((row) => row === null)) { + core.setFailed( + "Performance numbers must be either one accepted N/A line or a markdown table." + ); + return; + } + + const header = rows[0].map(normalizeHeader); + const separator = rows.length > 1 ? rows[1] : []; + const hasExpectedHeader = + header.length === expectedHeaders.length && + expectedHeaders.every((expected, index) => header[index] === expected); + + if ( + !hasExpectedHeader || + !isSeparatorRow(separator) || + rows.length < 3 + ) { + core.setFailed( + "Performance numbers tables must use columns: " + + "Scenario | Baseline (commit 25a4dcc) | This PR | Delta, " + + "plus at least one data row." + ); + return; + } + + const dataRows = rows.slice(2).filter( + (row) => + row.length === expectedHeaders.length && + row.every((cell) => !isPlaceholderCell(cell)) + ); + if (dataRows.length === 0) { + core.setFailed( + "Performance numbers tables must contain at least one non-placeholder data row." + ); + return; + } + + core.info("Performance numbers section contains a populated table."); diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 8c8022c3..e61c4c2c 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-05-04. Do not edit manually. +> **Auto-generated** on 2026-05-05. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,7 +9,7 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 146 | +| Total Skills | 148 | | Categories | 8 | --- @@ -19,7 +19,7 @@ - [Documentation](#documentation) (27) - [GitHub Actions](#github-actions) (5) - [Packaging](#packaging) (2) -- [Performance](#performance) (43) +- [Performance](#performance) (45) - [Scripting](#scripting) (15) - [Solid](#solid) (15) - [Testing](#testing) (38) @@ -96,7 +96,9 @@ | [Collection Pooling with RAII Pattern](./performance/collection-pooling.md) | [draft] 119 | [intermediate] | [stable] | [risk: high] | memory, allocation | | [Collection Pooling with RAII Pattern Part 1](./performance/collection-pooling-part-1.md) | [ok] 206 | [intermediate] | [stable] | [risk: low] | migration, split | | [Collection Pooling with RAII Pattern Part 2](./performance/collection-pooling-part-2.md) | [draft] 57 | [intermediate] | [stable] | [risk: low] | migration, split | +| [DxMessaging Dispatch Hot Path](./performance/dispatch-hot-path.md) | [ok] 213 | [advanced] | [stable] | [risk: critical] | dispatch, hot-path | | [DxMessaging Memory Reclamation](./performance/memory-reclamation.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | memory, reclamation | +| [DxMessaging Sweep Gate Must Be Cheap](./performance/sweep-gate-must-be-cheap.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | sweep, eviction | | [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 299 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [Git Hook Performance: Stages and Tooling](./performance/git-hook-performance-tooling.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [High-Performance Cache with Eviction Policies](./performance/cache-eviction-policies.md) | [ok] 177 | [advanced] | [stable] | [risk: high] | caching, memory | diff --git a/.llm/skills/performance/dispatch-hot-path.md b/.llm/skills/performance/dispatch-hot-path.md new file mode 100644 index 00000000..e3866142 --- /dev/null +++ b/.llm/skills/performance/dispatch-hot-path.md @@ -0,0 +1,212 @@ +--- +title: "DxMessaging Dispatch Hot Path" +id: "dispatch-hot-path" +category: "performance" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Runtime/Core/MessageBus/MessageBus.cs" + - path: "Runtime/Core/MessageHandler.cs" + - path: "Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs" + - path: "Tests/Editor/Allocations/EmitGateClockReadIsRare.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "dispatch" + - "hot-path" + - "throughput" + - "messaging" + - "il2cpp" + - "mono" + +complexity: + level: "advanced" + reasoning: "Requires understanding the per-message-type dispatch state machine, dispatch snapshot lifecycle, and platform-specific JIT/AOT codegen behavior." + +impact: + performance: + rating: "critical" + details: "Every message emission walks this path; small per-emit overhead multiplies into measurable throughput regressions." + maintainability: + rating: "high" + details: "Centralized rule set lets reviewers reject hot-path changes that violate the budget." + testability: + rating: "high" + details: "T0 benchmark harness, EmitGateClockReadIsRare, and AllocationMatrix tests pin compliance." + +prerequisites: + - "memory-reclamation" + - "aggressive-inlining" + - "allocation-coverage-required-for-dispatch" + +dependencies: + packages: [] + skills: + - "memory-reclamation" + - "sweep-gate-must-be-cheap" + - "aggressive-inlining" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - ".NET" + versions: + unity: ">=2021.3" + dotnet: ">=netstandard2.1" + +aliases: + - "DxMessaging emission perf" + - "dispatch loop" + - "RunHandlers" + - "AcquireDispatchSnapshot" + +related: + - "memory-reclamation" + - "sweep-gate-must-be-cheap" + - "aggressive-inlining" + - "array-pooling" + +status: "stable" +--- + +# DxMessaging Dispatch Hot Path + +> **One-line summary**: The emission path through `MessageBus` and +> `MessageHandler` carries a strict zero-allocation, near-zero-overhead +> contract; per-emit operations are budgeted in nanoseconds, not "fine". + +## Overview + +Every message a caller emits walks the same critical path: enter the bus, +acquire a dispatch snapshot, walk per-priority buckets, invoke each handler. +On a 1M emits/sec workload, every nanosecond added to the per-emit prologue +costs a measurable percentage of throughput. Adding work that "feels small" +on a single call (a clock read, a virtual through an unsealed type, an extra +field write) compounds into 30-50% regressions when multiplied across the +workload. + +This skill documents the prohibited operations, the established patterns, +and the test gates that enforce them. + +## Hot-path file map + +The dispatch hot path lives across: + +- `Runtime/Core/MessageBus/MessageBus.cs` -- `UntargetedBroadcast`, + `TargetedBroadcast`, `SourcedBroadcast`, `RunHandlers`, `RunPostProcessing`, + `AcquireDispatchSnapshot`, `EnterDispatch`, `TrySweepIdle`. +- `Runtime/Core/MessageHandler.cs` -- `TypedHandler.HandleUntargeted`, + `HandleTargeted`, `HandleBroadcast`, the 10 `*DispatchLink` classes, + `HandlerActionCache` invocation paths. +- `Runtime/Core/Pooling/*.cs` -- anything called from those sites. + +Any PR touching these files MUST include a `### Performance numbers` section +in the PR description per the `pull_request_template.md` checklist; the +`perf-numbers-check.yml` workflow enforces this. + +## Prohibited operations on the dispatch hot path + +The following are forbidden inside `RunHandlers`, `AcquireDispatchSnapshot`, +`Handle*` methods on `TypedHandler`, `*DispatchLink.Invoke`, and +the per-priority handler iteration in `HandlerActionCache`: + +1. **Unconditional clock reads** (`Stopwatch.GetTimestamp`, + `Time.realtimeSinceStartup`, any `IDxMessagingClock.NowSeconds` call). + `Stopwatch.GetTimestamp()` is a vDSO syscall (~15-20ns x64, + ~60-80ns on iOS ARM Mono). The sweep gate samples the clock at most once + per `SweepGateMask + 1` emissions; see `sweep-gate-must-be-cheap`. +1. **Allocations.** No `new`-ing reference types. All transient buffers come + from `DxPools` or pooled snapshot arrays. The `AllocationMatrixTests` + suite catches violations. +1. **Syscalls / P/Invokes.** No file or socket operations. No reading + `Environment.*` properties (most are P/Invokes). +1. **Virtual / interface dispatch through unsealed types.** Unity Mono lacks + guarded devirtualization; sealed types let the JIT inline. Every class on + the dispatch chain must be `sealed` or the method must be non-virtual. +1. **Boxing.** Never let a struct message hit an `object` field. Keep the + `ref TMessage where TMessage : IMessage` shape end-to-end. +1. **`ArrayPool.Shared.Rent` / `Return`.** The shared pool uses + `Interlocked` operations that are very expensive on IL2CPP. Use private + bus-owned pools or `DxPools` instead. + +## Required patterns + +### Per-iteration array access via `MemoryMarshal.GetReference` + +Replace `entries[h]` indexing with the bounds-check-elision pattern: + +```csharp +ref DispatchEntry first = ref MemoryMarshal.GetReference(entries.AsSpan(0, entryCount)); +for (int h = 0; h < entryCount; h++) +{ + ref readonly DispatchEntry e = ref Unsafe.Add(ref first, h); + InvokeUntargetedEntry(ref message, priority, in e); +} +``` + +`MemoryMarshal.GetReference` exists in Unity 2021.3 via the bundled +`System.Memory` package. **Do NOT** use `MemoryMarshal.GetArrayDataReference` +(added in .NET 5; not in Unity 2021.3). + +### Per-iteration `DispatchEntry` is passed by `in`, never by value + +`DispatchEntry` is a multi-reference struct (24+ bytes). Copying per +iteration costs cycles; passing by `in` does not. + +### `[Il2CppSetOption(Option.ArrayBoundsChecks, false)]` + +Only on verified-safe inner loop bodies, gated by `#if ENABLE_IL2CPP`. +**Keep `Option.NullChecks` enabled** -- silent SIGSEGV on a null delegate is +unacceptable. Validate inputs at the public API boundary. + +### Sealed everywhere on the dispatch chain + +Audit `MessageBus`, `MessageHandler.TypedHandler`, every +`*DispatchLink` class, and `HandlerActionCache`. Mono lacks +guarded devirtualization; sealing is load-bearing. + +## Per-emit budget + + + +The current empirical budget on Mono Editor is captured in +`progress/perf-baseline-2026-05-05.csv`. Any PR touching the hot-path file +list above must paste before/after T0 numbers in the PR description. + +## Enforcement + +- `Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs` -- the harness. +- `Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs` -- `[Explicit, +Category("PerfGate")]`; opt-in via `DX_PERF_GATE=1`. Median-of-5; fails + when within-platform regression vs. baseline CSV exceeds 1.5x. +- `.github/workflows/perf-numbers-check.yml` -- `paths:`-triggered workflow + that requires PR body to contain a populated `### Performance numbers` + section when hot-path files are modified. `N/A - refactor only` is an + acceptable populated value with a one-line justification. + +## Common pitfalls + +- "It's just a single field write." Per-emit field writes on the hot path + compound. The `Touch()` field write inside `AcquireDispatchSnapshot` was + ~1-2ns by itself but participated in the GC landing's combined regression. + Measure first. +- "I'll add a virtual call here, the JIT will devirtualize." Mono will not. + IL2CPP has limited devirtualization. Seal the type or pay the cost. +- "I'll use `ArrayPool.Shared`." See above. Use private pools or + `DxPools`. +- "I'll add a clock read just for diagnostics." Diagnostics that read the + clock per emit count toward the budget. Sample-not-call (see + `sweep-gate-must-be-cheap`) or capture once at scope entry. + +## See also + +- [Sweep Gate Must Be Cheap](./sweep-gate-must-be-cheap.md) +- [DxMessaging Memory Reclamation](./memory-reclamation.md) +- [Aggressive Inlining](./aggressive-inlining.md) +- [Allocation Coverage Required for Dispatch](../testing/allocation-coverage-required-for-dispatch.md) diff --git a/.llm/skills/performance/sweep-gate-must-be-cheap.md b/.llm/skills/performance/sweep-gate-must-be-cheap.md new file mode 100644 index 00000000..7386a858 --- /dev/null +++ b/.llm/skills/performance/sweep-gate-must-be-cheap.md @@ -0,0 +1,184 @@ +--- +title: "DxMessaging Sweep Gate Must Be Cheap" +id: "sweep-gate-must-be-cheap" +category: "performance" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Runtime/Core/MessageBus/MessageBus.cs" + - path: "Runtime/Core/Pooling/StopwatchClock.cs" + - path: "Runtime/Core/Pooling/IDxMessagingClock.cs" + - path: "Tests/Editor/Allocations/EmitGateClockReadIsRare.cs" + - path: "Tests/Editor/Contract/EvictionSweepContractTests.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "sweep" + - "eviction" + - "clock" + - "hot-path" + - "messaging" + +complexity: + level: "advanced" + reasoning: "Requires understanding the relationship between emission cadence, wall-clock idle eviction, and platform-specific Stopwatch costs." + +impact: + performance: + rating: "critical" + details: "An unconditional clock read per emission caused a measured ~30-50% throughput regression in the GC landing." + maintainability: + rating: "high" + details: "A small, reviewable rule set for one of the highest-leverage perf surfaces." + testability: + rating: "high" + details: "EmitGateClockReadIsRare and EvictionSweepContractTests pin both the gate cadence and the wall-clock semantics." + +prerequisites: + - "memory-reclamation" + - "dispatch-hot-path" + +dependencies: + packages: [] + skills: + - "memory-reclamation" + - "dispatch-hot-path" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - ".NET" + versions: + unity: ">=2021.3" + dotnet: ">=netstandard2.1" + +aliases: + - "TrySweepIdle" + - "sweep cadence gate" + - "SweepGateMask" + +related: + - "memory-reclamation" + - "dispatch-hot-path" + - "memory-reclaim-coverage" + +status: "stable" +--- + +# DxMessaging Sweep Gate Must Be Cheap + +> **One-line summary**: The idle-eviction sweep gate is on the per-emit +> hot path; it must NEVER do an unconditional clock read, virtual call, +> allocation, or syscall. Sample wall-clock at most once per +> `SweepGateMask + 1` emissions. + +## Overview + +The idle-eviction sweep is rare (cadence-gated to ~5 seconds of wall time +by default), but the gate that decides whether to run it is consulted on +every emission. The "first pass garbage collection" landing wired the gate +to call `IDxMessagingClock.NowSeconds` unconditionally; that translated to +a per-emit `Stopwatch.GetTimestamp()` syscall (~15-20ns on x64, ~60-80ns on +ARM Mono) and accounted for most of a measured ~30-50% throughput +regression. This skill documents the constraints any future gate redesign +must obey. + +## The mask-gate pattern + +The current gate uses a power-of-two mask to skip the clock read on most +emissions: + +```csharp +if ((_emissionCounter++ & SweepGateMask) == 0) +{ + double nowSeconds = _clock.NowSeconds; + if (nowSeconds - _lastSweepSeconds >= _evictionTickIntervalSeconds) + { + // ... perform sweep + } +} +``` + +`SweepGateMask` is `0x0F` by default (sample once per 16 emissions). +Tunable internally; not exposed as public API. The wall-clock comparison +preserves `_evictionTickIntervalSeconds` semantics -- the configured "30 +seconds idle then evict" still holds because the comparison still happens, +just less frequently. + +## Required properties of the gate + +1. **Sample-not-call.** Never read the clock unconditionally per emission. +1. **Wall-clock semantics preserved.** The comparison against + `_evictionTickIntervalSeconds` must remain -- the public configuration + surface must continue to mean what it says. +1. **Sealed clock type.** `StopwatchClock` is sealed; `NowSeconds` is + `[MethodImpl(AggressiveInlining)]`. Both are load-bearing. +1. **No interface dispatch in the gate body.** The clock is read through a + field of concrete type when reached; the `IDxMessagingClock` interface is + used only for test injection. The compiler must be able to inline the + sealed property getter. + +## Headless / non-Unity host guidance + +The PlayerLoop sweep hook is `#if UNITY_2021_3_OR_NEWER` only. In headless +test rigs, dedicated game-server builds without PlayerLoop, or any +non-Unity consumer: + +- The mask gate alone may not trip often enough on low-emission workloads. + At 1 emit/sec with mask `0x0F`, the gate samples once every ~16 seconds -- + about 3x the default `_evictionTickIntervalSeconds`. +- These hosts MUST call `bus.Trim()` periodically themselves (e.g. once + per frame in a game-server tick loop, or via a custom timer). + +## Test injection + +Tests inject a probe clock via `MessageBus.CreateForInternalUse(probeClock, +...)`. **Do NOT** use `DxMessagingRuntimeSettingsProvider.Override` to inject +the clock -- the settings asset does not carry a clock; the clock is +constructor-injected. + +## Per-emit clock-read budget + + + +`EmitGateClockReadIsRare` asserts the per-emission clock-read rate stays +below `(emitCount / SweepGateMaskSampleSize) + 1` over a 10k-emit run. Any +PR that increases the gate's clock-read rate must explicitly justify it in +the `### Performance numbers` PR description section. + +## Enforcement + +- `Tests/Editor/Allocations/EmitGateClockReadIsRare.cs` -- pins the gate + cadence; runs in default CI (lifted from `[Explicit]` after T1.1 ships). +- `Tests/Editor/Contract/EvictionSweepContractTests.cs` -- pins the + wall-clock idle-eviction semantics. Touching the gate must keep these + tests green. +- The hot-path file list in `dispatch-hot-path` includes + `MessageBus.cs:TrySweepIdle`; PR-template enforcement applies. + +## Common pitfalls + +- "I'll just use `Time.realtimeSinceStartup` instead of `Stopwatch`." Both + are syscalls/property gets that cost ~10-20ns; the cost is the call + itself, not the API. Sample-not-call still required. +- "I'll bump the mask to 0x3F so the clock read is even rarer." Higher + masks bound the worst-case skew between configured cadence and observed + cadence. At 0x3F, a 1-emit/sec workload would skew ~64 seconds -- much + larger than the 5-second default. Don't increase past 0x0F without + measurement. +- "I'll add a per-emit `_tickCounter++` increment for diagnostics." That + field already exists and is incremented inside `AdvanceTick`; do NOT add + duplicate increments. Splitting `_emissionCounter` from `_tickCounter` + has been considered and rejected (it broke `CounterBasedTouchTests`). + +## See also + +- [DxMessaging Dispatch Hot Path](./dispatch-hot-path.md) +- [DxMessaging Memory Reclamation](./memory-reclamation.md) +- [Memory Reclaim Coverage](../testing/memory-reclaim-coverage.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b88f4e..2018d779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Emit-time idle eviction now samples the wall clock only at the sweep-gate cadence instead of on every message emit, restoring dispatch hot-path throughput while preserving the extra timestamp read only when a sweep actually runs. - Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. - Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. - This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 00000000..2b59d97f --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 41dce8be641afe34ca8363865b8cd5d9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs index db895060..203c127c 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -62,7 +62,7 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject [SerializeField] [Tooltip( - "Minimum interval in seconds between idle sweeps. The bus checks the clock at the top of each Emit and only sweeps when this much wall time has elapsed since the last sweep. See EvictionTickIntervalSeconds." + "Minimum interval in seconds between idle sweeps. Emit-time idle eviction samples the clock periodically instead of at the top of every Emit, and sweeps only when this much wall time has elapsed since the last sweep. See EvictionTickIntervalSeconds." )] [Min(0f)] internal float _evictionTickIntervalSeconds = DefaultEvictionTickIntervalSeconds; diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 1f81e9db..c81f245c 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -51,6 +51,8 @@ public PrefreezeDescriptor(byte kind, int priority) private const byte PrefreezeKindGlobalBroadcastHandlers = 5; private const long DefaultIdleEvictionTicks = 30; private const double DefaultEvictionTickIntervalSeconds = 5d; + internal const int SweepGateSampleSize = 16; + private const long SweepGateMask = SweepGateSampleSize - 1; private static readonly SlotKey UntargetedHandleSlot = new SlotKey( DispatchKind.Untargeted, @@ -1155,6 +1157,11 @@ private void TrySweepIdle(bool advanceTickForIdleAging = false) return; } + if (!advanceTickForIdleAging && ((unchecked(_emissionId + 1)) & SweepGateMask) != 0) + { + return; + } + double nowSeconds = _clock.NowSeconds; if (nowSeconds - _lastSweepSeconds < _evictionTickIntervalSeconds) { diff --git a/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs new file mode 100644 index 00000000..5349d044 --- /dev/null +++ b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs @@ -0,0 +1,352 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Editor.Allocations +{ + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Pooling; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + + public sealed class EmitGateClockReadIsRare + { + [Test] + public void EmitsSampleClockNoMoreThanOncePerSixteenEmits( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + const int emits = 10_000; + CountingClock probeClock = new(); + MessageBus bus = MessageBus.CreateForInternalUse( + probeClock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 60, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new(new InstanceId(0x6501), bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + + try + { + RegisterNoOpHandler(scenario, token, handler.owner); + for (int index = 0; index < emits; index++) + { + Emit(scenario, bus, handler.owner); + } + + int expectedMaximumClockReads = + (emits + MessageBus.SweepGateSampleSize - 1) / MessageBus.SweepGateSampleSize + + 1; + Assert.LessOrEqual( + probeClock.ReadCount, + expectedMaximumClockReads, + "[{0}] the idle-sweep gate should sample wall-clock time rather than reading it on every emit. Reads={1}, Emits={2}, SampleSize={3}.", + scenario.Kind, + probeClock.ReadCount, + emits, + MessageBus.SweepGateSampleSize + ); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + [Test] + public void DisabledIdleEvictionDoesNotReadClockAfterConstruction( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.AllKindsIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + const int emits = MessageBus.SweepGateSampleSize * 2; + CountingClock probeClock = new(); + MessageBus bus = MessageBus.CreateForInternalUse( + probeClock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0, + idleEvictionEnabled: false, + trimApiEnabled: true + ); + MessageHandler handler = new(new InstanceId(0x6502), bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + + try + { + RegisterNoOpHandler(scenario, token, handler.owner); + long readsAfterConstruction = probeClock.ReadCount; + for (int index = 0; index < emits; index++) + { + Emit(scenario, bus, handler.owner); + } + + Assert.AreEqual( + readsAfterConstruction, + probeClock.ReadCount, + "[{0}] disabled idle eviction should return before the sampled wall-clock gate. Reads={1}, Emits={2}.", + scenario.Kind, + probeClock.ReadCount, + emits + ); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + [Test] + public void EmitGateFirstSamplesClockOnSixteenthEmit() + { + CountingClock probeClock = new(); + MessageBus bus = MessageBus.CreateForInternalUse( + probeClock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 60, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + + long readsAfterConstruction = probeClock.ReadCount; + SimpleUntargetedMessage message = new(); + for (int emitIndex = 1; emitIndex < MessageBus.SweepGateSampleSize; emitIndex++) + { + bus.UntargetedBroadcast(ref message); + Assert.AreEqual( + readsAfterConstruction, + probeClock.ReadCount, + "Emit {0} should not sample wall-clock time before the sweep gate reaches its sample window.", + emitIndex + ); + } + + bus.UntargetedBroadcast(ref message); + + Assert.AreEqual( + readsAfterConstruction + 1, + probeClock.ReadCount, + "The sixteenth emit should be the first emit-side sampled wall-clock read." + ); + } + + [Test] + public void SampledIdleSweepAddsOnlyOneSweepClockRead() + { + CountingClock probeClock = new(); + MessageBus bus = MessageBus.CreateForInternalUse( + probeClock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 0, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new(new InstanceId(0x6503), bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + + try + { + MessageRegistrationHandle handle = + token.RegisterUntargeted(NoOpUntargeted); + token.RemoveRegistration(handle); + SimpleUntargetedMessage message = new(); + for (int index = 0; index <= MessageBus.SweepGateSampleSize; index++) + { + bus.UntargetedBroadcast(ref message); + } + + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.LessOrEqual( + probeClock.ReadCount, + 4, + "A real sampled idle sweep should read the clock once for construction, at most twice for sampled cadence checks when the first sample fires before the dirty slot ages, and once to stamp the completed sweep. Reads={0}.", + probeClock.ReadCount + ); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + [Test] + public void MutationChurnDoesNotDriveEmitClockSampling() + { + const int emits = MessageBus.SweepGateSampleSize * 8; + CountingClock probeClock = new(); + MessageBus bus = MessageBus.CreateForInternalUse( + probeClock, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: 60, + idleEvictionEnabled: true, + trimApiEnabled: true + ); + MessageHandler handler = new(new InstanceId(0x6504), bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + + try + { + _ = token.RegisterUntargeted(NoOpUntargeted); + for (int emitIndex = 0; emitIndex < emits; emitIndex++) + { + for ( + int mutationIndex = 0; + mutationIndex < MessageBus.SweepGateSampleSize - 1; + mutationIndex++ + ) + { + _ = token.RegisterUntargeted(NoOpClassUntargeted); + } + + SimpleUntargetedMessage message = new(); + bus.UntargetedBroadcast(ref message); + } + + int expectedMaximumClockReads = emits / MessageBus.SweepGateSampleSize + 1; + Assert.LessOrEqual( + probeClock.ReadCount, + expectedMaximumClockReads, + "Registration churn between emits must not make the idle-sweep gate sample wall-clock time more often than the emit cadence. Reads={0}, Emits={1}, SampleSize={2}.", + probeClock.ReadCount, + emits, + MessageBus.SweepGateSampleSize + ); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + private static void RegisterNoOpHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId context + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = token.RegisterUntargeted(NoOpUntargeted); + return; + } + case MessageKind.Targeted: + { + _ = token.RegisterTargeted(context, NoOpTargeted); + return; + } + case MessageKind.TargetedWithoutTargeting: + { + _ = token.RegisterTargetedWithoutTargeting( + NoOpTargetedWithoutTargeting + ); + return; + } + case MessageKind.Broadcast: + { + _ = token.RegisterBroadcast(context, NoOpBroadcast); + return; + } + case MessageKind.BroadcastWithoutSource: + { + _ = token.RegisterBroadcastWithoutSource( + NoOpBroadcastWithoutSource + ); + return; + } + default: + { + throw new System.ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void Emit(MessageScenario scenario, MessageBus bus, InstanceId context) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + bus.UntargetedBroadcast(ref message); + return; + } + case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: + { + SimpleTargetedMessage message = new(); + bus.TargetedBroadcast(ref context, ref message); + return; + } + case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: + { + SimpleBroadcastMessage message = new(); + bus.SourcedBroadcast(ref context, ref message); + return; + } + default: + { + throw new System.ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void NoOpUntargeted(ref SimpleUntargetedMessage message) { } + + private static void NoOpTargeted(ref SimpleTargetedMessage message) { } + + private static void NoOpBroadcast(ref SimpleBroadcastMessage message) { } + + private static void NoOpClassUntargeted(ref ClassUntargetedMessage message) { } + + private static void NoOpTargetedWithoutTargeting( + ref InstanceId target, + ref SimpleTargetedMessage message + ) { } + + private static void NoOpBroadcastWithoutSource( + ref InstanceId source, + ref SimpleBroadcastMessage message + ) { } + + private sealed class CountingClock : IDxMessagingClock + { + public long ReadCount { get; private set; } + + public double NowSeconds + { + get + { + ReadCount++; + return 1d; + } + } + } + } +} +#endif diff --git a/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs.meta b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs.meta new file mode 100644 index 00000000..661e413e --- /dev/null +++ b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b8f8d9f01114f4c8849f01d195757f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Benchmarks/AssemblyInfo.cs b/Tests/Editor/Benchmarks/AssemblyInfo.cs index 0e763b98..267b4560 100644 --- a/Tests/Editor/Benchmarks/AssemblyInfo.cs +++ b/Tests/Editor/Benchmarks/AssemblyInfo.cs @@ -6,4 +6,4 @@ )] [assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor")] [assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor.Allocations")] -[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.01.Editor.Comparisons")] +[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons")] diff --git a/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs new file mode 100644 index 00000000..139f7465 --- /dev/null +++ b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs @@ -0,0 +1,266 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Editor.Benchmarks +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using DxMessaging.Tests.Runtime.Benchmarks; + using NUnit.Framework; + + public sealed class PerfRegressionSmokeTests + { + private const string PerfGateEnvVar = "DX_PERF_GATE"; + private const string BaselinePath = "progress/perf-baseline-2026-05-05.csv"; + private const string BaselineCommit = "25a4dcc"; + private const double RegressionMultiplier = 1.5d; + + [Test, Explicit, Category("PerfGate")] + public void UntargetedFloodOneHandler() + { + RunGate(DispatchBenchmarkScenario.UntargetedFloodOneHandler); + } + + [Test, Explicit, Category("PerfGate")] + public void UntargetedFloodFourHandlersOnePriority() + { + RunGate(DispatchBenchmarkScenario.UntargetedFloodFourHandlersOnePriority); + } + + [Test, Explicit, Category("PerfGate")] + public void UntargetedFloodFourHandlersFourPriorities() + { + RunGate(DispatchBenchmarkScenario.UntargetedFloodFourHandlersFourPriorities); + } + + [Test, Explicit, Category("PerfGate")] + public void TargetedFloodOneListener() + { + RunGate(DispatchBenchmarkScenario.TargetedFloodOneListener); + } + + [Test, Explicit, Category("PerfGate")] + public void TargetedFloodSixteenListeners() + { + RunGate(DispatchBenchmarkScenario.TargetedFloodSixteenListeners); + } + + [Test, Explicit, Category("PerfGate")] + public void BroadcastFloodOneHandler() + { + RunGate(DispatchBenchmarkScenario.BroadcastFloodOneHandler); + } + + [Test, Explicit, Category("PerfGate")] + public void InterceptorHeavyFourInterceptors() + { + RunGate(DispatchBenchmarkScenario.InterceptorHeavyFourInterceptors); + } + + [Test, Explicit, Category("PerfGate")] + public void PostProcessingHeavyFourPostProcessors() + { + RunGate(DispatchBenchmarkScenario.PostProcessingHeavyFourPostProcessors); + } + + [Test, Explicit, Category("PerfGate")] + public void RegistrationFlood1000TypesFromColdBus() + { + RunGate(DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus); + } + + private static void RunGate(DispatchBenchmarkScenario scenario) + { + if (Environment.GetEnvironmentVariable(PerfGateEnvVar) != "1") + { + Assert.Ignore($"{PerfGateEnvVar}=1 is required to run the perf smoke gate."); + } + + DispatchBenchmarkResult current = DispatchThroughputBenchmarks.RunScenario(scenario); + IReadOnlyList baselines = LoadBaselines(); + string scenarioName = DispatchThroughputBenchmarks.GetScenarioName(scenario); + BaselineRow baseline = FindBaseline(baselines, scenarioName, current.Platform); + + if (current.IsRegistrationScenario) + { + Assert.LessOrEqual( + current.WallClockMs, + baseline.WallClockMs * RegressionMultiplier, + $"{scenarioName} registration wall-clock regressed more than {RegressionMultiplier:0.0}x." + ); + return; + } + + double minimumAllowedEmitsPerSecond = baseline.EmitsPerSecond / RegressionMultiplier; + Assert.GreaterOrEqual( + current.EmitsPerSecond, + minimumAllowedEmitsPerSecond, + $"{scenarioName} throughput regressed more than {RegressionMultiplier:0.0}x." + ); + + long allocationBudgetBytes = Math.Max(0, baseline.AllocatedBytesDelta); + Assert.LessOrEqual( + current.AllocatedBytesDelta, + allocationBudgetBytes, + $"{scenarioName} allocated {current.AllocatedBytesDelta.ToString(CultureInfo.InvariantCulture)} bytes, exceeding the baseline allocation budget of {allocationBudgetBytes.ToString(CultureInfo.InvariantCulture)} bytes." + ); + } + + private static IReadOnlyList LoadBaselines() + { + string path = FindRepoRelativePath(BaselinePath); + if (!File.Exists(path)) + { + Assert.Ignore( + $"Performance baseline file not found: {BaselinePath}. Capture T0.3 baselines before enforcing PerfGate." + ); + } + + List rows = new(); + foreach (string line in File.ReadAllLines(path)) + { + if ( + string.IsNullOrWhiteSpace(line) + || line.StartsWith("scenario,", StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + rows.Add(BaselineRow.Parse(line)); + } + + Assert.Greater(rows.Count, 0, "Performance baseline file contains no data rows."); + return rows; + } + + private static BaselineRow FindBaseline( + IReadOnlyList rows, + string scenario, + string platform + ) + { + for (int index = 0; index < rows.Count; index++) + { + BaselineRow row = rows[index]; + if ( + string.Equals(row.Scenario, scenario, StringComparison.Ordinal) + && string.Equals(row.Platform, platform, StringComparison.Ordinal) + && string.Equals(row.Commit, BaselineCommit, StringComparison.OrdinalIgnoreCase) + ) + { + return row; + } + } + + Assert.Fail( + $"No {BaselineCommit} baseline row found for scenario {scenario} on platform {platform}." + ); + return default; + } + + private static string FindRepoRelativePath(string relativePath) + { + DirectoryInfo current = new(Directory.GetCurrentDirectory()); + while (current != null) + { + string candidate = Path.Combine(current.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + return Path.Combine(Directory.GetCurrentDirectory(), relativePath); + } + + private readonly struct BaselineRow + { + private BaselineRow( + string scenario, + string platform, + string commit, + double emitsPerSecond, + long allocatedBytesDelta, + double wallClockMs + ) + { + Scenario = scenario; + Platform = platform; + Commit = commit; + EmitsPerSecond = emitsPerSecond; + AllocatedBytesDelta = allocatedBytesDelta; + WallClockMs = wallClockMs; + } + + public string Scenario { get; } + + public string Platform { get; } + + public string Commit { get; } + + public double EmitsPerSecond { get; } + + public long AllocatedBytesDelta { get; } + + public double WallClockMs { get; } + + public static BaselineRow Parse(string line) + { + string[] parts = ParseCsvFields(line); + if (parts.Length < 7) + { + throw new FormatException($"Invalid baseline row: {line}"); + } + + return new BaselineRow( + parts[0], + parts[1], + parts[2], + double.Parse(parts[4], CultureInfo.InvariantCulture), + long.Parse(parts[5], CultureInfo.InvariantCulture), + double.Parse(parts[6], CultureInfo.InvariantCulture) + ); + } + + private static string[] ParseCsvFields(string line) + { + List fields = new(); + System.Text.StringBuilder builder = new(); + bool inQuotes = false; + + for (int index = 0; index < line.Length; index++) + { + char value = line[index]; + if (value == '"') + { + if (inQuotes && index + 1 < line.Length && line[index + 1] == '"') + { + builder.Append('"'); + index++; + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (value == ',' && !inQuotes) + { + fields.Add(builder.ToString()); + builder.Clear(); + continue; + } + + builder.Append(value); + } + + fields.Add(builder.ToString()); + return fields.ToArray(); + } + } + } +} +#endif diff --git a/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs.meta b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs.meta new file mode 100644 index 00000000..d92297a2 --- /dev/null +++ b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 595752d0b0d2477594c5208361e7a4d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef index 431067de..dbe4c912 100644 --- a/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef +++ b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef @@ -6,6 +6,7 @@ "UnityEditor.TestRunner", "WallstopStudios.DxMessaging", "WallstopStudios.DxMessaging.Tests.Runtime", + "WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks", "Unity.PerformanceTesting", "Zenject", "MessagePipe", diff --git a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef similarity index 94% rename from Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef rename to Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef index 067b9a7f..64c4e35d 100644 --- a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef +++ b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef @@ -1,5 +1,5 @@ { - "name": "WallstopStudios.DxMessaging.Tests.01.Editor.Comparisons", + "name": "WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons", "rootNamespace": "DxMessaging.Tests.Editor.Comparisons", "references": [ "UnityEngine.TestRunner", diff --git a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef.meta similarity index 100% rename from Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta rename to Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef.meta diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs b/Tests/Editor/Contract/EvictionSweepContractTests.cs index e60c3f79..4546ade7 100644 --- a/Tests/Editor/Contract/EvictionSweepContractTests.cs +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs @@ -360,14 +360,7 @@ public void EmitTimeSweepReclaimsIdleDirtySlotsWhenCadenceHasElapsed() Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); OtherProbeMessage other = new OtherProbeMessage(); - bus.UntargetedBroadcast(ref other); - Assert.GreaterOrEqual( - bus.OccupiedTypeSlots, - 1, - "The emit that checks cadence runs before AdvanceTick, so it only ages the candidate for the next emit." - ); - - bus.UntargetedBroadcast(ref other); + EmitUntargetedSweepSampleWindow(bus, ref other); Assert.AreEqual(0, bus.OccupiedTypeSlots); Assert.AreEqual(0, ReadCollectionCount(bus, "_dirtyTypes")); @@ -404,13 +397,12 @@ public void EmitTimeSweepWaitsForCadenceInterval() OtherProbeMessage other = new OtherProbeMessage(); clock.SetTo(9d); - bus.UntargetedBroadcast(ref other); - bus.UntargetedBroadcast(ref other); + EmitUntargetedSweepSampleWindow(bus, ref other); Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); clock.SetTo(10d); - bus.UntargetedBroadcast(ref other); + EmitUntargetedSweepSampleWindow(bus, ref other); Assert.AreEqual(0, bus.OccupiedTypeSlots); Assert.AreEqual(10d, ReadDoubleField(bus, "_lastSweepSeconds")); @@ -447,10 +439,9 @@ public void EmitTimeSweepPreservesActiveRegistration() ); ProbeMessage message = new ProbeMessage(); - bus.UntargetedBroadcast(ref message); - bus.UntargetedBroadcast(ref message); + EmitUntargetedSweepSampleWindow(bus, ref message); - Assert.AreEqual(2, calls); + Assert.AreEqual(SweepSampleWindowEmits, calls); Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); } finally @@ -482,8 +473,7 @@ public void DisabledIdleEvictionPreventsEmitTimeSweep() deregistration(); OtherProbeMessage other = new OtherProbeMessage(); - bus.UntargetedBroadcast(ref other); - bus.UntargetedBroadcast(ref other); + EmitUntargetedSweepSampleWindow(bus, ref other); Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyTypes")); @@ -592,14 +582,12 @@ public void EmitTimeSweepRunsForTargetedAndBroadcastTypedEntryPoints() Assert.GreaterOrEqual(bus.OccupiedTargetSlots, 2); TargetedProbeMessage targeted = new TargetedProbeMessage(); - bus.TargetedBroadcast(ref context, ref targeted); - bus.TargetedBroadcast(ref context, ref targeted); + EmitTargetedSweepSampleWindow(bus, ref context, ref targeted); Assert.LessOrEqual(bus.OccupiedTargetSlots, 1); BroadcastProbeMessage broadcast = new BroadcastProbeMessage(); - bus.SourcedBroadcast(ref context, ref broadcast); - bus.SourcedBroadcast(ref context, ref broadcast); + EmitSourcedSweepSampleWindow(bus, ref context, ref broadcast); Assert.AreEqual(0, bus.OccupiedTargetSlots); } @@ -655,6 +643,10 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() } finally { + DxMessagingRuntimeSettingsProvider.ResetForTests(); + DxMessagingRuntimeSettings.RaiseSettingsChanged( + DxMessagingRuntimeSettingsProvider.Current + ); UnityEngine.Object.DestroyImmediate(settings); } } @@ -728,6 +720,46 @@ private static object ReadTypedHandler(MessageHandler handler, IMessag return typedHandler; } + private static void EmitUntargetedSweepSampleWindow( + MessageBus bus, + ref TMessage message + ) + where TMessage : IUntargetedMessage + { + for (int i = 0; i < SweepSampleWindowEmits; i++) + { + bus.UntargetedBroadcast(ref message); + } + } + + private static void EmitTargetedSweepSampleWindow( + MessageBus bus, + ref InstanceId target, + ref TMessage message + ) + where TMessage : ITargetedMessage + { + for (int i = 0; i < SweepSampleWindowEmits; i++) + { + bus.TargetedBroadcast(ref target, ref message); + } + } + + private static void EmitSourcedSweepSampleWindow( + MessageBus bus, + ref InstanceId source, + ref TMessage message + ) + where TMessage : IBroadcastMessage + { + for (int i = 0; i < SweepSampleWindowEmits; i++) + { + bus.SourcedBroadcast(ref source, ref message); + } + } + + private const int SweepSampleWindowEmits = MessageBus.SweepGateSampleSize + 1; + private static Array ReadArrayField(object owner, string name) { FieldInfo field = owner diff --git a/Tests/Runtime/Benchmarks.meta b/Tests/Runtime/Benchmarks.meta new file mode 100644 index 00000000..eed04c63 --- /dev/null +++ b/Tests/Runtime/Benchmarks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d13e5ec13f64d7db7f7346dbd87c743 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs new file mode 100644 index 00000000..a8286382 --- /dev/null +++ b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs @@ -0,0 +1,790 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Benchmarks +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using Debug = UnityEngine.Debug; + + [AttributeUsage(AttributeTargets.Method)] + internal sealed class PerformanceAttribute : CategoryAttribute + { + public PerformanceAttribute() + : base("Performance") { } + } + + public enum DispatchBenchmarkScenario + { + UntargetedFloodOneHandler, + UntargetedFloodFourHandlersOnePriority, + UntargetedFloodFourHandlersFourPriorities, + TargetedFloodOneListener, + TargetedFloodSixteenListeners, + BroadcastFloodOneHandler, + InterceptorHeavyFourInterceptors, + PostProcessingHeavyFourPostProcessors, + RegistrationFlood1000TypesFromColdBus, + } + + public sealed class DispatchThroughputBenchmarks + { + private const int WarmupEmits = 10_000; + private const int MedianRuns = 5; + private static readonly TimeSpan MeasurementWindow = TimeSpan.FromSeconds(1); + private static readonly long MeasurementWindowTicks = (long)( + Stopwatch.Frequency * MeasurementWindow.TotalSeconds + ); + private static readonly InstanceId Target = new(31001); + private static readonly InstanceId Source = new(31002); + private static Action[] _registrationFloodBuilders; + + [Test, Performance, Category("PerfBench")] + public void UntargetedFloodOneHandler() + { + _ = RunScenario(DispatchBenchmarkScenario.UntargetedFloodOneHandler); + } + + [Test, Performance, Category("PerfBench")] + public void UntargetedFloodFourHandlersOnePriority() + { + _ = RunScenario(DispatchBenchmarkScenario.UntargetedFloodFourHandlersOnePriority); + } + + [Test, Performance, Category("PerfBench")] + public void UntargetedFloodFourHandlersFourPriorities() + { + _ = RunScenario(DispatchBenchmarkScenario.UntargetedFloodFourHandlersFourPriorities); + } + + [Test, Performance, Category("PerfBench")] + public void TargetedFloodOneListener() + { + _ = RunScenario(DispatchBenchmarkScenario.TargetedFloodOneListener); + } + + [Test, Performance, Category("PerfBench")] + public void TargetedFloodSixteenListeners() + { + _ = RunScenario(DispatchBenchmarkScenario.TargetedFloodSixteenListeners); + } + + [Test, Performance, Category("PerfBench")] + public void BroadcastFloodOneHandler() + { + _ = RunScenario(DispatchBenchmarkScenario.BroadcastFloodOneHandler); + } + + [Test, Performance, Category("PerfBench")] + public void InterceptorHeavyFourInterceptors() + { + _ = RunScenario(DispatchBenchmarkScenario.InterceptorHeavyFourInterceptors); + } + + [Test, Performance, Category("PerfBench")] + public void PostProcessingHeavyFourPostProcessors() + { + _ = RunScenario(DispatchBenchmarkScenario.PostProcessingHeavyFourPostProcessors); + } + + [Test, Performance, Category("PerfBench")] + public void RegistrationFlood1000TypesFromColdBus() + { + _ = RunScenario(DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus); + } + + public static DispatchBenchmarkResult RunScenario( + DispatchBenchmarkScenario scenario, + bool logResult = true + ) + { + DispatchBenchmarkResult[] runs = new DispatchBenchmarkResult[MedianRuns]; + for (int runIndex = 0; runIndex < runs.Length; runIndex++) + { + runs[runIndex] = + scenario == DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus + ? MeasureRegistrationFlood(runIndex) + : MeasureEmitScenario(scenario, runIndex); + } + + DispatchBenchmarkResult median = MedianByPrimaryMetric(runs); + if (logResult) + { + Debug.Log(median.ToStructuredLog()); + TestContext.Out.WriteLine(median.ToCsvRow()); + } + + return median; + } + + public static string GetScenarioName(DispatchBenchmarkScenario scenario) + { + return scenario switch + { + DispatchBenchmarkScenario.UntargetedFloodOneHandler => "UntargetedFlood_OneHandler", + DispatchBenchmarkScenario.UntargetedFloodFourHandlersOnePriority => + "UntargetedFlood_FourHandlers_OnePriority", + DispatchBenchmarkScenario.UntargetedFloodFourHandlersFourPriorities => + "UntargetedFlood_FourHandlers_FourPriorities", + DispatchBenchmarkScenario.TargetedFloodOneListener => "TargetedFlood_OneListener", + DispatchBenchmarkScenario.TargetedFloodSixteenListeners => + "TargetedFlood_SixteenListeners", + DispatchBenchmarkScenario.BroadcastFloodOneHandler => "BroadcastFlood_OneHandler", + DispatchBenchmarkScenario.InterceptorHeavyFourInterceptors => + "InterceptorHeavy_FourInterceptors", + DispatchBenchmarkScenario.PostProcessingHeavyFourPostProcessors => + "PostProcessingHeavy_FourPostProcessors", + DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus => + "RegistrationFlood_1000Types_FromColdBus", + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null), + }; + } + + private static DispatchBenchmarkResult MeasureEmitScenario( + DispatchBenchmarkScenario scenario, + int runIndex + ) + { + using BenchmarkRegistrationScope scope = new(); + InvocationCounter handlerInvocations = new(); + ConfigureScenario(scope, scenario, handlerInvocations); + + EmitMany(scope.Bus, scenario, WarmupEmits); + + long beforeAllocatedBytes = GC.GetAllocatedBytesForCurrentThread(); + long startTimestamp = Stopwatch.GetTimestamp(); + long endTimestamp = startTimestamp; + long emits = 0; + do + { + EmitMany(scope.Bus, scenario, WarmupEmits); + emits += WarmupEmits; + endTimestamp = Stopwatch.GetTimestamp(); + } while (endTimestamp - startTimestamp < MeasurementWindowTicks); + long afterAllocatedBytes = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Greater( + handlerInvocations.Count, + 0, + "Benchmark scenario did not invoke handlers." + ); + double elapsedSeconds = TimestampDeltaToSeconds(startTimestamp, endTimestamp); + double emitsPerSecond = emits / Math.Max(elapsedSeconds, double.Epsilon); + return DispatchBenchmarkResult.ForEmitScenario( + GetScenarioName(scenario), + runIndex, + emitsPerSecond, + afterAllocatedBytes - beforeAllocatedBytes, + elapsedSeconds * 1000d + ); + } + + private static DispatchBenchmarkResult MeasureRegistrationFlood(int runIndex) + { + Action[] builders = GetRegistrationFloodBuilders(); + long beforeAllocatedBytes = GC.GetAllocatedBytesForCurrentThread(); + long startTimestamp = Stopwatch.GetTimestamp(); + using (BenchmarkRegistrationScope scope = new()) + { + for (int index = 0; index < builders.Length; index++) + { + builders[index](scope.PrimaryToken); + } + } + long endTimestamp = Stopwatch.GetTimestamp(); + long afterAllocatedBytes = GC.GetAllocatedBytesForCurrentThread(); + + return DispatchBenchmarkResult.ForRegistrationScenario( + GetScenarioName(DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus), + runIndex, + afterAllocatedBytes - beforeAllocatedBytes, + TimestampDeltaToSeconds(startTimestamp, endTimestamp) * 1000d + ); + } + + private static double TimestampDeltaToSeconds(long startTimestamp, long endTimestamp) + { + return (endTimestamp - startTimestamp) / (double)Stopwatch.Frequency; + } + + private static void ConfigureScenario( + BenchmarkRegistrationScope scope, + DispatchBenchmarkScenario scenario, + InvocationCounter handlerInvocations + ) + { + switch (scenario) + { + case DispatchBenchmarkScenario.UntargetedFloodOneHandler: + RegisterUntargeted(scope, handlerInvocations, 0); + return; + case DispatchBenchmarkScenario.UntargetedFloodFourHandlersOnePriority: + for (int index = 0; index < 4; index++) + { + RegisterUntargeted(scope, handlerInvocations, 0); + } + return; + case DispatchBenchmarkScenario.UntargetedFloodFourHandlersFourPriorities: + for (int priority = 0; priority < 4; priority++) + { + RegisterUntargeted(scope, handlerInvocations, priority); + } + return; + case DispatchBenchmarkScenario.TargetedFloodOneListener: + RegisterTargeted(scope, handlerInvocations, 0); + return; + case DispatchBenchmarkScenario.TargetedFloodSixteenListeners: + for (int index = 0; index < 16; index++) + { + RegisterTargeted(scope, handlerInvocations, 0); + } + return; + case DispatchBenchmarkScenario.BroadcastFloodOneHandler: + RegisterBroadcast(scope, handlerInvocations, 0); + return; + case DispatchBenchmarkScenario.InterceptorHeavyFourInterceptors: + for (int priority = 0; priority < 4; priority++) + { + _ = + scope.PrimaryToken.RegisterUntargetedInterceptor( + AllowUntargeted, + priority + ); + } + RegisterUntargeted(scope, handlerInvocations, 0); + return; + case DispatchBenchmarkScenario.PostProcessingHeavyFourPostProcessors: + for (int priority = 0; priority < 4; priority++) + { + _ = + scope.PrimaryToken.RegisterUntargetedPostProcessor( + CountPostProcessed, + priority + ); + } + RegisterUntargeted(scope, handlerInvocations, 0); + return; + default: + throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null); + } + + void CountPostProcessed(ref SimpleUntargetedMessage message) + { + handlerInvocations.Increment(); + } + } + + private static void RegisterUntargeted( + BenchmarkRegistrationScope scope, + InvocationCounter handlerInvocations, + int priority + ) + { + MessageRegistrationToken token = scope.CreateToken(); + _ = token.RegisterUntargeted( + (ref SimpleUntargetedMessage message) => handlerInvocations.Increment(), + priority + ); + } + + private static void RegisterTargeted( + BenchmarkRegistrationScope scope, + InvocationCounter handlerInvocations, + int priority + ) + { + MessageRegistrationToken token = scope.CreateToken(); + _ = token.RegisterTargeted( + Target, + (ref SimpleTargetedMessage message) => handlerInvocations.Increment(), + priority + ); + } + + private static void RegisterBroadcast( + BenchmarkRegistrationScope scope, + InvocationCounter handlerInvocations, + int priority + ) + { + MessageRegistrationToken token = scope.CreateToken(); + _ = token.RegisterBroadcast( + Source, + (ref SimpleBroadcastMessage message) => handlerInvocations.Increment(), + priority + ); + } + + private static void EmitMany(MessageBus bus, DispatchBenchmarkScenario scenario, int count) + { + switch (scenario) + { + case DispatchBenchmarkScenario.UntargetedFloodOneHandler: + case DispatchBenchmarkScenario.UntargetedFloodFourHandlersOnePriority: + case DispatchBenchmarkScenario.UntargetedFloodFourHandlersFourPriorities: + case DispatchBenchmarkScenario.InterceptorHeavyFourInterceptors: + case DispatchBenchmarkScenario.PostProcessingHeavyFourPostProcessors: + SimpleUntargetedMessage untargeted = new(); + for (int index = 0; index < count; index++) + { + bus.UntargetedBroadcast(ref untargeted); + } + return; + case DispatchBenchmarkScenario.TargetedFloodOneListener: + case DispatchBenchmarkScenario.TargetedFloodSixteenListeners: + SimpleTargetedMessage targeted = new(); + InstanceId target = Target; + for (int index = 0; index < count; index++) + { + bus.TargetedBroadcast(ref target, ref targeted); + } + return; + case DispatchBenchmarkScenario.BroadcastFloodOneHandler: + SimpleBroadcastMessage broadcast = new(); + InstanceId source = Source; + for (int index = 0; index < count; index++) + { + bus.SourcedBroadcast(ref source, ref broadcast); + } + return; + default: + throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null); + } + } + + private static bool AllowUntargeted(ref SimpleUntargetedMessage message) + { + return true; + } + + private static DispatchBenchmarkResult MedianByPrimaryMetric( + DispatchBenchmarkResult[] results + ) + { + DispatchBenchmarkResult[] sorted = (DispatchBenchmarkResult[])results.Clone(); + Array.Sort( + sorted, + (left, right) => + { + int comparison = left.IsRegistrationScenario + ? left.WallClockMs.CompareTo(right.WallClockMs) + : right.EmitsPerSecond.CompareTo(left.EmitsPerSecond); + return comparison != 0 ? comparison : left.RunIndex.CompareTo(right.RunIndex); + } + ); + + return sorted[sorted.Length / 2].AsMedian(); + } + + private static Action[] GetRegistrationFloodBuilders() + { + if (_registrationFloodBuilders != null) + { + return _registrationFloodBuilders; + } + + MethodInfo builderMethod = typeof(DispatchThroughputBenchmarks).GetMethod( + nameof(RegisterFloodMessage), + BindingFlags.NonPublic | BindingFlags.Static + ); + if (builderMethod == null) + { + throw new MissingMethodException(nameof(RegisterFloodMessage)); + } + + Type[] markerTypes = RegistrationFloodMarkerTypes.All; + List> builders = new(capacity: 1000); + for (int outerIndex = 0; outerIndex < markerTypes.Length; outerIndex++) + { + for (int innerIndex = 0; innerIndex < markerTypes.Length; innerIndex++) + { + Type markerType = typeof(RegistrationFloodMarker<,>).MakeGenericType( + markerTypes[outerIndex], + markerTypes[innerIndex] + ); + MethodInfo closedMethod = builderMethod.MakeGenericMethod(markerType); + builders.Add( + (Action) + Delegate.CreateDelegate( + typeof(Action), + closedMethod + ) + ); + if (builders.Count == 1000) + { + break; + } + } + + if (builders.Count == 1000) + { + break; + } + } + + if (builders.Count < 1000) + { + throw new InvalidOperationException( + $"Expected at least 1000 marker types for the registration flood, found {builders.Count}." + ); + } + + _registrationFloodBuilders = builders.ToArray(); + return _registrationFloodBuilders; + } + + private static void RegisterFloodMessage(MessageRegistrationToken token) + { + _ = token.RegisterUntargeted>(NoOpFloodHandler); + } + + private static void NoOpFloodHandler( + ref RegistrationFloodMessage message + ) { } + + private readonly struct RegistrationFloodMessage + : DxMessaging.Core.Messages.IUntargetedMessage { } + + private readonly struct RegistrationFloodMarker { } + + private static class RegistrationFloodMarkerTypes + { + public static readonly Type[] All = + { + typeof(Marker00), + typeof(Marker01), + typeof(Marker02), + typeof(Marker03), + typeof(Marker04), + typeof(Marker05), + typeof(Marker06), + typeof(Marker07), + typeof(Marker08), + typeof(Marker09), + typeof(Marker10), + typeof(Marker11), + typeof(Marker12), + typeof(Marker13), + typeof(Marker14), + typeof(Marker15), + typeof(Marker16), + typeof(Marker17), + typeof(Marker18), + typeof(Marker19), + typeof(Marker20), + typeof(Marker21), + typeof(Marker22), + typeof(Marker23), + typeof(Marker24), + typeof(Marker25), + typeof(Marker26), + typeof(Marker27), + typeof(Marker28), + typeof(Marker29), + typeof(Marker30), + typeof(Marker31), + }; + + private readonly struct Marker00 { } + + private readonly struct Marker01 { } + + private readonly struct Marker02 { } + + private readonly struct Marker03 { } + + private readonly struct Marker04 { } + + private readonly struct Marker05 { } + + private readonly struct Marker06 { } + + private readonly struct Marker07 { } + + private readonly struct Marker08 { } + + private readonly struct Marker09 { } + + private readonly struct Marker10 { } + + private readonly struct Marker11 { } + + private readonly struct Marker12 { } + + private readonly struct Marker13 { } + + private readonly struct Marker14 { } + + private readonly struct Marker15 { } + + private readonly struct Marker16 { } + + private readonly struct Marker17 { } + + private readonly struct Marker18 { } + + private readonly struct Marker19 { } + + private readonly struct Marker20 { } + + private readonly struct Marker21 { } + + private readonly struct Marker22 { } + + private readonly struct Marker23 { } + + private readonly struct Marker24 { } + + private readonly struct Marker25 { } + + private readonly struct Marker26 { } + + private readonly struct Marker27 { } + + private readonly struct Marker28 { } + + private readonly struct Marker29 { } + + private readonly struct Marker30 { } + + private readonly struct Marker31 { } + } + + private sealed class InvocationCounter + { + public int Count { get; private set; } + + public void Increment() + { + Count++; + } + } + + private sealed class BenchmarkRegistrationScope : IDisposable + { + private readonly List _tokens = new(); + private int _nextOwner = 32000; + + public BenchmarkRegistrationScope() + { + Bus = new MessageBus(); + PrimaryToken = CreateToken(); + } + + public MessageBus Bus { get; } + + public MessageRegistrationToken PrimaryToken { get; } + + public MessageRegistrationToken CreateToken() + { + MessageHandler handler = new(new InstanceId(_nextOwner++), Bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, Bus); + token.Enable(); + _tokens.Add(token); + return token; + } + + public void Dispose() + { + for (int index = _tokens.Count - 1; index >= 0; index--) + { + _tokens[index].UnregisterAll(); + _tokens[index].Dispose(); + } + } + } + } + + public readonly struct DispatchBenchmarkResult + { + private DispatchBenchmarkResult( + string scenario, + string platform, + string commit, + int runIndex, + double emitsPerSecond, + long allocatedBytesDelta, + double wallClockMs, + bool isRegistrationScenario + ) + { + Scenario = scenario; + Platform = platform; + Commit = commit; + RunIndex = runIndex; + EmitsPerSecond = emitsPerSecond; + AllocatedBytesDelta = allocatedBytesDelta; + WallClockMs = wallClockMs; + IsRegistrationScenario = isRegistrationScenario; + } + + public string Scenario { get; } + + public string Platform { get; } + + public string Commit { get; } + + public int RunIndex { get; } + + public double EmitsPerSecond { get; } + + public long AllocatedBytesDelta { get; } + + public double WallClockMs { get; } + + public bool IsRegistrationScenario { get; } + + public static DispatchBenchmarkResult ForEmitScenario( + string scenario, + int runIndex, + double emitsPerSecond, + long allocatedBytesDelta, + double wallClockMs + ) + { + return new DispatchBenchmarkResult( + scenario, + ResolvePlatform(), + ResolveCommit(), + runIndex, + emitsPerSecond, + allocatedBytesDelta, + wallClockMs, + isRegistrationScenario: false + ); + } + + public static DispatchBenchmarkResult ForRegistrationScenario( + string scenario, + int runIndex, + long allocatedBytesDelta, + double wallClockMs + ) + { + return new DispatchBenchmarkResult( + scenario, + ResolvePlatform(), + ResolveCommit(), + runIndex, + emitsPerSecond: 0, + allocatedBytesDelta, + wallClockMs, + isRegistrationScenario: true + ); + } + + public DispatchBenchmarkResult AsMedian() + { + return new DispatchBenchmarkResult( + Scenario, + Platform, + Commit, + runIndex: -1, + EmitsPerSecond, + AllocatedBytesDelta, + WallClockMs, + IsRegistrationScenario + ); + } + + public string ToCsvRow() + { + return string.Join( + ",", + EscapeCsv(Scenario), + EscapeCsv(Platform), + EscapeCsv(Commit), + RunIndex.ToString(CultureInfo.InvariantCulture), + EmitsPerSecond.ToString("F3", CultureInfo.InvariantCulture), + AllocatedBytesDelta.ToString(CultureInfo.InvariantCulture), + WallClockMs.ToString("F3", CultureInfo.InvariantCulture) + ); + } + + public string ToStructuredLog() + { + return "{" + + $"scenario:\"{Scenario}\", " + + $"platform:\"{Platform}\", " + + $"commit:\"{Commit}\", " + + $"runIndex:{RunIndex.ToString(CultureInfo.InvariantCulture)}, " + + $"emitsPerSec:{EmitsPerSecond.ToString("F3", CultureInfo.InvariantCulture)}, " + + $"allocatedBytesDelta:{AllocatedBytesDelta.ToString(CultureInfo.InvariantCulture)}, " + + $"wallClockMs:{WallClockMs.ToString("F3", CultureInfo.InvariantCulture)}" + + "}"; + } + + private static string ResolvePlatform() + { + return $"{ResolveExecutionTarget()} {ResolveScriptingBackend()} {ResolveArchitecture()} {ResolveBuildConfiguration()} ({Application.platform}; Unity {Application.unityVersion})"; + } + + private static string ResolveExecutionTarget() + { +#if UNITY_EDITOR + return "Editor"; +#elif UNITY_STANDALONE + return "Standalone"; +#else + return Application.platform.ToString(); +#endif + } + + private static string ResolveScriptingBackend() + { +#if ENABLE_IL2CPP + return "IL2CPP"; +#elif ENABLE_MONO + return "Mono"; +#else + return Type.GetType("Mono.Runtime", throwOnError: false) == null + ? "UnknownBackend" + : "Mono"; +#endif + } + + private static string ResolveArchitecture() + { + return IntPtr.Size == 8 ? "x64" : "x86"; + } + + private static string ResolveBuildConfiguration() + { + return Debug.isDebugBuild ? "Development" : "Release"; + } + + private static string ResolveCommit() + { + string commit = Environment.GetEnvironmentVariable("DX_PERF_COMMIT"); + if (!string.IsNullOrWhiteSpace(commit)) + { + return commit; + } + + commit = Environment.GetEnvironmentVariable("GITHUB_SHA"); + return string.IsNullOrWhiteSpace(commit) ? "local" : commit; + } + + private static string EscapeCsv(string value) + { + if (value == null) + { + return string.Empty; + } + + if (value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0) + { + return value; + } + + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + } +} +#endif diff --git a/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs.meta b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs.meta new file mode 100644 index 00000000..1559c65c --- /dev/null +++ b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19f8df4d3b2b45ae82b9046ab948ab3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef b/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef new file mode 100644 index 00000000..ee9095f3 --- /dev/null +++ b/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef @@ -0,0 +1,19 @@ +{ + "name": "WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks", + "rootNamespace": "DxMessaging.Tests.Runtime.Benchmarks", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "WallstopStudios.DxMessaging", + "WallstopStudios.DxMessaging.Tests.Runtime" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": true, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef.meta b/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef.meta new file mode 100644 index 00000000..15ee98cf --- /dev/null +++ b/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1bcfe9a78b25417e8f11876f0bb95d42 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs index 067bc4be..652671b0 100644 --- a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs @@ -137,7 +137,7 @@ MessageScenario scenario MessageRegistrationToken token = CreateEnabledToken(bus); MessageRegistrationHandle handle = RegisterFirst(scenario, token, DefaultContext); token.RemoveRegistration(handle); - EmitSweepProbe(bus); + EmitSweepSampleWindow(bus); Assert.GreaterOrEqual( bus.OccupiedTypeSlots + bus.OccupiedTargetSlots, @@ -147,7 +147,7 @@ MessageScenario scenario ); clock.Advance(1d); - EmitSweepProbe(bus); + EmitSweepSampleWindow(bus); Assert.AreEqual( 0, @@ -194,8 +194,7 @@ MessageScenario scenario try { clock.Advance(3600d); - EmitSweepProbe(bus); - EmitSweepProbe(bus); + EmitSweepSampleWindow(bus); EmitFirst(scenario, bus, DefaultContext); Assert.AreEqual( @@ -324,8 +323,7 @@ public void RuntimeSettingsHotReloadUpdatesTrimAndIdleGates() token.RemoveRegistration(handle); clock.Advance(60d); - EmitSweepProbe(bus); - EmitSweepProbe(bus); + EmitSweepSampleWindow(bus); Assert.GreaterOrEqual( bus.OccupiedTypeSlots, 1, @@ -339,8 +337,7 @@ public void RuntimeSettingsHotReloadUpdatesTrimAndIdleGates() DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); Assert.AreEqual(default(IMessageBus.TrimResult), bus.Trim(force: true)); - EmitSweepProbe(bus); - EmitSweepProbe(bus); + EmitSweepSampleWindow(bus); Assert.AreEqual( 0, bus.OccupiedTypeSlots, @@ -755,6 +752,15 @@ private static void EmitSweepProbe(MessageBus bus) bus.UntargetedBroadcast(ref message); } + private static void EmitSweepSampleWindow(MessageBus bus) + { + SweepProbeMessage message = new SweepProbeMessage(); + for (int i = 0; i <= MessageBus.SweepGateSampleSize; i++) + { + bus.UntargetedBroadcast(ref message); + } + } + private static ArgumentOutOfRangeException UnsupportedScenario(MessageScenario scenario) { return new ArgumentOutOfRangeException( diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 05ec2882..0a6d38ef 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 13,341,719 | No | -| UniRx MessageBroker | 18,003,801 | No | -| MessagePipe (Global) | 97,678,389 | No | -| Zenject SignalBus | 2,210,499 | Yes | +| DxMessaging (Untargeted) - No-Copy | 17,608,773 | No | +| UniRx MessageBroker | 17,906,940 | No | +| MessagePipe (Global) | 97,275,163 | No | +| Zenject SignalBus | 2,202,326 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index f5b45b2b..bf978e88 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -1,20 +1,180 @@ # Performance Benchmarks -This page is auto-updated by the Unity PlayMode benchmark tests in the [Performance PlayMode benchmark suite](https://github.com/wallstop/DxMessaging/blob/master/Tests/Runtime/Benchmarks/PerformanceTests.cs). +This page documents the T0 throughput benchmark policy and keeps the older +PlayMode benchmark tables for broad historical context. + +See also: [Performance optimizations](./design-and-architecture.md#performance-optimizations) +for design details. + +## T0 Benchmark Methodology + +The T0 harness measures raw dispatch throughput before any hot-path runtime +changes land. It is intentionally narrow: warm up each scenario, measure a +1-second window, repeat five times, and compare the median. Each row records: + +- Scenario name. +- Platform identity, including editor/player target, scripting backend, + architecture, build configuration, Unity platform, and Unity version. Common + cells include Editor Mono x64, Standalone Mono x64, and Standalone IL2CPP + x64. +- Commit SHA. +- Run index. +- Emits per second for dispatch scenarios. +- `GC.GetAllocatedBytesForCurrentThread()` delta. +- Wall-clock milliseconds for registration scenarios. + +Run the runtime benchmark category from a Unity 2021.3 LTS or newer editor +checkout: + +```bash +unity -batchmode -runTests -testPlatform playmode -testCategory "PerfBench" +``` + +Use the project-specific Unity executable path if `unity` is not on `PATH`. +Keep the editor version, scripting backend, CPU governor, and machine load +stable across before/after runs. Close the Unity editor UI before batchmode +runs so the benchmark process owns the editor session. + +The T0 scenarios cover these paths: + +| Scenario | What it measures | +| --------------------------------------------- | -------------------------------------------------------- | +| `UntargetedFlood_OneHandler` | One untargeted handler on one message type. | +| `UntargetedFlood_FourHandlers_OnePriority` | Four untargeted handlers sharing priority 0. | +| `UntargetedFlood_FourHandlers_FourPriorities` | Four untargeted handlers across priorities 0-3. | +| `TargetedFlood_OneListener` | One targeted listener on one target. | +| `TargetedFlood_SixteenListeners` | Sixteen targeted listeners on one target. | +| `BroadcastFlood_OneHandler` | One broadcast handler. | +| `InterceptorHeavy_FourInterceptors` | Four interceptors plus one handler. | +| `PostProcessingHeavy_FourPostProcessors` | Four post-processors plus one handler. | +| `RegistrationFlood_1000Types_FromColdBus` | Registering 1000 distinct message types from a cold bus. | + +## Baseline Capture + +Capture baselines into `progress/perf-baseline-2026-05-05.csv`. The baseline +file should be updated in a dedicated measurement commit, separate from runtime +changes. + +Required commit cells: + +| Commit | Purpose | +| --------- | -------------------------------------- | +| `25a4dcc` | Pre-GC parent baseline. | +| `29a5338` | First-pass garbage-collection landing. | +| `HEAD` | Current branch result. | + +Required configuration cells: + +| Configuration | Requirement | +| --------------------- | --------------------------------------------------- | +| Editor Mono | Required. | +| Standalone Mono x64 | Required when a Mono build host is available. | +| Standalone IL2CPP x64 | Stretch, required when CI has an IL2CPP build host. | + +For each commit and configuration: + +- Keep the T0 harness/worktree available; older runtime commits do not contain + the benchmark harness. +- Measure the older runtime with a harness-preserving flow. Use a throwaway + branch that cherry-picks the T0 harness onto the measured runtime commit, or + keep the harness branch checked out and swap only the runtime files being + measured. +- Set `DX_PERF_COMMIT=` for every benchmark run so + CSV rows identify the runtime commit under test. `DX_PERF_COMMIT` overrides + CI's `GITHUB_SHA` when both are present. +- Run the PlayMode `PerfBench` category in batchmode. +- Append the structured output to `progress/perf-baseline-2026-05-05.csv`. +- Record the exact commit, platform, Unity version, and scripting backend. + +Do not mix methodology changes with baseline updates. If the harness changes, +capture a new baseline and make the old/new methodology boundary explicit in +the PR description. + +## Budget Interpretation + +Dispatch budgets are interpreted in per-emit terms. Convert throughput to +nanoseconds per emit with: + +```text +ns_per_emit = 1,000,000,000 / emits_per_second +``` + +Compare both throughput and per-emit nanoseconds. Throughput is easier to scan, +but per-emit nanoseconds makes fixed overhead visible. A 10 ns increase is +material on handlers whose work is only 10-20 ns. + +Allocation budgets are interpreted as bytes allocated during the measured +window. Dispatch scenarios should stay at zero measured bytes after warmup. +Any non-zero allocation delta on a hot-path dispatch scenario requires an +explanation, a fix, or an explicit reviewer-approved exception. + +The opt-in smoke gate uses `progress/perf-baseline-2026-05-05.csv`, requires an +exact `25a4dcc` row for the current scenario and platform identity, and fails +when a within-platform regression exceeds the configured threshold. Enable it +with: + +```bash +DX_PERF_GATE=1 unity -batchmode -runTests -testPlatform editmode -testCategory "PerfGate" +``` + +The smoke gate is an EditMode test category. The T1 clock-read scaffold remains +`[Test, Explicit]` before T1.1, but it is not tagged `PerfGate` until the +sample-not-call sweep gate lands. +Before T0.3 baseline capture creates `progress/perf-baseline-2026-05-05.csv`, +the smoke gate reports an inconclusive skip instead of failing the suite for a +missing baseline file. + +## Hot-Path PR Rule + +Any pull request that touches one of these paths must include before/after T0 +numbers in the PR description under `### Performance numbers`: + +- `Runtime/Core/MessageBus/MessageBus.cs` +- `Runtime/Core/MessageHandler.cs` +- `Runtime/Core/Pooling/**` + +Use this shape: + +```markdown +### Performance numbers + +| Scenario | Baseline (commit 25a4dcc) | This PR | Delta | +| ---------------------------------------- | ------------------------- | ---------------- | ----- | +| UntargetedFlood_OneHandler (Mono Editor) | X.XX M emits/sec | Y.YY M emits/sec | +Z.Z% | +``` + +The workflow accepts either the table shape above with at least one populated +data row or one of these one-line `N/A` forms: + +```text +N/A - refactor only +N/A - non-hot-path edit only +``` + +The justification or description must be on the same line as the `N/A` marker. +Bare `N/A`, empty sections, and template-only comments do not satisfy the +workflow gate. + +## Historical PlayMode Benchmarks + +The sections below are auto-updated by the Unity PlayMode benchmark tests in +the [Performance PlayMode benchmark suite](https://github.com/wallstop/DxMessaging/blob/master/Tests/Editor/Benchmarks/PerformanceTests.cs). How it works: -- Run PlayMode tests locally in your Unity project that references this package. +- Run PlayMode tests locally in your Unity project that references this + package. - The benchmark test writes an OS-specific section below with a markdown table. - CI runs skip writing to avoid noisy diffs. -See also: [Performance optimizations](./design-and-architecture.md#performance-optimizations) for design details. - -## Benchmark Methodology and Caveats +### Benchmark Methodology and Caveats -These benchmarks measure raw message dispatch throughput using a simple counter-increment handler. Each test runs for 5 seconds, dispatching messages in batches of 10,000 operations per iteration with a pre-warm phase to avoid cold-start effects. +These older benchmarks measure raw message dispatch throughput using a simple +counter-increment handler. Each test runs for 5 seconds, dispatching messages in +batches of 10,000 operations per iteration with a pre-warm phase to avoid +cold-start effects. -### Important considerations +#### Important considerations - Results will vary based on your hardware, Unity version, and runtime environment. - The benchmarks test isolated message dispatch with minimal handler logic. Real-world performance depends heavily on what your handlers actually do. @@ -32,17 +192,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,475,429 | Yes | -| DxMessaging (GameObject) - Normal | 7,740,844 | No | -| DxMessaging (Component) - Normal | 7,843,782 | No | -| DxMessaging (GameObject) - No-Copy | 8,814,737 | No | -| DxMessaging (Component) - No-Copy | 7,295,605 | No | -| DxMessaging (Untargeted) - No-Copy | 13,221,685 | No | -| DxMessaging (Untargeted) - Interceptors | 6,416,263 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,197,189 | No | -| Reflexive (One Argument) | 2,595,323 | No | -| Reflexive (Two Arguments) | 2,119,428 | No | -| Reflexive (Three Arguments) | 2,132,994 | No | +| Unity | 2,482,148 | Yes | +| DxMessaging (GameObject) - Normal | 9,924,411 | No | +| DxMessaging (Component) - Normal | 10,046,274 | No | +| DxMessaging (GameObject) - No-Copy | 11,446,131 | No | +| DxMessaging (Component) - No-Copy | 8,699,593 | No | +| DxMessaging (Untargeted) - No-Copy | 17,715,656 | No | +| DxMessaging (Untargeted) - Interceptors | 7,405,709 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,971,936 | No | +| Reflexive (One Argument) | 2,740,141 | No | +| Reflexive (Two Arguments) | 2,278,031 | No | +| Reflexive (Three Arguments) | 2,267,391 | No | ## macOS diff --git a/llms.txt b/llms.txt index 4f3c7056..3296214b 100644 --- a/llms.txt +++ b/llms.txt @@ -208,7 +208,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 146+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 148+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** From d6a33cbdda2be22bf3fca74ed950f66cb5ce9340 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Mon, 4 May 2026 21:38:33 -0700 Subject: [PATCH 04/16] Update dictionary --- .cspell.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index f990f692..cee8ee46 100644 --- a/.cspell.json +++ b/.cspell.json @@ -259,7 +259,10 @@ "unmatch", "unstubbed", "unparseable", - "entrancy" + "entrancy", + "devirtualization", + "SIGSEGV", + "devirtualize" ], "ignoreRegExpList": [ "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g", From 83501f66f7a502a5b22936f5ed5dffd9ea4be368 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Tue, 5 May 2026 18:10:27 -0700 Subject: [PATCH 05/16] Migration to smarter dev container --- .cspell.json | 8 +- .devcontainer/Dockerfile | 132 ++- .devcontainer/cache-contract.sh | 79 ++ .devcontainer/devcontainer-lock.json | 5 + .devcontainer/devcontainer.json | 100 +- .devcontainer/install-codex-cli.sh | 81 ++ .devcontainer/post-create.sh | 352 ++++++- .devcontainer/post-start.sh | 64 ++ .devcontainer/validate-caching.sh | 317 ++++++ .devcontainer/verify-tools.sh | 4 +- .github/workflows/devcontainer-prebuild.yml | 118 +++ .github/workflows/devcontainer-test.yml | 142 +++ .github/workflows/unity-benchmarks.yml | 182 ++++ .github/workflows/unity-il2cpp.yml | 241 +++++ .github/workflows/unity-tests.yml | 201 ++++ .gitignore | 33 +- .llm/context.md | 29 + .../cicd-devcontainer-workflows.md | 205 ++++ .llm/skills/index.md | 19 +- .../unity/devcontainer-cache-contract.md | 179 ++++ .llm/skills/unity/headless-test-runner.md | 223 ++++ .llm/skills/unity/unity-ci-matrix.md | 192 ++++ .llm/skills/unity/unity-license-bootstrap.md | 215 ++++ .../skills/unity/unity-perf-test-isolation.md | 210 ++++ .llm/skills/unity/upm-test-harness.md | 206 ++++ .npmignore | 6 +- .pre-commit-config.yaml | 27 + .unity-test-project/Assets/Editor.meta | 8 + .../Assets/Editor/TestRunnerBuilder.cs | 128 +++ .../Assets/Editor/TestRunnerBuilder.cs.meta | 11 + ...dios.DxMessaging.TestHarness.Editor.asmdef | 14 + ...DxMessaging.TestHarness.Editor.asmdef.meta | 7 + .unity-test-project/Packages/manifest.json | 11 + .../Packages/packages-lock.json | 293 ++++++ .../ProjectSettings/ProjectVersion.txt | 2 + CHANGELOG.md | 3 +- Runtime/Core/Internal/TypedSlots.cs | 28 +- Runtime/Core/MessageBus/IMessageBus.cs | 5 + Runtime/Core/MessageBus/Internal/BusSlots.cs | 16 +- Runtime/Core/MessageBus/MessageBus.cs | 30 +- Runtime/Core/MessageHandler.cs | 266 ++++- Runtime/Core/Pooling/IDxMessagingClock.cs | 4 +- Runtime/Core/Pooling/StopwatchClock.cs | 7 +- Runtime/Core/Pooling/UnityRealtimeClock.cs | 15 +- .../Allocations/EmitGateClockReadIsRare.cs | 1 + ...xMessaging.Tests.Editor.Allocations.asmdef | 4 - ...essaging.Tests.00.Editor.Benchmarks.asmdef | 6 +- ...ssaging.Tests.00.Editor.Comparisons.asmdef | 13 +- .../Contract/TypedSlotIndexCoverageTests.cs | 10 +- Tests/Editor/Contract/TypedSlotShapeTests.cs | 17 +- .../MemoryReclaim/MemoryReclamationTests.cs | 887 ++++++++++++++++ ...opStudios.DxMessaging.Tests.Runtime.asmdef | 9 +- docs/architecture/comparisons.md | 8 +- docs/architecture/performance.md | 22 +- llms.txt | 4 +- .../claude-permissions-contract.test.js | 113 ++ .../claude-permissions-contract.test.js.meta | 7 + .../devcontainer-cache-contract.test.js | 144 +++ .../devcontainer-cache-contract.test.js.meta | 7 + .../llm-skills-unity-coverage.test.js | 118 +++ .../llm-skills-unity-coverage.test.js.meta | 7 + .../__tests__/unity-perf-isolation.test.js | 149 +++ .../unity-perf-isolation.test.js.meta | 7 + .../unity-runner-script-contract.test.js | 273 +++++ .../unity-runner-script-contract.test.js.meta | 7 + .../unity-test-harness-contract.test.js | 215 ++++ .../unity-test-harness-contract.test.js.meta | 7 + .../__tests__/unity-workflow-shape.test.js | 179 ++++ .../unity-workflow-shape.test.js.meta | 7 + scripts/__tests__/validate-npm-meta.test.js | 988 +++++++++++------- scripts/unity.meta | 8 + scripts/unity/activate-license.sh | 285 +++++ scripts/unity/activate-license.sh.meta | 7 + scripts/unity/lib.meta | 8 + scripts/unity/lib/asmdef-discovery.js | 303 ++++++ scripts/unity/lib/asmdef-discovery.js.meta | 7 + scripts/unity/lib/parse-test-results.py | 61 ++ scripts/unity/lib/parse-test-results.py.meta | 7 + scripts/unity/run-tests.ps1 | 639 +++++++++++ scripts/unity/run-tests.ps1.meta | 7 + scripts/unity/run-tests.sh | 699 +++++++++++++ scripts/unity/run-tests.sh.meta | 7 + scripts/validate-npm-meta.js | 518 +++++---- 83 files changed, 9430 insertions(+), 753 deletions(-) create mode 100644 .devcontainer/cache-contract.sh create mode 100644 .devcontainer/install-codex-cli.sh create mode 100644 .devcontainer/post-start.sh create mode 100644 .devcontainer/validate-caching.sh create mode 100644 .github/workflows/devcontainer-prebuild.yml create mode 100644 .github/workflows/devcontainer-test.yml create mode 100644 .github/workflows/unity-benchmarks.yml create mode 100644 .github/workflows/unity-il2cpp.yml create mode 100644 .github/workflows/unity-tests.yml create mode 100644 .llm/skills/github-actions/cicd-devcontainer-workflows.md create mode 100644 .llm/skills/unity/devcontainer-cache-contract.md create mode 100644 .llm/skills/unity/headless-test-runner.md create mode 100644 .llm/skills/unity/unity-ci-matrix.md create mode 100644 .llm/skills/unity/unity-license-bootstrap.md create mode 100644 .llm/skills/unity/unity-perf-test-isolation.md create mode 100644 .llm/skills/unity/upm-test-harness.md create mode 100644 .unity-test-project/Assets/Editor.meta create mode 100644 .unity-test-project/Assets/Editor/TestRunnerBuilder.cs create mode 100644 .unity-test-project/Assets/Editor/TestRunnerBuilder.cs.meta create mode 100644 .unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef create mode 100644 .unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef.meta create mode 100644 .unity-test-project/Packages/manifest.json create mode 100644 .unity-test-project/Packages/packages-lock.json create mode 100644 .unity-test-project/ProjectSettings/ProjectVersion.txt create mode 100644 scripts/__tests__/claude-permissions-contract.test.js create mode 100644 scripts/__tests__/claude-permissions-contract.test.js.meta create mode 100644 scripts/__tests__/devcontainer-cache-contract.test.js create mode 100644 scripts/__tests__/devcontainer-cache-contract.test.js.meta create mode 100644 scripts/__tests__/llm-skills-unity-coverage.test.js create mode 100644 scripts/__tests__/llm-skills-unity-coverage.test.js.meta create mode 100644 scripts/__tests__/unity-perf-isolation.test.js create mode 100644 scripts/__tests__/unity-perf-isolation.test.js.meta create mode 100644 scripts/__tests__/unity-runner-script-contract.test.js create mode 100644 scripts/__tests__/unity-runner-script-contract.test.js.meta create mode 100644 scripts/__tests__/unity-test-harness-contract.test.js create mode 100644 scripts/__tests__/unity-test-harness-contract.test.js.meta create mode 100644 scripts/__tests__/unity-workflow-shape.test.js create mode 100644 scripts/__tests__/unity-workflow-shape.test.js.meta create mode 100644 scripts/unity.meta create mode 100644 scripts/unity/activate-license.sh create mode 100644 scripts/unity/activate-license.sh.meta create mode 100644 scripts/unity/lib.meta create mode 100644 scripts/unity/lib/asmdef-discovery.js create mode 100644 scripts/unity/lib/asmdef-discovery.js.meta create mode 100644 scripts/unity/lib/parse-test-results.py create mode 100644 scripts/unity/lib/parse-test-results.py.meta create mode 100644 scripts/unity/run-tests.ps1 create mode 100644 scripts/unity/run-tests.ps1.meta create mode 100644 scripts/unity/run-tests.sh create mode 100644 scripts/unity/run-tests.sh.meta diff --git a/.cspell.json b/.cspell.json index cee8ee46..9110a204 100644 --- a/.cspell.json +++ b/.cspell.json @@ -262,7 +262,13 @@ "entrancy", "devirtualization", "SIGSEGV", - "devirtualize" + "devirtualize", + "unityci", + "cicd", + "integ", + "Integ", + "jlumbroso", + "kubepods" ], "ignoreRegExpList": [ "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g", diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index dc9b4c8c..7b20ccab 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,10 @@ +# syntax=docker/dockerfile:1.4 + # DxMessaging Development Container # Base image: .NET 9.0 on Debian Bookworm +# Note: .NET 10 SDK is installed alongside .NET 9 to support C# Dev Kit. +# Note: docker-outside-of-docker (DooD) is enabled via devcontainer feature so +# Phase 2+ can spawn ephemeral unityci/editor containers from inside. FROM mcr.microsoft.com/devcontainers/dotnet:1-9.0-bookworm # Build argument for target architecture (amd64 or arm64) @@ -9,6 +14,8 @@ ARG TARGETARCH=amd64 LABEL org.opencontainers.image.source="https://github.com/wallstop/DxMessaging" LABEL org.opencontainers.image.description="DxMessaging development container" LABEL org.opencontainers.image.licenses="MIT" +LABEL unity.support="docker-outside-of-docker" +LABEL unity.note="Unity tests spawn ephemeral unityci/editor containers via DooD" # Environment variables ENV DEBIAN_FRONTEND=noninteractive @@ -19,30 +26,57 @@ ENV DOTNET_NOLOGO=1 # Fix expired Yarn GPG key issue # The base image includes the Yarn repository which has an expired signing key. # Remove it since this project doesn't require Yarn. +# This must run BEFORE the first apt-get update so feature installation does +# not fail on the expired signature. # ------------------------------------------------------------------------------ -RUN rm -f /etc/apt/sources.list.d/yarn.list \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/sources.list.d/yarn.list \ && rm -f /usr/share/keyrings/yarn-keyring.gpg \ - && rm -f /etc/apt/keyrings/yarn.gpg + && rm -f /etc/apt/keyrings/yarn.gpg \ + && rm -f /usr/share/keyrings/yarn.gpg \ + && rm -f /etc/apt/trusted.gpg.d/yarn.gpg \ + && find /etc/apt/sources.list.d/ -name "*yarn*" -delete || true # Install essential APT packages -RUN apt-get update && apt-get install -y --no-install-recommends \ - ripgrep \ - jq \ - htop \ - ncdu \ - tree \ - tldr \ - silversearcher-ag \ - moreutils \ - unzip \ - wget \ - ca-certificates \ - shellcheck \ - curl \ - gnupg \ - apt-transport-https \ - software-properties-common \ - && rm -rf /var/lib/apt/lists/* +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + ripgrep \ + jq \ + htop \ + ncdu \ + tree \ + tldr \ + silversearcher-ag \ + moreutils \ + unzip \ + wget \ + ca-certificates \ + shellcheck \ + curl \ + gnupg \ + apt-transport-https \ + software-properties-common \ + && apt-get autoremove -y + +# ------------------------------------------------------------------------------ +# Install .NET 10 SDK side-by-side (required by C# Dev Kit extension) +# Unity is targeting .NET Standard 2.1 / .NET 9, so .NET 9 SDK remains primary. +# We install .NET 10 SDK + runtime via the official dotnet-install.sh into a +# shared location so both vscode and root see it on PATH. +# ------------------------------------------------------------------------------ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + --mount=type=tmpfs,target=/tmp \ + apt-get update \ + && apt-get -y install --no-install-recommends \ + wget \ + ca-certificates \ + && wget https://dot.net/v1/dotnet-install.sh -O /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \ + && apt-get autoremove -y # Create a temporary directory for downloads WORKDIR /tmp/tools @@ -301,32 +335,38 @@ RUN set -eux; \ # Install PowerShell # https://docs.microsoft.com/en-us/powershell/scripting/install/install-debian # ------------------------------------------------------------------------------ -RUN set -eux; \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + set -eux; \ wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O packages-microsoft-prod.deb; \ dpkg -i packages-microsoft-prod.deb; \ rm packages-microsoft-prod.deb; \ apt-get update; \ apt-get install -y powershell; \ - rm -rf /var/lib/apt/lists/*; \ + apt-get autoremove -y; \ pwsh --version # ------------------------------------------------------------------------------ # Install Git LFS # ------------------------------------------------------------------------------ -RUN set -eux; \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + set -eux; \ apt-get update; \ apt-get install -y git-lfs; \ - git lfs install; \ - rm -rf /var/lib/apt/lists/*; \ + git lfs install --system; \ + apt-get autoremove -y; \ git lfs version # ------------------------------------------------------------------------------ # Install Python3, pip, and venv # ------------------------------------------------------------------------------ -RUN set -eux; \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + set -eux; \ apt-get update; \ apt-get install -y python3 python3-pip python3-venv; \ - rm -rf /var/lib/apt/lists/*; \ + apt-get autoremove -y; \ python3 --version; \ pip3 --version @@ -334,10 +374,12 @@ RUN set -eux; \ # Install Node.js LTS via NodeSource # https://github.com/nodesource/distributions # ------------------------------------------------------------------------------ -RUN set -eux; \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + set -eux; \ curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -; \ apt-get install -y nodejs; \ - rm -rf /var/lib/apt/lists/*; \ + apt-get autoremove -y; \ node --version; \ npm --version @@ -354,6 +396,38 @@ RUN pip3 install --no-cache-dir yamllint --break-system-packages && yamllint --v # ------------------------------------------------------------------------------ RUN pip3 install --no-cache-dir pre-commit --break-system-packages && pre-commit --version +# ------------------------------------------------------------------------------ +# Configure Git Defaults for Unity-style Repositories +# ------------------------------------------------------------------------------ +# Optimize git for Unity projects with large binary assets and Windows path +# lengths. These mirror the Shiro reference container. +# ------------------------------------------------------------------------------ +RUN git config --system core.longpaths true \ + && git config --system core.preloadindex true \ + && git config --system gc.auto 256 \ + && git config --system lfs.concurrenttransfers 10 \ + && git config --system pack.windowMemory 256m \ + && git config --system pack.deltaCacheSize 128m + +# ------------------------------------------------------------------------------ +# Pre-create volume mount targets with vscode:vscode ownership +# ------------------------------------------------------------------------------ +# Docker named volumes copy permissions/contents from the image ONLY on first +# attach (when the volume is empty). Pre-creating these directories with the +# correct ownership ensures the vscode user owns them on first run; rebuilds +# of an existing volume are repaired by post-create.sh / post-start.sh. +# +# Targets here MUST stay aligned with cache-contract.sh (4 entries). +# ------------------------------------------------------------------------------ +RUN mkdir -p /home/vscode/.nuget \ + && mkdir -p /home/vscode/.dotnet/tools \ + && mkdir -p /home/vscode/.local/share/powershell \ + && mkdir -p /home/vscode/.cache/pip \ + && chown -R vscode:vscode /home/vscode/.nuget \ + && chown -R vscode:vscode /home/vscode/.dotnet \ + && chown -R vscode:vscode /home/vscode/.local \ + && chown -R vscode:vscode /home/vscode/.cache + # ------------------------------------------------------------------------------ # Configure shell aliases for bash # ------------------------------------------------------------------------------ diff --git a/.devcontainer/cache-contract.sh b/.devcontainer/cache-contract.sh new file mode 100644 index 00000000..ca4dfe90 --- /dev/null +++ b/.devcontainer/cache-contract.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +# Shared devcontainer cache mount contract. +# Keep these arrays aligned by index: source[i] mounts to target[i]. +# +# Entries: +# 1. dxm-nuget-cache -> NuGet package cache for .NET restore +# 2. dxm-dotnet-tools -> Global dotnet tools (csharpier, etc.) +# 3. dxm-powershell-modules -> PowerShell module cache +# 4. dxm-python-cache -> pip wheel/download cache +# +# Unity Library caches are owned by scripts/unity/run-tests.sh and +# scripts/unity/run-tests.ps1 because they must be keyed by Unity image tag and +# test mode. Do not add a static .unity-test-project/Library mount here. + +# Re-source guard: this file is sourced by post-create.sh, post-start.sh, +# validate-caching.sh, and (in Phase 4) the contract test harness. Multiple +# sources in the same shell would otherwise re-declare the readonly arrays +# and abort under `set -e`. +[[ "${_DXM_CACHE_CONTRACT_LOADED:-}" == "1" ]] && return 0 +_DXM_CACHE_CONTRACT_LOADED=1 + +readonly CACHE_MOUNT_SOURCES=( + "dxm-nuget-cache" + "dxm-dotnet-tools" + "dxm-powershell-modules" + "dxm-python-cache" +) + +readonly CACHE_MOUNT_TARGETS=( + "/home/vscode/.nuget" + "/home/vscode/.dotnet/tools" + "/home/vscode/.local/share/powershell" + "/home/vscode/.cache/pip" +) + +cache_contract_validate_shape() { + if [[ "${#CACHE_MOUNT_SOURCES[@]}" -eq 0 ]] \ + || [[ "${#CACHE_MOUNT_TARGETS[@]}" -eq 0 ]] \ + || [[ "${#CACHE_MOUNT_SOURCES[@]}" -ne "${#CACHE_MOUNT_TARGETS[@]}" ]]; then + return 1 + fi + + return 0 +} + +cache_contract_get_owner_uid() { + local target="$1" + local owner_uid + + if owner_uid="$(stat -c %u "$target" 2>/dev/null)" && [[ "$owner_uid" =~ ^[0-9]+$ ]]; then + echo "$owner_uid" + return 0 + fi + + if owner_uid="$(stat -f %u "$target" 2>/dev/null)" && [[ "$owner_uid" =~ ^[0-9]+$ ]]; then + echo "$owner_uid" + return 0 + fi + + return 1 +} + +cache_contract_is_container_runtime() { + if [[ -f "/.dockerenv" ]]; then + return 0 + fi + + if [[ "${DEVCONTAINER:-}" == "true" ]] || [[ "${REMOTE_CONTAINERS:-}" == "true" ]]; then + return 0 + fi + + if grep -qaE '(docker|containerd|kubepods)' /proc/1/cgroup 2>/dev/null; then + return 0 + fi + + return 1 +} diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 39ba5182..ab46a8b6 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -5,6 +5,11 @@ "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4", "integrity": "sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4" }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "1.9.1", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:dc89605f01ff2f24252c61f7c8ba2a58ccdbc14f2ebf87a7952d9e2b89834850", + "integrity": "sha256:dc89605f01ff2f24252c61f7c8ba2a58ccdbc14f2ebf87a7952d9e2b89834850" + }, "ghcr.io/devcontainers/features/github-cli:1": { "version": "1.1.0", "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 52a9a057..4eb5ace5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,35 +1,125 @@ +// ============================================================================= +// DxMessaging Development Container Configuration +// ============================================================================= +// For format details, see https://aka.ms/devcontainer.json +// Project: DxMessaging | Language: C# (.NET 9 / .NET 10 SDKs side-by-side) +// Phase 1: docker-outside-of-docker (DooD) feature added so Phase 2+ can spawn +// ephemeral unityci/editor containers from inside this devcontainer. +// ============================================================================= { "name": "DxMessaging Dev", + // ------------------------------------------------------------------------- + // Build Configuration + // ------------------------------------------------------------------------- "build": { "dockerfile": "Dockerfile", "context": ".." }, + // ------------------------------------------------------------------------- + // Dev Container Features + // ------------------------------------------------------------------------- "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": true, "configureZshAsDefaultShell": false, "upgradePackages": false }, - "ghcr.io/devcontainers/features/github-cli:1": {} + "ghcr.io/devcontainers/features/github-cli:1": {}, + // Docker-outside-of-docker: installs the docker CLI inside the + // container and mounts /var/run/docker.sock from the host so we can + // spawn ephemeral unityci/editor containers in Phase 2+. + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "latest", + "moby": true + } }, + // ------------------------------------------------------------------------- + // Mount Configuration + // ------------------------------------------------------------------------- + // Volume names are deterministic (no ${devcontainerId}) so they survive + // rebuilds and are shared across all dev container instances of this + // project. Keep these aligned with .devcontainer/cache-contract.sh. + "mounts": [ + "source=dxm-nuget-cache,target=/home/vscode/.nuget,type=volume", + "source=dxm-dotnet-tools,target=/home/vscode/.dotnet/tools,type=volume", + "source=dxm-powershell-modules,target=/home/vscode/.local/share/powershell,type=volume", + "source=dxm-python-cache,target=/home/vscode/.cache/pip,type=volume" + ], + // ------------------------------------------------------------------------- + // Container Environment + // ------------------------------------------------------------------------- "containerEnv": { "DOTNET_CLI_TELEMETRY_OPTOUT": "1", "DOTNET_NOLOGO": "1", "NPM_CONFIG_PREFIX": "/home/vscode/.local" }, + // Forward host-side env vars into the container. Use ${localEnv:VAR} so + // the host value (or empty string) is captured at attach time. + "remoteEnv": { + "WORKSPACE_FOLDER": "${containerWorkspaceFolder}", + "UNITY_PROJECT_PATH": "${containerWorkspaceFolder}", + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}", + "GH_TOKEN": "${localEnv:GH_TOKEN}", + "UNITY_LICENSE": "${localEnv:UNITY_LICENSE}", + "UNITY_LICENSE_B64": "${localEnv:UNITY_LICENSE_B64}", + "UNITY_SERIAL": "${localEnv:UNITY_SERIAL}", + "UNITY_EMAIL": "${localEnv:UNITY_EMAIL}", + "UNITY_PASSWORD": "${localEnv:UNITY_PASSWORD}", + "UNITY_VERSION": "${localEnv:UNITY_VERSION}", + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + // ------------------------------------------------------------------------- + // Lifecycle Commands + // ------------------------------------------------------------------------- "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", + "waitFor": "postCreateCommand", + // ------------------------------------------------------------------------- + // Port Forwarding + // ------------------------------------------------------------------------- + "forwardPorts": [ + // Unity Profiler + 34999, + // Unity Remote debugging + 56000, 56001 + ], + // ------------------------------------------------------------------------- + // Host Requirements + // ------------------------------------------------------------------------- + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + // ------------------------------------------------------------------------- + // Container User + // ------------------------------------------------------------------------- + "remoteUser": "vscode", + // ------------------------------------------------------------------------- + // VS Code Customizations + // ------------------------------------------------------------------------- "customizations": { "vscode": { "extensions": [ + // ----- C# / .NET Development ----- "ms-dotnettools.csharp", "ms-dotnettools.csdevkit", "csharpier.csharpier-vscode", + "josefpihrt.roslynator", + // ----- Unity Development ----- + "visualstudiotoolsforunity.vstuc", + // ----- Code Quality & Linting ----- "editorconfig.editorconfig", + "streetsidesoftware.code-spell-checker", + // ----- AI / LLM Integration ----- "GitHub.copilot", "GitHub.copilot-chat", "anthropic.claude-code", "openai.chatgpt", - "streetsidesoftware.code-spell-checker", + // ----- CI/CD & Data ----- + "github.vscode-github-actions", + "redhat.vscode-yaml", + // ----- Markdown ----- "DavidAnson.vscode-markdownlint", "yzhang.markdown-all-in-one" ], @@ -43,6 +133,8 @@ "terminal.integrated.suggest.quickSuggestions": true, "terminal.integrated.suggest.suggestOnTriggerCharacters": true, "dotnet.defaultSolution": "disable", + "dotnet.suppressDotnetRestoreNotification": true, + "dotnet.backgroundAnalysis.analyzerDiagnosticsScope": "openFiles", "editor.formatOnSave": true, "editor.defaultFormatter": "csharpier.csharpier-vscode", "[csharp]": { @@ -50,7 +142,5 @@ } } } - }, - "forwardPorts": [], - "remoteUser": "vscode" + } } diff --git a/.devcontainer/install-codex-cli.sh b/.devcontainer/install-codex-cli.sh new file mode 100644 index 00000000..6b5dbb85 --- /dev/null +++ b/.devcontainer/install-codex-cli.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# ============================================================================= +# install-codex-cli.sh +# ----------------------------------------------------------------------------- +# Idempotently install the latest @openai/codex CLI as a user-global npm +# package. Designed to be invoked from post-start.sh on every container start. +# +# Behavior: +# * Resolves the registry's `latest` dist-tag with a bounded timeout. +# * Skips if the installed version already matches `latest`. +# * Installs into NPM_CONFIG_PREFIX (= /home/vscode/.local) — no sudo needed. +# * Retries up to 3 times with backoff on transient failures. +# * Never fails the caller: degrades gracefully when offline or when the +# registry is unreachable, keeping any previously-installed version. +# ============================================================================= + +set -euo pipefail + +PKG="@openai/codex" +NPM_PREFIX="${NPM_CONFIG_PREFIX:-${HOME}/.local}" +LOG_PREFIX="[install-codex]" + +log() { echo "${LOG_PREFIX} $*"; } +warn() { echo "${LOG_PREFIX} WARN: $*" >&2; } + +if ! command -v npm >/dev/null 2>&1; then + warn "npm not found; skipping ${PKG} install." + exit 0 +fi + +export PATH="${NPM_PREFIX}/bin:${PATH}" + +# ---- read currently-installed version (cheap, offline) ---------------------- +installed="" +pkg_json="${NPM_PREFIX}/lib/node_modules/@openai/codex/package.json" +if [[ -f "${pkg_json}" ]]; then + if command -v jq >/dev/null 2>&1; then + installed="$(jq -r '.version // empty' "${pkg_json}" 2>/dev/null || true)" + else + installed="$(grep -m1 '"version"' "${pkg_json}" \ + | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' \ + || true)" + fi +fi + +# ---- resolve `latest` from the registry (bounded) --------------------------- +latest="$(timeout 20 npm view "${PKG}" version 2>/dev/null | tr -d '[:space:]' || true)" + +if [[ -z "${latest}" ]]; then + if [[ -n "${installed}" ]]; then + log "registry unreachable; keeping installed ${PKG}@${installed}." + else + warn "registry unreachable and ${PKG} not installed; will retry next start." + fi + exit 0 +fi + +if [[ "${installed}" == "${latest}" ]]; then + log "${PKG}@${installed} already up-to-date." + exit 0 +fi + +log "Installing ${PKG}@${latest} (previously: ${installed:-not installed})..." + +for attempt in 1 2 3; do + if timeout 180 npm install -g "${PKG}@${latest}" \ + --silent --no-fund --no-audit; then + if command -v codex >/dev/null 2>&1; then + log "${PKG} ready: $(codex --version 2>/dev/null | head -n1 || echo "${latest}")" + exit 0 + fi + warn "codex binary missing from PATH after install attempt ${attempt}/3." + else + warn "npm install failed (attempt ${attempt}/3)." + fi + sleep "$((attempt * 2))" +done + +warn "failed to install ${PKG} after 3 attempts; continuing without it." +exit 0 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 29f93239..813b6a80 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,28 +1,64 @@ #!/usr/bin/env bash -# Post-create bootstrap for the DxMessaging devcontainer. +# shellcheck shell=bash +# ============================================================================= +# DxMessaging Devcontainer - Post-Create Bootstrap +# ============================================================================= +# Runs once after the devcontainer is created. Performs initial setup and +# validation of the development environment. +# ============================================================================= set -euo pipefail -BLUE='\033[0;34m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +LOG_PREFIX="[post-create]" + +if [[ -t 1 ]]; then + BLUE='\033[0;34m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + NC='\033[0m' +else + BLUE='' + GREEN='' + YELLOW='' + RED='' + NC='' +fi + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- log_info() { - echo -e "${BLUE}[INFO]${NC} $1" + echo -e "${BLUE}${LOG_PREFIX}${NC} $1" } log_success() { - echo -e "${GREEN}[OK]${NC} $1" + echo -e "${GREEN}${LOG_PREFIX} ✓${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}${LOG_PREFIX} ⚠${NC} $1" } -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" +log_error() { + echo -e "${RED}${LOG_PREFIX} ✗${NC} $1" >&2 +} + +log_header() { + echo "" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" } fail() { - echo -e "${RED}[ERROR]${NC} $1" >&2 + log_error "$1" exit 1 } @@ -34,12 +70,15 @@ run_optional() { if "$@"; then log_success "$label completed" else - log_warn "$label failed (continuing)" + log_warning "$label failed (continuing)" fi } ensure_path_line() { local rc_file="$1" + # The $HOME literal is intentional — it must be written to the rc file + # unexpanded so it expands in the user's shell at source time. + # shellcheck disable=SC2016 local path_line='export PATH="$HOME/.local/bin:$PATH"' if [[ ! -f "$rc_file" ]]; then @@ -55,45 +94,272 @@ ensure_path_line() { fi } -trap 'fail "post-create setup failed at line $LINENO"' ERR +# ----------------------------------------------------------------------------- +# Source the cache contract (FATAL if missing — same pattern as Shiro) +# ----------------------------------------------------------------------------- +if [[ ! -f "${SCRIPT_DIR}/cache-contract.sh" ]]; then + fail "cache-contract.sh not found at ${SCRIPT_DIR}/cache-contract.sh" +fi -log_info "Starting post-create setup" +# shellcheck source=.devcontainer/cache-contract.sh +source "${SCRIPT_DIR}/cache-contract.sh" || fail "failed to source cache-contract.sh" -mkdir -p "$HOME/.local/bin" +# ----------------------------------------------------------------------------- +# Volume Mount Permissions +# ----------------------------------------------------------------------------- -log_info "Configuring npm global prefix for non-root installs" -npm config set prefix "$HOME/.local" +fix_volume_permissions() { + log_header "Fixing Volume Mount Permissions" -current_prefix="$(npm config get prefix)" -if [[ "$current_prefix" != "$HOME/.local" ]]; then - fail "npm prefix is '$current_prefix', expected '$HOME/.local'" -fi -log_success "npm prefix configured: $current_prefix" + if ! cache_contract_validate_shape; then + log_error "Cache mount contract is invalid (sources/targets length mismatch)." + return 1 + fi -# Make codex immediately available in this session, and persist for future shells. -export PATH="$HOME/.local/bin:$PATH" -ensure_path_line "$HOME/.bashrc" -ensure_path_line "$HOME/.zshrc" + local current_uid + local current_gid + current_uid="$(id -u)" + current_gid="$(id -g)" -workspace_dir="${containerWorkspaceFolder:-$PWD}" + # Docker named volumes only inherit ownership from image content on first + # attach. Existing named volumes may be root-owned after rebuilds, so + # verify and fix each mount target. + for i in "${!CACHE_MOUNT_TARGETS[@]}"; do + local source_name="${CACHE_MOUNT_SOURCES[$i]}" + local target_dir="${CACHE_MOUNT_TARGETS[$i]}" -run_optional "Restoring .NET local tools" dotnet tool restore -run_optional "Installing workspace npm dependencies" npm install -run_optional "Configuring git safe.directory" git config --global --add safe.directory "$workspace_dir" -run_optional "Updating tldr cache" tldr --update -run_optional "Installing pre-commit hooks" pre-commit install --install-hooks + mkdir -p "${target_dir}" 2>/dev/null || true -log_info "Installing Codex CLI" -npm install -g --prefix "$HOME/.local" @openai/codex@latest + local owner_uid + owner_uid="$(cache_contract_get_owner_uid "${target_dir}" 2>/dev/null || echo "unknown")" + if [[ "${owner_uid}" != "${current_uid}" ]]; then + log_info "Fixing ownership of ${target_dir} (source=${source_name}, owner=${owner_uid}, expected=${current_uid})..." + if sudo chown -R "${current_uid}:${current_gid}" "${target_dir}" 2>/dev/null; then + owner_uid="$(cache_contract_get_owner_uid "${target_dir}" 2>/dev/null || echo "unknown")" + else + log_warning "Could not fix ownership of ${target_dir}" + fi + fi -if ! command -v codex >/dev/null 2>&1; then - fail "Codex CLI was installed but is not on PATH" -fi + if [[ "${owner_uid}" == "${current_uid}" ]]; then + log_success "${target_dir} ownership OK (source=${source_name}, uid=${owner_uid})" + else + log_error "${target_dir} ownership remains ${owner_uid} (expected ${current_uid}); sudo chown appears to have failed silently" + fi + done +} -codex_version="$(codex --version 2>/dev/null || true)" -if [[ -z "$codex_version" ]]; then - fail "Codex CLI did not return a version" -fi +# ----------------------------------------------------------------------------- +# Docker Socket Verification (warn-only — DooD is optional for .NET-only flow) +# ----------------------------------------------------------------------------- + +verify_docker_socket() { + log_header "Verifying Docker Socket (DooD)" + + if ! command -v docker >/dev/null 2>&1; then + log_warning "docker CLI not found in container." + log_warning " Remediation: ensure host Docker is running and the devcontainer was built" + log_warning " with the 'docker-outside-of-docker' feature enabled (see devcontainer.json)." + return 0 + fi + + if docker info >/dev/null 2>&1; then + log_success "Docker socket reachable; Phase 2+ Unity test runner can spawn containers." + else + log_warning "docker info failed — socket not accessible from inside the container." + log_warning " Remediation: ensure host Docker is running and the devcontainer was built" + log_warning " with the 'docker-outside-of-docker' feature enabled (see devcontainer.json)." + log_warning " .NET-only workflows will continue; Unity test workflows will fail in Phase 2+." + fi + + return 0 +} + +# ----------------------------------------------------------------------------- +# .NET Configuration +# ----------------------------------------------------------------------------- + +validate_dotnet() { + log_header "Validating .NET SDKs" + + mkdir -p "${HOME}/.dotnet/tools" + + if ! command -v dotnet >/dev/null 2>&1; then + log_error ".NET SDK not found!" + return 1 + fi + + local dotnet_version + dotnet_version="$(dotnet --version 2>/dev/null || echo unknown)" + log_success "Active .NET SDK: ${dotnet_version}" + + log_info "Installed .NET SDKs:" + dotnet --list-sdks | while read -r line; do + echo " $line" + done + + if dotnet --list-sdks | grep -q "^9\.[0-9]\+\."; then + log_success ".NET 9 SDK found" + else + log_warning ".NET 9 SDK not detected (expected from base image)." + fi + + if dotnet --list-sdks | grep -q "^10\.[0-9]\+\."; then + log_success ".NET 10 SDK found" + else + log_error ".NET 10 SDK not detected (C# Dev Kit requires it)." + return 1 + fi + + return 0 +} + +# ----------------------------------------------------------------------------- +# Workspace Validation (UPM package — no Assets/ or ProjectSettings/ at root) +# ----------------------------------------------------------------------------- + +validate_workspace() { + log_header "Validating Workspace" + + cd "${WORKSPACE_DIR}" + + log_info "Checking DxMessaging UPM package structure..." + + local checks_passed=0 + local checks_total=0 + + ((++checks_total)) + if [[ -f "package.json" ]]; then + log_success "package.json found" + ((++checks_passed)) + else + log_warning "package.json not found" + fi + + ((++checks_total)) + if [[ -d "Editor" ]]; then + log_success "Editor/ folder found" + ((++checks_passed)) + else + log_warning "Editor/ folder not found" + fi + + ((++checks_total)) + if [[ -d "Runtime" ]]; then + log_success "Runtime/ folder found" + ((++checks_passed)) + else + log_warning "Runtime/ folder not found" + fi + + ((++checks_total)) + if [[ -d "Tests" ]]; then + log_success "Tests/ folder found" + ((++checks_passed)) + else + log_warning "Tests/ folder not found" + fi + + echo "" + log_info "Workspace validation: ${checks_passed}/${checks_total} checks passed" + + return 0 +} + +# ----------------------------------------------------------------------------- +# Environment Summary +# ----------------------------------------------------------------------------- + +print_summary() { + log_header "Development Environment Ready" + + echo "" + echo " Project: DxMessaging (Unity UPM package)" + echo " Workspace: ${WORKSPACE_DIR}" + echo "" + echo " Available Tools:" + echo " .NET SDK: $(dotnet --version 2>/dev/null || echo 'N/A')" + # shellcheck disable=SC2016 # $PSVersionTable is PowerShell, not bash, so single-quote it. + echo " PowerShell: $(pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>/dev/null || echo 'N/A')" + echo " Python: $(python3 --version 2>/dev/null | cut -d' ' -f2 || echo 'N/A')" + echo " Node.js: $(node --version 2>/dev/null || echo 'N/A')" + echo " GitHub CLI: $(gh --version 2>/dev/null | head -n1 | cut -d' ' -f3 || echo 'N/A')" + echo " Git LFS: $(git lfs version 2>/dev/null | cut -d' ' -f1-2 || echo 'N/A')" + echo " Docker (DooD): $(docker --version 2>/dev/null || echo 'N/A — DooD not active')" + echo "" + echo " Quick Commands:" + echo " dotnet test # Run .NET tests" + echo " dotnet csharpier . # Format C# sources" + echo " npm run preflight:pre-commit # Run repo preflight checks" + echo " pre-commit run --all-files # Run all pre-commit hooks" + echo " bash .devcontainer/validate-caching.sh # Validate cache mount contract" + echo " # bash scripts/unity/run-tests.sh --platform editmode (Phase 2)" + echo "" + log_success "Environment setup complete!" + echo "" + + return 0 +} + +# ----------------------------------------------------------------------------- +# Main Execution +# ----------------------------------------------------------------------------- + +main() { + local exit_code=0 + + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ DxMessaging Devcontainer - Post-Create Bootstrap ║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" + + # Step 1: fix volume permissions FIRST so subsequent writes (npm, dotnet, + # pip, etc.) hit a writable home directory. + if ! fix_volume_permissions; then + log_error "Volume permission fix failed; cannot continue safely." + return 1 + fi + + # Step 2: warn (don't fail) if the docker socket isn't accessible. + verify_docker_socket || true + + # Step 3: configure npm prefix for non-root global installs. + log_header "Configuring npm Global Prefix" + mkdir -p "$HOME/.local/bin" + log_info "Setting npm prefix to $HOME/.local" + npm config set prefix "$HOME/.local" + + local current_prefix + current_prefix="$(npm config get prefix)" + if [[ "$current_prefix" != "$HOME/.local" ]]; then + fail "npm prefix is '$current_prefix', expected '$HOME/.local'" + fi + log_success "npm prefix configured: $current_prefix" + + export PATH="$HOME/.local/bin:$PATH" + ensure_path_line "$HOME/.bashrc" + ensure_path_line "$HOME/.zshrc" + + # Step 4: workspace bootstrap. + log_header "Bootstrapping Workspace" + local workspace_dir + workspace_dir="${containerWorkspaceFolder:-${WORKSPACE_DIR}}" + + cd "${WORKSPACE_DIR}" + + run_optional "Restoring .NET local tools" dotnet tool restore + run_optional "Installing workspace npm dependencies" npm install + run_optional "Configuring git safe.directory" git config --global --add safe.directory "$workspace_dir" + run_optional "Installing pre-commit hooks" pre-commit install --install-hooks + + # Step 5: validate environment (warn-only, never blocking). + validate_dotnet || { log_error ".NET validation failed"; exit_code=1; } + validate_workspace || { log_error "Workspace validation failed"; exit_code=1; } + + print_summary + + return "${exit_code}" +} -log_success "Codex ready: $codex_version" -log_success "Post-create setup finished" +main "$@" diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 00000000..1e501cbd --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -f "${SCRIPT_DIR}/cache-contract.sh" ]]; then + echo "[post-start] FATAL: cache-contract.sh not found at ${SCRIPT_DIR}/cache-contract.sh" + exit 1 +fi + +# shellcheck source=.devcontainer/cache-contract.sh +source "${SCRIPT_DIR}/cache-contract.sh" || { + echo "[post-start] FATAL: failed to source cache-contract.sh" + exit 1 +} + +if ! cache_contract_validate_shape; then + echo "[post-start] Cache mount contract is invalid (sources/targets length mismatch)." + exit 1 +fi + +if cache_contract_is_container_runtime; then + current_uid="$(id -u)" + current_gid="$(id -g)" + + for i in "${!CACHE_MOUNT_TARGETS[@]}"; do + source_name="${CACHE_MOUNT_SOURCES[$i]}" + target_dir="${CACHE_MOUNT_TARGETS[$i]}" + + mkdir -p "${target_dir}" 2>/dev/null || true + + owner_uid="$(cache_contract_get_owner_uid "${target_dir}" 2>/dev/null || echo "unknown")" + if [[ "${owner_uid}" != "${current_uid}" ]]; then + echo "[post-start] Fixing ownership for ${target_dir} (source=${source_name}, owner=${owner_uid}, expected=${current_uid})" + sudo chown -R "${current_uid}:${current_gid}" "${target_dir}" 2>/dev/null || true + owner_uid="$(cache_contract_get_owner_uid "${target_dir}" 2>/dev/null || echo "unknown")" + if [[ "${owner_uid}" != "${current_uid}" ]]; then + echo "[post-start] ERROR: ${target_dir} ownership remains ${owner_uid} (expected ${current_uid}); sudo chown appears to have failed silently" >&2 + fi + fi + done +else + echo "[post-start] Non-container runtime detected; skipping cache ownership checks." +fi + +git lfs pull || true + +# Install / refresh the OpenAI Codex CLI (@openai/codex) from npm. The script +# is idempotent and never fails the caller: it skips when already at latest, +# and degrades gracefully when offline. +if [[ -x "${SCRIPT_DIR}/install-codex-cli.sh" ]]; then + bash "${SCRIPT_DIR}/install-codex-cli.sh" || true +else + echo "[post-start] WARN: install-codex-cli.sh missing or not executable; skipping codex CLI install" +fi + +# Verify the docker socket is reachable (Phase 2+ Unity test runner depends on +# docker-outside-of-docker). Don't fail the post-start on socket absence so a +# pure .NET-only workflow still degrades gracefully. +if ! docker info >/dev/null 2>&1; then + echo "[post-start] WARN: docker socket not accessible — Unity test runner will fail in Phase 2+" +fi diff --git a/.devcontainer/validate-caching.sh b/.devcontainer/validate-caching.sh new file mode 100644 index 00000000..a611ad83 --- /dev/null +++ b/.devcontainer/validate-caching.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# ============================================================================= +# Devcontainer Caching Validation Script +# ============================================================================= +# Validates the cache mount contract across: +# 1) Dockerfile + devcontainer.json configuration +# 2) Lifecycle scripts that enforce permissions +# 3) Runtime mount state, ownership, and writability when run in-container +# +# When run outside a container, the runtime mount-state block is skipped +# entirely (a single warning is emitted). Inside a properly-built container, +# every mount-point assertion is a hard failure (matching Shiro). +# ============================================================================= + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -f "${SCRIPT_DIR}/cache-contract.sh" ]]; then + echo -e "${RED}FATAL: cache-contract.sh not found at ${SCRIPT_DIR}/cache-contract.sh${NC}" + exit 1 +fi + +# shellcheck source=.devcontainer/cache-contract.sh +source "${SCRIPT_DIR}/cache-contract.sh" || { + echo -e "${RED}FATAL: failed to source cache-contract.sh${NC}" + exit 1 +} + +CHECKS_PASSED=0 +CHECKS_FAILED=0 +CHECKS_TOTAL=0 +CHECKS_WARNINGS=0 + +log_header() { + echo "" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +check_pass() { + echo -e "${GREEN}✓${NC} $1" + ((++CHECKS_PASSED)) + ((++CHECKS_TOTAL)) +} + +check_fail() { + echo -e "${RED}✗${NC} $1" + ((++CHECKS_FAILED)) + ((++CHECKS_TOTAL)) +} + +check_warning() { + echo -e "${YELLOW}⚠${NC} $1" + ((++CHECKS_WARNINGS)) +} + +check_required_file() { + local file_path="$1" + local label="$2" + + if [[ -f "$file_path" ]]; then + check_pass "$label exists" + return 0 + fi + + check_fail "$label missing: $file_path" + return 1 +} + +matches_expected_mount() { + local mount_entry="$1" + local source_name="$2" + local target_dir="$3" + + [[ "$mount_entry" == *"source=${source_name},"* ]] \ + && [[ "$mount_entry" == *"target=${target_dir},"* ]] \ + && [[ "$mount_entry" == *",type=volume"* ]] +} + +is_exact_mount_point() { + local target_dir="$1" + + if ! command -v findmnt >/dev/null 2>&1; then + return 2 + fi + + local mount_target + mount_target="$(findmnt -T "$target_dir" -o TARGET -n 2>/dev/null || true)" + if [[ "$mount_target" == "$target_dir" ]]; then + return 0 + fi + + return 1 +} + +script_sources_contract() { + local script_file="$1" + + grep -Eq '^[[:space:]]*(source|\.)[[:space:]]+"?[^"]*cache-contract\.sh"?' "$script_file" +} + +log_header "Checking Contract and Static Files" + +if cache_contract_validate_shape; then + check_pass "Cache contract shape is valid (${#CACHE_MOUNT_SOURCES[@]} entries)" +else + check_fail "Cache contract shape invalid (sources/targets length mismatch)" +fi + +check_required_file "${SCRIPT_DIR}/cache-contract.sh" "cache-contract.sh" || true +check_required_file "${SCRIPT_DIR}/Dockerfile" "Dockerfile" || true +check_required_file "${SCRIPT_DIR}/devcontainer.json" "devcontainer.json" || true +check_required_file "${SCRIPT_DIR}/post-create.sh" "post-create.sh" || true +check_required_file "${SCRIPT_DIR}/post-start.sh" "post-start.sh" || true + +log_header "Checking Dockerfile and Lifecycle Script Wiring" + +if grep -q "# syntax=docker/dockerfile:1" "${SCRIPT_DIR}/Dockerfile"; then + check_pass "Dockerfile has BuildKit syntax directive" +else + check_fail "Dockerfile missing BuildKit syntax directive" +fi + +if grep -q -- "--mount=type=cache,target=/var/cache/apt" "${SCRIPT_DIR}/Dockerfile"; then + check_pass "Dockerfile uses BuildKit cache mounts for apt" +else + check_fail "Dockerfile missing BuildKit cache mounts" +fi + +if script_sources_contract "${SCRIPT_DIR}/post-create.sh"; then + check_pass "post-create.sh sources cache-contract.sh" +else + check_fail "post-create.sh does not source cache-contract.sh" +fi + +if script_sources_contract "${SCRIPT_DIR}/post-start.sh"; then + check_pass "post-start.sh sources cache-contract.sh" +else + check_fail "post-start.sh does not source cache-contract.sh" +fi + +log_header "Checking devcontainer.json Mount Contract" + +declare -a configured_mounts=() +if command -v jq >/dev/null 2>&1; then + jq_output="" + if jq_output="$(jq -r '.mounts[]? // empty' "${SCRIPT_DIR}/devcontainer.json" 2>/dev/null)"; then + if [[ -n "$jq_output" ]]; then + mapfile -t configured_mounts <<< "$jq_output" + check_pass "Parsed mounts from devcontainer.json using jq (count=${#configured_mounts[@]})" + else + check_warning "Parsed devcontainer.json with jq, but mounts array is empty" + fi + else + check_warning "jq could not parse devcontainer.json (comments likely); falling back to grep parsing" + mapfile -t configured_mounts < <(grep -o 'source=[^"]*,target=[^"]*,type=volume' "${SCRIPT_DIR}/devcontainer.json" || true) + fi +else + check_warning "jq is not available; falling back to grep parsing for mount checks" + mapfile -t configured_mounts < <(grep -o 'source=[^"]*,target=[^"]*,type=volume' "${SCRIPT_DIR}/devcontainer.json" || true) +fi + +if [[ "${#configured_mounts[@]}" -eq 0 ]]; then + check_fail "No mounts found in devcontainer.json" +fi + +for i in "${!CACHE_MOUNT_SOURCES[@]}"; do + source_name="${CACHE_MOUNT_SOURCES[$i]}" + target_dir="${CACHE_MOUNT_TARGETS[$i]}" + + found_match=false + for mount_entry in "${configured_mounts[@]}"; do + if matches_expected_mount "$mount_entry" "$source_name" "$target_dir"; then + found_match=true + break + fi + done + + if [[ "$found_match" == "true" ]]; then + check_pass "Mount contract entry configured: ${source_name} -> ${target_dir}" + else + check_fail "Mount contract entry missing from devcontainer.json: ${source_name} -> ${target_dir}" + fi +done + +if grep -Eq '"remoteUser"[[:space:]]*:[[:space:]]*"vscode"' "${SCRIPT_DIR}/devcontainer.json"; then + check_pass "devcontainer.json remoteUser is vscode (matches cache mount targets)" +else + check_fail "devcontainer.json remoteUser is not vscode; update cache-contract.sh targets or remoteUser" +fi + +log_header "Checking Workflow Configuration" + +WORKFLOW_FILE="${SCRIPT_DIR}/../.github/workflows/devcontainer-test.yml" +if [[ ! -f "$WORKFLOW_FILE" ]]; then + check_warning "Workflow file not found: ${WORKFLOW_FILE} (expected once Phase 3 lands)" +else + check_pass "Workflow file found" + + if grep -q "packages: write" "$WORKFLOW_FILE"; then + check_pass "Workflow has packages:write permission" + else + check_fail "Workflow missing packages:write permission" + fi + + if grep -q "docker/login-action" "$WORKFLOW_FILE"; then + check_pass "Workflow has GHCR login step" + else + check_fail "Workflow missing GHCR login step" + fi + + if grep -q "eventFilterForPush: \"\"" "$WORKFLOW_FILE"; then + check_pass "Workflow disables devcontainers/ci event push gate" + else + check_fail "Workflow missing eventFilterForPush override" + fi + + if grep -q "\.devcontainer/validate-caching.sh" "$WORKFLOW_FILE"; then + check_pass "Workflow runs validate-caching.sh" + else + check_warning "Workflow does not run validate-caching.sh (recommended)" + fi +fi + +log_header "Checking Runtime Mount State" + +echo "Container detection signals:" +echo " - /.dockerenv exists: $( [[ -f /.dockerenv ]] && echo yes || echo no )" +echo " - DEVCONTAINER=${DEVCONTAINER:-}" +echo " - REMOTE_CONTAINERS=${REMOTE_CONTAINERS:-}" + +if cache_contract_is_container_runtime; then + check_pass "Container runtime detected" + + current_uid="$(id -u)" + current_gid="$(id -g)" + echo "Current user: uid=${current_uid}, gid=${current_gid}" + + for i in "${!CACHE_MOUNT_SOURCES[@]}"; do + source_name="${CACHE_MOUNT_SOURCES[$i]}" + target_dir="${CACHE_MOUNT_TARGETS[$i]}" + + if [[ -d "$target_dir" ]]; then + check_pass "Target directory exists: ${target_dir}" + else + check_fail "Target directory missing: ${target_dir}" + continue + fi + + owner_uid="$(cache_contract_get_owner_uid "$target_dir" 2>/dev/null || echo unknown)" + if [[ "$owner_uid" == "$current_uid" ]]; then + check_pass "Ownership OK for ${target_dir} (uid=${owner_uid})" + else + check_fail "Ownership mismatch for ${target_dir} (owner=${owner_uid}, expected=${current_uid})" + fi + + if is_exact_mount_point "$target_dir"; then + mount_source="$(findmnt -T "$target_dir" -o SOURCE -n 2>/dev/null || echo unknown)" + check_pass "Mount point OK for ${target_dir} (source=${mount_source}, contract=${source_name})" + else + mount_check_status=$? + if [[ "$mount_check_status" -eq 2 ]]; then + check_warning "findmnt unavailable; cannot verify mount-point state for ${target_dir}" + else + actual_target="$(findmnt -T "$target_dir" -o TARGET -n 2>/dev/null || echo unresolved)" + actual_source="$(findmnt -T "$target_dir" -o SOURCE -n 2>/dev/null || echo unresolved)" + check_fail "${target_dir} is not a dedicated mount point (resolved target=${actual_target}, source=${actual_source})" + fi + fi + + probe_file="${target_dir}/.cache-write-probe-$$" + if touch "$probe_file" 2>/dev/null; then + rm -f "$probe_file" + check_pass "Write probe OK for ${target_dir}" + else + check_fail "Write probe failed for ${target_dir}" + fi + + size_value="$(du -sh "$target_dir" 2>/dev/null | cut -f1 || echo unknown)" + echo " Diagnostic: source=${source_name}, target=${target_dir}, owner=${owner_uid}, size=${size_value}" + done +else + check_warning "Container runtime not detected; skipping runtime mount-state checks (safe when invoked outside a container)" +fi + +log_header "Validation Summary" + +if [[ "$CHECKS_TOTAL" -eq 0 ]]; then + PASS_PERCENTAGE=0 +else + PASS_PERCENTAGE=$((CHECKS_PASSED * 100 / CHECKS_TOTAL)) +fi + +echo "" +echo " Checks Passed: ${CHECKS_PASSED}" +echo " Checks Failed: ${CHECKS_FAILED}" +echo " Warnings: ${CHECKS_WARNINGS}" +echo " Total Evaluated: ${CHECKS_TOTAL}" +echo " Pass Percentage: ${PASS_PERCENTAGE}%" +echo "" + +if [[ "$CHECKS_FAILED" -eq 0 ]]; then + echo -e "${GREEN}✓ Validation passed. Cache configuration and runtime checks are healthy.${NC}" + exit 0 +fi + +echo -e "${RED}✗ Validation failed. Review errors above.${NC}" +exit 1 diff --git a/.devcontainer/verify-tools.sh b/.devcontainer/verify-tools.sh index 7c6ff7d8..b505809f 100644 --- a/.devcontainer/verify-tools.sh +++ b/.devcontainer/verify-tools.sh @@ -152,8 +152,8 @@ echo -e "${BLUE}=== .NET Tools ===${NC}" check_tool "csharpier" "csharpier" "--version" echo "" -echo -e "${BLUE}=== Node.js Global Tools ===${NC}" -check_tool "codex" "codex" "--version" +echo -e "${BLUE}=== AI / Agent CLIs ===${NC}" +check_optional "codex (@openai)" "codex" "--version" echo "" echo -e "${BLUE}=== npm Configuration ===${NC}" diff --git a/.github/workflows/devcontainer-prebuild.yml b/.github/workflows/devcontainer-prebuild.yml new file mode 100644 index 00000000..3acaa223 --- /dev/null +++ b/.github/workflows/devcontainer-prebuild.yml @@ -0,0 +1,118 @@ +# ============================================================================= +# Devcontainer Pre-build (GHCR Cache Warm) +# ============================================================================= +# Purpose: +# Pre-builds the .devcontainer image and pushes it to GHCR so: +# 1. CI workflows that consume the same image (devcontainer-test.yml) +# get fast cache hits. +# 2. New contributors hitting "Open in Container" / "Open in Codespace" +# don't pay a 5-15 minute cold build the first time. +# 3. Weekly rebuilds catch base-image security updates and CVE drift. +# +# Triggers: +# - Pushes to master that touch .devcontainer/** (image needs rebuild). +# - Weekly cron (Sunday 00:00 UTC) — keep the cache warm + capture base +# image updates even when no code changes. +# - Manual dispatch. +# +# Critical: eventFilterForPush +# devcontainers/ci@v0.3 defaults `eventFilterForPush` to "push", which +# silently SKIPS pushing the image on `schedule` / `workflow_dispatch` +# events. Setting it to "" makes the explicit `push: always` knob the only +# source of truth — without this, the weekly cron and manual dispatches +# would build but never publish, defeating the whole purpose of this +# workflow. See .llm/skills/github-actions/cicd-devcontainer-workflows.md +# (ported from Shiro in Phase 4). +# +# Permissions: +# `packages: write` is required for the docker/login-action push to GHCR +# under the GITHUB_TOKEN. +# ============================================================================= + +name: Pre-build Devcontainer + +on: + push: + branches: + - master + paths: + - ".devcontainer/**" + schedule: + # Sunday 00:00 UTC — weekly rebuild keeps the image fresh and captures + # base-image updates / CVE patches. + - cron: "0 0 * * 0" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prebuild: + name: Build + push devcontainer image to GHCR + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + lfs: true + + - name: Debug workflow context + run: | + echo "Event name: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Actor: ${{ github.actor }}" + + - name: Set lowercase repository name + id: repo + run: | + set -euo pipefail + repo_lower=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "repository_lowercase=${repo_lower}" >> "${GITHUB_OUTPUT}" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push devcontainer image + uses: devcontainers/ci@v0.3 + env: + BUILDKIT_PROGRESS: plain + with: + imageName: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + cacheFrom: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + push: always + # Required: devcontainers/ci defaults eventFilterForPush to "push", + # which silently skips push on schedule / workflow_dispatch triggers. + # Empty string here makes `push: always` the single source of truth. + eventFilterForPush: "" + + - name: Verify image pushed to GHCR + run: | + set -euo pipefail + IMAGE="ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer:latest" + echo "=== Local Docker images ===" + docker images | grep devcontainer || echo "No local devcontainer images found" + echo "" + echo "=== Checking local image ===" + if docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "Image found locally" + docker image inspect "${IMAGE}" --format '{{.Id}} {{.Created}}' + else + echo "WARNING: Image not found locally" + fi + echo "" + echo "=== Pulling from GHCR to verify push ===" + if docker pull "${IMAGE}"; then + echo "Image successfully pulled from GHCR." + else + echo "ERROR: failed to pull image from GHCR — push may have failed." >&2 + exit 1 + fi diff --git a/.github/workflows/devcontainer-test.yml b/.github/workflows/devcontainer-test.yml new file mode 100644 index 00000000..3833d249 --- /dev/null +++ b/.github/workflows/devcontainer-test.yml @@ -0,0 +1,142 @@ +# ============================================================================= +# Devcontainer Build + Validation Test +# ============================================================================= +# Purpose: +# Builds the .devcontainer image (with the GHCR-cached layers) and runs an +# in-container smoke test verifying: +# - .NET SDK (9 + 10 side-by-side, per Phase 1) is present and runnable +# - git LFS is installed (Unity LFS workflow) +# - PowerShell is installed (parity with .ps1 entry points) +# - The host docker socket is reachable inside the container +# (docker-outside-of-docker — the linchpin for Unity headless runs) +# - The asmdef-discovery node module loads and self-tests cleanly +# - The cache-mount contract still holds (validate-caching.sh) +# - The Unity test runner script's --help is wired up (smoke test only; +# a real Unity run requires the UNITY_LICENSE secret which this gate +# does not exercise) +# - The script-test Jest suite passes inside the devcontainer image +# +# Triggers: +# - PRs to master that touch the devcontainer / workflows / Unity scripts +# - Pushes to master with the same paths (keep main image cache warm) +# - Manual dispatch +# +# Critical: eventFilterForPush +# devcontainers/ci@v0.3 defaults `eventFilterForPush` to "push", which +# silently SKIPS pushing the image on `schedule` / `workflow_dispatch` +# events. Setting it to "" makes the `push` knob the single source of +# truth (combined with `refFilterForPush: refs/heads/master`). This gotcha +# is documented in .llm/skills/github-actions/cicd-devcontainer-workflows.md +# (ported from Shiro in Phase 4). +# ============================================================================= + +name: Test Devcontainer + +on: + push: + branches: + - master + paths: + - ".devcontainer/**" + - ".github/**" + - "scripts/unity/**" + pull_request: + branches: + - master + paths: + - ".devcontainer/**" + - ".github/**" + - "scripts/unity/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build + smoke-test devcontainer image + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + lfs: true + + - name: Debug workflow context + run: | + echo "Event name: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Actor: ${{ github.actor }}" + + - name: Set lowercase repository name + id: repo + run: | + set -euo pipefail + repo_lower=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "repository_lowercase=${repo_lower}" >> "${GITHUB_OUTPUT}" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pre-build devcontainer image + uses: devcontainers/ci@v0.3 + env: + BUILDKIT_PROGRESS: plain + with: + imageName: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + cacheFrom: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + push: filter + refFilterForPush: refs/heads/master + # Required: devcontainers/ci defaults eventFilterForPush to "push", + # which silently skips push on schedule / workflow_dispatch triggers. + # Empty string here makes `push` + `refFilterForPush` the single + # source of truth. + eventFilterForPush: "" + + - name: Run devcontainer smoke tests + uses: devcontainers/ci@v0.3 + with: + imageName: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + cacheFrom: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + push: never + runCmd: | + set -euo pipefail + echo "=== Toolchain versions ===" + dotnet --version + git lfs version + pwsh -NoProfile -Command 'Write-Host "PowerShell OK"' + node --version + python3 --version + + echo "=== .NET 10 SDK + runtime presence ===" + dotnet --list-sdks | grep -E '^10\.0' \ + || (echo "ERROR: .NET 10 SDK not found" >&2 && exit 1) + dotnet --list-runtimes | grep -E '^Microsoft\.NETCore\.App 10\.0' \ + || (echo "ERROR: .NET 10 runtime not found" >&2 && exit 1) + + echo "=== Docker-outside-of-docker reachability ===" + docker info >/dev/null \ + || (echo "ERROR: docker socket not reachable from inside container" >&2 && exit 1) + + echo "=== asmdef-discovery self-test ===" + node scripts/unity/lib/asmdef-discovery.js | tail -n 30 + + echo "=== Cache-mount contract ===" + bash .devcontainer/validate-caching.sh + + echo "=== Unity runner help ===" + bash scripts/unity/run-tests.sh --help >/dev/null + + echo "=== Jest script tests ===" + node scripts/run-managed-jest.js scripts/__tests__/ + + echo "=== All devcontainer smoke checks passed ===" diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml new file mode 100644 index 00000000..c4b178e0 --- /dev/null +++ b/.github/workflows/unity-benchmarks.yml @@ -0,0 +1,182 @@ +# ============================================================================= +# Unity Performance Benchmarks (NIGHTLY + workflow_dispatch ONLY) +# ============================================================================= +# Purpose: +# Runs the perf / allocation test suites that are EXCLUDED from the PR gate +# (.github/workflows/unity-tests.yml). External library comparison suites +# stay opt-in until the harness manifest installs their packages. These are graded +# regression-watchers, not pass/fail PR gates: noise from cold caches, +# ubuntu-latest CPU variance, and shared-runner contention makes them +# unreliable as a merge gate. +# +# This workflow MUST NOT block PRs. +# - No `pull_request` trigger. +# - No `push` trigger to master (or any branch). +# - The only triggers are `workflow_dispatch` (manual) and a nightly cron. +# This is an explicit hard requirement from the project lead; verify +# compliance via: +# grep -A 3 "^on:" .github/workflows/unity-benchmarks.yml +# +# Assembly include list: +# Computed dynamically with `includePerf: true` on the same +# asmdef-discovery.js module used by the PR gate. No string lists in YAML; +# adding a new benchmark asmdef under Tests/ is automatically picked up. +# DI-integration suites remain excluded (their packages are not in the test +# project's manifest.json). +# +# Results: +# Uploaded as a workflow artifact (`unity-benchmarks-*`) with 90-day +# retention so longitudinal comparison is possible. If the repo variable +# `PERF_TRACKING_ISSUE_NUMBER` is set, a comment is posted on that issue +# linking back to the workflow run; otherwise the comment step is skipped +# silently. +# +# Required secrets: +# For Unity Personal: UNITY_LICENSE (raw .ulf contents) + UNITY_EMAIL + UNITY_PASSWORD +# For Unity Professional serial: UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD +# See .llm/skills/unity/unity-license-bootstrap.md for setup. +# 2FA on the Unity account MUST be disabled, or use a dedicated CI account. +# ============================================================================= + +name: Unity Benchmarks + +on: + schedule: + # 06:00 UTC nightly. Pick a time outside the EditMode/IL2CPP gate's busy + # window to avoid runner contention. + - cron: "0 6 * * *" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version (default 2022.3.45f1)." + required: false + default: "" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + issues: write + +jobs: + matrix-config: + name: Resolve benchmark matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2022.3.45f1"]' + fi + echo "unity-versions=${versions}" >> "${GITHUB_OUTPUT}" + + benchmarks: + name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: matrix-config + runs-on: ubuntu-22.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: [editmode, playmode] + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + swap-storage: false + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + # M4: include manifest, lock, and ProjectVersion in the hash. Variables + # are defined here so the long hashFiles() expressions don't violate the + # repo's 200-char yamllint cap (and stay readable). + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-bench-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Compute benchmark assembly list + run: | + set -euo pipefail + assemblies=$(node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(m.defaultIncludeAssemblies(process.cwd(), { includePerf: true }).join(';'))") + if [ -z "${assemblies}" ]; then + echo "::error::asmdef discovery returned an empty benchmark assembly list" >&2 + exit 1 + fi + echo "DXM_BENCH_ASSEMBLIES=${assemblies}" >> "${GITHUB_ENV}" + echo "Resolved benchmark assemblies: ${assemblies}" + + - name: Run Unity Benchmarks + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: .unity-test-project + unityVersion: ${{ matrix.unity-version }} + testMode: ${{ matrix.test-mode }} + customParameters: -nographics -assemblyNames "${{ env.DXM_BENCH_ASSEMBLIES }}" + coverageOptions: "generateAdditionalMetrics;generateHtmlReport" + artifactsPath: artifacts/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + checkName: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + githubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }} + # M3: include the CodeCoverage/ tree alongside the artifactsPath. + # game-ci writes coverage HTML to CodeCoverage/ at the project root + # by default (independent of artifactsPath), so we must enumerate + # both paths here or the HTML report is dropped. + path: | + artifacts/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + CodeCoverage/ + if-no-files-found: warn + retention-days: 90 + + - name: Post tracking issue comment + if: success() && vars.PERF_TRACKING_ISSUE_NUMBER != '' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ vars.PERF_TRACKING_ISSUE_NUMBER }} + body: | + Benchmark run completed for `${{ matrix.unity-version }}` `${{ matrix.test-mode }}`. + + - Workflow: `${{ github.workflow }}` + - Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + - Commit: `${{ github.sha }}` + - Trigger: `${{ github.event_name }}` + + Artifact: `unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }}` (retained 90 days). diff --git a/.github/workflows/unity-il2cpp.yml b/.github/workflows/unity-il2cpp.yml new file mode 100644 index 00000000..836c75d3 --- /dev/null +++ b/.github/workflows/unity-il2cpp.yml @@ -0,0 +1,241 @@ +# ============================================================================= +# Unity IL2CPP Standalone Player Tests +# ============================================================================= +# Purpose: +# Builds the StandaloneLinux64 IL2CPP test player via game-ci/unity-builder +# and runs the produced binary headlessly. IL2CPP exercises the AOT-compiled +# path that EditMode/PlayMode under Mono cannot — it catches: +# - Code stripping issues (linker.xml gaps, [Preserve] missing on reflected +# types, generic-method instantiations stripped at link time) +# - AOT generic-virtual-method (GVM) failures specific to IL2CPP +# - PInvoke / native-callable signature mismatches +# - C# language-feature regressions only the IL2CPP backend rejects +# These regressions have historically slipped past the Mono-based EditMode +# gate (.github/workflows/unity-tests.yml) and only surfaced when a +# downstream consumer built a player. +# +# Schedule: +# On every PR / push to master against a single Unity version (single-version +# keeps the gate fast), and weekly via cron for multi-version drift checks. +# Expected runtime: ~10 minutes for the build + ~3 minutes for the player run +# on the standard ubuntu-latest runner. +# +# Local debugging: +# Reproduce a build failure end-to-end with the local runner script: +# bash scripts/unity/run-tests.sh --platform standalone +# The script uses the same TestRunnerBuilder.BuildIL2CPPTestPlayer entry +# point and the same assembly include list as this workflow; if it passes +# locally and fails here, the divergence is environmental (Library cache, +# license, runner image) rather than test logic. +# +# Required secrets: +# For Unity Personal: UNITY_LICENSE (raw .ulf contents) + UNITY_EMAIL + UNITY_PASSWORD +# For Unity Professional serial: UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD +# See .llm/skills/unity/unity-license-bootstrap.md for setup. +# 2FA on the Unity account MUST be disabled, or use a dedicated CI account. +# ============================================================================= + +name: Unity IL2CPP + +on: + pull_request: + branches: + - master + push: + branches: + - master + schedule: + # Monday 05:00 UTC — weekly multi-version drift check (still single-version + # in the matrix today; expand `unity-version` here when adding versions). + - cron: "0 5 * * 1" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version (default 2022.3.45f1)." + required: false + default: "" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + matrix-config: + name: Resolve IL2CPP matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2022.3.45f1"]' + fi + echo "unity-versions=${versions}" >> "${GITHUB_OUTPUT}" + + il2cpp-tests: + name: IL2CPP ${{ matrix.unity-version }} + needs: matrix-config + runs-on: ubuntu-22.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + swap-storage: false + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + # M4: include manifest, lock, and ProjectVersion in the hash. Variables + # are defined here so the long hashFiles() expressions don't violate the + # repo's 200-char yamllint cap (and stay readable). + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-il2cpp-${{ matrix.unity-version }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Compute test assembly list + run: | + set -euo pipefail + assemblies=$(node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(m.defaultIncludeAssemblies(process.cwd()).join(';'))") + if [ -z "${assemblies}" ]; then + echo "::error::asmdef discovery returned an empty assembly list" >&2 + exit 1 + fi + echo "DXM_TEST_ASSEMBLIES=${assemblies}" >> "${GITHUB_ENV}" + echo "Resolved assemblies: ${assemblies}" + + - name: Build IL2CPP test player + uses: game-ci/unity-builder@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + # TestRunnerBuilder.BuildIL2CPPTestPlayer reads this env var to + # decide where to write the player. We pin it to the path the run + # step looks at below so the workflow doesn't depend on the local + # default in the .cs source. + DXM_IL2CPP_BUILD_PATH: ${{ github.workspace }}/builds/StandaloneLinux64/IL2CPPTests/Tests.x86_64 + with: + projectPath: .unity-test-project + unityVersion: ${{ matrix.unity-version }} + targetPlatform: StandaloneLinux64 + buildMethod: WallstopStudios.DxMessaging.TestHarness.Editor.TestRunnerBuilder.BuildIL2CPPTestPlayer + # NOTE: -assemblyNames is a Unity Test Runner argument consumed at + # PLAYER LAUNCH, not at build time. Passing it here is a no-op; the + # actual filter is applied in the run step below. + customParameters: -nographics + buildName: IL2CPPTests + buildsPath: builds + + - name: Run IL2CPP test player + id: run-il2cpp + run: | + set -uo pipefail + mkdir -p artifacts + binary="builds/StandaloneLinux64/IL2CPPTests/Tests.x86_64" + if [ ! -x "${binary}" ]; then + # Fallback: search for the produced binary if the layout differs + # (e.g. older game-ci images use a "Builds/" capitalization). + alt=$(find . -type f \( -name 'Tests.x86_64' -o -name 'Tests' \) \ + \( -path '*/builds/*' -o -path '*/Builds/*' \) \ + -perm -u+x 2>/dev/null | head -n1) + if [ -n "${alt}" ] && [ -x "${alt}" ]; then + binary="${alt}" + else + echo "::error::IL2CPP player binary not found under builds/ or Builds/" >&2 + find . -maxdepth 5 -type f \( -path '*/builds/*' -o -path '*/Builds/*' \) >&2 || true + exit 1 + fi + fi + echo "Launching ${binary}" + set +e + "${binary}" \ + -batchmode \ + -nographics \ + -runTests \ + -testResults artifacts/il2cpp-results.xml \ + -assemblyNames "${DXM_TEST_ASSEMBLIES}" \ + -logFile - 2>&1 | tee artifacts/il2cpp-log.txt + status=${PIPESTATUS[0]} + set -e + echo "il2cpp-exit-code=${status}" >> "${GITHUB_OUTPUT}" + echo "Player exited with status ${status}" + + - name: Parse IL2CPP test results + env: + IL2CPP_EXIT_CODE: ${{ steps.run-il2cpp.outputs.il2cpp-exit-code }} + run: | + set -euo pipefail + xml="artifacts/il2cpp-results.xml" + if [ ! -f "${xml}" ]; then + echo "::error::IL2CPP results XML missing at ${xml}" >&2 + exit 1 + fi + summary=$(python3 scripts/unity/lib/parse-test-results.py "${xml}") + echo "Summary: ${summary}" + if [[ "${summary}" == PARSE_ERROR:* ]]; then + echo "::error::Failed to parse IL2CPP results XML: ${summary#PARSE_ERROR:}" >&2 + exit 1 + fi + # parse-test-results.py emits "OK total=.. passed=.. failed=.. skipped=.." + total=$(echo "${summary}" | sed -n 's/.*total=\([0-9]*\).*/\1/p') + failed=$(echo "${summary}" | sed -n 's/.*failed=\([0-9]*\).*/\1/p') + # N3: check the player exit code FIRST. A crash after partial test + # execution can leave failed=0/total>0 yet the binary aborted; we + # must not let that look like a green run. + if [ -n "${IL2CPP_EXIT_CODE:-}" ] && [ "${IL2CPP_EXIT_CODE}" != "0" ]; then + echo "::error::IL2CPP player exited with code ${IL2CPP_EXIT_CODE} (possible crash)" >&2 + exit "${IL2CPP_EXIT_CODE}" + fi + if [ -z "${total}" ] || [ "${total}" = "0" ]; then + echo "::error::IL2CPP run produced 0 tests — check assembly list" >&2 + exit 1 + fi + if [ -n "${failed}" ] && [ "${failed}" != "0" ]; then + echo "::error::IL2CPP run reported ${failed} failed test(s)" >&2 + exit 1 + fi + echo "IL2CPP run OK (${summary})." + + - name: Upload IL2CPP artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: il2cpp-${{ matrix.unity-version }} + path: | + artifacts/il2cpp-results.xml + artifacts/il2cpp-log.txt + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml new file mode 100644 index 00000000..c7878035 --- /dev/null +++ b/.github/workflows/unity-tests.yml @@ -0,0 +1,201 @@ +# ============================================================================= +# Unity EditMode + PlayMode PR Gate +# ============================================================================= +# Purpose: +# Runs the DxMessaging test suite under the Unity Test Runner via the +# game-ci action across the supported Unity LTS / 6.0 versions. This is the +# canonical Unity correctness gate for pull requests targeting master. +# +# Why this is a PR gate (not benchmarks): +# The default assembly include list explicitly EXCLUDES the perf, +# allocation, and comparison suites — those run on a separate scheduled +# workflow (.github/workflows/unity-benchmarks.yml). DI-integration suites +# (VContainer / Zenject / Reflex) are also opt-in and excluded here per +# Phase 2 review fix M3. +# +# How the assembly list stays correct over time: +# The `Compute test assembly list` step shells out to +# scripts/unity/lib/asmdef-discovery.js, the SAME module used by +# scripts/unity/run-tests.sh and the Phase 4 contract test. When a new perf +# asmdef is added under Tests/, the regex in asmdef-discovery.js (matching +# Benchmarks|Allocations|Comparisons) classifies it automatically and this +# workflow needs no edits. Likewise, new core asmdefs are picked up +# automatically. +# +# Required repository secrets (set in repo Settings -> Secrets and variables): +# For Unity Personal: UNITY_LICENSE (raw .ulf contents) + UNITY_EMAIL + UNITY_PASSWORD +# For Unity Professional serial: UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD +# See .llm/skills/unity/unity-license-bootstrap.md for setup. +# 2FA on the Unity account MUST be disabled, or use a dedicated CI account. +# +# UNITY_LICENSE — Raw contents of a Unity .ulf license file. This is the +# shape expected by game-ci/unity-test-runner@v4. +# UNITY_SERIAL — Paid Unity serial for Professional activation. +# UNITY_EMAIL — Unity account email. +# UNITY_PASSWORD — Unity account password. +# +# Common failure modes & remediation: +# 1. "License activation failed" — the most common new-contributor failure. +# The UNITY_LICENSE secret is missing/expired, or UNITY_SERIAL does not +# match the configured Unity account. Re-bootstrap via +# unity-license-bootstrap.md and re-set the secret. +# 2. "Test framework not found" — usually means the .unity-test-project +# Library cache is corrupted. Bump the cache key prefix below to bust it, +# or push a no-op commit to .unity-test-project/Packages/manifest.json +# to invalidate the hashFiles() component of the key. +# 3. "0 tests ran" — the assembly include list resolved to a name that no +# asmdef advertises. Run `node scripts/unity/lib/asmdef-discovery.js` +# locally to diff the discovered names against the previous run. +# +# Manual override: +# Use the workflow_dispatch inputs to pin a single Unity version or a +# single test mode (handy when triaging a regression on one channel). +# ============================================================================= + +name: Unity Tests + +on: + pull_request: + branches: + - master + push: + branches: + - master + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version (e.g. 2022.3.45f1). Empty = full matrix." + required: false + default: "" + type: string + test-mode: + description: "Pin a single test mode (use 'all' for both)." + required: false + default: "all" + type: choice + options: + - all + - editmode + - playmode + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + matrix-config: + name: Resolve test matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + test-modes: ${{ steps.resolve.outputs.test-modes }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + INPUT_TEST_MODE: ${{ inputs.test-mode }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2021.3.45f1","2022.3.45f1","6000.0.32f1"]' + fi + if [ -n "${INPUT_TEST_MODE:-}" ] && [ "${INPUT_TEST_MODE}" != "all" ]; then + modes="[\"${INPUT_TEST_MODE}\"]" + else + modes='["editmode","playmode"]' + fi + { + echo "unity-versions=${versions}" + echo "test-modes=${modes}" + } >> "${GITHUB_OUTPUT}" + + unity-tests: + name: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: matrix-config + runs-on: ubuntu-22.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: ${{ fromJSON(needs.matrix-config.outputs.test-modes) }} + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + swap-storage: false + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + # M4: include manifest, lock, and ProjectVersion in the hash. Variables + # are defined here so the long hashFiles() expressions don't violate the + # repo's 200-char yamllint cap (and stay readable). + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Compute test assembly list + run: | + set -euo pipefail + assemblies=$(node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(m.defaultIncludeAssemblies(process.cwd()).join(';'))") + if [ -z "${assemblies}" ]; then + echo "::error::asmdef discovery returned an empty assembly list" >&2 + exit 1 + fi + echo "DXM_TEST_ASSEMBLIES=${assemblies}" >> "${GITHUB_ENV}" + echo "Resolved assemblies: ${assemblies}" + + - name: Run Unity Test Runner + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: .unity-test-project + unityVersion: ${{ matrix.unity-version }} + testMode: ${{ matrix.test-mode }} + customParameters: -nographics -assemblyNames "${{ env.DXM_TEST_ASSEMBLIES }}" + coverageOptions: "generateAdditionalMetrics;generateHtmlReport" + artifactsPath: artifacts/unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} + checkName: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + githubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Unity test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} + # M3: include the CodeCoverage/ tree alongside the artifactsPath. + # game-ci writes coverage HTML to CodeCoverage/ at the project root + # by default (independent of artifactsPath), so we must enumerate + # both paths here or the HTML report is dropped. + path: | + artifacts/unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} + CodeCoverage/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 65a0d07c..b57773c2 100644 --- a/.gitignore +++ b/.gitignore @@ -359,4 +359,35 @@ Temp.meta failed-tests.txt* -PERF-PLAN.md* \ No newline at end of file +PERF-PLAN.md* +OLD-PLAN.md* +*PLAN.md* + +# Unity test harness (.unity-test-project/) - track only the thin harness contract. +.unity-test-project/* +!.unity-test-project/Assets/ +.unity-test-project/Assets/* +!.unity-test-project/Assets/Editor/ +!.unity-test-project/Assets/Editor.meta +!.unity-test-project/Assets/Editor/TestRunnerBuilder.cs +!.unity-test-project/Assets/Editor/TestRunnerBuilder.cs.meta +!.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef +!.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef.meta +!.unity-test-project/Packages/ +!.unity-test-project/Packages/manifest.json +!.unity-test-project/Packages/packages-lock.json +!.unity-test-project/ProjectSettings/ +.unity-test-project/ProjectSettings/* +!.unity-test-project/ProjectSettings/ProjectVersion.txt + +# Unity test harness generated artifacts. +.unity-test-project/Library/ +.unity-test-project/Temp/ +.unity-test-project/Logs/ +.unity-test-project/obj/ +.unity-test-project/Builds/ +.unity-test-project/UserSettings/ +.unity-test-project/MemoryCaptures/ +.unity-test-project/.vsconfig + +*.ulf diff --git a/.llm/context.md b/.llm/context.md index 9b5fc69c..d9fc7ea2 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -60,6 +60,27 @@ This file is intentionally concise. It contains only critical, high-signal guida - Regenerate skills index: `node scripts/generate-skills-index.js` - Verify index is current: `node scripts/generate-skills-index.js --check` +## Running Unity Tests + +For Unity-side tests in `Tests/Editor/` or `Tests/Runtime/` (excludes Benchmarks/Allocations/Comparisons by default): + +- EditMode: `bash scripts/unity/run-tests.sh --platform editmode` +- PlayMode: `bash scripts/unity/run-tests.sh --platform playmode` +- IL2CPP standalone: `bash scripts/unity/run-tests.sh --platform standalone` +- Filter: `--filter ` (passed to `-testFilter`) +- Include perf: `--include-perf` (off by default; runnable perf tests run only via `unity-benchmarks.yml`) +- Include comparisons: `--include-comparisons` (off by default; requires MessagePipe/UniRx/UniTask/Zenject packages in the harness) +- Include DI integrations (Reflex/Zenject/VContainer): `--include-integrations` (off by default) +- Realtime log streams to stdout; XML written to `.artifacts/unity/results.xml` unless `--results` overrides it +- Bootstrap project: `.unity-test-project/` -- see [skills/unity/upm-test-harness.md](./skills/unity/upm-test-harness.md) +- License: see [skills/unity/unity-license-bootstrap.md](./skills/unity/unity-license-bootstrap.md) (Personal/GameCI: raw `.ulf` in `UNITY_LICENSE` plus credentials; Professional: `UNITY_SERIAL` plus credentials; local shells may use `UNITY_LICENSE_B64`.) +- ARM Mac (Apple Silicon): not supported locally -- use CI gates or a Codespace +- For source-generator tests (no Unity), use `dotnet test SourceGenerators/...Tests` + +## Devcontainer Workflow + +The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outside-of-docker). Unity tests spawn ephemeral `unityci/editor` containers via the host docker socket; the image is pulled lazily on first use, the `.unity-test-project/Library` cache is preserved in a named volume across runs. See [skills/unity/devcontainer-cache-contract.md](./skills/unity/devcontainer-cache-contract.md) and [skills/unity/headless-test-runner.md](./skills/unity/headless-test-runner.md). + ## C# Conventions - Use explicit types where practical; avoid unnecessary `var`. @@ -140,6 +161,7 @@ Use the index above and then select the most relevant skill pages. Frequently us - Test quality and investigation guidance under `./skills/testing/` - Memory reclaim testing guidance under `./skills/testing/memory-reclaim-coverage.md` - Workflow robustness under `./skills/github-actions/` +- Unity headless test workflow under `./skills/unity/` (see headless-test-runner, unity-license-bootstrap, upm-test-harness, devcontainer-cache-contract, unity-ci-matrix, unity-perf-test-isolation) ## Split File Maintenance @@ -162,3 +184,10 @@ Use the index above and then select the most relevant skill pages. Frequently us - [DxMessaging Memory Reclamation](./skills/performance/memory-reclamation.md) - [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md) - [Git Hook Performance Budget](./skills/performance/git-hook-performance.md) +- [Headless Unity Test Runner](./skills/unity/headless-test-runner.md) +- [Unity License Bootstrap](./skills/unity/unity-license-bootstrap.md) +- [UPM Test Harness](./skills/unity/upm-test-harness.md) +- [Devcontainer Cache Contract](./skills/unity/devcontainer-cache-contract.md) +- [Unity CI Matrix](./skills/unity/unity-ci-matrix.md) +- [Unity Perf Test Isolation](./skills/unity/unity-perf-test-isolation.md) +- [CI/CD Devcontainer Workflows](./skills/github-actions/cicd-devcontainer-workflows.md) diff --git a/.llm/skills/github-actions/cicd-devcontainer-workflows.md b/.llm/skills/github-actions/cicd-devcontainer-workflows.md new file mode 100644 index 00000000..499bc9fb --- /dev/null +++ b/.llm/skills/github-actions/cicd-devcontainer-workflows.md @@ -0,0 +1,205 @@ +--- +title: "CI/CD Devcontainer Workflows" +id: "cicd-devcontainer-workflows" +category: "github-actions" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".github/workflows/devcontainer-prebuild.yml" + - path: ".github/workflows/devcontainer-test.yml" + - path: ".devcontainer/Dockerfile" + - path: ".devcontainer/devcontainer.json" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "github-actions" + - "ci-cd" + - "devcontainer" + - "ghcr" + - "docker" + - "prebuild" + +complexity: + level: "intermediate" + reasoning: "Requires understanding the devcontainers/ci action's event-filter default and GHCR push semantics." + +impact: + performance: + rating: "high" + details: "Pre-built images cut new-contributor onboarding from 15 min to under 2 min" + maintainability: + rating: "high" + details: "Single skill captures the silent-failure gotcha so it does not bite the next contributor" + testability: + rating: "low" + details: "Verified by the explicit pull-back step in devcontainer-prebuild.yml" + +prerequisites: + - "Familiarity with GitHub Actions workflow YAML" + - "Awareness of GHCR authentication and image naming" + +dependencies: + packages: [] + skills: + - "devcontainer-cache-contract" + +applies_to: + languages: + - "YAML" + frameworks: + - "GitHub Actions" + versions: {} + +aliases: + - "Devcontainer CI" + - "GHCR prebuild" + - "eventFilterForPush" + +related: + - "devcontainer-cache-contract" + - "headless-test-runner" + - "unity-ci-matrix" + +status: "stable" +--- + + + +# CI/CD Devcontainer Workflows + +> **One-line summary**: When using `devcontainers/ci@v0.3` to push images to GHCR, set `eventFilterForPush: ""` explicitly; the default value silently skips pushes on `schedule` and `workflow_dispatch` triggers, breaking pre-build and dispatch flows without any error in the log. + +## When to Use + +- Creating or modifying devcontainer CI/CD workflows. +- Debugging image push failures to GHCR. +- Setting up GHCR caching for devcontainer builds. +- Investigating CI failures related to Docker image operations. + +## When NOT to Use + +- Unity-specific CI (build, test, deploy Unity projects). See [unity-ci-matrix](../unity/unity-ci-matrix.md). +- General Docker operations unrelated to devcontainers. +- Local devcontainer development (no CI involved). + +## Critical: `devcontainers/ci@v0.3` Event Filter Gotcha + +The `devcontainers/ci` action has a subtle and dangerous default that silently skips image pushes. + +### The Problem + +`eventFilterForPush` defaults to `"push"`, which acts as a universal gate on ALL push decisions, including `push: always`. This means: + +| Trigger | `github.event_name` | Matches default filter? | Push happens? | +| ------------------- | ------------------- | ----------------------- | ------------- | +| `push` to branch | `push` | Yes | Yes | +| `schedule` (cron) | `schedule` | No | No | +| `workflow_dispatch` | `workflow_dispatch` | No | No | + +### The Fix + +Always set `eventFilterForPush: ""` to disable the event gate: + +```yaml +- uses: devcontainers/ci@v0.3 + with: + imageName: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + cacheFrom: ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer + push: always + # Required: devcontainers/ci defaults eventFilterForPush to "push", + # which silently skips push on schedule/workflow_dispatch triggers + eventFilterForPush: "" +``` + +The DxMessaging workflows that need this: + +- `.github/workflows/devcontainer-prebuild.yml` (weekly cron + dispatch). +- `.github/workflows/devcontainer-test.yml` (uses `push: filter` plus `refFilterForPush: refs/heads/master`; the empty `eventFilterForPush` makes the ref filter the single source of truth). + +### Why This Is Dangerous + +- The push failure is silent. No error, no warning in logs. +- The build succeeds, the image exists locally, but never reaches the registry. +- Downstream steps that `docker pull` the image fail with a misleading "not found" error. +- The bug only manifests on `schedule` / `workflow_dispatch` triggers, making it hard to catch on regular PR runs. + +## Required Workflow Patterns + +### GHCR Lowercase Repository Name + +GHCR requires lowercase image names. Always convert: + +```yaml +- name: Set lowercase repository name + id: repo + run: | + set -euo pipefail + repo_lower=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "repository_lowercase=${repo_lower}" >> "${GITHUB_OUTPUT}" +``` + +Then use `${{ steps.repo.outputs.repository_lowercase }}` in every `imageName` and `cacheFrom`. + +### Debug Context Step + +Include a debug step early in the workflow: + +```yaml +- name: Debug workflow context + run: | + echo "Event name: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Actor: ${{ github.actor }}" +``` + +The repo's `wallstop-studios/com.wallstop-studios.dxmessaging` slug forces a lowercase conversion every time, so the debug step also surfaces a wrong-actor or wrong-ref problem before it consumes 15 minutes of runner time. + +### Diagnostic Verification + +When verifying a pushed image, include diagnostics: + +```yaml +- name: Verify image + run: | + set -euo pipefail + IMAGE="ghcr.io/${{ steps.repo.outputs.repository_lowercase }}/devcontainer:latest" + echo "=== Local Docker images ===" + docker images | grep devcontainer || echo "No local images found" + echo "=== Pulling from GHCR ===" + if docker pull "${IMAGE}"; then + echo "Image successfully pulled from GHCR" + else + echo "ERROR: Failed to pull from GHCR. Push may have failed." >&2 + exit 1 + fi +``` + +`devcontainer-prebuild.yml` already includes this step. It is the only check that catches a silently-failed push when `eventFilterForPush` was forgotten. + +## Common Pitfalls + +| Pitfall | Impact | Prevention | +| -------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- | +| Missing `eventFilterForPush: ""` | Silent push failure on schedule / dispatch | Always set it explicitly | +| Using `${{ github.repository }}` directly in image names | Case mismatch breaks GHCR | Always lowercase convert via the `set lowercase` step | +| `cacheFrom` on first build | Cache miss warnings (non-fatal) | Expected on bootstrap; no action needed | +| Double `devcontainers/ci` steps building same image | Wasted CI time | Use `cacheFrom` for the second step | +| No debug context step | Hard to diagnose trigger-related issues | Always add the debug step | +| Referencing gitignored files in workflow `paths:` | CI-only failure: file exists locally but not on runner | Run the workflow path linter before merging | +| `permissions: packages: write` missing | Push fails with 403 | Add it at the job level for prebuild workflows | + +## Related Skills + +- [Devcontainer Cache Contract](../unity/devcontainer-cache-contract.md) +- [Headless Test Runner](../unity/headless-test-runner.md) +- [Unity CI Matrix](../unity/unity-ci-matrix.md) + +## References + +- `devcontainers/ci` action: https://github.com/devcontainers/ci +- GHCR docs: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry +- Source: `.github/workflows/devcontainer-prebuild.yml`, `.github/workflows/devcontainer-test.yml` diff --git a/.llm/skills/index.md b/.llm/skills/index.md index e61c4c2c..e441b642 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -9,7 +9,7 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 148 | +| Total Skills | 155 | | Categories | 8 | --- @@ -17,13 +17,13 @@ ## Table of Contents - [Documentation](#documentation) (27) -- [GitHub Actions](#github-actions) (5) +- [GitHub Actions](#github-actions) (6) - [Packaging](#packaging) (2) - [Performance](#performance) (45) - [Scripting](#scripting) (15) - [Solid](#solid) (15) - [Testing](#testing) (38) -- [Unity](#unity) (1) +- [Unity](#unity) (7) --- @@ -63,6 +63,7 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ------------------------------------------------------------------------------------------------------ | ---------- | -------------- | -------- | -------------- | --------------------- | +| [CI/CD Devcontainer Workflows](./github-actions/cicd-devcontainer-workflows.md) | [ok] 206 | [intermediate] | [stable] | [risk: high] | github-actions, ci-cd | | [Git Renormalize Pattern Validation](./github-actions/git-renormalize-patterns.md) | [ok] 232 | [intermediate] | [stable] | [risk: low] | github-actions, git | | [GitHub Actions Workflow Consistency](./github-actions/workflow-consistency.md) | [ok] 183 | [intermediate] | [stable] | [risk: medium] | github-actions, ci-cd | | [GitHub Actions Workflow Consistency Part 1](./github-actions/workflow-consistency-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | @@ -211,9 +212,15 @@ ## Unity -| Skill | Lines | Complexity | Status | Performance | Tags | -| ------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | --------------- | -| [MessageAwareComponent Base-Call Contract](./unity/base-call-contract.md) | [warn] 267 | [intermediate] | [stable] | [risk: none] | unity, analyzer | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | -------------------- | +| [Devcontainer Cache Contract](./unity/devcontainer-cache-contract.md) | [ok] 180 | [intermediate] | [stable] | [risk: high] | devcontainer, docker | +| [Headless Unity Test Runner](./unity/headless-test-runner.md) | [ok] 224 | [intermediate] | [stable] | [risk: none] | unity, testing | +| [MessageAwareComponent Base-Call Contract](./unity/base-call-contract.md) | [warn] 267 | [intermediate] | [stable] | [risk: none] | unity, analyzer | +| [Unity CI Matrix](./unity/unity-ci-matrix.md) | [ok] 193 | [intermediate] | [stable] | [risk: low] | unity, ci | +| [Unity License Bootstrap](./unity/unity-license-bootstrap.md) | [ok] 216 | [basic] | [stable] | [risk: none] | unity, license | +| [Unity Perf Test Isolation](./unity/unity-perf-test-isolation.md) | [ok] 211 | [intermediate] | [stable] | [risk: high] | unity, performance | +| [UPM Test Harness](./unity/upm-test-harness.md) | [ok] 207 | [basic] | [stable] | [risk: none] | unity, upm | --- diff --git a/.llm/skills/unity/devcontainer-cache-contract.md b/.llm/skills/unity/devcontainer-cache-contract.md new file mode 100644 index 00000000..3c5b7317 --- /dev/null +++ b/.llm/skills/unity/devcontainer-cache-contract.md @@ -0,0 +1,179 @@ +--- +title: "Devcontainer Cache Contract" +id: "devcontainer-cache-contract" +category: "unity" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".devcontainer/cache-contract.sh" + - path: ".devcontainer/devcontainer.json" + - path: ".devcontainer/post-create.sh" + - path: ".devcontainer/post-start.sh" + - path: ".devcontainer/validate-caching.sh" + - path: ".devcontainer/Dockerfile" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "devcontainer" + - "docker" + - "cache" + - "volumes" + - "ownership" + +complexity: + level: "intermediate" + reasoning: "Requires understanding Docker named-volume ownership semantics and the multi-source contract pattern." + +impact: + performance: + rating: "high" + details: "Warm caches save minutes per devcontainer rebuild and Unity run" + maintainability: + rating: "high" + details: "One contract file is the single source of truth across four configuration surfaces" + testability: + rating: "high" + details: "validate-caching.sh + Phase 4 Jest contract test enforce the contract on every PR" + +prerequisites: + - "Familiarity with Docker named volumes and bind mounts" + - "Awareness of devcontainer lifecycle (postCreate, postStart)" + +dependencies: + packages: [] + skills: + - "headless-test-runner" + +applies_to: + languages: + - "Bash" + - "JSON" + frameworks: + - "Docker" + versions: {} + +aliases: + - "Cache contract" + - "Volume ownership" + +related: + - "headless-test-runner" + - "upm-test-harness" + - "cicd-devcontainer-workflows" + +status: "stable" +--- + + + +# Devcontainer Cache Contract + +> **One-line summary**: `.devcontainer/cache-contract.sh` is the single source of truth for the four devcontainer named-volume mounts; the same file is sourced by `post-create.sh`, `post-start.sh`, and `validate-caching.sh` so the four surfaces (Dockerfile, devcontainer.json, lifecycle scripts, validator) cannot drift. + +## When to Use + +- Adding a new persistent cache (npm modules, Cargo, pnpm, etc.). +- Removing or renaming a cache after deprecating a tool. +- Diagnosing "permission denied" errors writing into `~/.nuget` or `~/.dotnet/tools` after a container rebuild. +- Verifying CI sees the same mount shape as the local devcontainer. + +## When NOT to Use + +- Adding bind mounts (host paths). The contract covers named volumes only; bind mounts go directly in `devcontainer.json`. +- Per-container ephemeral state. `Temp/`, `Logs/`, build outputs belong inside the workspace, not in a shared volume. + +## Why This Exists + +Docker named volumes have a subtle ownership rule: when a volume is attached to a target directory for the first time, Docker copies the target's owner UID/GID onto the empty volume. Subsequent attaches keep that initial UID/GID regardless of the running container's user. If the first container that mounts the volume runs as root (which most build steps do), every later attach as `vscode` (uid 1000) sees an unwritable directory. + +The fix is two-pronged: + +1. The Dockerfile pre-creates each target with `vscode:vscode` ownership before any volume can attach. +1. `post-start.sh` re-runs `chown` on every container start, so an ownership drift (rare but possible after host upgrades) self-heals on the next attach. +1. `scripts/unity/run-tests.sh` and `scripts/unity/run-tests.ps1` install an `EXIT` trap inside each root-run `unityci/editor` container to return `.artifacts/unity`, `.unity-test-project/Builds`, and `.unity-test-project/Library` ownership to the invoking UID/GID even when Unity exits non-zero. + +`cache-contract.sh` is the table the devcontainer prongs read from. If a target is missing from the contract or misaligned by index, the validator fails loud rather than the developer hitting "permission denied" three minutes into a build. + +Unity `Library/` is intentionally not in this devcontainer contract. The headless runner owns that cache with a Docker volume name derived from the Unity image tag and test mode, so switching between Editor versions or IL2CPP/editor modes cannot reuse an incompatible `Library/`. + +## The Contract + +Four entries, sources and targets aligned by array index: + +| Index | Source (volume name) | Target (in-container path) | Purpose | +| ----- | ------------------------ | -------------------------------------- | ---------------------------------------- | +| 0 | `dxm-nuget-cache` | `/home/vscode/.nuget` | NuGet package cache for `dotnet restore` | +| 1 | `dxm-dotnet-tools` | `/home/vscode/.dotnet/tools` | Global dotnet tools (csharpier, etc.) | +| 2 | `dxm-powershell-modules` | `/home/vscode/.local/share/powershell` | PowerShell module cache | +| 3 | `dxm-python-cache` | `/home/vscode/.cache/pip` | pip wheel/download cache | + +Source (verbatim) lives in `.devcontainer/cache-contract.sh`: + +```bash +readonly CACHE_MOUNT_SOURCES=( + "dxm-nuget-cache" + "dxm-dotnet-tools" + "dxm-powershell-modules" + "dxm-python-cache" +) + +readonly CACHE_MOUNT_TARGETS=( + "/home/vscode/.nuget" + "/home/vscode/.dotnet/tools" + "/home/vscode/.local/share/powershell" + "/home/vscode/.cache/pip" +) +``` + +The arrays are `readonly`. The file uses a re-source guard so the validator and lifecycle scripts can both `source` it inside the same shell without aborting under `set -e`. + +## How the Validator Works + +`bash .devcontainer/validate-caching.sh` runs four blocks of checks: + +1. **Contract shape**: arrays exist, are non-empty, and have equal length. +1. **Static wiring**: Dockerfile has the BuildKit `# syntax=` directive and apt cache mounts; `post-create.sh` and `post-start.sh` both `source cache-contract.sh`. +1. **devcontainer.json mounts**: every contract entry appears in `mounts`; `remoteUser` is `vscode`. +1. **Runtime mount state** (only inside a container): each target is a real mount point owned by `vscode:vscode`, and a write probe succeeds. + +Outside a container the runtime block is skipped with a single warning. Inside the devcontainer, every assertion is a hard failure. The Phase 3 `devcontainer-test.yml` workflow runs `validate-caching.sh` and the Phase 4 contract test (`devcontainer-cache-contract.test.js`) statically verifies the mount list. + +## Adding a New Cache Mount + +A new mount must be added in three places, in this order: + +1. Append to BOTH arrays in `.devcontainer/cache-contract.sh`. Same index in both. Pick a name with the `dxm-` prefix. +1. Append the matching `source=...,target=...,type=volume` entry to `mounts` in `.devcontainer/devcontainer.json`. +1. Pre-create the target in `.devcontainer/Dockerfile` with `vscode:vscode` ownership: + + ```dockerfile + RUN install -d -o vscode -g vscode /home/vscode/.cache/ + ``` + +After the change, run: + +```bash +bash .devcontainer/validate-caching.sh +``` + +A pre-commit hook re-runs the validator when files under `.devcontainer/` change, so the three-place edit is enforced before the commit lands. + +## Removing a Cache Mount + +Remove in the inverse order: devcontainer.json first (so a fresh build does not request a mount that no longer has a target), then `cache-contract.sh`, then optionally the Dockerfile pre-create line. Do NOT remove the volume itself with `docker volume rm` until you are sure no team member's container is still running against it. + +## See Also + +- [Headless Test Runner](./headless-test-runner.md) +- [UPM Test Harness](./upm-test-harness.md) +- [CI/CD Devcontainer Workflows](../github-actions/cicd-devcontainer-workflows.md) + +## References + +- Docker volumes: https://docs.docker.com/storage/volumes/ +- Devcontainer mounts: https://containers.dev/implementors/json_reference/#mounts +- Source: `.devcontainer/cache-contract.sh` diff --git a/.llm/skills/unity/headless-test-runner.md b/.llm/skills/unity/headless-test-runner.md new file mode 100644 index 00000000..fbda1202 --- /dev/null +++ b/.llm/skills/unity/headless-test-runner.md @@ -0,0 +1,223 @@ +--- +title: "Headless Unity Test Runner" +id: "headless-test-runner" +category: "unity" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "scripts/unity/run-tests.sh" + - path: "scripts/unity/run-tests.ps1" + - path: "scripts/unity/lib/asmdef-discovery.js" + - path: "scripts/unity/lib/parse-test-results.py" + - path: ".github/workflows/unity-tests.yml" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "testing" + - "devcontainer" + - "docker" + - "test-runner" + - "headless" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of docker-outside-of-docker, Unity batchmode flags, and the asmdef classification module." + +impact: + performance: + rating: "none" + details: "Tooling only; no runtime cost" + maintainability: + rating: "high" + details: "One canonical entry point covers EditMode, PlayMode, and IL2CPP standalone runs" + testability: + rating: "high" + details: "Phase 4 contract tests pin the flag matrix and exclusion rules" + +prerequisites: + - "Devcontainer running with docker-outside-of-docker enabled" + - "UNITY_LICENSE, UNITY_LICENSE_B64, or UNITY_SERIAL path configured (see unity-license-bootstrap)" + +dependencies: + packages: [] + skills: + - "unity-license-bootstrap" + - "upm-test-harness" + - "unity-perf-test-isolation" + - "devcontainer-cache-contract" + +applies_to: + languages: + - "Bash" + - "PowerShell" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Unity test runner" + - "run-tests.sh" + - "Headless Unity" + +related: + - "unity-license-bootstrap" + - "upm-test-harness" + - "unity-perf-test-isolation" + - "unity-ci-matrix" + - "devcontainer-cache-contract" + - "cicd-devcontainer-workflows" + +status: "stable" +--- + + + +# Headless Unity Test Runner + +> **One-line summary**: `bash scripts/unity/run-tests.sh --platform ` is the canonical command to run Unity tests inside the devcontainer; it spawns an ephemeral `unityci/editor` container via the host docker socket and streams the log to stdout. + +## When to Use + +- Iterating on Runtime/Editor code that has Unity tests under `Tests/Editor` or `Tests/Runtime`. +- Reproducing a CI failure from `unity-tests.yml` or `unity-il2cpp.yml` locally. +- Smoke-testing a change to `scripts/unity/lib/asmdef-discovery.js` or the test harness. +- Verifying the perf-isolation contract by running with and without `--include-perf`. + +## When NOT to Use + +- Source-generator tests under `SourceGenerators/`. Use `dotnet test` directly; those tests run on the .NET SDK and do not need Unity. +- Standalone analyzer unit tests. Same: `dotnet test SourceGenerators/...Tests`. +- Pure documentation or markdown changes; no Unity surface to exercise. +- Apple Silicon (ARM Mac) hosts. See the limitation section below. + +## Command Reference + +| Flag | Type | Default | When to Set | +| ------------------------ | ---------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- | +| `--platform` | enum (req) | none | `editmode`, `playmode`, or `standalone`. Required. | +| `--unity-version` | string | `2022.3.45f1` (or `$UNITY_VERSION`) | Pin a different Editor version when reproducing a matrix-specific failure. | +| `--filter` | regex | empty | Forward to Unity's `-testFilter`. Use to narrow to a single fixture or namespace. | +| `--include-perf` | bool flag | off | Include `Benchmarks` / `Allocations` asmdefs. PR gate keeps these excluded. | +| `--include-integrations` | bool flag | off | Include `VContainer` / `Zenject` / `Reflex` suites. Requires those packages in `manifest.json`. | +| `--include-comparisons` | bool flag | off | Include external comparison benchmarks. Requires MessagePipe / UniRx / UniTask / Zenject. | +| `--results` | path | `.artifacts/unity/results.xml` | Override NUnit XML output path. Must live under the repo (bind-mount limit). | +| `--help` | flag | - | Print usage and exit 0. | + +The defaults match `defaultIncludeAssemblies(repoRoot)` from `scripts/unity/lib/asmdef-discovery.js`. That module is the single source of truth and is also called by `unity-tests.yml`. + +## Expected Runtimes + +Numbers below assume the mode-specific `dxm-unity-library--` volume is warm. First-ever run on a fresh image pulls roughly 6 GB into the `unityci/editor:` layer cache; that pull is one-time per Unity version. + +| Mode | Cold (first pull) | Warm Library cache | Notes | +| ------------ | ----------------- | ------------------ | ----------------------------------------------------------- | +| `editmode` | ~6-10 min | ~30-90 s | Cheapest. Runs in the Editor's edit-time NUnit harness. | +| `playmode` | ~7-12 min | ~2-5 min | Spins up a play-mode test runner; longer domain reload. | +| `standalone` | ~15-25 min | ~10+ min | IL2CPP build pass plus runtime pass; AOT compile dominates. | + +If a warm run takes more than 2x the expected time, the Library cache is likely cold or a domain reload is looping. Inspect `.artifacts/unity/log.txt`. + +## Realtime Feedback + +- `-logFile -` streams Unity's log to stdout while the container is alive. The script also `tee`s the same stream into `.artifacts/unity/log.txt`. +- NUnit XML lands at `.artifacts/unity/results.xml` (or `--results` override). The script invokes `python3 scripts/unity/lib/parse-test-results.py` on that XML and prints a one-line `PASS` or `FAIL` summary; the script's exit code matches the test status. +- For IL2CPP standalone runs the build pass writes `.artifacts/unity/build-log.txt` and the run pass writes `.artifacts/unity/log.txt`. Both are kept on the volume so a failure can be diffed against the prior run. + +## License Setup + +The runner refuses to launch without a supported Unity license path. The exact bootstrap flow lives in [unity-license-bootstrap](./unity-license-bootstrap.md): + +1. For GameCI-compatible ULF activation, set raw `.ulf` contents in `UNITY_LICENSE`. +1. For local shell profiles, run `bash scripts/unity/activate-license.sh --apply ` and add the printed `UNITY_LICENSE_B64` export. +1. For paid serial activation, set `UNITY_SERIAL`, `UNITY_EMAIL`, and `UNITY_PASSWORD`. +1. Run `bash scripts/unity/activate-license.sh --check` before the first test run. + +The devcontainer forwards `UNITY_LICENSE`, `UNITY_LICENSE_B64`, `UNITY_SERIAL`, `UNITY_EMAIL`, `UNITY_PASSWORD`, and `LOCAL_WORKSPACE_FOLDER` from the host via `remoteEnv` in `.devcontainer/devcontainer.json`. Inside a devcontainer the local runner still prefers `docker inspect` of the current container mount over `LOCAL_WORKSPACE_FOLDER`; this avoids passing Windows drive-letter paths to a Linux Docker CLI. + +## Iteration Patterns + +Run a single fixture by class name: + +```bash +bash scripts/unity/run-tests.sh --platform editmode --filter 'MessageBusBasicTests' +``` + +Reproduce a regression on the oldest supported LTS: + +```bash +bash scripts/unity/run-tests.sh --platform playmode --unity-version 2021.3.45f1 +``` + +Run the perf suite locally (no CI parity; use sparingly): + +```bash +bash scripts/unity/run-tests.sh --platform editmode --include-perf +``` + +Run external comparison benchmarks after adding their packages to the harness manifest: + +```bash +bash scripts/unity/run-tests.sh --platform editmode --include-comparisons +``` + +Build and run the IL2CPP test player end-to-end: + +```bash +bash scripts/unity/run-tests.sh --platform standalone +``` + +Diff the discovered assembly list before changing the runner default: + +```bash +node scripts/unity/lib/asmdef-discovery.js +``` + +## Failure Tree + +Pick the matching error signature in stdout, then apply the listed remediation. + +| Signature | Cause | Remediation | +| ------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `No Unity license configured.` | ULF and serial paths are unset. | Configure `UNITY_LICENSE`, `UNITY_LICENSE_B64`, or `UNITY_SERIAL` plus credentials. | +| `com.unity.editor.headless` / `No valid Unity Editor license found` | Email/password-only path or invalid entitlement. | Configure a `.ulf` or paid serial path; email/password alone is unsupported for headless docker runs. | +| `Error: docker socket is not reachable.` | DooD feature is missing or socket is not mounted. | Verify `docker-outside-of-docker` in `.devcontainer/devcontainer.json`. Rebuild the container. | +| Hangs at `Pulling unityci/editor:...` | Slow registry pull or rate limit. | First pull is ~6 GB; let it finish. If it hangs > 20 min, retry; the volume keeps partial layers. | +| `ERROR: 0 tests ran. Check filter / assembly list.` | `--filter` matched nothing or the asmdef list excluded all. | Re-run without `--filter`; verify discovery via `node scripts/unity/lib/asmdef-discovery.js`. | +| `IL2CPP build failed (exit ...).` | Code-stripping, AOT, or generic-virtual-method regression. | See [unity-ci-matrix](./unity-ci-matrix.md) for the IL2CPP-only failure catalog. | +| `IL2CPP build reported success but binary missing at ...` | TestRunnerBuilder wrote elsewhere or build silently aborted. | Check `.artifacts/unity/build-log.txt`; verify `DXM_IL2CPP_BUILD_PATH` is consistent with the runner. | +| `Cannot determine host path for the workspace.` | Inside a container without an inspectable bind mount. | Set `DXM_HOST_REPO_ROOT=/absolute/path/on/host` before invoking the script. | +| `Activation rate limit` in Unity log | Too many license activations in a short window. | Wait ~1 hour. See [unity-license-bootstrap](./unity-license-bootstrap.md) for the cooldown details. | + +## ARM Mac (Apple Silicon) Limitation + +`unityci/editor` images are amd64-only as of 2026-05. Running them via `docker run` on Apple Silicon falls back to QEMU emulation, which is roughly 10x slower and frequently hangs the editor during domain reload. There are two sanctioned paths on M-series Macs: + +1. Skip local Unity runs. Rely on the `unity-tests.yml` PR gate (free for public repos, ~5 min per matrix cell). +1. Open the repo in a hosted GitHub Codespace (`gh codespace create`). The Codespace runs on amd64 hardware and the in-container Unity flow works the same as on Linux/Windows hosts. + +`.llm/context.md` carries a single-line warning so an agent flags this proactively when `uname -m` returns `arm64`. + +## CI Parity + +When `CI=true` is set, the script does NOT spawn docker locally. It prints the equivalent `game-ci/unity-test-runner@v4` parameters and exits 0. This is what `unity-tests.yml` consumes. The shape is locked by a Phase 4 contract test (`unity-runner-script-contract.test.js`) so help text, flag names, and the assembly source-of-truth cannot drift apart. + +## See Also + +- [Unity License Bootstrap](./unity-license-bootstrap.md) +- [UPM Test Harness](./upm-test-harness.md) +- [Unity Perf Test Isolation](./unity-perf-test-isolation.md) +- [Unity CI Matrix](./unity-ci-matrix.md) +- [Devcontainer Cache Contract](./devcontainer-cache-contract.md) +- [CI/CD Devcontainer Workflows](../github-actions/cicd-devcontainer-workflows.md) + +## References + +- Unity command-line arguments: https://docs.unity3d.com/Manual/CommandLineArguments.html +- game-ci unity-test-runner: https://github.com/game-ci/unity-test-runner +- Source: `scripts/unity/run-tests.sh` diff --git a/.llm/skills/unity/unity-ci-matrix.md b/.llm/skills/unity/unity-ci-matrix.md new file mode 100644 index 00000000..25df3e5f --- /dev/null +++ b/.llm/skills/unity/unity-ci-matrix.md @@ -0,0 +1,192 @@ +--- +title: "Unity CI Matrix" +id: "unity-ci-matrix" +category: "unity" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".github/workflows/unity-tests.yml" + - path: ".github/workflows/unity-il2cpp.yml" + - path: ".github/workflows/unity-benchmarks.yml" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "ci" + - "matrix" + - "il2cpp" + - "lts" + - "game-ci" + +complexity: + level: "intermediate" + reasoning: "Requires understanding game-ci action shapes, IL2CPP-specific failure modes, and Unity LTS cadence." + +impact: + performance: + rating: "low" + details: "Each matrix cell costs about 5 minutes of runner time" + maintainability: + rating: "high" + details: "One workflow per concern; the matrix is computed from a single dispatch input" + testability: + rating: "high" + details: "Phase 4 contract test pins the workflow shape and asmdef discovery source-of-truth" + +prerequisites: + - "Familiarity with GitHub Actions matrix expansion" + - "Awareness of Unity LTS release cadence" + +dependencies: + packages: [] + skills: + - "headless-test-runner" + - "unity-perf-test-isolation" + +applies_to: + languages: + - "YAML" + frameworks: + - "GitHub Actions" + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Unity matrix" + - "IL2CPP gate" + +related: + - "headless-test-runner" + - "unity-license-bootstrap" + - "unity-perf-test-isolation" + - "cicd-devcontainer-workflows" + +status: "stable" +--- + + + +# Unity CI Matrix + +> **One-line summary**: `unity-tests.yml` runs editmode and playmode against three Unity versions; `unity-il2cpp.yml` runs the AOT-compiled standalone player against a single version on every PR plus weekly cron; expand the matrix only when a new LTS ships or a user reports a version-specific bug. + +## When to Use + +- Adding a new Unity LTS release to the supported set. +- Triaging an IL2CPP-only test failure that does not reproduce in EditMode. +- Investigating a game-ci log that fails before any test prints output. +- Deciding whether to expand or contract the matrix to balance signal vs runtime. + +## When NOT to Use + +- Tweaking which assemblies run. That is the asmdef-discovery module's responsibility (see [unity-perf-test-isolation](./unity-perf-test-isolation.md)). +- Adjusting cache keys. Those live in the workflow's `actions/cache@v4` block; they hash `manifest.json` + `packages-lock.json` + `ProjectVersion.txt`. + +## Current Matrix + +`unity-tests.yml` (PR gate, fast feedback): + +| Axis | Values | +| --------------- | ------------------------------------------- | +| `unity-version` | `2021.3.45f1`, `2022.3.45f1`, `6000.0.32f1` | +| `test-mode` | `editmode`, `playmode` | + +Six matrix cells. Workflow_dispatch inputs let you pin a single version or single mode for triage. + +`unity-il2cpp.yml` (PR gate, slower): + +| Axis | Values | +| --------------- | ------------- | +| `unity-version` | `2022.3.45f1` | + +Single cell on PRs to keep the gate under ~15 minutes; weekly cron currently uses the same single version (the matrix-config job is already shaped to expand without code changes). + +`unity-benchmarks.yml` (workflow_dispatch + nightly cron, NEVER on PRs): + +| Axis | Values | +| --------------- | ---------------------- | +| `unity-version` | `2022.3.45f1` | +| `test-mode` | `editmode`, `playmode` | + +The `unity-benchmarks.yml` triggers explicitly omit `pull_request` and `push` per the perf isolation rule. + +## When to Add a Unity Version + +Add a version to `unity-tests.yml`'s `unity-versions` JSON array when one of the following is true: + +- A new LTS reaches general availability (e.g., when 2024.3 LTS or 7000.0 LTS ships) and the package's `package.json` `unity` field still permits it. +- A user files an issue reproducing only on a specific Editor version. +- Unity publishes a security patch on a currently-supported channel that the maintainer wants the gate to track. + +## How to Add a Unity Version + +1. Edit `.github/workflows/unity-tests.yml`. The matrix is computed in the `matrix-config` job: + + ```yaml + versions='["2021.3.45f1","2022.3.45f1","6000.0.32f1"]' + ``` + + Append the new tag to the JSON array. Use the `unityci/editor` tag format (e.g., `2024.3.10f1`). + +1. Verify the corresponding `unityci/editor:-base-3` (and `-linux-il2cpp-3` for `unity-il2cpp.yml`) image exists at `https://hub.docker.com/r/unityci/editor/tags`. game-ci publishes images shortly after Unity ships; if the tag is missing, wait or pick the nearest released patch. + +1. Run the runner locally to validate the new version: + + ```bash + bash scripts/unity/run-tests.sh --platform editmode --unity-version + ``` + +1. Push the workflow change. The first CI run will pull the new image (slow); subsequent runs hit the cache. + +The `actions/cache@v4` keys include `${{ matrix.unity-version }}`, mode, manifest hash, lockfile hash, and `ProjectVersion.txt`. Do not add broad `restore-keys` for `Library/`; restoring a Library from a different Unity version or package graph can corrupt domain reloads and make failures nondeterministic. A new version should start cold and warm on the next exact-key run. + +## IL2CPP-Only Failure Patterns + +IL2CPP exercises an AOT-compiled path that EditMode/PlayMode under Mono cannot. These regressions historically slip past the Mono gate and only surface when a downstream consumer builds a player. The catalog: + +| Pattern | Signature in log | Remediation | +| --------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Generic virtual method (GVM) call | `ExecutionEngineException: Attempting to call method 'X' for which no AOT code...` | Add a non-generic forwarder, mark with `[Preserve]`, or instantiate the generic at compile time. | +| Code stripping | `MissingMethodException` or `TypeLoadException` for a reflected type | Add the type to `link.xml`, or annotate with `[Preserve]`. See Unity managed-code-stripping docs. | +| Reflection over open generics | Tests pass under Mono, fail under IL2CPP with reflection-related null returns | Avoid open-generic reflection on the hot path; use the source generator instead. | +| Incremental Mono / IL2CPP serialization drift | `Library/` cache is stale and the build hangs at "Domain Reload" | Delete the Library cache (or bump the cache key prefix in the workflow); rebuild. | +| PInvoke / native-callable mismatch | `EntryPointNotFoundException` or `MarshalAs` complaints unique to IL2CPP | Audit `[DllImport]` signatures; verify calling convention. | + +The `avoid-reflection-on-hot-paths` skill (see Performance section of the index) covers reflection-related cases in detail. The DxMessaging codebase uses the source generator precisely to avoid most reflection at runtime. + +## Reading game-ci Logs + +A game-ci job log is structured. To diagnose a failure, scan in this order: + +1. **Pre-Unity setup**: `Setup Node.js`, `Cache Unity Library`, `Compute test assembly list`. Failures here are infrastructure, not test logic. +1. **License activation**: search for `LICENSE SYSTEM` or `License client failed`. See [unity-license-bootstrap](./unity-license-bootstrap.md). +1. **Editor startup**: search for `[Licensing]` or `Loading native plugins`. A timeout here usually means a corrupted Library cache. +1. **Domain reload**: search for `Reloading assemblies`. A hang here typically means a circular asmdef reference or a missing dependency. +1. **Test execution**: search for `Run tests on platform`. NUnit failures appear as `[Test Failed]` lines with stack traces. +1. **Result emission**: search for `Test results saved at`. Missing results XML almost always means the player crashed before tests completed. + +For IL2CPP runs, two distinct stages emit logs: + +- `game-ci/unity-builder@v4` builds the player. Failures here are AOT or stripping. +- The custom `Run IL2CPP test player` step launches the produced binary. Failures here are runtime AOT or test-logic. + +The IL2CPP workflow checks the player exit code BEFORE parsing the XML so a crash mid-run cannot look green. + +## See Also + +- [Headless Test Runner](./headless-test-runner.md) +- [Unity License Bootstrap](./unity-license-bootstrap.md) +- [Unity Perf Test Isolation](./unity-perf-test-isolation.md) +- [CI/CD Devcontainer Workflows](../github-actions/cicd-devcontainer-workflows.md) + +## References + +- game-ci docs: https://game.ci/docs/ +- Unity LTS roadmap: https://unity.com/releases/lts +- Unity managed code stripping: https://docs.unity3d.com/Manual/ManagedCodeStripping.html +- Source: `.github/workflows/unity-tests.yml`, `.github/workflows/unity-il2cpp.yml` diff --git a/.llm/skills/unity/unity-license-bootstrap.md b/.llm/skills/unity/unity-license-bootstrap.md new file mode 100644 index 00000000..0ac1c6a5 --- /dev/null +++ b/.llm/skills/unity/unity-license-bootstrap.md @@ -0,0 +1,215 @@ +--- +title: "Unity License Bootstrap" +id: "unity-license-bootstrap" +category: "unity" +version: "2.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "scripts/unity/activate-license.sh" + - path: "scripts/unity/run-tests.sh" + - path: ".devcontainer/devcontainer.json" + - path: ".github/workflows/unity-tests.yml" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "license" + - "ulf" + - "personal" + - "ci" + - "secrets" + +complexity: + level: "basic" + reasoning: "Two short configuration paths plus a 2FA caveat; no algorithmic content." + +impact: + performance: + rating: "none" + details: "Tooling only" + maintainability: + rating: "high" + details: "ULF and serial paths match current Unity/GameCI behavior" + testability: + rating: "low" + details: "Validated implicitly: the runner refuses to launch without a working license" + +prerequisites: + - "A Unity ID (sign-up at id.unity.com)" + - "Docker socket reachable inside the devcontainer (for live --check)" + +dependencies: + packages: [] + skills: + - "headless-test-runner" + +applies_to: + languages: + - "Bash" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Unity license" + - "ULF activation" + - "activate-license.sh" + - "Unity Personal ULF" + +related: + - "headless-test-runner" + - "unity-ci-matrix" + - "cicd-devcontainer-workflows" + +status: "stable" +--- + + + +# Unity License Bootstrap + +> **One-line summary**: Headless Unity in docker needs either a Unity `.ulf` or a paid serial. Use raw `.ulf` contents in `UNITY_LICENSE` for GameCI-compatible runs, `UNITY_LICENSE_B64` for local shell convenience, or `UNITY_SERIAL` + `UNITY_EMAIL` + `UNITY_PASSWORD` for Professional serial activation. + +## When to Use + +- First-time setup on a new dev machine, codespace, or CI runner. +- After a `.ulf` expires or a serial activation starts failing. +- When the runner reports `Error: No Unity license configured.` +- When a CI run fails with `License client failed` or `Failed to activate / find license`. + +## License Types + +| Type | Activation Method | Cost | Notes | +| ------------ | ------------------------------------ | ---- | ----------------------------------------------------------------- | +| Personal | Raw `.ulf` + Unity credentials | Free | Email/password alone is not a supported headless container path. | +| Professional | Serial + Unity credentials | Paid | Use `UNITY_SERIAL`; do not mix serial activation with `.ulf`. | +| Local ULF | Base64 `.ulf` in `UNITY_LICENSE_B64` | Any | Local-only convenience for shell profiles; not the GameCI secret. | +| Enterprise | Floating license server | Paid | `UNITY_LICENSING_SERVER` URL + client cert; not first-class here. | + +The runner picks the path automatically: `UNITY_LICENSE` set -> raw `.ulf`; +`UNITY_LICENSE_B64` set -> local base64 `.ulf`; `UNITY_SERIAL` plus +`UNITY_EMAIL` plus `UNITY_PASSWORD` set -> serial activation. `UNITY_EMAIL` + +`UNITY_PASSWORD` alone exits 2 with remediation because current Unity +licensing does not grant the headless entitlement that way. + +## Personal / ULF Path + +Recommended for CI when using GameCI and for contributors with a Unity `.ulf`. + +1. Obtain a `.ulf` through Unity Hub or Unity's manual activation flow. + +1. For GitHub Actions/GameCI, paste the raw `.ulf` file contents into the + `UNITY_LICENSE` repository secret. Also set the account credentials: + + ```bash + export UNITY_LICENSE="$(cat path/to/Unity_lic.ulf)" + export UNITY_EMAIL='you@example.com' + export UNITY_PASSWORD='your-password' + ``` + +1. For a local shell profile, use the base64 convenience variable instead of + trying to store multiline XML: + + ```bash + bash scripts/unity/activate-license.sh --apply path/to/Unity_lic.ulf + ``` + + Add the printed `UNITY_LICENSE_B64` export to your shell profile. + +1. Verify: `bash scripts/unity/activate-license.sh --check` validates the + configured secret shape. + +1. Run: `bash scripts/unity/run-tests.sh --platform editmode`. + +### Email/Password-Only Caveat + +Do not configure only `UNITY_EMAIL` + `UNITY_PASSWORD`. A live repro on +2026-05-05 with `unityci/editor:2022.3.45f1-base-3` logged in successfully but +failed entitlement resolution with `com.unity.editor.headless` and +`No valid Unity Editor license found`. + +GameCI's current activation guide also treats Personal as a one-time `.ulf` +setup, not an email/password-only activation. + +### CI Secrets (Personal) + +| Secret | Value | Required | +| ---------------- | ---------------------- | -------- | +| `UNITY_EMAIL` | Unity account email | Yes | +| `UNITY_PASSWORD` | Unity account password | Yes | +| `UNITY_LICENSE` | Raw `.ulf` contents | Yes | + +The workflows under `.github/workflows/unity-*.yml` pass all three to +`game-ci/unity-test-runner@v4`; it picks the path from which secrets are set. + +## Professional Serial Path + +Use this path for paid serial activation. + +1. Set the paid Unity serial and account credentials: + + ```bash + export UNITY_SERIAL='XX-XXXX-XXXX-XXXX-XXXX-XXXX' + export UNITY_EMAIL='you@example.com' + export UNITY_PASSWORD='your-password' + ``` + +1. Verify: `bash scripts/unity/activate-license.sh --check`. + +1. Run: `bash scripts/unity/run-tests.sh --platform editmode`. + +### CI Secrets (Pro / Plus) + +| Secret | Value | Required | +| ---------------- | ---------------------------------------- | -------- | +| `UNITY_SERIAL` | Paid Unity serial | Yes | +| `UNITY_EMAIL` | Unity account email | Yes | +| `UNITY_PASSWORD` | Unity account password | Yes | +| `UNITY_LICENSE` | Leave unset when using serial activation | No | + +## Enterprise Path (Stub) + +Floating-license servers are not first-class here. Set +`UNITY_LICENSING_SERVER=` and provide the client cert per Unity +Enterprise support. `run-tests.sh` does not yet wire the cert through; +treat this as a future enhancement. + +## Common Failures + +| Signature | Cause | Remediation | +| ------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| `No Unity license configured` | ULF and serial paths unset. | Set `UNITY_LICENSE`, `UNITY_LICENSE_B64`, or `UNITY_SERIAL` plus credentials. | +| `com.unity.editor.headless` / `No valid Unity Editor license found` | Email/password-only path or invalid entitlement. | Configure a `.ulf` or paid serial path. | +| `License client failed to start` | Activation rate limit, expired `.ulf`, or wrong credentials. | Wait 1 hour; then verify the ULF/serial and account credentials. | +| `Activation rate limit` in the Unity log | Too many activations from one IP. | Cool down ~1 hour; do not retry in a loop. Use a single shared CI secret. | +| `LICENSE SYSTEM ... License is not valid for this build target` | The `.ulf` was issued for a different Unity major version. | Refresh the Hub on the issuing dev machine and re-run `--apply`. | +| `verification code` / `two-factor` in the log | 2FA is enabled on the Unity account. | Disable 2FA temporarily, or migrate to a dedicated CI account. | +| `Warn: does not look like a Unity license file.` | `--apply` was pointed at the wrong file. | Re-run with the actual `.ulf` from the Unity Hub install path. | +| Network unreachable from container | Corporate proxy or container egress blocked. | Set `HTTP_PROXY` / `HTTPS_PROXY` in the devcontainer env, or run the bootstrap on the host. | + +## Renewal + +- **ULF**: when the file expires or changes Unity version requirements, + refresh it and update `UNITY_LICENSE` in GitHub or `UNITY_LICENSE_B64` + locally. +- **Serial**: if activation fails after a license renewal, verify the serial + in the Unity dashboard and update `UNITY_SERIAL` if it changed. + +## See Also + +- [Headless Test Runner](./headless-test-runner.md) +- [Unity CI Matrix](./unity-ci-matrix.md) +- [CI/CD Devcontainer Workflows](../github-actions/cicd-devcontainer-workflows.md) + +## References + +- Unity license activation methods: +- Unity manual activation support: +- GameCI activation guide: +- GameCI test runner: +- Source: `scripts/unity/activate-license.sh`, `scripts/unity/run-tests.sh` diff --git a/.llm/skills/unity/unity-perf-test-isolation.md b/.llm/skills/unity/unity-perf-test-isolation.md new file mode 100644 index 00000000..b27c8994 --- /dev/null +++ b/.llm/skills/unity/unity-perf-test-isolation.md @@ -0,0 +1,210 @@ +--- +title: "Unity Perf Test Isolation" +id: "unity-perf-test-isolation" +category: "unity" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "scripts/unity/lib/asmdef-discovery.js" + - path: ".github/workflows/unity-tests.yml" + - path: ".github/workflows/unity-benchmarks.yml" + - path: "scripts/unity/run-tests.sh" + - path: ".llm/context.md" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "performance" + - "benchmarks" + - "isolation" + - "asmdef" + - "ci" + +complexity: + level: "intermediate" + reasoning: "Requires understanding the asmdef classification regex and the cross-cutting workflow / runner / context split." + +impact: + performance: + rating: "high" + details: "Keeps the PR gate at ~5 minutes by excluding perf suites that would otherwise dominate the runtime" + maintainability: + rating: "high" + details: "Single regex governs classification across runner, CI, and contract test" + testability: + rating: "high" + details: "Phase 4 contract test exercises every asmdef and asserts correct classification" + +prerequisites: + - "Familiarity with Unity asmdef files" + - "Awareness of the package's perf isolation rule (.llm/context.md)" + +dependencies: + packages: [] + skills: + - "headless-test-runner" + - "unity-ci-matrix" + +applies_to: + languages: + - "JavaScript" + - "YAML" + - "JSON" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Perf isolation" + - "Benchmark exclusion" + - "asmdef classification" + +related: + - "headless-test-runner" + - "unity-ci-matrix" + - "upm-test-harness" + +status: "stable" +--- + + + +# Unity Perf Test Isolation + +> **One-line summary**: Asmdefs whose name matches `Benchmarks|Allocations` are classified as `perf`; `Comparisons` assemblies are a separate external-package opt-in. Both are excluded from the PR gate by `scripts/unity/lib/asmdef-discovery.js`. + +## When to Use + +- Adding a new benchmark, allocation-counting, or library-comparison test suite. +- Investigating why a perf-looking asmdef does or does not run on a PR. +- Debugging a "0 tests ran" CI failure when the suite name pattern is suspect. +- Verifying the PR gate still excludes perf after a refactor. + +## When NOT to Use + +- Adding a regular correctness test. Those are `core` and run by default; no isolation work is needed. +- Adding a DI integration suite (VContainer / Zenject / Reflex). Those have their own classification (`integration`) and opt-in flag. + +## The Rule + +Source-of-truth is `.llm/context.md` line 114: + +> Benchmark and performance/allocation tests must stay isolated from the standard test suite. + +Operationally, this is enforced by classification in `scripts/unity/lib/asmdef-discovery.js`: + +```js +const PERF_NAME_REGEX = /(?:Benchmarks|Allocations)/; +const COMPARISON_NAME_REGEX = /(?:Comparisons)/; +``` + +Any asmdef under `Tests/` whose `name` field contains `Benchmarks` or `Allocations` is classified as `perf`; `Comparisons` is classified as `comparison` because those suites depend on external comparison packages that are not in the default harness manifest. The perf path assumes `.unity-test-project/Packages/manifest.json` includes `com.unity.test-framework.performance`, because benchmark and allocation asmdefs reference `Unity.PerformanceTesting`. Three things have to be true for the isolation to hold: + +1. Perf assemblies live under `Tests/Editor/Benchmarks`, `Tests/Editor/Allocations`, `Tests/Editor/Comparisons`, or `Tests/Runtime/Benchmarks`. +1. Their asmdef `name` field contains `Benchmarks`, `Allocations`, or `Comparisons` so classification matches. +1. They are NOT mentioned by name in any workflow's `customParameters`. The workflow reads its assembly list from `defaultIncludeAssemblies()`, never from a hand-edited list. + +## How Exclusion Works + +`scripts/unity/lib/asmdef-discovery.js` exports `defaultIncludeAssemblies(repoRoot, options)`. The behaviour: + +| Asmdef Class | Default Include? | Opt-in Flag | +| ------------- | ---------------- | ----------------------------------------------------------- | +| `core` | Yes | (always on) | +| `perf` | No | `{ includePerf: true }` or `--include-perf` | +| `comparison` | No | `{ includeComparisons: true }` or `--include-comparisons` | +| `integration` | No | `{ includeIntegrations: true }` or `--include-integrations` | + +Three callers consume this module: + +- `scripts/unity/run-tests.sh` builds its assembly list at startup and passes it to Unity via `-assemblyNames`. +- `scripts/unity/run-tests.ps1` does the same on Windows. +- `unity-tests.yml` and `unity-il2cpp.yml` shell out to `node -e "...defaultIncludeAssemblies(process.cwd())..."` in the `Compute test assembly list` step. +- `unity-benchmarks.yml` calls `defaultIncludeAssemblies(process.cwd(), { includePerf: true })` and skips integrations plus external comparisons. + +Because every caller goes through the same module, adding a new perf asmdef requires no edits to the workflows or runner scripts. + +## Adding a New Perf Asmdef + +1. Place the asmdef under `Tests/Editor/Benchmarks/`, `Tests/Editor/Allocations/`, or `Tests/Runtime/Benchmarks/`. +1. Set its `name` field to include one of the magic substrings. Examples that match: + - `WallstopStudios.DxMessaging.Tests.Editor.Benchmarks.Dispatch` + - `WallstopStudios.DxMessaging.Tests.Runtime.Allocations.Pooling` +1. Verify classification: + + ```bash + node scripts/unity/lib/asmdef-discovery.js + ``` + + The output groups asmdefs by category. Confirm the new entry shows `[perf]`. + +1. Confirm the PR gate excludes it: + + ```bash + bash scripts/unity/run-tests.sh --platform editmode + ``` + + The runner echoes the resolved assembly list at startup; the new asmdef should NOT appear. + +1. Confirm the benchmark workflow includes it: + + ```bash + bash scripts/unity/run-tests.sh --platform editmode --include-perf + ``` + + The new asmdef should now appear in the resolved list. + +If the asmdef ends up in the `core` bucket instead, the most common cause is the `name` field missing the magic substring. Rename the asmdef (and its file) so the substring is present. + +## Where Perf Actually Runs + +| Workflow | Triggers | Includes Perf? | +| ---------------------- | ---------------------------------------- | -------------- | +| `unity-tests.yml` | `pull_request`, `push: master`, dispatch | NO | +| `unity-il2cpp.yml` | `pull_request`, `push: master`, weekly | NO | +| `unity-benchmarks.yml` | `workflow_dispatch`, nightly cron | YES | + +`unity-benchmarks.yml` deliberately omits `pull_request` and `push` triggers. Verify any time you edit it: + +```bash +grep -A 3 "^on:" .github/workflows/unity-benchmarks.yml +``` + +## Comparison Suites + +Comparison asmdefs live under `Tests/Editor/Comparisons/` and benchmark against external libraries such as MessagePipe, UniRx, UniTask, and Zenject. They are excluded from `--include-perf` because the default `.unity-test-project/Packages/manifest.json` does not install those packages. To run them locally, add the external packages to the harness manifest and pass: + +```bash +bash scripts/unity/run-tests.sh --platform editmode --include-comparisons +``` + +The runner should print `comparisons=true` and include the comparison asmdef in the resolved assembly list. Unity will only compile that asmdef when the external comparison packages are installed, because its package-driven define constraints guard the whole assembly. + +## Phase 4 Contract Test + +`scripts/__tests__/unity-perf-isolation.test.js` (Phase 4B) enumerates every asmdef under `Tests/` and asserts: + +- Every asmdef matching the perf regex is classified as `perf`. +- Every asmdef NOT matching the perf or integration regex is classified as `core` and appears in `defaultIncludeAssemblies(repo)`. +- `unity-tests.yml` and `unity-il2cpp.yml` resolve their assembly lists via `defaultIncludeAssemblies` rather than hand-rolled YAML. +- `unity-benchmarks.yml` opts into perf via `{ includePerf: true }`. + +The test catches the silent regression "I added a new perf asmdef and forgot to update the exclusion list" because the exclusion list is computed, not hand-maintained. + +## See Also + +- [Headless Test Runner](./headless-test-runner.md) +- [Unity CI Matrix](./unity-ci-matrix.md) +- [UPM Test Harness](./upm-test-harness.md) +- [Devcontainer Cache Contract](./devcontainer-cache-contract.md) + +## References + +- Source: `scripts/unity/lib/asmdef-discovery.js` +- Source-of-truth: `.llm/context.md` +- Workflows: `.github/workflows/unity-tests.yml`, `.github/workflows/unity-benchmarks.yml` diff --git a/.llm/skills/unity/upm-test-harness.md b/.llm/skills/unity/upm-test-harness.md new file mode 100644 index 00000000..6fdc2844 --- /dev/null +++ b/.llm/skills/unity/upm-test-harness.md @@ -0,0 +1,206 @@ +--- +title: "UPM Test Harness" +id: "upm-test-harness" +category: "unity" +version: "1.0.0" +created: "2026-05-05" +updated: "2026-05-05" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".unity-test-project/Packages/manifest.json" + - path: ".unity-test-project/Packages/packages-lock.json" + - path: ".unity-test-project/ProjectSettings/ProjectVersion.txt" + - path: ".unity-test-project/Assets/Editor/TestRunnerBuilder.cs" + - path: ".unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "upm" + - "test-harness" + - "manifest" + - "testables" + +complexity: + level: "basic" + reasoning: "Five committed files; standard UPM testables semantics." + +impact: + performance: + rating: "none" + details: "Test infrastructure only" + maintainability: + rating: "high" + details: "Pinned manifest + lock + ProjectVersion guarantees reproducible test runs across machines and CI" + testability: + rating: "high" + details: "Without this harness, the package has no Unity surface to execute against" + +prerequisites: + - "Familiarity with Unity Package Manager (UPM)" + - "Awareness of asmdef structure" + +dependencies: + packages: [] + skills: + - "headless-test-runner" + +applies_to: + languages: + - "JSON" + - "C#" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Unity test project" + - ".unity-test-project" + - "Test harness" + +related: + - "headless-test-runner" + - "unity-ci-matrix" + - "unity-perf-test-isolation" + +status: "stable" +--- + + + +# UPM Test Harness + +> **One-line summary**: `.unity-test-project/` is a thin Unity host project whose only job is to import the package via `file:../..` and expose its `Tests/` asmdefs through the UPM `testables` field; everything else is gitignored. + +## When to Use + +- Adding a new `.asmdef` under `Tests/` and verifying it shows up in Test Runner. +- Reproducing a CI failure that needs a working Unity project on disk. +- Adding a UPM dependency that the test asmdefs need (e.g., a future Reflex DI integration). +- Diagnosing "Test framework not found" or empty test-run failures. + +## When NOT to Use + +- Editing source files for the package itself. Those live at the repo root (`Runtime/`, `Editor/`, `Tests/`); this harness only references them. +- Adding regular `Assets/` content. The harness intentionally ships exactly one `Assets/Editor` file and no scenes, sprites, or prefabs. + +## Architecture + +```text +repo-root/ ++-- package.json # the DxMessaging UPM package manifest ++-- Runtime/ # package source (asmdefs) ++-- Editor/ # package source (asmdefs) ++-- Tests/ +| +-- Editor/ # NUnit + UTF tests (asmdefs) +| +-- Runtime/ # PlayMode tests (asmdefs) ++-- .unity-test-project/ # thin host that imports the package + +-- Packages/ + | +-- manifest.json # "com.wallstop-studios.dxmessaging": "file:../..", + | | # plus "testables" exposing the package's Tests + | +-- packages-lock.json # committed for deterministic resolution + +-- ProjectSettings/ + | +-- ProjectVersion.txt # pinned to 2022.3.45f1 + +-- Assets/ + | +-- Editor/ + | +-- TestRunnerBuilder.cs # IL2CPP build entry point + | +-- WallstopStudios.DxMessaging.TestHarness.Editor.asmdef + +-- Library/ # gitignored, populated on first run + +-- Temp/ # gitignored + +-- Logs/ # gitignored + +-- Builds/ # gitignored, IL2CPP outputs land here +``` + +The shape is deliberate. UPM resolves `file:../..` to the repo root, the package surfaces its asmdefs, and the `testables` array tells Unity Test Framework to scan that package's Tests assemblies. The harness has zero application code. + +## Key Files + +| File | Role | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `Packages/manifest.json` | Declares the package via `file:../..` and lists it under `testables`. | +| `Packages/packages-lock.json` | Committed so the test environment resolves identically across machines and CI. | +| `ProjectSettings/ProjectVersion.txt` | Pinned Editor version. CI cache keys hash this; bumping it busts the Library cache. | +| `Assets/Editor/TestRunnerBuilder.cs` | `BuildPipeline.BuildPlayer` entry point invoked by `-executeMethod` for IL2CPP runs. | +| `Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef` | Editor-only asmdef that owns the `TestRunnerBuilder` class. | + +The current `manifest.json` (verbatim): + +```json +{ + "dependencies": { + "com.unity.test-framework": "1.4.5", + "com.unity.test-framework.performance": "3.4.2", + "com.unity.ide.rider": "3.0.31", + "com.unity.ide.visualstudio": "2.0.22", + "com.wallstop-studios.dxmessaging": "file:../.." + }, + "scopedRegistries": [], + "testables": ["com.wallstop-studios.dxmessaging"] +} +``` + +## What to Commit vs Gitignore + +Committed: + +- `Packages/manifest.json` +- `Packages/packages-lock.json` +- `ProjectSettings/ProjectVersion.txt` +- `Assets/Editor/TestRunnerBuilder.cs` and its `.meta` +- `Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef` and its `.meta` + +Gitignored (the repo `.gitignore` already covers these): + +- `.unity-test-project/Library/` +- `.unity-test-project/Temp/` +- `.unity-test-project/Logs/` +- `.unity-test-project/Builds/` +- `.unity-test-project/UserSettings/` +- `.unity-test-project/obj/` + +`Library/` is shared with the runner via a Docker volume whose name is derived from the Unity image tag and test mode (for example, `dxm-unity-library-2022.3.45f1-base-3-editmode`). This keeps local caches warm without allowing one Unity version or IL2CPP/editor mode to reuse another mode's `Library/`. + +## Adding a New Test Asmdef + +The `testables` field exposes every asmdef under the package's `Tests/` directory automatically; no harness change is needed for typical additions: + +1. Create the asmdef under `Tests/Editor//` (or `Tests/Runtime//`). +1. Set its `name` to a stable, descriptive identifier (e.g., `WallstopStudios.DxMessaging.Tests.Editor.NewSuite`). +1. Verify it shows up in the runner's discovery: + + ```bash + node scripts/unity/lib/asmdef-discovery.js + ``` + +1. Re-run the headless runner to confirm the new tests execute: + + ```bash + bash scripts/unity/run-tests.sh --platform editmode + ``` + +If the new asmdef is a perf or DI-integration suite, name it accordingly (`*Benchmarks*`, `*Allocations*`, `*Comparisons*`, `*VContainer*`, `*Zenject*`, `*Reflex*`). The classification regex in `scripts/unity/lib/asmdef-discovery.js` will excluded it from the default include list. See [unity-perf-test-isolation](./unity-perf-test-isolation.md). + +## Adding a Test Dependency + +When a new test suite needs an additional UPM package (a DI container, a third-party assertion library, etc.): + +1. Add it to `.unity-test-project/Packages/manifest.json` `dependencies` block. +1. Open the harness in Unity once locally so UPM can resolve and write `packages-lock.json`. Commit the regenerated lock. +1. Re-run the headless runner to confirm the new dependency loads cleanly. + +Avoid adding heavyweight runtime dependencies unless the corresponding tests can opt-in via the `--include-integrations` flag. The default suite stays lean so the PR gate stays under ~5 minutes. + +## See Also + +- [Headless Test Runner](./headless-test-runner.md) +- [Unity CI Matrix](./unity-ci-matrix.md) +- [Unity Perf Test Isolation](./unity-perf-test-isolation.md) + +## References + +- Unity Package Manager testables: https://docs.unity3d.com/Manual/cus-tests.html +- Unity Test Framework: https://docs.unity3d.com/Packages/com.unity.test-framework@1.4/manual/index.html +- Source: `.unity-test-project/Packages/manifest.json` diff --git a/.npmignore b/.npmignore index a9410e5c..138be61e 100644 --- a/.npmignore +++ b/.npmignore @@ -53,6 +53,10 @@ CLAUDE.md.meta # Devcontainer .devcontainer/ +# Unity test harness +.unity-test-project/ +.unity-test-project.meta + # Tool configuration .config/ @@ -120,4 +124,4 @@ GH-PAGES-PLAN.md.meta # ============================================================================= # Python Virtual Environment # ============================================================================= -.venv/ \ No newline at end of file +.venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b4496c8..bc266fb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -356,3 +356,30 @@ repos: stages: - pre-push description: Check spelling in changed files. Pre-push only; see .llm/skills/performance/git-hook-performance.md. + + - repo: local + hooks: + # perf-allow[scans-the-world-with-files,jest-at-pre-commit]: pre-push only; suite verifies cross-file invariants across cache mounts, asmdefs, workflows, allowlist, and skills. + - id: unity-contract-tests + name: Unity contract tests (Jest) + entry: node scripts/run-managed-jest.js + args: + - --runTestsByPath + - scripts/__tests__/unity-test-harness-contract.test.js + - scripts/__tests__/unity-runner-script-contract.test.js + - scripts/__tests__/devcontainer-cache-contract.test.js + - scripts/__tests__/unity-workflow-shape.test.js + - scripts/__tests__/unity-perf-isolation.test.js + - scripts/__tests__/claude-permissions-contract.test.js + - scripts/__tests__/llm-skills-unity-coverage.test.js + language: system + pass_filenames: false + files: >- + ^(\.devcontainer/|\.unity-test-project/|scripts/unity/|\.github/workflows/unity-|\.github/workflows/devcontainer-|\.claude/settings\.local\.json|\.llm/skills/unity/|\.llm/skills/github-actions/cicd-devcontainer-workflows\.md|\.llm/context\.md|Tests/.+/.+\.asmdef$) + stages: + - pre-push + description: >- + Run Unity / devcontainer contract Jest tests when their tracked + surface changes. Locks the Phase 1-3 deliverables (cache mounts, + asmdef classification, workflow shape, runner script flags, Claude + allowlist, skill file coverage). diff --git a/.unity-test-project/Assets/Editor.meta b/.unity-test-project/Assets/Editor.meta new file mode 100644 index 00000000..b770bb82 --- /dev/null +++ b/.unity-test-project/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 36c8fb5b592510972f0421d2f4808ccb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs b/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs new file mode 100644 index 00000000..5f0899a5 --- /dev/null +++ b/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs @@ -0,0 +1,128 @@ +// ============================================================================= +// TestRunnerBuilder.cs +// ============================================================================= +// Editor-only entry points used by scripts/unity/run-tests.sh (--platform +// standalone) to build and run an IL2CPP standalone test player. Invoked from +// the Unity command line via: +// +// -executeMethod +// WallstopStudios.DxMessaging.TestHarness.Editor.TestRunnerBuilder.BuildIL2CPPTestPlayer +// +// The harness lives entirely outside the package (under .unity-test-project/) +// and is not shipped to consumers. +// ============================================================================= +#if UNITY_EDITOR +namespace WallstopStudios.DxMessaging.TestHarness.Editor +{ + using System; + using System.IO; + using UnityEditor; + using UnityEditor.Build.Reporting; + using UnityEngine; + + public static class TestRunnerBuilder + { + private const string MenuRoot = "DxMessaging/Test Harness/"; + + // Output path for the IL2CPP test player. Resolved relative to + // .unity-test-project/. Kept in sync with scripts/unity/run-tests.sh + // and run-tests.ps1, which launch the produced binary as a separate + // step (we no longer use BuildOptions.AutoRunPlayer because the + // unityci/editor Linux IL2CPP container has no X server, and Unity's + // build-report does not propagate the player's test exit code). + // + // CI override: the GitHub Actions workflow (.github/workflows/ + // unity-il2cpp.yml) sets DXM_IL2CPP_BUILD_PATH to game-ci's expected + // output convention ($GITHUB_WORKSPACE/builds/StandaloneLinux64/ + // IL2CPPTests/Tests.x86_64). The local docker driver in + // scripts/unity/run-tests.sh{,ps1} sets it to the in-container + // equivalent. When unset (e.g. interactive Editor builds), the + // default below is used. + private const string DefaultBuildPathRelative = "Builds/IL2CPPTests/Tests.x86_64"; + private const string BuildPathEnvVar = "DXM_IL2CPP_BUILD_PATH"; + + [MenuItem(MenuRoot + "Build IL2CPP Test Player (Linux64)")] + public static void BuildIL2CPPTestPlayer() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string envOverride = Environment.GetEnvironmentVariable(BuildPathEnvVar); + string buildPath; + if (!string.IsNullOrEmpty(envOverride)) + { + // Treat the env var as authoritative. If a relative path was + // supplied, anchor it at the project root so callers don't + // have to know the editor's CWD. + buildPath = Path.IsPathRooted(envOverride) + ? Path.GetFullPath(envOverride) + : Path.GetFullPath(Path.Combine(projectRoot, envOverride)); + } + else + { + buildPath = Path.GetFullPath(Path.Combine(projectRoot, DefaultBuildPathRelative)); + } + string buildDir = Path.GetDirectoryName(buildPath); + if (!string.IsNullOrEmpty(buildDir) && !Directory.Exists(buildDir)) + { + Directory.CreateDirectory(buildDir); + } + + // IncludeTestAssemblies pulls all *.Tests.* asmdefs into the build. + // Development is required for Unity Test Framework's command-line + // test runner to be embedded in IL2CPP players (the runner uses + // development-only diagnostic hooks). The shell driver launches + // the produced binary with `-runTests -testResults ` and + // captures its exit code separately. + BuildPlayerOptions options = new BuildPlayerOptions + { + scenes = Array.Empty(), + locationPathName = buildPath, + target = BuildTarget.StandaloneLinux64, + targetGroup = BuildTargetGroup.Standalone, + options = BuildOptions.IncludeTestAssemblies | BuildOptions.Development, + }; + + PlayerSettings.SetScriptingBackend( + BuildTargetGroup.Standalone, + ScriptingImplementation.IL2CPP + ); + PlayerSettings.SetApiCompatibilityLevel( + BuildTargetGroup.Standalone, + ApiCompatibilityLevel.NET_Standard + ); + + BuildReport report = BuildPipeline.BuildPlayer(options); + BuildSummary summary = report.summary; + + Debug.Log( + $"[DxMessaging] IL2CPP test player build: result={summary.result}, " + + $"output={summary.outputPath}, totalErrors={summary.totalErrors}, " + + $"totalWarnings={summary.totalWarnings}" + ); + + if (summary.result != BuildResult.Succeeded) + { + EditorApplication.Exit(1); + } + else + { + EditorApplication.Exit(0); + } + } + + [MenuItem(MenuRoot + "Run PlayMode Tests Via Command Line")] + public static void RunPlayModeTestsViaCommandLine() + { + // Placeholder entry point: the canonical PlayMode invocation goes + // through `Unity -runTests -testPlatform PlayMode`. This method is + // exposed so future callers can opt into a programmatic launcher + // (for example UnityEditor.TestTools.TestRunner.Api.TestRunnerApi) + // without changing the runner script's flag surface. + Debug.Log( + "[DxMessaging] RunPlayModeTestsViaCommandLine invoked. " + + "Use `Unity -runTests -testPlatform PlayMode` for the canonical run path." + ); + EditorApplication.Exit(0); + } + } +} +#endif diff --git a/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs.meta b/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs.meta new file mode 100644 index 00000000..9a46dcf7 --- /dev/null +++ b/.unity-test-project/Assets/Editor/TestRunnerBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ac15d43625e0b2abe87c94755c969c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef b/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef new file mode 100644 index 00000000..1ff6224d --- /dev/null +++ b/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef @@ -0,0 +1,14 @@ +{ + "name": "WallstopStudios.DxMessaging.TestHarness.Editor", + "rootNamespace": "WallstopStudios.DxMessaging.TestHarness.Editor", + "references": [], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef.meta b/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef.meta new file mode 100644 index 00000000..16b48353 --- /dev/null +++ b/.unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7d80fa73f09ca57c30220011e1ba4347 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.unity-test-project/Packages/manifest.json b/.unity-test-project/Packages/manifest.json new file mode 100644 index 00000000..d7d84606 --- /dev/null +++ b/.unity-test-project/Packages/manifest.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "com.unity.test-framework": "1.4.5", + "com.unity.test-framework.performance": "3.4.2", + "com.unity.ide.rider": "3.0.31", + "com.unity.ide.visualstudio": "2.0.22", + "com.wallstop-studios.dxmessaging": "file:../.." + }, + "scopedRegistries": [], + "testables": ["com.wallstop-studios.dxmessaging"] +} diff --git a/.unity-test-project/Packages/packages-lock.json b/.unity-test-project/Packages/packages-lock.json new file mode 100644 index 00000000..8ea59f7a --- /dev/null +++ b/.unity-test-project/Packages/packages-lock.json @@ -0,0 +1,293 @@ +{ + "dependencies": { + "com.unity.ext.nunit": { + "version": "2.0.5", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.ide.rider": { + "version": "3.0.31", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.ext.nunit": "2.0.5" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ide.visualstudio": { + "version": "2.0.22", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.9" + }, + "url": "https://packages.unity.com" + }, + "com.unity.test-framework": { + "version": "1.4.5", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.ext.nunit": "2.0.5", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.test-framework.performance": { + "version": "3.4.2", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.wallstop-studios.dxmessaging": { + "version": "file:../..", + "depth": 0, + "source": "local", + "dependencies": {} + }, + "com.unity.modules.ai": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.androidjni": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.animation": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.assetbundle": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.audio": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.cloth": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.physics": "1.0.0" + } + }, + "com.unity.modules.director": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.animation": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0" + } + }, + "com.unity.modules.imageconversion": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.imgui": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.jsonserialize": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.particlesystem": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.physics": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.physics2d": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.subsystems": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.terrain": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.terrainphysics": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.terrain": "1.0.0" + } + }, + "com.unity.modules.ui": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.uielements": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.uielementsnative": "1.0.0" + } + }, + "com.unity.modules.uielementsnative": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.modules.umbra": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.unityanalytics": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.modules.unitywebrequest": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.unitywebrequestassetbundle": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.assetbundle": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + } + }, + "com.unity.modules.unitywebrequestaudio": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.audio": "1.0.0" + } + }, + "com.unity.modules.unitywebrequesttexture": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0" + } + }, + "com.unity.modules.unitywebrequestwww": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.unitywebrequestassetbundle": "1.0.0", + "com.unity.modules.unitywebrequestaudio": "1.0.0", + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.assetbundle": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0" + } + }, + "com.unity.modules.vehicles": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.physics": "1.0.0" + } + }, + "com.unity.modules.video": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + } + }, + "com.unity.modules.vr": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.xr": "1.0.0" + } + }, + "com.unity.modules.wind": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.xr": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.subsystems": "1.0.0" + } + } + } +} diff --git a/.unity-test-project/ProjectSettings/ProjectVersion.txt b/.unity-test-project/ProjectSettings/ProjectVersion.txt new file mode 100644 index 00000000..3b73dd93 --- /dev/null +++ b/.unity-test-project/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.45f1 +m_EditorVersionWithRevision: 2022.3.45f1 (a4ad03d2bff8) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2018d779..8df771b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Emit-time idle eviction now samples the wall clock only at the sweep-gate cadence instead of on every message emit, restoring dispatch hot-path throughput while preserving the extra timestamp read only when a sweep actually runs. +- Emit-time idle eviction now samples the wall clock only at the sweep-gate cadence instead of on every message emit, with sealed inline clock getters on the default clocks, reducing dispatch hot-path clock overhead while preserving the extra timestamp read only when a sweep actually runs. +- Forced and idle trims now release empty typed-handler outer wrappers and compact dirty-handler tracking, preventing long-running buses from retaining every message type a handler once registered after those registrations are removed. - Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. - Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. - This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). diff --git a/Runtime/Core/Internal/TypedSlots.cs b/Runtime/Core/Internal/TypedSlots.cs index 6a1c5954..2a3c12b9 100644 --- a/Runtime/Core/Internal/TypedSlots.cs +++ b/Runtime/Core/Internal/TypedSlots.cs @@ -61,7 +61,9 @@ internal interface IHandlerActionCache /// The bus emission id of the most recent dispatch that consumed /// this cache. Mirrors /// HandlerActionCache<TDelegate>.lastSeenEmissionId. - /// Used by the staged dispatch staleness check. + /// Used by the staged dispatch staleness check. Implementations use + /// an invalid sentinel before the first dispatch so emission id 0 + /// still materializes an initial snapshot. /// long LastSeenEmissionId { get; set; } @@ -101,6 +103,18 @@ internal interface IHandlerActionCache /// internal interface ITypedHandlerSlotSweeper { + /// + /// Message type index for the owning typed-handler wrapper. + /// + int MessageTypeIndex { get; } + + /// + /// True when the last sweep found no live typed slots or dispatch links + /// worth retaining and the owning MessageCache entry can be + /// removed. + /// + bool MarkedForOuterRemoval { get; } + /// /// Resets every empty typed or typed-global slot and removes it from /// the handler's slot arrays. @@ -211,7 +225,7 @@ internal sealed class TypedSlot : IEvictableSlot /// Forward-compat plumbing; not yet read by the typed-handler hot /// path. /// - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / @@ -366,7 +380,7 @@ public void Clear() byContext = null; version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; } @@ -428,7 +442,7 @@ KeyValuePair> ctx in byContext ReturnContextDictionaries(); byContext = null; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; unchecked { @@ -519,7 +533,7 @@ internal sealed class TypedGlobalSlot : IEvictableSlot /// this slot. Forward-compat plumbing; not yet read by the /// typed-handler hot path. /// - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / @@ -575,7 +589,7 @@ public void Clear() cache = null; version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; } @@ -604,7 +618,7 @@ public void Reset() cache?.Reset(); cache = null; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; unchecked { diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index 2d72c289..4e568f45 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -66,6 +66,11 @@ static bool GlobalDiagnosticsMode /// /// Reclaim empty message slots and pooled collections owned by this bus. /// + /// + /// Non-Unity and headless hosts must call this periodically when they need + /// deterministic reclamation. The automatic PlayerLoop sweep hook is only + /// installed on Unity 2021.3 or newer player/editor hosts. + /// /// /// When true, ignores idle-age thresholds and drains shared pools to zero. /// When false, only slots past the configured idle threshold are eligible. diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs b/Runtime/Core/MessageBus/Internal/BusSlots.cs index 00d458f0..4da1ac8e 100644 --- a/Runtime/Core/MessageBus/Internal/BusSlots.cs +++ b/Runtime/Core/MessageBus/Internal/BusSlots.cs @@ -58,7 +58,7 @@ internal sealed class BusPriorityBucket /// The bus emission id of the most recent dispatch that consumed this /// bucket. Used by the staged dispatch staleness check. /// - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Clear all bucket state. Mirrors the legacy @@ -75,7 +75,7 @@ public void Clear() cache.Clear(); version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; } } @@ -136,7 +136,7 @@ internal sealed class BusSinkSlot : IEvictableSlot /// The bus emission id of the most recent dispatch that consumed this /// slot. Used by the staged dispatch staleness check. /// - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / @@ -230,7 +230,7 @@ public void Clear() dispatchState = null; version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; } @@ -259,7 +259,7 @@ public void Reset() dispatchState?.Reset(); dispatchState = null; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; unchecked { @@ -583,7 +583,7 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// does not yet read this field; it is allocated for parity with the /// per-cache contract. /// - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / @@ -679,7 +679,7 @@ public void Clear() broadcastDispatchState = null; version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; } @@ -704,7 +704,7 @@ public void Reset() broadcastDispatchState?.Reset(); broadcastDispatchState = null; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; liveCount = 0; unchecked { diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index c81f245c..bdcac0bc 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -262,7 +262,7 @@ private sealed class HandlerCache public readonly List> cache = new(); public long version; public long lastSeenVersion = -1; - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; public long lastTouchTicks; public DispatchState dispatchState; @@ -276,7 +276,7 @@ public void Clear() cache.Clear(); version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; dispatchState?.Reset(); dispatchState = null; } @@ -285,13 +285,13 @@ public void Clear() private sealed class InterceptorCache { public readonly SortedList> handlers = new(); - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; public long lastTouchTicks; public void Clear() { handlers.Clear(); - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; lastTouchTicks = 0; } } @@ -347,7 +347,7 @@ private sealed class HandlerCache public readonly List cache = new(); public long version; public long lastSeenVersion = -1; - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; /// /// Clears all cached handler references and resets the version tracking metadata. @@ -358,7 +358,7 @@ public void Clear() cache.Clear(); version = 0; lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; } } @@ -1323,7 +1323,9 @@ private int SweepDirtyTypedHandlerSlots(bool force) return evicted; } - for (int i = 0; i < _dirtyHandlers.Count; ++i) + int write = 0; + int count = _dirtyHandlers.Count; + for (int i = 0; i < count; ++i) { MessageHandler handler = _dirtyHandlers[i]; if ( @@ -1334,10 +1336,24 @@ private int SweepDirtyTypedHandlerSlots(bool force) ) ) { + _dirtyHandlers[write++] = handler; continue; } evicted += handler.ResetEmptyTypedSlotsForSweep(this); + if (handler.HasTypedHandlersForBus(this)) + { + _dirtyHandlers[write++] = handler; + continue; + } + + _dirtyHandlerSet.Remove(handler); + _dirtyHandlerTicks.Remove(handler); + } + + if (write < count) + { + _dirtyHandlers.RemoveRange(write, count - write); } return evicted; diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index edcd554c..8f1c0cb7 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -2152,11 +2152,16 @@ internal int ResetEmptyTypedSlotsForSweep(IMessageBus messageBus = null) } int resetCount = 0; + MessageCache handlersByType = _handlersByTypeByMessageBus[messageBusIndex]; foreach (object untypedHandler in _handlersByTypeByMessageBus[messageBusIndex]) { if (untypedHandler is ITypedHandlerSlotSweeper sweeper) { resetCount += sweeper.ResetEmptySlotsForSweep(); + if (sweeper.MarkedForOuterRemoval) + { + handlersByType.RemoveAtIndex(sweeper.MessageTypeIndex); + } } } @@ -2205,6 +2210,26 @@ internal int CountEmptyTypedSlotsForSweep(IMessageBus messageBus = null) return count; } + internal bool HasTypedHandlersForBus(IMessageBus messageBus = null) + { + messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; + if (messageBusIndex < 0 || _handlersByTypeByMessageBus.Count <= messageBusIndex) + { + return false; + } + + foreach (object untypedHandler in _handlersByTypeByMessageBus[messageBusIndex]) + { + if (untypedHandler != null) + { + return true; + } + } + + return false; + } + internal int GetUntargetedPostProcessingPrefreezeCount( IMessageBus messageBus, int priority @@ -2348,7 +2373,7 @@ public Entry(T handler, int count) public readonly List cache = new(); public long version; public long lastSeenVersion = -1; - public long lastSeenEmissionId; + public long lastSeenEmissionId = -1; internal int prefreezeInvocationCount; /// Monotonic version field, read-only on the interface surface. @@ -2399,7 +2424,7 @@ void DxMessaging.Core.Internal.IHandlerActionCache.Reset() entries.Clear(); cache.Clear(); lastSeenVersion = -1; - lastSeenEmissionId = 0; + lastSeenEmissionId = -1; prefreezeInvocationCount = 0; unchecked { @@ -2412,10 +2437,12 @@ internal sealed class UntargetedDispatchLink where T : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal UntargetedDispatchLink(TypedHandler typedHandler) + internal UntargetedDispatchLink(TypedHandler typedHandler, long capturedGeneration) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2426,6 +2453,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + if (!messageHandler.active) { return; @@ -2439,10 +2474,15 @@ internal sealed class UntargetedPostDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal UntargetedPostDispatchLink(TypedHandler typedHandler) + internal UntargetedPostDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2453,6 +2493,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + if (!messageHandler.active) { return; @@ -2466,10 +2514,15 @@ internal sealed class TargetedDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal TargetedDispatchLink(TypedHandler typedHandler) + internal TargetedDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2481,6 +2534,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleTargeted(ref target, ref message, priority, emissionId); } } @@ -2489,10 +2550,15 @@ internal sealed class TargetedPostDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal TargetedPostDispatchLink(TypedHandler typedHandler) + internal TargetedPostDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2504,6 +2570,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleTargetedPostProcessing( ref target, ref message, @@ -2517,10 +2591,15 @@ internal sealed class TargetedWithoutTargetingDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal TargetedWithoutTargetingDispatchLink(TypedHandler typedHandler) + internal TargetedWithoutTargetingDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2532,6 +2611,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleTargetedWithoutTargeting( ref target, ref message, @@ -2545,10 +2632,15 @@ internal sealed class TargetedWithoutTargetingPostDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal TargetedWithoutTargetingPostDispatchLink(TypedHandler typedHandler) + internal TargetedWithoutTargetingPostDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2560,6 +2652,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleTargetedWithoutTargetingPostProcessing( ref target, ref message, @@ -2573,10 +2673,15 @@ internal sealed class BroadcastDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal BroadcastDispatchLink(TypedHandler typedHandler) + internal BroadcastDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2588,6 +2693,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleSourcedBroadcast(ref source, ref message, priority, emissionId); } } @@ -2596,10 +2709,15 @@ internal sealed class BroadcastPostDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal BroadcastPostDispatchLink(TypedHandler typedHandler) + internal BroadcastPostDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2611,6 +2729,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleSourcedBroadcastPostProcessing( ref source, ref message, @@ -2624,10 +2750,15 @@ internal sealed class BroadcastWithoutSourceDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal BroadcastWithoutSourceDispatchLink(TypedHandler typedHandler) + internal BroadcastWithoutSourceDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2639,6 +2770,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleSourcedBroadcastWithoutSource( ref source, ref message, @@ -2652,10 +2791,15 @@ internal sealed class BroadcastWithoutSourcePostDispatchLink where TMessage : IMessage { private readonly TypedHandler typedHandler; + internal readonly long capturedGeneration; - internal BroadcastWithoutSourcePostDispatchLink(TypedHandler typedHandler) + internal BroadcastWithoutSourcePostDispatchLink( + TypedHandler typedHandler, + long capturedGeneration + ) { this.typedHandler = typedHandler; + this.capturedGeneration = capturedGeneration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2667,6 +2811,14 @@ internal void Invoke( long emissionId ) { + // Generation guard: 1 field read + 1 compare per dispatch on the hot path. + // Sits at the top of Invoke so reclaimed wrappers return before handler-slot + // walks when the outer wrapper has been reclaimed. + if (typedHandler._outerGeneration != capturedGeneration) + { + return; + } + typedHandler.HandleBroadcastWithoutSourcePostProcessing( ref source, ref message, @@ -2703,6 +2855,21 @@ internal TypedHandler() ValidateSlotArrays(); } + internal long _outerGeneration; + internal bool _markedForOuterRemoval; + + int ITypedHandlerSlotSweeper.MessageTypeIndex + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => MessageHelperIndexer.SequentialId; + } + + bool ITypedHandlerSlotSweeper.MarkedForOuterRemoval + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _markedForOuterRemoval; + } + [Conditional("DEBUG")] private void ValidateSlotArrays() { @@ -2858,6 +3025,7 @@ Dictionary> handlersByContext int ITypedHandlerSlotSweeper.ResetEmptySlotsForSweep() { + _markedForOuterRemoval = false; int resetCount = 0; for (int i = 0; i < _slots.Length; ++i) { @@ -2881,11 +3049,13 @@ int ITypedHandlerSlotSweeper.ResetEmptySlotsForSweep() } } + MarkForOuterRemovalIfEmpty(); return resetCount; } int ITypedHandlerSlotSweeper.ResetAllSlotsForBusReset() { + _markedForOuterRemoval = false; int resetCount = 0; for (int i = 0; i < _slots.Length; ++i) { @@ -2909,6 +3079,11 @@ int ITypedHandlerSlotSweeper.ResetAllSlotsForBusReset() } } + ClearDispatchLinks(); + unchecked + { + ++_outerGeneration; + } return resetCount; } @@ -2936,6 +3111,53 @@ int ITypedHandlerSlotSweeper.CountEmptySlotsForSweep() return count; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void MarkForOuterRemovalIfEmpty() + { + if (HasLiveSlots()) + { + return; + } + + ClearDispatchLinks(); + _markedForOuterRemoval = true; + unchecked + { + ++_outerGeneration; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasLiveSlots() + { + for (int i = 0; i < _slots.Length; ++i) + { + if (_slots[i] != null) + { + return true; + } + } + + for (int i = 0; i < _globalSlots.Length; ++i) + { + if (_globalSlots[i] != null) + { + return true; + } + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearDispatchLinks() + { + for (int i = 0; i < _dispatchLinks.Length; ++i) + { + _dispatchLinks[i] = null; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal UntargetedDispatchLink GetOrCreateUntargetedLink() { @@ -2944,7 +3166,7 @@ internal UntargetedDispatchLink GetOrCreateUntargetedLink() as UntargetedDispatchLink; if (link == null) { - link = new UntargetedDispatchLink(this); + link = new UntargetedDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.UntargetedHandle] = link; } @@ -2959,7 +3181,7 @@ internal UntargetedPostDispatchLink GetOrCreateUntargetedPostLink() as UntargetedPostDispatchLink; if (link == null) { - link = new UntargetedPostDispatchLink(this); + link = new UntargetedPostDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.UntargetedPostProcess] = link; } @@ -2974,7 +3196,7 @@ internal TargetedDispatchLink GetOrCreateTargetedLink() as TargetedDispatchLink; if (link == null) { - link = new TargetedDispatchLink(this); + link = new TargetedDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.TargetedHandle] = link; } @@ -2989,7 +3211,7 @@ internal TargetedPostDispatchLink GetOrCreateTargetedPostLink() as TargetedPostDispatchLink; if (link == null) { - link = new TargetedPostDispatchLink(this); + link = new TargetedPostDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcess] = link; } @@ -3004,7 +3226,7 @@ internal TargetedWithoutTargetingDispatchLink GetOrCreateTargetedWithoutTarge as TargetedWithoutTargetingDispatchLink; if (link == null) { - link = new TargetedWithoutTargetingDispatchLink(this); + link = new TargetedWithoutTargetingDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.TargetedHandleWithoutContext] = link; } @@ -3019,7 +3241,7 @@ internal TargetedWithoutTargetingPostDispatchLink GetOrCreateTargetedWithoutT as TargetedWithoutTargetingPostDispatchLink; if (link == null) { - link = new TargetedWithoutTargetingPostDispatchLink(this); + link = new TargetedWithoutTargetingPostDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.TargetedPostProcessWithoutContext] = link; } @@ -3034,7 +3256,7 @@ internal BroadcastDispatchLink GetOrCreateBroadcastLink() as BroadcastDispatchLink; if (link == null) { - link = new BroadcastDispatchLink(this); + link = new BroadcastDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandle] = link; } @@ -3049,7 +3271,7 @@ internal BroadcastPostDispatchLink GetOrCreateBroadcastPostLink() as BroadcastPostDispatchLink; if (link == null) { - link = new BroadcastPostDispatchLink(this); + link = new BroadcastPostDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcess] = link; } @@ -3064,7 +3286,7 @@ internal BroadcastWithoutSourceDispatchLink GetOrCreateBroadcastWithoutSource as BroadcastWithoutSourceDispatchLink; if (link == null) { - link = new BroadcastWithoutSourceDispatchLink(this); + link = new BroadcastWithoutSourceDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.BroadcastHandleWithoutContext] = link; } @@ -3079,7 +3301,7 @@ internal BroadcastWithoutSourcePostDispatchLink GetOrCreateBroadcastWithoutSo as BroadcastWithoutSourcePostDispatchLink; if (link == null) { - link = new BroadcastWithoutSourcePostDispatchLink(this); + link = new BroadcastWithoutSourcePostDispatchLink(this, _outerGeneration); _dispatchLinks[TypedDispatchLinkIndex.BroadcastPostProcessWithoutContext] = link; } diff --git a/Runtime/Core/Pooling/IDxMessagingClock.cs b/Runtime/Core/Pooling/IDxMessagingClock.cs index 3554c492..1d57ddbd 100644 --- a/Runtime/Core/Pooling/IDxMessagingClock.cs +++ b/Runtime/Core/Pooling/IDxMessagingClock.cs @@ -3,8 +3,8 @@ namespace DxMessaging.Core.Pooling /// /// Abstraction over a monotonic wall-clock used by the eviction sweeper to /// decide whether enough time has elapsed since the last sweep. Implementations - /// must be cheap (single field read or call) because every Emit consults the - /// clock. + /// must be cheap (single field read or call) because sampled idle-sweep gates + /// sit on the emit path. /// public interface IDxMessagingClock { diff --git a/Runtime/Core/Pooling/StopwatchClock.cs b/Runtime/Core/Pooling/StopwatchClock.cs index aa571b45..972e53e3 100644 --- a/Runtime/Core/Pooling/StopwatchClock.cs +++ b/Runtime/Core/Pooling/StopwatchClock.cs @@ -1,6 +1,7 @@ namespace DxMessaging.Core.Pooling { using System.Diagnostics; + using System.Runtime.CompilerServices; /// /// Default backed by a process-lifetime @@ -17,6 +18,10 @@ public sealed class StopwatchClock : IDxMessagingClock private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); /// - public double NowSeconds => _stopwatch.ElapsedTicks * TicksToSeconds; + public double NowSeconds + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _stopwatch.ElapsedTicks * TicksToSeconds; + } } } diff --git a/Runtime/Core/Pooling/UnityRealtimeClock.cs b/Runtime/Core/Pooling/UnityRealtimeClock.cs index 92f822f5..b31a8cc2 100644 --- a/Runtime/Core/Pooling/UnityRealtimeClock.cs +++ b/Runtime/Core/Pooling/UnityRealtimeClock.cs @@ -1,14 +1,15 @@ namespace DxMessaging.Core.Pooling { #if UNITY_2021_3_OR_NEWER + using System.Runtime.CompilerServices; using UnityEngine; /// /// Unity-only backed by - /// . Use this when sweep cadence should - /// follow Unity wall time rather than the AppDomain Stopwatch (Stopwatch keeps - /// running across editor pause; Time.realtimeSinceStartup also runs across - /// pause but is the canonical Unity clock). + /// . Use this when sweep cadence + /// should follow Unity wall time rather than the AppDomain Stopwatch (Stopwatch + /// keeps running across editor pause; Time.realtimeSinceStartupAsDouble also + /// runs across pause but is the canonical Unity clock). /// /// /// Must be invoked from the Unity main thread; the underlying Time @@ -20,7 +21,11 @@ public sealed class UnityRealtimeClock : IDxMessagingClock public static readonly UnityRealtimeClock Instance = new(); /// - public double NowSeconds => Time.realtimeSinceStartupAsDouble; + public double NowSeconds + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Time.realtimeSinceStartupAsDouble; + } } #endif } diff --git a/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs index 5349d044..1734c262 100644 --- a/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs +++ b/Tests/Editor/Allocations/EmitGateClockReadIsRare.cs @@ -8,6 +8,7 @@ namespace DxMessaging.Tests.Editor.Allocations using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; + [Category("PerfGate")] public sealed class EmitGateClockReadIsRare { [Test] diff --git a/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef index 15ab912b..62136a59 100644 --- a/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef +++ b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef @@ -7,10 +7,6 @@ "WallstopStudios.DxMessaging", "WallstopStudios.DxMessaging.Tests.Runtime", "Unity.PerformanceTesting", - "Zenject", - "MessagePipe", - "UniRx", - "UniTask", "WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks" ], "includePlatforms": ["Editor"], diff --git a/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef index dbe4c912..4aa86467 100644 --- a/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef +++ b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef @@ -7,11 +7,7 @@ "WallstopStudios.DxMessaging", "WallstopStudios.DxMessaging.Tests.Runtime", "WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks", - "Unity.PerformanceTesting", - "Zenject", - "MessagePipe", - "UniRx", - "UniTask" + "Unity.PerformanceTesting" ], "includePlatforms": ["Editor"], "excludePlatforms": [], diff --git a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef index 64c4e35d..88c60c26 100644 --- a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef +++ b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef @@ -19,7 +19,13 @@ "overrideReferences": true, "precompiledReferences": ["nunit.framework.dll"], "autoReferenced": true, - "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "MESSAGEPIPE_PRESENT", + "UNIRX_PRESENT", + "ZENJECT_PRESENT", + "UNITASK_PRESENT" + ], "versionDefines": [ { "name": "com.cysharp.messagepipe", @@ -40,6 +46,11 @@ "name": "com.svermeulen.extenject", "expression": "0.0.1", "define": "ZENJECT_PRESENT" + }, + { + "name": "com.cysharp.unitask", + "expression": "0.0.1", + "define": "UNITASK_PRESENT" } ], "noEngineReferences": false diff --git a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs index 7d6df600..73993297 100644 --- a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs +++ b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs @@ -438,8 +438,16 @@ public void ExternalSweepResetsEmptyUntargetedSlotAndInvalidatesStaleDeregistrat priority: 17, messageBus: bus ); + object newTypedHandler = ReadTypedHandler(handler, bus); + Assert.AreNotSame( + typedHandler, + newTypedHandler, + "External sweep removes an empty typed-handler wrapper, so a later " + + "registration must allocate a replacement wrapper." + ); + Array newSlots = ReadArrayField(newTypedHandler, "_slots"); TypedSlot newSlot = - (TypedSlot)slots.GetValue(TypedSlotIndex.UntargetedHandleDefault); + (TypedSlot)newSlots.GetValue(TypedSlotIndex.UntargetedHandleDefault); Assert.AreEqual(1, newSlot.liveCount); deregistration(); diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs b/Tests/Editor/Contract/TypedSlotShapeTests.cs index cdb261dc..5ee3bcc1 100644 --- a/Tests/Editor/Contract/TypedSlotShapeTests.cs +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -373,6 +373,12 @@ public void HandlerActionCacheImplementsIHandlerActionCache() long _ = view.Version; view.LastSeenVersion = 7; Assert.AreEqual(7, view.LastSeenVersion); + Assert.AreEqual( + -1, + view.LastSeenEmissionId, + "Fresh HandlerActionCache instances must start with an invalid " + + "emission sentinel so emission id 0 materializes the first snapshot." + ); view.LastSeenEmissionId = 13; Assert.AreEqual(13, view.LastSeenEmissionId); int prefreeze = view.PrefreezeInvocationCount; @@ -442,7 +448,12 @@ public void HandlerActionCacheImplementsIHandlerActionCache() "Reset() must bump version monotonically (PLAN Risk Register R3)." ); Assert.AreEqual(-1, view.LastSeenVersion, "Reset() must restore lastSeenVersion = -1."); - Assert.AreEqual(0, view.LastSeenEmissionId); + Assert.AreEqual( + -1, + view.LastSeenEmissionId, + "Reset() must restore lastSeenEmissionId to an invalid sentinel so " + + "emission id 0 still materializes a fresh snapshot." + ); Assert.AreEqual(0, entries.Count, "Reset() must empty entries."); Assert.AreEqual(0, cacheList.Count, "Reset() must empty cache."); Assert.IsTrue(view.IsEmpty, "After Reset() the cache must report IsEmpty == true."); @@ -774,7 +785,7 @@ public void TypedSlotClearResetsVersionToZero() + "version to 0; eviction-driven monotonicity belongs to Reset()." ); Assert.AreEqual(-1, slot.lastSeenVersion); - Assert.AreEqual(0, slot.lastSeenEmissionId); + Assert.AreEqual(-1, slot.lastSeenEmissionId); Assert.AreEqual(0, slot.liveCount); Assert.AreEqual(0, slot.byPriority.Count); Assert.AreEqual(0, slot.orderedPriorities.Count); @@ -845,7 +856,7 @@ public void TypedGlobalSlotClearResetsVersionToZero() Assert.AreEqual(0, slot.version); Assert.AreEqual(-1, slot.lastSeenVersion); - Assert.AreEqual(0, slot.lastSeenEmissionId); + Assert.AreEqual(-1, slot.lastSeenEmissionId); Assert.AreEqual(0, slot.liveCount); Assert.IsNull(slot.cache); } diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs index 652671b0..49ca0a8d 100644 --- a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs @@ -3,6 +3,7 @@ namespace DxMessaging.Tests.Runtime.MemoryReclaim { using System; using System.Collections.Generic; + using System.Reflection; using DxMessaging.Core; using DxMessaging.Core.Configuration; using DxMessaging.Core.MessageBus; @@ -532,6 +533,385 @@ MessageScenario scenario } } + [Test] + public void TypedHandlerOuterWrapperReclaimedAfterTrim( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + Action deregisterFirst = null; + Action deregisterSecond = null; + Action deregisterThird = null; + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + try + { + deregisterFirst = RegisterDirect(scenario, handler, bus, DefaultContext, () => { }); + deregisterSecond = RegisterDirectSecond( + scenario, + handler, + bus, + DefaultContext, + () => { } + ); + deregisterThird = RegisterDirectThird( + scenario, + handler, + bus, + DefaultContext, + () => { } + ); + + Assert.IsTrue( + CountHandlerTypeCacheEntries(handler, bus) >= 3, + "[{0}] typed-handler wrappers must exist while registrations are live.", + scenario.Kind + ); + + deregisterFirst(); + deregisterFirst = null; + deregisterSecond(); + deregisterSecond = null; + deregisterThird(); + deregisterThird = null; + + Assert.IsTrue( + CountHandlerTypeCacheEntries(handler, bus) >= 3, + "[{0}] empty typed-handler wrappers must remain until trim.", + scenario.Kind + ); + + _ = bus.Trim(force: true); + + Assert.AreEqual( + 0, + CountHandlerTypeCacheEntries(handler, bus), + "[{0}] trim must remove empty typed-handler outer wrappers.", + scenario.Kind + ); + } + finally + { + deregisterFirst?.Invoke(); + deregisterSecond?.Invoke(); + deregisterThird?.Invoke(); + _ = bus.Trim(force: true); + } + } + + [Test] + public void TypedHandlerOuterWrappersReclaimedAtScaleAfterTrim() + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots(bus); + MessageHandler handler = CreateActiveHandler(bus); + List deregistrations = new List(1024); + MethodInfo registerMethod = typeof(MemoryReclamationTests).GetMethod( + nameof(RegisterUntargetedGenericDirect), + BindingFlags.Static | BindingFlags.NonPublic + ); + Type[] markerTypes = RegistrationFloodMarkerTypes; + + try + { + foreach (Type outerMarker in markerTypes) + { + foreach (Type innerMarker in markerTypes) + { + Type messageType = typeof(RegistrationFloodMessage<,>).MakeGenericType( + outerMarker, + innerMarker + ); + deregistrations.Add( + (Action) + registerMethod + .MakeGenericMethod(messageType) + .Invoke(null, new object[] { handler, bus }) + ); + } + } + + Assert.AreEqual( + 1024, + CountHandlerTypeCacheEntries(handler, bus), + "Scale setup must create one typed-handler wrapper per distinct message type." + ); + + foreach (Action deregister in deregistrations) + { + deregister(); + } + + deregistrations.Clear(); + + Assert.AreEqual( + 1024, + CountHandlerTypeCacheEntries(handler, bus), + "Empty typed-handler wrappers must remain in the sparse cache until trim." + ); + + _ = bus.Trim(force: true); + + Assert.AreEqual( + 0, + CountHandlerTypeCacheEntries(handler, bus), + "Trim must remove every empty typed-handler wrapper from the sparse cache." + ); + } + finally + { + foreach (Action deregistration in deregistrations) + { + deregistration(); + } + + _ = bus.Trim(force: true); + } + } + + [Test] + public void DirtyHandlerCompactedAfterTrim() + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots(bus); + MessageHandler handler = CreateActiveHandler(bus); + Action deregister = RegisterDirect( + MessageScenario.Untargeted(), + handler, + bus, + DefaultContext, + () => { } + ); + + deregister(); + + Assert.GreaterOrEqual( + CountDirtyHandlers(bus), + 1, + "Deregistering the last typed handler must dirty the owning MessageHandler." + ); + + _ = bus.Trim(force: true); + + Assert.AreEqual( + 0, + CountDirtyHandlers(bus), + "Trim must compact dirty-handler candidates after their typed wrappers are reclaimed." + ); + } + + [Test] + public void DispatchLinksCaptureOuterGenerationGuard() + { + Type messageHandlerType = typeof(MessageHandler); + Type[] linkTypes = + { + messageHandlerType + .GetNestedType("UntargetedDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(UntargetedOne)), + messageHandlerType + .GetNestedType("UntargetedPostDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(UntargetedOne)), + messageHandlerType + .GetNestedType("TargetedDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(TargetedOne)), + messageHandlerType + .GetNestedType("TargetedPostDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(TargetedOne)), + messageHandlerType + .GetNestedType("TargetedWithoutTargetingDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(TargetedOne)), + messageHandlerType + .GetNestedType( + "TargetedWithoutTargetingPostDispatchLink`1", + BindingFlags.NonPublic + ) + .MakeGenericType(typeof(TargetedOne)), + messageHandlerType + .GetNestedType("BroadcastDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(BroadcastOne)), + messageHandlerType + .GetNestedType("BroadcastPostDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(BroadcastOne)), + messageHandlerType + .GetNestedType("BroadcastWithoutSourceDispatchLink`1", BindingFlags.NonPublic) + .MakeGenericType(typeof(BroadcastOne)), + messageHandlerType + .GetNestedType( + "BroadcastWithoutSourcePostDispatchLink`1", + BindingFlags.NonPublic + ) + .MakeGenericType(typeof(BroadcastOne)), + }; + + foreach (Type linkType in linkTypes) + { + Assert.NotNull( + linkType.GetField( + "capturedGeneration", + BindingFlags.Instance | BindingFlags.NonPublic + ), + "{0} must capture the TypedHandler outer generation.", + linkType.Name + ); + } + } + + [Test] + public void OuterReclamationDoesNotFireStaleDispatchLink( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + int staleCalls = 0; + int currentCalls = 0; + Action staleDeregister = null; + Action currentDeregister = null; + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + try + { + staleDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => staleCalls++ + ); + object staleLink = CaptureDispatchLink(scenario, handler, bus); + + staleDeregister(); + staleDeregister = null; + _ = bus.Trim(force: true); + + currentDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => currentCalls++ + ); + object currentLink = CaptureDispatchLink(scenario, handler, bus); + + InvokeCapturedDispatchLink(scenario, staleLink, handler, DefaultContext); + InvokeCapturedDispatchLink(scenario, currentLink, handler, DefaultContext); + + Assert.AreEqual( + 0, + staleCalls, + "[{0}] reclaimed stale dispatch links must early-out without firing old handlers.", + scenario.DisplayName + ); + Assert.AreEqual( + 1, + currentCalls, + "[{0}] stale dispatch links must not disturb the replacement typed wrapper.", + scenario.DisplayName + ); + } + finally + { + staleDeregister?.Invoke(); + currentDeregister?.Invoke(); + _ = bus.Trim(force: true); + } + } + + [Test] + public void EmitAfterOuterReclamationDispatchesReplacementOnly( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext) + )] + MessageScenario scenario + ) + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + int staleCalls = 0; + int currentCalls = 0; + Action staleDeregister = null; + Action currentDeregister = null; + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + try + { + staleDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => staleCalls++ + ); + + EmitFirst(scenario, bus, DefaultContext); + Assert.AreEqual( + 1, + staleCalls, + "[{0}] setup emission must prove the stale registration was reachable.", + scenario.DisplayName + ); + + staleDeregister(); + staleDeregister = null; + IMessageBus.TrimResult trimResult = bus.Trim(force: true); + Assert.GreaterOrEqual( + trimResult.TypeSlotsEvicted, + 1, + "[{0}] trim must reclaim the empty typed-handler wrapper before replacement. Result={1}", + scenario.DisplayName, + trimResult + ); + + currentDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => currentCalls++ + ); + + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual( + 1, + staleCalls, + "[{0}] stale bus dispatch state must not fire the reclaimed registration.", + scenario.DisplayName + ); + Assert.AreEqual( + 1, + currentCalls, + "[{0}] replacement registration must dispatch through the production bus path.", + scenario.DisplayName + ); + } + finally + { + staleDeregister?.Invoke(); + currentDeregister?.Invoke(); + _ = bus.Trim(force: true); + } + } + private static MessageRegistrationToken CreateEnabledToken(MessageBus bus) { MessageHandler handler = CreateActiveHandler(bus); @@ -676,6 +1056,11 @@ private static Action RegisterDirect( Action onMessage ) { + if (scenario.UsePostProcessor) + { + return RegisterDirectPostProcessor(scenario, handler, bus, context, onMessage); + } + switch (scenario.Kind) { case MessageKind.Untargeted: @@ -710,6 +1095,26 @@ Action onMessage messageBus: bus ); } + case MessageKind.TargetedWithoutTargeting: + { + Action callback = (_, _) => onMessage(); + return handler.RegisterTargetedWithoutTargeting( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.BroadcastWithoutSource: + { + Action callback = (_, _) => onMessage(); + return handler.RegisterSourcedBroadcastWithoutSource( + callback, + callback, + priority: 0, + messageBus: bus + ); + } default: { throw UnsupportedScenario(scenario); @@ -717,6 +1122,383 @@ Action onMessage } } + private static Action RegisterDirectPostProcessor( + MessageScenario scenario, + MessageHandler handler, + MessageBus bus, + InstanceId context, + Action onMessage + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + Action callback = _ => onMessage(); + return handler.RegisterUntargetedPostProcessor( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Targeted: + { + Action callback = _ => onMessage(); + return handler.RegisterTargetedPostProcessor( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Broadcast: + { + Action callback = _ => onMessage(); + return handler.RegisterSourcedBroadcastPostProcessor( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.TargetedWithoutTargeting: + { + Action callback = (_, _) => onMessage(); + return handler.RegisterTargetedWithoutTargetingPostProcessor( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.BroadcastWithoutSource: + { + Action callback = (_, _) => onMessage(); + return handler.RegisterSourcedBroadcastWithoutSourcePostProcessor( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static Action RegisterDirectSecond( + MessageScenario scenario, + MessageHandler handler, + MessageBus bus, + InstanceId context, + Action onMessage + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + Action callback = _ => onMessage(); + return handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Targeted: + { + Action callback = _ => onMessage(); + return handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Broadcast: + { + Action callback = _ => onMessage(); + return handler.RegisterSourcedBroadcastMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static Action RegisterDirectThird( + MessageScenario scenario, + MessageHandler handler, + MessageBus bus, + InstanceId context, + Action onMessage + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + Action callback = _ => onMessage(); + return handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Targeted: + { + Action callback = _ => onMessage(); + return handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + case MessageKind.Broadcast: + { + Action callback = _ => onMessage(); + return handler.RegisterSourcedBroadcastMessageHandler( + context, + callback, + callback, + priority: 0, + messageBus: bus + ); + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static object CaptureDispatchLink( + MessageScenario scenario, + MessageHandler handler, + MessageBus bus + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + return scenario.UsePostProcessor + ? handler.GetOrCreateUntargetedPostDispatchLink(bus) + : handler.GetOrCreateUntargetedDispatchLink(bus); + case MessageKind.Targeted: + return scenario.UsePostProcessor + ? handler.GetOrCreateTargetedPostDispatchLink(bus) + : handler.GetOrCreateTargetedDispatchLink(bus); + case MessageKind.TargetedWithoutTargeting: + return scenario.UsePostProcessor + ? handler.GetOrCreateTargetedWithoutTargetingPostDispatchLink( + bus + ) + : handler.GetOrCreateTargetedWithoutTargetingDispatchLink(bus); + case MessageKind.Broadcast: + return scenario.UsePostProcessor + ? handler.GetOrCreateBroadcastPostDispatchLink(bus) + : handler.GetOrCreateBroadcastDispatchLink(bus); + case MessageKind.BroadcastWithoutSource: + return scenario.UsePostProcessor + ? handler.GetOrCreateBroadcastWithoutSourcePostDispatchLink( + bus + ) + : handler.GetOrCreateBroadcastWithoutSourceDispatchLink(bus); + default: + throw UnsupportedScenario(scenario); + } + } + + private static void InvokeCapturedDispatchLink( + MessageScenario scenario, + object link, + MessageHandler handler, + InstanceId context + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + UntargetedOne message = new UntargetedOne(); + if (scenario.UsePostProcessor) + { + ((MessageHandler.UntargetedPostDispatchLink)link).Invoke( + handler, + ref message, + priority: 0, + emissionId: 0 + ); + } + else + { + ((MessageHandler.UntargetedDispatchLink)link).Invoke( + handler, + ref message, + priority: 0, + emissionId: 0 + ); + } + + return; + } + case MessageKind.Targeted: + { + TargetedOne message = new TargetedOne(); + if (scenario.UsePostProcessor) + { + ((MessageHandler.TargetedPostDispatchLink)link).Invoke( + handler, + ref context, + ref message, + priority: 0, + emissionId: 0 + ); + } + else + { + ((MessageHandler.TargetedDispatchLink)link).Invoke( + handler, + ref context, + ref message, + priority: 0, + emissionId: 0 + ); + } + + return; + } + case MessageKind.TargetedWithoutTargeting: + { + TargetedOne message = new TargetedOne(); + if (scenario.UsePostProcessor) + { + ( + (MessageHandler.TargetedWithoutTargetingPostDispatchLink)link + ).Invoke(handler, ref context, ref message, priority: 0, emissionId: 0); + } + else + { + ( + (MessageHandler.TargetedWithoutTargetingDispatchLink)link + ).Invoke(handler, ref context, ref message, priority: 0, emissionId: 0); + } + + return; + } + case MessageKind.Broadcast: + { + BroadcastOne message = new BroadcastOne(); + if (scenario.UsePostProcessor) + { + ((MessageHandler.BroadcastPostDispatchLink)link).Invoke( + handler, + ref context, + ref message, + priority: 0, + emissionId: 0 + ); + } + else + { + ((MessageHandler.BroadcastDispatchLink)link).Invoke( + handler, + ref context, + ref message, + priority: 0, + emissionId: 0 + ); + } + + return; + } + case MessageKind.BroadcastWithoutSource: + { + BroadcastOne message = new BroadcastOne(); + if (scenario.UsePostProcessor) + { + ( + (MessageHandler.BroadcastWithoutSourcePostDispatchLink)link + ).Invoke(handler, ref context, ref message, priority: 0, emissionId: 0); + } + else + { + ( + (MessageHandler.BroadcastWithoutSourceDispatchLink)link + ).Invoke(handler, ref context, ref message, priority: 0, emissionId: 0); + } + + return; + } + default: + { + throw UnsupportedScenario(scenario); + } + } + } + + private static int CountDirtyHandlers(MessageBus bus) + { + FieldInfo field = typeof(MessageBus).GetField( + "_dirtyHandlers", + BindingFlags.Instance | BindingFlags.NonPublic + ); + List dirtyHandlers = (List)field.GetValue(bus); + return dirtyHandlers.Count; + } + + private static int CountHandlerTypeCacheEntries(MessageHandler handler, MessageBus bus) + { + int busIndex = bus.RegisteredGlobalSequentialIndex; + if (busIndex < 0 || handler._handlersByTypeByMessageBus.Count <= busIndex) + { + return 0; + } + + int count = 0; + foreach (object typedHandler in handler._handlersByTypeByMessageBus[busIndex]) + { + if (typedHandler != null) + { + count++; + } + } + + return count; + } + + private static Action RegisterUntargetedGenericDirect( + MessageHandler handler, + MessageBus bus + ) + where T : IUntargetedMessage + { + Action callback = _ => { }; + return handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 0, + messageBus: bus + ); + } + private static void EmitFirst(MessageScenario scenario, MessageBus bus, InstanceId context) { switch (scenario.Kind) @@ -728,12 +1510,14 @@ private static void EmitFirst(MessageScenario scenario, MessageBus bus, Instance return; } case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: { TargetedOne message = new TargetedOne(); bus.TargetedBroadcast(ref context, ref message); return; } case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: { BroadcastOne message = new BroadcastOne(); bus.SourcedBroadcast(ref context, ref message); @@ -792,6 +1576,109 @@ public void Dispose() } } + private static readonly Type[] RegistrationFloodMarkerTypes = + { + typeof(RegistrationFloodMarker00), + typeof(RegistrationFloodMarker01), + typeof(RegistrationFloodMarker02), + typeof(RegistrationFloodMarker03), + typeof(RegistrationFloodMarker04), + typeof(RegistrationFloodMarker05), + typeof(RegistrationFloodMarker06), + typeof(RegistrationFloodMarker07), + typeof(RegistrationFloodMarker08), + typeof(RegistrationFloodMarker09), + typeof(RegistrationFloodMarker10), + typeof(RegistrationFloodMarker11), + typeof(RegistrationFloodMarker12), + typeof(RegistrationFloodMarker13), + typeof(RegistrationFloodMarker14), + typeof(RegistrationFloodMarker15), + typeof(RegistrationFloodMarker16), + typeof(RegistrationFloodMarker17), + typeof(RegistrationFloodMarker18), + typeof(RegistrationFloodMarker19), + typeof(RegistrationFloodMarker20), + typeof(RegistrationFloodMarker21), + typeof(RegistrationFloodMarker22), + typeof(RegistrationFloodMarker23), + typeof(RegistrationFloodMarker24), + typeof(RegistrationFloodMarker25), + typeof(RegistrationFloodMarker26), + typeof(RegistrationFloodMarker27), + typeof(RegistrationFloodMarker28), + typeof(RegistrationFloodMarker29), + typeof(RegistrationFloodMarker30), + typeof(RegistrationFloodMarker31), + }; + + private readonly struct RegistrationFloodMessage + : IUntargetedMessage> { } + + private readonly struct RegistrationFloodMarker00 { } + + private readonly struct RegistrationFloodMarker01 { } + + private readonly struct RegistrationFloodMarker02 { } + + private readonly struct RegistrationFloodMarker03 { } + + private readonly struct RegistrationFloodMarker04 { } + + private readonly struct RegistrationFloodMarker05 { } + + private readonly struct RegistrationFloodMarker06 { } + + private readonly struct RegistrationFloodMarker07 { } + + private readonly struct RegistrationFloodMarker08 { } + + private readonly struct RegistrationFloodMarker09 { } + + private readonly struct RegistrationFloodMarker10 { } + + private readonly struct RegistrationFloodMarker11 { } + + private readonly struct RegistrationFloodMarker12 { } + + private readonly struct RegistrationFloodMarker13 { } + + private readonly struct RegistrationFloodMarker14 { } + + private readonly struct RegistrationFloodMarker15 { } + + private readonly struct RegistrationFloodMarker16 { } + + private readonly struct RegistrationFloodMarker17 { } + + private readonly struct RegistrationFloodMarker18 { } + + private readonly struct RegistrationFloodMarker19 { } + + private readonly struct RegistrationFloodMarker20 { } + + private readonly struct RegistrationFloodMarker21 { } + + private readonly struct RegistrationFloodMarker22 { } + + private readonly struct RegistrationFloodMarker23 { } + + private readonly struct RegistrationFloodMarker24 { } + + private readonly struct RegistrationFloodMarker25 { } + + private readonly struct RegistrationFloodMarker26 { } + + private readonly struct RegistrationFloodMarker27 { } + + private readonly struct RegistrationFloodMarker28 { } + + private readonly struct RegistrationFloodMarker29 { } + + private readonly struct RegistrationFloodMarker30 { } + + private readonly struct RegistrationFloodMarker31 { } + private readonly struct UntargetedOne : IUntargetedMessage { } private readonly struct UntargetedTwo : IUntargetedMessage { } diff --git a/Tests/Runtime/WallstopStudios.DxMessaging.Tests.Runtime.asmdef b/Tests/Runtime/WallstopStudios.DxMessaging.Tests.Runtime.asmdef index af9a118e..8506e445 100644 --- a/Tests/Runtime/WallstopStudios.DxMessaging.Tests.Runtime.asmdef +++ b/Tests/Runtime/WallstopStudios.DxMessaging.Tests.Runtime.asmdef @@ -1,14 +1,7 @@ { "name": "WallstopStudios.DxMessaging.Tests.Runtime", "rootNamespace": "DxMessaging.Tests", - "references": [ - "UnityEngine.TestRunner", - "UnityEditor.TestRunner", - "WallstopStudios.DxMessaging", - "WallstopStudios.DxMessaging.Reflex", - "WallstopStudios.DxMessaging.VContainer", - "WallstopStudios.DxMessaging.Zenject" - ], + "references": ["UnityEngine.TestRunner", "UnityEditor.TestRunner", "WallstopStudios.DxMessaging"], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 0a6d38ef..c2df2d78 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 17,608,773 | No | -| UniRx MessageBroker | 17,906,940 | No | -| MessagePipe (Global) | 97,275,163 | No | -| Zenject SignalBus | 2,202,326 | Yes | +| DxMessaging (Untargeted) - No-Copy | 17,247,491 | No | +| UniRx MessageBroker | 18,318,652 | No | +| MessagePipe (Global) | 97,657,508 | No | +| Zenject SignalBus | 2,449,497 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index bf978e88..f7fe28a2 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -192,17 +192,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,482,148 | Yes | -| DxMessaging (GameObject) - Normal | 9,924,411 | No | -| DxMessaging (Component) - Normal | 10,046,274 | No | -| DxMessaging (GameObject) - No-Copy | 11,446,131 | No | -| DxMessaging (Component) - No-Copy | 8,699,593 | No | -| DxMessaging (Untargeted) - No-Copy | 17,715,656 | No | -| DxMessaging (Untargeted) - Interceptors | 7,405,709 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,971,936 | No | -| Reflexive (One Argument) | 2,740,141 | No | -| Reflexive (Two Arguments) | 2,278,031 | No | -| Reflexive (Three Arguments) | 2,267,391 | No | +| Unity | 2,387,729 | Yes | +| DxMessaging (GameObject) - Normal | 10,069,781 | No | +| DxMessaging (Component) - Normal | 9,958,399 | No | +| DxMessaging (GameObject) - No-Copy | 11,369,437 | No | +| DxMessaging (Component) - No-Copy | 8,576,809 | No | +| DxMessaging (Untargeted) - No-Copy | 17,393,604 | No | +| DxMessaging (Untargeted) - Interceptors | 7,055,588 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,534,681 | No | +| Reflexive (One Argument) | 2,749,645 | No | +| Reflexive (Two Arguments) | 2,311,295 | No | +| Reflexive (Three Arguments) | 2,300,900 | No | ## macOS diff --git a/llms.txt b/llms.txt index 3296214b..6679ceac 100644 --- a/llms.txt +++ b/llms.txt @@ -208,7 +208,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 148+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 155+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -287,5 +287,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-05 +**Last Updated:** 2026-05-06 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/scripts/__tests__/claude-permissions-contract.test.js b/scripts/__tests__/claude-permissions-contract.test.js new file mode 100644 index 00000000..73a8ad9b --- /dev/null +++ b/scripts/__tests__/claude-permissions-contract.test.js @@ -0,0 +1,113 @@ +/** + * @fileoverview Contract test for the .claude/settings.local.json allowlist. + * + * The local Claude Code settings file pre-authorizes the canonical Unity-side + * commands so contributors don't get a permission prompt every time they ask + * the agent to run the headless test runner. The plan (Layer C.1) defines the + * exact entry strings; this test makes sure they survive merges and edits. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const SETTINGS_PATH = path.join(REPO_ROOT, ".claude", "settings.local.json"); + +/** + * Parse a possibly-JSONC file (strip `//` and `/* ... *\/` comments before + * JSON.parse). The Claude CLI accepts JSONC, so be permissive here. + * + * @param {string} text + * @returns {unknown} + */ +function parseJsonc(text) { + // Naive but adequate for our settings file: + // - block comments: /* ... */ + // - line comments: // ... + // Skip stripping inside string literals so we don't corrupt patterns that + // legitimately contain `//`. + let out = ""; + let i = 0; + let inString = false; + let stringChar = ""; + while (i < text.length) { + const c = text[i]; + const next = text[i + 1]; + if (inString) { + if (c === "\\" && i + 1 < text.length) { + out += c + next; + i += 2; + continue; + } + if (c === stringChar) { + inString = false; + } + out += c; + i += 1; + continue; + } + if (c === '"' || c === "'") { + inString = true; + stringChar = c; + out += c; + i += 1; + continue; + } + if (c === "/" && next === "/") { + // Skip until end of line. + while (i < text.length && text[i] !== "\n") { + i += 1; + } + continue; + } + if (c === "/" && next === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) { + i += 1; + } + i += 2; + continue; + } + out += c; + i += 1; + } + return JSON.parse(out); +} + +const REQUIRED_ENTRIES = [ + "Bash(bash scripts/unity/run-tests.sh:*)", + "Bash(pwsh -NoProfile -File scripts/unity/run-tests.ps1:*)", + "Bash(bash scripts/unity/activate-license.sh:*)", + "Bash(docker run --rm * unityci/editor:*)", + "Bash(docker pull unityci/editor:*)", + "Bash(docker volume:*)", + "Bash(node scripts/run-managed-jest.js:*)" +]; + +describe(".claude/settings.local.json contract", () => { + let parsed; + + beforeAll(() => { + expect(fs.existsSync(SETTINGS_PATH)).toBe(true); + const raw = fs.readFileSync(SETTINGS_PATH, "utf8"); + parsed = parseJsonc(raw); + }); + + test("is structurally valid JSON / JSONC", () => { + expect(parsed).toEqual(expect.any(Object)); + }); + + test("declares a permissions.allow array", () => { + expect(parsed.permissions).toBeDefined(); + expect(Array.isArray(parsed.permissions.allow)).toBe(true); + }); + + test.each(REQUIRED_ENTRIES.map((entry) => [entry]))( + "permissions.allow contains the canonical entry %s", + (entry) => { + expect(parsed.permissions.allow).toContain(entry); + } + ); +}); diff --git a/scripts/__tests__/claude-permissions-contract.test.js.meta b/scripts/__tests__/claude-permissions-contract.test.js.meta new file mode 100644 index 00000000..7991c7cf --- /dev/null +++ b/scripts/__tests__/claude-permissions-contract.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 078663c8046ea254ba3dcf20951867e3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/devcontainer-cache-contract.test.js b/scripts/__tests__/devcontainer-cache-contract.test.js new file mode 100644 index 00000000..c740f1d1 --- /dev/null +++ b/scripts/__tests__/devcontainer-cache-contract.test.js @@ -0,0 +1,144 @@ +/** + * @fileoverview Contract tests for the .devcontainer/ cache mount surface. + * + * cache-contract.sh defines the bash arrays CACHE_MOUNT_SOURCES and + * CACHE_MOUNT_TARGETS that post-create.sh, post-start.sh, validate-caching.sh, + * and devcontainer.json all rely on. We line-scan rather than `source`-ing the + * file because Jest runs in pure Node.js — and even when a bash were available, + * `set -e` + the file's re-source guard would make repeat runs of the test + * suite spuriously fail. The line-scan also keeps the test fast (<10ms). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const DEVCONTAINER_DIR = path.join(REPO_ROOT, ".devcontainer"); + +/** + * Parse a `readonly NAME=( "a" "b" )` style array out of a bash file. Tolerant + * of leading whitespace, single- or double-quoted entries, and inline + * comments. Throws when the array isn't found. + * + * @param {string} content - Raw bash source + * @param {string} arrayName - The array variable name (without the `$`) + * @returns {string[]} Array entries (quotes stripped) + */ +function parseBashArray(content, arrayName) { + const re = new RegExp( + `^\\s*(?:readonly\\s+|declare\\s+-[a-z]+\\s+)?${arrayName}\\s*=\\s*\\(([\\s\\S]*?)\\)`, + "m" + ); + const match = content.match(re); + if (!match) { + throw new Error(`bash array ${arrayName} not found`); + } + return match[1] + .split("\n") + .map((line) => line.replace(/#.*$/, "").trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^["']|["']$/g, "")); +} + +describe(".devcontainer cache mount contract", () => { + const cacheContractPath = path.join(DEVCONTAINER_DIR, "cache-contract.sh"); + const devcontainerJsonPath = path.join(DEVCONTAINER_DIR, "devcontainer.json"); + const dockerfilePath = path.join(DEVCONTAINER_DIR, "Dockerfile"); + const postCreatePath = path.join(DEVCONTAINER_DIR, "post-create.sh"); + const postStartPath = path.join(DEVCONTAINER_DIR, "post-start.sh"); + + let cacheContract; + let devcontainerJson; + let dockerfile; + let sources; + let targets; + + beforeAll(() => { + cacheContract = fs.readFileSync(cacheContractPath, "utf8"); + devcontainerJson = fs.readFileSync(devcontainerJsonPath, "utf8"); + dockerfile = fs.readFileSync(dockerfilePath, "utf8"); + sources = parseBashArray(cacheContract, "CACHE_MOUNT_SOURCES"); + targets = parseBashArray(cacheContract, "CACHE_MOUNT_TARGETS"); + }); + + test("cache-contract.sh exists", () => { + expect(fs.existsSync(cacheContractPath)).toBe(true); + }); + + test("CACHE_MOUNT_SOURCES has at least 4 entries", () => { + expect(sources.length).toBeGreaterThanOrEqual(4); + }); + + test("CACHE_MOUNT_TARGETS has the same length as CACHE_MOUNT_SOURCES", () => { + // Aligned-by-index is the documented contract; a length mismatch would + // silently shift mounts under the rug. + expect(targets.length).toBe(sources.length); + }); + + test("each source name appears verbatim in devcontainer.json `mounts`", () => { + for (const source of sources) { + expect(devcontainerJson).toContain(`source=${source}`); + } + }); + + test("each target path appears verbatim in devcontainer.json `mounts`", () => { + for (const target of targets) { + expect(devcontainerJson).toContain(`target=${target}`); + } + }); + + test("devcontainer cache contract does not include Unity Library", () => { + expect(sources).not.toContain("dxm-unity-library-cache"); + expect(targets).not.toContain( + "/workspaces/com.wallstop-studios.dxmessaging/.unity-test-project/Library" + ); + expect(devcontainerJson).not.toContain("dxm-unity-library-cache"); + }); + + test("Dockerfile pre-creates every cache target that lives under the workspace", () => { + const workspaceTargets = targets.filter((target) => target.startsWith("/workspaces/")); + + for (const target of workspaceTargets) { + expect(dockerfile).toContain(target); + } + }); + + test("Dockerfile does not pre-create static Unity Library cache target", () => { + expect(dockerfile).not.toContain( + "/workspaces/com.wallstop-studios.dxmessaging/.unity-test-project/Library" + ); + }); + + test("devcontainer.json includes the docker-outside-of-docker feature", () => { + // The feature reference key on the registry; any version is fine. + expect(devcontainerJson).toMatch(/devcontainers\/features\/docker-outside-of-docker:1/); + }); + + test("devcontainer forwards Unity license and host workspace env vars", () => { + expect(devcontainerJson).toContain('"UNITY_LICENSE"'); + expect(devcontainerJson).toContain('"UNITY_LICENSE_B64"'); + expect(devcontainerJson).toContain('"UNITY_SERIAL"'); + expect(devcontainerJson).toContain('"UNITY_EMAIL"'); + expect(devcontainerJson).toContain('"UNITY_PASSWORD"'); + expect(devcontainerJson).toContain('"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"'); + }); + + test("Dockerfile declares the BuildKit syntax directive on the first line", () => { + // First non-empty line must be a `# syntax=docker/dockerfile:` + // directive — Docker only honors it when it is the very first line. + const firstLine = dockerfile.split(/\r?\n/)[0]; + expect(firstLine).toMatch(/^#\s*syntax=docker\/dockerfile:1\.\d+/); + }); + + test("post-create.sh sources cache-contract.sh", () => { + const postCreate = fs.readFileSync(postCreatePath, "utf8"); + expect(postCreate).toMatch(/source\s+["']?[^"'\s]*cache-contract\.sh/); + }); + + test("post-start.sh sources cache-contract.sh", () => { + const postStart = fs.readFileSync(postStartPath, "utf8"); + expect(postStart).toMatch(/source\s+["']?[^"'\s]*cache-contract\.sh/); + }); +}); diff --git a/scripts/__tests__/devcontainer-cache-contract.test.js.meta b/scripts/__tests__/devcontainer-cache-contract.test.js.meta new file mode 100644 index 00000000..2a9625e7 --- /dev/null +++ b/scripts/__tests__/devcontainer-cache-contract.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9f60b4f52b28fec439e827ad06d21796 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/llm-skills-unity-coverage.test.js b/scripts/__tests__/llm-skills-unity-coverage.test.js new file mode 100644 index 00000000..0773fcaf --- /dev/null +++ b/scripts/__tests__/llm-skills-unity-coverage.test.js @@ -0,0 +1,118 @@ +/** + * @fileoverview Contract test for the Unity + GitHub Actions skill coverage. + * + * Phase 4A creates a fixed set of skill pages under .llm/skills/unity/ and one + * ported page under .llm/skills/github-actions/. The headless workflow, the + * license bootstrap walkthrough, and the devcontainer cache contract docs all + * live in those skill files (the .llm/context.md additions in Phase 4B link + * directly to them). We lock the file paths and basic shape here so any + * silent rename, deletion, or accidental wipe fails loudly. + * + * Note: this test will FAIL until Phase 4A finishes writing the 7 new skill + * files. That is the intended contract behavior — the test is correct, and + * will pass automatically once Phase 4A lands. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const SKILLS_DIR = path.join(REPO_ROOT, ".llm", "skills"); +const CONTEXT_PATH = path.join(REPO_ROOT, ".llm", "context.md"); + +const NEW_UNITY_SKILLS = [ + "headless-test-runner", + "unity-license-bootstrap", + "upm-test-harness", + "devcontainer-cache-contract", + "unity-ci-matrix", + "unity-perf-test-isolation" +]; + +const NEW_GITHUB_ACTIONS_SKILL = "github-actions/cicd-devcontainer-workflows.md"; + +const EXISTING_BASELINE_SKILL = "unity/base-call-contract.md"; + +describe(".llm/skills unity + github-actions coverage", () => { + test.each(NEW_UNITY_SKILLS.map((slug) => [slug]))("unity skill page exists: %s.md", (slug) => { + const absPath = path.join(SKILLS_DIR, "unity", `${slug}.md`); + expect(fs.existsSync(absPath)).toBe(true); + }); + + test("github-actions skill page cicd-devcontainer-workflows.md exists", () => { + const absPath = path.join(SKILLS_DIR, NEW_GITHUB_ACTIONS_SKILL); + expect(fs.existsSync(absPath)).toBe(true); + }); + + test("existing baseline skill (unity/base-call-contract.md) still exists", () => { + const absPath = path.join(SKILLS_DIR, EXISTING_BASELINE_SKILL); + expect(fs.existsSync(absPath)).toBe(true); + }); + + test.each(NEW_UNITY_SKILLS.map((slug) => [slug]))("%s.md is non-empty (>1 KB)", (slug) => { + const absPath = path.join(SKILLS_DIR, "unity", `${slug}.md`); + if (!fs.existsSync(absPath)) { + // Surface a clearer failure than a synthetic stat error. + throw new Error(`Skill ${slug}.md missing — Phase 4A must create it.`); + } + const stats = fs.statSync(absPath); + expect(stats.size).toBeGreaterThan(1024); + }); + + test("github-actions/cicd-devcontainer-workflows.md is non-empty (>1 KB)", () => { + const absPath = path.join(SKILLS_DIR, NEW_GITHUB_ACTIONS_SKILL); + if (!fs.existsSync(absPath)) { + throw new Error( + "github-actions/cicd-devcontainer-workflows.md missing -- Phase 4A must create it." + ); + } + const stats = fs.statSync(absPath); + expect(stats.size).toBeGreaterThan(1024); + }); + + test.each(NEW_UNITY_SKILLS.map((slug) => [slug]))( + "%s.md begins with a `# ` heading on the first non-blank line", + (slug) => { + const absPath = path.join(SKILLS_DIR, "unity", `${slug}.md`); + if (!fs.existsSync(absPath)) { + throw new Error(`Skill ${slug}.md missing — Phase 4A must create it.`); + } + const content = fs.readFileSync(absPath, "utf8"); + const firstNonBlank = content.split(/\r?\n/).find((line) => line.trim().length > 0); + // Frontmatter `---` lines are common in this repo's skill files; + // accept either a `# ` heading directly OR `---` (frontmatter + // delimiter) as the first non-blank line. + expect(firstNonBlank).toMatch(/^(#\s|---\s*$)/); + } + ); + + test("github-actions/cicd-devcontainer-workflows.md begins with `# ` heading or frontmatter", () => { + const absPath = path.join(SKILLS_DIR, NEW_GITHUB_ACTIONS_SKILL); + if (!fs.existsSync(absPath)) { + throw new Error( + "github-actions/cicd-devcontainer-workflows.md missing -- Phase 4A must create it." + ); + } + const content = fs.readFileSync(absPath, "utf8"); + const firstNonBlank = content.split(/\r?\n/).find((line) => line.trim().length > 0); + expect(firstNonBlank).toMatch(/^(#\s|---\s*$)/); + }); + + test(".llm/context.md links to each new unity skill by file path", () => { + const context = fs.readFileSync(CONTEXT_PATH, "utf8"); + for (const slug of NEW_UNITY_SKILLS) { + // The slug must appear in a path-like reference (skills/unity/...) + // somewhere in the file. Be tolerant of `./` and bare relative + // forms. + const re = new RegExp(`skills/unity/${slug}\\.md`); + expect(context).toMatch(re); + } + }); + + test(".llm/context.md links to the github-actions devcontainer skill", () => { + const context = fs.readFileSync(CONTEXT_PATH, "utf8"); + expect(context).toMatch(/skills\/github-actions\/cicd-devcontainer-workflows\.md/); + }); +}); diff --git a/scripts/__tests__/llm-skills-unity-coverage.test.js.meta b/scripts/__tests__/llm-skills-unity-coverage.test.js.meta new file mode 100644 index 00000000..758e817d --- /dev/null +++ b/scripts/__tests__/llm-skills-unity-coverage.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a097c8ec981c9b4cad7f2390c19815c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/unity-perf-isolation.test.js b/scripts/__tests__/unity-perf-isolation.test.js new file mode 100644 index 00000000..04e133b8 --- /dev/null +++ b/scripts/__tests__/unity-perf-isolation.test.js @@ -0,0 +1,149 @@ +/** + * @fileoverview Keystone contract test for the Unity perf-isolation rule. + * + * The .llm/context.md "perf isolation" line (114) requires that the + * Benchmarks/Allocations asmdefs run ONLY on the scheduled + * benchmarks workflow, never on the PR gate. The single source of truth for + * that decision is scripts/unity/lib/asmdef-discovery.js — both the run-tests + * scripts and the Unity Tests workflow shell out to it via `node -e`. This + * test locks the contract end-to-end: + * 1. Asmdef classification is correct for every Tests/ asmdef (no drift). + * 2. defaultIncludeAssemblies returns exactly the core assemblies by + * default, exactly core+perf with includePerf, exactly core+comparison + * with includeComparisons, and exactly core+integration with + * includeIntegrations. + * 3. The CI workflow consumes the discovery module rather than hardcoding + * a string list (which would silently rot the moment a new asmdef + * lands). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const { + enumerateTestAsmdefs, + classifyAsmdef, + defaultIncludeAssemblies +} = require("../../scripts/unity/lib/asmdef-discovery.js"); + +describe("unity perf-isolation contract", () => { + let entries; + + beforeAll(() => { + entries = enumerateTestAsmdefs(REPO_ROOT); + }); + + test("enumerateTestAsmdefs discovers exactly 9 asmdefs under Tests/", () => { + // 9 = 2 core (Editor, Runtime) + 3 perf (00.Editor.Benchmarks, + // 00.Runtime.Benchmarks, Editor.Allocations) + 1 comparison + // (00.Editor.Comparisons) + 3 integration (Reflex, VContainer, Zenject). + // If a new asmdef is intentionally added, update this number AND + // add it to one of the buckets below. + expect(entries).toHaveLength(9); + }); + + test("every Benchmarks/Allocations asmdef is classified as `perf`", () => { + const perfAsmdefs = entries.filter((e) => /Benchmarks|Allocations/.test(e.name)); + expect(perfAsmdefs.length).toBeGreaterThan(0); + for (const entry of perfAsmdefs) { + expect(classifyAsmdef(entry.name)).toBe("perf"); + expect(entry.isPerf).toBe(true); + expect(entry.isInteg).toBe(false); + } + }); + + test("every Comparisons asmdef is classified as `comparison`", () => { + const comparisonAsmdefs = entries.filter((e) => /Comparisons/.test(e.name)); + expect(comparisonAsmdefs.length).toBeGreaterThan(0); + for (const entry of comparisonAsmdefs) { + expect(classifyAsmdef(entry.name)).toBe("comparison"); + expect(entry.isComparison).toBe(true); + expect(entry.isPerf).toBe(false); + expect(entry.isInteg).toBe(false); + } + }); + + test("every Reflex/Zenject/VContainer asmdef is classified as `integration`", () => { + const integAsmdefs = entries.filter((e) => /Reflex|Zenject|VContainer/.test(e.name)); + expect(integAsmdefs.length).toBeGreaterThan(0); + for (const entry of integAsmdefs) { + expect(classifyAsmdef(entry.name)).toBe("integration"); + expect(entry.isInteg).toBe(true); + expect(entry.isPerf).toBe(false); + } + }); + + test("remaining asmdefs (Editor, Runtime) are classified as `core`", () => { + const coreAsmdefs = entries.filter( + (e) => !/Benchmarks|Allocations|Comparisons|Reflex|Zenject|VContainer/.test(e.name) + ); + expect(coreAsmdefs.length).toBeGreaterThan(0); + for (const entry of coreAsmdefs) { + expect(classifyAsmdef(entry.name)).toBe("core"); + expect(entry.isPerf).toBe(false); + expect(entry.isInteg).toBe(false); + } + }); + + test("defaultIncludeAssemblies(repoRoot) returns exactly 2 core assemblies", () => { + const included = defaultIncludeAssemblies(REPO_ROOT); + expect(included).toHaveLength(2); + expect(included).toEqual( + expect.arrayContaining([ + "WallstopStudios.DxMessaging.Tests.Editor", + "WallstopStudios.DxMessaging.Tests.Runtime" + ]) + ); + }); + + test("defaultIncludeAssemblies({ includePerf: true }) adds the 3 perf assemblies (total 5)", () => { + const included = defaultIncludeAssemblies(REPO_ROOT, { includePerf: true }); + expect(included).toHaveLength(5); + // Verify the perf names show up. + for (const expected of [ + "WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks", + "WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks", + "WallstopStudios.DxMessaging.Tests.Editor.Allocations" + ]) { + expect(included).toContain(expected); + } + }); + + test("defaultIncludeAssemblies({ includeComparisons: true }) adds external comparison assembly", () => { + const included = defaultIncludeAssemblies(REPO_ROOT, { + includeComparisons: true + }); + expect(included).toHaveLength(3); + expect(included).toContain("WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons"); + }); + + test("defaultIncludeAssemblies({ includeIntegrations: true }) adds the 3 integration assemblies (total 5)", () => { + const included = defaultIncludeAssemblies(REPO_ROOT, { + includeIntegrations: true + }); + expect(included).toHaveLength(5); + for (const expected of [ + "WallstopStudios.DxMessaging.Tests.Runtime.Reflex", + "WallstopStudios.DxMessaging.Tests.Runtime.VContainer", + "WallstopStudios.DxMessaging.Tests.Runtime.Zenject" + ]) { + expect(included).toContain(expected); + } + }); + + test("unity-tests.yml shells out to defaultIncludeAssemblies (no hardcoded asmdef list)", () => { + const workflowPath = path.join(REPO_ROOT, ".github", "workflows", "unity-tests.yml"); + const workflow = fs.readFileSync(workflowPath, "utf8"); + + // The single source of truth contract: the workflow must shell out to + // the JS module via `node -e`. Tolerate whitespace and quoting + // variation but require both the require() and the + // defaultIncludeAssemblies(...) call. + expect(workflow).toMatch(/node\s+-e/); + expect(workflow).toMatch(/require\(['"]\.\/scripts\/unity\/lib\/asmdef-discovery\.js['"]\)/); + expect(workflow).toMatch(/defaultIncludeAssemblies\s*\(/); + }); +}); diff --git a/scripts/__tests__/unity-perf-isolation.test.js.meta b/scripts/__tests__/unity-perf-isolation.test.js.meta new file mode 100644 index 00000000..ee76740e --- /dev/null +++ b/scripts/__tests__/unity-perf-isolation.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 251d7dfd69acd8e4a9b994a33371cf30 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js new file mode 100644 index 00000000..564f86a4 --- /dev/null +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -0,0 +1,273 @@ +/** + * @fileoverview Contract tests for the Unity headless runner CLI surface. + * + * scripts/unity/run-tests.sh, run-tests.ps1, and activate-license.sh are the + * canonical local entry points for the headless Unity workflow. They are + * invoked from documentation, the .claude allowlist, the devcontainer test + * workflow, and (transitively) the activate-license skill page. + * + * Renaming a flag or dropping the docker-outside-of-docker (DooD) path + * translation contract would silently break those callers, so we lock the + * surface here with text-grep assertions. We deliberately avoid invoking + * `bash` / `pwsh` on these scripts: they would try to talk to Docker and + * Unity and the grep approach is sub-millisecond. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const UNITY_SCRIPTS = path.join(REPO_ROOT, "scripts", "unity"); + +function readScript(relPath) { + const abs = path.join(REPO_ROOT, relPath); + expect(fs.existsSync(abs)).toBe(true); + return fs.readFileSync(abs, "utf8"); +} + +function hasExecutableBit(absPath) { + const mode = fs.statSync(absPath).mode; + // Any of user/group/other execute bits set is enough; chmod tooling on + // contributor machines varies, but git+the CI runner only require one. + return (mode & 0o111) !== 0; +} + +describe("scripts/unity/run-tests.sh contract", () => { + const shPath = path.join(UNITY_SCRIPTS, "run-tests.sh"); + let content; + + beforeAll(() => { + content = readScript("scripts/unity/run-tests.sh"); + }); + + test("file exists and is executable", () => { + expect(fs.existsSync(shPath)).toBe(true); + expect(hasExecutableBit(shPath)).toBe(true); + }); + + test.each([ + ["--platform"], + ["--unity-version"], + ["--filter"], + ["--include-perf"], + ["--include-integrations"], + ["--include-comparisons"], + ["--results"], + ["--help"] + ])("help text references %s", (flag) => { + expect(content).toContain(flag); + }); + + test("references LOCAL_WORKSPACE_FOLDER (DooD path translation)", () => { + expect(content).toContain("LOCAL_WORKSPACE_FOLDER"); + }); + + test("references DXM_HOST_REPO_ROOT override (DooD path translation)", () => { + expect(content).toContain("DXM_HOST_REPO_ROOT"); + }); + + test("prefers docker-inspected devcontainer mount before LOCAL_WORKSPACE_FOLDER", () => { + expect(content.indexOf("if is_container_runtime")).toBeGreaterThan( + content.indexOf("DXM_HOST_REPO_ROOT") + ); + expect(content.indexOf("detect_host_repo_root_from_container")).toBeLessThan( + content.indexOf("Ignoring LOCAL_WORKSPACE_FOLDER=") + ); + }); + + test("license contract supports ULF, local base64 ULF, and serial paths", () => { + expect(content).toContain("UNITY_LICENSE"); + expect(content).toContain("UNITY_LICENSE_B64"); + expect(content).toContain("UNITY_SERIAL"); + expect(content).toContain("UNITY_EMAIL"); + expect(content).toContain("UNITY_PASSWORD"); + expect(content).not.toContain('-serial ""'); + expect(content).not.toContain("personal-email"); + }); + + test("standalone player run forwards the same assembly and filter controls", () => { + const standaloneRun = content.slice(content.indexOf("build_standalone_run_cmd_inner")); + expect(standaloneRun).toContain("-assemblyNames"); + expect(standaloneRun).toContain("-testFilter"); + }); + + test("normalizes relative --results paths under the repo before validation", () => { + expect(content).toMatch(/RESULTS_PATH=.*REPO_ROOT/); + expect(content).toContain("${RESULTS_PATH#./}"); + expect(content).toContain("RESULTS_DIR_REAL="); + expect(content).toContain("REPO_ROOT_REAL="); + expect(content).toContain('${RESULTS_PATH#"${REPO_ROOT_REAL}/"}'); + }); + + test("quotes caller-controlled inner bash arguments", () => { + expect(content).toContain("printf '%q'"); + expect(content).toContain("filter_q="); + expect(content).toContain("results_q="); + expect(content).toContain("assemblies_q="); + }); + + test("returns Unity Library cache ownership to the invoking user", () => { + expect(content).toContain("trap cleanup_ownership EXIT"); + expect(content).toContain("UNITY_LIBRARY_CACHE_SOURCE="); + expect(content).toContain("dxm-unity-library-%s-%s"); + expect(content).toContain( + 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true' + ); + expect(content).toContain( + 'chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true' + ); + expect(content).toContain( + '-v "${UNITY_LIBRARY_CACHE_SOURCE}:/workspace/.unity-test-project/Library"' + ); + expect(content).not.toContain("dxm-unity-library-cache:/workspace/.unity-test-project/Library"); + }); +}); + +describe("scripts/unity/run-tests.ps1 contract", () => { + const ps1Path = path.join(UNITY_SCRIPTS, "run-tests.ps1"); + let content; + + beforeAll(() => { + content = readScript("scripts/unity/run-tests.ps1"); + }); + + test("file exists", () => { + expect(fs.existsSync(ps1Path)).toBe(true); + }); + + test("ValidateSet pins editmode/playmode/standalone (whitespace-tolerant)", () => { + // Allow optional whitespace between members so contributors can run + // PowerShell formatters without invalidating the contract test. + expect(content).toMatch( + /\[ValidateSet\(\s*'editmode'\s*,\s*'playmode'\s*,\s*'standalone'\s*\)\]/ + ); + }); + + test.each([ + ["Platform"], + ["UnityVersion"], + ["IncludePerf"], + ["IncludeIntegrations"], + ["IncludeComparisons"] + ])("declares parameter %s (as $Name and -Name)", (paramName) => { + // PowerShell convention: `param([type]$Name)` declares the parameter, + // callers pass it as `-Name`. We require BOTH forms: the variable + // declaration AND at least one caller-side reference (or .PARAMETER + // doc) so a typo in either half fails loudly. + const variableForm = new RegExp(`\\$${paramName}\\b`); + const callerForm = new RegExp(`-${paramName}\\b`); + const docForm = new RegExp(`\\.PARAMETER\\s+${paramName}\\b`); + expect(content).toMatch(variableForm); + // Either a caller-side `-Name` reference (in examples / Write-Host + // help text) or a `.PARAMETER Name` doc block satisfies the + // discoverability half of the contract. + expect(callerForm.test(content) || docForm.test(content)).toBe(true); + }); + + test("references LOCAL_WORKSPACE_FOLDER (DooD path translation)", () => { + expect(content).toContain("LOCAL_WORKSPACE_FOLDER"); + }); + + test("references DXM_HOST_REPO_ROOT override (DooD path translation)", () => { + expect(content).toContain("DXM_HOST_REPO_ROOT"); + }); + + test("prefers docker-inspected devcontainer mount before LOCAL_WORKSPACE_FOLDER", () => { + expect(content.indexOf("$InContainer = Test-ContainerRuntime")).toBeGreaterThan( + content.indexOf("$env:DXM_HOST_REPO_ROOT") + ); + expect(content.indexOf("$HostRepoRoot = Get-InspectedHostRepoRoot")).toBeLessThan( + content.indexOf("Ignoring LOCAL_WORKSPACE_FOLDER=") + ); + }); + + test("license contract supports ULF, local base64 ULF, and serial paths", () => { + expect(content).toContain("UNITY_LICENSE"); + expect(content).toContain("UNITY_LICENSE_B64"); + expect(content).toContain("UNITY_SERIAL"); + expect(content).not.toContain('-serial ""'); + expect(content).not.toContain("personal-email"); + }); + + test("standalone player run forwards the same assembly and filter controls", () => { + const standaloneRun = content.slice(content.indexOf("Get-StandaloneRunCommandInner")); + expect(standaloneRun).toContain("-assemblyNames"); + expect(standaloneRun).toContain("-testFilter"); + }); + + test("uses boundary-aware Results path validation", () => { + expect(content).toContain("$RepoRootReal"); + expect(content).toContain("$ResultsDirReal"); + expect(content).toContain("$ResultsRel -eq $RepoRootReal"); + expect(content).toContain('$ResultsRel.StartsWith("$RepoRootReal/")'); + expect(content).toContain('$ResultsRel.StartsWith("$RepoRootReal\\")'); + expect(content).toContain("$Results = Join-Path $RepoRoot $Results"); + expect(content).not.toContain("TrimStart('.', '/', '\\')"); + }); + + test("quotes caller-controlled inner bash arguments", () => { + expect(content).toContain("ConvertTo-BashSingleQuotedString"); + expect(content).toContain("$filterQ"); + expect(content).toContain("$resultsQ"); + expect(content).toContain("$assembliesQ"); + }); + + test("returns Unity Library cache ownership to the invoking user", () => { + expect(content).toContain("trap cleanup_ownership EXIT"); + expect(content).toContain("$UnityLibraryCacheSource"); + expect(content).toContain("dxm-unity-library-$ImageTag-$Platform"); + expect(content).toContain( + 'chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true' + ); + expect(content).toContain('"$UnityLibraryCacheSource`:/workspace/.unity-test-project/Library"'); + expect(content).not.toContain("dxm-unity-library-cache:/workspace/.unity-test-project/Library"); + }); +}); + +describe("scripts/unity/activate-license.sh contract", () => { + const licPath = path.join(UNITY_SCRIPTS, "activate-license.sh"); + let content; + + beforeAll(() => { + content = readScript("scripts/unity/activate-license.sh"); + }); + + test("file exists and is executable", () => { + expect(fs.existsSync(licPath)).toBe(true); + expect(hasExecutableBit(licPath)).toBe(true); + }); + + test("exposes --check mode (diagnostic / default)", () => { + expect(content).toContain("--check"); + }); + + test("exposes --apply mode (Pro .ulf encoder)", () => { + expect(content).toContain("--apply"); + }); + + test("references LOCAL_WORKSPACE_FOLDER (DooD path translation)", () => { + expect(content).toContain("LOCAL_WORKSPACE_FOLDER"); + }); + + test("references DXM_HOST_REPO_ROOT override (DooD path translation)", () => { + expect(content).toContain("DXM_HOST_REPO_ROOT"); + }); + + test("prefers docker-inspected devcontainer mount before LOCAL_WORKSPACE_FOLDER", () => { + expect(content.indexOf("if is_container_runtime")).toBeGreaterThan( + content.indexOf("DXM_HOST_REPO_ROOT") + ); + expect(content.indexOf("detect_host_repo_root_from_container")).toBeLessThan( + content.indexOf("Ignoring LOCAL_WORKSPACE_FOLDER=") + ); + }); + + test("does not advertise email/password-only Personal activation", () => { + expect(content).toContain("UNITY_LICENSE_B64"); + expect(content).toContain("UNITY_SERIAL"); + expect(content).not.toContain('-serial ""'); + expect(content).not.toContain("Personal activation succeeded"); + }); +}); diff --git a/scripts/__tests__/unity-runner-script-contract.test.js.meta b/scripts/__tests__/unity-runner-script-contract.test.js.meta new file mode 100644 index 00000000..5c7a9d9d --- /dev/null +++ b/scripts/__tests__/unity-runner-script-contract.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 150f3961587f7ed46b0d144439fddde0 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/unity-test-harness-contract.test.js b/scripts/__tests__/unity-test-harness-contract.test.js new file mode 100644 index 00000000..6ada4015 --- /dev/null +++ b/scripts/__tests__/unity-test-harness-contract.test.js @@ -0,0 +1,215 @@ +/** + * @fileoverview Contract tests for the .unity-test-project/ test harness. + * + * Phase 1 of the Unity headless workflow lays down a Unity project under + * .unity-test-project/ that pulls the package via `file:../..` and exposes + * the package's Tests/ asmdefs through `testables`. Several downstream + * artifacts (run-tests.sh, the GitHub Actions workflow's cache key, + * TestRunnerBuilder.cs) hard-code paths/values from these files, so the + * goal of this suite is to make any silent rename or shape drift fail + * loudly at the JS-test layer. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const TEST_PROJECT = path.join(REPO_ROOT, ".unity-test-project"); + +describe("unity test harness contract (.unity-test-project/)", () => { + describe("Packages/manifest.json", () => { + const manifestPath = path.join(TEST_PROJECT, "Packages", "manifest.json"); + + test("manifest.json exists and parses as JSON", () => { + expect(fs.existsSync(manifestPath)).toBe(true); + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + expect(parsed).toEqual(expect.any(Object)); + }); + + test("declares the package as `file:../..` (so the harness pulls the workspace package)", () => { + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + expect(parsed.dependencies).toBeDefined(); + expect(parsed.dependencies["com.wallstop-studios.dxmessaging"]).toBe("file:../.."); + }); + + test("declares Performance Testing package required by perf asmdefs", () => { + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + expect(parsed.dependencies["com.unity.test-framework.performance"]).toBe("3.4.2"); + }); + + test("`testables` array contains the package id", () => { + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + expect(Array.isArray(parsed.testables)).toBe(true); + expect(parsed.testables).toContain("com.wallstop-studios.dxmessaging"); + }); + }); + + describe("Packages/packages-lock.json", () => { + const lockPath = path.join(TEST_PROJECT, "Packages", "packages-lock.json"); + + test("packages-lock.json exists and parses as JSON", () => { + expect(fs.existsSync(lockPath)).toBe(true); + const raw = fs.readFileSync(lockPath, "utf8"); + // Throw a friendly error on parse failure rather than the bare JSON one. + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`packages-lock.json is not valid JSON: ${error.message}`); + } + expect(parsed).toEqual(expect.any(Object)); + }); + + test("locks Performance Testing package required by perf asmdefs", () => { + const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")); + expect(parsed.dependencies["com.unity.test-framework.performance"]).toEqual( + expect.objectContaining({ + version: "3.4.2", + source: "registry" + }) + ); + }); + }); + + describe("ProjectSettings/ProjectVersion.txt", () => { + const versionPath = path.join(TEST_PROJECT, "ProjectSettings", "ProjectVersion.txt"); + + test("ProjectVersion.txt exists and contains m_EditorVersion", () => { + expect(fs.existsSync(versionPath)).toBe(true); + const content = fs.readFileSync(versionPath, "utf8"); + expect(content).toMatch(/m_EditorVersion:/); + }); + + test("editor version matches one of the unity-tests.yml matrix entries", () => { + const content = fs.readFileSync(versionPath, "utf8"); + const match = content.match(/m_EditorVersion:\s*(\S+)/); + expect(match).not.toBeNull(); + const projectVersion = match[1].trim(); + + const workflowPath = path.join(REPO_ROOT, ".github", "workflows", "unity-tests.yml"); + const workflowText = fs.readFileSync(workflowPath, "utf8"); + // The matrix is generated dynamically inside a shell heredoc, so a + // structural YAML walk would skip those values; the canonical list + // is encoded as a literal JSON array on one line. Grep for any + // version literal that looks like a Unity tag and verify the + // ProjectVersion is among them. + const versionRegex = /\d+\.\d+\.\d+f\d+/g; + const matrixVersions = new Set(workflowText.match(versionRegex) || []); + expect(matrixVersions.size).toBeGreaterThan(0); + expect(matrixVersions).toContain(projectVersion); + }); + }); + + describe("Assets/Editor/TestRunnerBuilder.cs", () => { + const builderPath = path.join(TEST_PROJECT, "Assets", "Editor", "TestRunnerBuilder.cs"); + + test("TestRunnerBuilder.cs exists", () => { + expect(fs.existsSync(builderPath)).toBe(true); + }); + + test("exposes BuildIL2CPPTestPlayer entry point", () => { + const content = fs.readFileSync(builderPath, "utf8"); + expect(content).toMatch(/BuildIL2CPPTestPlayer/); + }); + + test("respects the DXM_IL2CPP_BUILD_PATH env var override", () => { + const content = fs.readFileSync(builderPath, "utf8"); + expect(content).toMatch(/DXM_IL2CPP_BUILD_PATH/); + // The env var should be read via Environment.GetEnvironmentVariable + // (so CI / local docker can both override the build output path + // without source edits). + expect(content).toMatch(/Environment\.GetEnvironmentVariable/); + }); + }); + + describe("Assets/Editor/...TestHarness.Editor.asmdef", () => { + const asmdefPath = path.join( + TEST_PROJECT, + "Assets", + "Editor", + "WallstopStudios.DxMessaging.TestHarness.Editor.asmdef" + ); + + test("test harness asmdef exists", () => { + expect(fs.existsSync(asmdefPath)).toBe(true); + }); + + test("asmdef parses as JSON and declares the canonical name", () => { + const parsed = JSON.parse(fs.readFileSync(asmdefPath, "utf8")); + expect(parsed.name).toBe("WallstopStudios.DxMessaging.TestHarness.Editor"); + }); + }); + + describe("default runtime test asmdef", () => { + const runtimeAsmdefPath = path.join( + REPO_ROOT, + "Tests", + "Runtime", + "WallstopStudios.DxMessaging.Tests.Runtime.asmdef" + ); + + test("does not reference optional DI integration assemblies", () => { + const parsed = JSON.parse(fs.readFileSync(runtimeAsmdefPath, "utf8")); + expect(parsed.references).toEqual(expect.any(Array)); + expect(parsed.references).not.toContain("WallstopStudios.DxMessaging.Reflex"); + expect(parsed.references).not.toContain("WallstopStudios.DxMessaging.VContainer"); + expect(parsed.references).not.toContain("WallstopStudios.DxMessaging.Zenject"); + }); + }); + + describe("default benchmark asmdefs", () => { + const externalComparisonRefs = ["Zenject", "MessagePipe", "UniRx", "UniTask"]; + + test.each([ + ["Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef"], + ["Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef"] + ])("%s does not require external comparison packages", (relPath) => { + const parsed = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, relPath), "utf8")); + for (const externalRef of externalComparisonRefs) { + expect(parsed.references).not.toContain(externalRef); + } + }); + }); + + describe("comparison benchmark asmdef", () => { + const comparisonAsmdefPath = path.join( + REPO_ROOT, + "Tests", + "Editor", + "Comparisons", + "WallstopStudios.DxMessaging.Tests.00.Editor.Comparisons.asmdef" + ); + + test("requires external comparison package symbols before Unity compiles it", () => { + const parsed = JSON.parse(fs.readFileSync(comparisonAsmdefPath, "utf8")); + expect(parsed.defineConstraints).toEqual( + expect.arrayContaining([ + "MESSAGEPIPE_PRESENT", + "UNIRX_PRESENT", + "ZENJECT_PRESENT", + "UNITASK_PRESENT" + ]) + ); + expect(parsed.versionDefines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "com.cysharp.messagepipe", + define: "MESSAGEPIPE_PRESENT" + }), + expect.objectContaining({ name: "com.svermeulen.extenject", define: "ZENJECT_PRESENT" }), + expect.objectContaining({ name: "com.cysharp.unitask", define: "UNITASK_PRESENT" }) + ]) + ); + }); + }); + + // Sanity: unused yaml import elsewhere would be dead weight; reference it + // here so removing the dependency without updating other suites still trips + // CI early. (Other tests use yaml extensively.) + test("js-yaml is available for downstream YAML-shape suites", () => { + expect(typeof yaml.load).toBe("function"); + }); +}); diff --git a/scripts/__tests__/unity-test-harness-contract.test.js.meta b/scripts/__tests__/unity-test-harness-contract.test.js.meta new file mode 100644 index 00000000..62b823ab --- /dev/null +++ b/scripts/__tests__/unity-test-harness-contract.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2652042e680db3749aae79bb409138e8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/unity-workflow-shape.test.js b/scripts/__tests__/unity-workflow-shape.test.js new file mode 100644 index 00000000..1015b351 --- /dev/null +++ b/scripts/__tests__/unity-workflow-shape.test.js @@ -0,0 +1,179 @@ +/** + * @fileoverview Contract tests for the shape of the Unity-related GitHub + * Actions workflows. + * + * These workflows have non-obvious invariants that, if violated, cause silent + * regressions: + * - unity-benchmarks.yml MUST be schedule + workflow_dispatch only (no + * pull_request, no push). Adding either trigger would convert the noisy + * perf suite into a PR-blocking gate, which is an explicit project-lead + * directive (see header of the file). + * - All Unity workflows must include manifest, packages-lock, and + * ProjectVersion in the exact Library cache key, with no broad restore + * keys — otherwise stale Library/ dirs from a prior Unity version corrupt + * the run. + * - The devcontainer workflows must override `eventFilterForPush: ""` to + * avoid devcontainers/ci@v0.3's silent push-skip on schedule/dispatch. + * + * We use js-yaml when available for the structural parts (the on: block in + * particular) and fall back to text-grep for cross-cutting requirements that + * are easier to verify line-by-line (cache key references, action versions). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows"); + +function readWorkflow(name) { + const abs = path.join(WORKFLOWS_DIR, name); + expect(fs.existsSync(abs)).toBe(true); + return fs.readFileSync(abs, "utf8"); +} + +function loadWorkflowYaml(name) { + const text = readWorkflow(name); + // js-yaml interprets the bare `on` key as the YAML 1.1 boolean `true` + // unless we use FAILSAFE / CORE schemas; load with default schema and + // pull either key when present. + return yaml.load(text); +} + +function expectExactUnityLibraryCache(text) { + expect(text).toContain("actions/cache@v4"); + expect(text).toContain("manifest.json"); + expect(text).toContain("packages-lock.json"); + expect(text).toContain("ProjectVersion.txt"); + expect(text).toContain("key: Library"); + expect(text).not.toContain("restore-keys:"); +} + +describe(".github/workflows/unity-tests.yml", () => { + let text; + + beforeAll(() => { + text = readWorkflow("unity-tests.yml"); + }); + + test("uses game-ci/unity-test-runner@v4", () => { + expect(text).toContain("game-ci/unity-test-runner@v4"); + }); + + test("references secrets.UNITY_LICENSE", () => { + expect(text).toMatch(/secrets\.UNITY_LICENSE/); + }); + + test("references secrets.UNITY_SERIAL for paid serial activation", () => { + expect(text).toMatch(/secrets\.UNITY_SERIAL/); + }); + + test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { + expectExactUnityLibraryCache(text); + }); + + test("uses actions/upload-artifact@v7 (matches repo baseline)", () => { + expect(text).toContain("actions/upload-artifact@v7"); + }); +}); + +describe(".github/workflows/unity-il2cpp.yml", () => { + let text; + + beforeAll(() => { + text = readWorkflow("unity-il2cpp.yml"); + }); + + test("uses game-ci/unity-builder@v4", () => { + expect(text).toContain("game-ci/unity-builder@v4"); + }); + + test("references secrets.UNITY_LICENSE", () => { + expect(text).toMatch(/secrets\.UNITY_LICENSE/); + }); + + test("references secrets.UNITY_SERIAL for paid serial activation", () => { + expect(text).toMatch(/secrets\.UNITY_SERIAL/); + }); + + test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { + expectExactUnityLibraryCache(text); + }); + + test("uses actions/upload-artifact@v7", () => { + expect(text).toContain("actions/upload-artifact@v7"); + }); + + test("references secrets.UNITY_SERIAL for paid serial activation", () => { + expect(text).toMatch(/secrets\.UNITY_SERIAL/); + }); +}); + +describe(".github/workflows/unity-benchmarks.yml", () => { + let text; + let parsed; + + beforeAll(() => { + text = readWorkflow("unity-benchmarks.yml"); + parsed = loadWorkflowYaml("unity-benchmarks.yml"); + }); + + test("`on:` block has ONLY schedule and workflow_dispatch (no pull_request, no push)", () => { + // YAML 1.1 turns the bare `on` key into `true`; check both keys to + // tolerate either representation across yaml/parser versions. + const onBlock = parsed.on || parsed[true]; + expect(onBlock).toBeDefined(); + expect(typeof onBlock).toBe("object"); + + const triggerKeys = Object.keys(onBlock).sort(); + expect(triggerKeys).toEqual(["schedule", "workflow_dispatch"]); + + // Belt-and-suspenders text grep: a stray `pull_request:` or `push:` + // anywhere in the on: block (even commented-out or otherwise missed + // by the structural walk above) should not appear at column-2 + // indentation. + const onLineMatch = text.match(/^on:[\s\S]*?(?=^\w)/m); + expect(onLineMatch).not.toBeNull(); + const onSection = onLineMatch[0]; + expect(onSection).not.toMatch(/^\s{2}pull_request:/m); + expect(onSection).not.toMatch(/^\s{2}push:/m); + }); + + test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { + expectExactUnityLibraryCache(text); + }); + + test("uses actions/upload-artifact@v7", () => { + expect(text).toContain("actions/upload-artifact@v7"); + }); +}); + +describe(".github/workflows/devcontainer-test.yml", () => { + let text; + + beforeAll(() => { + text = readWorkflow("devcontainer-test.yml"); + }); + + test('contains eventFilterForPush: "" exactly (overrides devcontainers/ci@v0.3 default)', () => { + // The default of "push" silently skips publishing on schedule / + // workflow_dispatch — empty string makes the explicit `push:` knob + // the single source of truth. Match either flow style or block style. + expect(text).toMatch(/eventFilterForPush:\s*""/); + }); +}); + +describe(".github/workflows/devcontainer-prebuild.yml", () => { + let text; + + beforeAll(() => { + text = readWorkflow("devcontainer-prebuild.yml"); + }); + + test('contains eventFilterForPush: "" exactly', () => { + expect(text).toMatch(/eventFilterForPush:\s*""/); + }); +}); diff --git a/scripts/__tests__/unity-workflow-shape.test.js.meta b/scripts/__tests__/unity-workflow-shape.test.js.meta new file mode 100644 index 00000000..b5f504ca --- /dev/null +++ b/scripts/__tests__/unity-workflow-shape.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 91149117504767d478715c08a18d52c0 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-npm-meta.test.js b/scripts/__tests__/validate-npm-meta.test.js index 9990837c..a1d4777d 100644 --- a/scripts/__tests__/validate-npm-meta.test.js +++ b/scripts/__tests__/validate-npm-meta.test.js @@ -14,380 +14,624 @@ const childProcess = require("child_process"); const { toShellCommand } = require("../lib/shell-command"); const { - getPackageFiles, - parseNpmPackJsonOutput, - parseTarListingOutput, - validateMetaFilesHaveTargets, - validateFilesHaveMetaFiles, + getPackageFiles, + parseNpmPackJsonOutput, + parseTarListingOutput, + validateDevelopmentFilesExcluded, + validateMetaFilesHaveTargets, + validateFilesHaveMetaFiles, + validateNpmMeta } = require("../validate-npm-meta.js"); describe("validate-npm-meta", () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe("parseTarListingOutput", () => { - test("parses package paths with LF line endings", () => { - const tarOutput = [ - "package/Runtime/File.cs", - "package/Runtime/File.cs.meta", - "", - ].join("\n"); - - const files = parseTarListingOutput(tarOutput); - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - }); - - test("parses package paths with lone CR line endings", () => { - const tarOutput = [ - "package/Runtime/File.cs", - "package/Runtime/File.cs.meta", - "", - ].join("\r"); - - const files = parseTarListingOutput(tarOutput); - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - }); - }); - - describe("parseNpmPackJsonOutput", () => { - test("parses npm pack --json files with object entries", () => { - const packOutput = JSON.stringify([ - { - files: [ - { path: "Runtime/File.cs" }, - { path: "Runtime/File.cs.meta" }, - ], - }, - ]); - - const files = parseNpmPackJsonOutput(packOutput); - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - }); - - test("parses npm pack --json files with string entries", () => { - const packOutput = JSON.stringify([ - { - files: ["Runtime/File.cs", "Runtime/File.cs.meta"], - }, - ]); - - const files = parseNpmPackJsonOutput(packOutput); - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - }); - - test("parses npm pack JSON output with CRLF and surrounding whitespace", () => { - const packOutput = - "\r\n" + - JSON.stringify([ - { - files: [ - { path: "Runtime/File.cs" }, - { path: "Runtime/File.cs.meta" }, - ], - }, - ]) + - "\r\n"; - - const files = parseNpmPackJsonOutput(packOutput); - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - }); - - test("throws when npm pack output is not valid JSON", () => { - expect(() => parseNpmPackJsonOutput("not-json")).toThrow( - "Unable to parse npm pack --json output" - ); - }); - - test("throws when npm pack output does not include files", () => { - const packOutput = JSON.stringify([ - { - name: "com.wallstop-studios.dxmessaging", - }, - ]); - - expect(() => parseNpmPackJsonOutput(packOutput)).toThrow( - "did not include a files list" - ); - }); - }); - - describe("getPackageFiles", () => { - test("uses cross-platform npm pack invocation and returns file list", () => { - const spawnSyncSpy = jest.spyOn(childProcess, "spawnSync").mockReturnValue({ - status: 0, - stdout: JSON.stringify([ - { - files: [ - { path: "Runtime/File.cs" }, - { path: "Runtime/File.cs.meta" }, - ], - }, - ]), - stderr: "", - }); - - const files = getPackageFiles(); - - expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); - expect(spawnSyncSpy).toHaveBeenCalledWith( - toShellCommand("npm"), - ["pack", "--json", "--dry-run"], - expect.objectContaining({ - cwd: expect.any(String), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }) - ); - }); - - test("uses npm.cmd command name on win32", () => { - expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); - expect(toShellCommand("npx", "win32")).toBe("npx.cmd"); - }); - - test("throws when npm pack exits with non-zero status", () => { - jest.spyOn(childProcess, "spawnSync").mockReturnValue({ - status: 1, - stdout: "", - stderr: "simulated failure", - }); - - expect(() => getPackageFiles()).toThrow( - "npm pack --json --dry-run failed with exit code 1" - ); - }); - - test("throws when npm process spawn fails", () => { - jest.spyOn(childProcess, "spawnSync").mockReturnValue({ - error: new Error("spawn failed"), - status: null, - stdout: "", - stderr: "", - }); - - expect(() => getPackageFiles()).toThrow("spawn failed"); - }); - }); - - describe("validateMetaFilesHaveTargets", () => { - test("should pass when all .meta files have corresponding files", () => { - const files = [ - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - "Editor/Settings.meta", - "Editor/Settings/DxMessagingSettings.cs", - "Editor/Settings/DxMessagingSettings.cs.meta", - ]; - - const result = validateMetaFilesHaveTargets(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - test("should pass when directory .meta files have files in that directory", () => { - const files = [ - "Runtime.meta", - "Runtime/Core.meta", - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - ]; - - const result = validateMetaFilesHaveTargets(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - test("should fail when .meta file has no corresponding file", () => { - const files = [ - "Runtime/Core/MessageHandler.cs.meta", - "Runtime/Core/OtherFile.cs", - "Runtime/Core/OtherFile.cs.meta", - ]; - - const result = validateMetaFilesHaveTargets(files); - - expect(result.valid).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].type).toBe("orphaned-meta"); - expect(result.errors[0].file).toBe("Runtime/Core/MessageHandler.cs.meta"); - }); - - test("should fail when directory .meta has no files in directory", () => { - const files = [ - "Runtime.meta", - "Runtime/Core.meta", - "Editor/Settings.meta", - "Editor/OtherDir.meta", - "Editor/OtherDir/File.cs", - "Editor/OtherDir/File.cs.meta", - ]; - - const result = validateMetaFilesHaveTargets(files); - - expect(result.valid).toBe(false); - expect(result.errors).toHaveLength(2); - // Runtime.meta and Runtime/Core.meta don't have files, but Editor/Settings.meta and Editor/OtherDir.meta do - expect(result.errors.map(e => e.file)).toContain("Runtime/Core.meta"); - expect(result.errors.map(e => e.file)).toContain("Editor/Settings.meta"); - }); - - test("should handle nested directory structures", () => { - const files = [ - "Runtime.meta", - "Runtime/Core.meta", - "Runtime/Core/Messages.meta", - "Runtime/Core/Messages/StringMessage.cs", - "Runtime/Core/Messages/StringMessage.cs.meta", - ]; - - const result = validateMetaFilesHaveTargets(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - }); - - describe("validateFilesHaveMetaFiles", () => { - test("should pass when all files have .meta files", () => { - const files = [ - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - "Editor/Settings.meta", - "Editor/Settings/DxMessagingSettings.cs", - "Editor/Settings/DxMessagingSettings.cs.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - test("should fail when files are missing .meta files", () => { - const files = [ - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/OtherFile.cs", - "Runtime/Core/OtherFile.cs.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].type).toBe("missing-meta"); - expect(result.errors[0].file).toBe("Runtime/Core/MessageHandler.cs"); - }); - - test("should allow package.json and package-lock.json without .meta", () => { - const files = [ - "package.json", - "package-lock.json", - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - test("should allow .github, .git, and node_modules paths without .meta", () => { - const files = [ - ".github/workflows/build.yml", - ".git/HEAD", - "node_modules/some-package/index.js", - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - test("should detect multiple missing .meta files", () => { - const files = [ - "Runtime/Core/File1.cs", - "Runtime/Core/File2.cs", - "Runtime/Core/File3.cs", - "Runtime/Core/File3.cs.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(false); - expect(result.errors).toHaveLength(2); - expect(result.errors.map(e => e.file)).toContain("Runtime/Core/File1.cs"); - expect(result.errors.map(e => e.file)).toContain("Runtime/Core/File2.cs"); - }); - - test("should handle various file extensions", () => { - const files = [ - "README.md", - "README.md.meta", - "LICENSE.md", - "LICENSE.md.meta", - "Runtime/WallstopStudios.DxMessaging.asmdef", - "Runtime/WallstopStudios.DxMessaging.asmdef.meta", - ]; - - const result = validateFilesHaveMetaFiles(files); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - }); - - describe("integration scenarios", () => { - test("should validate a typical Unity package structure", () => { - const files = [ - "package.json", - "package.json.meta", - "README.md", - "README.md.meta", - "Runtime.meta", - "Runtime/Core.meta", - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MessageHandler.cs.meta", - "Editor.meta", - "Editor/Settings.meta", - "Editor/Settings/DxMessagingSettings.cs", - "Editor/Settings/DxMessagingSettings.cs.meta", - ]; - - const metaResult = validateMetaFilesHaveTargets(files); - const fileResult = validateFilesHaveMetaFiles(files); - - expect(metaResult.valid).toBe(true); - expect(fileResult.valid).toBe(true); - }); - - test("should detect both orphaned and missing meta files", () => { - const files = [ - "Runtime.meta", - "Runtime/Core.meta", - "Runtime/Core/MessageHandler.cs", - "Runtime/Core/MissingFile.cs.meta", - "Editor/Settings.meta", - "Editor/Settings/DxMessagingSettings.cs", - ]; - - const metaResult = validateMetaFilesHaveTargets(files); - const fileResult = validateFilesHaveMetaFiles(files); - - expect(metaResult.valid).toBe(false); - expect(metaResult.errors).toHaveLength(1); - expect(metaResult.errors[0].file).toBe("Runtime/Core/MissingFile.cs.meta"); - - expect(fileResult.valid).toBe(false); - expect(fileResult.errors).toHaveLength(2); - expect(fileResult.errors.map(e => e.file)).toContain("Runtime/Core/MessageHandler.cs"); - expect(fileResult.errors.map(e => e.file)).toContain("Editor/Settings/DxMessagingSettings.cs"); - }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("parseTarListingOutput", () => { + test("parses package paths with LF line endings", () => { + const tarOutput = ["package/Runtime/File.cs", "package/Runtime/File.cs.meta", ""].join("\n"); + + const files = parseTarListingOutput(tarOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("parses package paths with lone CR line endings", () => { + const tarOutput = ["package/Runtime/File.cs", "package/Runtime/File.cs.meta", ""].join("\r"); + + const files = parseTarListingOutput(tarOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + }); + + describe("parseNpmPackJsonOutput", () => { + test("parses npm pack --json files with object entries", () => { + const packOutput = JSON.stringify([ + { + files: [{ path: "Runtime/File.cs" }, { path: "Runtime/File.cs.meta" }] + } + ]); + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("parses npm pack --json files with string entries", () => { + const packOutput = JSON.stringify([ + { + files: ["Runtime/File.cs", "Runtime/File.cs.meta"] + } + ]); + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("parses npm pack JSON output with CRLF and surrounding whitespace", () => { + const packOutput = + "\r\n" + + JSON.stringify([ + { + files: [{ path: "Runtime/File.cs" }, { path: "Runtime/File.cs.meta" }] + } + ]) + + "\r\n"; + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("throws when npm pack output is not valid JSON", () => { + expect(() => parseNpmPackJsonOutput("not-json")).toThrow( + "Unable to parse npm pack --json output" + ); + }); + + test("throws when npm pack output does not include files", () => { + const packOutput = JSON.stringify([ + { + name: "com.wallstop-studios.dxmessaging" + } + ]); + + expect(() => parseNpmPackJsonOutput(packOutput)).toThrow("did not include a files list"); + }); + }); + + describe("getPackageFiles", () => { + test("uses cross-platform npm pack invocation and returns file list", () => { + const spawnSyncSpy = jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: JSON.stringify([ + { + files: [{ path: "Runtime/File.cs" }, { path: "Runtime/File.cs.meta" }] + } + ]), + stderr: "" + }); + + const files = getPackageFiles(); + + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + expect(spawnSyncSpy).toHaveBeenCalledWith( + toShellCommand("npm"), + ["pack", "--json", "--dry-run"], + expect.objectContaining({ + cwd: expect.any(String), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }) + ); + }); + + test("uses npm.cmd command name on win32", () => { + expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); + expect(toShellCommand("npx", "win32")).toBe("npx.cmd"); + }); + + test("throws when npm pack exits with non-zero status", () => { + jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 1, + stdout: "", + stderr: "simulated failure" + }); + + expect(() => getPackageFiles()).toThrow("npm pack --json --dry-run failed with exit code 1"); + }); + + test("throws when npm process spawn fails", () => { + jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + error: new Error("spawn failed"), + status: null, + stdout: "", + stderr: "" + }); + + expect(() => getPackageFiles()).toThrow("spawn failed"); + }); + }); + + describe("validateMetaFilesHaveTargets", () => { + test("should pass when all .meta files have corresponding files", () => { + const files = [ + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta", + "Editor/Settings.meta", + "Editor/Settings/DxMessagingSettings.cs", + "Editor/Settings/DxMessagingSettings.cs.meta" + ]; + + const result = validateMetaFilesHaveTargets(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should pass when directory .meta files have files in that directory", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateMetaFilesHaveTargets(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should fail when .meta file has no corresponding file", () => { + const files = [ + "Runtime/Core/MessageHandler.cs.meta", + "Runtime/Core/OtherFile.cs", + "Runtime/Core/OtherFile.cs.meta" + ]; + + const result = validateMetaFilesHaveTargets(files); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("orphaned-meta"); + expect(result.errors[0].file).toBe("Runtime/Core/MessageHandler.cs.meta"); + }); + + test("should fail when directory .meta has no files in directory", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Editor/Settings.meta", + "Editor/OtherDir.meta", + "Editor/OtherDir/File.cs", + "Editor/OtherDir/File.cs.meta" + ]; + + const result = validateMetaFilesHaveTargets(files); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + // Runtime.meta and Runtime/Core.meta don't have files, but Editor/Settings.meta and Editor/OtherDir.meta do + expect(result.errors.map((e) => e.file)).toContain("Runtime/Core.meta"); + expect(result.errors.map((e) => e.file)).toContain("Editor/Settings.meta"); + }); + + test("should handle nested directory structures", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/Messages.meta", + "Runtime/Core/Messages/StringMessage.cs", + "Runtime/Core/Messages/StringMessage.cs.meta" + ]; + + const result = validateMetaFilesHaveTargets(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe("validateFilesHaveMetaFiles", () => { + test("should pass when all files have .meta files", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta", + "Editor.meta", + "Editor/Settings.meta", + "Editor/Settings/DxMessagingSettings.cs", + "Editor/Settings/DxMessagingSettings.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should fail when files are missing .meta files", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/OtherFile.cs", + "Runtime/Core/OtherFile.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("missing-meta"); + expect(result.errors[0].file).toBe("Runtime/Core/MessageHandler.cs"); + }); + + test("should require directory .meta files for included Unity assets", () => { + const files = [ + "Runtime.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { + type: "missing-meta", + file: "Runtime/Core", + message: "Directory 'Runtime/Core' is missing its .meta file in the package" + } + ]); + }); + + test("should require nested directory .meta files for included Unity assets", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/Messages/StringMessage.cs", + "Runtime/Core/Messages/StringMessage.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { + type: "missing-meta", + file: "Runtime/Core/Messages", + message: "Directory 'Runtime/Core/Messages' is missing its .meta file in the package" + } + ]); + }); + + test("should not require directory .meta files for non-Unity and development-only paths", () => { + const files = [ + "package.json", + "package-lock.json", + ".github/workflows/build.yml", + ".git/HEAD", + "node_modules/some-package/index.js", + ".unity-test-project/Packages/manifest.json", + "scripts/validate-npm-meta.js", + "Samples~/Mini Combat.meta", + "Samples~/Mini Combat/Boot.cs", + "Samples~/Mini Combat/Boot.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should allow package.json and package-lock.json without .meta", () => { + const files = [ + "package.json", + "package-lock.json", + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should allow .github, .git, and node_modules paths without .meta", () => { + const files = [ + ".github/workflows/build.yml", + ".git/HEAD", + "node_modules/some-package/index.js", + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should detect multiple missing .meta files", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/File1.cs", + "Runtime/Core/File2.cs", + "Runtime/Core/File3.cs", + "Runtime/Core/File3.cs.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors.map((e) => e.file)).toContain("Runtime/Core/File1.cs"); + expect(result.errors.map((e) => e.file)).toContain("Runtime/Core/File2.cs"); + }); + + test("should handle various file extensions", () => { + const files = [ + "README.md", + "README.md.meta", + "LICENSE.md", + "LICENSE.md.meta", + "Runtime.meta", + "Runtime/WallstopStudios.DxMessaging.asmdef", + "Runtime/WallstopStudios.DxMessaging.asmdef.meta" + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe("validateDevelopmentFilesExcluded", () => { + test("should pass for package runtime contents", () => { + const files = [ + "package.json", + "package.json.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta", + "Editor/Settings/DxMessagingSettings.cs", + "Editor/Settings/DxMessagingSettings.cs.meta" + ]; + + const result = validateDevelopmentFilesExcluded(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("should reject development-only repository roots", () => { + const files = [ + ".config/tool.json", + ".unity-test-project/Packages/manifest.json", + ".unity-test-project.meta", + ".unity-test-project/ProjectSettings/ProjectVersion.txt", + ".llm/context.md", + ".github/workflows/unity-tests.yml", + ".husky/pre-commit", + ".devcontainer/devcontainer.json", + ".venv/bin/python", + "Tests/Runtime/SomeTest.cs", + "Tests.meta", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTests.cs", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests.meta", + "scripts/validate-npm-meta.js", + "scripts.meta", + "node_modules/some-package/index.js", + "node_modules.meta", + "coverage/lcov.info", + "coverage.meta", + "site/index.html", + "site.meta", + "progress/notes.md", + "progress.meta", + "package-lock.json", + "package-lock.json.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateDevelopmentFilesExcluded(files); + + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.file)).toEqual([ + ".config/tool.json", + ".unity-test-project/Packages/manifest.json", + ".unity-test-project.meta", + ".unity-test-project/ProjectSettings/ProjectVersion.txt", + ".llm/context.md", + ".github/workflows/unity-tests.yml", + ".husky/pre-commit", + ".devcontainer/devcontainer.json", + ".venv/bin/python", + "Tests/Runtime/SomeTest.cs", + "Tests.meta", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTests.cs", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests.meta", + "scripts/validate-npm-meta.js", + "scripts.meta", + "node_modules/some-package/index.js", + "node_modules.meta", + "coverage/lcov.info", + "coverage.meta", + "site/index.html", + "site.meta", + "progress/notes.md", + "progress.meta", + "package-lock.json", + "package-lock.json.meta" + ]); + expect(new Set(result.errors.map((error) => error.type))).toEqual( + new Set(["development-file-in-package"]) + ); + }); + + test("should reject development-only root files and tool configuration", () => { + const files = [ + "AGENTS.md", + "AGENTS.md.meta", + "CLAUDE.md", + "CLAUDE.md.meta", + "CONTRIBUTING.md", + "CONTRIBUTING.md.meta", + "GH-PAGES-PLAN.md", + "GH-PAGES-PLAN.md.meta", + "PLAN.md", + "PLAN.md.meta", + ".gitattributes", + ".editorconfig", + ".prettierrc", + ".prettierrc.json", + ".prettierignore", + ".markdownlint.json", + ".markdownlint.jsonc", + ".markdownlint-cli2.jsonc", + ".markdownlintignore", + ".yamllint.yaml", + ".cspell.json", + ".lychee.toml", + ".csharpierignore", + ".csharpierrc.json", + ".cursorrules", + ".pre-commit-config.yaml", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateDevelopmentFilesExcluded(files); + + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.file)).toEqual(files.slice(0, -2)); + }); + + test("should reject documentation build tooling entries", () => { + const files = [ + "requirements-docs.txt", + "requirements-docs.txt.meta", + "mkdocs.yml", + "mkdocs.yml.meta", + "docs/hooks.py", + "docs/hooks.py.meta", + "docs/__pycache__/hooks.pyc", + "__pycache__.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta" + ]; + + const result = validateDevelopmentFilesExcluded(files); + + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.file)).toEqual(files.slice(0, -2)); + }); + }); + + describe("integration scenarios", () => { + test("should validate a typical Unity package structure", () => { + const files = [ + "package.json", + "package.json.meta", + "README.md", + "README.md.meta", + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta", + "Editor.meta", + "Editor/Settings.meta", + "Editor/Settings/DxMessagingSettings.cs", + "Editor/Settings/DxMessagingSettings.cs.meta" + ]; + + const metaResult = validateMetaFilesHaveTargets(files); + const fileResult = validateFilesHaveMetaFiles(files); + + expect(metaResult.valid).toBe(true); + expect(fileResult.valid).toBe(true); + }); + + test("should detect both orphaned and missing meta files", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MissingFile.cs.meta", + "Editor.meta", + "Editor/Settings.meta", + "Editor/Settings/DxMessagingSettings.cs" + ]; + + const metaResult = validateMetaFilesHaveTargets(files); + const fileResult = validateFilesHaveMetaFiles(files); + + expect(metaResult.valid).toBe(false); + expect(metaResult.errors).toHaveLength(1); + expect(metaResult.errors[0].file).toBe("Runtime/Core/MissingFile.cs.meta"); + + expect(fileResult.valid).toBe(false); + expect(fileResult.errors).toHaveLength(2); + expect(fileResult.errors.map((e) => e.file)).toContain("Runtime/Core/MessageHandler.cs"); + expect(fileResult.errors.map((e) => e.file)).toContain( + "Editor/Settings/DxMessagingSettings.cs" + ); + }); + + test("validateNpmMeta should fail when npm pack includes development-only files", () => { + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: JSON.stringify([ + { + files: [ + { path: "package.json" }, + { path: "package.json.meta" }, + { path: "Runtime.meta" }, + { path: "Runtime/Core.meta" }, + { path: "Runtime/Core/MessageHandler.cs" }, + { path: "Runtime/Core/MessageHandler.cs.meta" }, + { path: ".unity-test-project.meta" }, + { path: ".unity-test-project/Packages/manifest.json" }, + { path: ".unity-test-project/Packages/manifest.json.meta" } + ] + } + ]), + stderr: "" + }); + + const result = validateNpmMeta(); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { + type: "development-file-in-package", + file: ".unity-test-project.meta", + message: + "Development-only file '.unity-test-project.meta' must not be included in the npm package" + }, + { + type: "development-file-in-package", + file: ".unity-test-project/Packages/manifest.json", + message: + "Development-only file '.unity-test-project/Packages/manifest.json' must not be included in the npm package" + }, + { + type: "development-file-in-package", + file: ".unity-test-project/Packages/manifest.json.meta", + message: + "Development-only file '.unity-test-project/Packages/manifest.json.meta' must not be included in the npm package" + } + ]); }); + }); }); diff --git a/scripts/unity.meta b/scripts/unity.meta new file mode 100644 index 00000000..7a4d3c51 --- /dev/null +++ b/scripts/unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6f84e6b22d254f6c8139b503c0e148fd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/activate-license.sh b/scripts/unity/activate-license.sh new file mode 100644 index 00000000..890df9fd --- /dev/null +++ b/scripts/unity/activate-license.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/unity/activate-license.sh +# ============================================================================= +# Unity license diagnostic and ULF encoding helper. +# +# Modes: +# --check (default) Diagnose the current environment. Reports which +# supported license path is active (raw ULF, +# base64 ULF, paid serial, or unconfigured). +# +# --apply Read a .ulf file the operator +# obtained from license.unity3d.com or the Unity +# Hub on a dev machine, validate the contents, +# and print the local `UNITY_LICENSE_B64` export. +# GitHub/GameCI secrets should use the raw .ulf +# contents, not the base64 value. +# +# --help Show this help. +# +# UNITY_EMAIL + UNITY_PASSWORD alone is not a supported headless container +# activation path. Personal/GameCI runs require a .ulf in UNITY_LICENSE; paid +# serial activation requires UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD. +# +# Bind-mount path translation (docker-outside-of-docker): +# The docker daemon runs on the HOST, so the `-v` source must be a HOST +# path, not a path inside this devcontainer. Resolved (in priority order): +# 1) $DXM_HOST_REPO_ROOT (absolute manual override) +# 2) docker inspect of the current devcontainer bind mount +# 3) $LOCAL_WORKSPACE_FOLDER (absolute path from VS Code Dev Containers, +# used only outside a container or after inspect fails) +# 4) $REPO_ROOT (when running outside a container) +# If none resolves and we appear to be inside a container, the script +# fails loud with remediation instructions. +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# --------------------------------------------------------------------------- +# Colors (TTY only) +# --------------------------------------------------------------------------- +if [[ -t 1 ]]; then + C_RED=$'\033[0;31m' + C_GREEN=$'\033[0;32m' + C_YELLOW=$'\033[1;33m' + C_BLUE=$'\033[0;34m' + C_NC=$'\033[0m' +else + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_NC="" +fi + +usage() { + cat <<'EOF' +Usage: scripts/unity/activate-license.sh [mode] + +Modes: + --check (default) Diagnostic mode. Verifies that EITHER + UNITY_LICENSE, UNITY_LICENSE_B64, or + UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD are set. + Exit 0 on success, 2 on configuration failure. + + --apply Validate and base64-encode a .ulf obtained from + license.unity3d.com or the Unity Hub. Prints + `export UNITY_LICENSE_B64=''` for local use. + + --help Show this help. + +UNITY_EMAIL + UNITY_PASSWORD alone is not enough for headless Unity in docker. +Use a raw .ulf in UNITY_LICENSE, local base64 .ulf in UNITY_LICENSE_B64, or +UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD for paid serial activation. See +.llm/skills/unity/unity-license-bootstrap.md for the full flow. + +Environment: + UNITY_LICENSE Raw Unity .ulf contents. Same shape GameCI expects. + UNITY_LICENSE_B64 Base64-encoded Unity .ulf for local shell profiles. + UNITY_SERIAL Paid Unity serial. Requires UNITY_EMAIL and + UNITY_PASSWORD. + UNITY_EMAIL, UNITY_PASSWORD Unity account email and password. Required for + UNITY_SERIAL and commonly required by GameCI when + reactivating a .ulf. + UNITY_VERSION Override the Unity Editor image tag (default: + 2022.3.45f1). Used by --check. + LOCAL_WORKSPACE_FOLDER HOST path to the repo root. Auto-set by VS Code + Dev Containers. Used by --check as the docker + bind-mount source. + DXM_HOST_REPO_ROOT Manual override for the HOST path. + +Examples: + bash scripts/unity/activate-license.sh --check + bash scripts/unity/activate-license.sh --apply ~/Downloads/Unity_v2022.x.ulf +EOF +} + +# --------------------------------------------------------------------------- +# Resolve the host repo path for the docker bind mount (DooD). +# --------------------------------------------------------------------------- +is_absolute_path() { + [[ "$1" == /* ]] || [[ "$1" =~ ^[A-Za-z]:[\\/] ]] +} + +is_container_runtime() { + [[ -f /.dockerenv ]] && return 0 + [[ -f /proc/1/cgroup ]] && grep -qE '(docker|containerd|kubepods)' /proc/1/cgroup 2>/dev/null +} + +detect_host_repo_root_from_container() { + local container_id mount_source + container_id="$(hostname)" + mount_source="$(docker inspect "${container_id}" \ + --format "{{range .Mounts}}{{if eq .Destination \"${REPO_ROOT}\"}}{{.Source}}{{end}}{{end}}" \ + 2>/dev/null || true)" + if [[ -n "${mount_source}" ]]; then + printf '%s' "${mount_source}" + return 0 + fi + return 1 +} + +resolve_host_repo_root() { + if [[ -n "${DXM_HOST_REPO_ROOT:-}" ]]; then + if is_absolute_path "${DXM_HOST_REPO_ROOT}"; then + printf '%s' "${DXM_HOST_REPO_ROOT}" + return 0 + fi + printf '%sIgnoring relative DXM_HOST_REPO_ROOT=%s; docker bind mounts require an absolute host path.%s\n' \ + "${C_YELLOW}" "${DXM_HOST_REPO_ROOT}" "${C_NC}" >&2 + fi + if is_container_runtime; then + if detect_host_repo_root_from_container; then + return 0 + fi + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]] && [[ "${LOCAL_WORKSPACE_FOLDER}" == /* ]]; then + printf '%s' "${LOCAL_WORKSPACE_FOLDER}" + return 0 + fi + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]]; then + printf '%sIgnoring LOCAL_WORKSPACE_FOLDER=%s inside devcontainer; docker inspect did not resolve a POSIX host path.%s\n' \ + "${C_YELLOW}" "${LOCAL_WORKSPACE_FOLDER}" "${C_NC}" >&2 + fi + printf '%sERROR: Cannot determine host path for the workspace.%s\n' \ + "${C_RED}" "${C_NC}" >&2 + printf 'When running inside a devcontainer with docker-outside-of-docker, set:\n' >&2 + printf ' DXM_HOST_REPO_ROOT=/absolute/path/on/host\n' >&2 + return 1 + fi + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]]; then + if is_absolute_path "${LOCAL_WORKSPACE_FOLDER}"; then + printf '%s' "${LOCAL_WORKSPACE_FOLDER}" + return 0 + fi + printf '%sIgnoring relative LOCAL_WORKSPACE_FOLDER=%s; docker bind mounts require an absolute host path.%s\n' \ + "${C_YELLOW}" "${LOCAL_WORKSPACE_FOLDER}" "${C_NC}" >&2 + fi + printf '%s' "${REPO_ROOT}" +} + +# --------------------------------------------------------------------------- +# --check mode: diagnose the current configuration. +# --------------------------------------------------------------------------- +do_check() { + local mode="" + if [[ -n "${UNITY_LICENSE:-}" ]]; then + mode="ulf" + elif [[ -n "${UNITY_LICENSE_B64:-}" ]]; then + mode="ulf-b64" + elif [[ -n "${UNITY_SERIAL:-}" ]] && [[ -n "${UNITY_EMAIL:-}" ]] && [[ -n "${UNITY_PASSWORD:-}" ]]; then + mode="serial" + else + printf '%sNo Unity license configured.%s\n' "${C_RED}" "${C_NC}" >&2 + printf 'Set EITHER:\n' >&2 + printf ' UNITY_LICENSE (raw .ulf contents; GameCI-compatible)\n' >&2 + printf ' UNITY_LICENSE_B64 (base64 .ulf contents; local shell convenience)\n' >&2 + printf ' UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD (paid serial activation)\n' >&2 + printf '\nUNITY_EMAIL + UNITY_PASSWORD alone is not a supported headless container license path.\n' >&2 + return 2 + fi + + printf '%sLicense mode detected: %s%s\n' "${C_BLUE}" "${mode}" "${C_NC}" + + if [[ "${mode}" == "ulf" ]]; then + if ! printf '%s' "${UNITY_LICENSE}" | grep -q -E '|UnityLicense'; then + printf '%sUNITY_LICENSE is set but does not look like raw Unity .ulf contents.%s\n' \ + "${C_YELLOW}" "${C_NC}" >&2 + return 2 + fi + printf '%sUNITY_LICENSE contains plausible raw .ulf contents.%s\n' \ + "${C_GREEN}" "${C_NC}" + return 0 + fi + + if [[ "${mode}" == "ulf-b64" ]]; then + local decoded + if ! decoded="$(printf '%s' "${UNITY_LICENSE_B64}" | base64 -d 2>/dev/null)"; then + printf '%sUNITY_LICENSE_B64 is set but not valid base64.%s\n' \ + "${C_RED}" "${C_NC}" >&2 + return 2 + fi + if ! printf '%s' "${decoded}" | grep -q -E '|UnityLicense'; then + printf '%sUNITY_LICENSE_B64 decodes but does not look like a Unity .ulf.%s\n' \ + "${C_YELLOW}" "${C_NC}" >&2 + return 2 + fi + printf '%sUNITY_LICENSE_B64 present and decodes to a plausible .ulf.%s\n' \ + "${C_GREEN}" "${C_NC}" + return 0 + fi + + printf '%sUNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD are present.%s\n' \ + "${C_GREEN}" "${C_NC}" + printf 'Run scripts/unity/run-tests.sh to perform the live Unity activation.\n' + return 0 +} + +# --------------------------------------------------------------------------- +# --apply mode: encode a .ulf for the UNITY_LICENSE_B64 local env var. +# --------------------------------------------------------------------------- +do_apply() { + local ulf_path="$1" + + if [[ -z "${ulf_path}" ]]; then + printf '%sError: --apply requires a path to the .ulf file.%s\n' "${C_RED}" "${C_NC}" >&2 + usage >&2 + exit 2 + fi + + if [[ ! -f "${ulf_path}" ]]; then + printf '%sError: file not found: %s%s\n' "${C_RED}" "${ulf_path}" "${C_NC}" >&2 + exit 1 + fi + + if ! grep -q -E '|UnityLicense' "${ulf_path}" 2>/dev/null; then + printf '%sWarn: %s does not look like a Unity license file.%s\n' \ + "${C_YELLOW}" "${ulf_path}" "${C_NC}" >&2 + fi + + local encoded + encoded="$(base64 -w 0 "${ulf_path}" 2>/dev/null || base64 "${ulf_path}" | tr -d '\n')" + + if [[ -z "${encoded}" ]]; then + printf '%sError: base64 encoding produced no output.%s\n' "${C_RED}" "${C_NC}" >&2 + exit 1 + fi + + printf '%sUNITY_LICENSE_B64 export line for local shells:%s\n' "${C_GREEN}" "${C_NC}" + printf "export UNITY_LICENSE_B64='%s'\n" "${encoded}" + printf '\n' + printf '%sFor GitHub Actions/GameCI secrets:%s paste the raw .ulf file contents into UNITY_LICENSE.\n' \ + "${C_BLUE}" "${C_NC}" + printf 'Do not paste the base64 UNITY_LICENSE_B64 value into UNITY_LICENSE.\n' +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +if [[ $# -eq 0 ]]; then + do_check + exit $? +fi + +case "$1" in + --check) + do_check + exit $? + ;; + --apply) + do_apply "${2:-}" + ;; + --help|-h) + usage + ;; + *) + printf '%sUnknown mode: %s%s\n' "${C_RED}" "$1" "${C_NC}" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/unity/activate-license.sh.meta b/scripts/unity/activate-license.sh.meta new file mode 100644 index 00000000..2894fad5 --- /dev/null +++ b/scripts/unity/activate-license.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1802e0ec45abf18dcb46a1497d27a295 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib.meta b/scripts/unity/lib.meta new file mode 100644 index 00000000..5b50e28d --- /dev/null +++ b/scripts/unity/lib.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 04266c5a25d03439a34af691d5fc192a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib/asmdef-discovery.js b/scripts/unity/lib/asmdef-discovery.js new file mode 100644 index 00000000..e2b5c694 --- /dev/null +++ b/scripts/unity/lib/asmdef-discovery.js @@ -0,0 +1,303 @@ +"use strict"; + +/** + * @file asmdef-discovery.js + * + * Shared, deterministic discovery + classification of Unity test asmdef files. + * + * Used by: + * - scripts/unity/run-tests.sh (default include / exclude assembly list) + * - scripts/unity/run-tests.ps1 (PowerShell parity) + * - scripts/__tests__/unity-perf-isolation.test.js (Phase 4 contract) + * - .github/workflows/unity-tests.yml (Phase 3 customParameters) + * + * No filesystem mutation. Pure functions only. + * + * Exports: + * - enumerateTestAsmdefs(repoRoot) + * - classifyAsmdef(name) + * - defaultIncludeAssemblies(repoRoot, options?) + * - defaultExcludeAssemblies(repoRoot, options?) + * + * Default include/exclude rules: + * - "core" => INCLUDED by default. + * - "perf" => EXCLUDED by default. Opt in with { includePerf: true }. + * - "comparison" => EXCLUDED by default. Opt in with + * { includeComparisons: true } after installing external + * comparison packages. + * - "integration" => EXCLUDED by default (their packages are not in the test + * project's manifest.json and would fail to compile). + * Opt in with { includeIntegrations: true }. + */ + +const fs = require("fs"); +const path = require("path"); + +/** + * Names matching this pattern are perf/benchmark/allocation assemblies and must + * be excluded from default Unity Test Runner runs. + * + * Source of truth lives in .llm/context.md line 114 (perf isolation rule). + * + * @type {RegExp} + */ +const PERF_NAME_REGEX = /(?:Benchmarks|Allocations)/; +const COMPARISON_NAME_REGEX = /(?:Comparisons)/; + +/** + * Names matching this pattern are DI-container integration suites + * (VContainer / Zenject / Reflex). EXCLUDED from the default suite because + * their backing packages (com.gustavopsantos.reflex, com.svermeulen.extenject, + * jp.hadashikick.vcontainer) are not declared in the test project's + * manifest.json — including them would cause compile errors. Opt in via the + * `includeIntegrations` option on `defaultIncludeAssemblies`. + * + * @type {RegExp} + */ +const INTEGRATION_NAME_REGEX = /(?:VContainer|Zenject|Reflex)/; + +/** + * Recursively enumerate every file path under `dir` whose basename matches + * `predicate`. Sync. Returns POSIX-style relative paths joined to `dir`. + * + * @param {string} dir - Absolute directory to walk + * @param {(basename: string) => boolean} predicate - File-name filter + * @returns {string[]} Absolute file paths + */ +function walkSync(dir, predicate) { + const results = []; + if (!fs.existsSync(dir)) { + return results; + } + + /** @type {fs.Dirent[]} */ + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const childPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkSync(childPath, predicate)); + continue; + } + + if (entry.isFile() && predicate(entry.name)) { + results.push(childPath); + } + } + + return results; +} + +/** + * Strip the `.asmdef` extension and return the asmdef's declared name. The + * file's `name` field is the canonical assembly name and must match the + * filename per Unity convention; we read the JSON to be safe. + * + * @param {string} asmdefPath - Absolute path to an .asmdef file + * @returns {string} Asmdef name (without extension) + */ +function readAsmdefName(asmdefPath) { + const raw = fs.readFileSync(asmdefPath, "utf8"); + const parsed = JSON.parse(raw); + if (typeof parsed.name !== "string" || parsed.name.length === 0) { + // Fall back to the filename to keep this function pure-ish. + return path.basename(asmdefPath, ".asmdef"); + } + return parsed.name; +} + +/** + * Classify an asmdef name into a single category. + * + * Categories: + * - "perf" — Benchmarks/Allocations (excluded from PR + * gates per .llm/context.md line 114). + * - "comparison" — external comparison benchmarks. + * - "integration" — VContainer/Zenject/Reflex DI integration suites. + * - "core" — Everything else (Editor, Runtime, etc.). + * + * Note: comparison suites benchmark DxMessaging against alternative messaging + * libraries, so they require an additional opt-in after the external packages + * are installed in the harness manifest. + * + * @param {string} name - Asmdef assembly name (no extension) + * @returns {"perf" | "comparison" | "integration" | "core"} Classification + */ +function classifyAsmdef(name) { + if (typeof name !== "string" || name.length === 0) { + return "core"; + } + + if (PERF_NAME_REGEX.test(name)) { + return "perf"; + } + + if (COMPARISON_NAME_REGEX.test(name)) { + return "comparison"; + } + + if (INTEGRATION_NAME_REGEX.test(name)) { + return "integration"; + } + + return "core"; +} + +/** + * @typedef {object} AsmdefEntry + * @property {string} name - Asmdef assembly name + * @property {string} path - Absolute path to the asmdef file + * @property {boolean} isPerf - True when classification is "perf" + * @property {boolean} isComparison - True when classification is "comparison" + * @property {boolean} isInteg - True when classification is "integration" + */ + +/** + * Enumerate every asmdef under `/Tests/`. Sorted by `name` for + * stable downstream output (CI summaries, contract tests). + * + * @param {string} repoRoot - Absolute path to the repository root + * @returns {AsmdefEntry[]} Discovered test asmdefs + */ +function enumerateTestAsmdefs(repoRoot) { + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("enumerateTestAsmdefs: repoRoot must be a non-empty string"); + } + + const testsDir = path.join(repoRoot, "Tests"); + const asmdefPaths = walkSync(testsDir, (n) => n.endsWith(".asmdef")); + + /** @type {AsmdefEntry[]} */ + const entries = asmdefPaths.map((asmdefPath) => { + const name = readAsmdefName(asmdefPath); + const classification = classifyAsmdef(name); + return { + name, + path: asmdefPath, + isPerf: classification === "perf", + isComparison: classification === "comparison", + isInteg: classification === "integration" + }; + }); + + entries.sort((a, b) => a.name.localeCompare(b.name)); + return entries; +} + +/** + * @typedef {object} IncludeOptions + * @property {boolean} [includePerf=false] Include "perf" asmdefs. + * @property {boolean} [includeComparisons=false] Include comparison benchmark asmdefs. + * @property {boolean} [includeIntegrations=false] Include "integration" asmdefs. + */ + +/** + * Names of test asmdefs included in the default Unity Test Runner suite. + * + * By default ONLY "core" asmdefs are returned. Perf and integration suites + * are opt-in: + * - includePerf: add Benchmarks/Allocations. + * - includeComparisons: add external comparison benchmarks. + * - includeIntegrations: add VContainer/Zenject/Reflex (caller must ensure + * the corresponding DI packages are in manifest.json). + * + * @param {string} repoRoot - Absolute path to the repository root + * @param {IncludeOptions} [options] - Opt-in flags (default: all false) + * @returns {string[]} Sorted asmdef names (no extension) + */ +function defaultIncludeAssemblies(repoRoot, options) { + const opts = options || {}; + const includePerf = opts.includePerf === true; + const includeComparisons = opts.includeComparisons === true; + const includeIntegrations = opts.includeIntegrations === true; + + return enumerateTestAsmdefs(repoRoot) + .filter((entry) => { + if (entry.isPerf) { + return includePerf; + } + if (entry.isComparison) { + return includeComparisons; + } + if (entry.isInteg) { + return includeIntegrations; + } + return true; + }) + .map((entry) => entry.name); +} + +/** + * Names of test asmdefs excluded from the default Unity Test Runner suite. + * Mirror of `defaultIncludeAssemblies` — anything not selected by the include + * options is returned here. With no options, returns all perf + integration + * asmdefs. + * + * @param {string} repoRoot - Absolute path to the repository root + * @param {IncludeOptions} [options] - Opt-in flags (default: all false) + * @returns {string[]} Sorted asmdef names (no extension) + */ +function defaultExcludeAssemblies(repoRoot, options) { + const opts = options || {}; + const includePerf = opts.includePerf === true; + const includeComparisons = opts.includeComparisons === true; + const includeIntegrations = opts.includeIntegrations === true; + + return enumerateTestAsmdefs(repoRoot) + .filter((entry) => { + if (entry.isPerf) { + return !includePerf; + } + if (entry.isComparison) { + return !includeComparisons; + } + if (entry.isInteg) { + return !includeIntegrations; + } + return false; + }) + .map((entry) => entry.name); +} + +module.exports = { + PERF_NAME_REGEX, + COMPARISON_NAME_REGEX, + INTEGRATION_NAME_REGEX, + classifyAsmdef, + enumerateTestAsmdefs, + defaultIncludeAssemblies, + defaultExcludeAssemblies +}; + +if (require.main === module) { + // Self-test mode: print classified asmdefs for the current repo. + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + const all = enumerateTestAsmdefs(repoRoot); + const include = defaultIncludeAssemblies(repoRoot); + const exclude = defaultExcludeAssemblies(repoRoot); + + process.stdout.write(`repoRoot: ${repoRoot}\n`); + process.stdout.write(`discovered ${all.length} asmdef(s):\n`); + for (const entry of all) { + const cls = entry.isPerf + ? "perf" + : entry.isComparison + ? "comparison" + : entry.isInteg + ? "integration" + : "core"; + process.stdout.write(` [${cls}] ${entry.name}\n`); + } + process.stdout.write( + `\ndefault include (${include.length}, core only — pass ` + + `{ includePerf, includeComparisons, includeIntegrations } to opt in):\n` + ); + for (const name of include) { + process.stdout.write(` + ${name}\n`); + } + process.stdout.write( + `\ndefault exclude (${exclude.length}, perf + comparison + integration suites):\n` + ); + for (const name of exclude) { + process.stdout.write(` - ${name}\n`); + } +} diff --git a/scripts/unity/lib/asmdef-discovery.js.meta b/scripts/unity/lib/asmdef-discovery.js.meta new file mode 100644 index 00000000..3b3e74ba --- /dev/null +++ b/scripts/unity/lib/asmdef-discovery.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a44625cbe1d597fdc8f7760cd4621c94 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib/parse-test-results.py b/scripts/unity/lib/parse-test-results.py new file mode 100644 index 00000000..7ebf8811 --- /dev/null +++ b/scripts/unity/lib/parse-test-results.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# ============================================================================= +# scripts/unity/lib/parse-test-results.py +# ============================================================================= +# Tiny NUnit XML summary extractor. The single source of truth for parsing the +# first element from a Unity Test Framework results.xml. Used by: +# - .github/workflows/unity-il2cpp.yml (parse step) +# - scripts/unity/run-tests.sh (print_results_summary) +# - scripts/unity/run-tests.ps1 (Write-ResultsSummary) +# All three callers consume the same one-line "OK ..." format below, so any +# behavioral change here applies uniformly. +# +# Usage: +# python3 scripts/unity/lib/parse-test-results.py +# +# Output (single line on stdout): +# OK total= passed= failed= skipped= on success (exit 0) +# PARSE_ERROR: on failure (exit 2) +# +# Exit codes: +# 0 success +# 2 could not parse / no element / file missing / wrong argv +# ============================================================================= + +import sys +import xml.etree.ElementTree as ET + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + sys.stdout.write("PARSE_ERROR:usage parse-test-results.py \n") + return 2 + + path = argv[1] + try: + root = ET.parse(path).getroot() + except FileNotFoundError as exc: + sys.stdout.write(f"PARSE_ERROR:file not found: {exc}\n") + return 2 + except ET.ParseError as exc: + sys.stdout.write(f"PARSE_ERROR:malformed XML: {exc}\n") + return 2 + + tr = root if root.tag == "test-run" else root.find(".//test-run") + if tr is None: + sys.stdout.write("PARSE_ERROR:no element\n") + return 2 + + sys.stdout.write( + "OK total={t} passed={p} failed={f} skipped={s}\n".format( + t=tr.get("total", "0"), + p=tr.get("passed", "0"), + f=tr.get("failed", "0"), + s=tr.get("skipped", "0"), + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/unity/lib/parse-test-results.py.meta b/scripts/unity/lib/parse-test-results.py.meta new file mode 100644 index 00000000..fb852b5c --- /dev/null +++ b/scripts/unity/lib/parse-test-results.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 857022b447524b969bf0c944ca4f0867 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/run-tests.ps1 b/scripts/unity/run-tests.ps1 new file mode 100644 index 00000000..049d1a6a --- /dev/null +++ b/scripts/unity/run-tests.ps1 @@ -0,0 +1,639 @@ +<# + .SYNOPSIS + Headless Unity Test Runner driver (PowerShell parity). + + .DESCRIPTION + Mirror of scripts/unity/run-tests.sh — keep behavior in sync. + Spawns an ephemeral unityci/editor container via the host docker + socket and streams Unity's log to stdout in realtime. + + Default behavior excludes Benchmarks/Allocations/Comparisons asmdefs + per .llm/context.md line 114 (perf isolation). Use -IncludePerf to + include them. + + Default behavior also excludes the DI integration suites + (VContainer/Zenject/Reflex) because their backing packages are not + present in the test project's manifest.json. Use + -IncludeIntegrations to opt in. + + Bind-mount path translation (docker-outside-of-docker): the docker + daemon runs on the HOST, so the -v source must be a HOST path, not + a path inside this devcontainer. Resolution priority: + 1) $env:DXM_HOST_REPO_ROOT (absolute manual override) + 2) docker inspect of the current devcontainer bind mount + 3) $env:LOCAL_WORKSPACE_FOLDER (absolute path from VS Code Dev Containers; + used only outside a container or after inspect fails) + 4) the in-script $RepoRoot (only when NOT inside a container) + + .PARAMETER Platform + editmode | playmode | standalone (required for normal runs; omit + when passing -Help). + + .PARAMETER UnityVersion + Unity Editor version tag (default: 2022.3.45f1 or $env:UNITY_VERSION). + + .PARAMETER Filter + Forwarded to Unity's -testFilter. + + .PARAMETER IncludePerf + Include Benchmarks/Allocations asmdefs that do not require external + comparison packages. + + .PARAMETER IncludeComparisons + Include comparison benchmarks against MessagePipe, UniRx, UniTask, and + Zenject. Requires those packages in .unity-test-project/Packages/manifest.json. + + .PARAMETER IncludeIntegrations + Include VContainer/Zenject/Reflex integration asmdefs (default: + excluded). Requires the corresponding DI packages to be present in + .unity-test-project/Packages/manifest.json. + + .PARAMETER Results + Path to write the NUnit XML results + (default: .artifacts/unity/results.xml). Must be within the repo + root (the docker bind-mount only exposes the repo root). + + .EXAMPLE + pwsh -NoProfile -File scripts/unity/run-tests.ps1 -Platform editmode + + .EXAMPLE + pwsh -NoProfile -File scripts/unity/run-tests.ps1 -Platform playmode ` + -Filter 'MessageBus.*' + + .EXAMPLE + pwsh -NoProfile -File scripts/unity/run-tests.ps1 -Platform standalone + + .NOTES + Mirror of scripts/unity/run-tests.sh — when changing CLI surface or + docker invocation, update both files in the same change. The repo's + .llm/context.md mandates synchronized JS+PS dual implementations. +#> + +[CmdletBinding()] +param( + # M1: Platform must NOT be Mandatory — that blocks `-Help` in non- + # interactive shells. We validate manually below. + [ValidateSet('editmode', 'playmode', 'standalone')] + [string]$Platform, + + [string]$UnityVersion, + + [string]$Filter, + + [switch]$IncludePerf, + + [switch]$IncludeIntegrations, + + [switch]$IncludeComparisons, + + [string]$Results, + + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Help short-circuit (must come before -Platform validation so `-Help` works +# even when no platform is provided). +# --------------------------------------------------------------------------- +if ($Help) { + Get-Help $PSCommandPath -Detailed + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($Platform)) { + Write-Host @" +ERROR: -Platform is required (editmode | playmode | standalone). + +Usage: + pwsh -NoProfile -File scripts/unity/run-tests.ps1 -Platform [options] + +For full help, run with -Help. +"@ -ForegroundColor Red + exit 2 +} + +# --------------------------------------------------------------------------- +# Resolve repo root + paths +# --------------------------------------------------------------------------- +$ScriptDir = Split-Path -Parent $PSCommandPath +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path +$ArtifactsDir = Join-Path $RepoRoot '.artifacts/unity' + +if (-not $UnityVersion -or [string]::IsNullOrWhiteSpace($UnityVersion)) { + $envVersion = $env:UNITY_VERSION + if ([string]::IsNullOrWhiteSpace($envVersion)) { + $UnityVersion = '2022.3.45f1' + } else { + $UnityVersion = $envVersion + } +} + +if (-not $Results -or [string]::IsNullOrWhiteSpace($Results)) { + $Results = Join-Path $ArtifactsDir 'results.xml' +} +if (-not [System.IO.Path]::IsPathRooted($Results)) { + $Results = Join-Path $RepoRoot $Results +} +$ResultsDir = Split-Path -Parent $Results +$ResultsLeaf = Split-Path -Leaf $Results +if ([string]::IsNullOrWhiteSpace($ResultsLeaf) -or $ResultsLeaf -eq '.' -or $ResultsLeaf -eq '..') { + Write-Host @" +ERROR: -Results path must be a file within the repo (got '$Results'). +"@ -ForegroundColor Red + exit 2 +} +if (-not (Test-Path $ResultsDir)) { + New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null +} +$RepoRootReal = (Resolve-Path $RepoRoot).Path +$ResultsDirReal = (Resolve-Path $ResultsDir).Path +$Results = Join-Path $ResultsDirReal $ResultsLeaf + +# --------------------------------------------------------------------------- +# Resolve image tag +# --------------------------------------------------------------------------- +if ($Platform -eq 'standalone') { + $ImageTag = "$UnityVersion-linux-il2cpp-3" +} else { + $ImageTag = "$UnityVersion-base-3" +} +$ImageRef = "unityci/editor:$ImageTag" +$UnityLibraryCacheSource = "dxm-unity-library-$ImageTag-$Platform" -replace '[^A-Za-z0-9_.-]', '-' + +# --------------------------------------------------------------------------- +# Build assembly include list via shared discovery library +# --------------------------------------------------------------------------- +function Get-AssemblyList { + param( + [bool]$IncludePerfFlag, + [bool]$IncludeIntegrationsFlag, + [bool]$IncludeComparisonsFlag + ) + + # Single source of truth: defaultIncludeAssemblies in + # scripts/unity/lib/asmdef-discovery.js. Pass both opt-in flags through. + $perfBool = if ($IncludePerfFlag) { 'true' } else { 'false' } + $integBool = if ($IncludeIntegrationsFlag) { 'true' } else { 'false' } + $comparisonsBool = if ($IncludeComparisonsFlag) { 'true' } else { 'false' } + $opts = "{ includePerf: $perfBool, includeIntegrations: $integBool, includeComparisons: $comparisonsBool }" + $nodeScript = "const m=require('./scripts/unity/lib/asmdef-discovery.js');console.log(m.defaultIncludeAssemblies(process.cwd(), $opts).join(';'));" + + Push-Location $RepoRoot + try { + $output = & node -e $nodeScript + if ($LASTEXITCODE -ne 0) { + throw "asmdef discovery failed (exit code $LASTEXITCODE)" + } + return ($output -join "`n").Trim() + } + finally { + Pop-Location + } +} + +$Assemblies = Get-AssemblyList ` + -IncludePerfFlag:$IncludePerf.IsPresent ` + -IncludeIntegrationsFlag:$IncludeIntegrations.IsPresent ` + -IncludeComparisonsFlag:$IncludeComparisons.IsPresent +if ([string]::IsNullOrWhiteSpace($Assemblies)) { + Write-Error 'Assembly include list is empty (asmdef discovery failed).' + exit 1 +} + +# --------------------------------------------------------------------------- +# CI mode short-circuit +# --------------------------------------------------------------------------- +if ($env:CI -eq 'true') { + Write-Host 'CI mode detected -- skipping local docker invocation.' -ForegroundColor Cyan + Write-Host 'game-ci/unity-test-runner@v4 parameters:' + Write-Host " projectPath: .unity-test-project" + Write-Host " unityVersion: $UnityVersion" + Write-Host " testMode: $Platform" + $cp = "-nographics -assemblyNames `"$Assemblies`"" + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $cp = "$cp -testFilter `"$Filter`"" + } + Write-Host " customParameters: $cp" + return +} + +# --------------------------------------------------------------------------- +# Argument-level path validation (m5: before docker/license checks so users +# see the most relevant error first regardless of system state). +# --------------------------------------------------------------------------- +$ResultsRel = $Results +if ($ResultsRel -eq $RepoRootReal) { + Write-Host @" +ERROR: -Results path must be a file within the repo (got '$Results'). +"@ -ForegroundColor Red + exit 2 +} elseif ($ResultsRel.StartsWith("$RepoRootReal/") -or $ResultsRel.StartsWith("$RepoRootReal\")) { + $ResultsRel = $ResultsRel.Substring($RepoRootReal.Length).TrimStart('\','/') +} else { + # Use Write-Host + exit instead of Write-Error so $ErrorActionPreference + # = 'Stop' doesn't preempt our chosen exit code. + Write-Host @" +ERROR: -Results path must be within the repo (got '$Results'). +The bind-mounted /workspace inside the unityci/editor container only exposes +the repo. Either omit -Results or use a path under .artifacts/ or +.unity-test-project/. +"@ -ForegroundColor Red + exit 2 +} +# Normalize to POSIX path inside container +$ResultsContainer = "/workspace/$($ResultsRel -replace '\\','/')" + +# --------------------------------------------------------------------------- +# Resolve docker executable (Windows uses docker.exe; elsewhere docker) +# --------------------------------------------------------------------------- +$DockerCommand = if ($IsWindows) { 'docker.exe' } else { 'docker' } + +# Verify docker socket reachability +& $DockerCommand info *> $null +if ($LASTEXITCODE -ne 0) { + Write-Error @" +docker socket is not reachable. +Remediation: + 1) Verify the docker-outside-of-docker devcontainer feature is enabled + (.devcontainer/devcontainer.json). + 2) Rebuild the devcontainer (Command Palette: 'Dev Containers: Rebuild + Container'). + 3) On the host, confirm the docker daemon is running: + $DockerCommand info +"@ + exit 1 +} + +# License activation: auto-detect ULF vs paid serial vs failure. +# Mirrors the bash script: current Unity/GameCI behavior does not support +# email/password-only Personal headless activation in docker. +$LicenseMode = '' +if (-not [string]::IsNullOrEmpty($env:UNITY_LICENSE)) { + $LicenseMode = 'ulf' +} elseif (-not [string]::IsNullOrEmpty($env:UNITY_LICENSE_B64)) { + $LicenseMode = 'ulf-b64' +} elseif ((-not [string]::IsNullOrEmpty($env:UNITY_SERIAL)) -and ` + (-not [string]::IsNullOrEmpty($env:UNITY_EMAIL)) -and ` + (-not [string]::IsNullOrEmpty($env:UNITY_PASSWORD))) { + $LicenseMode = 'serial' +} else { + Write-Host @" +ERROR: No Unity license configured. +Set EITHER: + UNITY_LICENSE (raw .ulf contents; GameCI-compatible) + UNITY_LICENSE_B64 (base64 .ulf contents; local shell convenience) + UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD (paid serial activation) + +UNITY_EMAIL + UNITY_PASSWORD alone is not a supported headless container license path. +Run 'bash scripts/unity/activate-license.sh --check' for diagnostics. +"@ -ForegroundColor Red + exit 2 +} +Write-Host "[run-tests] license mode: $LicenseMode" + +# Ensure artifacts dir exists +if (-not (Test-Path $ArtifactsDir)) { + New-Item -ItemType Directory -Path $ArtifactsDir -Force | Out-Null +} + +function Test-AbsoluteHostPath { + param([string]$Value) + return (-not [string]::IsNullOrWhiteSpace($Value)) -and ( + $Value.StartsWith('/') -or ($Value -match '^[A-Za-z]:[\\/]') + ) +} + +function Test-ContainerRuntime { + $InContainer = $false + if (Test-Path '/.dockerenv') { + $InContainer = $true + } elseif (Test-Path '/proc/1/cgroup') { + try { + $cgroup = Get-Content '/proc/1/cgroup' -Raw -ErrorAction Stop + if ($cgroup -match '(docker|containerd|kubepods)') { + $InContainer = $true + } + } catch { + # Not all hosts expose /proc/1/cgroup readably; assume not-container. + } + } + return $InContainer +} + +function Get-InspectedHostRepoRoot { + $containerId = (& hostname).Trim() + $format = "{{range .Mounts}}{{if eq .Destination `"$RepoRoot`"}}{{.Source}}{{end}}{{end}}" + $mountSource = & $DockerCommand inspect $containerId --format $format 2>$null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($mountSource)) { + return ($mountSource -join "`n").Trim() + } + return '' +} + +# B2: Resolve the HOST path that the docker daemon will see. Priority: +# 1) absolute DXM_HOST_REPO_ROOT +# 2) docker inspect of the current devcontainer bind mount +# 3) absolute LOCAL_WORKSPACE_FOLDER +# 4) $RepoRoot (only when NOT inside a container) +$HostRepoRoot = '' +if (-not [string]::IsNullOrWhiteSpace($env:DXM_HOST_REPO_ROOT)) { + if (Test-AbsoluteHostPath $env:DXM_HOST_REPO_ROOT) { + $HostRepoRoot = $env:DXM_HOST_REPO_ROOT + } else { + Write-Host "Ignoring relative DXM_HOST_REPO_ROOT=$env:DXM_HOST_REPO_ROOT; docker bind mounts require an absolute host path." -ForegroundColor Yellow + } +} +if ([string]::IsNullOrWhiteSpace($HostRepoRoot)) { + $InContainer = Test-ContainerRuntime + + if ($InContainer) { + $HostRepoRoot = Get-InspectedHostRepoRoot + } + if ($InContainer -and [string]::IsNullOrWhiteSpace($HostRepoRoot) -and + -not [string]::IsNullOrWhiteSpace($env:LOCAL_WORKSPACE_FOLDER) -and + $env:LOCAL_WORKSPACE_FOLDER.StartsWith('/')) { + $HostRepoRoot = $env:LOCAL_WORKSPACE_FOLDER + } elseif ($InContainer -and [string]::IsNullOrWhiteSpace($HostRepoRoot) -and + -not [string]::IsNullOrWhiteSpace($env:LOCAL_WORKSPACE_FOLDER)) { + Write-Host "Ignoring LOCAL_WORKSPACE_FOLDER=$env:LOCAL_WORKSPACE_FOLDER inside devcontainer; docker inspect did not resolve a POSIX host path." -ForegroundColor Yellow + } + if ($InContainer -and [string]::IsNullOrWhiteSpace($HostRepoRoot)) { + Write-Error @" +ERROR: Cannot determine host path for the workspace. +When running inside a devcontainer with docker-outside-of-docker, set: + DXM_HOST_REPO_ROOT=/absolute/path/on/host +"@ + exit 1 + } + + if (-not $InContainer) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCAL_WORKSPACE_FOLDER)) { + if (Test-AbsoluteHostPath $env:LOCAL_WORKSPACE_FOLDER) { + $HostRepoRoot = $env:LOCAL_WORKSPACE_FOLDER + } else { + Write-Host "Ignoring relative LOCAL_WORKSPACE_FOLDER=$env:LOCAL_WORKSPACE_FOLDER; docker bind mounts require an absolute host path." -ForegroundColor Yellow + } + } + if ([string]::IsNullOrWhiteSpace($HostRepoRoot)) { + $HostRepoRoot = $RepoRoot + } + } +} + +# UID/GID resolution (best-effort; non-Linux hosts won't honor chown) +if ($IsWindows) { + $UserUid = '0' + $UserGid = '0' +} else { + $UserUid = (& id -u).Trim() + $UserGid = (& id -g).Trim() +} + +# --------------------------------------------------------------------------- +# Build inner Unity commands (editmode/playmode share one; standalone needs +# two passes — build, then launch). +# --------------------------------------------------------------------------- + +# Container-side path (relative to .unity-test-project/) for the IL2CPP +# binary. Kept in sync with TestRunnerBuilder.cs DefaultBuildPathRelative +# and run-tests.sh. +$StandaloneBuildRel = 'Builds/IL2CPPTests/Tests.x86_64' +$StandaloneBuildHost = Join-Path $RepoRoot ".unity-test-project/$StandaloneBuildRel" +$StandaloneBuildContainer = "/workspace/.unity-test-project/$StandaloneBuildRel" + +function ConvertTo-BashSingleQuotedString { + param([string]$Value) + return "'" + ($Value -replace "'", "'\''") + "'" +} + +function Get-EditorCommandInner { + $sb = [System.Text.StringBuilder]::new() + $projectPathQ = ConvertTo-BashSingleQuotedString '/workspace/.unity-test-project' + $resultsQ = ConvertTo-BashSingleQuotedString $ResultsContainer + $assembliesQ = ConvertTo-BashSingleQuotedString $Assemblies + [void]$sb.AppendLine('set -euo pipefail') + [void]$sb.AppendLine('cleanup_ownership() {') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') + [void]$sb.AppendLine('}') + [void]$sb.AppendLine('trap cleanup_ownership EXIT') + [void]$sb.AppendLine('mkdir -p /root/.cache/unity3d') + if ($LicenseMode -eq 'ulf' -or $LicenseMode -eq 'ulf-b64') { + [void]$sb.AppendLine('mkdir -p /root/.local/share/unity3d/Unity') + if ($LicenseMode -eq 'ulf-b64') { + [void]$sb.AppendLine('printf "%s" "${UNITY_LICENSE_B64}" | base64 -d > /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } else { + [void]$sb.AppendLine('printf "%s" "${UNITY_LICENSE}" > /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } + [void]$sb.AppendLine('chmod 644 /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } + [void]$sb.AppendLine('/opt/unity/Editor/Unity \') + [void]$sb.AppendLine(' -batchmode -nographics \') + [void]$sb.AppendLine(" -projectPath $projectPathQ \") + [void]$sb.AppendLine(" -runTests -testPlatform $Platform \") + [void]$sb.AppendLine(" -testResults $resultsQ \") + [void]$sb.AppendLine(" -assemblyNames $assembliesQ \") + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $filterQ = ConvertTo-BashSingleQuotedString $Filter + [void]$sb.AppendLine(" -testFilter $filterQ \") + } + if ($LicenseMode -eq 'serial') { + [void]$sb.AppendLine(' -username "${UNITY_EMAIL}" -password "${UNITY_PASSWORD}" -serial "${UNITY_SERIAL}" \') + } + [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt') + return $sb.ToString() +} + +function Get-StandaloneBuildCommandInner { + # B1: export DXM_IL2CPP_BUILD_PATH so TestRunnerBuilder.BuildIL2CPPTestPlayer + # writes to the same path we read from below. Local + CI use the same + # env-var contract; the value differs but the mechanism is identical. + $sb = [System.Text.StringBuilder]::new() + $projectPathQ = ConvertTo-BashSingleQuotedString '/workspace/.unity-test-project' + $buildPathQ = ConvertTo-BashSingleQuotedString $StandaloneBuildContainer + [void]$sb.AppendLine('set -euo pipefail') + [void]$sb.AppendLine('cleanup_ownership() {') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Builds || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') + [void]$sb.AppendLine('}') + [void]$sb.AppendLine('trap cleanup_ownership EXIT') + [void]$sb.AppendLine('mkdir -p /root/.cache/unity3d') + if ($LicenseMode -eq 'ulf' -or $LicenseMode -eq 'ulf-b64') { + [void]$sb.AppendLine('mkdir -p /root/.local/share/unity3d/Unity') + if ($LicenseMode -eq 'ulf-b64') { + [void]$sb.AppendLine('printf "%s" "${UNITY_LICENSE_B64}" | base64 -d > /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } else { + [void]$sb.AppendLine('printf "%s" "${UNITY_LICENSE}" > /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } + [void]$sb.AppendLine('chmod 644 /root/.local/share/unity3d/Unity/Unity_lic.ulf') + } + [void]$sb.AppendLine('mkdir -p /workspace/.unity-test-project/Builds/IL2CPPTests') + [void]$sb.AppendLine("export DXM_IL2CPP_BUILD_PATH=$buildPathQ") + [void]$sb.AppendLine('/opt/unity/Editor/Unity \') + [void]$sb.AppendLine(' -batchmode -nographics \') + [void]$sb.AppendLine(" -projectPath $projectPathQ \") + [void]$sb.AppendLine(' -buildTarget StandaloneLinux64 \') + [void]$sb.AppendLine(' -executeMethod WallstopStudios.DxMessaging.TestHarness.Editor.TestRunnerBuilder.BuildIL2CPPTestPlayer \') + if ($LicenseMode -eq 'serial') { + [void]$sb.AppendLine(' -username "${UNITY_EMAIL}" -password "${UNITY_PASSWORD}" -serial "${UNITY_SERIAL}" \') + } + [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/build-log.txt') + return $sb.ToString() +} + +function Get-StandaloneRunCommandInner { + $sb = [System.Text.StringBuilder]::new() + $buildPathQ = ConvertTo-BashSingleQuotedString $StandaloneBuildContainer + $resultsQ = ConvertTo-BashSingleQuotedString $ResultsContainer + $assembliesQ = ConvertTo-BashSingleQuotedString $Assemblies + [void]$sb.AppendLine('set -euo pipefail') + [void]$sb.AppendLine('cleanup_ownership() {') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') + [void]$sb.AppendLine('}') + [void]$sb.AppendLine('trap cleanup_ownership EXIT') + [void]$sb.AppendLine("if [[ ! -x $buildPathQ ]]; then") + [void]$sb.AppendLine(" echo `"[run-tests] ERROR: built test player not found at $StandaloneBuildContainer`" >&2") + [void]$sb.AppendLine(' exit 1') + [void]$sb.AppendLine('fi') + [void]$sb.AppendLine("$buildPathQ \") + [void]$sb.AppendLine(' -batchmode -nographics \') + [void]$sb.AppendLine(' -runTests \') + [void]$sb.AppendLine(" -testResults $resultsQ \") + [void]$sb.AppendLine(" -assemblyNames $assembliesQ \") + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $filterQ = ConvertTo-BashSingleQuotedString $Filter + [void]$sb.AppendLine(" -testFilter $filterQ \") + } + [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt') + return $sb.ToString() +} + +# --------------------------------------------------------------------------- +# Invoke docker +# --------------------------------------------------------------------------- +Write-Host "Launching $ImageRef" -ForegroundColor Cyan +Write-Host " platform=$Platform assemblies=$Assemblies" +Write-Host " results=$Results log=$ArtifactsDir/log.txt" +Write-Host " perf=$($IncludePerf.IsPresent) comparisons=$($IncludeComparisons.IsPresent) integrations=$($IncludeIntegrations.IsPresent) filter=$Filter" +Write-Host " host_repo_root=$HostRepoRoot" +Write-Host " library_cache=$UnityLibraryCacheSource" + +$dockerBaseArgs = @( + 'run', '--rm', + '-v', "$HostRepoRoot`:/workspace:rw", + '-v', "$UnityLibraryCacheSource`:/workspace/.unity-test-project/Library", + '-e', 'UNITY_LICENSE', + '-e', 'UNITY_LICENSE_B64', + '-e', 'UNITY_SERIAL', + '-e', 'UNITY_EMAIL', + '-e', 'UNITY_PASSWORD', + '-e', "USER_UID=$UserUid", + '-e', "USER_GID=$UserGid" +) + +if ($Platform -eq 'standalone') { + Write-Host 'Step 1/2: building IL2CPP test player...' -ForegroundColor Cyan + $buildInner = Get-StandaloneBuildCommandInner + & $DockerCommand @dockerBaseArgs $ImageRef bash -c $buildInner + $BuildExit = $LASTEXITCODE + if ($BuildExit -ne 0) { + Write-Host "IL2CPP build failed (exit $BuildExit)." -ForegroundColor Red + exit $BuildExit + } + if (-not (Test-Path $StandaloneBuildHost)) { + Write-Host "IL2CPP build reported success but binary missing at $StandaloneBuildHost." -ForegroundColor Red + exit 1 + } + + Write-Host 'Step 2/2: running IL2CPP test player...' -ForegroundColor Cyan + $runInner = Get-StandaloneRunCommandInner + & $DockerCommand @dockerBaseArgs $ImageRef bash -c $runInner + $ExitCode = $LASTEXITCODE +} else { + $UnityCmdInner = Get-EditorCommandInner + & $DockerCommand @dockerBaseArgs $ImageRef bash -c $UnityCmdInner + $ExitCode = $LASTEXITCODE +} + +# --------------------------------------------------------------------------- +# Summary tail (B2: delegate parsing to scripts/unity/lib/parse-test-results.py +# so this script, the IL2CPP workflow, and run-tests.sh all share one parser +# implementation. The helper emits "OK total=.. passed=.. failed=.. skipped=.." +# on success and "PARSE_ERROR:" on failure with exit code 2.) +# --------------------------------------------------------------------------- +function Write-ResultsSummary { + param([string]$ResultsXml) + + if (-not (Test-Path $ResultsXml)) { + Write-Host "No results.xml at $ResultsXml" -ForegroundColor Yellow + return 2 + } + + $parser = Join-Path $RepoRoot 'scripts/unity/lib/parse-test-results.py' + $summary = & python3 $parser $ResultsXml + if ($LASTEXITCODE -ne 0 -or -not ($summary -match '^OK ')) { + Write-Host "Could not parse results summary: $summary" -ForegroundColor Yellow + return 2 + } + + $kvLine = $summary -replace '^OK ', '' + $kvs = @{} + foreach ($pair in ($kvLine -split '\s+')) { + if ($pair -match '^(\w+)=(.*)$') { + $kvs[$Matches[1]] = $Matches[2] + } + } + $total = if ($kvs.ContainsKey('total')) { $kvs['total'] } else { '0' } + $passed = if ($kvs.ContainsKey('passed')) { $kvs['passed'] } else { '0' } + $failed = if ($kvs.ContainsKey('failed')) { $kvs['failed'] } else { '0' } + $skipped = if ($kvs.ContainsKey('skipped')) { $kvs['skipped'] } else { '0' } + + if ($total -eq '0') { + Write-Host 'ERROR: 0 tests ran. Check filter / assembly list.' -ForegroundColor Red + Write-Host " failed=$failed passed=$passed skipped=$skipped" -ForegroundColor Red + return 2 + } + + if ($failed -eq '0') { + Write-Host "PASS $passed passed (total=$total skipped=$skipped)" -ForegroundColor Green + return 0 + } + + Write-Host "FAIL $failed failed of $total (passed=$passed skipped=$skipped)" -ForegroundColor Red + return 1 +} + +$SummaryExit = Write-ResultsSummary -ResultsXml $Results + +function Test-ActivationFailureLog { + param([string]$LogPath) + if (-not (Test-Path $LogPath)) { return $false } + $needle = '2FA|two-factor|verification code|License client failed|LICENSE SYSTEM .* (Failed|invalid)|com\.unity\.editor\.headless|No valid Unity Editor license found' + return (Select-String -Path $LogPath -Pattern $needle -Quiet -ErrorAction SilentlyContinue) -eq $true +} + +if ($ExitCode -ne 0) { + Write-Host "Unity exited with code $ExitCode." -ForegroundColor Red + $logFile = Join-Path $ArtifactsDir 'log.txt' + if (Test-ActivationFailureLog -LogPath $logFile) { + Write-Host '' -ForegroundColor Red + Write-Host 'ERROR: Unity license activation failed; common causes:' -ForegroundColor Red + Write-Host ' 1. UNITY_LICENSE is not raw .ulf contents, or UNITY_LICENSE_B64 is not valid base64 .ulf contents.' -ForegroundColor Red + Write-Host ' 2. UNITY_SERIAL is missing, expired, or does not match the Unity account.' -ForegroundColor Red + Write-Host ' 3. Wrong UNITY_EMAIL/UNITY_PASSWORD (check for typos, especially trailing' -ForegroundColor Red + Write-Host ' newlines from `cat`).' -ForegroundColor Red + Write-Host ' 4. Activation rate limit (Unity throttles repeated activations from one IP).' -ForegroundColor Red + Write-Host ' Wait 1 hour and retry.' -ForegroundColor Red + Write-Host 'See .llm/skills/unity/unity-license-bootstrap.md for details.' -ForegroundColor Red + } +} + +# Surface a non-zero exit when either Unity OR the summary check failed. +if ($ExitCode -ne 0) { + exit $ExitCode +} +exit $SummaryExit diff --git a/scripts/unity/run-tests.ps1.meta b/scripts/unity/run-tests.ps1.meta new file mode 100644 index 00000000..a4d131c2 --- /dev/null +++ b/scripts/unity/run-tests.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: faf439d874e65be3e240cd07df34e969 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/run-tests.sh b/scripts/unity/run-tests.sh new file mode 100644 index 00000000..13781e97 --- /dev/null +++ b/scripts/unity/run-tests.sh @@ -0,0 +1,699 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/unity/run-tests.sh +# ============================================================================= +# Headless Unity Test Runner driver. Spawns an ephemeral unityci/editor +# container via the host docker socket (docker-outside-of-docker, configured +# in .devcontainer/devcontainer.json) and streams Unity's log to stdout in +# realtime. +# +# This is the canonical local entry point. CI (Phase 3) invokes +# game-ci/unity-test-runner@v4 directly; in CI mode this script prints the +# equivalent parameters and exits 0 instead of spawning docker locally. +# +# Default behavior excludes Benchmarks/Allocations/Comparisons assemblies per +# .llm/context.md line 114 (perf isolation). Use --include-perf to override. +# +# Default behavior also excludes the DI integration suites (VContainer/Zenject/ +# Reflex) because their backing packages are not in the test project's +# manifest.json. Use --include-integrations to opt in (requires the relevant +# packages to be added first). +# +# Bind-mount path translation (docker-outside-of-docker): +# The docker daemon runs on the HOST, so the `-v` source must be a HOST path, +# not a path inside this devcontainer. We resolve it (in priority order): +# 1) $DXM_HOST_REPO_ROOT (absolute manual override) +# 2) docker inspect of the current devcontainer bind mount +# 3) $LOCAL_WORKSPACE_FOLDER (absolute path from VS Code Dev Containers, +# used only outside a container or after inspect fails) +# 4) $PWD (when running outside a container, e.g., a CI runner directly) +# If none of these resolves and we appear to be inside a container, the +# script fails loud with remediation instructions. +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +PLATFORM="" +UNITY_VERSION_DEFAULT="${UNITY_VERSION:-2022.3.45f1}" +UNITY_VERSION_ARG="" +TEST_FILTER="" +INCLUDE_PERF="false" +INCLUDE_INTEGRATIONS="false" +INCLUDE_COMPARISONS="false" +RESULTS_PATH="" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARTIFACTS_DIR="${REPO_ROOT}/.artifacts/unity" + +# Colors for the summary tail (only printed when stdout is a TTY). +if [[ -t 1 ]]; then + C_RED=$'\033[0;31m' + C_GREEN=$'\033[0;32m' + C_YELLOW=$'\033[1;33m' + C_BLUE=$'\033[0;34m' + C_NC=$'\033[0m' +else + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_NC="" +fi + +# --------------------------------------------------------------------------- +# Help / usage +# --------------------------------------------------------------------------- +usage() { + cat <<'EOF' +Usage: scripts/unity/run-tests.sh --platform [options] + +Required: + --platform editmode | playmode | standalone + +Options: + --unity-version Unity Editor version tag (default: 2022.3.45f1 + or $UNITY_VERSION) + --filter Forwarded to Unity's -testFilter + --include-perf Include Benchmarks/Allocations asmdefs that do + not require external comparison packages + (default: excluded) + --include-integrations Include VContainer/Zenject/Reflex integration + asmdefs (default: excluded). Requires the + corresponding DI packages in + .unity-test-project/Packages/manifest.json. + --include-comparisons Include comparison benchmarks against MessagePipe, + UniRx, UniTask, and Zenject. Requires those + packages in .unity-test-project/Packages/manifest.json. + --results Path to write NUnit XML + (default: .artifacts/unity/results.xml). Must be + within the repo (the docker bind-mount only + exposes the repo root). + --help Show this help and exit 0 + +Environment: + UNITY_LICENSE Raw Unity .ulf contents. This is the same shape + expected by game-ci/unity-test-runner@v4. + UNITY_LICENSE_B64 Base64-encoded Unity .ulf contents for local shell + profiles that cannot hold multiline secrets. + UNITY_SERIAL Paid Unity serial for Professional activation. + Requires UNITY_EMAIL + UNITY_PASSWORD. + UNITY_EMAIL, UNITY_PASSWORD Unity account credentials. Required with + UNITY_SERIAL and commonly required by GameCI when + reactivating a UNITY_LICENSE .ulf. + CI When "true", prints the equivalent + game-ci/unity-test-runner@v4 parameters and exits + without invoking docker locally. + LOCAL_WORKSPACE_FOLDER HOST path to the repo root. Set automatically by + the VS Code Dev Containers extension when + docker-outside-of-docker is configured. + DXM_HOST_REPO_ROOT Absolute override for the HOST path. Use this when + VS Code did not set LOCAL_WORKSPACE_FOLDER (e.g., + attached terminals, plain docker exec sessions). + +Examples: + bash scripts/unity/run-tests.sh --platform editmode + bash scripts/unity/run-tests.sh --platform playmode --filter 'MessageBus.*' + bash scripts/unity/run-tests.sh --platform standalone + bash scripts/unity/run-tests.sh --platform editmode --include-perf + bash scripts/unity/run-tests.sh --platform editmode --include-integrations + +See .llm/skills/unity/headless-test-runner.md for the full skill page (Phase 4). +EOF +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) + PLATFORM="${2:-}" + shift 2 + ;; + --unity-version) + UNITY_VERSION_ARG="${2:-}" + shift 2 + ;; + --filter) + TEST_FILTER="${2:-}" + shift 2 + ;; + --include-perf) + INCLUDE_PERF="true" + shift 1 + ;; + --include-integrations) + INCLUDE_INTEGRATIONS="true" + shift 1 + ;; + --include-comparisons) + INCLUDE_COMPARISONS="true" + shift 1 + ;; + --results) + RESULTS_PATH="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf '%sUnknown argument: %s%s\n' "${C_RED}" "$1" "${C_NC}" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${PLATFORM}" ]]; then + printf '%sError: --platform is required.%s\n' "${C_RED}" "${C_NC}" >&2 + usage >&2 + exit 2 +fi + +case "${PLATFORM}" in + editmode|playmode|standalone) ;; + *) + printf '%sError: --platform must be editmode|playmode|standalone (got %s).%s\n' \ + "${C_RED}" "${PLATFORM}" "${C_NC}" >&2 + exit 2 + ;; +esac + +UNITY_VERSION_RESOLVED="${UNITY_VERSION_ARG:-${UNITY_VERSION_DEFAULT}}" + +if [[ -z "${RESULTS_PATH}" ]]; then + RESULTS_PATH="${ARTIFACTS_DIR}/results.xml" +fi + +if [[ "${RESULTS_PATH}" != /* ]] && [[ ! "${RESULTS_PATH}" =~ ^[A-Za-z]:[\\/] ]]; then + RESULTS_PATH="${REPO_ROOT}/${RESULTS_PATH#./}" +fi + +RESULTS_DIR="$(dirname "${RESULTS_PATH}")" +RESULTS_BASENAME="$(basename "${RESULTS_PATH}")" +if [[ "${RESULTS_BASENAME}" == "." ]] || [[ "${RESULTS_BASENAME}" == ".." ]]; then + printf '%sError: --results path must be a file within the repo (got %s).%s\n' \ + "${C_RED}" "${RESULTS_PATH}" "${C_NC}" >&2 + exit 2 +fi +mkdir -p "${RESULTS_DIR}" +RESULTS_DIR_REAL="$(cd "${RESULTS_DIR}" && pwd -P)" +RESULTS_PATH="${RESULTS_DIR_REAL}/${RESULTS_BASENAME}" + +# --------------------------------------------------------------------------- +# Image tag selection +# --------------------------------------------------------------------------- +if [[ "${PLATFORM}" == "standalone" ]]; then + IMAGE_TAG="${UNITY_VERSION_RESOLVED}-linux-il2cpp-3" +else + IMAGE_TAG="${UNITY_VERSION_RESOLVED}-base-3" +fi +IMAGE_REF="unityci/editor:${IMAGE_TAG}" +UNITY_LIBRARY_CACHE_SOURCE="$( + printf 'dxm-unity-library-%s-%s' "${IMAGE_TAG}" "${PLATFORM}" \ + | tr -c 'A-Za-z0-9_.-' '-' +)" + +# --------------------------------------------------------------------------- +# Assembly include list +# --------------------------------------------------------------------------- +build_assembly_list() { + local include_perf="$1" + local include_integrations="$2" + local include_comparisons="$3" + local node_script + + # Pass the include options through to defaultIncludeAssemblies so the + # opt-in semantics defined in scripts/unity/lib/asmdef-discovery.js are + # the single source of truth. + local opts="{ includePerf: ${include_perf}, includeIntegrations: ${include_integrations}, includeComparisons: ${include_comparisons} }" + node_script="const m=require('./scripts/unity/lib/asmdef-discovery.js');" + node_script+="console.log(m.defaultIncludeAssemblies(process.cwd(), ${opts}).join(';'));" + + (cd "${REPO_ROOT}" && node -e "${node_script}") +} + +ASSEMBLIES="$(build_assembly_list "${INCLUDE_PERF}" "${INCLUDE_INTEGRATIONS}" "${INCLUDE_COMPARISONS}")" +if [[ -z "${ASSEMBLIES}" ]]; then + printf '%sError: assembly include list is empty (asmdef discovery failed).%s\n' \ + "${C_RED}" "${C_NC}" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# CI mode short-circuit +# --------------------------------------------------------------------------- +if [[ "${CI:-false}" == "true" ]]; then + printf '%sCI mode detected — skipping local docker invocation.%s\n' \ + "${C_BLUE}" "${C_NC}" + printf 'game-ci/unity-test-runner@v4 parameters:\n' + printf ' projectPath: .unity-test-project\n' + printf ' unityVersion: %s\n' "${UNITY_VERSION_RESOLVED}" + printf ' testMode: %s\n' "${PLATFORM}" + assemblies_ci_q="$(printf '%q' "${ASSEMBLIES}")" + printf ' customParameters: -nographics -assemblyNames %s' "${assemblies_ci_q}" + if [[ -n "${TEST_FILTER}" ]]; then + filter_ci_q="$(printf '%q' "${TEST_FILTER}")" + printf ' -testFilter %s' "${filter_ci_q}" + fi + printf '\n' + exit 0 +fi + +# --------------------------------------------------------------------------- +# Argument-level path validation (before docker/license checks so users see +# the most relevant error first regardless of system state). +# --------------------------------------------------------------------------- + +# m5: --results paths outside the repo cannot be bind-mounted; reject loudly +# rather than silently rewriting (previous behavior was confusing UX). +REPO_ROOT_REAL="$(cd "${REPO_ROOT}" && pwd -P)" +if [[ "${RESULTS_PATH}" == "${REPO_ROOT_REAL}" ]] || [[ "${RESULTS_PATH}" != "${REPO_ROOT_REAL}/"* ]]; then + printf '%sError: --results path must be within the repo (got %s).%s\n' \ + "${C_RED}" "${RESULTS_PATH}" "${C_NC}" >&2 + printf 'The bind-mounted /workspace inside the unityci/editor container only\n' >&2 + printf 'exposes the repo. Either omit --results or use a path under\n' >&2 + printf '.artifacts/ or .unity-test-project/.\n' >&2 + exit 2 +fi +RESULTS_REL="${RESULTS_PATH#"${REPO_ROOT_REAL}/"}" +RESULTS_CONTAINER="/workspace/${RESULTS_REL}" + +# --------------------------------------------------------------------------- +# Local mode preconditions +# --------------------------------------------------------------------------- +if ! docker info >/dev/null 2>&1; then + printf '%sError: docker socket is not reachable.%s\n' "${C_RED}" "${C_NC}" >&2 + printf 'Remediation:\n' >&2 + printf ' 1) Verify the docker-outside-of-docker devcontainer feature is\n' >&2 + printf ' enabled (.devcontainer/devcontainer.json).\n' >&2 + printf ' 2) Rebuild the devcontainer (Command Palette: "Dev Containers:\n' >&2 + printf ' Rebuild Container").\n' >&2 + printf ' 3) On the host, confirm the docker daemon is running:\n' >&2 + printf ' docker info\n' >&2 + exit 1 +fi + +# License activation: auto-detect ULF vs paid serial vs failure. +# Current Unity/GameCI behavior does not support email/password-only Personal +# headless activation in docker. Personal users need a .ulf in UNITY_LICENSE +# (raw) or UNITY_LICENSE_B64 (local convenience). Paid users may use +# UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD. +LICENSE_MODE="" +if [[ -n "${UNITY_LICENSE:-}" ]]; then + LICENSE_MODE="ulf" +elif [[ -n "${UNITY_LICENSE_B64:-}" ]]; then + LICENSE_MODE="ulf-b64" +elif [[ -n "${UNITY_SERIAL:-}" ]] && [[ -n "${UNITY_EMAIL:-}" ]] && [[ -n "${UNITY_PASSWORD:-}" ]]; then + LICENSE_MODE="serial" +else + printf '%sError: No Unity license configured.%s\n' "${C_RED}" "${C_NC}" >&2 + printf 'Set EITHER:\n' >&2 + printf ' UNITY_LICENSE (raw .ulf contents; GameCI-compatible)\n' >&2 + printf ' UNITY_LICENSE_B64 (base64 .ulf contents; local shell convenience)\n' >&2 + printf ' UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD (paid serial activation)\n' >&2 + printf '\nUNITY_EMAIL + UNITY_PASSWORD alone is not a supported headless container license path.\n' >&2 + printf "Run 'bash scripts/unity/activate-license.sh --check' for diagnostics.\n" >&2 + exit 2 +fi +printf '[run-tests] license mode: %s\n' "${LICENSE_MODE}" + +mkdir -p "${ARTIFACTS_DIR}" + +is_absolute_path() { + [[ "$1" == /* ]] || [[ "$1" =~ ^[A-Za-z]:[\\/] ]] +} + +is_container_runtime() { + [[ -f /.dockerenv ]] && return 0 + [[ -f /proc/1/cgroup ]] && grep -qE '(docker|containerd|kubepods)' /proc/1/cgroup 2>/dev/null +} + +detect_host_repo_root_from_container() { + local container_id mount_source + container_id="$(hostname)" + mount_source="$(docker inspect "${container_id}" \ + --format "{{range .Mounts}}{{if eq .Destination \"${REPO_ROOT}\"}}{{.Source}}{{end}}{{end}}" \ + 2>/dev/null || true)" + if [[ -n "${mount_source}" ]]; then + printf '%s' "${mount_source}" + return 0 + fi + return 1 +} + +resolve_host_repo_root() { + if [[ -n "${DXM_HOST_REPO_ROOT:-}" ]]; then + if is_absolute_path "${DXM_HOST_REPO_ROOT}"; then + printf '%s' "${DXM_HOST_REPO_ROOT}" + return 0 + fi + printf '%sIgnoring relative DXM_HOST_REPO_ROOT=%s; docker bind mounts require an absolute host path.%s\n' \ + "${C_YELLOW}" "${DXM_HOST_REPO_ROOT}" "${C_NC}" >&2 + fi + + if is_container_runtime; then + if detect_host_repo_root_from_container; then + return 0 + fi + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]] && [[ "${LOCAL_WORKSPACE_FOLDER}" == /* ]]; then + printf '%s' "${LOCAL_WORKSPACE_FOLDER}" + return 0 + fi + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]]; then + printf '%sIgnoring LOCAL_WORKSPACE_FOLDER=%s inside devcontainer; docker inspect did not resolve a POSIX host path.%s\n' \ + "${C_YELLOW}" "${LOCAL_WORKSPACE_FOLDER}" "${C_NC}" >&2 + fi + printf '%sERROR: Cannot determine host path for the workspace.%s\n' \ + "${C_RED}" "${C_NC}" >&2 + printf 'When running inside a devcontainer with docker-outside-of-docker, set:\n' >&2 + printf ' DXM_HOST_REPO_ROOT=/absolute/path/on/host\n' >&2 + printf 'The script can also auto-detect the path when the current container is inspectable by docker.\n' >&2 + return 1 + fi + + if [[ -n "${LOCAL_WORKSPACE_FOLDER:-}" ]]; then + if is_absolute_path "${LOCAL_WORKSPACE_FOLDER}"; then + printf '%s' "${LOCAL_WORKSPACE_FOLDER}" + return 0 + fi + printf '%sIgnoring relative LOCAL_WORKSPACE_FOLDER=%s; docker bind mounts require an absolute host path.%s\n' \ + "${C_YELLOW}" "${LOCAL_WORKSPACE_FOLDER}" "${C_NC}" >&2 + fi + + printf '%s' "${REPO_ROOT}" +} + +HOST_REPO_ROOT="$(resolve_host_repo_root)" + +USER_UID_VAL="$(id -u)" +USER_GID_VAL="$(id -g)" + +# --------------------------------------------------------------------------- +# Build inner Unity commands (editmode/playmode share one; standalone needs +# two passes — build, then launch). +# --------------------------------------------------------------------------- + +# Container-side path (relative to .unity-test-project/) for the IL2CPP +# binary. Kept in sync with TestRunnerBuilder.cs DefaultBuildPathRelative. +STANDALONE_BUILD_REL="Builds/IL2CPPTests/Tests.x86_64" +STANDALONE_BUILD_HOST="${REPO_ROOT}/.unity-test-project/${STANDALONE_BUILD_REL}" +STANDALONE_BUILD_CONTAINER="/workspace/.unity-test-project/${STANDALONE_BUILD_REL}" + +build_editor_cmd_inner() { + # Editor-driven editmode/playmode invocation. + local cmd + local project_path_q results_q assemblies_q filter_q + project_path_q="$(printf '%q' "/workspace/.unity-test-project")" + results_q="$(printf '%q' "${RESULTS_CONTAINER}")" + assemblies_q="$(printf '%q' "${ASSEMBLIES}")" + cmd=$'set -euo pipefail\n' + cmd+=$'cleanup_ownership() {\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' + cmd+=$'}\n' + cmd+=$'trap cleanup_ownership EXIT\n' + cmd+=$'mkdir -p /root/.cache/unity3d\n' + if [[ "${LICENSE_MODE}" == "ulf" ]] || [[ "${LICENSE_MODE}" == "ulf-b64" ]]; then + cmd+=$'mkdir -p /root/.local/share/unity3d/Unity\n' + if [[ "${LICENSE_MODE}" == "ulf-b64" ]]; then + cmd+=$'printf "%s" "${UNITY_LICENSE_B64}" | base64 -d > /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + else + cmd+=$'printf "%s" "${UNITY_LICENSE}" > /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + fi + cmd+=$'chmod 644 /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + fi + cmd+="/opt/unity/Editor/Unity \\ +" + cmd+=" -batchmode -nographics \\ +" + cmd+=" -projectPath ${project_path_q} \\ +" + cmd+=" -runTests -testPlatform ${PLATFORM} \\ +" + cmd+=" -testResults ${results_q} \\ +" + cmd+=" -assemblyNames ${assemblies_q} \\ +" + if [[ -n "${TEST_FILTER}" ]]; then + filter_q="$(printf '%q' "${TEST_FILTER}")" + cmd+=" -testFilter ${filter_q} \\ +" + fi + if [[ "${LICENSE_MODE}" == "serial" ]]; then + cmd+=" -username \"\${UNITY_EMAIL}\" -password \"\${UNITY_PASSWORD}\" -serial \"\${UNITY_SERIAL}\" \\ +" + fi + cmd+=" -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt +" + printf '%s' "${cmd}" +} + +build_standalone_build_cmd_inner() { + # Pass 1 of standalone: invoke the editor to build the IL2CPP test player. + # B1: export DXM_IL2CPP_BUILD_PATH so TestRunnerBuilder.BuildIL2CPPTestPlayer + # writes to the same path we read from below. Local + CI use the same + # env-var contract; the value differs but the mechanism is identical. + local cmd + local project_path_q build_path_q + project_path_q="$(printf '%q' "/workspace/.unity-test-project")" + build_path_q="$(printf '%q' "${STANDALONE_BUILD_CONTAINER}")" + cmd=$'set -euo pipefail\n' + cmd+=$'cleanup_ownership() {\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Builds || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' + cmd+=$'}\n' + cmd+=$'trap cleanup_ownership EXIT\n' + cmd+=$'mkdir -p /root/.cache/unity3d\n' + if [[ "${LICENSE_MODE}" == "ulf" ]] || [[ "${LICENSE_MODE}" == "ulf-b64" ]]; then + cmd+=$'mkdir -p /root/.local/share/unity3d/Unity\n' + if [[ "${LICENSE_MODE}" == "ulf-b64" ]]; then + cmd+=$'printf "%s" "${UNITY_LICENSE_B64}" | base64 -d > /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + else + cmd+=$'printf "%s" "${UNITY_LICENSE}" > /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + fi + cmd+=$'chmod 644 /root/.local/share/unity3d/Unity/Unity_lic.ulf\n' + fi + cmd+=$'mkdir -p /workspace/.unity-test-project/Builds/IL2CPPTests\n' + cmd+="export DXM_IL2CPP_BUILD_PATH=${build_path_q} +" + cmd+="/opt/unity/Editor/Unity \\ +" + cmd+=" -batchmode -nographics \\ +" + cmd+=" -projectPath ${project_path_q} \\ +" + cmd+=" -buildTarget StandaloneLinux64 \\ +" + cmd+=" -executeMethod WallstopStudios.DxMessaging.TestHarness.Editor.TestRunnerBuilder.BuildIL2CPPTestPlayer \\ +" + if [[ "${LICENSE_MODE}" == "serial" ]]; then + cmd+=" -username \"\${UNITY_EMAIL}\" -password \"\${UNITY_PASSWORD}\" -serial \"\${UNITY_SERIAL}\" \\ +" + fi + cmd+=" -logFile - 2>&1 | tee /workspace/.artifacts/unity/build-log.txt +" + printf '%s' "${cmd}" +} + +build_standalone_run_cmd_inner() { + # Pass 2 of standalone: launch the IL2CPP binary that was just built. + # The Unity Test Framework embeds a command-line runner in the player + # when built with BuildOptions.Development | IncludeTestAssemblies; the + # binary writes results.xml itself and exits with non-zero on failure. + local cmd + local build_path_q results_q assemblies_q filter_q + build_path_q="$(printf '%q' "${STANDALONE_BUILD_CONTAINER}")" + results_q="$(printf '%q' "${RESULTS_CONTAINER}")" + assemblies_q="$(printf '%q' "${ASSEMBLIES}")" + cmd=$'set -euo pipefail\n' + cmd+=$'cleanup_ownership() {\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' + cmd+=$'}\n' + cmd+=$'trap cleanup_ownership EXIT\n' + cmd+="if [[ ! -x ${build_path_q} ]]; then +" + cmd+=" echo \"[run-tests] ERROR: built test player not found at ${STANDALONE_BUILD_CONTAINER}\" >&2 +" + cmd+=" exit 1 +" + cmd+="fi +" + cmd+="${build_path_q} \\ +" + cmd+=" -batchmode -nographics \\ +" + cmd+=" -runTests \\ +" + cmd+=" -testResults ${results_q} \\ +" + cmd+=" -assemblyNames ${assemblies_q} \\ +" + if [[ -n "${TEST_FILTER}" ]]; then + filter_q="$(printf '%q' "${TEST_FILTER}")" + cmd+=" -testFilter ${filter_q} \\ +" + fi + cmd+=" -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt +" + printf '%s' "${cmd}" +} + +# --------------------------------------------------------------------------- +# Invoke docker +# --------------------------------------------------------------------------- +printf '%sLaunching %s%s\n' "${C_BLUE}" "${IMAGE_REF}" "${C_NC}" +printf ' platform=%s assemblies=%s\n' "${PLATFORM}" "${ASSEMBLIES}" +printf ' results=%s log=%s/log.txt\n' "${RESULTS_PATH}" "${ARTIFACTS_DIR}" +printf ' perf=%s comparisons=%s integrations=%s filter=%s\n' \ + "${INCLUDE_PERF}" "${INCLUDE_COMPARISONS}" "${INCLUDE_INTEGRATIONS}" "${TEST_FILTER:-}" +printf ' host_repo_root=%s\n' "${HOST_REPO_ROOT}" +printf ' library_cache=%s\n' "${UNITY_LIBRARY_CACHE_SOURCE}" + +# Standard docker args reused across passes. +DOCKER_BASE_ARGS=( + run --rm + -v "${HOST_REPO_ROOT}:/workspace:rw" + -v "${UNITY_LIBRARY_CACHE_SOURCE}:/workspace/.unity-test-project/Library" + -e UNITY_LICENSE + -e UNITY_LICENSE_B64 + -e UNITY_SERIAL + -e UNITY_EMAIL + -e UNITY_PASSWORD + -e "USER_UID=${USER_UID_VAL}" + -e "USER_GID=${USER_GID_VAL}" +) + +EXIT_CODE=0 +if [[ "${PLATFORM}" == "standalone" ]]; then + BUILD_CMD_INNER="$(build_standalone_build_cmd_inner)" + printf '%sStep 1/2: building IL2CPP test player...%s\n' "${C_BLUE}" "${C_NC}" + BUILD_EXIT=0 + docker "${DOCKER_BASE_ARGS[@]}" "${IMAGE_REF}" \ + bash -c "${BUILD_CMD_INNER}" \ + || BUILD_EXIT=$? + + if [[ "${BUILD_EXIT}" -ne 0 ]]; then + printf '%sIL2CPP build failed (exit %s).%s\n' \ + "${C_RED}" "${BUILD_EXIT}" "${C_NC}" >&2 + exit "${BUILD_EXIT}" + fi + + if [[ ! -x "${STANDALONE_BUILD_HOST}" ]]; then + printf '%sIL2CPP build reported success but binary missing at %s.%s\n' \ + "${C_RED}" "${STANDALONE_BUILD_HOST}" "${C_NC}" >&2 + exit 1 + fi + + RUN_CMD_INNER="$(build_standalone_run_cmd_inner)" + printf '%sStep 2/2: running IL2CPP test player...%s\n' "${C_BLUE}" "${C_NC}" + docker "${DOCKER_BASE_ARGS[@]}" "${IMAGE_REF}" \ + bash -c "${RUN_CMD_INNER}" \ + || EXIT_CODE=$? +else + UNITY_CMD_INNER="$(build_editor_cmd_inner)" + docker "${DOCKER_BASE_ARGS[@]}" "${IMAGE_REF}" \ + bash -c "${UNITY_CMD_INNER}" \ + || EXIT_CODE=$? +fi + +# --------------------------------------------------------------------------- +# Summary tail (B2: delegate parsing to scripts/unity/lib/parse-test-results.py +# so this script, the IL2CPP workflow, and run-tests.ps1 all share one parser +# implementation. The helper emits "OK total=.. passed=.. failed=.. skipped=.." +# on success and "PARSE_ERROR:" on failure with exit code 2.) +# --------------------------------------------------------------------------- +print_results_summary() { + local results_xml="$1" + if [[ ! -f "${results_xml}" ]]; then + printf '%sNo results.xml at %s%s\n' "${C_YELLOW}" "${results_xml}" "${C_NC}" + return 2 + fi + + local parser="${REPO_ROOT}/scripts/unity/lib/parse-test-results.py" + local summary + if ! summary="$(python3 "${parser}" "${results_xml}")"; then + printf '%sCould not parse results summary: %s%s\n' \ + "${C_YELLOW}" "${summary}" "${C_NC}" + return 2 + fi + + if [[ "${summary}" != OK* ]]; then + printf '%sCould not parse results summary: %s%s\n' \ + "${C_YELLOW}" "${summary}" "${C_NC}" + return 2 + fi + + # Strip the "OK " prefix and tokenize key=value pairs. + summary="${summary#OK }" + local total="" passed="" failed="" skipped="" + for kv in ${summary}; do + case "${kv}" in + total=*) total="${kv#total=}" ;; + passed=*) passed="${kv#passed=}" ;; + failed=*) failed="${kv#failed=}" ;; + skipped=*) skipped="${kv#skipped=}" ;; + esac + done + + if [[ "${total:-0}" == "0" ]]; then + printf '%sERROR: 0 tests ran. Check filter / assembly list.%s\n' \ + "${C_RED}" "${C_NC}" >&2 + printf ' failed=%s passed=%s skipped=%s\n' \ + "${failed:-0}" "${passed:-0}" "${skipped:-0}" >&2 + return 2 + fi + + if [[ "${failed:-0}" == "0" ]]; then + printf '%sPASS%s %s passed (total=%s skipped=%s)\n' \ + "${C_GREEN}" "${C_NC}" "${passed:-0}" "${total}" "${skipped:-0}" + return 0 + fi + + printf '%sFAIL%s %s failed of %s (passed=%s skipped=%s)\n' \ + "${C_RED}" "${C_NC}" "${failed}" "${total}" "${passed:-0}" "${skipped:-0}" + return 1 +} + +SUMMARY_EXIT=0 +print_results_summary "${RESULTS_PATH}" || SUMMARY_EXIT=$? + +# Scan the Unity log for 2FA / activation-failure signatures. We only emit +# the remediation block when Unity actually exited non-zero; a successful +# run can still mention these strings in package metadata or stack traces. +detect_activation_failure() { + local log_file="${ARTIFACTS_DIR}/log.txt" + [[ -f "${log_file}" ]] || return 1 + grep -qE "2FA|two-factor|verification code|License client failed|LICENSE SYSTEM .* (Failed|invalid)|com\\.unity\\.editor\\.headless|No valid Unity Editor license found" \ + "${log_file}" 2>/dev/null +} + +if [[ "${EXIT_CODE}" -ne 0 ]]; then + printf '%sUnity exited with code %s.%s\n' "${C_RED}" "${EXIT_CODE}" "${C_NC}" + if detect_activation_failure; then + printf '\n' >&2 + printf '%sERROR: Unity license activation failed; common causes:%s\n' \ + "${C_RED}" "${C_NC}" >&2 + printf ' 1. UNITY_LICENSE is not raw .ulf contents, or UNITY_LICENSE_B64 is not valid base64 .ulf contents.\n' >&2 + printf ' 2. UNITY_SERIAL is missing, expired, or does not match the Unity account.\n' >&2 + printf ' 3. Wrong UNITY_EMAIL/UNITY_PASSWORD (check for typos, especially trailing\n' >&2 + # shellcheck disable=SC2016 + printf ' newlines from `cat`).\n' >&2 + printf ' 4. Activation rate limit (Unity throttles repeated activations from one IP).\n' >&2 + printf ' Wait 1 hour and retry.\n' >&2 + printf 'See .llm/skills/unity/unity-license-bootstrap.md for details.\n' >&2 + fi +fi + +# Surface a non-zero exit when either Unity OR the summary check failed. +if [[ "${EXIT_CODE}" -ne 0 ]]; then + exit "${EXIT_CODE}" +fi +exit "${SUMMARY_EXIT}" diff --git a/scripts/unity/run-tests.sh.meta b/scripts/unity/run-tests.sh.meta new file mode 100644 index 00000000..58c7fc90 --- /dev/null +++ b/scripts/unity/run-tests.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2cbc00b5e177a7c86d7e204b651324f5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-npm-meta.js b/scripts/validate-npm-meta.js index c1a8cfbf..baa97a44 100644 --- a/scripts/validate-npm-meta.js +++ b/scripts/validate-npm-meta.js @@ -27,11 +27,11 @@ const { spawnPlatformCommandSync } = require("./lib/shell-command"); * @returns {string[]} Package-relative file list */ function parseTarListingOutput(tarOutput) { - return normalizeToLf(tarOutput) - .split("\n") - .filter((line) => line.trim()) - .map((line) => line.replace(/^package\//, "")) - .filter((line) => line); // Remove empty strings + return normalizeToLf(tarOutput) + .split("\n") + .filter((line) => line.trim()) + .map((line) => line.replace(/^package\//, "")) + .filter((line) => line); // Remove empty strings } /** @@ -41,52 +41,52 @@ function parseTarListingOutput(tarOutput) { * @returns {string[]} Package-relative file list */ function parseNpmPackJsonOutput(packOutput) { - const trimmedOutput = normalizeToLf(packOutput || "").trim(); - - if (!trimmedOutput) { - throw new Error("npm pack produced no output"); - } - - let parsedOutput; - try { - parsedOutput = JSON.parse(trimmedOutput); - } catch (error) { - throw new Error(`Unable to parse npm pack --json output: ${error.message}`); - } - - if ( - !Array.isArray(parsedOutput) || - parsedOutput.length === 0 || - parsedOutput[0] === null || - typeof parsedOutput[0] !== "object" - ) { - throw new Error("npm pack --json output did not contain package metadata"); - } - - const packageInfo = parsedOutput[0]; - if (!Array.isArray(packageInfo.files)) { - throw new Error("npm pack --json output did not include a files list"); - } - - const files = packageInfo.files - .map((entry) => { - if (typeof entry === "string") { - return entry; - } - - if (entry && typeof entry.path === "string") { - return entry.path; - } - - return ""; - }) - .filter((entry) => entry.length > 0); - - if (files.length === 0) { - throw new Error("npm pack --json output contained an empty files list"); - } - - return files; + const trimmedOutput = normalizeToLf(packOutput || "").trim(); + + if (!trimmedOutput) { + throw new Error("npm pack produced no output"); + } + + let parsedOutput; + try { + parsedOutput = JSON.parse(trimmedOutput); + } catch (error) { + throw new Error(`Unable to parse npm pack --json output: ${error.message}`); + } + + if ( + !Array.isArray(parsedOutput) || + parsedOutput.length === 0 || + parsedOutput[0] === null || + typeof parsedOutput[0] !== "object" + ) { + throw new Error("npm pack --json output did not contain package metadata"); + } + + const packageInfo = parsedOutput[0]; + if (!Array.isArray(packageInfo.files)) { + throw new Error("npm pack --json output did not include a files list"); + } + + const files = packageInfo.files + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + + if (entry && typeof entry.path === "string") { + return entry.path; + } + + return ""; + }) + .filter((entry) => entry.length > 0); + + if (files.length === 0) { + throw new Error("npm pack --json output contained an empty files list"); + } + + return files; } /** @@ -97,40 +97,103 @@ function parseNpmPackJsonOutput(packOutput) { * @returns {string[]} Array of file paths relative to package root */ function getPackageFiles() { - const repoRoot = path.resolve(__dirname, ".."); - - try { - console.log("Computing package file list via npm pack --json --dry-run..."); - const packResult = spawnPlatformCommandSync("npm", ["pack", "--json", "--dry-run"], { - encoding: "utf8", - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); + const repoRoot = path.resolve(__dirname, ".."); + + try { + console.log("Computing package file list via npm pack --json --dry-run..."); + const packResult = spawnPlatformCommandSync("npm", ["pack", "--json", "--dry-run"], { + encoding: "utf8", + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"] + }); + + if (packResult.error) { + throw packResult.error; + } - if (packResult.error) { - throw packResult.error; - } - - if (packResult.status !== 0) { - const stderr = normalizeToLf(packResult.stderr || "").trim(); - throw new Error( - `npm pack --json --dry-run failed with exit code ${packResult.status}${stderr ? `: ${stderr}` : ""}` - ); - } - - return parseNpmPackJsonOutput(packResult.stdout || ""); - } catch (error) { - if (error && error.code === "ENOENT") { - console.error( - "Error creating or reading npm package:", - `${error.message}\n` + - "npm was not found in this hook shell. Verify npm --version in the same shell used for git commits." - ); - } else { - console.error("Error creating or reading npm package:", error.message); - } - throw error; + if (packResult.status !== 0) { + const stderr = normalizeToLf(packResult.stderr || "").trim(); + throw new Error( + `npm pack --json --dry-run failed with exit code ${packResult.status}${stderr ? `: ${stderr}` : ""}` + ); } + + return parseNpmPackJsonOutput(packResult.stdout || ""); + } catch (error) { + if (error && error.code === "ENOENT") { + console.error( + "Error creating or reading npm package:", + `${error.message}\n` + + "npm was not found in this hook shell. Verify npm --version in the same shell used for git commits." + ); + } else { + console.error("Error creating or reading npm package:", error.message); + } + throw error; + } +} + +// Files that don't need .meta files because they are package metadata or non-Unity assets. +const metaRequirementExcludePatterns = [ + /^package\.json$/, + /^package-lock\.json$/, + /^node_modules\//, + /^\.git\//, + /^\.github\// +]; + +const developmentFileExcludePatterns = [ + /^\.config(?:\/|\.meta$|$)/, + /^\.devcontainer(?:\/|\.meta$|$)/, + /^\.git(?:\/|\.meta$|$)/, + /^\.gitattributes(?:\.meta)?$/, + /^\.github(?:\/|\.meta$|$)/, + /^\.husky(?:\/|\.meta$|$)/, + /^\.llm(?:\/|\.meta$|$)/, + /^\.unity-test-project(?:\/|\.meta$|$)/, + /^\.venv(?:\/|\.meta$|$)/, + /^\.(?:editorconfig|prettierrc(?:\.json)?|prettierignore|markdownlint(?:\.json|\.jsonc)|markdownlint-cli2\.jsonc|markdownlintignore|yamllint\.yaml|cspell\.json|lychee\.toml|csharpierignore|csharpierrc\.json|cursorrules|pre-commit-config\.yaml)(?:\.meta)?$/, + /^AGENTS\.md(?:\.meta)?$/, + /^CLAUDE\.md(?:\.meta)?$/, + /^CONTRIBUTING\.md(?:\.meta)?$/, + /^GH-PAGES-PLAN\.md(?:\.meta)?$/, + /^PLAN\.md(?:\.meta)?$/, + /^Tests(?:\/|\.meta$|$)/, + /^SourceGenerators\/WallstopStudios\.DxMessaging\.SourceGenerators\.Tests(?:\/|\.meta$|$)/, + /^scripts(?:\/|\.meta$|$)/, + /^node_modules(?:\/|\.meta$|$)/, + /^coverage(?:\/|\.meta$|$)/, + /^docs\/(?:__pycache__\/|hooks\.py(?:\.meta)?$)/, + /^jest\.config\.(?:js|mjs)(?:\.meta)?$/, + /^mkdocs\.yml(?:\.meta)?$/, + /^__pycache__(?:\/|\.meta$|$)/, + /^progress(?:\/|\.meta$|$)/, + /^requirements-docs\.txt(?:\.meta)?$/, + /^site(?:\/|\.meta$|$)/, + /^package-lock\.json(?:\.meta)?$/ +]; + +const directoryMetaRequirementExcludePatterns = [ + // UPM hides Samples~ from the package asset tree; sample folders beneath it still need metadata files. + /^Samples~$/ +]; + +function matchesAnyPattern(file, patterns) { + return patterns.some((pattern) => pattern.test(file)); +} + +function shouldSkipMetaRequirement(file) { + return ( + matchesAnyPattern(file, metaRequirementExcludePatterns) || + matchesAnyPattern(file, developmentFileExcludePatterns) + ); +} + +function shouldSkipDirectoryMetaRequirement(directory) { + return ( + shouldSkipMetaRequirement(directory) || + matchesAnyPattern(directory, directoryMetaRequirementExcludePatterns) + ); } /** @@ -139,32 +202,32 @@ function getPackageFiles() { * @returns {Object} Validation result with errors array */ function validateMetaFilesHaveTargets(files) { - const errors = []; - const fileSet = new Set(files); - - for (const file of files) { - if (file.endsWith(".meta")) { - // Remove .meta extension to get the target path - const targetPath = file.substring(0, file.length - 5); - - // Check if the target file exists directly - const hasTargetFile = fileSet.has(targetPath); - - // Check if this is a directory .meta by seeing if any files start with targetPath/ - const targetPathPrefix = targetPath + "/"; - const hasFilesInDirectory = files.some((f) => f.startsWith(targetPathPrefix)); - - if (!hasTargetFile && !hasFilesInDirectory) { - errors.push({ - type: "orphaned-meta", - file: file, - message: `Meta file '${file}' has no corresponding file or directory in the package`, - }); - } - } + const errors = []; + const fileSet = new Set(files); + + for (const file of files) { + if (file.endsWith(".meta")) { + // Remove .meta extension to get the target path + const targetPath = file.substring(0, file.length - 5); + + // Check if the target file exists directly + const hasTargetFile = fileSet.has(targetPath); + + // Check if this is a directory .meta by seeing if any files start with targetPath/ + const targetPathPrefix = targetPath + "/"; + const hasFilesInDirectory = files.some((f) => f.startsWith(targetPathPrefix)); + + if (!hasTargetFile && !hasFilesInDirectory) { + errors.push({ + type: "orphaned-meta", + file: file, + message: `Meta file '${file}' has no corresponding file or directory in the package` + }); + } } + } - return { valid: errors.length === 0, errors }; + return { valid: errors.length === 0, errors }; } /** @@ -173,40 +236,74 @@ function validateMetaFilesHaveTargets(files) { * @returns {Object} Validation result with errors array */ function validateFilesHaveMetaFiles(files) { - const errors = []; - const metaFiles = new Set(files.filter((f) => f.endsWith(".meta"))); - - // Files that don't need .meta files (non-Unity assets) - const excludePatterns = [ - /^package\.json$/, - /^package-lock\.json$/, - /^node_modules\//, - /^\.git\//, - /^\.github\//, - ]; + const errors = []; + const metaFiles = new Set(files.filter((f) => f.endsWith(".meta"))); + const packageDirectories = new Set(); + + for (const file of files) { + // Skip .meta files themselves + if (file.endsWith(".meta")) { + continue; + } + + // Skip files that don't need .meta files + if (shouldSkipMetaRequirement(file)) { + continue; + } + + let directory = path.posix.dirname(file); + while (directory && directory !== ".") { + packageDirectories.add(directory); + directory = path.posix.dirname(directory); + } + + const metaPath = file + ".meta"; + if (!metaFiles.has(metaPath)) { + errors.push({ + type: "missing-meta", + file: file, + message: `File '${file}' is missing its .meta file in the package` + }); + } + } - for (const file of files) { - // Skip .meta files themselves - if (file.endsWith(".meta")) { - continue; - } - - // Skip files that don't need .meta files - if (excludePatterns.some((pattern) => pattern.test(file))) { - continue; - } - - const metaPath = file + ".meta"; - if (!metaFiles.has(metaPath)) { - errors.push({ - type: "missing-meta", - file: file, - message: `File '${file}' is missing its .meta file in the package`, - }); - } + for (const directory of packageDirectories) { + if (shouldSkipDirectoryMetaRequirement(directory)) { + continue; } - return { valid: errors.length === 0, errors }; + const metaPath = directory + ".meta"; + if (!metaFiles.has(metaPath)) { + errors.push({ + type: "missing-meta", + file: directory, + message: `Directory '${directory}' is missing its .meta file in the package` + }); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate that development-only repository paths are not published. + * @param {string[]} files - List of files in the package + * @returns {Object} Validation result with errors array + */ +function validateDevelopmentFilesExcluded(files) { + const errors = []; + + for (const file of files) { + if (matchesAnyPattern(file, developmentFileExcludePatterns)) { + errors.push({ + type: "development-file-in-package", + file: file, + message: `Development-only file '${file}' must not be included in the npm package` + }); + } + } + + return { valid: errors.length === 0, errors }; } /** @@ -216,79 +313,98 @@ function validateFilesHaveMetaFiles(files) { * @returns {Object} Validation results */ function validateNpmMeta(options = {}) { - console.log("Validating NPM package meta files...\n"); - - const files = getPackageFiles(); - console.log(`Found ${files.length} files in package\n`); - - // Count .meta files - const metaFileCount = files.filter((f) => f.endsWith(".meta")).length; - const regularFileCount = files.length - metaFileCount; - console.log(` - Regular files: ${regularFileCount}`); - console.log(` - Meta files: ${metaFileCount}\n`); - - // Validate orphaned .meta files - console.log("Checking for orphaned .meta files..."); - const orphanedResult = validateMetaFilesHaveTargets(files); - if (orphanedResult.valid) { - console.log("✓ All .meta files have corresponding files/directories\n"); - } else { - console.log(`✗ Found ${orphanedResult.errors.length} orphaned .meta file(s):\n`); - for (const error of orphanedResult.errors) { - console.log(` - ${error.message}`); - } - console.log(); + console.log("Validating NPM package meta files...\n"); + + const files = getPackageFiles(); + console.log(`Found ${files.length} files in package\n`); + + // Count .meta files + const metaFileCount = files.filter((f) => f.endsWith(".meta")).length; + const regularFileCount = files.length - metaFileCount; + console.log(` - Regular files: ${regularFileCount}`); + console.log(` - Meta files: ${metaFileCount}\n`); + + // Validate orphaned .meta files + console.log("Checking for orphaned .meta files..."); + const orphanedResult = validateMetaFilesHaveTargets(files); + if (orphanedResult.valid) { + console.log("✓ All .meta files have corresponding files/directories\n"); + } else { + console.log(`✗ Found ${orphanedResult.errors.length} orphaned .meta file(s):\n`); + for (const error of orphanedResult.errors) { + console.log(` - ${error.message}`); } - - // Validate missing .meta files - console.log("Checking for missing .meta files..."); - const missingResult = validateFilesHaveMetaFiles(files); - if (missingResult.valid) { - console.log("✓ All files have corresponding .meta files\n"); - } else { - console.log(`✗ Found ${missingResult.errors.length} file(s) missing .meta:\n`); - for (const error of missingResult.errors) { - console.log(` - ${error.message}`); - } - console.log(); + console.log(); + } + + // Validate missing .meta files + console.log("Checking for missing .meta files..."); + const missingResult = validateFilesHaveMetaFiles(files); + if (missingResult.valid) { + console.log("✓ All files have corresponding .meta files\n"); + } else { + console.log(`✗ Found ${missingResult.errors.length} file(s) missing .meta:\n`); + for (const error of missingResult.errors) { + console.log(` - ${error.message}`); } + console.log(); + } + + console.log("Checking for development-only package contents..."); + const developmentFilesResult = validateDevelopmentFilesExcluded(files); + if (developmentFilesResult.valid) { + console.log("✓ Development-only files are excluded from the package\n"); + } else { + console.log( + `✗ Found ${developmentFilesResult.errors.length} development-only file(s) in package:\n` + ); + for (const error of developmentFilesResult.errors) { + console.log(` - ${error.message}`); + } + console.log(); + } + + // Summary + const allValid = orphanedResult.valid && missingResult.valid && developmentFilesResult.valid; + if (allValid) { + console.log("✓ NPM package meta file validation passed!"); + return { valid: true, errors: [] }; + } else { + console.log("✗ NPM package meta file validation failed!"); + const allErrors = [ + ...orphanedResult.errors, + ...missingResult.errors, + ...developmentFilesResult.errors + ]; - // Summary - const allValid = orphanedResult.valid && missingResult.valid; - if (allValid) { - console.log("✓ NPM package meta file validation passed!"); - return { valid: true, errors: [] }; - } else { - console.log("✗ NPM package meta file validation failed!"); - const allErrors = [...orphanedResult.errors, ...missingResult.errors]; - - if (options.check) { - process.exit(1); - } - - return { valid: false, errors: allErrors }; + if (options.check) { + process.exit(1); } + + return { valid: false, errors: allErrors }; + } } // Run validation if called directly if (require.main === module) { - const args = process.argv.slice(2); - const check = args.includes("--check"); - - try { - validateNpmMeta({ check }); - } catch (error) { - console.error("Validation failed with error:", error.message); - process.exit(1); - } + const args = process.argv.slice(2); + const check = args.includes("--check"); + + try { + validateNpmMeta({ check }); + } catch (error) { + console.error("Validation failed with error:", error.message); + process.exit(1); + } } // Export for testing module.exports = { - getPackageFiles, - parseNpmPackJsonOutput, - parseTarListingOutput, - validateMetaFilesHaveTargets, - validateFilesHaveMetaFiles, - validateNpmMeta, + getPackageFiles, + parseNpmPackJsonOutput, + parseTarListingOutput, + validateDevelopmentFilesExcluded, + validateMetaFilesHaveTargets, + validateFilesHaveMetaFiles, + validateNpmMeta }; From 98b876393da44ce54c0240a3efd8d6990c1e3e21 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 09:06:04 -0700 Subject: [PATCH 06/16] Fix mosts tests, minor correctness improvements --- .llm/skills/index.md | 4 +- .llm/skills/performance/memory-reclamation.md | 27 +- CHANGELOG.md | 1 + .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 26112 -> 25088 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 33280 bytes Runtime/Core/DxMessagingStaticState.cs | 1 + Runtime/Core/MessageBus/IMessageBus.cs | 41 +- Runtime/Core/MessageBus/MessageBus.cs | 249 +++++++- Runtime/Core/Pooling/DxPools.cs | 22 + .../Core/Pooling/PoolDiagnosticsSnapshot.cs | 10 + .../Allocations/AllocationMatrixTests.cs | 393 ++++++++++++ .../Core/MessageHandlerGlobalBusTests.cs | 59 +- .../MemoryReclaim/MemoryReclamationTests.cs | 603 ++++++++++++++++++ docs/architecture/comparisons.md | 8 +- docs/architecture/performance.md | 49 +- .../__tests__/extract-perf-baseline.test.js | 73 +++ .../extract-perf-baseline.test.js.meta | 7 + scripts/unity/extract-perf-baseline.js | 346 ++++++++++ scripts/unity/extract-perf-baseline.js.meta | 7 + 19 files changed, 1863 insertions(+), 37 deletions(-) create mode 100644 scripts/__tests__/extract-perf-baseline.test.js create mode 100644 scripts/__tests__/extract-perf-baseline.test.js.meta create mode 100644 scripts/unity/extract-perf-baseline.js create mode 100644 scripts/unity/extract-perf-baseline.js.meta diff --git a/.llm/skills/index.md b/.llm/skills/index.md index e441b642..46a40fb8 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-05-05. Do not edit manually. +> **Auto-generated** on 2026-05-06. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -98,7 +98,7 @@ | [Collection Pooling with RAII Pattern Part 1](./performance/collection-pooling-part-1.md) | [ok] 206 | [intermediate] | [stable] | [risk: low] | migration, split | | [Collection Pooling with RAII Pattern Part 2](./performance/collection-pooling-part-2.md) | [draft] 57 | [intermediate] | [stable] | [risk: low] | migration, split | | [DxMessaging Dispatch Hot Path](./performance/dispatch-hot-path.md) | [ok] 213 | [advanced] | [stable] | [risk: critical] | dispatch, hot-path | -| [DxMessaging Memory Reclamation](./performance/memory-reclamation.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | memory, reclamation | +| [DxMessaging Memory Reclamation](./performance/memory-reclamation.md) | [ok] 198 | [advanced] | [stable] | [risk: critical] | memory, reclamation | | [DxMessaging Sweep Gate Must Be Cheap](./performance/sweep-gate-must-be-cheap.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | sweep, eviction | | [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 299 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [Git Hook Performance: Stages and Tooling](./performance/git-hook-performance-tooling.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | diff --git a/.llm/skills/performance/memory-reclamation.md b/.llm/skills/performance/memory-reclamation.md index 51b59e9d..7f6e530a 100644 --- a/.llm/skills/performance/memory-reclamation.md +++ b/.llm/skills/performance/memory-reclamation.md @@ -2,9 +2,9 @@ title: "DxMessaging Memory Reclamation" id: "memory-reclamation" category: "performance" -version: "1.0.0" +version: "1.1.0" created: "2026-05-04" -updated: "2026-05-04" +updated: "2026-05-06" source: repository: "wallstop/DxMessaging" @@ -115,6 +115,7 @@ pool trim path. | Typed handler slots | message type, handler, priority, optional context | `MessageHandler.ResetEmptyTypedSlotsForSweep` | | Global accept-all slot | global handler delegates | `MessageBus.SweepGlobalSlot` | | Shared collection pools | pooled dictionaries, lists, stacks, sets | `DxPools.TrimAll` | +| Bus context map pool | targeted/broadcast context dictionaries | `MessageBus.Trim` and settings hot reload | Any new holder keyed by message type or `InstanceId` must have an explicit row in tests and, if it is a `MessageCache<>` field, an entry in the sweepable @@ -139,14 +140,22 @@ handlers touched since the previous sweep. ## Pool Layer `CollectionPool` backs the internal reusable collections. `DxPools` -centralizes the pools for `InstanceId` dictionaries, typed-handler context +centralizes the pools for `InstanceId` dictionaries, dirty-target +`List` and `HashSet` holders, typed-handler context dictionaries, typed-handler priority dictionaries, object lists, object stacks, and integer sets. +`MessageBus` also owns the private static `ContextHandlerByTargetDicts` pool for +bus-side targeted and sourced-broadcast context dictionaries. This pool stays +inside `MessageBus` because its value type references private handler-cache +types. It must be configured with the same runtime settings as `DxPools`, +trimmed from `MessageBus.Trim`, and covered by memory-reclamation tests. + `DxMessagingRuntimeSettings.BufferMaxDistinctEntries` controls the retained entry cap for each pool. `BufferUseLruEviction` chooses between LRU retention and bounded LIFO behavior. `DxPools.Configure(settings)` hot-reloads both the -cap and retention mode without recreating buses. +cap and retention mode without recreating buses; bus-owned pools must mirror +the same settings in `MessageBus.ApplyRuntimeSettings`. ## Adding a MessageCache @@ -159,6 +168,9 @@ When adding a new `MessageCache<>` storage field to `MessageBus`: 1. Update `LeakWatcher` if the cache introduces a new public leak counter. 1. Keep stale deregistration closures safe after sweep; a stale closure must not remove a later registration that reused the same slot. +1. If the cache introduces a dirty-tracking collection or bus-owned pool, add a + sweep-time compaction or return-to-pool test that proves the object is both + returned and reused. ## Performance Notes @@ -179,6 +191,7 @@ When adding a new `MessageCache<>` storage field to `MessageBus`: ## Changelog -| Version | Date | Changes | -| ------- | ---------- | --------------- | -| 1.0.0 | 2026-05-04 | Initial version | +| Version | Date | Changes | +| ------- | ---------- | ------------------------------------------------------------------------ | +| 1.1.0 | 2026-05-06 | Documented bus-side context dictionary and dirty-target collection pools | +| 1.0.0 | 2026-05-04 | Initial version | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df771b3..ae04193f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Emit-time idle eviction now samples the wall clock only at the sweep-gate cadence instead of on every message emit, with sealed inline clock getters on the default clocks, reducing dispatch hot-path clock overhead while preserving the extra timestamp read only when a sweep actually runs. - Forced and idle trims now release empty typed-handler outer wrappers and compact dirty-handler tracking, preventing long-running buses from retaining every message type a handler once registered after those registrations are removed. +- Empty targeted and sourced-broadcast registrations now recycle their dirty-target tracking collections and bus context dictionaries during trim/reset, reducing retained memory and repeated allocation churn on long-running buses that register many distinct targets or sources. - Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. - Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. - This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 35356752f4b1352f7042bbc2f2247d3041ae040d..16ad759220406c272e68d1d40f22dbaa1a321266 100644 GIT binary patch delta 7427 zcmb7J4RlmRmcI3V`@Qado%g!a9Y}x#2)rcSkRKBhgpq%66eWZRk)Q+;e&pvt5*QUt zcaQ%Fqp{Tyl{uie=dc4F5#xyC%n}tHU2qR))Ri4YanxOOT!(ezS#*W{s=E16=bSy$ zp})HK*1fmxy;ZNOUXwb+Qm?T4?i&8k!Us=L?(-oHFZQ!aqWKW`vcfeRdjIiit7rq! zRa}rFx=ZOT2?w?l1;D@6O*A)CBE>T;Y_!>ZHAKbu&d+RO73`YKb8IwQlsU$R51I`R z{4*0@iBuY&AbNNP5zCacUR*L%azjLB`M7K=$+s{frTkMVq4nhPj?5;hFlmD-O$|Yh z=YaIWya)aYD`grD1N^hy2!t-E3RCi;paYKN#?bof2cYUOw*X8q#N0t>jfM{)G`zu} zU6n}dgyq^ow-9{8LEa^MMWDlqtu!S2Ag~n=0hr!UE>-LAfXGqZVVu))P95!Z6y3}# z=2G3(-4YOqYi->d&LuwE=ZydnzryxKy^)}Xji|}5_=)Z7ycDCmyw5AmX$sL`Zxq;M zJe;#UuUGlc8f2rB*LpBZX1P?b1=GforO{zyx0S|*wQlpqKyGT$YE#l; z^F^@bQu&y!u@I*eK6OK-jVPj0{_0R!HXHBc`!z#Ly_fHij3fcIn#G@pm{V8$2vyoqQN z$!4E72}C&G$#*C7o=H7;1ab+-tA5ZMs~L7)5_PA*z?;fp8eqt2R-s2Llvo9eVu>mV zzpW;8TZ=-@qPBh=R;iLOAXIH$GFu57nGduH$@p~Jw2ei!Z;>5X6t53a51uE~;@m_` zhxdXlcvLZ!M#RG-Za!Wb>ukize%!?}GTqAnd~z_|=GnPHNj1a09FnMa1yI<9gLY6% z=J`RBf8VI=2cz1)cr8N4qyPVhtX~+etq8+d^6$4a6PMY6_*4vwh4jN=2mPp0`s1Yq zwqLB!T=g!fnvu-P!qyKe9uD>7!@KU--Yk?ap?BVn)GH+04(GX7LSpCHmhNJCn_)-q ze2ihAnGGgB#c=WTYw=4u4?)&>3}8DhX3LBf#A2Oab4AWYI5w}Ql!v1Yn?StS_aonJ-*XN97N{m(i#MNx{dzp1d)IMx0oZu4?k(a9Z2*2A z@y$xkrJ=-v$D*3I7^1{)j+t6q)3SSZwA?^a*SpvhZ zb^y-14mAGy9lTd8pwfggDpH|}T4Luz#eST&{k^nxZz+5P9ob!mme)8MR=_O>7gj4O zlQ?BofOhoL`8Zt}&a~^niY+;;*;+hgYqA^QD@u)pl?ns;E1{5h4cm#T=#9~GJsYMT z4`{l#3Vq{d91(U z+sFhq)B6_JUol!{@Fl1)m>C8dd^_?1NCB|x{x;oWF?5?K0~%l%WMqIVAC=$hb~RrhVGp>ISO$!6e`$w z6phw)otGALCb+ar=hDpBy!;C+28->hGiH9zqOh}>TYT&n7VGlz{pY;7kxXyiSaxTo zKd&747lAQ*4Cv|hnB<*^CB^q|l#%D3Q3hsyx2l3Lf@ z=qTvg#JkU`O_w%u1@|5Z`QD(x8IBn(ReYhdYPtAv2;9xw6wDJ~BImeA&vj8I=9EsD z>n9q4c{zYKf~(DlrtZ!aYnEXd;o^XTYQ*!R62-wk~N{^e=Qs#sJ1q zeIEIdphaIovV{6ToAgF#LC~bVp$9`I%{3nkb>-y$r92qAoEBn_Fe#!hhtt*mjnYt&C$nM1#T zu|vCn8odjNNlRsJ)hP6z0PRqL&2<`t<~IVHfhK)K3xc1EpdA`7bS4Yz<2p&&fJUR8 z#7k7eI1cx(LsdSucvbdbFpXRvmp^QBJRo!Y0JKRHQAUS8mHJGRwj*eVb_DxOhh7!K zeh79-~*N}=y#v(TCbS|$cA%|D|Gko-Jj=Nra z!7!+DbhhsUqaUm9Mb0HLU`^sHq8}W zHbaNDXZu8;4m}eWnmO+Gf;+4Es^D(L1OI2kp-%*t%yx$9i(KbzbkYdZ8Np?<50f7+ zuV+gdrm)~{r8+tV&Eg!l7+i!BInHLM3|ym8^5a4NBAmf#{(X8fb{tXQj`2wsNP_3gBiX7;ECWV;8@>j;2fXK#?x}iW+!NKD8?G3PgD(qae6$R zkPBIvbkgP?5H9Ga?Fv>T5-4Gv!75h5l0De6riwgDSeZGAm9aA*vi_Z4}8C3D_DuTo2`>h2cKbA2oGuLxAse{f$fqI3Ogw`4K$U6 z)JiYuKauuIH>fkDGU**_7O*!sPwJNjtcAk1UFwyB2xt;pX6{CC)xoEwc2WFQqWJ6B zB5OBWC2F&aoeaDurKQ1Y@}$%aB}=|pD5cqcqeQ+thtrhN@^Oh*;)KLYdQxJcIr44D zb&=e_RC%Smg1r)0CvT=Rc8qNngFPVpY-QENhX zOR}9pX)oJqtIB>cFq1ci{E8vZv20~G3`&(}g@G-9Ak~BBq4W#=es-TdS$Un^X3bPy zq_)riy(o%&T=*Fzf2A%~3@lr$f9WfAy~6S5$}33VA*D?IC;L}QvP!m`gUTQ%9a8Q_ zhen6QT#f|(yRw(QRR$D3@HdKu7m8u3h4s)1jL>Re0rdcj=w4tkZ3m8|y+D^<0>5{RuYRZj~uHU4fI$>y<#q{pN;q=cN2-Myfex)9GOk04tkDWB|ocY z&$CZio%DkAlBCK7@;rH?e5-s=J}eh0W0fZ57s`8zlHwcIg`b&8T+*ZqB#Jr%J0Jg< z4jWzW6U3p;No;!OK2i11-_Ywbxq^mY0CaqPurTngpk1^KH;G&59rhbKBQfSFD%(Xm z|N7YhCCGN|J2~V#HKUGf*>~f}m;7u(hFuxmH@DIqn)mW`UC+4-CrtnCSKD9y?AHaw znax)Y&%Ac!oa!OEk?$;IR=(ZAoE73_6la#GRHLt$LCsD#>-kRVZlvQYmZUJ{g_-DYF-U=*g;r6KN)ozp zxpC5MRuvAS)Gnl*ZnM)J_$T>5@*WH%LO!>fTtu3Q;UAX4LWM6iOJ|}e~rGbwA%NK+FNj=oIq{89D2Sc&{`MT3ZU zWSc%+#J|<@lMLxLD-V$EdOO>k%6vJ$i0#P)T52WS52v(jm=j>6&*@mz(bm<0{}TgK zHKvmP5nk+Qj9@D2YicX2me$oRy|}HmvbwEyadmY=P2JMkj`rI2rAylzI@&H;+)-0k zPjoS(>dd5g!kF99)r~iOg=J=5Go`x!(S>`6&6>?O-NX30__>Jxpn3Mg_vbLa{Bk~k zzJYm}-(NF)%#`PUe)V5|dDl;OHh&k`zN!KRC7L>}b&A*C(K@?r$;!569bM;%`xg{D ztxewg&Ltf)I@WY_wsm`*U9E_wDD&4*zM@ud@%62_Ok3APt!Z!TY_A?uc~;42 zSlhmsnr2R_uC1fo;G5cWgCmE;p-Zkf_Q}<6o_sXczJ4lx={ozlc0=aP1%sv88}@y+ z;16t{b?s;JRrOSZ|B$N0#dj&y;cqEjOl?9^E!xFYjlTwH@xEG|Y3;yv&`W_0;M;J1 ZFGkBX>i5l9+|Q)7b^Gl0FIjJ;{(k@^Fm3<< delta 8269 zcmb7I3w%`7ng7mx-#asR=H5G*ykU|M7%~YYB!L7Fi4PtsBJvD^5<=7{aA5*!uw`Zx zD_HZhbX;3l{aHo2wOi{_)UI7=iLLfU-P*ctSuIs7Wo=8VXltz+_J7VD5NLn<+a1Vv zzW4dQ?|kPmllvaa9b%u`UiV%0D=|r%ce}Nl~OSxiyCGW5lQ|uBT^cm?KLaH3baE~e1wJcMw7NW&=05E3LB-$ ztP8w8R}BN-18Ql0Tu8}isBD($W|&IcI#mL83RK!6$uH$Pu9Pan80t6=Y?r0VK}0CZ zQWZeR#B`qKXpI_mR6)V?E5S~wM4G2KN(QVAm8onMm^Lqd+~kAlb_&E;mtKY`_%sU)YY(=)r)NCH~Sui^ZV6H3)`B;rIGWlG=Vg#0>A zhn=uLCJU_5cESn29@yx*5!AlavmKkOUY0H)C!9GSk*3B#?2i?8r+5|qIBs3S{Q&ty zZ$wJ7gco(9O$RjyQxJ~jagGNvb>7L7Cyzv3b0mQzS=ProS@bHeUz&@*Idbx(<~IWM zVrg<>4aBg#65UJRx|~IM4ZLQRW@41ZqNbXl8Z7GsVBvIwR5o-eJl7{iPkf-hbo7b` z{D}})T4GX2(n^=8719{B~lu^W{tY|O#CA!{1#}C>`xNI zR+L_;Z}P^*M>G>_InWAreU5WWhvtcSlmCo*Kj5Qn(iC)|P5bx^Jm4s$+F`L7MPZf} z&yL7^^-G)*zQ*@JMe`>^?j_trY6_6hR&=PM`yIf!W~o!kHT$3mSDoN$7^h-PO#=uU z@xWu5Q^xhqgP!3}ha@5zvfL@>?6F{*DT}B~<4!A_3eI)J^yfkO?kfwwSIWv~0IkoV zjg%Cso%R&XFG{P`Q&Qwwl&Yttm#byjz!sfWlxl`Q6Yd&U8a|3^H7z#$3ov?>Ze{91 zF6i2d4Oq;yvE>|TY3t}uIEmCnFz_$tU=~0%@7e?!m$#knC`}#w3$5`crS9znHQH|43+pXB!$oKSO%*I4zLpVCn zS!UeF`C~m?Uz%Bf&GB$*E~wNc07M;g;U7za9<)xa42q|D5i@lecnKd}V!=L(5FI`~=YC|<2o8~DJ< zcZQGulZ&a`g=pS7r>=1^jt_5)Go~>+n^zfU<+=XK|E9|N!}Vo2ws;#CD}PqS{|o!H zxFlZmoF(&OTXAwLy6gIv!@Nn)+<`i!CCANrP6`*3aE030uMB~(hwN=q|;<_4UKXRekYipAsc)I#oUY?Pd_L06=w z{f%*1_J6mI!#W;V2Zx9(u2I|{xXS!Rn7~T6TtQi-X*dx{li7eHRi+zIM2$O#_a)pXPUNLxmRT;PmT^ZoNxCrxCkgH_ zen*Tyr>ik_&*`c86^1QW>S^gH!OL=3H|6W> zzY;_!eXZ_a4TR5r4aUuF*n&I+ac~x+xWM#RaTzRFQNpK2d@0lKfy7@8;OVX|4po^~ zhVQ@NelGQlBbB84z0hg;{1MzHx?a4O5}turjfUV7Dy7U03L#!mv2o~Yg9om}7i?Vv z+krj+OY*P95bF2?*V9GQ);B3*<7ri_6+fZH!yt>NNU5iHya6@i-1Uo?XRM%j0tCuW z3ZK_u0#9+3)b$)O9kb}_3y>x}l_2To5-UB&wNhAFJOjuHx%Nm&0u{oMb0bkUxP2XKpS4PZ!XguY(@n zHrQEHKf-;EEKx)~=rajb!^P=PRuDo&c=&j3WjSvVe^QBZns}gyq8aHH z*9eMc;oNe=#iE6I(Sr9B-XS>8@t)$nZDSk4KosZs1jw<7B?Hx|{dnq}wdqjLsWzBg zi?WW59pE)fG#@3g#17bu3!Og;g68HeY+^yRjtS7v`~j$ibO^<3(L-}>0*60;HRxDE zXEQjbUX6k62~;N@hB+7cHvxDS7W3vT$INsc^1g+myhD-)Yl%{WoJ3n7Jtq|AYj38u zfYSH8{tE zLd(I^8O|rk$uSRul^WvKp!S?e{Nrh&FfCet-Uh5o9hU!9jB{Mz3pZu@*I$P|CmMs< zX5z1I@j_ZNod{h{b+Z@G#nX>Af&QrQKy+Nq4WMwEP#yk|B^H$kGvj4Jgy<+-I2x_b zzGh%=;G@wN{pGMmhXh=r-5HKjJi_^pYgL9u`}IwhMj-)zpmF@DfV&0f4uLla+d7T= zc}?GAYP8qpaE8WVG|J&r_Hr0s8SLHAA+{%L+Vt;qNX7(a$XH6}AzhpP2u?2@18&hr z(V>V%zm9%CYSCw`??(rU{1eLeqnFY>xMeJwrSFE*2SdBVHmzcZWQ$IQh9VmMRlO6_ z+<;^(s*Z6T574ELV(l@D>dc`?)}l)=SY%inev5JVpMsMS&=l}a@HM)Rb}^R@!dRnW zfxjZ~UjVo0TAAD4F7)>Rcj*$RJ?7Gl&~d3z;CG6N-=Lw$`yvfDOY4PTlYqyB>Q{u* zM~IiJm2tQzvTEc%(jgZO#mnB~%6 zgy%SBvFHI*RHG+lHDpnChKP5z%47e~=CDrT;#aI$SmSZR>0~&vDCTnh6Y`sIS`hA* zi$H%7dK1!K6x^b#;LW93BAIOh{yZ`iv1z}-=$P%G>_Qk+uVxYbenpYThyRyGFA2t`}ZdK;91-UOf z^H~dhUET|hC(UOIbI&mj|0v*W!I=oSAg8+=eqC7IC*UCgFBkAnGM65)IUG{wvsRiN zdCF|1Wh#ejB@Qd(17-`wv*G74TrJ=x0WT780$>e2E&bS>OgFKY%mmW-1+qWlyahaE z9T)uffj82JfXf8b*@xy8NnV!El${7^u<1Tc@uI91RDpUW4foqYP`CWP5DZB-9S;im=cnyf}kr|*pmsAS8NKZ#M?icZ?O5 zLsd|#L5xAb zg(}AjLUpFh%{Jg>VAKK*$9e1S5oT>d^9%TP!RW6-|A@dJ6Zk1;(uokm(~x&UIiN-3 z0b|r6V5flDE-}m%aDjl`0`>^l52#Tdumrp2M5vO+1J=?;!4K0+w~OMyyK!B-ulqEH zeyeY%@zQ?do75sr2_2wa)EfC2n)J=k8`MU3Mz_;Gx?9rdMY_>AL0wttyOBSG2*pm) zVOk#1*y~UYvA3WaV>@ULSCSUWC9F&Os>$&|qXKxPJqobLr~}+)q}X)2UYZQ6Z%3!I z1=3Mf!(<$vPH)LGp;PB_4>PfCyQDXqD_NCDpoL{0i7aC+>|fBw7l}Mt*j3i`tds4Q zpJiR_;?NIRKl?-U1a9{qMJ9v)g7E_2?Hc$Jh?3**?xL6CQR+$DNPa zT=u9m9q=6iXV|6EBI2PhqTS|rX(LnAiPA&Tg;}*->SwAoO&Vki3}nRKRp&^Z>EZw5sCUr?hbUWa8BcGREl*UEBC~O~u(+C2>xoI6k zaBCv(OKU{&*NWoj+2`$JY^|uxFsqAXTjX6*g*r>#1f^d2Q$lGc`<;1{{7?~Itb9s- zN8*+EK;k83a%*(IybZYy%X673ACd>zpTj?s@1_=aI=fp;c5krKY>o4K`Cc*EHnBja zTyHlh_Y0jzxK5S^*-i3v_J~mWA^S1DNWUm1cI3yRcPQoZHv6;6*I@9V@`P|&BmYYJ z25=tAVWEGRJ?lK9yw0AopH-frLbQTCBZ~Zi@H1Y1SN)}8qHQ&_Yzn{)9H5I&eBY585_+<<~V$*KM|UM z=k;CI1oEf=SV`XitfPMeOp^?0D>VU5qYl6e>0-c31%IJ{SJ7bTLh7Y`fEHk{z=FcmmF4TiJ`!FC|C5O@2?l zSb14ls;W4Ah=#*COTsS>GD4b5*U(mag?>lP>>BnitC2QJOg81^@;don;)*fpMEU8z&+Ec^U^_@iT;%2S;%Tp8njY9{M|a z#|pz0C3Q{o0`=1^EK82`G%b>4_FF|~FOZ492%Dxx*$ewWsQ6g*F6vvo`MT@+G8bh0 zn+JMUukBm7sc<5>GIADwe^<>woFSh z=Hzxdx?JKeU?uKiW=k9v@|P~0FNvomPhK!gpF zbMqy!7=w$IY!sEa`RampSu3kAOueib@W#u^v#Kr|k}YYrnq)?`ET+!47|@L%h6kd} zOoDkpRF5LnVotvOLVcW@d&qWkd)?fln2vuy1k>8!KW1~dPjYfka+!!S_cTX?4`gbR z+}!I~3{c`kzEc6-9U+*^=7_-R1 zE;NtYhn_;;0TVO=?*()v`Z0G{ zta1?6VUTDR$>|1D(cFBVyAn25l4W^wRl~nnl)a~uJ6ueTk36}r4%gr4Z@Esm~iIe*P zGakqICqCjoXS=zNxd^O0lSIsKiBb$%yzv56;}jyYfg4`EqB|2k!t=57g;jx;S==vRK_fe=DmZ-$Z?x zcHuX*T~`&XrM1b}{*vXr%HDZ}^)D>CIemQ#zmq23>}PHq*wEA4H!!iVd}-31xX|C6 z>Ft}{x4tjav&qkZea(`p!m6=G#YBJg4HHEM6W8}`8ramceoaqi&7=v+*L{=UZX5pcPrq_y|F@6-HzGRqk?`vtC3n^&r1u8*&sz2aR`}eq75nYw zzm?}tqE70ec521H4vg30MbH9XAMj4R5!!&Q!EM}1lYvixdd3kP diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index b02ef6b06b7ea38fc888de18394b5816e9bcb316..8d81d9859f7bec9702356b4acf9e4b9acae221ce 100644 GIT binary patch delta 237 zcmZo@VQOe$n$W>wwp-!R#vYMWft7QT*oC|wJM9T)@;@cpp}BcOYQ3O_g|VrDVVap) znt7tBfl;Dql97?6v00jFYKmz}T3U)_YNAO}sNQ%%3b#5g_34ocsOt zrHj1Q&ffa(xxgy9$rTkB6rh4!FhQVd`73d*2_E-;9IzL^W4Sq_@&Sv#1%okzDT4un zAwwF284#v1m@_1TMU2375`z&CS^`yp=Nk)c7h9<_QMiz-_hRGI5Nhanglf%pRvN$eZ`fajAMSwthoW#yF z+e6-q8t*1^Ts?Goaz(`j1*l;3Bd8!yb(v}Xj1_JvQh&Uc>&@7lQTc$y--yAKA(6p^ zA%!6s2$LAh8Pb3vDGZiCwi!^w5J;u~RU|PO0ofpI!e9(kVZ>ko6ioy2lYy)xAT$B$ LP1!uXE|VDmAInX7 diff --git a/Runtime/Core/DxMessagingStaticState.cs b/Runtime/Core/DxMessagingStaticState.cs index f4a10977..14cad6f6 100644 --- a/Runtime/Core/DxMessagingStaticState.cs +++ b/Runtime/Core/DxMessagingStaticState.cs @@ -61,6 +61,7 @@ public static void Reset() IMessageBus defaultBus = MessageHandler.InitialGlobalMessageBus; MessageHandler.ResetStatics(); + MessageBus.MessageBus.ResetStaticPools(); if ( !ReferenceEquals(activeBus, defaultBus) diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index 4e568f45..ee3dda8c 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -148,7 +148,7 @@ internal static bool ShouldEnableDiagnostics() /// /// Result returned by . /// - readonly struct TrimResult + readonly struct TrimResult : IEquatable { public TrimResult( int typeSlotsEvicted, @@ -174,6 +174,30 @@ int liveTypeSlotsRemaining /// Number of occupied type slots remaining after trim. public int LiveTypeSlotsRemaining { get; } + + public bool Equals(TrimResult other) => + TypeSlotsEvicted == other.TypeSlotsEvicted + && TargetSlotsEvicted == other.TargetSlotsEvicted + && PooledCollectionsEvicted == other.PooledCollectionsEvicted + && LiveTypeSlotsRemaining == other.LiveTypeSlotsRemaining; + + public override bool Equals(object obj) => obj is TrimResult other && Equals(other); + + public override int GetHashCode() => + ( + TypeSlotsEvicted, + TargetSlotsEvicted, + PooledCollectionsEvicted, + LiveTypeSlotsRemaining + ).GetHashCode(); + + public static bool operator ==(TrimResult left, TrimResult right) => left.Equals(right); + + public static bool operator !=(TrimResult left, TrimResult right) => + !left.Equals(right); + + public override string ToString() => + $"TrimResult(TypeSlotsEvicted={TypeSlotsEvicted}, TargetSlotsEvicted={TargetSlotsEvicted}, PooledCollectionsEvicted={PooledCollectionsEvicted}, LiveTypeSlotsRemaining={LiveTypeSlotsRemaining})"; } int RegisteredBroadcast { get; } @@ -378,6 +402,11 @@ Action RegisterSourcedBroadcastWithoutSource( /// param1: Current message instance by reference /// And returns: true if message handling should continue, false if message handling should be stopped. /// + /// + /// Interceptor delegates registered with this method are retained for the bus lifetime. + /// Calling the returned deregistration action removes the interceptor from dispatch, but + /// the delegate reference may remain until the final deregistration of that interceptor. + /// /// The deregistration action. Should be invoked when the handler no longer wants to intercept messages. Action RegisterUntargetedInterceptor( UntargetedInterceptor interceptor, @@ -403,6 +432,11 @@ Action RegisterUntargetedInterceptor( /// param1: Current message instance by reference /// And returns: true if message handling should continue, false if message handling should be stopped. /// + /// + /// Interceptor delegates registered with this method are retained for the bus lifetime. + /// Calling the returned deregistration action removes the interceptor from dispatch, but + /// the delegate reference may remain until the final deregistration of that interceptor. + /// /// The deregistration action. Should be invoked when the handler no longer wants to intercept messages. Action RegisterTargetedInterceptor(TargetedInterceptor interceptor, int priority = 0) where T : ITargetedMessage; @@ -425,6 +459,11 @@ Action RegisterTargetedInterceptor(TargetedInterceptor interceptor, int pr /// param1: Current message instance by reference /// And returns: true if message handling should continue, false if message handling should be stopped. /// + /// + /// Interceptor delegates registered with this method are retained for the bus lifetime. + /// Calling the returned deregistration action removes the interceptor from dispatch, but + /// the delegate reference may remain until the final deregistration of that interceptor. + /// /// The deregistration action. Should be invoked when the handler no longer wants to intercept messages. Action RegisterBroadcastInterceptor( BroadcastInterceptor interceptor, diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index bdcac0bc..8c5be024 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -135,6 +135,27 @@ public PrefreezeDescriptor(byte kind, int priority) private static readonly ArrayPool DispatchEntryPool = ArrayPool.Shared; + private static CollectionPool< + Dictionary> + > ContextHandlerByTargetDicts => ContextHandlerByTargetDictPoolHolder.Instance; + + private static class ContextHandlerByTargetDictPoolHolder + { + public static readonly CollectionPool< + Dictionary> + > Instance = new( + maxRetained: 512, + useLru: true, + factory: static () => new Dictionary>(), + onRecycled: static dict => dict.Clear() + ); + } + + internal static int ResetStaticPools() + { + return ContextHandlerByTargetDicts.Trim(0); + } + internal readonly struct DispatchEntry { public DispatchEntry( @@ -271,6 +292,10 @@ private sealed class HandlerCache /// public void Clear() { + // LEGACY: version reset semantics; the BusSinkSlot.Reset path under P3 will + // preserve monotonic as required by R3. Bus-side deregistration closures use + // captured cache identity and reset generations, so no current consumer depends + // on monotonic version here until storage migration lands. handlers.Clear(); order.Clear(); cache.Clear(); @@ -354,6 +379,10 @@ private sealed class HandlerCache /// public void Clear() { + // LEGACY: version reset semantics; the BusSinkSlot.Reset path under P3 will + // preserve monotonic as required by R3. Bus-side deregistration closures use + // captured cache identity and reset generations, so no current consumer depends + // on monotonic version here until storage migration lands. handlers.Clear(); cache.Clear(); version = 0; @@ -796,6 +825,7 @@ private static void ResetIdleSweepRegistry() DxMessagingRuntimeSettings.SettingsChanged -= HandleRuntimeSettingsChanged; IdleSweepBuses.Clear(); RuntimeSettingsSubscribed = false; + ResetStaticPools(); } private void ApplyRuntimeSettings(DxMessagingRuntimeSettings settings) @@ -806,6 +836,8 @@ private void ApplyRuntimeSettings(DxMessagingRuntimeSettings settings) } DxPools.Configure(settings); + ContextHandlerByTargetDicts.UseLru = settings.BufferUseLruEviction; + ContextHandlerByTargetDicts.MaxRetained = settings.BufferMaxDistinctEntries; if (!settings.IsFallbackInstance) { IMessageBus.GlobalMessageBufferSize = Math.Max(0, settings.MessageBufferSize); @@ -970,8 +1002,13 @@ private readonly Dictionary< private double _lastSweepSeconds; private readonly List _dirtyTypes = new(); private readonly Dictionary> _dirtyTargets = new(); + private readonly Dictionary _dirtyTargetHighWaterCounts = new(); private readonly HashSet _dirtyTypeSet = new(); private readonly Dictionary> _dirtyTargetSets = new(); + private readonly Dictionary< + Dictionary>, + int + > _contextMapHighWaterCounts = new(); private readonly List _dirtyHandlers = new(); private readonly HashSet _dirtyHandlerSet = new(); private readonly Dictionary _dirtyHandlerTicks = new(); @@ -1072,19 +1109,23 @@ private void MarkDirtyTarget(InstanceId target) if (!_dirtyTargets.TryGetValue(typeIndex, out List targets)) { - targets = new List(); + targets = DxPools.InstanceIdLists.Rent(); _dirtyTargets[typeIndex] = targets; } if (!_dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet)) { - targetSet = new HashSet(); + targetSet = DxPools.InstanceIdSets.Rent(); _dirtyTargetSets[typeIndex] = targetSet; } if (targetSet.Add(target)) { targets.Add(target); + _dirtyTargetHighWaterCounts[typeIndex] = Math.Max( + GetDirtyTargetHighWaterCount(typeIndex), + targets.Count + ); } } @@ -1130,7 +1171,6 @@ internal TrimResult Sweep(bool force) typeSlotsEvicted += SweepableTypeCacheRegistry[4].Sweep(this, force); typeSlotsEvicted += SweepGlobalSlot(force); typeSlotsEvicted += SweepDirtyTypedHandlerSlots(force); - int pooledCollectionsEvicted = DxPools.TrimAll(force); if (force) { ClearDirtySweepCandidates(); @@ -1139,6 +1179,10 @@ internal TrimResult Sweep(bool force) { PruneDirtySweepCandidates(); } + int pooledCollectionsEvicted = DxPools.TrimAll(force); + pooledCollectionsEvicted += ContextHandlerByTargetDicts.Trim( + force ? 0 : ContextHandlerByTargetDicts.MaxRetained + ); _lastSweepSeconds = _clock.NowSeconds; return new TrimResult( @@ -1285,7 +1329,7 @@ out HandlerCache handlers if (handlersByTarget.Count == 0) { - sink.RemoveAtIndex(typeIndex); + RemoveAndReturnContextMap(sink, typeIndex, handlersByTarget); _lastContextTypeSlotsEvicted++; } } @@ -1306,6 +1350,9 @@ private int SweepGlobalSlot(bool force) return 0; } + // LEGACY: global slot reset keeps the current sweep-generation guard for stale + // deregistration closures. The BusSinkSlot.Reset path under P3 will preserve + // monotonic as required by R3 when the remaining bus-side storage migrates. _globalSlots.Reset(); unchecked { @@ -1492,6 +1539,7 @@ private void PruneDirtyTargetCandidates() for (int i = 0; i < emptyTypeKeys.Count; ++i) { int typeIndex = emptyTypeKeys[i]; + ReturnDirtyTargetCollections(typeIndex); _dirtyTargets.Remove(typeIndex); _dirtyTargetSets.Remove(typeIndex); } @@ -1520,7 +1568,10 @@ out HandlerCache handlers if ( handlers.handlers.Count == 0 - && !IsIdleForSweep(handlers.lastTouchTicks, force: false) + && ( + HasActiveDispatchSnapshot(handlers.dispatchState) + || !IsIdleForSweep(handlers.lastTouchTicks, force: false) + ) ) { return true; @@ -1565,6 +1616,179 @@ private void ClearDirtySweepCandidates() ClearDirtyHandlerCandidatesWithoutEmptySlots(); } + private void ReturnDirtyTargetCollections(int typeIndex) + { + _dirtyTargets.TryGetValue(typeIndex, out List targets); + _dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet); + int highWaterCount = GetDirtyTargetHighWaterCount(typeIndex); + ReturnDirtyTargetList(targets, highWaterCount); + ReturnDirtyTargetSet(targetSet, highWaterCount); + _dirtyTargetHighWaterCounts.Remove(typeIndex); + } + + private void ReturnAllDirtyTargetCollections() + { + foreach (KeyValuePair> entry in _dirtyTargets) + { + int highWaterCount = GetDirtyTargetHighWaterCount(entry.Key); + ReturnDirtyTargetList(entry.Value, highWaterCount); + } + + foreach (KeyValuePair> entry in _dirtyTargetSets) + { + int highWaterCount = GetDirtyTargetHighWaterCount(entry.Key); + ReturnDirtyTargetSet(entry.Value, highWaterCount); + } + + _dirtyTargetHighWaterCounts.Clear(); + } + + private int GetDirtyTargetHighWaterCount(int typeIndex) + { + return _dirtyTargetHighWaterCounts.TryGetValue(typeIndex, out int count) ? count : 0; + } + + private static void ReturnDirtyTargetList(List targets, int highWaterCount) + { + if (targets == null) + { + return; + } + + if (ShouldDropOversizedPoolEntry(highWaterCount, DxPools.InstanceIdLists.MaxRetained)) + { + targets.Clear(); + return; + } + + DxPools.InstanceIdLists.Return(targets); + } + + private static void ReturnDirtyTargetSet(HashSet targets, int highWaterCount) + { + if (targets == null) + { + return; + } + + if (ShouldDropOversizedPoolEntry(highWaterCount, DxPools.InstanceIdSets.MaxRetained)) + { + targets.Clear(); + return; + } + + DxPools.InstanceIdSets.Return(targets); + } + + internal CollectionPoolDiagnostics GetContextDictPoolDiagnosticsForTesting() + { + return ContextHandlerByTargetDicts.Snapshot(); + } + + private Dictionary> GetOrRentContextMap( + MessageCache>> sinks + ) + where T : IMessage + { + if ( + sinks.TryGetValue( + out Dictionary> handlersByTarget + ) + ) + { + return handlersByTarget; + } + + handlersByTarget = ContextHandlerByTargetDicts.Rent(); + _contextMapHighWaterCounts[handlersByTarget] = handlersByTarget.Count; + sinks.Set(handlersByTarget); + return handlersByTarget; + } + + private void RemoveAndReturnContextMap( + MessageCache>> sink, + int typeIndex, + Dictionary> handlersByTarget + ) + { + sink.RemoveAtIndex(typeIndex); + ReturnContextMap(handlersByTarget); + } + + private void ReturnContextMap( + Dictionary> handlersByTarget + ) + { + if (handlersByTarget == null) + { + return; + } + + int highWaterCount = GetContextMapHighWaterCount(handlersByTarget); + _contextMapHighWaterCounts.Remove(handlersByTarget); + + foreach (HandlerCache handlers in handlersByTarget.Values) + { + handlers?.Clear(); + } + + handlersByTarget.Clear(); + if ( + ShouldDropOversizedPoolEntry( + highWaterCount, + ContextHandlerByTargetDicts.MaxRetained + ) + ) + { + return; + } + + ContextHandlerByTargetDicts.Return(handlersByTarget); + } + + private void ClearAndReturnContextSink( + MessageCache>> sink + ) + { + foreach ( + Dictionary> handlersByTarget in sink + ) + { + ReturnContextMap(handlersByTarget); + } + + sink.Clear(); + } + + private void TrackContextMapHighWater( + Dictionary> handlersByTarget + ) + { + if (handlersByTarget == null) + { + return; + } + + _contextMapHighWaterCounts[handlersByTarget] = Math.Max( + GetContextMapHighWaterCount(handlersByTarget), + handlersByTarget.Count + ); + } + + private int GetContextMapHighWaterCount( + Dictionary> handlersByTarget + ) + { + return _contextMapHighWaterCounts.TryGetValue(handlersByTarget, out int count) + ? count + : handlersByTarget?.Count ?? 0; + } + + private static bool ShouldDropOversizedPoolEntry(int retainedEntryCount, int maxRetained) + { + return maxRetained > 0 && retainedEntryCount > maxRetained; + } + private void ClearDirtyTypeCandidatesWithoutEmptySlots() { int write = 0; @@ -1661,6 +1885,7 @@ private void ClearDirtyTargetCandidatesWithoutEmptySlots() for (int i = 0; i < emptyTypeKeys.Count; ++i) { int typeIndex = emptyTypeKeys[i]; + ReturnDirtyTargetCollections(typeIndex); _dirtyTargets.Remove(typeIndex); _dirtyTargetSets.Remove(typeIndex); } @@ -1761,11 +1986,11 @@ internal void ResetState() _scalarSinks[BusSinkIndex.UntargetedHandleDefault].Clear(); _scalarSinks[BusSinkIndex.BroadcastHandleWithoutContext].Clear(); _scalarSinks[BusSinkIndex.TargetedHandleWithoutContext].Clear(); - _contextSinks[BusContextIndex.TargetedHandleDefault].Clear(); - _contextSinks[BusContextIndex.BroadcastHandleDefault].Clear(); + ClearAndReturnContextSink(_contextSinks[BusContextIndex.TargetedHandleDefault]); + ClearAndReturnContextSink(_contextSinks[BusContextIndex.BroadcastHandleDefault]); _scalarSinks[BusSinkIndex.UntargetedPostProcessDefault].Clear(); - _contextSinks[BusContextIndex.TargetedPostProcessDefault].Clear(); - _contextSinks[BusContextIndex.BroadcastPostProcessDefault].Clear(); + ClearAndReturnContextSink(_contextSinks[BusContextIndex.TargetedPostProcessDefault]); + ClearAndReturnContextSink(_contextSinks[BusContextIndex.BroadcastPostProcessDefault]); _scalarSinks[BusSinkIndex.TargetedPostProcessWithoutContext].Clear(); _scalarSinks[BusSinkIndex.BroadcastPostProcessWithoutContext].Clear(); _globalSlots.Clear(); @@ -1778,9 +2003,12 @@ internal void ResetState() _innerInterceptorsStack.Clear(); _methodCache.Clear(); _dirtyTypes.Clear(); + ReturnAllDirtyTargetCollections(); _dirtyTargets.Clear(); _dirtyTypeSet.Clear(); _dirtyTargetSets.Clear(); + _dirtyTargetHighWaterCounts.Clear(); + _contextMapHighWaterCounts.Clear(); _dirtyHandlers.Clear(); _dirtyHandlerSet.Clear(); _dirtyHandlerTicks.Clear(); @@ -6015,7 +6243,7 @@ int priority long touchTick = AdvanceTick(); Dictionary> broadcastHandlers = - sinks.GetOrAdd(); + GetOrRentContextMap(sinks); Dictionary> capturedBroadcastHandlers = broadcastHandlers; SlotKey slotKey = RegistrationMethodAxes.GetSlotKey(registrationMethod); @@ -6029,6 +6257,7 @@ out HandlerCache handlers { handlers = new HandlerCache(); broadcastHandlers[context] = handlers; + TrackContextMapHighWater(broadcastHandlers); } Touch(handlers, touchTick); HandlerCache capturedHandlers = handlers; diff --git a/Runtime/Core/Pooling/DxPools.cs b/Runtime/Core/Pooling/DxPools.cs index 41f20d10..4be14e55 100644 --- a/Runtime/Core/Pooling/DxPools.cs +++ b/Runtime/Core/Pooling/DxPools.cs @@ -31,6 +31,20 @@ internal static class DxPools onRecycled: dict => dict.Clear() ); + internal static readonly CollectionPool> InstanceIdLists = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new List(), + onRecycled: list => list.Clear() + ); + + internal static readonly CollectionPool> InstanceIdSets = new( + maxRetained: DefaultMaxRetained, + useLru: true, + factory: () => new HashSet(), + onRecycled: set => set.Clear() + ); + internal static readonly CollectionPool> ObjectLists = new( maxRetained: DefaultMaxRetained, useLru: true, @@ -79,6 +93,8 @@ internal static int TrimAll(bool force) { int evicted = 0; evicted += InstanceIdDicts.Trim(force ? 0 : InstanceIdDicts.MaxRetained); + evicted += InstanceIdLists.Trim(force ? 0 : InstanceIdLists.MaxRetained); + evicted += InstanceIdSets.Trim(force ? 0 : InstanceIdSets.MaxRetained); evicted += ObjectLists.Trim(force ? 0 : ObjectLists.MaxRetained); evicted += ObjectStacks.Trim(force ? 0 : ObjectStacks.MaxRetained); evicted += IntSets.Trim(force ? 0 : IntSets.MaxRetained); @@ -105,12 +121,16 @@ internal static void Configure(DxMessagingRuntimeSettings settings) int cap = settings.BufferMaxDistinctEntries; bool useLru = settings.BufferUseLruEviction; InstanceIdDicts.UseLru = useLru; + InstanceIdLists.UseLru = useLru; + InstanceIdSets.UseLru = useLru; ObjectLists.UseLru = useLru; ObjectStacks.UseLru = useLru; IntSets.UseLru = useLru; TypedHandlerContextDicts.UseLru = useLru; TypedHandlerPriorityDicts.UseLru = useLru; InstanceIdDicts.MaxRetained = cap; + InstanceIdLists.MaxRetained = cap; + InstanceIdSets.MaxRetained = cap; ObjectLists.MaxRetained = cap; ObjectStacks.MaxRetained = cap; IntSets.MaxRetained = cap; @@ -124,6 +144,8 @@ internal static PoolDiagnosticsSnapshot DescribeAll() { return new PoolDiagnosticsSnapshot( InstanceIdDicts.Snapshot(), + InstanceIdLists.Snapshot(), + InstanceIdSets.Snapshot(), ObjectLists.Snapshot(), ObjectStacks.Snapshot(), IntSets.Snapshot(), diff --git a/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs index 0540fd2e..53e5eaa3 100644 --- a/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs +++ b/Runtime/Core/Pooling/PoolDiagnosticsSnapshot.cs @@ -10,6 +10,12 @@ internal readonly struct PoolDiagnosticsSnapshot /// Dictionary<InstanceId, object> pool diagnostics. public readonly CollectionPoolDiagnostics InstanceIdDicts; + /// List<InstanceId> pool diagnostics. + public readonly CollectionPoolDiagnostics InstanceIdLists; + + /// HashSet<InstanceId> pool diagnostics. + public readonly CollectionPoolDiagnostics InstanceIdSets; + /// List<object> pool diagnostics. public readonly CollectionPoolDiagnostics ObjectLists; @@ -27,6 +33,8 @@ internal readonly struct PoolDiagnosticsSnapshot internal PoolDiagnosticsSnapshot( CollectionPoolDiagnostics instanceIdDicts, + CollectionPoolDiagnostics instanceIdLists, + CollectionPoolDiagnostics instanceIdSets, CollectionPoolDiagnostics objectLists, CollectionPoolDiagnostics objectStacks, CollectionPoolDiagnostics intSets, @@ -35,6 +43,8 @@ CollectionPoolDiagnostics typedHandlerPriorityDicts ) { InstanceIdDicts = instanceIdDicts; + InstanceIdLists = instanceIdLists; + InstanceIdSets = instanceIdSets; ObjectLists = objectLists; ObjectStacks = objectStacks; IntSets = intSets; diff --git a/Tests/Editor/Allocations/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs index fab562fd..3ecd7832 100644 --- a/Tests/Editor/Allocations/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -3,6 +3,7 @@ namespace DxMessaging.Tests.Editor.Allocations { using System; using System.Collections.Generic; + using System.Reflection; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; @@ -121,6 +122,7 @@ public sealed class AllocationMatrixTests : BenchmarkTestBase /// to cover the augmented closure's captured state. /// private const long PerAugmentedRegistrationByteBudget = 768L; + private const int DirtyTargetPoolRetainedEntryCount = 64; // The InstanceId values below are arbitrary 32-bit integers that // distinguish the targeted/source/owner participants from each other @@ -655,6 +657,340 @@ MessageScenario scenario ); } + [Test] + [Category("Allocation")] + public void DirtyTargetTrimReturnsInstanceIdCollectionsToPools( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.KindsWithComponentTarget) + )] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + int previousListCap = DxPools.InstanceIdLists.MaxRetained; + int previousSetCap = DxPools.InstanceIdSets.MaxRetained; + bool previousListLru = DxPools.InstanceIdLists.UseLru; + bool previousSetLru = DxPools.InstanceIdSets.UseLru; + try + { + _ = DxPools.TrimAll(force: true); + DxPools.InstanceIdLists.UseLru = true; + DxPools.InstanceIdSets.UseLru = true; + DxPools.InstanceIdLists.MaxRetained = DirtyTargetPoolRetainedEntryCount; + DxPools.InstanceIdSets.MaxRetained = DirtyTargetPoolRetainedEntryCount; + Action emit = BuildEmitClosure(scenario, bus); + MessageRegistrationHandle handle = RegisterHandler(scenario, token); + emit(); + token.RemoveRegistration(handle); + emit(); + int listsBefore = DxPools.DescribeAll().InstanceIdLists.Cached; + int setsBefore = DxPools.DescribeAll().InstanceIdSets.Cached; + + IMessageBus.TrimResult result = bus.Trim(force: false); + + Assert.Greater( + result.TargetSlotsEvicted, + 0, + $"Trim-{scenario.Kind} must reclaim a dirty target slot." + ); + Assert.Greater( + DxPools.DescribeAll().InstanceIdLists.Cached, + listsBefore, + $"Trim-{scenario.Kind} must return the dirty-target list to the pool." + ); + Assert.Greater( + DxPools.DescribeAll().InstanceIdSets.Cached, + setsBefore, + $"Trim-{scenario.Kind} must return the dirty-target set to the pool." + ); + + long listHitsBeforeReuse = DxPools.DescribeAll().InstanceIdLists.Hits; + long setHitsBeforeReuse = DxPools.DescribeAll().InstanceIdSets.Hits; + MessageRegistrationHandle reused = RegisterHandler(scenario, token); + emit(); + token.RemoveRegistration(reused); + + Assert.Greater( + DxPools.DescribeAll().InstanceIdLists.Hits, + listHitsBeforeReuse, + $"Register-{scenario.Kind} must rent a pooled dirty-target list." + ); + Assert.Greater( + DxPools.DescribeAll().InstanceIdSets.Hits, + setHitsBeforeReuse, + $"Register-{scenario.Kind} must rent a pooled dirty-target set." + ); + } + finally + { + _ = DxPools.TrimAll(force: true); + DxPools.InstanceIdLists.UseLru = previousListLru; + DxPools.InstanceIdSets.UseLru = previousSetLru; + DxPools.InstanceIdLists.MaxRetained = previousListCap; + DxPools.InstanceIdSets.MaxRetained = previousSetCap; + } + } + ); + } + + public static IEnumerable RetainedDirtyTargetWarmupCounts + { + get + { + yield return 1; + yield return DirtyTargetPoolRetainedEntryCount - 1; + yield return DirtyTargetPoolRetainedEntryCount; + } + } + + public static IEnumerable OversizedDirtyTargetWarmupCounts + { + get + { + yield return DirtyTargetPoolRetainedEntryCount + 1; + yield return DirtyTargetPoolRetainedEntryCount * 2; + yield return 1000; + } + } + + [Test] + [Category("Allocation")] + public void DirtyTargetTrackingIsAllocationFreeAfterWarmup( + [ValueSource(nameof(RetainedDirtyTargetWarmupCounts))] int targetCount + ) + { + MessageBus bus = MessageBus.CreateForInternalUse( + StopwatchClock.Instance, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: double.PositiveInfinity, + idleEvictionEnabled: false, + trimApiEnabled: true + ); + try + { + int previousListCap = DxPools.InstanceIdLists.MaxRetained; + int previousSetCap = DxPools.InstanceIdSets.MaxRetained; + bool previousListLru = DxPools.InstanceIdLists.UseLru; + bool previousSetLru = DxPools.InstanceIdSets.UseLru; + _ = DxPools.TrimAll(force: true); + DxPools.InstanceIdLists.UseLru = true; + DxPools.InstanceIdSets.UseLru = true; + DxPools.InstanceIdLists.MaxRetained = DirtyTargetPoolRetainedEntryCount; + DxPools.InstanceIdSets.MaxRetained = DirtyTargetPoolRetainedEntryCount; + PrimeDirtyTargetMessageTypeIndex(bus); + _ = DxPools.TrimAll(force: true); + try + { + Action markDirtyTarget = CreateDirtyTargetMarker(bus); + MarkDirtyTargets(markDirtyTarget, 0x2424_0000, targetCount); + _ = bus.Trim(force: false); + PoolDiagnosticsSnapshot afterWarmup = DxPools.DescribeAll(); + + Assert.Greater( + afterWarmup.InstanceIdLists.Cached, + 0, + "Dirty-target warmup must return a retained InstanceId list to the pool " + + $"before measuring reuse. targetCount={targetCount}, " + + $"cap={DirtyTargetPoolRetainedEntryCount}, " + + $"listPool={FormatPoolDiagnostics(afterWarmup.InstanceIdLists)}." + ); + Assert.Greater( + afterWarmup.InstanceIdSets.Cached, + 0, + "Dirty-target warmup must return a retained InstanceId set to the pool " + + $"before measuring reuse. targetCount={targetCount}, " + + $"cap={DirtyTargetPoolRetainedEntryCount}, " + + $"setPool={FormatPoolDiagnostics(afterWarmup.InstanceIdSets)}." + ); + + long listHitsBefore = afterWarmup.InstanceIdLists.Hits; + long setHitsBefore = afterWarmup.InstanceIdSets.Hits; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetAllocatedBytesForCurrentThread(); + MarkDirtyTargets(markDirtyTarget, 0x2425_0000, targetCount); + long after = GC.GetAllocatedBytesForCurrentThread(); + PoolDiagnosticsSnapshot afterReuse = DxPools.DescribeAll(); + + Assert.AreEqual( + 0, + after - before, + "Dirty-target tracking must reuse warmed InstanceId list/set storage without managed allocations." + ); + Assert.Greater( + afterReuse.InstanceIdLists.Hits, + listHitsBefore, + "Dirty-target tracking must rent the warmed InstanceId list. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterWarmup.InstanceIdLists)}, " + + $"after={FormatPoolDiagnostics(afterReuse.InstanceIdLists)}." + ); + Assert.Greater( + afterReuse.InstanceIdSets.Hits, + setHitsBefore, + "Dirty-target tracking must rent the warmed InstanceId set. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterWarmup.InstanceIdSets)}, " + + $"after={FormatPoolDiagnostics(afterReuse.InstanceIdSets)}." + ); + } + finally + { + DxPools.InstanceIdLists.UseLru = previousListLru; + DxPools.InstanceIdSets.UseLru = previousSetLru; + DxPools.InstanceIdLists.MaxRetained = previousListCap; + DxPools.InstanceIdSets.MaxRetained = previousSetCap; + } + } + finally + { + _ = bus.Trim(force: false); + _ = DxPools.TrimAll(force: true); + } + } + + [Test] + [Category("Allocation")] + public void DirtyTargetTrackingDropsOversizedWarmupCollections( + [ValueSource(nameof(OversizedDirtyTargetWarmupCounts))] int targetCount + ) + { + MessageBus bus = MessageBus.CreateForInternalUse( + StopwatchClock.Instance, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: double.PositiveInfinity, + idleEvictionEnabled: false, + trimApiEnabled: true + ); + try + { + int previousListCap = DxPools.InstanceIdLists.MaxRetained; + int previousSetCap = DxPools.InstanceIdSets.MaxRetained; + bool previousListLru = DxPools.InstanceIdLists.UseLru; + bool previousSetLru = DxPools.InstanceIdSets.UseLru; + _ = DxPools.TrimAll(force: true); + DxPools.InstanceIdLists.UseLru = true; + DxPools.InstanceIdSets.UseLru = true; + DxPools.InstanceIdLists.MaxRetained = DirtyTargetPoolRetainedEntryCount; + DxPools.InstanceIdSets.MaxRetained = DirtyTargetPoolRetainedEntryCount; + PrimeDirtyTargetMessageTypeIndex(bus); + _ = DxPools.TrimAll(force: true); + try + { + Action markDirtyTarget = CreateDirtyTargetMarker(bus); + MarkDirtyTargets(markDirtyTarget, 0x2525_0000, targetCount); + _ = bus.Trim(force: false); + PoolDiagnosticsSnapshot afterOversizedTrim = DxPools.DescribeAll(); + + Assert.AreEqual( + 0, + afterOversizedTrim.InstanceIdLists.Cached, + "Oversized dirty-target warmup must drop its InstanceId list instead of caching it. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"listPool={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdLists)}." + ); + Assert.AreEqual( + 0, + afterOversizedTrim.InstanceIdSets.Cached, + "Oversized dirty-target warmup must drop its InstanceId set instead of caching it. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"setPool={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdSets)}." + ); + + MarkDirtyTargets(markDirtyTarget, 0x2526_0000, 1); + PoolDiagnosticsSnapshot afterFreshRent = DxPools.DescribeAll(); + + Assert.AreEqual( + afterOversizedTrim.InstanceIdLists.Hits, + afterFreshRent.InstanceIdLists.Hits, + "Renting after an oversized dirty-target drop must not report a pooled list hit. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdLists)}, " + + $"after={FormatPoolDiagnostics(afterFreshRent.InstanceIdLists)}." + ); + Assert.AreEqual( + afterOversizedTrim.InstanceIdSets.Hits, + afterFreshRent.InstanceIdSets.Hits, + "Renting after an oversized dirty-target drop must not report a pooled set hit. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdSets)}, " + + $"after={FormatPoolDiagnostics(afterFreshRent.InstanceIdSets)}." + ); + Assert.Greater( + afterFreshRent.InstanceIdLists.Misses, + afterOversizedTrim.InstanceIdLists.Misses, + "Renting after an oversized dirty-target drop must allocate a fresh list. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdLists)}, " + + $"after={FormatPoolDiagnostics(afterFreshRent.InstanceIdLists)}." + ); + Assert.Greater( + afterFreshRent.InstanceIdSets.Misses, + afterOversizedTrim.InstanceIdSets.Misses, + "Renting after an oversized dirty-target drop must allocate a fresh set. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterOversizedTrim.InstanceIdSets)}, " + + $"after={FormatPoolDiagnostics(afterFreshRent.InstanceIdSets)}." + ); + + _ = bus.Trim(force: false); + PoolDiagnosticsSnapshot afterSmallTrim = DxPools.DescribeAll(); + + Assert.Greater( + afterSmallTrim.InstanceIdLists.Cached, + 0, + "A small dirty-target cycle after an oversized drop must return its list to the pool. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"listPool={FormatPoolDiagnostics(afterSmallTrim.InstanceIdLists)}." + ); + Assert.Greater( + afterSmallTrim.InstanceIdSets.Cached, + 0, + "A small dirty-target cycle after an oversized drop must return its set to the pool. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"setPool={FormatPoolDiagnostics(afterSmallTrim.InstanceIdSets)}." + ); + + MarkDirtyTargets(markDirtyTarget, 0x2527_0000, 1); + PoolDiagnosticsSnapshot afterSmallReuse = DxPools.DescribeAll(); + + Assert.Greater( + afterSmallReuse.InstanceIdLists.Hits, + afterSmallTrim.InstanceIdLists.Hits, + "A small dirty-target cycle after an oversized drop must rent the recovered pooled list. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterSmallTrim.InstanceIdLists)}, " + + $"after={FormatPoolDiagnostics(afterSmallReuse.InstanceIdLists)}." + ); + Assert.Greater( + afterSmallReuse.InstanceIdSets.Hits, + afterSmallTrim.InstanceIdSets.Hits, + "A small dirty-target cycle after an oversized drop must rent the recovered pooled set. " + + $"targetCount={targetCount}, cap={DirtyTargetPoolRetainedEntryCount}, " + + $"before={FormatPoolDiagnostics(afterSmallTrim.InstanceIdSets)}, " + + $"after={FormatPoolDiagnostics(afterSmallReuse.InstanceIdSets)}." + ); + } + finally + { + DxPools.InstanceIdLists.UseLru = previousListLru; + DxPools.InstanceIdSets.UseLru = previousSetLru; + DxPools.InstanceIdLists.MaxRetained = previousListCap; + DxPools.InstanceIdSets.MaxRetained = previousSetCap; + } + } + finally + { + _ = bus.Trim(force: false); + _ = DxPools.TrimAll(force: true); + } + } + public static IEnumerable DiagnosticsOnScenariosIncludingWithoutContext { get @@ -863,6 +1199,63 @@ MessageRegistrationToken token } } + private static Action CreateDirtyTargetMarker(MessageBus bus) + { + MethodInfo method = typeof(MessageBus) + .GetMethod("MarkDirtyTarget", BindingFlags.Instance | BindingFlags.NonPublic) + .MakeGenericMethod(typeof(SimpleTargetedMessage)); + return (Action) + Delegate.CreateDelegate(typeof(Action), bus, method); + } + + private static void PrimeDirtyTargetMessageTypeIndex(MessageBus bus) + { + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + try + { + token.Enable(); + MessageRegistrationHandle handle = + ScenarioHarness.RegisterTargeted( + MessageScenario.Targeted(), + token, + StableTarget, + NoOpTargeted + ); + token.RemoveRegistration(handle); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + _ = bus.Trim(force: true); + } + } + + private static void MarkDirtyTargets( + Action markDirtyTarget, + int baseValue, + int targetCount + ) + { + for (int i = 0; i < targetCount; ++i) + { + markDirtyTarget(new InstanceId(baseValue + i)); + } + } + + private static string FormatPoolDiagnostics(CollectionPoolDiagnostics diagnostics) + { + return "Cached=" + + diagnostics.Cached + + ", Hits=" + + diagnostics.Hits + + ", Misses=" + + diagnostics.Misses + + ", Evictions=" + + diagnostics.Evictions; + } + private static MessageRegistrationHandle RegisterPostProcessor( MessageScenario scenario, MessageRegistrationToken token diff --git a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs index 1b10dd06..fa3c86ee 100644 --- a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs +++ b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs @@ -65,7 +65,46 @@ public void TrimAllUsesCurrentGlobalMessageBus() Assert.AreEqual(1, wrapper.TrimCallCount); Assert.IsTrue(wrapper.LastForce); - Assert.AreEqual(default(IMessageBus.TrimResult), result); + // The wrapped bus has no registrations, so its eviction-side fields are always zero. + // PooledCollectionsEvicted is intentionally NOT asserted: Trim(force: true) drains + // AppDomain-scoped static pools (DxPools / ContextHandlerByTargetDicts) shared with + // other test fixtures, so its value is non-deterministic across test orderings. + Assert.AreEqual( + 0, + result.TypeSlotsEvicted, + "TypeSlotsEvicted should be 0 on a fresh bus." + ); + Assert.AreEqual( + 0, + result.TargetSlotsEvicted, + "TargetSlotsEvicted should be 0 on a fresh bus." + ); + Assert.AreEqual( + 0, + result.LiveTypeSlotsRemaining, + "LiveTypeSlotsRemaining should be 0 on a fresh bus." + ); + } + + [Test] + public void TrimAllPropagatesInnerBusResultUnchanged() + { + IMessageBus.TrimResult sentinel = new IMessageBus.TrimResult(7, 11, 13, 17); + SentinelTrimMessageBus wrapper = new SentinelTrimMessageBus( + new GlobalMessageBus(), + sentinel + ); + MessageHandler.SetGlobalMessageBus(wrapper); + + IMessageBus.TrimResult result = MessageHandler.TrimAll(force: false); + + Assert.AreEqual( + sentinel, + result, + "MessageHandler.TrimAll must return the inner bus's TrimResult unchanged. expected={0}, actual={1}", + sentinel, + result + ); } [Test] @@ -257,5 +296,23 @@ public override IMessageBus.TrimResult Trim(bool force = false) return base.Trim(force); } } + + /// + /// Wrapper that returns a fixed sentinel so the test + /// can assert field-by-field propagation through + /// without depending on the real bus's pool/eviction state. + /// + private sealed class SentinelTrimMessageBus : WrapperMessageBus + { + private readonly IMessageBus.TrimResult _sentinel; + + public SentinelTrimMessageBus(IMessageBus inner, IMessageBus.TrimResult sentinel) + : base(inner) + { + _sentinel = sentinel; + } + + public override IMessageBus.TrimResult Trim(bool force = false) => _sentinel; + } } } diff --git a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs index 49ca0a8d..6fc3b3b9 100644 --- a/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs +++ b/Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs @@ -19,9 +19,21 @@ namespace DxMessaging.Tests.Runtime.MemoryReclaim public sealed class MemoryReclamationTests : MessagingTestBase { private const int DistinctTargetCount = 1024; + private const int RetainedPoolEntryCount = 64; private static readonly InstanceId HandlerOwner = new InstanceId(0x5A17_0001); private static readonly InstanceId DefaultContext = new InstanceId(0x5A17_0002); + public static IEnumerable ContextDictPoolScenarios + { + get + { + yield return MessageScenario.Targeted(); + yield return MessageScenario.Broadcast(); + yield return MessageScenario.Targeted().WithPostProcessor(true); + yield return MessageScenario.Broadcast().WithPostProcessor(true); + } + } + [Test] public void TrimEvictsEmptyTypeSlots( [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] @@ -265,6 +277,126 @@ MessageScenario scenario } } + [Test] + public void NonForceTrimDuringActiveContextDispatchKeepsDirtyTargetCandidate( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.KindsWithComponentTarget) + )] + MessageScenario scenario + ) + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + int calls = 0; + int inDispatchTargetEvictions = -1; + MessageRegistrationHandle handle = default; + handle = RegisterCountingFirst( + scenario, + token, + DefaultContext, + () => + { + calls++; + token.RemoveRegistration(handle); + // This trim runs INSIDE active dispatch and must short-circuit the dirty + // target eviction (HasActiveDispatchSnapshot guard). Capture the eviction + // count to assert the in-dispatch contract directly. + IMessageBus.TrimResult inDispatch = bus.Trim(force: false); + inDispatchTargetEvictions = inDispatch.TargetSlotsEvicted; + } + ); + + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual( + 1, + calls, + "[{0}] the self-removing handler must run exactly once.", + scenario.Kind + ); + Assert.AreEqual( + 0, + inDispatchTargetEvictions, + "[{0}] non-force trim called inside active dispatch must NOT evict the empty target slot it could otherwise reclaim.", + scenario.Kind + ); + + // Advance the bus tick so the dirty target candidate ages past the idle threshold + // (idleEvictionTicks=0 still requires _tickCounter strictly greater than the slot's + // lastTouchTicks). Without this probe the post-dispatch trim observes 0 elapsed + // ticks since deregister and skips eviction. EmitSweepProbe is the canonical pattern + // shared with TrimAfterDeregisterReclaimsHandlerCache and BusContextDictReturnsToPool. + EmitSweepProbe(bus); + IMessageBus.TrimResult afterDispatch = bus.Trim(force: false); + + Assert.GreaterOrEqual( + afterDispatch.TargetSlotsEvicted, + 1, + "[{0}] a non-force trim skipped during active dispatch must leave the dirty target candidate for the next trim. afterDispatch={1}", + scenario.Kind, + afterDispatch + ); + } + + /// + /// Pins the contract that idleEvictionTicks: 0 still requires at least one tick + /// advancement between the deregister-touch and a non-force trim. The strict + /// _tickCounter > lastTouchTicks comparison in IsIdleForSweep means a trim + /// called immediately after deregister observes 0 elapsed ticks and skips eviction; the + /// next trim, after a single tick-advancing emit, evicts the slot. This test exists so a + /// regression that flips the comparison to >= (or that omits the tick advance in + /// dispatch paths) breaks loudly. + /// + [Test] + public void NonForceTrimRequiresOneTickAdvancementWithZeroIdleBudget( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock(), idleEvictionTicks: 0); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst(scenario, token, DefaultContext); + + token.RemoveRegistration(handle); + IMessageBus.TrimResult immediate = bus.Trim(force: false); + Assert.AreEqual( + 0, + immediate.TypeSlotsEvicted, + "[{0}] non-force trim with no elapsed ticks must NOT evict a freshly-deregistered slot. immediate={1}", + scenario.Kind, + immediate + ); + Assert.AreEqual( + 0, + immediate.TargetSlotsEvicted, + "[{0}] non-force trim with no elapsed ticks must NOT evict a freshly-deregistered context slot. immediate={1}", + scenario.Kind, + immediate + ); + + EmitSweepProbe(bus); + IMessageBus.TrimResult afterProbe = bus.Trim(force: false); + int totalEvicted = afterProbe.TypeSlotsEvicted + afterProbe.TargetSlotsEvicted; + Assert.GreaterOrEqual( + totalEvicted, + 1, + "[{0}] one tick advancement must make the slot eligible for non-force trim. afterProbe={1}", + scenario.Kind, + afterProbe + ); + } + [Test] public void RuntimeSettingsHotReloadAppliesCaps() { @@ -275,13 +407,26 @@ public void RuntimeSettingsHotReloadAppliesCaps() { settings._bufferMaxDistinctEntries = 4; settings._bufferUseLruEviction = true; + settings._idleEvictionSeconds = 0f; overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); MessageBus bus = MessageBus.CreateForInternalUse(new FakeClock()); + using IDisposable cleanup = ForceTrimCleanup(bus); List pooled = DxPools.ObjectLists.Rent(); DxPools.ObjectLists.Return(pooled); Assert.Greater(DxPools.DescribeAll().ObjectLists.Cached, 0); + MessageRegistrationToken token = CreateEnabledToken(bus); + MessageRegistrationHandle handle = RegisterFirst( + MessageScenario.Targeted(), + token, + DefaultContext + ); + token.RemoveRegistration(handle); + EmitSweepProbe(bus); + _ = bus.Trim(force: false); + Assert.Greater(bus.GetContextDictPoolDiagnosticsForTesting().Cached, 0); + settings._bufferMaxDistinctEntries = 0; settings._bufferUseLruEviction = false; DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); @@ -289,6 +434,7 @@ public void RuntimeSettingsHotReloadAppliesCaps() Assert.AreEqual(0, DxPools.ObjectLists.MaxRetained); Assert.IsFalse(DxPools.ObjectLists.UseLru); Assert.AreEqual(0, DxPools.DescribeAll().ObjectLists.Cached); + Assert.AreEqual(0, bus.GetContextDictPoolDiagnosticsForTesting().Cached); GC.KeepAlive(bus); } finally @@ -458,6 +604,443 @@ MessageScenario scenario } } + [Test] + public void BusContextDictReturnsToPool( + [ValueSource(nameof(ContextDictPoolScenarios))] MessageScenario scenario + ) + { + // Pin the AppDomain-scoped ContextHandlerByTargetDicts pool's MaxRetained to a + // known >0 value for the duration of this test. A sibling test that drops it to + // 0 (e.g. RuntimeSettingsHotReloadAppliesCaps) and runs first under a randomized + // execution order would otherwise make the trim's Return path drop the dict on + // the floor instead of caching it, breaking the assertion below. Mirrors the + // pattern in TrimAfterDeregisterReclaimsHandlerCache. + DxMessagingRuntimeSettings settings = null; + IDisposable overrideToken = null; + Action firstDeregister = null; + Action secondDeregister = null; + try + { + MessageBus bus = CreatePoolRetainingBus( + new FakeClock(), + out settings, + out overrideToken + ); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + + // The bus's context-dict pool is AppDomain-scoped (shared across all MessageBus + // instances). Capture the baseline AFTER the registration's rent so the delta + // cleanly measures the trim's contribution alone, regardless of whatever entries + // prior fixtures left in the pool. (Capturing before the rent would produce a + // net-zero delta: the rent decrements Cached, the trim's return increments it back.) + firstDeregister = RegisterDirect(scenario, handler, bus, DefaultContext, () => { }); + CollectionPoolDiagnostics afterFirstRent = + bus.GetContextDictPoolDiagnosticsForTesting(); + firstDeregister(); + firstDeregister = null; + EmitSweepProbe(bus); + + IMessageBus.TrimResult firstTrim = bus.Trim(force: false); + CollectionPoolDiagnostics afterReturn = + bus.GetContextDictPoolDiagnosticsForTesting(); + + Assert.GreaterOrEqual( + firstTrim.TargetSlotsEvicted, + 1, + "[{0}] trim must reclaim the empty context dictionary slot. firstTrim={1}", + scenario.Kind, + firstTrim + ); + Assert.Greater( + afterReturn.Cached, + afterFirstRent.Cached, + "[{0}] trim must return the bus context dictionary to the pool. afterFirstRent.Cached={1}, afterReturn.Cached={2}", + scenario.Kind, + afterFirstRent.Cached, + afterReturn.Cached + ); + + secondDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => { } + ); + CollectionPoolDiagnostics afterRent = bus.GetContextDictPoolDiagnosticsForTesting(); + + Assert.Greater( + afterRent.Hits, + afterReturn.Hits, + "[{0}] the next context registration must reuse a pooled dictionary. afterReturn.Hits={1}, afterRent.Hits={2}", + scenario.Kind, + afterReturn.Hits, + afterRent.Hits + ); + + secondDeregister(); + secondDeregister = null; + } + finally + { + firstDeregister?.Invoke(); + secondDeregister?.Invoke(); + overrideToken?.Dispose(); + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + } + + [Test] + public void StaleBusContextDeregisterAfterPooledDictionaryReuseDoesNotRemoveReplacement( + [ValueSource(nameof(ContextDictPoolScenarios))] MessageScenario scenario + ) + { + DxMessagingRuntimeSettings settings = null; + IDisposable overrideToken = null; + MessageBus bus = CreatePoolRetainingBus( + new FakeClock(), + out settings, + out overrideToken + ); + int staleCalls = 0; + int currentCalls = 0; + List logs = new List(); + Action previousLogFunction = MessagingDebug.LogFunction; + bool previousMessagingDebugEnabled = MessagingDebug.enabled; + Action currentDeregister = null; + + using IDisposable cleanup = ForceTrimCleanup(bus); + MessageHandler handler = CreateActiveHandler(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots( + bus, + label: scenario.DisplayName + ); + try + { + Action staleDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => staleCalls++ + ); + EmitFirst(scenario, bus, DefaultContext); + staleDeregister(); + EmitSweepProbe(bus); + + IMessageBus.TrimResult trimResult = bus.Trim(force: false); + CollectionPoolDiagnostics afterReturn = + bus.GetContextDictPoolDiagnosticsForTesting(); + + Assert.GreaterOrEqual( + trimResult.TargetSlotsEvicted, + 1, + "[{0}] trim must reclaim the stale context slot before replacement.", + scenario.Kind + ); + Assert.Greater( + afterReturn.Cached, + 0, + "[{0}] trim must return the bus context dictionary to the private pool.", + scenario.Kind + ); + + currentDeregister = RegisterDirect( + scenario, + handler, + bus, + DefaultContext, + () => currentCalls++ + ); + CollectionPoolDiagnostics afterRent = bus.GetContextDictPoolDiagnosticsForTesting(); + + Assert.Greater( + afterRent.Hits, + afterReturn.Hits, + "[{0}] replacement registration must reuse the pooled context dictionary.", + scenario.Kind + ); + + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (_, message) => logs.Add(message); + staleDeregister(); + EmitFirst(scenario, bus, DefaultContext); + + Assert.AreEqual( + 1, + staleCalls, + "[{0}] stale deregistration must not revive the old registration.", + scenario.Kind + ); + Assert.AreEqual( + 1, + currentCalls, + "[{0}] stale deregistration must not remove the replacement registration.", + scenario.Kind + ); + Assert.AreEqual( + 0, + logs.Count, + "[{0}] stale deregistration after pooled dictionary reuse must not log diagnostics.", + scenario.Kind + ); + + currentDeregister(); + currentDeregister = null; + _ = bus.Trim(force: true); + } + finally + { + MessagingDebug.enabled = previousMessagingDebugEnabled; + MessagingDebug.LogFunction = previousLogFunction; + currentDeregister?.Invoke(); + _ = bus.Trim(force: true); + overrideToken?.Dispose(); + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + } + + [Test] + public void OversizedDirtyTargetAndContextPoolsDropHighWaterCollections() + { + DxMessagingRuntimeSettings settings = null; + IDisposable overrideToken = null; + MessageBus bus = CreatePoolRetainingBus( + new FakeClock(), + out settings, + out overrideToken + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots(bus); + MessageHandler handler = CreateActiveHandler(bus); + List deregistrations = new List(RetainedPoolEntryCount + 1); + Action smallDeregister = null; + try + { + _ = DxPools.TrimAll(force: true); + MessageBus.ResetStaticPools(); + + for (int i = 0; i <= RetainedPoolEntryCount; ++i) + { + InstanceId target = new InstanceId(0x5A18_0000 + i); + deregistrations.Add( + RegisterDirect(MessageScenario.Targeted(), handler, bus, target, () => { }) + ); + } + + foreach (Action deregister in deregistrations) + { + deregister(); + } + deregistrations.Clear(); + + EmitSweepProbe(bus); + IMessageBus.TrimResult oversizedTrim = bus.Trim(force: false); + PoolDiagnosticsSnapshot oversizedPools = DxPools.DescribeAll(); + CollectionPoolDiagnostics oversizedContextPool = + bus.GetContextDictPoolDiagnosticsForTesting(); + + Assert.GreaterOrEqual( + oversizedTrim.TargetSlotsEvicted, + RetainedPoolEntryCount + 1, + "Trim must reclaim every oversized target slot before evaluating pool retention." + ); + Assert.AreEqual( + 0, + oversizedPools.InstanceIdLists.Cached, + "Dirty-target lists that exceeded the pool cap must be dropped instead of cached." + ); + Assert.AreEqual( + 0, + oversizedPools.InstanceIdSets.Cached, + "Dirty-target sets that exceeded the pool cap must be dropped instead of cached." + ); + Assert.AreEqual( + 0, + oversizedContextPool.Cached, + "Bus context dictionaries that exceeded the pool cap must be dropped instead of cached." + ); + + smallDeregister = RegisterDirect( + MessageScenario.Targeted(), + handler, + bus, + DefaultContext, + () => { } + ); + smallDeregister(); + smallDeregister = null; + EmitSweepProbe(bus); + _ = bus.Trim(force: false); + + PoolDiagnosticsSnapshot smallPools = DxPools.DescribeAll(); + CollectionPoolDiagnostics smallContextPool = + bus.GetContextDictPoolDiagnosticsForTesting(); + Assert.Greater( + smallPools.InstanceIdLists.Cached, + 0, + "Small dirty-target lists should still return to the pool." + ); + Assert.Greater( + smallPools.InstanceIdSets.Cached, + 0, + "Small dirty-target sets should still return to the pool." + ); + Assert.Greater( + smallContextPool.Cached, + 0, + "Small bus context dictionaries should still return to the private pool." + ); + } + finally + { + smallDeregister?.Invoke(); + foreach (Action deregistration in deregistrations) + { + deregistration(); + } + _ = bus.Trim(force: true); + overrideToken?.Dispose(); + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + } + + [Test] + public void StaticResetDrainsBusContextDictionaryPool() + { + DxMessagingRuntimeSettings settings = null; + IDisposable overrideToken = null; + MessageBus bus = CreatePoolRetainingBus( + new FakeClock(), + out settings, + out overrideToken + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots(bus); + Action deregister = null; + try + { + MessageBus.ResetStaticPools(); + MessageHandler handler = CreateActiveHandler(bus); + deregister = RegisterDirect( + MessageScenario.Targeted(), + handler, + bus, + DefaultContext, + () => { } + ); + deregister(); + deregister = null; + EmitSweepProbe(bus); + _ = bus.Trim(force: false); + + Assert.Greater( + bus.GetContextDictPoolDiagnosticsForTesting().Cached, + 0, + "Setup must return at least one bus context dictionary to the static pool." + ); + + DxMessagingStaticState.Reset(); + + Assert.AreEqual( + 0, + bus.GetContextDictPoolDiagnosticsForTesting().Cached, + "DxMessagingStaticState.Reset must drain the bus-owned static context dictionary pool." + ); + } + finally + { + deregister?.Invoke(); + _ = bus.Trim(force: true); + overrideToken?.Dispose(); + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + } + + [Test] + public void OversizedContextPoolDropsAfterPartialReclaim() + { + DxMessagingRuntimeSettings settings = null; + IDisposable overrideToken = null; + MessageBus bus = CreatePoolRetainingBus( + new FakeClock(), + out settings, + out overrideToken + ); + using IDisposable cleanup = ForceTrimCleanup(bus); + using LeakWatcher watcher = LeakWatcher.WatchWithSlots(bus); + MessageHandler handler = CreateActiveHandler(bus); + List deregistrations = new List(RetainedPoolEntryCount + 1); + try + { + MessageBus.ResetStaticPools(); + + for (int i = 0; i <= RetainedPoolEntryCount; ++i) + { + InstanceId target = new InstanceId(0x5A19_0000 + i); + deregistrations.Add( + RegisterDirect(MessageScenario.Targeted(), handler, bus, target, () => { }) + ); + } + + for (int i = 0; i < RetainedPoolEntryCount; ++i) + { + deregistrations[i](); + deregistrations[i] = null; + } + + EmitSweepProbe(bus); + _ = bus.Trim(force: false); + + Assert.AreEqual( + 0, + bus.GetContextDictPoolDiagnosticsForTesting().Cached, + "The oversized context dictionary must remain live while one target is still registered." + ); + + deregistrations[RetainedPoolEntryCount](); + deregistrations[RetainedPoolEntryCount] = null; + EmitSweepProbe(bus); + _ = bus.Trim(force: false); + + Assert.AreEqual( + 0, + bus.GetContextDictPoolDiagnosticsForTesting().Cached, + "The oversized context dictionary must be dropped even when it empties across multiple sweeps." + ); + } + finally + { + foreach (Action deregistration in deregistrations) + { + deregistration?.Invoke(); + } + _ = bus.Trim(force: true); + overrideToken?.Dispose(); + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + } + } + [Test] public void ResetGenerationBumpInvalidatesPostEvictDeregister( [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] @@ -925,6 +1508,26 @@ private static MessageHandler CreateActiveHandler(MessageBus bus) return new MessageHandler(HandlerOwner, bus) { active = true }; } + private static MessageBus CreatePoolRetainingBus( + IDxMessagingClock clock, + out DxMessagingRuntimeSettings settings, + out IDisposable overrideToken + ) + { + settings = ScriptableObject.CreateInstance(); + settings._bufferMaxDistinctEntries = RetainedPoolEntryCount; + settings._bufferUseLruEviction = true; + settings._idleEvictionSeconds = 0f; + settings._evictionTickIntervalSeconds = 0f; + settings._enableTrimApi = true; + settings._evictionEnabled = true; + overrideToken = DxMessagingRuntimeSettingsProvider.Override(settings); + MessageBus bus = MessageBus.CreateForInternalUse(clock, idleEvictionTicks: 0); + DxPools.Configure(settings); + DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); + return bus; + } + private static IDisposable ForceTrimCleanup(MessageBus bus) { return new CleanupScope(() => diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index c2df2d78..a3f7247e 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 17,247,491 | No | -| UniRx MessageBroker | 18,318,652 | No | -| MessagePipe (Global) | 97,657,508 | No | -| Zenject SignalBus | 2,449,497 | Yes | +| DxMessaging (Untargeted) - No-Copy | 17,006,569 | No | +| UniRx MessageBroker | 16,882,243 | No | +| MessagePipe (Global) | 97,768,988 | No | +| Zenject SignalBus | 2,129,280 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index f7fe28a2..f3c57c8a 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -83,9 +83,34 @@ For each commit and configuration: CSV rows identify the runtime commit under test. `DX_PERF_COMMIT` overrides CI's `GITHUB_SHA` when both are present. - Run the PlayMode `PerfBench` category in batchmode. -- Append the structured output to `progress/perf-baseline-2026-05-05.csv`. +- Extract the benchmark rows from the Unity output and append them to + `progress/perf-baseline-2026-05-05.csv`. - Record the exact commit, platform, Unity version, and scripting backend. +The benchmark harness writes both structured log lines and CSV-shaped rows. +Prefer the extractor so the baseline file has one header and normalized rows: + +```bash +DX_PERF_COMMIT=25a4dcc \ +bash scripts/unity/run-tests.sh \ + --platform playmode \ + --include-perf \ + --filter 'DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*' \ + --results .artifacts/unity/perf-25a4dcc-editor-mono.xml \ + 2>&1 | tee .artifacts/unity/perf-25a4dcc-editor-mono.log + +node scripts/unity/extract-perf-baseline.js \ + --input .artifacts/unity/perf-25a4dcc-editor-mono.log \ + --input .artifacts/unity/perf-25a4dcc-editor-mono.xml \ + --output progress/perf-baseline-2026-05-05.csv \ + --append +``` + +For the first cell, omit `--append` so the script creates the file with the +CSV header. For later cells, use `--append`. Repeat the run for `29a5338` and +`HEAD`, changing both `DX_PERF_COMMIT` and artifact filenames so each cell is +traceable. + Do not mix methodology changes with baseline updates. If the harness changes, capture a new baseline and make the old/new methodology boundary explicit in the PR description. @@ -192,17 +217,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,387,729 | Yes | -| DxMessaging (GameObject) - Normal | 10,069,781 | No | -| DxMessaging (Component) - Normal | 9,958,399 | No | -| DxMessaging (GameObject) - No-Copy | 11,369,437 | No | -| DxMessaging (Component) - No-Copy | 8,576,809 | No | -| DxMessaging (Untargeted) - No-Copy | 17,393,604 | No | -| DxMessaging (Untargeted) - Interceptors | 7,055,588 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,534,681 | No | -| Reflexive (One Argument) | 2,749,645 | No | -| Reflexive (Two Arguments) | 2,311,295 | No | -| Reflexive (Three Arguments) | 2,300,900 | No | +| Unity | 2,578,664 | Yes | +| DxMessaging (GameObject) - Normal | 9,922,135 | No | +| DxMessaging (Component) - Normal | 9,917,199 | No | +| DxMessaging (GameObject) - No-Copy | 11,101,203 | No | +| DxMessaging (Component) - No-Copy | 8,525,796 | No | +| DxMessaging (Untargeted) - No-Copy | 17,070,253 | No | +| DxMessaging (Untargeted) - Interceptors | 7,069,814 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,845,186 | No | +| Reflexive (One Argument) | 2,771,579 | No | +| Reflexive (Two Arguments) | 2,260,609 | No | +| Reflexive (Three Arguments) | 2,186,370 | No | ## macOS diff --git a/scripts/__tests__/extract-perf-baseline.test.js b/scripts/__tests__/extract-perf-baseline.test.js new file mode 100644 index 00000000..d57e5061 --- /dev/null +++ b/scripts/__tests__/extract-perf-baseline.test.js @@ -0,0 +1,73 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const childProcess = require("child_process"); + +const { CSV_HEADER, buildCsv, extractRows } = require("../unity/extract-perf-baseline.js"); + +const SCRIPT = path.resolve(__dirname, "..", "unity", "extract-perf-baseline.js"); + +describe("extract-perf-baseline", () => { + test("extracts CSV rows from Unity output and preserves quoted platform fields", () => { + const content = [ + "Noise before results", + 'UntargetedFlood_OneHandler,"Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1)",25a4dcc,-1,25000000.125,0,1000.000', + "Noise after results" + ].join("\n"); + + const rows = extractRows(content); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + scenario: "UntargetedFlood_OneHandler", + platform: "Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1)", + commit: "25a4dcc", + runIndex: "-1", + emitsPerSecond: "25000000.125", + allocatedBytesDelta: "0", + wallClockMs: "1000.000" + }); + }); + + test("extracts structured Debug.Log rows from prefixed Unity log lines", () => { + const content = + '[TestRunner] {scenario:"BroadcastFlood_OneHandler", platform:"Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1)", commit:"HEAD", runIndex:-1, emitsPerSec:17000000.5, allocatedBytesDelta:0, wallClockMs:1000.25}'; + + expect(buildCsv(extractRows(content))).toBe( + [ + CSV_HEADER, + "BroadcastFlood_OneHandler,Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1),HEAD,-1,17000000.500,0,1000.250", + "" + ].join("\n") + ); + }); + + test("appends rows to an existing baseline without duplicating the header", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxm-perf-")); + const inputPath = path.join(tempDir, "unity.log"); + const outputPath = path.join(tempDir, "perf-baseline.csv"); + fs.writeFileSync(`${outputPath}`, `${CSV_HEADER}\n`, "utf8"); + fs.writeFileSync( + inputPath, + 'TargetedFlood_OneListener,"Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1)",29a5338,-1,18000000.000,0,1000.000\n', + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [SCRIPT, "--input", inputPath, "--output", outputPath, "--append"], + { encoding: "utf8" } + ); + + expect(result.status).toBe(0); + expect(fs.readFileSync(outputPath, "utf8")).toBe( + [ + CSV_HEADER, + "TargetedFlood_OneListener,Editor Mono x64 Development (LinuxEditor; Unity 2022.3.45f1),29a5338,-1,18000000.000,0,1000.000", + "" + ].join("\n") + ); + }); +}); diff --git a/scripts/__tests__/extract-perf-baseline.test.js.meta b/scripts/__tests__/extract-perf-baseline.test.js.meta new file mode 100644 index 00000000..8916614e --- /dev/null +++ b/scripts/__tests__/extract-perf-baseline.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d0186ca897654e65a03eb29d3936c1f6 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/extract-perf-baseline.js b/scripts/unity/extract-perf-baseline.js new file mode 100644 index 00000000..8ff04f98 --- /dev/null +++ b/scripts/unity/extract-perf-baseline.js @@ -0,0 +1,346 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const CSV_HEADER = + "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs"; + +const SCENARIOS = new Set([ + "UntargetedFlood_OneHandler", + "UntargetedFlood_FourHandlers_OnePriority", + "UntargetedFlood_FourHandlers_FourPriorities", + "TargetedFlood_OneListener", + "TargetedFlood_SixteenListeners", + "BroadcastFlood_OneHandler", + "InterceptorHeavy_FourInterceptors", + "PostProcessingHeavy_FourPostProcessors", + "RegistrationFlood_1000Types_FromColdBus" +]); + +function usage() { + return `Usage: node scripts/unity/extract-perf-baseline.js --input [--input ...] [--output ] [--append|--replace] + +Extracts DispatchThroughputBenchmarks CSV rows from Unity logs or NUnit XML output. +When --output is omitted, writes the normalized CSV to stdout. +`; +} + +function parseArgs(argv) { + const options = { + inputs: [], + output: "", + append: false, + replace: false + }; + + for (let index = 2; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--input") { + options.inputs.push(requireValue(argv, ++index, arg)); + continue; + } + + if (arg === "--output") { + options.output = requireValue(argv, ++index, arg); + continue; + } + + if (arg === "--append") { + options.append = true; + continue; + } + + if (arg === "--replace") { + options.replace = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (options.append && options.replace) { + throw new Error("--append and --replace cannot be used together."); + } + + return options; +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + + return value; +} + +function readInputs(inputs) { + if (inputs.length === 0) { + throw new Error("At least one --input path is required."); + } + + return inputs + .map((inputPath) => { + if (!fs.existsSync(inputPath)) { + throw new Error(`Input file not found: ${inputPath}`); + } + + return fs.readFileSync(inputPath, "utf8"); + }) + .join("\n"); +} + +function extractRows(content) { + const rows = []; + const seen = new Set(); + for (const line of content.split(/\r?\n/)) { + const row = parseStructuredLogFromLine(line) || parseCsvRowFromLine(line); + if (!row) { + continue; + } + + const csv = toCsvRow(row); + if (!seen.has(csv)) { + rows.push(row); + seen.add(csv); + } + } + + return rows; +} + +function parseCsvRowFromLine(line) { + const trimmed = stripUnityPrefix(line.trim()); + if (!trimmed || trimmed.startsWith("scenario,")) { + return null; + } + + const fields = parseCsvFields(trimmed); + if (fields.length !== 7 || !SCENARIOS.has(fields[0])) { + return null; + } + + return normalizeRow({ + scenario: fields[0], + platform: fields[1], + commit: fields[2], + runIndex: fields[3], + emitsPerSecond: fields[4], + allocatedBytesDelta: fields[5], + wallClockMs: fields[6] + }); +} + +function parseStructuredLogFromLine(line) { + const trimmed = stripUnityPrefix(line.trim()); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + + const row = { + scenario: matchStructuredString(trimmed, "scenario"), + platform: matchStructuredString(trimmed, "platform"), + commit: matchStructuredString(trimmed, "commit"), + runIndex: matchStructuredNumber(trimmed, "runIndex"), + emitsPerSecond: matchStructuredNumber(trimmed, "emitsPerSec"), + allocatedBytesDelta: matchStructuredNumber(trimmed, "allocatedBytesDelta"), + wallClockMs: matchStructuredNumber(trimmed, "wallClockMs") + }; + + if (!SCENARIOS.has(row.scenario)) { + return null; + } + + return normalizeRow(row); +} + +function stripUnityPrefix(line) { + const structuredStart = line.indexOf("{scenario:"); + if (structuredStart >= 0) { + return line.slice(structuredStart); + } + + const csvStart = findScenarioIndex(line); + if (csvStart >= 0) { + return line.slice(csvStart); + } + + return line; +} + +function findScenarioIndex(line) { + let bestIndex = -1; + for (const scenario of SCENARIOS) { + const index = line.indexOf(scenario); + if (index >= 0 && (bestIndex < 0 || index < bestIndex)) { + bestIndex = index; + } + } + + return bestIndex; +} + +function parseCsvFields(line) { + const fields = []; + let current = ""; + let inQuotes = false; + + for (let index = 0; index < line.length; index++) { + const value = line[index]; + if (value === '"') { + if (inQuotes && line[index + 1] === '"') { + current += '"'; + index++; + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (value === "," && !inQuotes) { + fields.push(current); + current = ""; + continue; + } + + current += value; + } + + fields.push(current); + return fields; +} + +function matchStructuredString(line, name) { + const match = new RegExp(`${name}:"([^"]*)"`).exec(line); + return match ? match[1] : ""; +} + +function matchStructuredNumber(line, name) { + const match = new RegExp(`${name}:([-+]?\\d+(?:\\.\\d+)?)`).exec(line); + return match ? match[1] : ""; +} + +function normalizeRow(row) { + return { + scenario: requireText(row.scenario, "scenario"), + platform: requireText(row.platform, "platform"), + commit: requireText(row.commit, "commit"), + runIndex: normalizeInteger(row.runIndex, "runIndex"), + emitsPerSecond: normalizeDecimal(row.emitsPerSecond, "emitsPerSecond"), + allocatedBytesDelta: normalizeInteger(row.allocatedBytesDelta, "allocatedBytesDelta"), + wallClockMs: normalizeDecimal(row.wallClockMs, "wallClockMs") + }; +} + +function requireText(value, name) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Invalid ${name}: expected a non-empty string.`); + } + + return value; +} + +function normalizeInteger(value, name) { + if (!/^-?\d+$/.test(String(value))) { + throw new Error(`Invalid ${name}: ${value}`); + } + + return String(Number.parseInt(value, 10)); +} + +function normalizeDecimal(value, name) { + const parsed = Number.parseFloat(String(value)); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${name}: ${value}`); + } + + return parsed.toFixed(3); +} + +function buildCsv(rows) { + return [CSV_HEADER, ...rows.map(toCsvRow)].join("\n") + "\n"; +} + +function toCsvRow(row) { + return [ + escapeCsv(row.scenario), + escapeCsv(row.platform), + escapeCsv(row.commit), + row.runIndex, + row.emitsPerSecond, + row.allocatedBytesDelta, + row.wallClockMs + ].join(","); +} + +function escapeCsv(value) { + if (!/[",\r\n]/.test(value)) { + return value; + } + + return `"${value.replace(/"/g, '""')}"`; +} + +function writeRows(outputPath, rows, options) { + if (!outputPath) { + process.stdout.write(buildCsv(rows)); + return; + } + + const outputExists = fs.existsSync(outputPath); + if (outputExists && !options.append && !options.replace) { + throw new Error(`Output already exists: ${outputPath}. Use --append or --replace.`); + } + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + if (options.append && outputExists) { + const existing = fs.readFileSync(outputPath, "utf8"); + const prefix = existing.endsWith("\n") || existing.length === 0 ? "" : "\n"; + const body = rows.map(toCsvRow).join("\n"); + fs.appendFileSync(outputPath, `${prefix}${body}\n`, "utf8"); + return; + } + + fs.writeFileSync(outputPath, buildCsv(rows), "utf8"); +} + +function main(argv = process.argv) { + const options = parseArgs(argv); + if (options.help) { + process.stdout.write(usage()); + return 0; + } + + const rows = extractRows(readInputs(options.inputs)); + if (rows.length === 0) { + throw new Error("No DispatchThroughputBenchmarks rows found."); + } + + writeRows(options.output, rows, options); + process.stderr.write(`Extracted ${rows.length} performance baseline row(s).\n`); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error.message}\n\n${usage()}`); + process.exitCode = 1; + } +} + +module.exports = { + CSV_HEADER, + extractRows, + buildCsv, + parseArgs +}; diff --git a/scripts/unity/extract-perf-baseline.js.meta b/scripts/unity/extract-perf-baseline.js.meta new file mode 100644 index 00000000..4ca7b866 --- /dev/null +++ b/scripts/unity/extract-perf-baseline.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4fe1d2b43c624d47a15b8077fcb85b6a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From bf448fe84872022343260cb636409a9d3831bdec Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 09:54:04 -0700 Subject: [PATCH 07/16] Remove PLAN mentions --- .github/pull_request_template.md | 10 +- .github/workflows/perf-numbers-check.yml | 14 ++- CHANGELOG.md | 8 -- .../DxMessagingRuntimeSettings.cs | 9 +- .../Core/Internal/TypedDispatchLinkIndex.cs | 2 +- Runtime/Core/Internal/TypedGlobalSlotIndex.cs | 2 +- Runtime/Core/Internal/TypedSlotIndex.cs | 2 +- Runtime/Core/Internal/TypedSlots.cs | 114 ++++++++---------- Runtime/Core/MessageBus/Internal/BusSlots.cs | 79 +++++------- .../MessageBus/Internal/IEvictableSlot.cs | 11 +- Runtime/Core/MessageBus/MessageBus.cs | 21 ++-- Runtime/Core/MessageHandler.cs | 8 +- .../Benchmarks/PerfRegressionSmokeTests.cs | 55 +++++++-- .../Contract/BusGlobalSlotLiveCountTests.cs | 2 +- .../Editor/Contract/CounterBasedTouchTests.cs | 9 +- .../RegistrationMethodAxisCoverageTests.cs | 4 +- .../Contract/TypedSlotIndexCoverageTests.cs | 20 ++- Tests/Editor/Contract/TypedSlotShapeTests.cs | 74 +++++------- docs/architecture/performance.md | 93 +++++++------- 19 files changed, 260 insertions(+), 277 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5e95d83c..58cf6b4f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -40,9 +40,9 @@ REQUIRED if this PR modifies hot-path files: The perf-numbers-check workflow gates on this section's presence. Format: - Scenario | Baseline (commit 25a4dcc) | This PR | Delta - --- | --- | --- | --- - UntargetedFlood_OneHandler (Mono Editor) | X.XX M emits/sec | Y.YY M emits/sec | +Z.Z% + | Scenario | Baseline run | This PR | Delta | + | --- | --- | --- | --- | + | UntargetedFlood_OneHandler (Mono Editor) | X.XX M emits/sec | Y.YY M emits/sec | +Z.Z% | ... Acceptable substitutions: @@ -51,6 +51,6 @@ Acceptable substitutions: - "N/A - non-hot-path edit only " to describe what was actually touched, e.g. settings asset shape or editor tooling. -See .llm/skills/performance/dispatch-hot-path.md for the budget and the -T0 benchmark harness invocation. +See docs/architecture/performance.md for the budget and dispatch throughput +benchmark invocation. --> diff --git a/.github/workflows/perf-numbers-check.yml b/.github/workflows/perf-numbers-check.yml index b69519fb..b24917f9 100644 --- a/.github/workflows/perf-numbers-check.yml +++ b/.github/workflows/perf-numbers-check.yml @@ -7,7 +7,6 @@ on: - "Runtime/Core/MessageBus/MessageBus.cs" - "Runtime/Core/MessageHandler.cs" - "Runtime/Core/Pooling/**" - - ".github/workflows/perf-numbers-check.yml" workflow_dispatch: concurrency: @@ -88,18 +87,21 @@ jobs: const expectedHeaders = [ "scenario", - "baseline (commit 25a4dcc)", + "baseline run", "this pr", "delta", ]; function splitMarkdownRow(line) { - if (!line.startsWith("|") || !line.endsWith("|")) { + if (!line.includes("|")) { return null; } - return line - .slice(1, -1) + const trimmed = line + .replace(/^\|/, "") + .replace(/\|$/, ""); + + return trimmed .split("|") .map((cell) => cell.trim()); } @@ -154,7 +156,7 @@ jobs: ) { core.setFailed( "Performance numbers tables must use columns: " + - "Scenario | Baseline (commit 25a4dcc) | This PR | Delta, " + + "| Scenario | Baseline run | This PR | Delta |, " + "plus at least one data row." ); return; diff --git a/CHANGELOG.md b/CHANGELOG.md index ae04193f..0fe35733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,26 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Behavioural gap closures: `HandlerExceptionTests`, `ReentrantEmissionTests`, `NullAndInvalidInputTests`, `SingleThreadContractTests` pinning exception-in-handler, re-entrancy, null-input, and threading contracts. - `AllocationMatrixTests` covering zero-GC dispatch across kinds, interceptors, post-processors, diagnostics, and priority-based dispatch. - Expanded coverage now pins source-generator and analyzer behaviour that users rely on: generic / record struct / nested partial / nullable annotation cases for `DxMessageIdGenerator`; `[DxOptionalParameter]` permutations and DXMSG005 boundary cases for `DxAutoConstructorGenerator`; positive opt-out cases for `DxIgnoreMissingBaseCallAttribute`. No runtime API change. -- `[Category("Stress")]`, `[Category("Performance")]`, and `[Category("Allocation")]` tagging across the suite to enable filtered runs and a default-suite speed budget under 60 seconds. -- `SuiteSpeedBudgetTest` as a default-suite speed guard rail. -- `TestAttributeContractTests` extensions enforcing kind-parameterization and allocation coverage discipline. - Three new public read-only registration counters on `IMessageBus`: `RegisteredInterceptors`, `RegisteredPostProcessors`, and `RegisteredGlobalAcceptAll`. Lets diagnostic and leak-check tooling distinguish interceptor / post-processor / global accept-all leaks from regular handler leaks, and lets external monitors aggregate the bus's registration footprint without reflecting on internals. `MessageBus` aggregates the counters on each read by walking the per-message-type caches; consumers polling these properties in tight loops should snapshot at region boundaries. - Runtime memory-reclamation foundations: `DxMessagingRuntimeSettings` loads from `Resources/DxMessagingRuntimeSettings` and hot-reloads eviction cadence, enablement, trim opt-out, and pool-cap changes without recreating the bus. Pooled internal collections and typed/bus slot registries preserve existing dispatch APIs while making empty handler and interceptor slots reclaimable. `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` reset dirty empty slots and trim shared pools on demand, `OccupiedTypeSlots` / `OccupiedTargetSlots` expose the retained bus and dirty typed-handler slot footprint for diagnostics, and idle sweeps run from emits and Unity's PlayerLoop. - New explicit-factory registration helpers across all three DI integrations: `VContainerRegistrationExtensions.RegisterDxMessagingBus`, `ReflexRegistrationExtensions.AddDxMessagingBus`, and `ZenjectRegistrationExtensions.BindDxMessagingBus`. Each helper exposes the bus under both the concrete `MessageBus` contract and the `IMessageBus` interface, accepts an overloadable lifetime where the container supports it, accepts a user-supplied `Func` factory, and accepts an `IDxMessagingClock` overload that constructs the bus through the new internal-only `MessageBus.CreateForInternalUse` factory so test-side clocks (for example `FakeClock`) can be injected through the container. The VContainer helper registers both contracts in one registration call, avoiding VContainer environments where chained `.AsSelf().As()` drops the concrete contract and fails with `No such registration of type: DxMessaging.Core.MessageBus.MessageBus`; the DI samples either call the helper directly or document the corresponding helper preference for their container shape. ### Changed -- `MessageBus`'s clock-taking constructors are no longer publicly visible. The four prior `internal MessageBus(IDxMessagingClock, ...)` overloads collapse into a single `internal static MessageBus CreateForInternalUse(IDxMessagingClock clock, long? idleEvictionTicks = null, double? evictionTickIntervalSeconds = null, bool? idleEvictionEnabled = null, bool? trimApiEnabled = null)` factory; the constructor surface now exposes only the public parameterless `MessageBus()`. This stops reflection-driven DI containers from latching onto a clock-taking overload they cannot satisfy via `InternalsVisibleTo`, and makes the public API contract obvious. Internal callers in the test suite (`AllocationMatrixTests`, `EvictionSweepContractTests`, `MemoryReclamationTests`, `DiagnosticsTests`) were migrated to the factory. - Mutation tests now exercise every messaging kind (Untargeted/Targeted/Broadcast) via a single parameterized fixture (`[ValueSource(MessageScenarios.AllKinds)]`) across `MutationDuringEmissionTests`, `MutationInterceptorTests`, and `MutationDestructionTests`. Users get tighter cross-kind parity guarantees; no runtime API change. (~720 lines of duplication removed; test count preserved.) -- `MessagingTestBase` now reseeds a deterministic `Random` with a logged seed (env var `DXMESSAGING_TEST_SEED` to override), polls a generous 1.5-second timeout for handler cleanup, drains the prior test's deferred destroy queue before resetting `DxMessagingStaticState` in `[UnitySetUp]`, and asserts the bus returns to a fresh state at the end of `[UnityTearDown]`. - Renamed `UntargetedTests`, `TargetedTests`, `BroadcastTests` to `EmitUntargetedSpecificTests`, `EmitTargetedSpecificTests`, `EmitBroadcastSpecificTests` to clarify that kind-common tests live in `EmitTests` and kind-specific tests live in the renamed files. (Test-suite hardening is test-only; no `Runtime/` behavior was modified.) - Documentation now warns up front that `MessageAwareComponent` subclasses must call `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, and `base.RegisterMessageHandlers()` from any override; admonitions added to the Quick Start, Getting Started Guide, Visual Guide, README, FAQ, and Troubleshooting pages all link to [`DXMSG006`](docs/reference/analyzers.md#dxmsg006-missing-base-call) (issue #195). ### Fixed -- Emit-time idle eviction now samples the wall clock only at the sweep-gate cadence instead of on every message emit, with sealed inline clock getters on the default clocks, reducing dispatch hot-path clock overhead while preserving the extra timestamp read only when a sweep actually runs. -- Forced and idle trims now release empty typed-handler outer wrappers and compact dirty-handler tracking, preventing long-running buses from retaining every message type a handler once registered after those registrations are removed. -- Empty targeted and sourced-broadcast registrations now recycle their dirty-target tracking collections and bus context dictionaries during trim/reset, reducing retained memory and repeated allocation churn on long-running buses that register many distinct targets or sources. - Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. - Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. - This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs index 203c127c..21236d49 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -19,7 +19,10 @@ namespace DxMessaging.Core.Configuration /// Field changes raise ; consumers should /// re-read derived state on the event. /// - [CreateAssetMenu(fileName = ResourceName, menuName = "Wallstop/DxMessaging/Runtime Settings")] + [CreateAssetMenu( + fileName = ResourceName, + menuName = "Wallstop Studios/DxMessaging/Runtime Settings" + )] public sealed class DxMessagingRuntimeSettings : ScriptableObject { /// Resource name (no extension) used by Resources.Load. @@ -136,7 +139,9 @@ private static void ClearSubscribersOnLoad() private const string ResourceFolder = "Assets/Resources"; private const string ResourceAssetPath = ResourceFolder + "/" + ResourceName + ".asset"; - [UnityEditor.MenuItem("Assets/Create/Wallstop/DxMessaging/Runtime Settings (in Resources)")] + [UnityEditor.MenuItem( + "Assets/Create/Wallstop Studios/DxMessaging/Runtime Settings (in Resources)" + )] private static void CreateAssetInResources() { if (!UnityEditor.AssetDatabase.IsValidFolder(ResourceFolder)) diff --git a/Runtime/Core/Internal/TypedDispatchLinkIndex.cs b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs index 933737fd..d5c6a846 100644 --- a/Runtime/Core/Internal/TypedDispatchLinkIndex.cs +++ b/Runtime/Core/Internal/TypedDispatchLinkIndex.cs @@ -12,7 +12,7 @@ namespace DxMessaging.Core.Internal /// PostProcess within Phase, and with-context before WithoutContext /// within Variant. The xmldoc on each constant names the legacy /// TypedHandler<T> dispatch-link field whose storage role - /// the slot will assume in the P3.3 storage migration. + /// the slot assumes in typed dispatch storage. /// internal static class TypedDispatchLinkIndex { diff --git a/Runtime/Core/Internal/TypedGlobalSlotIndex.cs b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs index c35b53c9..4f2e50b7 100644 --- a/Runtime/Core/Internal/TypedGlobalSlotIndex.cs +++ b/Runtime/Core/Internal/TypedGlobalSlotIndex.cs @@ -11,7 +11,7 @@ namespace DxMessaging.Core.Internal /// Untargeted -> Targeted -> Broadcast within Kind, Default before /// Fast within Variant. The xmldoc on each constant names the legacy /// TypedHandler<T> field whose storage role the slot will - /// assume in the P3.3 storage migration. + /// assume in typed global storage. /// internal static class TypedGlobalSlotIndex { diff --git a/Runtime/Core/Internal/TypedSlotIndex.cs b/Runtime/Core/Internal/TypedSlotIndex.cs index 29699be2..c37fbc80 100644 --- a/Runtime/Core/Internal/TypedSlotIndex.cs +++ b/Runtime/Core/Internal/TypedSlotIndex.cs @@ -12,7 +12,7 @@ namespace DxMessaging.Core.Internal /// PostProcess within Phase; and Default -> Fast -> WithoutContext -> /// WithoutContextFast within Variant. The xmldoc on each constant names /// the legacy TypedHandler<T> field whose storage role the - /// slot will assume in the P3.3 storage migration. + /// slot assumes in typed storage. /// internal static class TypedSlotIndex { diff --git a/Runtime/Core/Internal/TypedSlots.cs b/Runtime/Core/Internal/TypedSlots.cs index 2a3c12b9..c209b481 100644 --- a/Runtime/Core/Internal/TypedSlots.cs +++ b/Runtime/Core/Internal/TypedSlots.cs @@ -23,7 +23,7 @@ namespace DxMessaging.Core.Internal /// /// /// HandlerActionCache<TDelegate> implements this interface - /// explicitly as of P3.2 -- the explicit form keeps the public field + /// explicitly; the explicit form keeps the public field /// shape on the nested cache type unchanged, so the public dispatch /// surface picks up no new members from the interface retrofit. /// @@ -90,7 +90,7 @@ internal interface IHandlerActionCache /// / prefreezeInvocationCount, /// and bumps as the LAST step so any captured /// dispatch closure that observed the prior version detects - /// invalidation (PLAN Risk Register R3). Idempotent. + /// invalidation. Idempotent. /// void Reset(); } @@ -147,21 +147,16 @@ internal interface ITypedHandlerSlotSweeper /// /// /// - /// PLAN section 2.3 sketched this type as abstract. We chose - /// sealed here because there is no concrete subclass to - /// introduce per delegate variant without speculatively enumerating the - /// variants the storage migration will need. If delegate-variant - /// specialisation becomes necessary in P3.3 (for example, to encode a - /// non-generic dispatch fast path per shape), the class can be promoted - /// to abstract at that point with the concrete subclasses - /// introduced in the same change. Promoting now would commit to a - /// specific subclass layout the migration may not actually need. + /// This type is sealed because the current slot layout uses one + /// type-erased cache shape for every delegate variant. A future + /// delegate-specific fast path can promote the type to abstract + /// together with its concrete subclasses, without affecting the current + /// storage contract. /// /// - /// PLAN section 2.3 also sketched RequiresContext as an abstract - /// property. Because this class is sealed, the property collapses to a - /// readonly field () set via the - /// constructor. The semantic is identical: the field is true for + /// The context requirement is represented as a readonly field + /// () set via the constructor. The field is + /// true for /// slots whose resolves to a /// that carries an /// recipient or source (Targeted / Broadcast, @@ -170,16 +165,15 @@ internal interface ITypedHandlerSlotSweeper /// /// /// routes storage through this slot; - /// P3.3 deleted the legacy named fields and made the + /// the legacy named fields were deleted and the /// _slots[] array the /// storage owner. /// /// - /// PLAN section 2.3 also calls for a + /// owns a /// _dispatchLinks[] - /// array on . That array is a plain - /// object[] field on the handler, not a slot type; P3.3 - /// deleted the named dispatch-link fields. + /// array as a plain object[] field. It is not a slot type; the + /// legacy named dispatch-link fields were removed. /// /// /// @@ -187,8 +181,8 @@ internal interface ITypedHandlerSlotSweeper /// binds to. The slot itself does not /// reference directly today (the type-erased /// handles the per-delegate generic - /// shapes) -- the parameter is carried so the P3.3 storage migration - /// can add a concrete cache reference here without an additional + /// shapes) -- the parameter is carried so typed storage can add a + /// concrete cache reference here without an additional /// generic re-parameterization. /// internal sealed class TypedSlot : IEvictableSlot @@ -214,23 +208,20 @@ internal sealed class TypedSlot : IEvictableSlot /// /// The value observed by the most recent /// dispatcher snapshot. Used to decide whether the cache list needs - /// to be re-materialised before the next dispatch. Forward-compat - /// plumbing; not yet read by the typed-handler hot path. + /// to be re-materialised before the next dispatch. /// public long lastSeenVersion = -1; /// /// The bus emission id of the most recent dispatch that consumed /// this slot. Used by the staged dispatch staleness check. - /// Forward-compat plumbing; not yet read by the typed-handler hot - /// path. /// public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Will be maintained by P4's touch - /// hook; preserved across and + /// emit that touched this slot. Maintained by the sweep touch hook; + /// preserved across and /// so the sweep can distinguish freshly-reset slots from /// never-touched slots. /// @@ -255,28 +246,26 @@ internal sealed class TypedSlot : IEvictableSlot /// True iff this slot's resolves to a /// dispatch variant that carries an /// recipient or source (the non-WithoutContext Targeted and - /// Broadcast variants). When true, the storage migration - /// will populate ; when false, - /// storage flows through directly. + /// Broadcast variants). When true, context-bound storage uses + /// ; when false, storage flows through + /// directly. /// /// - /// PLAN section 2.3 sketched this as an abstract RequiresContext - /// property; collapsed to a readonly field here because - /// is sealed (see class remarks). + /// Kept as a readonly field because is + /// sealed (see class remarks). /// public readonly bool requiresContext; /// /// Inner per-context map for context-bound slots. Null unless /// is true AND at least one - /// context has been registered. Forward-compat plumbing. + /// context has been registered. /// /// /// - /// Lifetime semantic for the storage migration: - /// and return the outer context dictionary and - /// every inner priority dictionary to before - /// nulling the field. + /// Lifetime semantic: and + /// return the outer context dictionary and every inner priority + /// dictionary to before nulling the field. /// /// /// Unlike the bus-side , which @@ -298,11 +287,10 @@ internal sealed class TypedSlot : IEvictableSlot /// 3-level shape was chosen over the alternatives (extend /// with per-priority buckets, or /// recurse with Dictionary<InstanceId, TypedSlot<T>>) - /// because it preserves the legacy storage layout exactly -- - /// minimising the per-call-site rewrite the P3.3 storage migration - /// has to perform. PLAN Risk Register R3 informs the - /// monotonic-version drain contract on : every - /// inner cache is drained through + /// because it preserves the legacy storage layout exactly and keeps + /// call sites aligned with the existing shape. The monotonic-version + /// drain contract on + /// requires every inner cache to be drained through /// before the outer /// container is cleared. /// @@ -397,8 +385,8 @@ public void Clear() /// ones. /// /// - /// Drain order is load-bearing per PLAN Risk Register R3: inner - /// caches must be reset (and their own monotonic versions bumped) + /// Drain order is load-bearing: inner caches must be reset (and their + /// own monotonic versions bumped) /// BEFORE the outer container is cleared, so any captured dispatch /// closure observing an inner cache detects invalidation regardless /// of whether the outer reference is still reachable. The outer @@ -409,10 +397,9 @@ public void Reset() { // Inline the structural-clear body of Clear(); do NOT call // Clear() because that resets version=0 and would break the - // monotonic invariant the eviction layer depends on (PLAN Risk - // Register R3: stale deregister closures captured before reset - // must observe a strictly larger version after reset and skip - // their work). + // monotonic invariant the eviction layer depends on: stale + // deregister closures captured before reset must observe a + // strictly larger version after reset and skip their work. // Per-cache drain BEFORE the structural clear: every // IHandlerActionCache.Reset() bumps its own version internally, // so closures captured against the inner cache also detect @@ -476,12 +463,12 @@ KeyValuePair> ctx in byContext /// /// /// - /// Per PLAN section 2.3 the typed handler holds an array of 6 - /// . The per-(, - /// ) indexing scheme that maps the six - /// global flavours (3 kinds {Untargeted, Targeted, + /// The typed handler holds an array of 6 + /// entries. The per-(, + /// ) indexing scheme maps the six global + /// flavours (3 kinds {Untargeted, Targeted, /// Broadcast} times 2 variants {Default, Fast}) to - /// array slots is committed in + /// array slots and is committed in /// (sibling file in the same folder). This type defines the per-slot /// shape; the index file owns the layout decision. /// routes global storage through this type. @@ -523,22 +510,20 @@ internal sealed class TypedGlobalSlot : IEvictableSlot /// /// The value observed by the most recent - /// dispatcher snapshot. Forward-compat plumbing; not yet read by - /// the typed-handler hot path. + /// dispatcher snapshot. /// public long lastSeenVersion = -1; /// /// The bus emission id of the most recent dispatch that consumed - /// this slot. Forward-compat plumbing; not yet read by the - /// typed-handler hot path. + /// this slot. /// public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Will be maintained by P4's touch - /// hook; preserved across and + /// emit that touched this slot. Maintained by the sweep touch hook; + /// preserved across and /// . /// public long lastTouchTicks; @@ -602,8 +587,8 @@ public void Clear() /// is intentionally preserved. /// /// - /// Drain order is load-bearing per PLAN Risk Register R3: the - /// inner cache's own monotonic version is bumped BEFORE the slot + /// Drain order is load-bearing: the inner cache's own monotonic + /// version is bumped BEFORE the slot /// drops the reference, so closures captured against the inner /// cache also detect invalidation. The outer /// bump is the LAST statement in the method for the same reason at @@ -613,8 +598,7 @@ public void Reset() { // Inline the structural-clear body of Clear(); do NOT call // Clear() because that resets version=0 and would break the - // monotonic invariant the eviction layer depends on (PLAN Risk - // Register R3). + // monotonic invariant the eviction layer depends on. cache?.Reset(); cache = null; lastSeenVersion = -1; diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs b/Runtime/Core/MessageBus/Internal/BusSlots.cs index 4da1ac8e..ec29229f 100644 --- a/Runtime/Core/MessageBus/Internal/BusSlots.cs +++ b/Runtime/Core/MessageBus/Internal/BusSlots.cs @@ -96,9 +96,7 @@ public void Clear() /// /// /// The field carries the staged Stage/Acquire - /// snapshot for this slot. It is forward-compatible plumbing -- BusSinkSlot - /// is not yet the storage type wired into the hot dispatch path; that - /// wiring lands in a future P3 / storage-migration session. + /// snapshot for this slot. /// /// internal sealed class BusSinkSlot : IEvictableSlot @@ -140,7 +138,7 @@ internal sealed class BusSinkSlot : IEvictableSlot /// /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Maintained by P4's touch hook; + /// emit that touched this slot. Maintained by the sweep touch hook; /// preserved across and so the /// sweep can distinguish never-touched slots from freshly-reset slots. /// @@ -152,13 +150,10 @@ internal sealed class BusSinkSlot : IEvictableSlot /// (, priority) pair count across every /// entry in , so /// becomes a single integer compare rather than a walk over priority - /// buckets. The bus does not yet maintain this counter; the wiring - /// lands when becomes the storage type - /// backing the typed-sink hot dispatch path (the same deferred phase - /// described on ). - /// currently returns true at all times because no writer - /// increments ; eviction will only consult - /// once the bus-side counter wiring lands. + /// buckets. This counter is reserved until is + /// used as the typed-sink storage type. currently + /// returns true at all times because no writer increments + /// . /// /// /// Intended transitions once wired: re-registration of an existing @@ -246,9 +241,9 @@ public void Reset() { // Inline the structural-clear body of Clear(); do NOT call Clear() // because that resets version=0 and would break the monotonic - // invariant the eviction layer depends on (PLAN Risk Register R3: - // stale deregister closures captured before reset must observe a - // strictly larger version after reset and skip their work). + // invariant the eviction layer depends on: stale deregister + // closures captured before reset must observe a strictly larger + // version after reset and skip their work. foreach (BusPriorityBucket bucket in handlersByPriority.Values) { bucket.Clear(); @@ -285,7 +280,7 @@ public void Reset() /// ; the class is sealed and only inserted /// from this type's own methods, so the cast cannot encounter a foreign /// runtime type. DEBUG builds verify the invariant at every - /// cast site (PLAN Risk Register R4). + /// cast site. /// /// /// The map is left null until first registration so empty slots cost only @@ -309,7 +304,7 @@ internal sealed class BusContextSlot : IEvictableSlot /// /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Maintained by P4's touch hook; + /// emit that touched this slot. Maintained by the sweep touch hook; /// preserved across and . /// public long lastTouchTicks; @@ -320,14 +315,10 @@ internal sealed class BusContextSlot : IEvictableSlot /// keys in that /// currently retain at least one live handler, so /// becomes a single integer compare rather than a recursive walk over - /// the inner per-context slots. The bus does not yet maintain this - /// counter; the wiring lands when becomes - /// the storage type backing the typed-sink hot dispatch path (the same - /// deferred phase described on ). + /// the inner per-context slots. This counter is reserved until + /// is used as the typed-sink storage type. /// currently returns true at all times - /// because no writer increments ; eviction will - /// only consult once the bus-side counter wiring - /// lands. + /// because no writer increments . /// /// /// Intended transitions once wired: the bus will increment by 1 when @@ -482,9 +473,9 @@ public void Clear() /// /// Eviction-driven reset. Walks every inner - /// and calls on each (PLAN Risk - /// Register R3: inner pooled state must be drained BEFORE the outer - /// map is recycled), then returns to the + /// and calls on each. Inner pooled + /// state must be drained BEFORE the outer map is recycled. Then returns + /// to the /// shared pool and nulls the /// field. Bumps as the LAST step so any captured /// dispatch closure that observed the prior version detects @@ -534,10 +525,9 @@ private static void DebugAssertSlot(object boxed) /// /// /// - /// PLAN.md section 2.5 Option G2 chooses to model the legacy - /// global-handlers triple-category share as one slot with a shared - /// handler set ( / ) - /// and three separate per-kind dispatch state fields + /// This slot models global accept-all handlers as one shared handler set + /// ( / ) and three + /// separate per-kind dispatch state fields /// (, /// , /// ). The discrete fields keep the @@ -556,11 +546,11 @@ internal sealed class BusGlobalSlot : IEvictableSlot public readonly Dictionary sharedHandlers = new(); /// - /// Reserved for future global-slot snapshot iteration. Mirrors the - /// legacy non-generic HandlerCache.cache field, which was likewise + /// Reserved for global-slot snapshot iteration. Mirrors the legacy + /// non-generic HandlerCache.cache field, which was likewise /// allocated for parity but never populated or read by any dispatch path. - /// Cleared by and so adding a writer - /// in a later phase requires no extra lifecycle plumbing. + /// Cleared by and as part of the + /// slot lifecycle. /// public readonly List sharedCache = new(); @@ -569,25 +559,22 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// /// Reserved counter intended to record the value - /// observed by the most recent dispatcher snapshot. The global path - /// does not yet read this field; it is allocated for parity with the - /// per-cache contract so the - /// staged-dispatch staleness check can adopt it without an additional - /// lifecycle change. + /// observed by the most recent dispatcher snapshot. Allocated for parity + /// with the per-cache contract. /// public long lastSeenVersion = -1; /// /// Reserved counter intended to record the bus emission id of the - /// most recent dispatch that consumed this slot. The global path - /// does not yet read this field; it is allocated for parity with the - /// per-cache contract. + /// most recent dispatch that consumed this slot. Allocated for parity + /// with the per-cache + /// contract. /// public long lastSeenEmissionId = -1; /// /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Maintained by P4's touch hook; + /// emit that touched this slot. Maintained by the sweep touch hook; /// preserved across and . /// public long lastTouchTicks; @@ -618,8 +605,8 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// /// Dispatch state for the Untargeted-global emission path. One of the - /// three discrete per-kind fields per Option G2 of PLAN section 2.5 -- - /// separate slots over a per-kind dictionary keep the per-emission + /// three discrete per-kind fields. Separate slots over a per-kind + /// dictionary keep the per-emission /// select branch-free under JIT monomorphization. Lazy alloc on first /// Stage/Acquire; null after Reset(). /// @@ -694,7 +681,7 @@ public void Reset() { // Inline the structural-clear body of Clear(); do NOT call Clear() // because that resets version=0 and would break the monotonic - // invariant the eviction layer depends on (PLAN Risk Register R3). + // invariant the eviction layer depends on. sharedHandlers.Clear(); sharedCache.Clear(); untargetedDispatchState?.Reset(); diff --git a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs index 77007998..b6f87f53 100644 --- a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs +++ b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs @@ -2,16 +2,15 @@ namespace DxMessaging.Core.MessageBus.Internal { /// /// Marker interface for bus-level slot containers that the eviction layer - /// (PLAN Phase P4) can sweep. Each slot tracks its last-touch tick so the - /// sweep can decide whether to reclaim it, and exposes a monotonic + /// can sweep. Each slot tracks its last-touch tick so the sweep can decide + /// whether to reclaim it, and exposes a monotonic /// so that staged dispatch closures captured before /// eviction can detect they have been invalidated. /// /// - /// In P2.1 these contracts are declared but not yet swept -- P2.5 wires - /// to return inner pooled collections to - /// DxMessaging.Core.Pooling.DxPools, and P4 implements the sweep - /// policy that calls on idle empty slots. + /// returns inner pooled collections to + /// DxMessaging.Core.Pooling.DxPools. The sweep policy calls + /// on idle empty slots. /// internal interface IEvictableSlot { diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 8c5be024..d9c1b567 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -292,10 +292,9 @@ private sealed class HandlerCache /// public void Clear() { - // LEGACY: version reset semantics; the BusSinkSlot.Reset path under P3 will - // preserve monotonic as required by R3. Bus-side deregistration closures use - // captured cache identity and reset generations, so no current consumer depends - // on monotonic version here until storage migration lands. + // LEGACY: version reset semantics. Bus-side deregistration closures use + // captured cache identity and reset generations, so monotonic versioning + // is handled by sweep-driven slot reset paths. handlers.Clear(); order.Clear(); cache.Clear(); @@ -379,10 +378,9 @@ private sealed class HandlerCache /// public void Clear() { - // LEGACY: version reset semantics; the BusSinkSlot.Reset path under P3 will - // preserve monotonic as required by R3. Bus-side deregistration closures use - // captured cache identity and reset generations, so no current consumer depends - // on monotonic version here until storage migration lands. + // LEGACY: version reset semantics. Bus-side deregistration closures use + // captured cache identity and reset generations, so monotonic versioning + // is handled by sweep-driven slot reset paths. handlers.Clear(); cache.Clear(); version = 0; @@ -638,7 +636,7 @@ private delegate void FastSourcedBroadcast(ref InstanceId target, ref T messa // BusGlobalSlot -- the global accept-all slot is single-cardinality, so // there is no array to index, but it is grouped here because it shares // the lifecycle of the typed sinks (cleared together in ResetState, - // touched together by the eviction layer in P4). + // touched together by the eviction layer). private readonly MessageCache>[] _scalarSinks = new MessageCache>[BusSinkIndex.Length] { @@ -1350,9 +1348,8 @@ private int SweepGlobalSlot(bool force) return 0; } - // LEGACY: global slot reset keeps the current sweep-generation guard for stale - // deregistration closures. The BusSinkSlot.Reset path under P3 will preserve - // monotonic as required by R3 when the remaining bus-side storage migrates. + // LEGACY: global slot reset keeps the sweep-generation guard for stale + // deregistration closures. _globalSlots.Reset(); unchecked { diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index 8f1c0cb7..e6ff0769 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -2134,7 +2134,7 @@ out TypedHandler existingTypedHandler /// /// Resets empty typed-handler slots associated with - /// . P4's eviction layer calls through + /// . The eviction layer calls through /// this erased surface after bus-side slots prove idle and empty. /// /// @@ -2417,7 +2417,7 @@ bool DxMessaging.Core.Internal.IHandlerActionCache.IsEmpty /// /// Eviction-driven full clear; bumps as the LAST step - /// to invalidate any captured dispatch closure (PLAN Risk Register R3). + /// so captured dispatch closures observe invalidation. /// void DxMessaging.Core.Internal.IHandlerActionCache.Reset() { @@ -2835,7 +2835,7 @@ long emissionId internal sealed class TypedHandler : ITypedHandlerSlotSweeper where T : IMessage { - // P3.3 storage: 20 typed slots + 6 global slots + 10 dispatch + // Typed storage: 20 typed slots + 6 global slots + 10 dispatch // links. The legacy named fields were deleted so new handler // variants must pick an explicit axis-indexed slot. internal readonly TypedSlot[] _slots = new TypedSlot[TypedSlotIndex.Length]; @@ -2891,7 +2891,7 @@ private void ValidateSlotArrays() $"_dispatchLinks length is {_dispatchLinks.Length} but TypedDispatchLinkIndex.Length is {TypedDispatchLinkIndex.Length}." ); } - // P3.3 wires lazy registration writers; this assertion still + // Lazy registration writers update the slot arrays; this assertion still // holds at construction (slots populate on first register, // not on construction). The invariant flips meaning -- not // the message -- when writers land. diff --git a/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs index 139f7465..ffd1e945 100644 --- a/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs +++ b/Tests/Editor/Benchmarks/PerfRegressionSmokeTests.cs @@ -11,8 +11,8 @@ namespace DxMessaging.Tests.Editor.Benchmarks public sealed class PerfRegressionSmokeTests { private const string PerfGateEnvVar = "DX_PERF_GATE"; - private const string BaselinePath = "progress/perf-baseline-2026-05-05.csv"; - private const string BaselineCommit = "25a4dcc"; + private const string BaselinePathEnvVar = "DX_PERF_BASELINE"; + private const string BaselineCommitEnvVar = "DX_PERF_BASELINE_COMMIT"; private const double RegressionMultiplier = 1.5d; [Test, Explicit, Category("PerfGate")] @@ -79,7 +79,13 @@ private static void RunGate(DispatchBenchmarkScenario scenario) DispatchBenchmarkResult current = DispatchThroughputBenchmarks.RunScenario(scenario); IReadOnlyList baselines = LoadBaselines(); string scenarioName = DispatchThroughputBenchmarks.GetScenarioName(scenario); - BaselineRow baseline = FindBaseline(baselines, scenarioName, current.Platform); + string baselineCommit = GetBaselineCommit(); + BaselineRow baseline = FindBaseline( + baselines, + scenarioName, + current.Platform, + baselineCommit + ); if (current.IsRegistrationScenario) { @@ -108,11 +114,19 @@ private static void RunGate(DispatchBenchmarkScenario scenario) private static IReadOnlyList LoadBaselines() { - string path = FindRepoRelativePath(BaselinePath); + string configuredPath = Environment.GetEnvironmentVariable(BaselinePathEnvVar); + if (string.IsNullOrWhiteSpace(configuredPath)) + { + Assert.Ignore( + $"{BaselinePathEnvVar}= is required to run the perf smoke gate." + ); + } + + string path = ResolvePath(configuredPath); if (!File.Exists(path)) { Assert.Ignore( - $"Performance baseline file not found: {BaselinePath}. Capture T0.3 baselines before enforcing PerfGate." + $"Performance baseline file not found: {configuredPath}. Capture a dispatch throughput baseline before enforcing PerfGate." ); } @@ -137,7 +151,8 @@ private static IReadOnlyList LoadBaselines() private static BaselineRow FindBaseline( IReadOnlyList rows, string scenario, - string platform + string platform, + string baselineCommit ) { for (int index = 0; index < rows.Count; index++) @@ -146,7 +161,7 @@ string platform if ( string.Equals(row.Scenario, scenario, StringComparison.Ordinal) && string.Equals(row.Platform, platform, StringComparison.Ordinal) - && string.Equals(row.Commit, BaselineCommit, StringComparison.OrdinalIgnoreCase) + && string.Equals(row.Commit, baselineCommit, StringComparison.OrdinalIgnoreCase) ) { return row; @@ -154,17 +169,35 @@ string platform } Assert.Fail( - $"No {BaselineCommit} baseline row found for scenario {scenario} on platform {platform}." + $"No {baselineCommit} baseline row found for scenario {scenario} on platform {platform}." ); return default; } - private static string FindRepoRelativePath(string relativePath) + private static string GetBaselineCommit() { + string configuredCommit = Environment.GetEnvironmentVariable(BaselineCommitEnvVar); + if (string.IsNullOrWhiteSpace(configuredCommit)) + { + Assert.Ignore( + $"{BaselineCommitEnvVar}= is required to run the perf smoke gate." + ); + } + + return configuredCommit; + } + + private static string ResolvePath(string configuredPath) + { + if (Path.IsPathRooted(configuredPath)) + { + return configuredPath; + } + DirectoryInfo current = new(Directory.GetCurrentDirectory()); while (current != null) { - string candidate = Path.Combine(current.FullName, relativePath); + string candidate = Path.Combine(current.FullName, configuredPath); if (File.Exists(candidate)) { return candidate; @@ -173,7 +206,7 @@ private static string FindRepoRelativePath(string relativePath) current = current.Parent; } - return Path.Combine(Directory.GetCurrentDirectory(), relativePath); + return Path.Combine(Directory.GetCurrentDirectory(), configuredPath); } private readonly struct BaselineRow diff --git a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs index 9092e520..29d0f701 100644 --- a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs +++ b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs @@ -9,7 +9,7 @@ namespace DxMessaging.Tests.Editor.Contract /// /// Contract guardrails for the - /// invariant wired in Phase P2.5 of the memory reclamation plan. The + /// invariant wired for memory reclamation. The /// counter must remain in lockstep with /// BusGlobalSlot.sharedHandlers.Count across every register and /// deregister flow exercised through diff --git a/Tests/Editor/Contract/CounterBasedTouchTests.cs b/Tests/Editor/Contract/CounterBasedTouchTests.cs index 2f3c3034..f6113090 100644 --- a/Tests/Editor/Contract/CounterBasedTouchTests.cs +++ b/Tests/Editor/Contract/CounterBasedTouchTests.cs @@ -9,10 +9,9 @@ namespace DxMessaging.Tests.Editor.Contract using NUnit.Framework; /// - /// Reflection-based contract tests for PLAN P4.1 counter-based slot touch - /// wiring. These intentionally pin likely private/internal names so the - /// tests compile before the runtime touch hook exists, while failing with - /// focused messages until the implementation lands. + /// Reflection-based contract tests for counter-based slot touch wiring. + /// These intentionally pin likely private/internal names so implementation + /// drift fails with focused messages. /// [TestFixture] [Category("Contract")] @@ -444,7 +443,7 @@ private static long ReadBusTick(MessageBus bus) Assert.Fail( "Could not locate the MessageBus touch tick counter. Tried: " + string.Join(", ", TickMemberNames) - + ". Update CounterBasedTouchTests.TickMemberNames if P4.1 uses a different name." + + ". Update CounterBasedTouchTests.TickMemberNames if the implementation uses a different name." ); return 0; } diff --git a/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs index fef0599e..7e6c036d 100644 --- a/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs +++ b/Tests/Editor/Contract/RegistrationMethodAxisCoverageTests.cs @@ -10,8 +10,8 @@ namespace DxMessaging.Tests.Editor.Contract /// /// Contract guardrails for the table. - /// These tests are the structural backstop for Phase P1 of the memory - /// reclamation plan: they assert that every + /// These tests are the structural backstop for memory reclamation: they + /// assert that every /// has an explicit /// mapping and that real (non-sentinel) mappings are unique. /// diff --git a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs index 73993297..141176bd 100644 --- a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs +++ b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs @@ -12,8 +12,8 @@ namespace DxMessaging.Tests.Editor.Contract using NUnit.Framework; /// - /// Contract guardrails for the per-message-type slot-index tables wired in - /// PLAN Phase P3.2: (20-slot + /// Contract guardrails for the per-message-type slot-index tables: + /// (20-slot /// TypedSlot<T>[] on TypedHandler<T>), /// (6-slot /// TypedGlobalSlot[]), and @@ -21,9 +21,8 @@ namespace DxMessaging.Tests.Editor.Contract /// /// /// - /// Each test pins one structural invariant the P3.3 storage migration - /// depends on so the migration can land without revisiting the per-axis - /// layout. The tests reflect over a freshly-instantiated typed handler + /// Each test pins one structural invariant typed storage depends on. The + /// tests reflect over a freshly-instantiated typed handler /// rather than asserting against a hand-written shape so accidental /// future field additions / index renumbering surface here BEFORE they /// drift away from ValidateSlotArrays(). @@ -43,9 +42,8 @@ public sealed class TypedSlotIndexCoverageTests private readonly struct ProbeTargetedMessage : ITargetedMessage { } - // The expected legacy field name -> slot-index constant map. P3.3 - // deletes the fields; the names stay here as a migration ledger so - // new variants must still pick an explicit axis-indexed slot. + // The expected legacy field name -> slot-index constant map. The names + // stay here so new variants must still pick an explicit axis-indexed slot. private static readonly (string FieldName, string ConstantName)[] LegacySlotMap = { ("_untargetedHandlers", nameof(TypedSlotIndex.UntargetedHandleDefault)), @@ -300,7 +298,7 @@ public void LegacyNamedFieldsAreDeletedAfterSlotMigration() declaredFieldNames.Contains(fieldName), "TypedHandler must not redeclare legacy typed field '" + fieldName - + "' after P3.3 storage migration." + + "' because typed storage owns that slot." ); Assert.IsTrue( indexConstantNames.Contains(constantName), @@ -323,7 +321,7 @@ public void LegacyNamedFieldsAreDeletedAfterSlotMigration() declaredFieldNames.Contains(fieldName), "TypedHandler must not redeclare legacy global field '" + fieldName - + "' after P3.3 storage migration." + + "' because typed global storage owns that slot." ); } @@ -333,7 +331,7 @@ public void LegacyNamedFieldsAreDeletedAfterSlotMigration() declaredFieldNames.Contains(fieldName), "TypedHandler must not redeclare legacy dispatch-link field '" + fieldName - + "' after P3.3 storage migration." + + "' because typed dispatch storage owns that slot." ); } } diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs b/Tests/Editor/Contract/TypedSlotShapeTests.cs index 5ee3bcc1..54811a16 100644 --- a/Tests/Editor/Contract/TypedSlotShapeTests.cs +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -11,8 +11,8 @@ namespace DxMessaging.Tests.Editor.Contract using NUnit.Framework; /// - /// Contract guardrails for the typed-handler-side slot grid introduced in - /// PLAN Phase P3.1: (the non-generic + /// Contract guardrails for the typed-handler-side slot grid: + /// (the non-generic /// erasure of the per-delegate-shape cache type), /// (the per-message-type, per- /// dispatch slot), and (the per-message-type @@ -20,21 +20,17 @@ namespace DxMessaging.Tests.Editor.Contract /// /// /// - /// These types are forward-compat plumbing in P3.1 -- no writer populates - /// them yet -- but the eviction-driven monotonic Reset() contract - /// (PLAN Risk Register R3) and the shape - /// must be locked in before the storage migration in P3.2 / P3.3 starts - /// reading them. Each test below pins one structural invariant so the - /// migration can land without revisiting the per-slot lifecycle. + /// These tests pin the eviction-driven monotonic Reset() contract + /// and the shape that typed storage relies on. + /// Each test below covers one structural invariant in the per-slot + /// lifecycle. /// /// /// The test reflects over /// the interface's declared members and is the structural backstop for - /// P3.2: when the storage migration retrofits - /// HandlerActionCache<TDelegate> to implement - /// , any silent member add or remove - /// here will fail this test until reviewers update both the interface - /// and its expected-shape list in lockstep. + /// typed storage. Any silent member add or remove here will fail this test + /// until reviewers update both the interface and its expected-shape list in + /// lockstep. /// /// [TestFixture] @@ -108,8 +104,7 @@ public OwnEntry(T outerValue, U ownValue) /// Trivial in-test stub for . Used so /// the slot tests can populate /// and without depending on the - /// real HandlerActionCache<TDelegate> implementation - /// (which, in P3.1, does not yet implement the interface). + /// real HandlerActionCache<TDelegate> implementation. /// private sealed class StubCache : IHandlerActionCache { @@ -132,8 +127,8 @@ public void Reset() } /// - /// Probe stub for the drain-BEFORE-clear ordering tests (PLAN Risk - /// Register R3). is invoked from + /// Probe stub for the drain-BEFORE-clear ordering tests. + /// is invoked from /// and the observed value pinned in /// ; the slot tests set /// to read the size of the outer @@ -200,7 +195,7 @@ public void TypedSlotResetBumpsVersionMonotonically() Assert.Greater( slot.version, previous, - "Reset() must bump version strictly monotonically (PLAN Risk R3)." + "Reset() must bump version strictly monotonically." ); previous = slot.version; } @@ -221,15 +216,12 @@ public void TypedSlotResetPreservesLastTouchTicks() } /// - /// Pins the per-cache drain wired in P3.2: + /// Pins the per-cache drain: /// must invoke on every /// held by /// BEFORE clearing the - /// container. Drain order is load-bearing per PLAN Risk Register R3 - /// so that closures captured against the inner cache also detect - /// invalidation. The earlier P3.1 placeholder pin asserted the - /// inverse (ResetCallCount == 0) and was flipped here in - /// lockstep with the wiring. + /// container. Drain order is load-bearing so that closures captured + /// against the inner cache also detect invalidation. /// [Test] public void TypedSlotResetDrainsHeldCachesViaIHandlerActionCache() @@ -243,9 +235,8 @@ public void TypedSlotResetDrainsHeldCachesViaIHandlerActionCache() Assert.AreEqual( 1, child.ResetCallCount, - "P3.2 wires Reset() to drain every IHandlerActionCache held by " - + "byPriority via IHandlerActionCache.Reset() BEFORE the structural " - + "clear (PLAN Risk Register R3). Re-check the xmldoc on " + "Reset() must drain every IHandlerActionCache held by byPriority " + + "via IHandlerActionCache.Reset() BEFORE the structural clear. Re-check the xmldoc on " + "TypedSlot.Reset() if this assertion needs to change." ); } @@ -256,7 +247,7 @@ public void TypedSlotResetDrainsHeldCachesViaIHandlerActionCache() /// is also drained on /// . Walks both axes of the flat /// 3-level InstanceId -> (priority -> IHandlerActionCache) - /// shape committed in P3.2. + /// shape used by typed storage. /// [Test] public void TypedSlotResetDrainsByContextHeldCachesViaIHandlerActionCache() @@ -284,7 +275,7 @@ public void TypedSlotResetDrainsByContextHeldCachesViaIHandlerActionCache() /// /// Pins the drain-BEFORE-clear ordering on - /// (PLAN Risk Register R3). + /// . /// The probe cache reads byPriority.Count at the moment its /// fires; a value of 1 /// proves the drain ran while the outer dict still held the entry. @@ -303,8 +294,7 @@ public void TypedSlotResetDrainsBeforeClearingByPriority() Assert.AreEqual( 1, probe.ObservedOuterCountAtReset, - "Reset() must drain inner caches BEFORE clearing byPriority " - + "(PLAN Risk Register R3)." + "Reset() must drain inner caches BEFORE clearing byPriority." ); } @@ -335,14 +325,13 @@ public void TypedSlotResetDrainsBeforeClearingByContext() Assert.AreEqual( 1, probe.ObservedOuterCountAtReset, - "Reset() must drain inner caches BEFORE clearing byContext " - + "(PLAN Risk Register R3)." + "Reset() must drain inner caches BEFORE clearing byContext." ); } /// /// Pins that MessageHandler.HandlerActionCache<T> implements - /// after P3.2 (Task 1). The interface + /// . The interface /// is implemented explicitly so the public-facing field shape on the /// nested cache type is unchanged; this test exercises the six /// interface members through an interface-typed reference to confirm @@ -362,7 +351,7 @@ public void HandlerActionCacheImplementsIHandlerActionCache() System.Type closed = nested.MakeGenericType(typeof(System.Action)); Assert.IsTrue( typeof(IHandlerActionCache).IsAssignableFrom(closed), - "HandlerActionCache must implement IHandlerActionCache after P3.2." + "HandlerActionCache must implement IHandlerActionCache." ); object instance = System.Activator.CreateInstance(closed, nonPublic: true); @@ -442,11 +431,7 @@ public void HandlerActionCacheImplementsIHandlerActionCache() long beforeReset = view.Version; view.Reset(); - Assert.Greater( - view.Version, - beforeReset, - "Reset() must bump version monotonically (PLAN Risk Register R3)." - ); + Assert.Greater(view.Version, beforeReset, "Reset() must bump version monotonically."); Assert.AreEqual(-1, view.LastSeenVersion, "Reset() must restore lastSeenVersion = -1."); Assert.AreEqual( -1, @@ -703,7 +688,7 @@ public void CloseNestedGenericFourArgOverloadAcceptsNestedOwnArgs() /// /// Pins that is the flat /// 3-level Dictionary<InstanceId, Dictionary<int, IHandlerActionCache>> - /// shape committed in P3.2 (option (2) from the P3.1 enumeration). + /// shape used by typed storage. /// [Test] public void TypedSlotByContextShapeIsFlatThreeLevelDictionary() @@ -803,7 +788,7 @@ public void TypedGlobalSlotResetBumpsVersionMonotonically() Assert.Greater( slot.version, previous, - "Reset() must bump version strictly monotonically (PLAN Risk R3)." + "Reset() must bump version strictly monotonically." ); previous = slot.version; } @@ -889,9 +874,8 @@ public void TypedGlobalSlotImplementsIEvictableSlot() /// , and /// . Adding or removing a /// member breaks this test until reviewers update the expected list, - /// providing a structural backstop for P3.2 (where - /// HandlerActionCache<TDelegate> retroactively implements - /// the interface). + /// providing a structural backstop for the + /// HandlerActionCache<TDelegate> interface implementation. /// [Test] public void IHandlerActionCacheInterfaceShape() diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index f3c57c8a..255317ba 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -1,16 +1,16 @@ # Performance Benchmarks -This page documents the T0 throughput benchmark policy and keeps the older -PlayMode benchmark tables for broad historical context. +This page documents the dispatch throughput benchmark policy and keeps the +older PlayMode benchmark tables for broad historical context. See also: [Performance optimizations](./design-and-architecture.md#performance-optimizations) for design details. -## T0 Benchmark Methodology +## Dispatch Throughput Methodology -The T0 harness measures raw dispatch throughput before any hot-path runtime -changes land. It is intentionally narrow: warm up each scenario, measure a -1-second window, repeat five times, and compare the median. Each row records: +The throughput harness measures raw dispatch cost for the current runtime. It is +intentionally narrow: warm up each scenario, measure a 1-second window, repeat +five times, and compare the median. Each row records: - Scenario name. - Platform identity, including editor/player target, scripting backend, @@ -35,7 +35,7 @@ Keep the editor version, scripting backend, CPU governor, and machine load stable across before/after runs. Close the Unity editor UI before batchmode runs so the benchmark process owns the editor session. -The T0 scenarios cover these paths: +The dispatch throughput scenarios cover these paths: | Scenario | What it measures | | --------------------------------------------- | -------------------------------------------------------- | @@ -51,17 +51,19 @@ The T0 scenarios cover these paths: ## Baseline Capture -Capture baselines into `progress/perf-baseline-2026-05-05.csv`. The baseline -file should be updated in a dedicated measurement commit, separate from runtime -changes. +Capture baselines into a local CSV file and keep the file path explicit in the +commands that consume it. Do not put generated baseline CSVs in package +documentation or rely on a dated filename. For CI or release comparison, publish +the CSV as a workflow artifact or attach it to the pull request that records the +measurement. -Required commit cells: +Recommended commit cells: -| Commit | Purpose | -| --------- | -------------------------------------- | -| `25a4dcc` | Pre-GC parent baseline. | -| `29a5338` | First-pass garbage-collection landing. | -| `HEAD` | Current branch result. | +| Commit reference | Purpose | +| ----------------------------- | --------------------------------------- | +| Chosen comparison commit | Accepted baseline for regression gates. | +| Previous optimization landing | Runtime after the last relevant change. | +| `HEAD` | Current branch result. | Required configuration cells: @@ -73,43 +75,43 @@ Required configuration cells: For each commit and configuration: -- Keep the T0 harness/worktree available; older runtime commits do not contain - the benchmark harness. +- Keep the benchmark harness available; older runtime commits may not contain + the benchmark files. - Measure the older runtime with a harness-preserving flow. Use a throwaway - branch that cherry-picks the T0 harness onto the measured runtime commit, or - keep the harness branch checked out and swap only the runtime files being + branch that cherry-picks the current harness onto the measured runtime commit, + or keep the harness branch checked out and swap only the runtime files being measured. - Set `DX_PERF_COMMIT=` for every benchmark run so CSV rows identify the runtime commit under test. `DX_PERF_COMMIT` overrides CI's `GITHUB_SHA` when both are present. - Run the PlayMode `PerfBench` category in batchmode. - Extract the benchmark rows from the Unity output and append them to - `progress/perf-baseline-2026-05-05.csv`. + the local baseline CSV. - Record the exact commit, platform, Unity version, and scripting backend. The benchmark harness writes both structured log lines and CSV-shaped rows. Prefer the extractor so the baseline file has one header and normalized rows: ```bash -DX_PERF_COMMIT=25a4dcc \ +DX_PERF_COMMIT= \ bash scripts/unity/run-tests.sh \ --platform playmode \ --include-perf \ --filter 'DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*' \ - --results .artifacts/unity/perf-25a4dcc-editor-mono.xml \ - 2>&1 | tee .artifacts/unity/perf-25a4dcc-editor-mono.log + --results \ + 2>&1 | tee node scripts/unity/extract-perf-baseline.js \ - --input .artifacts/unity/perf-25a4dcc-editor-mono.log \ - --input .artifacts/unity/perf-25a4dcc-editor-mono.xml \ - --output progress/perf-baseline-2026-05-05.csv \ + --input \ + --input \ + --output \ --append ``` For the first cell, omit `--append` so the script creates the file with the -CSV header. For later cells, use `--append`. Repeat the run for `29a5338` and -`HEAD`, changing both `DX_PERF_COMMIT` and artifact filenames so each cell is -traceable. +CSV header. For later cells, use `--append`. Repeat the run for each comparison +commit and for `HEAD`, changing `DX_PERF_COMMIT` and artifact filenames so each +cell is traceable. Do not mix methodology changes with baseline updates. If the harness changes, capture a new baseline and make the old/new methodology boundary explicit in @@ -133,26 +135,27 @@ window. Dispatch scenarios should stay at zero measured bytes after warmup. Any non-zero allocation delta on a hot-path dispatch scenario requires an explanation, a fix, or an explicit reviewer-approved exception. -The opt-in smoke gate uses `progress/perf-baseline-2026-05-05.csv`, requires an -exact `25a4dcc` row for the current scenario and platform identity, and fails -when a within-platform regression exceeds the configured threshold. Enable it +The opt-in smoke gate reads `DX_PERF_BASELINE`, requires a row for the configured +comparison commit on the current scenario and platform identity, and fails when +a within-platform regression exceeds the configured threshold. Enable the gate with: ```bash -DX_PERF_GATE=1 unity -batchmode -runTests -testPlatform editmode -testCategory "PerfGate" +DX_PERF_GATE=1 \ +DX_PERF_BASELINE= \ +DX_PERF_BASELINE_COMMIT= \ +unity -batchmode -runTests -testPlatform editmode -testCategory "PerfGate" ``` -The smoke gate is an EditMode test category. The T1 clock-read scaffold remains -`[Test, Explicit]` before T1.1, but it is not tagged `PerfGate` until the -sample-not-call sweep gate lands. -Before T0.3 baseline capture creates `progress/perf-baseline-2026-05-05.csv`, -the smoke gate reports an inconclusive skip instead of failing the suite for a -missing baseline file. +The smoke gate is an EditMode test category. If the gate is enabled without +`DX_PERF_BASELINE` or `DX_PERF_BASELINE_COMMIT`, or if the configured CSV is +missing, it reports an inconclusive skip instead of failing the suite for a +missing local artifact. ## Hot-Path PR Rule -Any pull request that touches one of these paths must include before/after T0 -numbers in the PR description under `### Performance numbers`: +Any pull request that touches one of these paths must include before/after +throughput numbers in the PR description under `### Performance numbers`: - `Runtime/Core/MessageBus/MessageBus.cs` - `Runtime/Core/MessageHandler.cs` @@ -163,9 +166,9 @@ Use this shape: ```markdown ### Performance numbers -| Scenario | Baseline (commit 25a4dcc) | This PR | Delta | -| ---------------------------------------- | ------------------------- | ---------------- | ----- | -| UntargetedFlood_OneHandler (Mono Editor) | X.XX M emits/sec | Y.YY M emits/sec | +Z.Z% | +| Scenario | Baseline run | This PR | Delta | +| ---------------------------------------- | ---------------- | ---------------- | ----- | +| UntargetedFlood_OneHandler (Mono Editor) | X.XX M emits/sec | Y.YY M emits/sec | +Z.Z% | ``` The workflow accepts either the table shape above with at least one populated From 733060e756fa4fe436fed42770b4f9d079529491 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 10:35:32 -0700 Subject: [PATCH 08/16] Add new perf baseline --- ...nity-perf-baseline-script-contract.test.js | 189 ++++++++++++++++ ...perf-baseline-script-contract.test.js.meta | 7 + .../unity-runner-script-contract.test.js | 8 + scripts/unity/capture-perf-baseline.ps1 | 201 ++++++++++++++++++ scripts/unity/capture-perf-baseline.ps1.meta | 7 + scripts/unity/run-tests.ps1 | 1 + scripts/unity/run-tests.sh | 1 + 7 files changed, 414 insertions(+) create mode 100644 scripts/__tests__/unity-perf-baseline-script-contract.test.js create mode 100644 scripts/__tests__/unity-perf-baseline-script-contract.test.js.meta create mode 100644 scripts/unity/capture-perf-baseline.ps1 create mode 100644 scripts/unity/capture-perf-baseline.ps1.meta diff --git a/scripts/__tests__/unity-perf-baseline-script-contract.test.js b/scripts/__tests__/unity-perf-baseline-script-contract.test.js new file mode 100644 index 00000000..011c781e --- /dev/null +++ b/scripts/__tests__/unity-perf-baseline-script-contract.test.js @@ -0,0 +1,189 @@ +"use strict"; + +const childProcess = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const SCRIPT_PATH = path.join(REPO_ROOT, "scripts", "unity", "capture-perf-baseline.ps1"); +const REAL_PWSH = (() => { + const result = childProcess.spawnSync("pwsh", ["-NoProfile", "-Command", "(Get-Command pwsh).Source"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + + if (result.status !== 0) { + return null; + } + + return result.stdout.trim(); +})(); + +function makeTempToolDir() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-perf-baseline-wrapper-")); + const binDir = path.join(tempRoot, "bin"); + fs.mkdirSync(binDir); + + const pwshMarker = path.join(tempRoot, "fake-pwsh.json"); + const nodeMarker = path.join(tempRoot, "fake-node.json"); + + const fakePwsh = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf \'{"commit":%s}\\n\' "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_COMMIT || \"\"))\')" > "$FAKE_PWSH_MARKER"', + 'printf "%s\\n" "fake unity stdout for $DX_PERF_COMMIT"', + 'printf "%s\\n" "fake unity stderr for $DX_PERF_COMMIT" >&2', + 'exit "${FAKE_PWSH_EXIT:-0}"', + "", + ].join("\n"); + + const fakeNode = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf "%s\\n" "node invoked" > "$FAKE_NODE_MARKER"', + 'exit "${FAKE_NODE_EXIT:-0}"', + "", + ].join("\n"); + + fs.writeFileSync(path.join(binDir, "pwsh"), fakePwsh, { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, "node"), fakeNode, { mode: 0o755 }); + + return { tempRoot, binDir, pwshMarker, nodeMarker }; +} + +function runCapture(args, tools, extraEnv = {}) { + return childProcess.spawnSync(REAL_PWSH, ["-NoProfile", "-File", SCRIPT_PATH, ...args], { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + FAKE_NODE_MARKER: tools.nodeMarker, + FAKE_PWSH_MARKER: tools.pwshMarker, + FAKE_REAL_NODE: process.execPath, + PATH: `${tools.binDir}${path.delimiter}${process.env.PATH}`, + }, + }); +} + +function cleanupPerfArtifacts(commit) { + const artifactToken = commit.replace(/[^A-Za-z0-9_.-]/g, "-"); + for (const suffix of ["results.xml", "unity-log.txt"]) { + fs.rmSync(path.join(REPO_ROOT, ".artifacts", `perf-${artifactToken}-${suffix}`), { + force: true, + }); + } +} + +describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { + let content; + + beforeAll(() => { + expect(fs.existsSync(SCRIPT_PATH)).toBe(true); + content = fs.readFileSync(SCRIPT_PATH, "utf8"); + }); + + test("accepts or prompts for a commit and exports DX_PERF_COMMIT", () => { + expect(content).toContain("[Parameter(Position = 0)]"); + expect(content).toContain("Read-Host 'Commit/ref for DX_PERF_COMMIT'"); + expect(content).toContain("$env:DX_PERF_COMMIT = $Commit"); + expect(content).toContain("$previousDxPerfCommit = $env:DX_PERF_COMMIT"); + }); + + test("runs playmode performance benchmarks through the PowerShell Unity runner", () => { + expect(content).toContain("run-tests.ps1"); + expect(content).toContain("-Platform playmode"); + expect(content).toContain("-IncludePerf"); + expect(content).toContain( + "DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*" + ); + expect(content).not.toContain("run-tests.sh"); + }); + + test("tees Unity stdout and stderr to a commit-specific artifacts log", () => { + expect(content).toContain("2>&1 |"); + expect(content).toContain("Tee-Object -FilePath $logPath"); + expect(content).toContain('Join-Path $ArtifactsDir "perf-$artifactToken-unity-log.txt"'); + }); + + test("uses commit-specific results and writes the shared baseline CSV", () => { + expect(content).toContain('Join-Path $ArtifactsDir "perf-$artifactToken-results.xml"'); + expect(content).toContain("[string]$Output = '.artifacts/perf-baseline.csv'"); + expect(content).toContain("$artifactToken = $Commit -replace '[^A-Za-z0-9_.-]', '-'"); + }); + + test("extracts baseline rows from both the log and NUnit XML inputs", () => { + expect(content).toContain("extract-perf-baseline.js"); + expect(content).toContain("'--input', $logPath"); + expect(content).toContain("'--input', $resultsPath"); + expect(content).toContain("'--output', $Output"); + expect(content).toContain("$extractArgs += '--append'"); + expect(content).toContain("$extractArgs += '--replace'"); + }); + + test("fails before invoking pwsh when output exists without append or replace", () => { + if (!REAL_PWSH) { + return; + } + + const tools = makeTempToolDir(); + const outputPath = path.join(tools.tempRoot, "existing-baseline.csv"); + fs.writeFileSync(outputPath, "Benchmark,Median\n"); + + const result = runCapture(["-Commit", "collision-test", "-Output", outputPath], tools); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("Output already exists"); + expect(fs.existsSync(tools.pwshMarker)).toBe(false); + expect(fs.existsSync(tools.nodeMarker)).toBe(false); + }); + + test("exports DX_PERF_COMMIT to pwsh and tees its output to the Unity log", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "behavior-tee-test"; + const tools = makeTempToolDir(); + const outputPath = path.join(tools.tempRoot, "baseline.csv"); + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools); + const logPath = path.join(REPO_ROOT, ".artifacts", `perf-${commit}-unity-log.txt`); + + expect(result.status).toBe(0); + expect(JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")).commit).toBe(commit); + expect(result.stdout).toContain(`fake unity stdout for ${commit}`); + expect(result.stdout).toContain(`fake unity stderr for ${commit}`); + expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stdout for ${commit}`); + expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stderr for ${commit}`); + expect(fs.existsSync(tools.nodeMarker)).toBe(true); + } finally { + cleanupPerfArtifacts(commit); + } + }); + + test("propagates a nonzero pwsh exit and does not invoke node", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "failing-unity-test"; + const tools = makeTempToolDir(); + const outputPath = path.join(tools.tempRoot, "baseline.csv"); + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools, { + FAKE_PWSH_EXIT: "37", + }); + + expect(result.status).toBe(37); + expect(result.stdout).toContain("Unity perf run failed with exit code 37"); + expect(fs.existsSync(tools.pwshMarker)).toBe(true); + expect(fs.existsSync(tools.nodeMarker)).toBe(false); + } finally { + cleanupPerfArtifacts(commit); + } + }); +}); diff --git a/scripts/__tests__/unity-perf-baseline-script-contract.test.js.meta b/scripts/__tests__/unity-perf-baseline-script-contract.test.js.meta new file mode 100644 index 00000000..e14a610f --- /dev/null +++ b/scripts/__tests__/unity-perf-baseline-script-contract.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 472ed4cdec09cb467bfcae9d7f5d7ef8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js index 564f86a4..78a43b88 100644 --- a/scripts/__tests__/unity-runner-script-contract.test.js +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -87,6 +87,10 @@ describe("scripts/unity/run-tests.sh contract", () => { expect(content).not.toContain("personal-email"); }); + test("forwards the perf commit environment into Unity containers", () => { + expect(content).toContain("-e DX_PERF_COMMIT"); + }); + test("standalone player run forwards the same assembly and filter controls", () => { const standaloneRun = content.slice(content.indexOf("build_standalone_run_cmd_inner")); expect(standaloneRun).toContain("-assemblyNames"); @@ -191,6 +195,10 @@ describe("scripts/unity/run-tests.ps1 contract", () => { expect(content).not.toContain("personal-email"); }); + test("forwards the perf commit environment into Unity containers", () => { + expect(content).toContain("'-e', 'DX_PERF_COMMIT'"); + }); + test("standalone player run forwards the same assembly and filter controls", () => { const standaloneRun = content.slice(content.indexOf("Get-StandaloneRunCommandInner")); expect(standaloneRun).toContain("-assemblyNames"); diff --git a/scripts/unity/capture-perf-baseline.ps1 b/scripts/unity/capture-perf-baseline.ps1 new file mode 100644 index 00000000..2250f3c5 --- /dev/null +++ b/scripts/unity/capture-perf-baseline.ps1 @@ -0,0 +1,201 @@ +<# + .SYNOPSIS + Capture a Unity performance baseline for DispatchThroughputBenchmarks. + + .DESCRIPTION + Runs the playmode DispatchThroughputBenchmarks perf suite for a commit, + tees the Unity output to a commit-specific log file, and extracts the + normalized baseline CSV via scripts/unity/extract-perf-baseline.js. + + .PARAMETER Commit + Commit/ref value to expose to Unity as DX_PERF_COMMIT. When omitted, + the script prompts interactively. + + .PARAMETER Output + Baseline CSV output path. Defaults to .artifacts/perf-baseline.csv. + + .PARAMETER Append + Append extracted rows to an existing baseline CSV. + + .PARAMETER Replace + Replace an existing baseline CSV. + + .PARAMETER Help + Show detailed help and exit. + + .EXAMPLE + pwsh -NoProfile -File scripts/unity/capture-perf-baseline.ps1 ` + -Commit bf448fe84872022343260cb636409a9d3831bdec + + .EXAMPLE + pwsh -NoProfile -File scripts/unity/capture-perf-baseline.ps1 -Append +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$Commit, + + [string]$Output = '.artifacts/perf-baseline.csv', + + [switch]$Append, + + [switch]$Replace, + + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +# cspell:ignore PSHOME + +if ($Help) { + Get-Help $PSCommandPath -Detailed + exit 0 +} + +if ($Append -and $Replace) { + Write-Host 'ERROR: -Append and -Replace cannot both be specified.' -ForegroundColor Red + exit 2 +} + +if ([string]::IsNullOrWhiteSpace($Commit)) { + $Commit = Read-Host 'Commit/ref for DX_PERF_COMMIT' +} +$Commit = $Commit.Trim() + +if ([string]::IsNullOrWhiteSpace($Commit)) { + Write-Host 'ERROR: -Commit is required.' -ForegroundColor Red + exit 2 +} + +$ScriptDir = Split-Path -Parent $PSCommandPath +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path +$ArtifactsDir = Join-Path $RepoRoot '.artifacts' + +if (-not (Test-Path $ArtifactsDir)) { + New-Item -ItemType Directory -Path $ArtifactsDir -Force | Out-Null +} + +$artifactToken = $Commit -replace '[^A-Za-z0-9_.-]', '-' +$resultsPath = Join-Path $ArtifactsDir "perf-$artifactToken-results.xml" +$logPath = Join-Path $ArtifactsDir "perf-$artifactToken-unity-log.txt" +$runnerPath = Join-Path $ScriptDir 'run-tests.ps1' +$extractorPath = Join-Path $ScriptDir 'extract-perf-baseline.js' +$filter = 'DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*' + +if (-not [System.IO.Path]::IsPathRooted($Output)) { + $Output = Join-Path $RepoRoot $Output +} +$outputDir = Split-Path -Parent $Output +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null +} + +if ((Test-Path -LiteralPath $Output) -and -not $Append -and -not $Replace) { + Write-Host "ERROR: Output already exists: $Output. Specify -Append or -Replace." -ForegroundColor Red + exit 2 +} + +function Find-ExecutableOnPath { + param( + [Parameter(Mandatory = $true)] + [string]$CommandName + ) + + $pathValue = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrWhiteSpace($pathValue)) { + return $null + } + + $extensions = @('') + if ($IsWindows) { + $pathExtValue = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrWhiteSpace($pathExtValue)) { + $extensions = @('.exe', '.cmd', '.bat', '') + } else { + $extensions = $pathExtValue.Split([System.IO.Path]::PathSeparator) + '' + } + } + + $fallbackCandidate = $null + foreach ($pathEntry in $pathValue.Split([System.IO.Path]::PathSeparator)) { + if ([string]::IsNullOrWhiteSpace($pathEntry)) { + continue + } + + foreach ($extension in $extensions) { + $candidate = Join-Path $pathEntry "$CommandName$extension" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $resolvedCandidate = (Resolve-Path -LiteralPath $candidate).Path + if (-not $fallbackCandidate) { + $fallbackCandidate = $resolvedCandidate + } + + $candidateDir = Split-Path -Parent $resolvedCandidate + if (-not $PSHOME -or $candidateDir -ne $PSHOME) { + return $resolvedCandidate + } + } + } + } + + return $fallbackCandidate +} + +$pwshPath = Find-ExecutableOnPath 'pwsh' +if (-not $pwshPath) { + Write-Host 'ERROR: pwsh is required to run the Unity PowerShell test runner.' -ForegroundColor Red + exit 1 +} + +$previousDxPerfCommit = $env:DX_PERF_COMMIT +$hadDxPerfCommit = Test-Path Env:DX_PERF_COMMIT +$env:DX_PERF_COMMIT = $Commit + +try { + Write-Host "Running DispatchThroughputBenchmarks for $Commit" + Write-Host "Unity results: $resultsPath" + Write-Host "Unity log: $logPath" + + & $pwshPath -NoProfile -File $runnerPath ` + -Platform playmode ` + -IncludePerf ` + -Filter $filter ` + -Results $resultsPath 2>&1 | + Tee-Object -FilePath $logPath + + $unityExitCode = $LASTEXITCODE + if ($unityExitCode -ne 0) { + Write-Host "ERROR: Unity perf run failed with exit code $unityExitCode." -ForegroundColor Red + exit $unityExitCode + } + + $extractArgs = @( + $extractorPath, + '--input', $logPath, + '--input', $resultsPath, + '--output', $Output + ) + if ($Append) { + $extractArgs += '--append' + } elseif ($Replace) { + $extractArgs += '--replace' + } + + & node @extractArgs + $extractExitCode = $LASTEXITCODE + if ($extractExitCode -ne 0) { + Write-Host "ERROR: Baseline extraction failed with exit code $extractExitCode." -ForegroundColor Red + exit $extractExitCode + } + + Write-Host "Baseline CSV: $Output" +} +finally { + if ($hadDxPerfCommit) { + $env:DX_PERF_COMMIT = $previousDxPerfCommit + } else { + Remove-Item Env:DX_PERF_COMMIT -ErrorAction SilentlyContinue + } +} diff --git a/scripts/unity/capture-perf-baseline.ps1.meta b/scripts/unity/capture-perf-baseline.ps1.meta new file mode 100644 index 00000000..521acf24 --- /dev/null +++ b/scripts/unity/capture-perf-baseline.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fc303a20c1a959f7041063c60e701cb1 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/run-tests.ps1 b/scripts/unity/run-tests.ps1 index 049d1a6a..7fdedcd6 100644 --- a/scripts/unity/run-tests.ps1 +++ b/scripts/unity/run-tests.ps1 @@ -531,6 +531,7 @@ $dockerBaseArgs = @( '-e', 'UNITY_SERIAL', '-e', 'UNITY_EMAIL', '-e', 'UNITY_PASSWORD', + '-e', 'DX_PERF_COMMIT', '-e', "USER_UID=$UserUid", '-e', "USER_GID=$UserGid" ) diff --git a/scripts/unity/run-tests.sh b/scripts/unity/run-tests.sh index 13781e97..4f3fc7f7 100644 --- a/scripts/unity/run-tests.sh +++ b/scripts/unity/run-tests.sh @@ -567,6 +567,7 @@ DOCKER_BASE_ARGS=( -e UNITY_SERIAL -e UNITY_EMAIL -e UNITY_PASSWORD + -e DX_PERF_COMMIT -e "USER_UID=${USER_UID_VAL}" -e "USER_GID=${USER_GID_VAL}" ) From 11265459e1ca8f5fd6a9776d3131495067252dd6 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 10:41:42 -0700 Subject: [PATCH 09/16] Fix tests --- .../unity-runner-script-contract.test.js | 24 ++++++++++ scripts/unity/run-tests.ps1 | 44 +++++++++++++++++-- scripts/unity/run-tests.sh | 25 +++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js index 78a43b88..d6099385 100644 --- a/scripts/__tests__/unity-runner-script-contract.test.js +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -87,6 +87,15 @@ describe("scripts/unity/run-tests.sh contract", () => { expect(content).not.toContain("personal-email"); }); + test("auto-loads common local Unity license files before failing", () => { + expect(content).toContain("UNITY_LICENSE_FILE"); + expect(content).toContain("ProgramData"); + expect(content).toContain("LOCALAPPDATA"); + expect(content).toContain(".local/share/unity3d/Unity/Unity_lic.ulf"); + expect(content).toContain("Library/Application Support/Unity/Unity_lic.ulf"); + expect(content).toContain('UNITY_LICENSE="$(cat "${license_path}")"'); + }); + test("forwards the perf commit environment into Unity containers", () => { expect(content).toContain("-e DX_PERF_COMMIT"); }); @@ -195,6 +204,21 @@ describe("scripts/unity/run-tests.ps1 contract", () => { expect(content).not.toContain("personal-email"); }); + test("auto-loads common local Unity license files before failing", () => { + expect(content).toContain("UNITY_LICENSE_FILE"); + expect(content).toContain("ProgramData"); + expect(content).toContain("LOCALAPPDATA"); + expect(content).toContain(".local/share/unity3d/Unity/Unity_lic.ulf"); + expect(content).toContain("Library/Application Support/Unity/Unity_lic.ulf"); + expect(content).toContain("Get-Content -LiteralPath $licensePath -Raw"); + }); + + test("normalizes generated bash payloads to LF for docker bash", () => { + expect(content).toContain("function ConvertTo-BashScriptText"); + expect(content).toContain('return $Value.Replace("`r`n", "`n")'); + expect(content).toContain("return ConvertTo-BashScriptText $sb.ToString()"); + }); + test("forwards the perf commit environment into Unity containers", () => { expect(content).toContain("'-e', 'DX_PERF_COMMIT'"); }); diff --git a/scripts/unity/run-tests.ps1 b/scripts/unity/run-tests.ps1 index 7fdedcd6..1d7603e6 100644 --- a/scripts/unity/run-tests.ps1 +++ b/scripts/unity/run-tests.ps1 @@ -269,6 +269,39 @@ Remediation: # License activation: auto-detect ULF vs paid serial vs failure. # Mirrors the bash script: current Unity/GameCI behavior does not support # email/password-only Personal headless activation in docker. +function Get-UnityLicenseFileCandidates { + $candidates = @() + + if (-not [string]::IsNullOrWhiteSpace($env:UNITY_LICENSE_FILE)) { + $candidates += $env:UNITY_LICENSE_FILE + } + + if (-not [string]::IsNullOrWhiteSpace($env:ProgramData)) { + $candidates += (Join-Path $env:ProgramData 'Unity/Unity_lic.ulf') + } + + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $candidates += (Join-Path $env:LOCALAPPDATA 'Unity/Unity_lic.ulf') + } + + if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { + $candidates += (Join-Path $env:HOME '.local/share/unity3d/Unity/Unity_lic.ulf') + $candidates += (Join-Path $env:HOME 'Library/Application Support/Unity/Unity_lic.ulf') + } + + return $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique +} + +if ([string]::IsNullOrEmpty($env:UNITY_LICENSE) -and [string]::IsNullOrEmpty($env:UNITY_LICENSE_B64)) { + foreach ($licensePath in Get-UnityLicenseFileCandidates) { + if (Test-Path -LiteralPath $licensePath -PathType Leaf) { + $env:UNITY_LICENSE = Get-Content -LiteralPath $licensePath -Raw + Write-Host "[run-tests] loaded UNITY_LICENSE from $licensePath" + break + } + } +} + $LicenseMode = '' if (-not [string]::IsNullOrEmpty($env:UNITY_LICENSE)) { $LicenseMode = 'ulf' @@ -408,6 +441,11 @@ function ConvertTo-BashSingleQuotedString { return "'" + ($Value -replace "'", "'\''") + "'" } +function ConvertTo-BashScriptText { + param([string]$Value) + return $Value.Replace("`r`n", "`n") +} + function Get-EditorCommandInner { $sb = [System.Text.StringBuilder]::new() $projectPathQ = ConvertTo-BashSingleQuotedString '/workspace/.unity-test-project' @@ -443,7 +481,7 @@ function Get-EditorCommandInner { [void]$sb.AppendLine(' -username "${UNITY_EMAIL}" -password "${UNITY_PASSWORD}" -serial "${UNITY_SERIAL}" \') } [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt') - return $sb.ToString() + return ConvertTo-BashScriptText $sb.ToString() } function Get-StandaloneBuildCommandInner { @@ -481,7 +519,7 @@ function Get-StandaloneBuildCommandInner { [void]$sb.AppendLine(' -username "${UNITY_EMAIL}" -password "${UNITY_PASSWORD}" -serial "${UNITY_SERIAL}" \') } [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/build-log.txt') - return $sb.ToString() + return ConvertTo-BashScriptText $sb.ToString() } function Get-StandaloneRunCommandInner { @@ -509,7 +547,7 @@ function Get-StandaloneRunCommandInner { [void]$sb.AppendLine(" -testFilter $filterQ \") } [void]$sb.AppendLine(' -logFile - 2>&1 | tee /workspace/.artifacts/unity/log.txt') - return $sb.ToString() + return ConvertTo-BashScriptText $sb.ToString() } # --------------------------------------------------------------------------- diff --git a/scripts/unity/run-tests.sh b/scripts/unity/run-tests.sh index 4f3fc7f7..2b9062d9 100644 --- a/scripts/unity/run-tests.sh +++ b/scripts/unity/run-tests.sh @@ -304,6 +304,31 @@ fi # headless activation in docker. Personal users need a .ulf in UNITY_LICENSE # (raw) or UNITY_LICENSE_B64 (local convenience). Paid users may use # UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD. +if [[ -z "${UNITY_LICENSE:-}" ]] && [[ -z "${UNITY_LICENSE_B64:-}" ]]; then + UNITY_LICENSE_CANDIDATES=() + if [[ -n "${UNITY_LICENSE_FILE:-}" ]]; then + UNITY_LICENSE_CANDIDATES+=("${UNITY_LICENSE_FILE}") + fi + if [[ -n "${ProgramData:-}" ]]; then + UNITY_LICENSE_CANDIDATES+=("${ProgramData}/Unity/Unity_lic.ulf") + fi + if [[ -n "${LOCALAPPDATA:-}" ]]; then + UNITY_LICENSE_CANDIDATES+=("${LOCALAPPDATA}/Unity/Unity_lic.ulf") + fi + if [[ -n "${HOME:-}" ]]; then + UNITY_LICENSE_CANDIDATES+=("${HOME}/.local/share/unity3d/Unity/Unity_lic.ulf") + UNITY_LICENSE_CANDIDATES+=("${HOME}/Library/Application Support/Unity/Unity_lic.ulf") + fi + for license_path in "${UNITY_LICENSE_CANDIDATES[@]}"; do + if [[ -f "${license_path}" ]]; then + UNITY_LICENSE="$(cat "${license_path}")" + export UNITY_LICENSE + printf '[run-tests] loaded UNITY_LICENSE from %s\n' "${license_path}" + break + fi + done +fi + LICENSE_MODE="" if [[ -n "${UNITY_LICENSE:-}" ]]; then LICENSE_MODE="ulf" From cb81783530fb5310a97759493095eab48080e77a Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 10:46:44 -0700 Subject: [PATCH 10/16] Fix windows runner --- .../unity-runner-script-contract.test.js | 16 ++- scripts/unity/run-tests.ps1 | 112 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js index d6099385..9aeb6caa 100644 --- a/scripts/__tests__/unity-runner-script-contract.test.js +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -163,7 +163,8 @@ describe("scripts/unity/run-tests.ps1 contract", () => { ["UnityVersion"], ["IncludePerf"], ["IncludeIntegrations"], - ["IncludeComparisons"] + ["IncludeComparisons"], + ["Runner"] ])("declares parameter %s (as $Name and -Name)", (paramName) => { // PowerShell convention: `param([type]$Name)` declares the parameter, // callers pass it as `-Name`. We require BOTH forms: the variable @@ -219,6 +220,19 @@ describe("scripts/unity/run-tests.ps1 contract", () => { expect(content).toContain("return ConvertTo-BashScriptText $sb.ToString()"); }); + test("supports local Windows Unity execution for editmode and playmode", () => { + expect(content).toMatch(/\[ValidateSet\(\s*'auto'\s*,\s*'docker'\s*,\s*'local'\s*\)\]/); + expect(content).toContain("function Find-UnityEditorPath"); + expect(content).toContain("UNITY_EDITOR_PATH"); + expect(content).toContain("UNITY_PATH"); + expect(content).toContain("Unity/Hub/Editor/$Version/Editor/Unity.exe"); + expect(content).toContain("function Invoke-LocalUnityTests"); + expect(content).toContain("Launching local Unity"); + expect(content).toContain("$IsWindows -and $Platform -ne 'standalone'"); + expect(content).toContain("$ResolvedRunner = 'local'"); + expect(content).toContain("-Runner local does not support standalone"); + }); + test("forwards the perf commit environment into Unity containers", () => { expect(content).toContain("'-e', 'DX_PERF_COMMIT'"); }); diff --git a/scripts/unity/run-tests.ps1 b/scripts/unity/run-tests.ps1 index 1d7603e6..d0e68faa 100644 --- a/scripts/unity/run-tests.ps1 +++ b/scripts/unity/run-tests.ps1 @@ -53,6 +53,10 @@ (default: .artifacts/unity/results.xml). Must be within the repo root (the docker bind-mount only exposes the repo root). + .PARAMETER Runner + auto | docker | local. auto uses local Unity on Windows for editmode + and playmode, and docker elsewhere. standalone always uses docker. + .EXAMPLE pwsh -NoProfile -File scripts/unity/run-tests.ps1 -Platform editmode @@ -88,6 +92,9 @@ param( [string]$Results, + [ValidateSet('auto', 'docker', 'local')] + [string]$Runner = 'auto', + [switch]$Help ) @@ -219,6 +226,111 @@ if ($env:CI -eq 'true') { return } +function Find-UnityEditorPath { + param([string]$Version) + + foreach ($envName in @('UNITY_EDITOR_PATH', 'UNITY_PATH')) { + $envValue = [Environment]::GetEnvironmentVariable($envName) + if (-not [string]::IsNullOrWhiteSpace($envValue) -and + (Test-Path -LiteralPath $envValue -PathType Leaf)) { + return (Resolve-Path -LiteralPath $envValue).Path + } + } + + $candidates = @() + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ([string]::IsNullOrWhiteSpace($root)) { + continue + } + + if (-not [string]::IsNullOrWhiteSpace($Version)) { + $candidates += (Join-Path $root "Unity/Hub/Editor/$Version/Editor/Unity.exe") + } + + $hubRoot = Join-Path $root 'Unity/Hub/Editor' + if (Test-Path -LiteralPath $hubRoot -PathType Container) { + $candidates += Get-ChildItem -LiteralPath $hubRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName 'Editor/Unity.exe' } + } + } + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + + return '' +} + +function Invoke-LocalUnityTests { + $unityPath = Find-UnityEditorPath $UnityVersion + if ([string]::IsNullOrWhiteSpace($unityPath)) { + Write-Host @" +ERROR: Could not find a local Unity Editor. +Set UNITY_EDITOR_PATH to your Unity.exe path, for example: + `$env:UNITY_EDITOR_PATH = 'C:\Program Files\Unity\Hub\Editor\$UnityVersion\Editor\Unity.exe' +"@ -ForegroundColor Red + exit 1 + } + + $projectPath = Join-Path $RepoRoot '.unity-test-project' + $unityArgs = @( + '-batchmode', + '-nographics', + '-projectPath', $projectPath, + '-runTests', + '-testPlatform', $Platform, + '-testResults', $Results, + '-assemblyNames', $Assemblies + ) + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $unityArgs += @('-testFilter', $Filter) + } + $unityArgs += @('-logFile', '-') + + Write-Host "Launching local Unity: $unityPath" -ForegroundColor Cyan + Write-Host " platform=$Platform assemblies=$Assemblies" + Write-Host " results=$Results log=$ArtifactsDir/log.txt" + Write-Host " perf=$($IncludePerf.IsPresent) comparisons=$($IncludeComparisons.IsPresent) integrations=$($IncludeIntegrations.IsPresent) filter=$Filter" + + & $unityPath @unityArgs 2>&1 | Tee-Object -FilePath (Join-Path $ArtifactsDir 'log.txt') + $UnityExit = $LASTEXITCODE + if ($UnityExit -ne 0) { + if (-not (Test-Path $Results)) { + Write-Host "No results.xml at $Results" -ForegroundColor Yellow + } + Write-Host "Unity exited with code $UnityExit." -ForegroundColor Red + exit $UnityExit + } + + if (-not (Test-Path $Results)) { + Write-Host "Unity exited 0 but no results.xml was produced at $Results." -ForegroundColor Red + exit 1 + } +} + +$ResolvedRunner = $Runner +if ($ResolvedRunner -eq 'auto') { + if ($IsWindows -and $Platform -ne 'standalone') { + $ResolvedRunner = 'local' + } else { + $ResolvedRunner = 'docker' + } +} +if ($ResolvedRunner -eq 'local') { + if ($Platform -eq 'standalone') { + Write-Host 'ERROR: -Runner local does not support standalone; use -Runner docker.' -ForegroundColor Red + exit 2 + } + if (-not (Test-Path $ArtifactsDir)) { + New-Item -ItemType Directory -Path $ArtifactsDir -Force | Out-Null + } + Invoke-LocalUnityTests + return +} + # --------------------------------------------------------------------------- # Argument-level path validation (m5: before docker/license checks so users # see the most relevant error first regardless of system state). From 5595d591c9f86fe2265939f96d3924707dd5bb6b Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 14:12:37 -0700 Subject: [PATCH 11/16] Perf test improvements --- .unity-test-project/Packages/manifest.json | 12 +- .../Packages/packages-lock.json | 225 ++-------- .../DispatchThroughputBenchmarks.cs | 390 ++++++++++++++++++ docs/architecture/comparisons.md | 8 +- docs/architecture/performance.md | 22 +- ...nity-perf-baseline-script-contract.test.js | 279 +++++++++++-- .../unity-runner-script-contract.test.js | 17 +- scripts/unity/capture-perf-baseline.ps1 | 116 ++++-- scripts/unity/run-tests.ps1 | 35 +- scripts/unity/run-tests.sh | 35 +- 10 files changed, 843 insertions(+), 296 deletions(-) diff --git a/.unity-test-project/Packages/manifest.json b/.unity-test-project/Packages/manifest.json index d7d84606..62a1e3ea 100644 --- a/.unity-test-project/Packages/manifest.json +++ b/.unity-test-project/Packages/manifest.json @@ -1,10 +1,14 @@ { "dependencies": { - "com.unity.test-framework": "1.4.5", + "com.unity.ide.rider": "3.0.38", + "com.unity.ide.visualstudio": "2.0.25", + "com.unity.multiplayer.center": "1.0.1", + "com.unity.test-framework": "1.6.0", "com.unity.test-framework.performance": "3.4.2", - "com.unity.ide.rider": "3.0.31", - "com.unity.ide.visualstudio": "2.0.22", - "com.wallstop-studios.dxmessaging": "file:../.." + "com.wallstop-studios.dxmessaging": "file:../..", + "com.unity.modules.accessibility": "1.0.0", + "com.unity.modules.adaptiveperformance": "1.0.0", + "com.unity.modules.vectorgraphics": "1.0.0" }, "scopedRegistries": [], "testables": ["com.wallstop-studios.dxmessaging"] diff --git a/.unity-test-project/Packages/packages-lock.json b/.unity-test-project/Packages/packages-lock.json index 8ea59f7a..b64146d1 100644 --- a/.unity-test-project/Packages/packages-lock.json +++ b/.unity-test-project/Packages/packages-lock.json @@ -3,38 +3,44 @@ "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" + "source": "builtin", + "dependencies": {} }, "com.unity.ide.rider": { - "version": "3.0.31", + "version": "3.0.38", "depth": 0, "source": "registry", "dependencies": { - "com.unity.ext.nunit": "2.0.5" + "com.unity.ext.nunit": "1.0.6" }, "url": "https://packages.unity.com" }, "com.unity.ide.visualstudio": { - "version": "2.0.22", + "version": "2.0.25", "depth": 0, "source": "registry", "dependencies": { - "com.unity.test-framework": "1.1.9" + "com.unity.test-framework": "1.1.31" }, "url": "https://packages.unity.com" }, + "com.unity.multiplayer.center": { + "version": "1.0.1", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.uielements": "1.0.0" + } + }, "com.unity.test-framework": { - "version": "1.4.5", + "version": "1.6.0", "depth": 0, - "source": "registry", + "source": "builtin", "dependencies": { - "com.unity.ext.nunit": "2.0.5", + "com.unity.ext.nunit": "2.0.3", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" + } }, "com.unity.test-framework.performance": { "version": "3.4.2", @@ -52,241 +58,84 @@ "source": "local", "dependencies": {} }, - "com.unity.modules.ai": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.androidjni": { + "com.unity.modules.accessibility": { "version": "1.0.0", "depth": 0, "source": "builtin", "dependencies": {} }, - "com.unity.modules.animation": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.assetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.audio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.cloth": { + "com.unity.modules.adaptiveperformance": { "version": "1.0.0", "depth": 0, "source": "builtin", "dependencies": { - "com.unity.modules.physics": "1.0.0" + "com.unity.modules.subsystems": "1.0.0" } }, - "com.unity.modules.director": { + "com.unity.modules.hierarchycore": { "version": "1.0.0", - "depth": 0, + "depth": 2, "source": "builtin", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.animation": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } + "dependencies": {} }, "com.unity.modules.imageconversion": { "version": "1.0.0", - "depth": 0, + "depth": 1, "source": "builtin", "dependencies": {} }, "com.unity.modules.imgui": { "version": "1.0.0", - "depth": 0, + "depth": 1, "source": "builtin", "dependencies": {} }, "com.unity.modules.jsonserialize": { "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.particlesystem": { - "version": "1.0.0", - "depth": 0, + "depth": 1, "source": "builtin", "dependencies": {} }, "com.unity.modules.physics": { "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.physics2d": { - "version": "1.0.0", - "depth": 0, + "depth": 2, "source": "builtin", "dependencies": {} }, "com.unity.modules.subsystems": { "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.terrain": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.terrainphysics": { - "version": "1.0.0", - "depth": 0, + "depth": 1, "source": "builtin", "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.terrain": "1.0.0" + "com.unity.modules.jsonserialize": "1.0.0" } }, "com.unity.modules.ui": { "version": "1.0.0", - "depth": 0, + "depth": 2, "source": "builtin", "dependencies": {} }, "com.unity.modules.uielements": { "version": "1.0.0", - "depth": 0, + "depth": 1, "source": "builtin", "dependencies": { "com.unity.modules.ui": "1.0.0", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.uielementsnative": "1.0.0" - } - }, - "com.unity.modules.uielementsnative": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.umbra": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unityanalytics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.unitywebrequest": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unitywebrequestassetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestaudio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.audio": "1.0.0" - } - }, - "com.unity.modules.unitywebrequesttexture": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestwww": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.unitywebrequestassetbundle": "1.0.0", - "com.unity.modules.unitywebrequestaudio": "1.0.0", - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.vehicles": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { + "com.unity.modules.hierarchycore": "1.0.0", "com.unity.modules.physics": "1.0.0" } }, - "com.unity.modules.video": { + "com.unity.modules.vectorgraphics": { "version": "1.0.0", "depth": 0, "source": "builtin", "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.vr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.xr": "1.0.0" - } - }, - "com.unity.modules.wind": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.xr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.subsystems": "1.0.0" + "com.unity.modules.uielements": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.imgui": "1.0.0" } } } diff --git a/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs index a8286382..4259a652 100644 --- a/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs +++ b/Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs @@ -5,7 +5,10 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using System.Collections.Generic; using System.Diagnostics; using System.Globalization; + using System.IO; using System.Reflection; + using System.Text; + using System.Text.RegularExpressions; using DxMessaging.Core; using DxMessaging.Core.MessageBus; using DxMessaging.Tests.Runtime.Scripts.Messages; @@ -35,6 +38,11 @@ public enum DispatchBenchmarkScenario public sealed class DispatchThroughputBenchmarks { + private const string BaselineOutputEnvVar = "DX_PERF_BASELINE"; + private const string BaselineModeEnvVar = "DX_PERF_BASELINE_MODE"; + private const string PackageName = "com.wallstop-studios.dxmessaging"; + private const string BaselineCsvHeader = + "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs"; private const int WarmupEmits = 10_000; private const int MedianRuns = 5; private static readonly TimeSpan MeasurementWindow = TimeSpan.FromSeconds(1); @@ -99,6 +107,30 @@ public void RegistrationFlood1000TypesFromColdBus() _ = RunScenario(DispatchBenchmarkScenario.RegistrationFlood1000TypesFromColdBus); } + [Test, Explicit, Performance, Category("PerfBaseline")] + public void UpdateDispatchThroughputBaseline() + { + string outputPath = ResolveBaselineOutputPath(); + bool replaceAllRows = string.Equals( + Environment.GetEnvironmentVariable(BaselineModeEnvVar), + "replace", + StringComparison.OrdinalIgnoreCase + ); + + List results = new(); + foreach ( + DispatchBenchmarkScenario scenario in Enum.GetValues( + typeof(DispatchBenchmarkScenario) + ) + ) + { + results.Add(RunScenario(scenario)); + } + + WriteBaselineRows(outputPath, results, replaceAllRows); + TestContext.Out.WriteLine($"Updated performance baseline: {outputPath}"); + } + public static DispatchBenchmarkResult RunScenario( DispatchBenchmarkScenario scenario, bool logResult = true @@ -439,6 +471,247 @@ private static Action[] GetRegistrationFloodBuilders() return _registrationFloodBuilders; } + private static string ResolveBaselineOutputPath() + { + string configuredPath = Environment.GetEnvironmentVariable(BaselineOutputEnvVar); + if (string.IsNullOrWhiteSpace(configuredPath)) + { + configuredPath = ".artifacts/perf-baseline.csv"; + } + + if (Path.IsPathRooted(configuredPath)) + { + return configuredPath; + } + + string packageRoot = ResolvePackageRoot(); + string baseDirectory = packageRoot ?? ResolveUnityProjectRoot(); + return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath)); + } + + internal static string ResolvePackageRoot() + { +#if UNITY_EDITOR + string packageInfoRoot = ResolvePackageInfoRoot( + typeof(DispatchThroughputBenchmarks).Assembly + ); + if (packageInfoRoot != null) + { + return packageInfoRoot; + } + + packageInfoRoot = ResolvePackageInfoRoot(typeof(MessageBus).Assembly); + if (packageInfoRoot != null) + { + return packageInfoRoot; + } +#endif + + string[] roots = { Directory.GetCurrentDirectory(), Application.dataPath }; + for (int index = 0; index < roots.Length; index++) + { + string packageRoot = FindPackageRoot(roots[index]); + if (packageRoot != null) + { + return packageRoot; + } + } + + return null; + } + +#if UNITY_EDITOR + private static string ResolvePackageInfoRoot(Assembly assembly) + { + UnityEditor.PackageManager.PackageInfo packageInfo = + UnityEditor.PackageManager.PackageInfo.FindForAssembly(assembly); + if ( + packageInfo != null + && string.Equals(packageInfo.name, PackageName, StringComparison.Ordinal) + && Directory.Exists(packageInfo.resolvedPath) + ) + { + return FindPackageRoot(packageInfo.resolvedPath); + } + + return null; + } +#endif + + private static string FindPackageRoot(string startDirectory) + { + if (string.IsNullOrWhiteSpace(startDirectory)) + { + return null; + } + + DirectoryInfo current = new(startDirectory); + while (current != null) + { + if (IsPackageRoot(current.FullName)) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } + + private static bool IsPackageRoot(string directory) + { + string packageJsonPath = Path.Combine(directory, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return false; + } + + string packageJson = File.ReadAllText(packageJsonPath); + return Regex.IsMatch(packageJson, $"\"name\"\\s*:\\s*\"{Regex.Escape(PackageName)}\""); + } + + private static string ResolveUnityProjectRoot() + { + string assetsPath = Application.dataPath; + if (string.IsNullOrWhiteSpace(assetsPath)) + { + return Directory.GetCurrentDirectory(); + } + + return Directory.GetParent(assetsPath)?.FullName ?? Directory.GetCurrentDirectory(); + } + + private static void WriteBaselineRows( + string outputPath, + IReadOnlyList results, + bool replaceAllRows + ) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? "."); + + List rows = replaceAllRows + ? new List() + : ReadExistingBaselineRows(outputPath); + for (int index = 0; index < results.Count; index++) + { + DispatchBenchmarkResult result = results[index]; + RemoveMatchingBaselineRow(rows, result); + rows.Add(result.ToCsvRow()); + } + + rows.Sort(CompareBaselineRows); + + StringBuilder builder = new(); + builder.AppendLine(BaselineCsvHeader); + for (int index = 0; index < rows.Count; index++) + { + builder.AppendLine(rows[index]); + } + + File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(false)); + } + + private static List ReadExistingBaselineRows(string outputPath) + { + List rows = new(); + if (!File.Exists(outputPath)) + { + return rows; + } + + string[] lines = File.ReadAllLines(outputPath); + for (int index = 0; index < lines.Length; index++) + { + string line = lines[index]; + if ( + string.IsNullOrWhiteSpace(line) + || line.StartsWith("scenario,", StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + rows.Add(line); + } + + return rows; + } + + private static void RemoveMatchingBaselineRow( + List rows, + DispatchBenchmarkResult result + ) + { + for (int index = rows.Count - 1; index >= 0; index--) + { + string[] fields = ParseCsvFields(rows[index]); + if ( + fields.Length >= 3 + && string.Equals(fields[0], result.Scenario, StringComparison.Ordinal) + && string.Equals(fields[1], result.Platform, StringComparison.Ordinal) + && string.Equals(fields[2], result.Commit, StringComparison.OrdinalIgnoreCase) + ) + { + rows.RemoveAt(index); + } + } + } + + private static int CompareBaselineRows(string left, string right) + { + string[] leftFields = ParseCsvFields(left); + string[] rightFields = ParseCsvFields(right); + for (int index = 2; index >= 0; index--) + { + string leftValue = index < leftFields.Length ? leftFields[index] : string.Empty; + string rightValue = index < rightFields.Length ? rightFields[index] : string.Empty; + int comparison = string.CompareOrdinal(leftValue, rightValue); + if (comparison != 0) + { + return comparison; + } + } + + return string.CompareOrdinal(left, right); + } + + private static string[] ParseCsvFields(string line) + { + List fields = new(); + StringBuilder builder = new(); + bool inQuotes = false; + + for (int index = 0; index < line.Length; index++) + { + char value = line[index]; + if (value == '"') + { + if (inQuotes && index + 1 < line.Length && line[index + 1] == '"') + { + builder.Append('"'); + index++; + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (value == ',' && !inQuotes) + { + fields.Add(builder.ToString()); + builder.Clear(); + continue; + } + + builder.Append(value); + } + + fields.Add(builder.ToString()); + return fields.ToArray(); + } + private static void RegisterFloodMessage(MessageRegistrationToken token) { _ = token.RegisterUntargeted>(NoOpFloodHandler); @@ -768,9 +1041,126 @@ private static string ResolveCommit() } commit = Environment.GetEnvironmentVariable("GITHUB_SHA"); + if (!string.IsNullOrWhiteSpace(commit)) + { + return commit; + } + + commit = ResolveGitHeadCommit(DispatchThroughputBenchmarks.ResolvePackageRoot()); return string.IsNullOrWhiteSpace(commit) ? "local" : commit; } + private static string ResolveGitHeadCommit(string packageRoot) + { + if (string.IsNullOrWhiteSpace(packageRoot)) + { + return null; + } + + string gitPath = Path.Combine(packageRoot, ".git"); + if (File.Exists(gitPath)) + { + string gitFile = File.ReadAllText(gitPath).Trim(); + const string GitDirPrefix = "gitdir:"; + if (gitFile.StartsWith(GitDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + gitPath = gitFile.Substring(GitDirPrefix.Length).Trim(); + if (!Path.IsPathRooted(gitPath)) + { + gitPath = Path.GetFullPath(Path.Combine(packageRoot, gitPath)); + } + } + } + + string headPath = Path.Combine(gitPath, "HEAD"); + if (!File.Exists(headPath)) + { + return null; + } + + string head = File.ReadAllText(headPath).Trim(); + string commonGitPath = ResolveCommonGitPath(gitPath); + const string RefPrefix = "ref:"; + if (!head.StartsWith(RefPrefix, StringComparison.OrdinalIgnoreCase)) + { + return string.IsNullOrWhiteSpace(head) ? null : head; + } + + string refName = head.Substring(RefPrefix.Length).Trim(); + string commit = + ReadGitRefCommit(gitPath, refName) ?? ReadGitRefCommit(commonGitPath, refName); + return string.IsNullOrWhiteSpace(commit) ? null : commit; + } + + private static string ResolveCommonGitPath(string gitPath) + { + string commonDirPath = Path.Combine(gitPath, "commondir"); + if (!File.Exists(commonDirPath)) + { + return gitPath; + } + + string commonDir = File.ReadAllText(commonDirPath).Trim(); + if (string.IsNullOrWhiteSpace(commonDir)) + { + return gitPath; + } + + return Path.IsPathRooted(commonDir) + ? commonDir + : Path.GetFullPath(Path.Combine(gitPath, commonDir)); + } + + private static string ReadGitRefCommit(string gitPath, string refName) + { + if (string.IsNullOrWhiteSpace(gitPath)) + { + return null; + } + + string normalizedRefName = refName.Replace('/', Path.DirectorySeparatorChar); + string refPath = Path.Combine(gitPath, normalizedRefName); + if (File.Exists(refPath)) + { + string commit = File.ReadAllText(refPath).Trim(); + if (!string.IsNullOrWhiteSpace(commit)) + { + return commit; + } + } + + string packedRefsPath = Path.Combine(gitPath, "packed-refs"); + if (!File.Exists(packedRefsPath)) + { + return null; + } + + string[] packedRefs = File.ReadAllLines(packedRefsPath); + for (int index = 0; index < packedRefs.Length; index++) + { + string line = packedRefs[index]; + if (line.Length == 0 || line[0] == '#' || line[0] == '^') + { + continue; + } + + int separatorIndex = line.IndexOf(' '); + if ( + separatorIndex > 0 + && string.Equals( + line.Substring(separatorIndex + 1), + refName, + StringComparison.Ordinal + ) + ) + { + return line.Substring(0, separatorIndex); + } + } + + return null; + } + private static string EscapeCsv(string value) { if (value == null) diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index a3f7247e..66ffdc8a 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 17,006,569 | No | -| UniRx MessageBroker | 16,882,243 | No | -| MessagePipe (Global) | 97,768,988 | No | -| Zenject SignalBus | 2,129,280 | Yes | +| DxMessaging (Untargeted) - No-Copy | 16,802,867 | No | +| UniRx MessageBroker | 18,187,545 | No | +| MessagePipe (Global) | 95,845,160 | No | +| Zenject SignalBus | 2,468,473 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 255317ba..a4133c60 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -220,17 +220,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,578,664 | Yes | -| DxMessaging (GameObject) - Normal | 9,922,135 | No | -| DxMessaging (Component) - Normal | 9,917,199 | No | -| DxMessaging (GameObject) - No-Copy | 11,101,203 | No | -| DxMessaging (Component) - No-Copy | 8,525,796 | No | -| DxMessaging (Untargeted) - No-Copy | 17,070,253 | No | -| DxMessaging (Untargeted) - Interceptors | 7,069,814 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,845,186 | No | -| Reflexive (One Argument) | 2,771,579 | No | -| Reflexive (Two Arguments) | 2,260,609 | No | -| Reflexive (Three Arguments) | 2,186,370 | No | +| Unity | 2,466,070 | Yes | +| DxMessaging (GameObject) - Normal | 9,955,012 | No | +| DxMessaging (Component) - Normal | 10,025,723 | No | +| DxMessaging (GameObject) - No-Copy | 11,154,213 | No | +| DxMessaging (Component) - No-Copy | 8,462,079 | No | +| DxMessaging (Untargeted) - No-Copy | 16,613,774 | No | +| DxMessaging (Untargeted) - Interceptors | 7,193,011 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,519,230 | No | +| Reflexive (One Argument) | 2,733,126 | No | +| Reflexive (Two Arguments) | 2,265,041 | No | +| Reflexive (Three Arguments) | 2,292,211 | No | ## macOS diff --git a/scripts/__tests__/unity-perf-baseline-script-contract.test.js b/scripts/__tests__/unity-perf-baseline-script-contract.test.js index 011c781e..8c650ac1 100644 --- a/scripts/__tests__/unity-perf-baseline-script-contract.test.js +++ b/scripts/__tests__/unity-perf-baseline-script-contract.test.js @@ -7,11 +7,22 @@ const path = require("path"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const SCRIPT_PATH = path.join(REPO_ROOT, "scripts", "unity", "capture-perf-baseline.ps1"); +const BENCHMARK_PATH = path.join( + REPO_ROOT, + "Tests", + "Runtime", + "Benchmarks", + "DispatchThroughputBenchmarks.cs" +); const REAL_PWSH = (() => { - const result = childProcess.spawnSync("pwsh", ["-NoProfile", "-Command", "(Get-Command pwsh).Source"], { - cwd: REPO_ROOT, - encoding: "utf8", - }); + const result = childProcess.spawnSync( + "pwsh", + ["-NoProfile", "-Command", "(Get-Command pwsh).Source"], + { + cwd: REPO_ROOT, + encoding: "utf8" + } + ); if (result.status !== 0) { return null; @@ -31,20 +42,15 @@ function makeTempToolDir() { const fakePwsh = [ "#!/usr/bin/env bash", "set -euo pipefail", - 'printf \'{"commit":%s}\\n\' "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_COMMIT || \"\"))\')" > "$FAKE_PWSH_MARKER"', + 'printf \'{"commit":%s,"baseline":%s,"mode":%s}\\n\' "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_COMMIT || \"\"))\')" "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_BASELINE || \"\"))\')" "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_BASELINE_MODE || \"\"))\')" > "$FAKE_PWSH_MARKER"', + 'if [[ "${FAKE_SKIP_BASELINE:-0}" != "1" && -n "${DX_PERF_BASELINE:-}" && ! -f "$DX_PERF_BASELINE" ]]; then mkdir -p "$(dirname "$DX_PERF_BASELINE")"; printf "%s\\n" "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs" > "$DX_PERF_BASELINE"; fi', 'printf "%s\\n" "fake unity stdout for $DX_PERF_COMMIT"', 'printf "%s\\n" "fake unity stderr for $DX_PERF_COMMIT" >&2', 'exit "${FAKE_PWSH_EXIT:-0}"', - "", + "" ].join("\n"); - const fakeNode = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'printf "%s\\n" "node invoked" > "$FAKE_NODE_MARKER"', - 'exit "${FAKE_NODE_EXIT:-0}"', - "", - ].join("\n"); + const fakeNode = ["#!/usr/bin/env bash", "set -euo pipefail", "exit 99", ""].join("\n"); fs.writeFileSync(path.join(binDir, "pwsh"), fakePwsh, { mode: 0o755 }); fs.writeFileSync(path.join(binDir, "node"), fakeNode, { mode: 0o755 }); @@ -62,8 +68,8 @@ function runCapture(args, tools, extraEnv = {}) { FAKE_NODE_MARKER: tools.nodeMarker, FAKE_PWSH_MARKER: tools.pwshMarker, FAKE_REAL_NODE: process.execPath, - PATH: `${tools.binDir}${path.delimiter}${process.env.PATH}`, - }, + PATH: `${tools.binDir}${path.delimiter}${process.env.PATH}` + } }); } @@ -71,7 +77,7 @@ function cleanupPerfArtifacts(commit) { const artifactToken = commit.replace(/[^A-Za-z0-9_.-]/g, "-"); for (const suffix of ["results.xml", "unity-log.txt"]) { fs.rmSync(path.join(REPO_ROOT, ".artifacts", `perf-${artifactToken}-${suffix}`), { - force: true, + force: true }); } } @@ -88,6 +94,8 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(content).toContain("[Parameter(Position = 0)]"); expect(content).toContain("Read-Host 'Commit/ref for DX_PERF_COMMIT'"); expect(content).toContain("$env:DX_PERF_COMMIT = $Commit"); + expect(content).toContain("$env:DX_PERF_BASELINE = $BaselinePathForUnity"); + expect(content).toContain("$env:DX_PERF_BASELINE_MODE = 'replace'"); expect(content).toContain("$previousDxPerfCommit = $env:DX_PERF_COMMIT"); }); @@ -96,7 +104,7 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(content).toContain("-Platform playmode"); expect(content).toContain("-IncludePerf"); expect(content).toContain( - "DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*" + "DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.UpdateDispatchThroughputBaseline" ); expect(content).not.toContain("run-tests.sh"); }); @@ -111,56 +119,125 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(content).toContain('Join-Path $ArtifactsDir "perf-$artifactToken-results.xml"'); expect(content).toContain("[string]$Output = '.artifacts/perf-baseline.csv'"); expect(content).toContain("$artifactToken = $Commit -replace '[^A-Za-z0-9_.-]', '-'"); + expect(content).toContain("function ConvertTo-RepoRelativePath"); + expect(content).toContain("$BaselinePathForUnity = ConvertTo-RepoRelativePath $Output"); + expect(content).toContain("[System.IO.Path]::IsPathRooted($relativePath)"); + expect(content).toContain("[System.IO.Path]::GetFullPath($Path, $RepoRoot)"); + expect(content).toContain("$BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output, $RepoRoot)"); + expect(content).toContain("$baselineTimestampBeforeRun"); + expect(content).toContain("$baselineTimestampAfterRun -le $baselineTimestampBeforeRun"); + expect(content).toContain("did not write baseline CSV"); + expect(content).toContain("did not update baseline CSV"); + }); + + test("does not shell out to the extractor because the Unity test writes the baseline", () => { + expect(content).not.toContain("extract-perf-baseline.js"); + expect(content).not.toContain("& node"); + }); + + test("exports perf baseline variables to pwsh and tees its output to the Unity log", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "behavior-tee-test"; + const tools = makeTempToolDir(); + const outputPath = ".artifacts/behavior-baseline.csv"; + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools); + const logPath = path.join(REPO_ROOT, ".artifacts", `perf-${commit}-unity-log.txt`); + const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); + + expect(result.status).toBe(0); + expect(marker.commit).toBe(commit); + expect(marker.baseline).toBe(outputPath); + expect(marker.mode).toBe("replace"); + expect(result.stdout).toContain(`fake unity stdout for ${commit}`); + expect(result.stdout).toContain(`fake unity stderr for ${commit}`); + expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stdout for ${commit}`); + expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stderr for ${commit}`); + expect(fs.existsSync(tools.nodeMarker)).toBe(false); + } finally { + cleanupPerfArtifacts(commit); + fs.rmSync(path.join(REPO_ROOT, outputPath), { force: true }); + } }); - test("extracts baseline rows from both the log and NUnit XML inputs", () => { - expect(content).toContain("extract-perf-baseline.js"); - expect(content).toContain("'--input', $logPath"); - expect(content).toContain("'--input', $resultsPath"); - expect(content).toContain("'--output', $Output"); - expect(content).toContain("$extractArgs += '--append'"); - expect(content).toContain("$extractArgs += '--replace'"); + test("translates repo-root absolute output to repo-relative for docker-safe resolution", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "absolute-output-test"; + const tools = makeTempToolDir(); + const outputPath = path.join(REPO_ROOT, ".artifacts", "absolute-baseline.csv"); + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools); + const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); + + expect(result.status).toBe(0); + expect(marker.baseline).toBe(path.join(".artifacts", "absolute-baseline.csv")); + expect(result.stdout).toContain(outputPath); + } finally { + cleanupPerfArtifacts(commit); + fs.rmSync(outputPath, { force: true }); + } }); - test("fails before invoking pwsh when output exists without append or replace", () => { + test("rejects absolute output outside the repo because Docker cannot see it", () => { if (!REAL_PWSH) { return; } const tools = makeTempToolDir(); - const outputPath = path.join(tools.tempRoot, "existing-baseline.csv"); - fs.writeFileSync(outputPath, "Benchmark,Median\n"); + const outputPath = path.join(tools.tempRoot, "outside-baseline.csv"); + const result = runCapture(["-Commit", "outside-output-test", "-Output", outputPath], tools); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("-Output must be relative to the repo or under the repo root"); + expect(fs.existsSync(tools.pwshMarker)).toBe(false); + }); + + test("rejects relative output outside the repo because Docker cannot see it", () => { + if (!REAL_PWSH) { + return; + } - const result = runCapture(["-Commit", "collision-test", "-Output", outputPath], tools); + const tools = makeTempToolDir(); + const result = runCapture( + ["-Commit", "relative-outside-output-test", "-Output", "../outside-baseline.csv"], + tools + ); expect(result.status).toBe(2); - expect(result.stdout).toContain("Output already exists"); + expect(result.stdout).toContain("-Output must be relative to the repo or under the repo root"); expect(fs.existsSync(tools.pwshMarker)).toBe(false); - expect(fs.existsSync(tools.nodeMarker)).toBe(false); }); - test("exports DX_PERF_COMMIT to pwsh and tees its output to the Unity log", () => { + test("forwards the default baseline output as repo-relative for docker-safe resolution", () => { if (!REAL_PWSH) { return; } - const commit = "behavior-tee-test"; + const commit = "default-output-test"; const tools = makeTempToolDir(); - const outputPath = path.join(tools.tempRoot, "baseline.csv"); + const outputPath = path.join(REPO_ROOT, ".artifacts", "perf-baseline.csv"); + const baselineExisted = fs.existsSync(outputPath); try { - const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools); - const logPath = path.join(REPO_ROOT, ".artifacts", `perf-${commit}-unity-log.txt`); + const result = runCapture(["-Commit", commit, "-Replace"], tools); + const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); expect(result.status).toBe(0); - expect(JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")).commit).toBe(commit); - expect(result.stdout).toContain(`fake unity stdout for ${commit}`); - expect(result.stdout).toContain(`fake unity stderr for ${commit}`); - expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stdout for ${commit}`); - expect(fs.readFileSync(logPath, "utf8")).toContain(`fake unity stderr for ${commit}`); - expect(fs.existsSync(tools.nodeMarker)).toBe(true); + expect(marker.baseline).toBe(".artifacts/perf-baseline.csv"); + expect(result.stdout).toContain(path.join(REPO_ROOT, ".artifacts", "perf-baseline.csv")); } finally { cleanupPerfArtifacts(commit); + if (!baselineExisted) { + fs.rmSync(outputPath, { force: true }); + } } }); @@ -171,11 +248,11 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { const commit = "failing-unity-test"; const tools = makeTempToolDir(); - const outputPath = path.join(tools.tempRoot, "baseline.csv"); + const outputPath = ".artifacts/failing-baseline.csv"; try { const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools, { - FAKE_PWSH_EXIT: "37", + FAKE_PWSH_EXIT: "37" }); expect(result.status).toBe(37); @@ -184,6 +261,122 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(fs.existsSync(tools.nodeMarker)).toBe(false); } finally { cleanupPerfArtifacts(commit); + fs.rmSync(path.join(REPO_ROOT, outputPath), { force: true }); } }); + + test("fails if the Unity runner exits successfully without writing the baseline", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "missing-baseline-test"; + const tools = makeTempToolDir(); + const outputPath = ".artifacts/missing-baseline.csv"; + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath], tools, { + FAKE_SKIP_BASELINE: "1" + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("did not write baseline CSV"); + expect(fs.existsSync(tools.pwshMarker)).toBe(true); + expect(fs.existsSync(tools.nodeMarker)).toBe(false); + } finally { + cleanupPerfArtifacts(commit); + fs.rmSync(path.join(REPO_ROOT, outputPath), { force: true }); + } + }); + + test("fails if the Unity runner exits successfully without updating an existing baseline", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "stale-baseline-test"; + const tools = makeTempToolDir(); + const outputPath = ".artifacts/stale-baseline.csv"; + const fullOutputPath = path.join(REPO_ROOT, outputPath); + + try { + fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true }); + fs.writeFileSync( + fullOutputPath, + "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs\n" + ); + const staleTimestamp = new Date("2020-01-01T00:00:00Z"); + fs.utimesSync(fullOutputPath, staleTimestamp, staleTimestamp); + + const result = runCapture(["-Commit", commit, "-Output", outputPath], tools, { + FAKE_SKIP_BASELINE: "1" + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("did not update baseline CSV"); + expect(fs.existsSync(tools.pwshMarker)).toBe(true); + expect(fs.existsSync(tools.nodeMarker)).toBe(false); + } finally { + cleanupPerfArtifacts(commit); + fs.rmSync(fullOutputPath, { force: true }); + } + }); +}); + +describe("DispatchThroughputBenchmarks baseline update test", () => { + let content; + + beforeAll(() => { + expect(fs.existsSync(BENCHMARK_PATH)).toBe(true); + content = fs.readFileSync(BENCHMARK_PATH, "utf8"); + }); + + test("has an explicit Unity test that updates the perf baseline CSV", () => { + expect(content).toContain('private const string BaselineOutputEnvVar = "DX_PERF_BASELINE"'); + expect(content).toContain('private const string BaselineModeEnvVar = "DX_PERF_BASELINE_MODE"'); + expect(content).toContain( + 'private const string PackageName = "com.wallstop-studios.dxmessaging"' + ); + expect(content).toContain('[Test, Explicit, Performance, Category("PerfBaseline")]'); + expect(content).toContain("public void UpdateDispatchThroughputBaseline()"); + expect(content).toContain("WriteBaselineRows(outputPath, results, replaceAllRows)"); + }); + + test("resolves default output under the package root instead of an arbitrary current directory", () => { + expect(content).toContain("UnityEditor.PackageManager.PackageInfo.FindForAssembly"); + expect(content).toContain("packageInfo.resolvedPath"); + expect(content).toContain("ResolvePackageInfoRoot("); + expect(content).toContain("typeof(DispatchThroughputBenchmarks).Assembly"); + expect(content).toContain("typeof(MessageBus).Assembly"); + expect(content).toContain("return FindPackageRoot(packageInfo.resolvedPath)"); + expect(content).toContain("IsPackageRoot(current.FullName)"); + expect(content).toContain("Regex.IsMatch("); + expect(content).toContain("Regex.Escape(PackageName)"); + expect(content).toContain("ResolveUnityProjectRoot()"); + expect(content).not.toContain('File.Exists(Path.Combine(current.FullName, "package.json"))'); + }); + + test("resolves direct Unity baseline commits from the package git metadata when env is absent", () => { + expect(content).toContain('Environment.GetEnvironmentVariable("DX_PERF_COMMIT")'); + expect(content).toContain('Environment.GetEnvironmentVariable("GITHUB_SHA")'); + expect(content).toContain( + "ResolveGitHeadCommit(DispatchThroughputBenchmarks.ResolvePackageRoot())" + ); + expect(content).toContain('Path.Combine(packageRoot, ".git")'); + expect(content).toContain('Path.Combine(gitPath, "HEAD")'); + expect(content).toContain('Path.Combine(gitPath, "commondir")'); + expect(content).toContain("ReadGitRefCommit(gitPath, refName)"); + expect(content).toContain("ReadGitRefCommit(commonGitPath, refName)"); + expect(content).toContain('Path.Combine(gitPath, "packed-refs")'); + }); + + test("updates matching rows and preserves unrelated baseline rows by default", () => { + expect(content).toContain("ReadExistingBaselineRows(outputPath)"); + expect(content).toContain("RemoveMatchingBaselineRow(rows, result)"); + expect(content).toContain("rows.Add(result.ToCsvRow())"); + expect(content).toContain("rows.Sort(CompareBaselineRows)"); + expect(content).toContain( + "File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(false))" + ); + }); }); diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js index 9aeb6caa..1f98a1d1 100644 --- a/scripts/__tests__/unity-runner-script-contract.test.js +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -98,6 +98,8 @@ describe("scripts/unity/run-tests.sh contract", () => { test("forwards the perf commit environment into Unity containers", () => { expect(content).toContain("-e DX_PERF_COMMIT"); + expect(content).toContain("-e DX_PERF_BASELINE"); + expect(content).toContain("-e DX_PERF_BASELINE_MODE"); }); test("standalone player run forwards the same assembly and filter controls", () => { @@ -126,8 +128,12 @@ describe("scripts/unity/run-tests.sh contract", () => { expect(content).toContain("UNITY_LIBRARY_CACHE_SOURCE="); expect(content).toContain("dxm-unity-library-%s-%s"); expect(content).toContain( - 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true' + 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true' ); + expect(content).toContain('baseline_path="${DX_PERF_BASELINE}"'); + expect(content).toContain('baseline_path="/workspace/${baseline_path}"'); + expect(content).toContain('chown "${USER_UID}:${USER_GID}" "${baseline_path}"'); + expect(content).toContain('baseline_dir="$(dirname "${baseline_path}")"'); expect(content).toContain( 'chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true' ); @@ -235,6 +241,8 @@ describe("scripts/unity/run-tests.ps1 contract", () => { test("forwards the perf commit environment into Unity containers", () => { expect(content).toContain("'-e', 'DX_PERF_COMMIT'"); + expect(content).toContain("'-e', 'DX_PERF_BASELINE'"); + expect(content).toContain("'-e', 'DX_PERF_BASELINE_MODE'"); }); test("standalone player run forwards the same assembly and filter controls", () => { @@ -264,6 +272,13 @@ describe("scripts/unity/run-tests.ps1 contract", () => { expect(content).toContain("trap cleanup_ownership EXIT"); expect(content).toContain("$UnityLibraryCacheSource"); expect(content).toContain("dxm-unity-library-$ImageTag-$Platform"); + expect(content).toContain( + 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true' + ); + expect(content).toContain('baseline_path="${DX_PERF_BASELINE}"'); + expect(content).toContain('baseline_path="/workspace/${baseline_path}"'); + expect(content).toContain('chown "${USER_UID}:${USER_GID}" "${baseline_path}"'); + expect(content).toContain('baseline_dir="$(dirname "${baseline_path}")"'); expect(content).toContain( 'chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true' ); diff --git a/scripts/unity/capture-perf-baseline.ps1 b/scripts/unity/capture-perf-baseline.ps1 index 2250f3c5..7f83a210 100644 --- a/scripts/unity/capture-perf-baseline.ps1 +++ b/scripts/unity/capture-perf-baseline.ps1 @@ -3,9 +3,8 @@ Capture a Unity performance baseline for DispatchThroughputBenchmarks. .DESCRIPTION - Runs the playmode DispatchThroughputBenchmarks perf suite for a commit, - tees the Unity output to a commit-specific log file, and extracts the - normalized baseline CSV via scripts/unity/extract-perf-baseline.js. + Runs the explicit DispatchThroughputBenchmarks baseline update test + for a commit. The Unity test writes the normalized baseline CSV. .PARAMETER Commit Commit/ref value to expose to Unity as DX_PERF_COMMIT. When omitted, @@ -15,10 +14,11 @@ Baseline CSV output path. Defaults to .artifacts/perf-baseline.csv. .PARAMETER Append - Append extracted rows to an existing baseline CSV. + Kept for compatibility. Baseline updates add missing rows and replace + matching rows by default. .PARAMETER Replace - Replace an existing baseline CSV. + Replace the full baseline CSV instead of updating matching rows. .PARAMETER Help Show detailed help and exit. @@ -28,7 +28,7 @@ -Commit bf448fe84872022343260cb636409a9d3831bdec .EXAMPLE - pwsh -NoProfile -File scripts/unity/capture-perf-baseline.ps1 -Append + pwsh -NoProfile -File scripts/unity/capture-perf-baseline.ps1 -Replace #> [CmdletBinding()] @@ -54,11 +54,6 @@ if ($Help) { exit 0 } -if ($Append -and $Replace) { - Write-Host 'ERROR: -Append and -Replace cannot both be specified.' -ForegroundColor Red - exit 2 -} - if ([string]::IsNullOrWhiteSpace($Commit)) { $Commit = Read-Host 'Commit/ref for DX_PERF_COMMIT' } @@ -81,22 +76,47 @@ $artifactToken = $Commit -replace '[^A-Za-z0-9_.-]', '-' $resultsPath = Join-Path $ArtifactsDir "perf-$artifactToken-results.xml" $logPath = Join-Path $ArtifactsDir "perf-$artifactToken-unity-log.txt" $runnerPath = Join-Path $ScriptDir 'run-tests.ps1' -$extractorPath = Join-Path $ScriptDir 'extract-perf-baseline.js' -$filter = 'DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.*' +$filter = 'DxMessaging.Tests.Runtime.Benchmarks.DispatchThroughputBenchmarks.UpdateDispatchThroughputBaseline' -if (-not [System.IO.Path]::IsPathRooted($Output)) { - $Output = Join-Path $RepoRoot $Output -} -$outputDir = Split-Path -Parent $Output -if (-not (Test-Path $outputDir)) { - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null +function ConvertTo-RepoRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + $fullPath = [System.IO.Path]::GetFullPath($Path) + } else { + $fullPath = [System.IO.Path]::GetFullPath($Path, $RepoRoot) + } + + $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $fullPath) + if ([System.IO.Path]::IsPathRooted($relativePath) -or $relativePath -eq '..' -or $relativePath.StartsWith("..$([System.IO.Path]::DirectorySeparatorChar)") -or $relativePath.StartsWith("..$([System.IO.Path]::AltDirectorySeparatorChar)")) { + return $null + } + + return $relativePath } -if ((Test-Path -LiteralPath $Output) -and -not $Append -and -not $Replace) { - Write-Host "ERROR: Output already exists: $Output. Specify -Append or -Replace." -ForegroundColor Red +$BaselinePathForUnity = ConvertTo-RepoRelativePath $Output +if ([string]::IsNullOrWhiteSpace($BaselinePathForUnity)) { + Write-Host @" +ERROR: -Output must be relative to the repo or under the repo root. +Paths outside the repo are not visible to Docker Unity runs. +"@ -ForegroundColor Red exit 2 } +if ([System.IO.Path]::IsPathRooted($Output)) { + $BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output) +} else { + $BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output, $RepoRoot) +} +$outputDir = Split-Path -Parent $BaselineDisplayPath +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null +} + function Find-ExecutableOnPath { param( [Parameter(Mandatory = $true)] @@ -151,12 +171,28 @@ if (-not $pwshPath) { $previousDxPerfCommit = $env:DX_PERF_COMMIT $hadDxPerfCommit = Test-Path Env:DX_PERF_COMMIT +$previousBaseline = $env:DX_PERF_BASELINE +$hadBaseline = Test-Path Env:DX_PERF_BASELINE +$previousBaselineMode = $env:DX_PERF_BASELINE_MODE +$hadBaselineMode = Test-Path Env:DX_PERF_BASELINE_MODE $env:DX_PERF_COMMIT = $Commit +$env:DX_PERF_BASELINE = $BaselinePathForUnity +if ($Replace) { + $env:DX_PERF_BASELINE_MODE = 'replace' +} else { + Remove-Item Env:DX_PERF_BASELINE_MODE -ErrorAction SilentlyContinue +} try { - Write-Host "Running DispatchThroughputBenchmarks for $Commit" + Write-Host "Updating DispatchThroughputBenchmarks baseline for $Commit" Write-Host "Unity results: $resultsPath" Write-Host "Unity log: $logPath" + Write-Host "Baseline CSV: $BaselineDisplayPath" + + $baselineTimestampBeforeRun = $null + if (Test-Path -LiteralPath $BaselineDisplayPath -PathType Leaf) { + $baselineTimestampBeforeRun = (Get-Item -LiteralPath $BaselineDisplayPath).LastWriteTimeUtc + } & $pwshPath -NoProfile -File $runnerPath ` -Platform playmode ` @@ -171,26 +207,16 @@ try { exit $unityExitCode } - $extractArgs = @( - $extractorPath, - '--input', $logPath, - '--input', $resultsPath, - '--output', $Output - ) - if ($Append) { - $extractArgs += '--append' - } elseif ($Replace) { - $extractArgs += '--replace' + if (-not (Test-Path -LiteralPath $BaselineDisplayPath -PathType Leaf)) { + Write-Host "ERROR: Unity perf run completed but did not write baseline CSV: $BaselineDisplayPath" -ForegroundColor Red + exit 1 } - & node @extractArgs - $extractExitCode = $LASTEXITCODE - if ($extractExitCode -ne 0) { - Write-Host "ERROR: Baseline extraction failed with exit code $extractExitCode." -ForegroundColor Red - exit $extractExitCode + $baselineTimestampAfterRun = (Get-Item -LiteralPath $BaselineDisplayPath).LastWriteTimeUtc + if ($baselineTimestampBeforeRun -and $baselineTimestampAfterRun -le $baselineTimestampBeforeRun) { + Write-Host "ERROR: Unity perf run completed but did not update baseline CSV: $BaselineDisplayPath" -ForegroundColor Red + exit 1 } - - Write-Host "Baseline CSV: $Output" } finally { if ($hadDxPerfCommit) { @@ -198,4 +224,16 @@ finally { } else { Remove-Item Env:DX_PERF_COMMIT -ErrorAction SilentlyContinue } + + if ($hadBaseline) { + $env:DX_PERF_BASELINE = $previousBaseline + } else { + Remove-Item Env:DX_PERF_BASELINE -ErrorAction SilentlyContinue + } + + if ($hadBaselineMode) { + $env:DX_PERF_BASELINE_MODE = $previousBaselineMode + } else { + Remove-Item Env:DX_PERF_BASELINE_MODE -ErrorAction SilentlyContinue + } } diff --git a/scripts/unity/run-tests.ps1 b/scripts/unity/run-tests.ps1 index d0e68faa..2d0d3ef0 100644 --- a/scripts/unity/run-tests.ps1 +++ b/scripts/unity/run-tests.ps1 @@ -565,7 +565,16 @@ function Get-EditorCommandInner { $assembliesQ = ConvertTo-BashSingleQuotedString $Assemblies [void]$sb.AppendLine('set -euo pipefail') [void]$sb.AppendLine('cleanup_ownership() {') - [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true') + [void]$sb.AppendLine(' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then') + [void]$sb.AppendLine(' baseline_path="${DX_PERF_BASELINE}"') + [void]$sb.AppendLine(' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"') + [void]$sb.AppendLine(' if [[ "${baseline_path}" == /workspace/* ]]; then') + [void]$sb.AppendLine(' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true') + [void]$sb.AppendLine(' baseline_dir="$(dirname "${baseline_path}")"') + [void]$sb.AppendLine(' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true') + [void]$sb.AppendLine(' fi') + [void]$sb.AppendLine(' fi') [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') [void]$sb.AppendLine('}') [void]$sb.AppendLine('trap cleanup_ownership EXIT') @@ -605,7 +614,16 @@ function Get-StandaloneBuildCommandInner { $buildPathQ = ConvertTo-BashSingleQuotedString $StandaloneBuildContainer [void]$sb.AppendLine('set -euo pipefail') [void]$sb.AppendLine('cleanup_ownership() {') - [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true') + [void]$sb.AppendLine(' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then') + [void]$sb.AppendLine(' baseline_path="${DX_PERF_BASELINE}"') + [void]$sb.AppendLine(' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"') + [void]$sb.AppendLine(' if [[ "${baseline_path}" == /workspace/* ]]; then') + [void]$sb.AppendLine(' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true') + [void]$sb.AppendLine(' baseline_dir="$(dirname "${baseline_path}")"') + [void]$sb.AppendLine(' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true') + [void]$sb.AppendLine(' fi') + [void]$sb.AppendLine(' fi') [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Builds || true') [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') [void]$sb.AppendLine('}') @@ -641,7 +659,16 @@ function Get-StandaloneRunCommandInner { $assembliesQ = ConvertTo-BashSingleQuotedString $Assemblies [void]$sb.AppendLine('set -euo pipefail') [void]$sb.AppendLine('cleanup_ownership() {') - [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true') + [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true') + [void]$sb.AppendLine(' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then') + [void]$sb.AppendLine(' baseline_path="${DX_PERF_BASELINE}"') + [void]$sb.AppendLine(' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"') + [void]$sb.AppendLine(' if [[ "${baseline_path}" == /workspace/* ]]; then') + [void]$sb.AppendLine(' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true') + [void]$sb.AppendLine(' baseline_dir="$(dirname "${baseline_path}")"') + [void]$sb.AppendLine(' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true') + [void]$sb.AppendLine(' fi') + [void]$sb.AppendLine(' fi') [void]$sb.AppendLine(' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true') [void]$sb.AppendLine('}') [void]$sb.AppendLine('trap cleanup_ownership EXIT') @@ -682,6 +709,8 @@ $dockerBaseArgs = @( '-e', 'UNITY_EMAIL', '-e', 'UNITY_PASSWORD', '-e', 'DX_PERF_COMMIT', + '-e', 'DX_PERF_BASELINE', + '-e', 'DX_PERF_BASELINE_MODE', '-e', "USER_UID=$UserUid", '-e', "USER_GID=$UserGid" ) diff --git a/scripts/unity/run-tests.sh b/scripts/unity/run-tests.sh index 2b9062d9..eb3d8cec 100644 --- a/scripts/unity/run-tests.sh +++ b/scripts/unity/run-tests.sh @@ -439,7 +439,16 @@ build_editor_cmd_inner() { assemblies_q="$(printf '%q' "${ASSEMBLIES}")" cmd=$'set -euo pipefail\n' cmd+=$'cleanup_ownership() {\n' - cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true\n' + cmd+=$' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then\n' + cmd+=$' baseline_path="${DX_PERF_BASELINE}"\n' + cmd+=$' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"\n' + cmd+=$' if [[ "${baseline_path}" == /workspace/* ]]; then\n' + cmd+=$' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true\n' + cmd+=$' baseline_dir="$(dirname "${baseline_path}")"\n' + cmd+=$' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true\n' + cmd+=$' fi\n' + cmd+=$' fi\n' cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' cmd+=$'}\n' cmd+=$'trap cleanup_ownership EXIT\n' @@ -490,7 +499,16 @@ build_standalone_build_cmd_inner() { build_path_q="$(printf '%q' "${STANDALONE_BUILD_CONTAINER}")" cmd=$'set -euo pipefail\n' cmd+=$'cleanup_ownership() {\n' - cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true\n' + cmd+=$' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then\n' + cmd+=$' baseline_path="${DX_PERF_BASELINE}"\n' + cmd+=$' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"\n' + cmd+=$' if [[ "${baseline_path}" == /workspace/* ]]; then\n' + cmd+=$' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true\n' + cmd+=$' baseline_dir="$(dirname "${baseline_path}")"\n' + cmd+=$' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true\n' + cmd+=$' fi\n' + cmd+=$' fi\n' cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Builds || true\n' cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' cmd+=$'}\n' @@ -539,7 +557,16 @@ build_standalone_run_cmd_inner() { assemblies_q="$(printf '%q' "${ASSEMBLIES}")" cmd=$'set -euo pipefail\n' cmd+=$'cleanup_ownership() {\n' - cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts/unity || true\n' + cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true\n' + cmd+=$' if [[ -n "${DX_PERF_BASELINE:-}" ]]; then\n' + cmd+=$' baseline_path="${DX_PERF_BASELINE}"\n' + cmd+=$' [[ "${baseline_path}" = /* ]] || baseline_path="/workspace/${baseline_path}"\n' + cmd+=$' if [[ "${baseline_path}" == /workspace/* ]]; then\n' + cmd+=$' chown "${USER_UID}:${USER_GID}" "${baseline_path}" 2>/dev/null || true\n' + cmd+=$' baseline_dir="$(dirname "${baseline_path}")"\n' + cmd+=$' [[ "${baseline_dir}" == "/workspace" ]] || chown -R "${USER_UID}:${USER_GID}" "${baseline_dir}" 2>/dev/null || true\n' + cmd+=$' fi\n' + cmd+=$' fi\n' cmd+=$' chown -R "${USER_UID}:${USER_GID}" /workspace/.unity-test-project/Library || true\n' cmd+=$'}\n' cmd+=$'trap cleanup_ownership EXIT\n' @@ -593,6 +620,8 @@ DOCKER_BASE_ARGS=( -e UNITY_EMAIL -e UNITY_PASSWORD -e DX_PERF_COMMIT + -e DX_PERF_BASELINE + -e DX_PERF_BASELINE_MODE -e "USER_UID=${USER_UID_VAL}" -e "USER_GID=${USER_GID_VAL}" ) From cf7199cc0a629bf048a9d222b50bf23e1c3a9e9f Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 16:15:59 -0700 Subject: [PATCH 12/16] Documentation cleanup --- .cspell.json | 1 + .github/workflows/validate-npm-meta.yml | 29 + .llm/context.md | 4 + .../documentation/memory-reclamation-docs.md | 142 +++ .../documentation/no-plan-vocabulary.md | 153 +++ .llm/skills/index.md | 66 +- .../packaging/npm-package-configuration.md | 28 + .npmignore | 10 + .pre-commit-config.yaml | 42 + CHANGELOG.md | 3 +- README.md | 1 + .../DxMessagingRuntimeSettings.cs | 4 +- docs/advanced/runtime-configuration.md | 23 + docs/architecture/comparisons.md | 8 +- docs/architecture/design-and-architecture.md | 12 + docs/architecture/performance.md | 54 +- docs/guides/diagnostics.md | 36 + docs/guides/memory-reclamation.md | 309 +++++ docs/guides/memory-reclamation.md.meta | 7 + docs/guides/migration-guide.md | 52 + docs/reference/faq.md | 10 + docs/reference/glossary.md | 35 + docs/reference/quick-reference.md | 16 + docs/reference/runtime-settings.md | 236 ++++ docs/reference/runtime-settings.md.meta | 7 + docs/reference/troubleshooting.md | 13 + llms.txt | 25 +- mkdocs.yml | 2 + package.json | 13 +- .../validate-no-plan-vocabulary.test.js | 467 ++++++++ .../validate-no-plan-vocabulary.test.js.meta | 7 + scripts/__tests__/validate-npm-meta.test.js | 347 +++++- .../validate-runtime-settings-docs.test.js | 750 ++++++++++++ ...alidate-runtime-settings-docs.test.js.meta | 7 + .../validate-untracked-policy.test.js | 502 ++++++++ .../validate-untracked-policy.test.js.meta | 7 + scripts/update-llms-txt.js | 23 +- scripts/validate-no-plan-vocabulary.js | 600 ++++++++++ scripts/validate-no-plan-vocabulary.js.meta | 7 + scripts/validate-npm-meta.js | 304 ++++- scripts/validate-runtime-settings-docs.js | 1017 +++++++++++++++++ .../validate-runtime-settings-docs.js.meta | 7 + scripts/validate-untracked-policy.js | 521 +++++++++ scripts/validate-untracked-policy.js.meta | 7 + 44 files changed, 5847 insertions(+), 67 deletions(-) create mode 100644 .llm/skills/documentation/memory-reclamation-docs.md create mode 100644 .llm/skills/documentation/no-plan-vocabulary.md create mode 100644 docs/guides/memory-reclamation.md create mode 100644 docs/guides/memory-reclamation.md.meta create mode 100644 docs/reference/runtime-settings.md create mode 100644 docs/reference/runtime-settings.md.meta create mode 100644 scripts/__tests__/validate-no-plan-vocabulary.test.js create mode 100644 scripts/__tests__/validate-no-plan-vocabulary.test.js.meta create mode 100644 scripts/__tests__/validate-runtime-settings-docs.test.js create mode 100644 scripts/__tests__/validate-runtime-settings-docs.test.js.meta create mode 100644 scripts/__tests__/validate-untracked-policy.test.js create mode 100644 scripts/__tests__/validate-untracked-policy.test.js.meta create mode 100644 scripts/validate-no-plan-vocabulary.js create mode 100644 scripts/validate-no-plan-vocabulary.js.meta create mode 100644 scripts/validate-runtime-settings-docs.js create mode 100644 scripts/validate-runtime-settings-docs.js.meta create mode 100644 scripts/validate-untracked-policy.js create mode 100644 scripts/validate-untracked-policy.js.meta diff --git a/.cspell.json b/.cspell.json index 9110a204..f4ebb958 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,6 +38,7 @@ "words": [ "DxMessaging", "dxmessaging", + "metas", "mtimes", "nofilter", "relitigate", diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index 86f6080f..90ab692e 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -14,6 +14,7 @@ on: - ".npmignore" - "scripts/validate-npm-meta.js" - "scripts/__tests__/validate-npm-meta.test.js" + - ".github/workflows/validate-npm-meta.yml" push: branches: - main @@ -30,6 +31,7 @@ on: - ".npmignore" - "scripts/validate-npm-meta.js" - "scripts/__tests__/validate-npm-meta.test.js" + - ".github/workflows/validate-npm-meta.yml" workflow_dispatch: concurrency: @@ -92,3 +94,30 @@ jobs: - name: Validate NPM package meta files run: npm run validate:npm-meta + + - name: "Inspect real npm pack tarball for issue #204 regressions" + # Defense-in-depth check that runs `npm pack` (not --dry-run) and inspects the + # produced tarball for any build artifact paths. The dry-run validator above + # already enforces this, but a real pack on each runner OS catches platform + # drift (e.g. case sensitivity, path separators, glob expansion differences). + # See https://github.com/wallstop/DxMessaging/issues/204 for the original + # GuidDB::CreateMetaFileMappings regression that motivated this guard. + # + # IMPORTANT: keep the grep alternation below in sync with `buildArtifactPatterns` + # in scripts/validate-npm-meta.js. Order matters -- list the most specific + # patterns first (`.csproj.user`, `.DotSettings.user`) before the generic + # `.user$` so any drift surfaces with the most actionable label. + run: | + set -euo pipefail + npm pack + tarball="$(ls -1 *.tgz | head -n1)" + echo "Inspecting tarball: ${tarball}" + leaks="$(tar -tzf "${tarball}" | grep -E '(^|/)(bin|obj)/|\.pdb$|\.tmp$|\.csproj\.user$|(^|/)\.vs/|(^|/)\.idea/|\.suo$|\.DotSettings\.user$|\.user$' || true)" + if [ -n "${leaks}" ]; then + echo "::error::npm pack tarball contains forbidden build artifact paths (issue 204 regression):" + echo "${leaks}" + rm -f -- *.tgz + exit 1 + fi + echo "OK: no bin/obj/pdb/tmp/IDE state in tarball." + rm -f -- *.tgz diff --git a/.llm/context.md b/.llm/context.md index d9fc7ea2..0c1fa244 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -31,7 +31,10 @@ This file is intentionally concise. It contains only critical, high-signal guida - For Node child-process calls in `scripts/*.js`, prefer argument-array invocations (`spawnSync` / `execFileSync`) and `stdio` options instead of shell redirection. - For dynamic `import()` in `scripts/*.js`, convert filesystem paths with `pathToFileURL(...).href` before importing (raw Windows drive-letter paths fail Node's ESM loader). - When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, `.github/workflows/*.yml`, or hook-related scripts in `package.json`, run `npm run preflight:pre-commit` before finishing. +- When running `preflight:pre-commit` with unstaged docs changes, the markdown hook may report 'files were modified' -- stage the changes first. - When editing `.pre-commit-config.yaml` or hook scripts, the new performance budget test (`scripts/__tests__/hook-perf-budget.test.js`) must pass; see [Git Hook Performance Budget](./skills/performance/git-hook-performance.md). +- Never introduce `PLAN.md` / `PERF-PLAN.md` / `OLD-PLAN.md` / `GH-PAGES-PLAN.md` filename references or `T0.0` / `P0.0`-style milestone tags into shipping content (under `Runtime/`, `Editor/`, `SourceGenerators/`, `Samples~/`, `docs/`, root `*.md`, `llms.txt`). The `validate:no-plan-vocabulary` hook enforces this; treat any failure as a prose rewrite, not a hook bypass. See [No PLAN Vocabulary in Shipping Content](./skills/documentation/no-plan-vocabulary.md). +- Untracked-and-unignored paths at the repo root are forbidden. The `validate:untracked-policy` hook fails if `git ls-files --others --exclude-standard` reports any path. Either commit the file or extend `.gitignore` / `.npmignore`. ## Build and Test Commands @@ -150,6 +153,7 @@ The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outsid - Every C# code sample in docs - inline, fenced, and XML `` blocks - must compile; see [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` (or, for the hook-equivalent batch run, `node scripts/run-staged-md-pipeline.js ` for `.md` and `node scripts/run-staged-validators.js ` for `.cs`) and the `DocsSnippetCompilationTests` suite before finishing. - Documentation prose must avoid LLM-style filler, marketing adjectives, hedge transitions, and vague quantifiers; see [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md). Run `node scripts/validate-docs-prose.js` (or, for the hook-equivalent batch run, `node scripts/run-staged-md-pipeline.js ` for `.md` and `node scripts/run-staged-validators.js ` for `.cs`) before finishing. - Subclasses of `MessageAwareComponent` MUST call `base.()` from every guarded lifecycle override (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`); see [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md). Five enforcement layers (Roslyn analyzer DXMSG006-010, IL scanner, Inspector overlay, runtime self-check, meta-test) keep the contract honest. +- When editing `Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs` or its provider, run `npm run validate:runtime-settings-docs` and update `docs/reference/runtime-settings.md` and `docs/guides/memory-reclamation.md` in the same change; see [Memory Reclamation Documentation Maintenance](./skills/documentation/memory-reclamation-docs.md). ## Skills to Prefer diff --git a/.llm/skills/documentation/memory-reclamation-docs.md b/.llm/skills/documentation/memory-reclamation-docs.md new file mode 100644 index 00000000..2d279ec4 --- /dev/null +++ b/.llm/skills/documentation/memory-reclamation-docs.md @@ -0,0 +1,142 @@ +--- +title: "Memory Reclamation Documentation Maintenance" +id: "memory-reclamation-docs" +category: "documentation" +version: "1.0.0" +created: "2026-05-06" +updated: "2026-05-06" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs" + - path: "Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs" + - path: "Runtime/Core/MessageBus/IMessageBus.cs" + - path: "Runtime/Core/MessageBus/MessageHandler.cs" + - path: "Runtime/Core/Pooling/DxPools.cs" + - path: "docs/guides/memory-reclamation.md" + - path: "docs/reference/runtime-settings.md" + - path: "CHANGELOG.md" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "documentation" + - "memory-reclamation" + - "runtime-settings" + - "changelog" + - "maintenance" + +complexity: + level: "basic" + reasoning: "Mechanical doc-update checklist tied to a fixed list of trigger files." + +impact: + performance: + rating: "none" + details: "Documentation only; runtime behavior is unaffected." + maintainability: + rating: "high" + details: "Keeps the user-facing memory-reclamation surface and CHANGELOG aligned with the runtime." + testability: + rating: "low" + details: "validate:runtime-settings-docs and validate:changelog:coverage cover the update mechanically." + +prerequisites: + - "memory-reclamation" + - "memory-reclaim-coverage" + - "changelog-management" + +dependencies: + packages: [] + skills: + - "memory-reclamation" + - "memory-reclaim-coverage" + - "changelog-management" + +applies_to: + languages: + - "C#" + - "Markdown" + frameworks: + - "Unity" + - ".NET" + versions: + unity: ">=2021.3" + dotnet: ">=netstandard2.0" + +aliases: + - "Memory reclamation docs" + - "Runtime settings doc maintenance" + +related: + - "memory-reclamation" + - "memory-reclaim-coverage" + - "changelog-management" + +status: "stable" +--- + +# Memory Reclamation Documentation Maintenance + +> **One-line summary**: When changing memory-reclamation runtime behavior, +> update the user docs and CHANGELOG in the same change. + +## When this skill applies + +Trigger files. When any of these change, the user-facing memory-reclamation +docs are likely affected: + +- `Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs` +- `Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs` +- `Runtime/Core/MessageBus/IMessageBus.cs` (`Trim`, `OccupiedTypeSlots`, + `OccupiedTargetSlots`, `TrimResult`) +- `Runtime/Core/MessageBus/MessageHandler.cs` (`TrimAll`) +- `Runtime/Core/Pooling/**` +- `Runtime/Core/Configuration/**` + +Treat changes to public field names, default values, attribute thresholds, or +public method shapes on these files as user-visible by default. + +## Required updates + +When any trigger file changes, update IN THE SAME CHANGE: + +1. `docs/guides/memory-reclamation.md` -- the narrative guide for tuning idle + sweeps, forced trims, and pool caps. +1. `docs/reference/runtime-settings.md` -- the per-setting reference table that + `validate:runtime-settings-docs` cross-references against + `DxMessagingRuntimeSettings`. +1. `CHANGELOG.md` -- the existing `## [Unreleased]` "Runtime memory-reclamation + foundations" bullet. Mutate the existing bullet rather than stacking a new + one; see [Changelog Management](./changelog-management.md). When the change + is a distinct user-facing fix that the bullet does not cover, add a single + `### Fixed` line item instead of duplicating the foundations bullet. + +## Validation + +Run from the repository root: + +```bash +npm run validate:runtime-settings-docs +npm run validate:changelog:coverage +``` + +If `validate:runtime-settings-docs` reports `missing-doc-row`, add the new +row to `docs/reference/runtime-settings.md` matching the shape of the +existing rows. If it reports `extra-doc-row`, remove the stale row because +the underlying setting was removed or renamed. + +If `validate:changelog:coverage` raises `W002`, rewrite the entry around user +impact. Internal-only renames belong in developer docs, not in the changelog. + +## See also + +- [DxMessaging Memory Reclamation](../performance/memory-reclamation.md) +- [Memory Reclaim Coverage](../testing/memory-reclaim-coverage.md) +- [Changelog Management](./changelog-management.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-06 | Initial version | diff --git a/.llm/skills/documentation/no-plan-vocabulary.md b/.llm/skills/documentation/no-plan-vocabulary.md new file mode 100644 index 00000000..aec68177 --- /dev/null +++ b/.llm/skills/documentation/no-plan-vocabulary.md @@ -0,0 +1,153 @@ +--- +title: "No PLAN Vocabulary in Shipping Content" +id: "no-plan-vocabulary" +category: "documentation" +version: "1.0.0" +created: "2026-05-06" +updated: "2026-05-06" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "scripts/validate-no-plan-vocabulary.js" + - path: "scripts/__tests__/validate-no-plan-vocabulary.test.js" + - path: "Runtime/" + - path: "Editor/" + - path: "SourceGenerators/" + - path: "Samples~/" + - path: "docs/" + - path: "llms.txt" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "documentation" + - "vocabulary" + - "policy" + - "shipping-content" + - "validation" + +complexity: + level: "basic" + reasoning: "Mechanical phrase rejection with an allowlist for legitimate exceptions." + +impact: + performance: + rating: "none" + details: "Validator runs at pre-push only; no runtime impact." + maintainability: + rating: "medium" + details: "Keeps internal planning vocabulary out of user-facing surfaces." + testability: + rating: "low" + details: "validate-no-plan-vocabulary.js plus its Jest test cover the rule." + +prerequisites: + - "human-prose-policy" + +dependencies: + packages: [] + skills: + - "changelog-management" + - "human-prose-policy" + +applies_to: + languages: + - "C#" + - "Markdown" + frameworks: + - "Unity" + - ".NET" + versions: + unity: ">=2021.3" + dotnet: ">=netstandard2.0" + +aliases: + - "No PLAN vocabulary" + - "PLAN.md vocabulary policy" + +related: + - "changelog-management" + - "human-prose-policy" + +status: "stable" +--- + +# No PLAN Vocabulary in Shipping Content + +> **One-line summary**: Internal `PLAN.md` filenames and `T0.0` / `P0.0`-style +> milestone tags must not appear in shipping content. + +## The rule + +The `validate:no-plan-vocabulary` validator rejects three forbidden patterns +inside any path under `Runtime/`, `Editor/`, `SourceGenerators/`, `Samples~/`, +`docs/`, root markdown (`*.md` at the repository root), and `llms.txt`: + +1. Internal plan-file filename references: `PLAN.md`, `PERF-PLAN.md`, + `OLD-PLAN.md`, `GH-PAGES-PLAN.md`. The validator matches on the literal + filename token, not on every occurrence of the substring `plan`. +1. Tier tag patterns shaped like `T.` and `P.` + (for example `T0.0`, `T6.3`, `P1.2`). The validator matches on the dotted + form with explicit digit groups so that bare `T1` / `P0` references are + not affected. +1. Plan-section headings: lines starting with `# Phase P` or + `# Tier T` (any heading depth). The validator scans markdown files + only. User-facing 'Phase 0/1/2/3' headings without the `P` prefix are + intentionally allowed. + +The root files `PLAN.md`, `PERF-PLAN.md`, `OLD-PLAN.md`, and `GH-PAGES-PLAN.md` +themselves are explicitly outside the validator's scan set; they are internal +planning artifacts that never ship. + +## What is intentionally allowed + +- Bare `T1` through `T6` Mermaid node IDs inside diagrams. +- Bare `P0`, `P1`, `P2` tokens inside test method names and identifiers. +- Phase 0 through Phase 3 stages in `docs/guides/migration-guide.md`. + Other 'Phase N' variants (without the `P` prefix) are also allowed by + the validator regex but are not currently used in shipping docs. + +If any of those bare forms gets flagged, that is a validator false positive +to fix in `scripts/validate-no-plan-vocabulary.js`, not a docs rewrite. + +## How to add a legitimate exception + +If a public reference to one of the forbidden tokens is genuinely required +(for example, a CHANGELOG entry that has to name a deleted file), edit the +validator's allowlist with a justification comment in the same change. Never +disable the validator; never add a per-file ignore that hides the rule from +review. + +## Why this rule exists + +The repository's internal `PLAN.md`, `PERF-PLAN.md`, `OLD-PLAN.md`, and +`GH-PAGES-PLAN.md` capture in-flight planning that never ships to users. +Mixing those filenames or tier tags into release docs, runtime XML doc +comments, or sample code confuses readers, dates the docs, and hands users +a vocabulary they have to mentally translate. Keeping the planning +vocabulary out of shipping content is cheap when enforced mechanically and +expensive to scrub once it leaks. + +## Validation + +Run from the repository root: + +```bash +npm run validate:no-plan-vocabulary +``` + +The hook fires at pre-push because the validator walks the full shipping +tree. Treat any failure as a prose rewrite, not a hook bypass; see +[Git Hook Performance Budget](../performance/git-hook-performance.md) for +the underlying budget rationale. + +## See also + +- [Changelog Management](./changelog-management.md) +- [Human-Prose Documentation Policy](./human-prose-policy.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-06 | Initial version | diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 46a40fb8..d25576a4 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -9,14 +9,14 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 155 | +| Total Skills | 157 | | Categories | 8 | --- ## Table of Contents -- [Documentation](#documentation) (27) +- [Documentation](#documentation) (29) - [GitHub Actions](#github-actions) (6) - [Packaging](#packaging) (2) - [Performance](#performance) (45) @@ -29,35 +29,37 @@ ## Documentation -| Skill | Lines | Complexity | Status | Performance | Tags | -| ----------------------------------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | ---------------------------- | -| [ASCII-Only Documentation Policy](./documentation/ascii-only-docs.md) | [ok] 173 | [basic] | [stable] | [risk: none] | documentation, ascii | -| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 296 | [basic] | [stable] | [risk: none] | changelog, release-notes | -| [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | [draft] 56 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Changelog Management](./documentation/changelog-management.md) | [ok] 229 | [basic] | [stable] | [risk: none] | changelog, documentation | -| [Changelog Release Workflow](./documentation/changelog-release-workflow.md) | [ok] 250 | [basic] | [stable] | [risk: none] | changelog, release-workflow | -| [Code Samples Must Compile](./documentation/code-samples-must-compile.md) | [ok] 139 | [basic] | [stable] | [risk: none] | documentation, code-samples | -| [Documentation Code Samples](./documentation/documentation-code-samples.md) | [ok] 213 | [basic] | [stable] | [risk: none] | documentation, code-samples | -| [Documentation Code Samples Part 1](./documentation/documentation-code-samples-part-1.md) | [draft] 82 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Documentation Style Guide](./documentation/documentation-style-guide.md) | [ok] 204 | [basic] | [stable] | [risk: none] | documentation, style | -| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | [ok] 155 | [basic] | [stable] | [risk: none] | documentation, workflow | -| [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | [ok] 149 | [basic] | [stable] | [risk: none] | documentation, code-comments | -| [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | [ok] 182 | [basic] | [stable] | [risk: none] | documentation, links | -| [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | [ok] 204 | [basic] | [stable] | [risk: none] | github-actions, ci-cd | -| [Human-Prose Documentation Policy](./documentation/human-prose-policy.md) | [ok] 187 | [basic] | [stable] | [risk: none] | documentation, prose | -| [Link Quality and External URL Management](./documentation/link-quality-guidelines.md) | [ok] 120 | [basic] | [stable] | [risk: none] | documentation, links | -| [Link Quality and External URL Management Part 1](./documentation/link-quality-guidelines-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Link Quality and External URL Management Part 2](./documentation/link-quality-guidelines-part-2.md) | [draft] 64 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Markdown Compatibility Guidelines](./documentation/markdown-compatibility.md) | [ok] 136 | [basic] | [stable] | [risk: none] | documentation, markdown | -| [Markdown Compatibility Guidelines Part 1](./documentation/markdown-compatibility-part-1.md) | [ok] 202 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Markdown Compatibility Guidelines Part 2](./documentation/markdown-compatibility-part-2.md) | [ok] 210 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Mermaid Diagram Theming](./documentation/mermaid-theming.md) | [ok] 199 | [intermediate] | [stable] | [risk: none] | documentation, mermaid | -| [Mermaid Diagram Theming Part 1](./documentation/mermaid-theming-part-1.md) | [ok] 160 | [intermediate] | [stable] | [risk: low] | migration, split | -| [MkDocs Navigation Management](./documentation/mkdocs-navigation.md) | [ok] 252 | [basic] | [stable] | [risk: none] | documentation, mkdocs | -| [MkDocs Navigation Management Part 1](./documentation/mkdocs-navigation-part-1.md) | [draft] 71 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Skill File Sizing Guidelines](./documentation/skill-file-sizing.md) | [ok] 256 | [basic] | [stable] | [risk: none] | documentation, skills | -| [Skill File Sizing Guidelines Part 1](./documentation/skill-file-sizing-part-1.md) | [draft] 34 | [intermediate] | [stable] | [risk: low] | migration, split | -| [XML Documentation Standards](./documentation/documentation-xml-docs.md) | [ok] 191 | [basic] | [stable] | [risk: none] | documentation, xml-docs | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ----------------------------------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | --------------------------------- | +| [ASCII-Only Documentation Policy](./documentation/ascii-only-docs.md) | [ok] 173 | [basic] | [stable] | [risk: none] | documentation, ascii | +| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 296 | [basic] | [stable] | [risk: none] | changelog, release-notes | +| [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | [draft] 56 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Changelog Management](./documentation/changelog-management.md) | [ok] 229 | [basic] | [stable] | [risk: none] | changelog, documentation | +| [Changelog Release Workflow](./documentation/changelog-release-workflow.md) | [ok] 250 | [basic] | [stable] | [risk: none] | changelog, release-workflow | +| [Code Samples Must Compile](./documentation/code-samples-must-compile.md) | [ok] 139 | [basic] | [stable] | [risk: none] | documentation, code-samples | +| [Documentation Code Samples](./documentation/documentation-code-samples.md) | [ok] 213 | [basic] | [stable] | [risk: none] | documentation, code-samples | +| [Documentation Code Samples Part 1](./documentation/documentation-code-samples-part-1.md) | [draft] 82 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Documentation Style Guide](./documentation/documentation-style-guide.md) | [ok] 204 | [basic] | [stable] | [risk: none] | documentation, style | +| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | [ok] 155 | [basic] | [stable] | [risk: none] | documentation, workflow | +| [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | [ok] 149 | [basic] | [stable] | [risk: none] | documentation, code-comments | +| [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | [ok] 182 | [basic] | [stable] | [risk: none] | documentation, links | +| [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | [ok] 204 | [basic] | [stable] | [risk: none] | github-actions, ci-cd | +| [Human-Prose Documentation Policy](./documentation/human-prose-policy.md) | [ok] 187 | [basic] | [stable] | [risk: none] | documentation, prose | +| [Link Quality and External URL Management](./documentation/link-quality-guidelines.md) | [ok] 120 | [basic] | [stable] | [risk: none] | documentation, links | +| [Link Quality and External URL Management Part 1](./documentation/link-quality-guidelines-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Link Quality and External URL Management Part 2](./documentation/link-quality-guidelines-part-2.md) | [draft] 64 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Markdown Compatibility Guidelines](./documentation/markdown-compatibility.md) | [ok] 136 | [basic] | [stable] | [risk: none] | documentation, markdown | +| [Markdown Compatibility Guidelines Part 1](./documentation/markdown-compatibility-part-1.md) | [ok] 202 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Markdown Compatibility Guidelines Part 2](./documentation/markdown-compatibility-part-2.md) | [ok] 210 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Memory Reclamation Documentation Maintenance](./documentation/memory-reclamation-docs.md) | [ok] 143 | [basic] | [stable] | [risk: none] | documentation, memory-reclamation | +| [Mermaid Diagram Theming](./documentation/mermaid-theming.md) | [ok] 199 | [intermediate] | [stable] | [risk: none] | documentation, mermaid | +| [Mermaid Diagram Theming Part 1](./documentation/mermaid-theming-part-1.md) | [ok] 160 | [intermediate] | [stable] | [risk: low] | migration, split | +| [MkDocs Navigation Management](./documentation/mkdocs-navigation.md) | [ok] 252 | [basic] | [stable] | [risk: none] | documentation, mkdocs | +| [MkDocs Navigation Management Part 1](./documentation/mkdocs-navigation-part-1.md) | [draft] 71 | [intermediate] | [stable] | [risk: low] | migration, split | +| [No PLAN Vocabulary in Shipping Content](./documentation/no-plan-vocabulary.md) | [ok] 154 | [basic] | [stable] | [risk: none] | documentation, vocabulary | +| [Skill File Sizing Guidelines](./documentation/skill-file-sizing.md) | [ok] 256 | [basic] | [stable] | [risk: none] | documentation, skills | +| [Skill File Sizing Guidelines Part 1](./documentation/skill-file-sizing-part-1.md) | [draft] 34 | [intermediate] | [stable] | [risk: low] | migration, split | +| [XML Documentation Standards](./documentation/documentation-xml-docs.md) | [ok] 191 | [basic] | [stable] | [risk: none] | documentation, xml-docs | ## GitHub Actions @@ -74,7 +76,7 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ----------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ----------- | ---------------- | -| [npm Package Configuration](./packaging/npm-package-configuration.md) | [ok] 221 | [intermediate] | [stable] | [risk: low] | npm, packaging | +| [npm Package Configuration](./packaging/npm-package-configuration.md) | [ok] 249 | [intermediate] | [stable] | [risk: low] | npm, packaging | | [npm Package Configuration Part 1](./packaging/npm-package-configuration-part-1.md) | [draft] 110 | [intermediate] | [stable] | [risk: low] | migration, split | ## Performance diff --git a/.llm/skills/packaging/npm-package-configuration.md b/.llm/skills/packaging/npm-package-configuration.md index 8a498e99..ccdffecb 100644 --- a/.llm/skills/packaging/npm-package-configuration.md +++ b/.llm/skills/packaging/npm-package-configuration.md @@ -215,6 +215,34 @@ npm pack --dry-run 2>&1 | grep "Tests/" || echo "Tests/ correctly excluded" npm pack --dry-run 2>&1 | grep "Tests\.meta" || echo "Tests.meta correctly excluded" ``` +## Issue #204 invariants + +[Issue #204](https://github.com/wallstop/DxMessaging/issues/204) shipped +build artifacts and orphaned `.meta` files in the npm tarball. The fix lives +in `scripts/validate-npm-meta.js` and is enforced at pre-push, in +`prepack`, and by the `validate-npm-meta` workflow. The invariants the +validator pins: + +1. The npm tarball contains no `bin/`, `obj/`, `*.pdb`, `*.tmp`, + `*.csproj.user`, `.vs/`, `.idea/`, `*.suo`, or `*.DotSettings.user` + paths. Function: `validateNoBuildArtifactsInTarball`. +1. Every shipped Unity-relevant path has a corresponding `.meta` neighbour + in the tarball (a `Foo.cs` ships with `Foo.cs.meta`; a `Foo.asmdef` + ships with `Foo.asmdef.meta`). Function: + `validatePublishedFilesArePairedWithMetas`. +1. Every shipped directory has its directory `.meta` in the tarball. If + `Runtime/Core/Foo.cs` ships, the tarball must also contain + `Runtime/Core.meta` and `Runtime.meta`. Function: + `validatePublishedFilesArePairedWithMetas`. + +### New tooling directories + +When a script writes outputs to a new top-level directory (for example +`.artifacts/`, `.profiler-output/`, `.unity-test-project/`), add the +directory to `.gitignore` AND `.npmignore` AND the validator's exclude +list IN THE SAME CHANGE. Skipping any of the three lets build artifacts +leak into the tarball or the working tree on a fresh checkout. + ## See Also - [npm package configuration part 1](./npm-package-configuration-part-1.md) diff --git a/.npmignore b/.npmignore index 138be61e..1a566422 100644 --- a/.npmignore +++ b/.npmignore @@ -17,6 +17,12 @@ **/obj/ **/*.pdb +# ============================================================================= +# Tooling Output Directories +# ============================================================================= +.artifacts/ +.artifacts.meta + # ============================================================================= # SourceGenerator Exclusions # ============================================================================= @@ -120,6 +126,10 @@ PLAN.md PLAN.md.meta GH-PAGES-PLAN.md GH-PAGES-PLAN.md.meta +PERF-PLAN.md +PERF-PLAN.md.meta +OLD-PLAN.md +OLD-PLAN.md.meta # ============================================================================= # Python Virtual Environment diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc266fb9..eca6b598 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -232,6 +232,48 @@ repos: - pre-push description: Validate that npm package includes all .meta files. Pre-push only; see .llm/skills/performance/git-hook-performance.md. + - repo: local + hooks: + - id: validate-runtime-settings-docs + name: Validate runtime-settings docs are in sync + entry: node scripts/validate-runtime-settings-docs.js + language: system + pass_filenames: false + files: '^(Runtime/Core/Configuration/.*\.cs|Runtime/Core/MessageBus/(IMessageBus|MessageHandler)\.cs|Runtime/Core/Pooling/.*\.cs|docs/reference/runtime-settings\.md|docs/guides/memory-reclamation\.md|scripts/validate-runtime-settings-docs\.js|scripts/__tests__/validate-runtime-settings-docs\.test\.js)$' + stages: + - pre-push + description: >- + Cross-reference public runtime settings, the Trim and + OccupiedTypeSlots surface, and the user-facing docs at + docs/reference/runtime-settings.md and docs/guides/memory-reclamation.md. + Pre-push only; see .llm/skills/performance/git-hook-performance.md. + - id: validate-untracked-policy + name: Validate no untracked-and-unignored repo paths + entry: node scripts/validate-untracked-policy.js + language: system + pass_filenames: false + always_run: true + stages: + - pre-push + description: >- + Fail the push when git ls-files --others --exclude-standard reports + any untracked-and-unignored path. Either commit the file or extend + .gitignore / .npmignore. Pre-push only; see + .llm/skills/performance/git-hook-performance.md. + - id: validate-no-plan-vocabulary + name: Validate shipping content has no PLAN.md vocabulary + entry: node scripts/validate-no-plan-vocabulary.js + language: system + pass_filenames: false + files: '^(Runtime/|Editor/|SourceGenerators/|Samples~/|docs/|llms\.txt|[^/]+\.md|scripts/validate-no-plan-vocabulary\.js|scripts/__tests__/validate-no-plan-vocabulary\.test\.js)' + stages: + - pre-push + description: >- + Reject PLAN.md / PERF-PLAN.md / OLD-PLAN.md / GH-PAGES-PLAN.md + filename references and T0.0 / P0.0-style milestone tags in shipping + content. Pre-push only; see + .llm/skills/performance/git-hook-performance.md. + - repo: local hooks: # perf-allow[scans-the-world-with-files]: validator reads the entire CHANGELOG.md and git-diff metadata to enforce coverage-by-section; staged argv would not narrow the changelog scan itself diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe35733..a02714b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AllocationMatrixTests` covering zero-GC dispatch across kinds, interceptors, post-processors, diagnostics, and priority-based dispatch. - Expanded coverage now pins source-generator and analyzer behaviour that users rely on: generic / record struct / nested partial / nullable annotation cases for `DxMessageIdGenerator`; `[DxOptionalParameter]` permutations and DXMSG005 boundary cases for `DxAutoConstructorGenerator`; positive opt-out cases for `DxIgnoreMissingBaseCallAttribute`. No runtime API change. - Three new public read-only registration counters on `IMessageBus`: `RegisteredInterceptors`, `RegisteredPostProcessors`, and `RegisteredGlobalAcceptAll`. Lets diagnostic and leak-check tooling distinguish interceptor / post-processor / global accept-all leaks from regular handler leaks, and lets external monitors aggregate the bus's registration footprint without reflecting on internals. `MessageBus` aggregates the counters on each read by walking the per-message-type caches; consumers polling these properties in tight loops should snapshot at region boundaries. -- Runtime memory-reclamation foundations: `DxMessagingRuntimeSettings` loads from `Resources/DxMessagingRuntimeSettings` and hot-reloads eviction cadence, enablement, trim opt-out, and pool-cap changes without recreating the bus. Pooled internal collections and typed/bus slot registries preserve existing dispatch APIs while making empty handler and interceptor slots reclaimable. `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` reset dirty empty slots and trim shared pools on demand, `OccupiedTypeSlots` / `OccupiedTargetSlots` expose the retained bus and dirty typed-handler slot footprint for diagnostics, and idle sweeps run from emits and Unity's PlayerLoop. +- Runtime memory-reclamation foundations: `DxMessagingRuntimeSettings` loads from `Resources/DxMessagingRuntimeSettings` and hot-reloads eviction cadence, enablement, trim opt-out, and pool-cap changes without recreating the bus. Pooled internal collections and typed/bus slot registries preserve existing dispatch APIs while making empty handler and interceptor slots reclaimable. `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` reset dirty empty slots and trim shared pools on demand, `OccupiedTypeSlots` / `OccupiedTargetSlots` expose the retained bus and dirty typed-handler slot footprint for diagnostics, and idle sweeps run from emits and Unity's PlayerLoop. New user-facing reference and tuning docs ship at `docs/reference/runtime-settings.md` (per-setting reference table) and `docs/guides/memory-reclamation.md` (forced trim, idle sweep, and pool tuning narrative). - New explicit-factory registration helpers across all three DI integrations: `VContainerRegistrationExtensions.RegisterDxMessagingBus`, `ReflexRegistrationExtensions.AddDxMessagingBus`, and `ZenjectRegistrationExtensions.BindDxMessagingBus`. Each helper exposes the bus under both the concrete `MessageBus` contract and the `IMessageBus` interface, accepts an overloadable lifetime where the container supports it, accepts a user-supplied `Func` factory, and accepts an `IDxMessagingClock` overload that constructs the bus through the new internal-only `MessageBus.CreateForInternalUse` factory so test-side clocks (for example `FakeClock`) can be injected through the container. The VContainer helper registers both contracts in one registration call, avoiding VContainer environments where chained `.AsSelf().As()` drops the concrete contract and fails with `No such registration of type: DxMessaging.Core.MessageBus.MessageBus`; the DI samples either call the helper directly or document the corresponding helper preference for their container shape. ### Changed @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RegisterGlobalAcceptAll` (`HandleGlobalUntargeted`/`HandleGlobalTargeted`/`HandleGlobalBroadcast`) is intentionally NOT covered by this fix. The bus's global accept-all dispatch path prefreezes lazily per-entry inside the dispatch loop, so a sibling `MessageHandler` that removes another's global registration mid-emit causes the removed handler to be skipped on the in-flight emission. The behavior is pinned by `MutationPostProcessorAcrossHandlersTests.RemoveOtherGlobalAcceptAllAcrossHandlersDuringDispatch`; if a future change introduces upfront global-handler prefreeze, that test must be updated to expect the snapshot semantics that the per-kind paths already provide. - `DxMessagingStaticState.Reset` is now race-safe against deferred deregistrations. Previously, when a message-aware component was destroyed but its disable callback had not yet run (Unity defers Object.Destroy to end of frame) and Reset ran in between, the deferred token teardown would log spurious "Received over-deregistration of {type} for {handler}" errors against the user's Unity console. The bus now stamps each captured deregister closure with a generation counter and silently no-ops closures captured before a Reset. Applied uniformly across every register entry point (untargeted, targeted, broadcast, GlobalAcceptAll, and all three interceptor kinds). The same race-safety guarantee is now propagated to user-installed custom global buses via `MessageBus.BumpResetGeneration()`, which `DxMessagingStaticState.Reset` invokes on the active global bus when it differs from the built-in default; the custom bus's sinks are intentionally left intact to avoid clobbering state the user installed it to preserve. User code is unaffected except that previously-spurious error logs disappear. - `MessageRegistrationToken.RemoveRegistration(handle)` no longer leaks the staged registration entry, so a `Disable()`/`Enable()` cycle after `RemoveRegistration` no longer silently re-registers the removed handler. The fix also drops the matching metadata and call-count entries so diagnostic mode does not accumulate stale handles. +- Resolved [issue #204](https://github.com/wallstop/DxMessaging/issues/204) (build artifacts and orphaned `.meta` files leaking into the npm tarball) and prevented its regression: `scripts/validate-npm-meta.js` now runs `validateNoBuildArtifactsInTarball` (rejects `bin/`, `obj/`, `*.pdb`, `*.tmp`, `*.csproj.user`, `.vs/`, `.idea/`, `*.suo`, and `*.DotSettings.user` paths in the tarball) and `validatePublishedFilesArePairedWithMetas` (every shipped Unity-relevant file has its `.meta` neighbour and every shipped directory has its directory `.meta`), wired into `prepack` and the `validate-npm-meta` workflow so the next publish cannot reintroduce the regression. ## [2.2.0] diff --git a/README.md b/README.md index d70169f4..1e302b85 100644 --- a/README.md +++ b/README.md @@ -695,6 +695,7 @@ public void TestAchievementSystem() { - [Unity Integration](docs/guides/unity-integration.md) -- MessagingComponent deep dive - [Targeting & Context](docs/concepts/targeting-and-context.md) -- GameObject vs Component - [Diagnostics](docs/guides/diagnostics.md) -- Inspector tools and debugging +- [Memory Reclamation](docs/guides/memory-reclamation.md) -- idle eviction, explicit Trim, and tuning the runtime settings asset Important: Inheritance with MessageAwareComponent diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs index 21236d49..87a13704 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -13,7 +13,7 @@ namespace DxMessaging.Core.Configuration /// out-of-the-box. /// /// - /// To customize, create the asset via Assets > Create > Wallstop > + /// To customize, create the asset via Assets > Create > Wallstop Studios > /// DxMessaging > Runtime Settings and place it under any /// Resources/ folder named DxMessagingRuntimeSettings.asset. /// Field changes raise ; consumers should @@ -183,7 +183,7 @@ private void OnValidate() ) { Debug.LogWarning( - "[DxMessaging] Runtime settings asset is not under a Resources/ folder; Resources.Load will not find it. Move it under Assets/Resources/ or use the 'Assets/Create/Wallstop/DxMessaging/Runtime Settings (in Resources)' menu.", + "[DxMessaging] Runtime settings asset is not under a Resources/ folder; Resources.Load will not find it. Move it under Assets/Resources/ or use the 'Assets/Create/Wallstop Studios/DxMessaging/Runtime Settings (in Resources)' menu.", this ); } diff --git a/docs/advanced/runtime-configuration.md b/docs/advanced/runtime-configuration.md index 4f0b604d..b6215dcf 100644 --- a/docs/advanced/runtime-configuration.md +++ b/docs/advanced/runtime-configuration.md @@ -9,6 +9,7 @@ This guide covers how to configure message buses at runtime and retarget existin - [Global Message Bus Management](#global-message-bus-management) - [Re-binding Registrations](#re-binding-registrations) - [Common Patterns](#common-patterns) + - [Memory Reclamation Policy](#memory-reclamation-policy) --- @@ -399,8 +400,30 @@ public class DynamicComponentManager --- +## Memory Reclamation Policy + +Long-running buses retain per-message-type and per-context slots and a set of +shared collection pools. The memory reclamation system bounds that growth +through idle sweeps and an explicit `Trim` API. Both knobs live on the +`DxMessagingRuntimeSettings` ScriptableObject loaded from `Resources/`. + +The asset is hot-reloadable. Live buses subscribe to +`DxMessagingRuntimeSettings.SettingsChanged` and re-apply caps without +recreation, which means you can tune the policy from the editor while Play +mode is running. + +For scenario-driven tuning recommendations, the public `Trim` and +diagnostic-counter API surface, and worked examples for scene transitions +and leak diagnosis, see the [Memory Reclamation guide](../guides/memory-reclamation.md). +For the full parameter reference (including defaults, mins, and tooltip +text), see the [Runtime Settings reference](../reference/runtime-settings.md). + +--- + ## See Also +- **[Memory Reclamation Guide](../guides/memory-reclamation.md)** -- when reclamation runs, how to tune it, and how to verify it +- **[Runtime Settings Reference](../reference/runtime-settings.md)** -- complete parameter reference for `DxMessagingRuntimeSettings` - **[Message Bus Providers](message-bus-providers.md)** -- ScriptableObject-based provider system for design-time configuration - **[Registration Builders](registration-builders.md)** -- Fluent API for building message registrations with priority and lifecycle control - **[DI Integration Guides](../integrations/index.md)** -- Zenject, VContainer, and Reflex integration patterns diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 66ffdc8a..30ef5ec3 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 16,802,867 | No | -| UniRx MessageBroker | 18,187,545 | No | -| MessagePipe (Global) | 95,845,160 | No | -| Zenject SignalBus | 2,468,473 | Yes | +| DxMessaging (Untargeted) - No-Copy | 17,074,646 | No | +| UniRx MessageBroker | 17,919,648 | No | +| MessagePipe (Global) | 94,913,633 | No | +| Zenject SignalBus | 2,495,730 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/design-and-architecture.md b/docs/architecture/design-and-architecture.md index a9c96ef0..2a70cf95 100644 --- a/docs/architecture/design-and-architecture.md +++ b/docs/architecture/design-and-architecture.md @@ -114,6 +114,18 @@ Attributes like `[DxTargetedMessage]` and `[DxBroadcastMessage]` (with source ge - Disposal cleans up handlers automatically, preventing leaks. - `MessageAwareComponent` wires Unity lifecycles to tokens for safety. +### Memory Reclamation Subsystem + +A separate memory-reclamation subsystem bounds the empty per-message-type +and per-context slots a long-running bus retains, plus the shared +collection pools `DxPools` and the bus-owned context dictionary use. Idle +sweeps run on a wall-clock cadence and through a Unity PlayerLoop hook; +`IMessageBus.Trim` exposes the same sweep synchronously. Active +registrations are never reclaimed. See the +[Memory Reclamation guide](../guides/memory-reclamation.md) and +[Runtime Settings reference](../reference/runtime-settings.md) for the full +policy, tuning recommendations, and diagnostic counters. + ## The Message Bus Message flow: Interceptors > Handlers > Post-Processors. diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index a4133c60..9ee85070 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -220,17 +220,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,466,070 | Yes | -| DxMessaging (GameObject) - Normal | 9,955,012 | No | -| DxMessaging (Component) - Normal | 10,025,723 | No | -| DxMessaging (GameObject) - No-Copy | 11,154,213 | No | -| DxMessaging (Component) - No-Copy | 8,462,079 | No | -| DxMessaging (Untargeted) - No-Copy | 16,613,774 | No | -| DxMessaging (Untargeted) - Interceptors | 7,193,011 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,519,230 | No | -| Reflexive (One Argument) | 2,733,126 | No | -| Reflexive (Two Arguments) | 2,265,041 | No | -| Reflexive (Three Arguments) | 2,292,211 | No | +| Unity | 2,568,246 | Yes | +| DxMessaging (GameObject) - Normal | 9,854,653 | No | +| DxMessaging (Component) - Normal | 9,902,081 | No | +| DxMessaging (GameObject) - No-Copy | 11,074,789 | No | +| DxMessaging (Component) - No-Copy | 8,424,722 | No | +| DxMessaging (Untargeted) - No-Copy | 16,976,111 | No | +| DxMessaging (Untargeted) - Interceptors | 7,232,103 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,779,454 | No | +| Reflexive (One Argument) | 2,706,648 | No | +| Reflexive (Two Arguments) | 2,188,005 | No | +| Reflexive (Three Arguments) | 2,187,457 | No | ## macOS @@ -239,3 +239,35 @@ Run the PlayMode benchmarks on macOS to populate this section. ## Linux Run the PlayMode benchmarks on Linux to populate this section. + +## Memory footprint and reclamation + +Dispatch state is stored per message type and, for targeted and broadcast +paths, per `InstanceId`. Long-running sessions accumulate slots for every +type or entity ever touched unless something reclaims them. The memory +reclamation system caps that growth without changing dispatch semantics or +allocating during emit. + +Reclamation runs on two paths: + +- An idle sweep that runs from emit-time clock samples and the Unity + PlayerLoop, gated by `DxMessagingRuntimeSettings.EvictionEnabled` and + `EvictionTickIntervalSeconds`. Empty slots become eligible only after + remaining empty for at least `IdleEvictionSeconds` of wall time. +- An explicit `IMessageBus.Trim(force)` and `MessageHandler.TrimAll(force)` + pair that runs synchronously at scene boundaries, in tests, or in + maintenance windows. The master switch `EnableTrimApi` controls whether + the explicit calls perform work; idle sweeps remain controlled by + `EvictionEnabled` independently. + +Active registrations are never reclaimed. Only empty slots and shared pool +entries are touched. Sweep work runs outside the hot handler loop, so emit +throughput is unaffected; the per-emit overhead is one branch that samples +the wall clock. + +For tuning recommendations, the public `Trim` and diagnostic-counter API +surface, and worked examples (scene transitions, leak diagnosis, mobile +caps, shipped-title configurations), see the +[Memory Reclamation guide](../guides/memory-reclamation.md). For the +parameter reference, see the +[Runtime Settings reference](../reference/runtime-settings.md). diff --git a/docs/guides/diagnostics.md b/docs/guides/diagnostics.md index c0f67e26..d9a8730b 100644 --- a/docs/guides/diagnostics.md +++ b/docs/guides/diagnostics.md @@ -294,7 +294,43 @@ Attach `MessagingComponent` to a GameObject. In the Unity Inspector: - Set `Log.Enabled = true` in tests to verify registration behavior. - Use `Log.Clear()` between test cases to isolate registration tracking. +## Memory diagnostic counters + +Three pieces of API expose memory-reclamation state on `IMessageBus`: + +- `OccupiedTypeSlots` returns the number of distinct per-message-type slots + currently occupied on the bus. +- `OccupiedTargetSlots` returns the number of distinct target or source + context slots currently occupied on the bus. +- `Trim(bool force = false)` reclaims empty slots and returns a `TrimResult` + whose `TypeSlotsEvicted`, `TargetSlotsEvicted`, + `PooledCollectionsEvicted`, and `LiveTypeSlotsRemaining` fields describe + the work performed. `MessageHandler.TrimAll(force)` is the convenience + wrapper for the global bus. + +Both counters aggregate on read by walking the per-kind caches; the cost is +O(n) in the number of distinct message types known to the bus. Snapshot the +values at region boundaries (start of a scene unload, end of a leak-watching +scope) rather than polling them every frame. + +A typical leak-watching pattern uses these counters together with the +internal test-suite `LeakWatcher` utility (see +`Tests/Runtime/TestUtilities/LeakWatcher.cs` for the pattern; users can build +their own equivalent for production diagnostics): + +1. Snapshot `OccupiedTypeSlots` and `OccupiedTargetSlots` at the start of a + scoped operation. +1. Run the operation. +1. Call `Trim(force: true)` to reset every empty slot. +1. Compare the post-trim counters against the snapshot. Surviving slots + correspond to active registrations. + +For the full reclamation model, tuning recommendations, and worked examples, +see the [Memory Reclamation guide](memory-reclamation.md). + ## Related - [Listening Patterns](../concepts/listening-patterns.md) +- [Memory Reclamation](memory-reclamation.md) +- [Runtime Settings Reference](../reference/runtime-settings.md) - [Troubleshooting](../reference/troubleshooting.md) diff --git a/docs/guides/memory-reclamation.md b/docs/guides/memory-reclamation.md new file mode 100644 index 00000000..5fcbf9d3 --- /dev/null +++ b/docs/guides/memory-reclamation.md @@ -0,0 +1,309 @@ +# Memory Reclamation + +DxMessaging keeps dispatch state in per-message-type and per-context slots so +lookups stay O(1) on the hot path. Long-running sessions can otherwise retain a +slot for every message type or `InstanceId` ever touched. The memory +reclamation system bounds that growth without changing dispatch semantics or +allocating during emit. + +This page describes when reclamation runs, what it touches, how to configure +it for common scenarios, and the diagnostic counters you can use to verify it +is doing its job. + +## Table of Contents + +- [Memory Reclamation](#memory-reclamation) + - [Overview](#overview) + - [Quick Start](#quick-start) + - [How Reclamation Works](#how-reclamation-works) + - [Tuning by Scenario](#tuning-by-scenario) + - [Reading TrimResult](#reading-trimresult) + - [Diagnostics Counters](#diagnostics-counters) + - [Troubleshooting](#troubleshooting) + - [I called Trim but nothing changed](#i-called-trim-but-nothing-changed) + - [Memory keeps growing across scenes](#memory-keeps-growing-across-scenes) + - [Idle sweeps don't seem to fire](#idle-sweeps-dont-seem-to-fire) + - [See Also](#see-also) + +--- + +## Overview + +Reclamation targets two kinds of state: + +- **Empty handler and interceptor slots** kept on the bus per message type and, + for targeted and broadcast messages, per `InstanceId`. A slot becomes empty + after every registration that used it has been deregistered. Empty slots are + retained until reclamation runs because a freshly empty slot is often about + to be used again on the next dispatch. +- **Pooled collections** held by `DxPools` and the bus-owned context-dictionary + pool. Pools cap their retained entries with either LRU or bounded LIFO + retention. + +Active registrations are never reclaimed. A handler that has not been +deregistered, an interceptor that is still wired up, or a typed-handler slot +with at least one live registration is treated as live state and left alone, +no matter how old it is. Reclamation only resets slots that are already empty. + +The system exists for long-running sessions. Editor play sessions, dedicated +servers, and shipped titles that keep the same process running across many +scene changes accumulate distinct message types and target `InstanceId`s over +time. Without reclamation, those slots stay around for the lifetime of the +process. With reclamation, idle empty slots are reset on a sweep cadence you +control, and shared collection pools stay below the configured cap. + +--- + +## Quick Start + +Default behavior works without any setup. The first bus construction calls +`Resources.Load("DxMessagingRuntimeSettings")` +and, on a miss, hands out a defaulted in-memory instance so the package runs +out-of-the-box. If you do not need to change defaults, you do not need an +asset. + +To customize, create the asset: + +1. In the Project window, run `Assets > Create > Wallstop Studios > DxMessaging > Runtime Settings`. + The default `[CreateAssetMenu]` path creates the asset in the currently + selected folder. +1. Move or place the asset under any `Resources/` folder. The file name must + stay `DxMessagingRuntimeSettings.asset` because that is the resource name + `Resources.Load` looks up. +1. Alternatively, use `Assets > Create > Wallstop Studios > DxMessaging > Runtime Settings (in Resources)` + from the menu bar. That helper creates + `Assets/Resources/DxMessagingRuntimeSettings.asset` directly, ensuring the + asset is picked up at runtime. + +The recommended path is `Assets/Resources/DxMessagingRuntimeSettings.asset`. +The asset's editor `OnValidate` warns when it is placed outside a `Resources/` +folder because `Resources.Load` cannot find it there. + +Field changes raise `DxMessagingRuntimeSettings.SettingsChanged`. Live buses +re-apply caps, retention modes, and toggles without recreation, so editing the +asset while the editor is in Play mode takes effect on the next sweep +boundary. + +For the full list of fields, defaults, and tooltip text, see the +[Runtime Settings reference](../reference/runtime-settings.md). + +--- + +## How Reclamation Works + +There are two reclamation paths and they share the same underlying sweep code. +A sweep reclaims only empty slots; active registrations are never touched. + +### Idle Sweep + +Idle sweeps run on a wall-clock cadence. Two triggers can drive a sweep: + +- **Emit-time sampling.** Every emit checks whether enough wall time has + elapsed since the last sweep. The threshold is + `EvictionTickIntervalSeconds`. When it has, the bus runs a sweep before + dispatching the message. Sampling the clock periodically keeps the per-emit + overhead at one branch on the hot path (every 16th emit samples the wall + clock to decide whether enough time has elapsed). +- **Unity PlayerLoop hook.** `EvictionPlayerLoopHook` inserts a sweep callback + into Unity's PlayerLoop so that idle sweeps still run when no emits are + happening. The hook is installed automatically on Unity 2021.3 and newer + player and editor hosts. Non-Unity hosts must drive cadence by emitting + messages or calling `Trim` directly. + +Idle sweeps are gated by two settings. When `EvictionEnabled` is false neither +the inline emit-time path nor the PlayerLoop path runs. When +`EvictionTickIntervalSeconds` is large, sweeps run less often. + +Empty slots become eligible only after they have remained empty (and free of +register, deregister, or dispatch activity) for at least `IdleEvictionSeconds` +worth of bus activity ticks (advanced on emit, register, deregister, and once +per frame from the Unity PlayerLoop). The bus tracks per-slot dirty state by +stamping touched slots with an internal tick counter, then revisits only the +types, targets, interceptors, and handlers that have changed since the +previous sweep. + +On non-Unity hosts the tick counter only advances on bus activity, so an +inactive bus does not age out empty slots without an explicit `Trim`. Drive +sweeps by emitting messages periodically or call `Trim` from a maintenance +thread. + +### Explicit Trim + +Two public APIs reclaim synchronously: + +- `IMessageBus.Trim(bool force = false)` runs a sweep on a single bus. +- `MessageHandler.TrimAll(bool force = false)` is the convenience entry point + for the global bus. + +When `force` is true, the sweep ignores the idle threshold and reclaims every +empty candidate immediately, including draining shared collection pools to +zero. When `force` is false, the explicit call uses the same idle threshold as +the sweep cadence, so it acts as an opportunistic top-up rather than a +heavy-handed flush. + +Both APIs return a `TrimResult` so callers can log or assert what was +reclaimed. The master switch `EnableTrimApi` controls whether explicit trim +performs work; when the switch is false, both APIs become a no-op that returns +a default `TrimResult`. `EnableTrimApi` and `EvictionEnabled` are independent. +A shipped title can keep idle sweeps on while disabling the explicit API, or +disable idle sweeps and reclaim only at scene boundaries. + +--- + +## Tuning by Scenario + +The defaults (30 second idle threshold, 5 second tick, LRU pool retention with +512 distinct entries, both master switches on) are tuned for general-purpose +projects. Use the table below as a starting point when defaults do not fit. + +| Scenario | Recommended settings | Rationale | When to call Trim explicitly | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| High-throughput stable types | `EvictionEnabled = true`, larger `IdleEvictionSeconds` (60 to 120), default tick, default pools | A small set of message types is touched constantly. Long idle thresholds prevent sweeping slots that briefly empty between bursts. | Rarely. Defaults already match this shape. | +| Mobile or low-memory titles | `BufferMaxDistinctEntries` lowered (128 to 256), `BufferUseLruEviction = true`, default tick, default idle | Shrinking the pool cap caps peak retained pool memory. LRU retains the entries that are actually being reused. | Call `Trim(force: true)` on scene-load completion to drop pool entries that the loaded scene will not reuse. | +| Dynamic types across scenes | Default eviction, `EnableTrimApi = true` | Each scene introduces a different set of targets and message types. Idle eviction handles steady-state cleanup; explicit trim handles transitions. | `Trim(force: true)` on scene unload, scene additive load, or after a teardown of a scoped subsystem. | +| Shipped title with minimal churn | `EvictionEnabled = false`, `EnableTrimApi = true`, `EvictionTickIntervalSeconds` left at default | When the set of types and targets is fixed at startup, idle sweeps are pure overhead. Keeping the explicit API on lets you reclaim at well-defined transitions. | At explicit transitions: scene boundaries, post-bootstrap, before extended idle screens. | +| Leak diagnosis | `EvictionEnabled = true`, low `IdleEvictionSeconds` (1 to 5), low `BufferMaxDistinctEntries` (32 to 64) | Aggressive caps and a short idle threshold expose slots that are not getting evicted because something is holding a registration live. | After a suspected leak, call `Trim(force: true)` and read `OccupiedTypeSlots` and `OccupiedTargetSlots`. Slots that survive a forced trim correspond to active registrations. | +| Editor safe-mode | `EvictionEnabled = false`, `EnableTrimApi = false` | Domain reload races and editor-time enter/exit play transitions can race a running sweep. Disabling both switches removes that risk in safe-mode bring-up. | Re-enable the switches after the editor stabilizes. | + +The settings asset hot-reloads, so you can change a row of this table by +editing the asset rather than restarting the editor. + +--- + +## Reading TrimResult + +Every successful `Trim` call returns an `IMessageBus.TrimResult`. Its fields +are read-only counters for the work the sweep performed: + +- `TypeSlotsEvicted` is the number of typed-handler-slot entries that were + reset across all reclaimed message types. +- `TargetSlotsEvicted` is the number of bus-side target or source context + entries removed across all reclaimed `InstanceId`s. +- `PooledCollectionsEvicted` is the number of pooled collections dropped from + shared pools (`DxPools` plus the bus-owned context-dictionary pool). +- `LiveTypeSlotsRemaining` is the count of occupied type slots remaining on + the bus after the sweep. This is the same value `OccupiedTypeSlots` returns + immediately after the sweep. + +The struct also overrides `ToString()` so it renders as a single line in +logs. + +```csharp +using DxMessaging.Core; +using DxMessaging.Core.MessageBus; +using UnityEngine; + +public static class MemoryReclamationLogger +{ + public static void LogForcedTrim() + { + IMessageBus.TrimResult result = MessageHandler.TrimAll(force: true); + Debug.Log($"[DxMessaging] {result}"); + Debug.Log( + $"[DxMessaging] reclaimed {result.TypeSlotsEvicted} type slots, " + + $"{result.TargetSlotsEvicted} target slots, " + + $"{result.PooledCollectionsEvicted} pooled collections; " + + $"{result.LiveTypeSlotsRemaining} type slots still live." + ); + } +} +``` + +For non-global buses, call `bus.Trim(force: true)` and inspect the result the +same way. + +--- + +## Diagnostics Counters + +Two public counters on `IMessageBus` report current slot occupancy: + +- `OccupiedTypeSlots` is the count of distinct per-message-type slots that are + currently occupied on the bus. +- `OccupiedTargetSlots` is the count of distinct target or source context + slots that are currently occupied on the bus. + +Both counters are aggregated on read. The implementation walks the per-kind +caches, so the call is O(n) in the number of message types or targets known +to the bus. Snapshot the values at region boundaries (start of a scene +unload, end of a leak-watching scope) rather than polling them every frame. + +These counters integrate with the internal test-suite `LeakWatcher` utility +(see `Tests/Runtime/TestUtilities/LeakWatcher.cs` for the pattern; users can +build their own equivalent for production diagnostics). A typical +verification pattern: + +1. Snapshot `OccupiedTypeSlots` and `OccupiedTargetSlots` at the start of a + scoped operation. +1. Run the operation. +1. Call `Trim(force: true)` so empty slots are reset. +1. Compare the post-trim counters against the snapshot. Any difference + represents registrations that survived the operation; either intentional + or a leak. + +The `TrimResult.LiveTypeSlotsRemaining` field is identical to a post-trim +read of `OccupiedTypeSlots` and is the cheaper option when you have just +called `Trim`. + +--- + +## Troubleshooting + +### I called Trim but nothing changed + +`Trim` only reclaims empty slots. If a message type's typed-handler slot still +has at least one active registration, the slot is preserved. Confirm with +`OccupiedTypeSlots` before and after the call and check that the registrations +you expected to be torn down were actually deregistered. + +If the result really is empty, check `EnableTrimApi` on the active settings +asset. When it is false, both `IMessageBus.Trim` and +`MessageHandler.TrimAll` return a default `TrimResult` without doing any +work. Set it to true (the default) or use a different settings asset. + +If empty slots exist but were not reclaimed, the call may have run with +`force: false` and slots may not yet have aged out. Pass `force: true` to +ignore `IdleEvictionSeconds` and reclaim every empty candidate. + +### Memory keeps growing across scenes + +Cross-scene growth usually comes from new `InstanceId`s introduced by each +scene. Idle eviction will eventually reclaim the empty slots, but you can +reclaim deterministically at the transition. Call +`MessageHandler.TrimAll(force: true)` (or `bus.Trim(force: true)` for +non-global buses) on scene unload, after the previous scene's components have +finished tearing down. The result's `TargetSlotsEvicted` and +`PooledCollectionsEvicted` fields confirm that the transition's targets were +cleared. + +If forced trim does not reduce the counters, an active registration is +keeping the slot live. Audit components that survive scene boundaries +(singletons, `DontDestroyOnLoad` objects, container-managed services) and +verify their tokens are deregistered when their owners go away. + +### Idle sweeps don't seem to fire + +Three settings gate idle sweeps: + +- `EvictionEnabled` must be true. When it is false neither the inline + emit-time path nor the PlayerLoop hook runs. +- `EvictionTickIntervalSeconds` controls the minimum interval between sweeps. + A very large value defers sweeps; zero allows back-to-back sweeps but does + not force one to run on every emit. +- `IdleEvictionSeconds` controls when an empty slot becomes eligible. If the + threshold is larger than the interval between dispatches that touch the + slot, the slot is repeatedly reset and never ages out. Lower the threshold + or call `Trim(force: true)` for a deterministic reclaim. + +In non-Unity hosts, the PlayerLoop hook is unavailable. Either drive sweeps +by continuing to emit messages periodically or call `Trim` from a maintenance +thread. + +--- + +## See Also + +- [Runtime Settings reference](../reference/runtime-settings.md) +- [Runtime Message Bus Configuration](../advanced/runtime-configuration.md) +- [Diagnostics](diagnostics.md) +- [Performance](../architecture/performance.md) diff --git a/docs/guides/memory-reclamation.md.meta b/docs/guides/memory-reclamation.md.meta new file mode 100644 index 00000000..22ca3ffb --- /dev/null +++ b/docs/guides/memory-reclamation.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a4d3f9e2c8b7e944a9d6f1c2b3e4d5f6 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/migration-guide.md b/docs/guides/migration-guide.md index 0c589c23..d6bd44e2 100644 --- a/docs/guides/migration-guide.md +++ b/docs/guides/migration-guide.md @@ -208,6 +208,58 @@ When to use direct references/events: - Private implementation details -> keep internal ``` +## Memory reclamation in 3.0 + +DxMessaging 3.0 adds a memory-reclamation subsystem that resets empty +per-message-type and per-context slots so long-running sessions do not +retain a slot for every message type or `InstanceId` ever touched. The +2.x dispatch surface is unchanged; the new pieces are opt-in tuning and +diagnostics. + +### What is new + +- **Optional `DxMessagingRuntimeSettings` asset.** A ScriptableObject loaded + via `Resources.Load("DxMessagingRuntimeSettings")`. + Use `Assets > Create > Wallstop Studios > DxMessaging > Runtime Settings (in Resources)` + to drop the asset under `Assets/Resources/`. Without an asset the runtime + hands out a defaulted instance, so the 2.x out-of-the-box behavior is + preserved. +- **New `Trim` and `TrimAll` API.** `IMessageBus.Trim(bool force = false)` + and the convenience wrapper `MessageHandler.TrimAll(bool force = false)` + reclaim empty slots and pooled collections synchronously. Both return a + `TrimResult` reporting how much was reclaimed. +- **New `OccupiedTypeSlots` and `OccupiedTargetSlots` counters on + `IMessageBus`.** Aggregated read-only counters for use in diagnostics and + leak-watching tests. + +### Backward compatibility + +- The defaults match 2.x behavior with no asset present: idle eviction is + on with a 30 second threshold, the explicit Trim API is on, pool retention + is LRU at 512 entries. +- No existing dispatch, registration, or interceptor code changes. Active + registrations are never reclaimed; the sweep only resets empty slots. +- The settings asset hot-reloads through `DxMessagingRuntimeSettings.SettingsChanged`. + Existing buses re-apply caps without recreation, so editing the asset + during Play mode does not invalidate registrations. + +### When to adopt + +- **Shipped titles or dedicated servers running for hours.** Drop the asset + in to bound retained slot memory; tune `IdleEvictionSeconds` and + `BufferMaxDistinctEntries` for the workload. +- **Editor sessions across many scene loads.** Call + `MessageHandler.TrimAll(force: true)` at scene unload to keep the + occupancy counters honest. +- **Leak diagnosis.** Snapshot `OccupiedTypeSlots` / `OccupiedTargetSlots`, + run the operation, force a trim, then compare. Surviving slots correspond + to active registrations. + +For tuning, scenario tables, and worked examples, see the +[Memory Reclamation guide](memory-reclamation.md). For asset parameters, +defaults, and the full diagnostic API, see the +[Runtime Settings reference](../reference/runtime-settings.md). + ## Coexistence Patterns ### Pattern 1: Event-to-Message Bridge diff --git a/docs/reference/faq.md b/docs/reference/faq.md index ffd78421..e92c69ad 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -45,6 +45,16 @@ The most common cause is forgetting to call `base.Awake()` (or `base.OnEnable()` - Yes. You can integrate with legacy patterns via `ReflexiveMessage`. Prefer DxMessaging for new code. +## Why is my game retaining memory across scenes? + +- Each scene introduces new `InstanceId`s and sometimes new message types, + which add empty slots on the bus when their handlers tear down. Idle + eviction will reclaim them eventually; for deterministic cleanup call + `MessageHandler.TrimAll(force: true)` on scene unload (or + `bus.Trim(force: true)` for a non-global bus). See the + [Memory Reclamation guide](../guides/memory-reclamation.md) for the full + pattern. + --- ## Related Documentation diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 83d6f7b3..9f077403 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -169,6 +169,41 @@ A **debug feature** that tracks message history and handler statistics. Enable i IMessageBus.GlobalDiagnosticsMode = true; // See message history ``` +## Memory Reclamation Terms + +### Idle Eviction + +Background reclamation that resets empty per-message-type and per-context +slots after they have stayed empty for `IdleEvictionSeconds` worth of bus +activity ticks. Active registrations are never touched. Gated by +`EvictionEnabled` and `EvictionTickIntervalSeconds`. + +### Sweep + +A single pass of the reclamation algorithm. A sweep walks dirty-tracked slots +and pool entries, resets the eligible empty ones, and updates the live +occupancy counters. Triggered by emit-time sampling, the Unity PlayerLoop +hook, or an explicit `Trim` call. + +### Trim + +The public API (`IMessageBus.Trim` / `MessageHandler.TrimAll`) that runs a +sweep synchronously. With `force: true` it ignores the idle threshold and +drains shared pools to zero; with `force: false` it uses the same idle +threshold as scheduled sweeps. Gated by `EnableTrimApi`. + +### TrimResult + +The struct returned by `Trim`. Its fields (`TypeSlotsEvicted`, +`TargetSlotsEvicted`, `PooledCollectionsEvicted`, `LiveTypeSlotsRemaining`) +report how much state the sweep reclaimed and how much remains live. + +### Empty Slot + +A typed-handler, interceptor, or context slot whose registrations have all +been deregistered. Empty slots stay around until reclamation runs because a +freshly empty slot is often about to be reused on the next dispatch. + ## Attributes (Source Generation) ### [DxUntargetedMessage] diff --git a/docs/reference/quick-reference.md b/docs/reference/quick-reference.md index 0fcae256..75da18aa 100644 --- a/docs/reference/quick-reference.md +++ b/docs/reference/quick-reference.md @@ -146,12 +146,28 @@ void OnDisable() { token.Disable(); } - Emitting to a GameObject will not reach Component-targeted listeners (and vice-versa). Use the matching helper. - Shorthands exist for strings too; be explicit about using a GameObject vs Component with `EmitAt`/`EmitFrom`. +## Memory Reclamation + +| API | Purpose | +| ------------------------------- | --------------------------------------------------------------------------------- | +| `bus.Trim(bool force = false)` | Reclaim empty slots and pooled collections on a single bus; returns `TrimResult`. | +| `MessageHandler.TrimAll(force)` | Convenience wrapper that calls `Trim` on the global bus. | +| `bus.OccupiedTypeSlots` | Count of distinct per-message-type slots currently occupied on the bus. | +| `bus.OccupiedTargetSlots` | Count of distinct (type, target) context tuples currently occupied on the bus. | + +For tuning, scenario tables, and a leak-watching pattern see the +[Memory Reclamation guide](../guides/memory-reclamation.md). For the asset +parameters and defaults see the +[Runtime Settings reference](runtime-settings.md). + ## See also - [Emit Shorthands](../advanced/emit-shorthands.md) - [Advanced](../guides/advanced.md) - [Targeting & Context](../concepts/targeting-and-context.md) - [Interceptors & Ordering](../concepts/interceptors-and-ordering.md) +- [Memory Reclamation](../guides/memory-reclamation.md) +- [Runtime Settings](runtime-settings.md) ## Execution Order diff --git a/docs/reference/runtime-settings.md b/docs/reference/runtime-settings.md new file mode 100644 index 00000000..2be953e5 --- /dev/null +++ b/docs/reference/runtime-settings.md @@ -0,0 +1,236 @@ +# Runtime Settings + +[Back to Reference](reference.md) | [Memory Reclamation Guide](../guides/memory-reclamation.md) | [Runtime Configuration](../advanced/runtime-configuration.md) + +--- + +The `DxMessagingRuntimeSettings` ScriptableObject controls memory-reclamation +policy and pool sizing for DxMessaging. This page is the canonical reference +for the asset, its parameters, and the public APIs that consume them. + +For tuning guidance and scenario-driven recommendations, see the +[Memory Reclamation guide](../guides/memory-reclamation.md). + +--- + +## Overview + +`DxMessagingRuntimeSettings` is a ScriptableObject that ships with the package +and is loaded once per AppDomain during the first message-bus construction +via `Resources.Load("DxMessagingRuntimeSettings")`. +On a load miss the runtime hands out a defaulted in-memory instance so the +package always has a usable settings object. Field changes raise +`SettingsChanged`, which live buses subscribe to so they can re-apply caps +without recreation. The asset is hot-reloadable: edits saved to disk while +Play mode is running take effect on the next sweep boundary. + +In non-Unity builds (where `UNITY_2021_3_OR_NEWER` is not defined) the +provider returns `null` because `ScriptableObject` is unavailable. Callers +must tolerate a null result outside Unity. + +--- + +## Asset Location + +The asset must live under any `Resources/` folder so that `Resources.Load` +can find it at runtime. The recommended path is: + +```text +Assets/Resources/DxMessagingRuntimeSettings.asset +``` + +Two creation paths put the asset in place: + +- `Assets > Create > Wallstop Studios > DxMessaging > Runtime Settings`. The + asset's `[CreateAssetMenu]` entry; the asset is created in the currently + selected folder. Move it under a `Resources/` folder afterwards. +- `Assets > Create > Wallstop Studios > DxMessaging > Runtime Settings (in Resources)`. + The editor menu helper that creates + `Assets/Resources/DxMessagingRuntimeSettings.asset` directly, creating the + `Assets/Resources` folder if it does not already exist. + +The asset's `OnValidate` warns when an asset path lies outside a `Resources/` +folder because `Resources.Load` would not find it there. + +--- + +## Parameter Reference + +| Name | C# property | Type | Default | Min | Tooltip | Hot-reload | When to change | +| ------------------------------ | ----------------------------- | ----- | -------------------------------------------- | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Idle Eviction Seconds | `IdleEvictionSeconds` | float | 30.0 | 0 | Idle threshold in seconds. Empty per-message-type slots are evicted only after going at least this long without a register/deregister/dispatch touch. | Yes | Lower for leak diagnosis or aggressive reclaim; raise for high-throughput scenarios where slots empty briefly between bursts. | +| Buffer Max Distinct Entries | `BufferMaxDistinctEntries` | int | 512 | 0 | Soft cap on the number of distinct entries each shared collection pool will retain. Excess entries are evicted (LRU or LIFO depending on BufferUseLruEviction). | Yes | Lower on memory-constrained targets; raise when profiling shows pool churn from a small cap. | +| Buffer Use LRU Eviction | `BufferUseLruEviction` | bool | true | -- | When true, shared collection pools use LRU eviction; otherwise pools behave as a bounded LIFO stack. | Yes | Switch to LIFO when access patterns are short-lived bursts; keep LRU for steady-state reuse. | +| Enable Trim API | `EnableTrimApi` | bool | true | -- | When true, IMessageBus.Trim performs its work; when false it is a no-op returning default. Lets shipped titles disable on-demand reclamation. | Yes | Disable in shipped titles that do not call Trim and do not want third-party code to force a sweep. | +| Eviction Tick Interval Seconds | `EvictionTickIntervalSeconds` | float | 5.0 | 0 | Minimum interval in seconds between idle sweeps. Emit-time idle eviction samples the clock periodically instead of at the top of every Emit, and sweeps only when this much wall time has elapsed since the last sweep. | Yes | Raise to reduce sweep frequency on busy hot paths; lower for tighter reclaim cadence. | +| Eviction Enabled | `EvictionEnabled` | bool | true | -- | Master switch for idle-time eviction. When false neither inline emit-time sweeps nor PlayerLoop sweeps run; explicit Trim still works (gated by EnableTrimApi). | Yes | Disable when you only want explicit Trim to reclaim, or during editor safe-mode bring-up. | +| Message Buffer Size | `MessageBufferSize` | int | `IMessageBus.DefaultMessageBufferSize` (100) | 0 | Diagnostic message buffer size used when the bus is constructed. Mirrors IMessageBus.DefaultMessageBufferSize so the runtime asset can override the global default without touching code. | Yes | Raise for longer history when debugging; set to 0 to discard emission history and skip the ring buffer. | + +The `Min` column reflects the `[Min(...)]` attribute that the editor enforces +on numeric fields. Editor-time `OnValidate` clamps negative values back to +zero before raising `SettingsChanged`. + +--- + +## Public Constants + +`DxMessagingRuntimeSettings` exposes four `public const` fields so scripts can +reference the same defaults the asset ships with: + +| Constant | Type | Value | Purpose | +| ------------------------------------ | -------- | ------------------------------ | --------------------------------------------------------------------- | +| `ResourceName` | `string` | `"DxMessagingRuntimeSettings"` | Resource name (no extension) used by `Resources.Load`. | +| `DefaultBufferMaxDistinctEntries` | `int` | 512 | Default soft cap on per-pool retained entries. | +| `DefaultIdleEvictionSeconds` | `float` | 30 | Default idle threshold in seconds before an empty slot becomes stale. | +| `DefaultEvictionTickIntervalSeconds` | `float` | 5 | Default minimum interval between idle sweeps, in seconds. | + +Reference these constants from test fixtures and bootstrap code rather than +duplicating literal values. + +--- + +## Public Diagnostic API + +These APIs let runtime and test code inspect occupancy and request explicit +reclamation. All four are stable public surface. + +### `IMessageBus.OccupiedTypeSlots` + +```csharp +int OccupiedTypeSlots { get; } +``` + +Number of currently occupied per-message-type slots on this bus. Includes +scalar handler sinks, context handler sinks (one count per (type, dictionary), +not per (type, target)), interceptor type slots, and dirty-empty +typed-handler slots. Aggregated on read by walking the per-kind caches; the +cost is O(n) in the number of distinct message types known to the bus. +Snapshot at region boundaries rather than reading in a tight loop. + +### `IMessageBus.OccupiedTargetSlots` + +```csharp +int OccupiedTargetSlots { get; } +``` + +Number of currently occupied per-context target or source slots on this bus. +Counts (type, target) tuples: five distinct message types each with the +same target ID counts as 5, not 1. Same aggregation behavior as +`OccupiedTypeSlots`; the cost is O(n) in the number of distinct message +types known to the bus. + +### `IMessageBus.Trim` + +```csharp +TrimResult Trim(bool force = false); +``` + +Reclaim empty message slots and pooled collections owned by this bus. When +`force` is true, the call ignores idle-age thresholds and drains shared pools +to zero. When `force` is false, only slots past the configured idle threshold +are eligible. The call is a no-op returning `default(TrimResult)` when +`EnableTrimApi` is false. + +Non-Unity and headless hosts must call this periodically when they need +deterministic reclamation. The automatic PlayerLoop sweep hook is only +installed on Unity 2021.3 or newer player and editor hosts. + +### `MessageHandler.TrimAll` + +```csharp +public static IMessageBus.TrimResult TrimAll(bool force = false); +``` + +Convenience wrapper that calls `Trim` on the global message bus. Same `force` +semantics as `IMessageBus.Trim`. + +### `IMessageBus.TrimResult` + +```csharp +public readonly struct TrimResult : IEquatable +{ + public int TypeSlotsEvicted { get; } + public int TargetSlotsEvicted { get; } + public int PooledCollectionsEvicted { get; } + public int LiveTypeSlotsRemaining { get; } +} +``` + +| Field | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `TypeSlotsEvicted` | Number of typed-handler slots reset across all reclaimed message types. | +| `TargetSlotsEvicted` | Number of bus target or source context entries removed. | +| `PooledCollectionsEvicted` | Number of pooled collections dropped from shared pools (`DxPools` plus the bus-owned context-dictionary pool). | +| `LiveTypeSlotsRemaining` | Number of occupied type slots remaining after the trim; equal to a post-trim read of `OccupiedTypeSlots`. | + +The struct overrides `ToString()` and implements equality on all four fields. + +--- + +## Hot-Reload Semantics + +`DxMessagingRuntimeSettings` raises the static event +`DxMessagingRuntimeSettings.SettingsChanged` from its editor `OnValidate` and +from `DxMessagingRuntimeSettingsProvider.Override`. Subscribers should be +small and re-entrancy-safe; the event is invoked synchronously on the calling +thread. + +```csharp +public static event Action SettingsChanged; +``` + +Existing buses subscribe to the event and call `ApplyRuntimeSettings`, which +re-applies the eviction toggles, idle threshold, tick interval, pool caps, +retention mode, and message buffer size without recreating the bus. The +shared `DxPools` pools and the bus-owned context-dictionary pool reapply the +new caps on the next reclaim opportunity. Live registrations are not +disturbed. + +The `RuntimeInitializeOnLoadMethod(SubsystemRegistration)` hook clears +`SettingsChanged` subscribers when a new domain loads, preventing stale +subscriptions from previous Play mode sessions from firing. + +--- + +## Test Override + +`DxMessagingRuntimeSettingsProvider.Override(DxMessagingRuntimeSettings settings)` +pushes a test-supplied settings instance as the active `Current` value and +returns an `IDisposable`. Disposing the token restores the previous instance +(LIFO; if a deeper override was pushed on top, dispose is a no-op until the +deeper override is popped first). Both the push and the pop raise +`SettingsChanged` so subscribed buses re-apply caps in both directions. + +```csharp +using System; +using DxMessaging.Core.Configuration; +using UnityEngine; + +public static class TestSettingsExample +{ + public static void RunWithCustomSettings() + { + DxMessagingRuntimeSettings testSettings = + ScriptableObject.CreateInstance(); + // Test-only: production code should not call Override directly. + using (IDisposable token = + DxMessagingRuntimeSettingsProvider.Override(testSettings)) + { + // Bus reads testSettings until token is disposed. + } + } +} +``` + +The provider is intended for tests and bootstrap code that needs to inject a +specific configuration. Production code should rely on the loaded asset (or +the defaulted fallback) rather than calling `Override` directly. + +--- + +## See Also + +- [Memory Reclamation guide](../guides/memory-reclamation.md) +- [Runtime Configuration](../advanced/runtime-configuration.md) +- [Diagnostics](../guides/diagnostics.md) +- [Performance](../architecture/performance.md) diff --git a/docs/reference/runtime-settings.md.meta b/docs/reference/runtime-settings.md.meta new file mode 100644 index 00000000..18605b10 --- /dev/null +++ b/docs/reference/runtime-settings.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b5e4a0f3d9c8fa55b0e7f2d3c4f5e6a7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index f3ca1c2b..18ec8e13 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -48,6 +48,19 @@ Diagnostics overhead - Disable diagnostics in release builds (`IMessageBus.GlobalDiagnosticsMode = false`). +## Memory grows in long sessions + +- Read `bus.OccupiedTypeSlots` and `bus.OccupiedTargetSlots` (or the global + `MessageHandler.MessageBus.OccupiedTypeSlots` / `OccupiedTargetSlots`) at + region boundaries to see whether per-type or per-target slots are the + culprit. +- Call `MessageHandler.TrimAll(force: true)` (or `bus.Trim(force: true)`) at + scene unload or other natural transitions. Slots that survive a forced + trim correspond to active registrations. +- Tune the reclamation policy through `DxMessagingRuntimeSettings`. See the + [Memory Reclamation guide](../guides/memory-reclamation.md) for tuning + recommendations and a leak-watching pattern. + --- ## Related Documentation diff --git a/llms.txt b/llms.txt index 6679ceac..a434425b 100644 --- a/llms.txt +++ b/llms.txt @@ -41,12 +41,12 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re - Anyone can listen - Example: Game settings changed -2. **Targeted Messages** - Commands to specific entities +1. **Targeted Messages** - Commands to specific entities - Has a specific GameObject/Component target - Only target and its children listen - Example: Heal this specific character -3. **Broadcast Messages** - Observable facts from a source +1. **Broadcast Messages** - Observable facts from a source - Has a source GameObject/Component - Anyone can observe what happened - Example: This enemy took damage @@ -118,12 +118,14 @@ public class HealthDisplay : MessageAwareComponent ## Documentation Structure ### Getting Started + - [Overview](https://wallstop.github.io/DxMessaging/getting-started/overview/) - [Installation](https://wallstop.github.io/DxMessaging/getting-started/install/) - [Quick Start](https://wallstop.github.io/DxMessaging/getting-started/quick-start/) - [Visual Guide](https://wallstop.github.io/DxMessaging/getting-started/visual-guide/) ### Concepts + - [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles - [Message Types](https://wallstop.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast - [Listening Patterns](https://wallstop.github.io/DxMessaging/concepts/listening-patterns/) @@ -131,30 +133,37 @@ public class HealthDisplay : MessageAwareComponent - [Interceptors & Ordering](https://wallstop.github.io/DxMessaging/concepts/interceptors-and-ordering/) ### Guides + - [Patterns](https://wallstop.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns - [Unity Integration](https://wallstop.github.io/DxMessaging/guides/unity-integration/) - [Testing](https://wallstop.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems - [Diagnostics](https://wallstop.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging +- [Memory Reclamation](https://wallstop.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters - [Migration Guide](https://wallstop.github.io/DxMessaging/guides/migration-guide/) ### Architecture + - [Design & Architecture](https://wallstop.github.io/DxMessaging/architecture/design-and-architecture/) - [Performance](https://wallstop.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) - [Comparisons](https://wallstop.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses ### Advanced Topics + - [Emit Shorthands](https://wallstop.github.io/DxMessaging/advanced/emit-shorthands/) - [Message Bus Providers](https://wallstop.github.io/DxMessaging/advanced/message-bus-providers/) - [Registration Builders](https://wallstop.github.io/DxMessaging/advanced/registration-builders/) - [Runtime Configuration](https://wallstop.github.io/DxMessaging/advanced/runtime-configuration/) ### Integrations + - [Zenject](https://wallstop.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration - [VContainer](https://wallstop.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration - [Reflex](https://wallstop.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration ### Reference + - [Quick Reference](https://wallstop.github.io/DxMessaging/reference/quick-reference/) +- [Runtime Settings](https://wallstop.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API - [FAQ](https://wallstop.github.io/DxMessaging/reference/faq/) - [Glossary](https://wallstop.github.io/DxMessaging/reference/glossary/) - [Troubleshooting](https://wallstop.github.io/DxMessaging/reference/troubleshooting/) @@ -208,7 +217,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 155+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 157+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -221,18 +230,22 @@ This repository includes comprehensive AI agent guidance in the `.llm/` director ## Common Pitfalls & Solutions ### Memory Leaks + **Problem:** Forgot to unsubscribe from events **Solution:** Use `MessageAwareComponent` or `MessageHandler` for automatic lifecycle management ### Message Not Received + **Problem:** Handler registered after message was emitted **Solution:** Messages are synchronous; ensure registration happens during `Awake`/`OnEnable` ### Wrong Message Type + **Problem:** Used Broadcast when Targeted was needed **Solution:** See [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) for type selection guidance ### Performance Issues + **Problem:** Too many handlers or heavy interceptors **Solution:** Use priority ordering, profile with Inspector diagnostics @@ -249,7 +262,9 @@ See [Performance Documentation](https://wallstop.github.io/DxMessaging/architect ## Examples ### Mini Combat Sample + Demonstrates all three message types in a simple combat scenario: + - **Untargeted:** Game settings changes - **Targeted:** Heal specific character - **Broadcast:** Enemy takes damage @@ -257,7 +272,9 @@ Demonstrates all three message types in a simple combat scenario: **Location:** `Samples~/Mini Combat` ### DI Integration Sample + Shows integration with Zenject, VContainer, and Reflex: + - Scoped message buses - Container lifecycle integration - IMessageRegistrationBuilder usage @@ -265,7 +282,9 @@ Shows integration with Zenject, VContainer, and Reflex: **Location:** `Samples~/DI` ### Inspector Diagnostics Sample + Demonstrates debugging tools: + - Global observer pattern - Message flow visualization - Timestamp and payload inspection diff --git a/mkdocs.yml b/mkdocs.yml index 06fe8561..5d1706ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -177,6 +177,7 @@ nav: - Unity Integration: guides/unity-integration.md - Testing: guides/testing.md - Diagnostics: guides/diagnostics.md + - Memory Reclamation: guides/memory-reclamation.md - Inspector Overlay & Base-Call Warnings: guides/inspector-overlay.md - Advanced Topics: guides/advanced.md - Migration Guide: guides/migration-guide.md @@ -202,6 +203,7 @@ nav: - reference/reference.md - Quick Reference: reference/quick-reference.md - Helpers: reference/helpers.md + - Runtime Settings: reference/runtime-settings.md - FAQ: reference/faq.md - Glossary: reference/glossary.md - Troubleshooting: reference/troubleshooting.md diff --git a/package.json b/package.json index 64017a58..b9fb9191 100644 --- a/package.json +++ b/package.json @@ -73,15 +73,20 @@ "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", "update:llms-txt": "node scripts/update-llms-txt.js", "check:llms-txt": "node scripts/update-llms-txt.js --check", - "validate:llms-txt": "npm run test:llms-txt && npm run check:llms-txt", + "validate:all": "npm run validate:node-tooling && npm run validate:pre-commit-tooling && npm run validate:npm-meta && npm run validate:changelog:coverage && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy", "validate:changelog": "node scripts/validate-changelog.js", "validate:changelog:coverage": "node scripts/validate-changelog.js --check-coverage", - "validate:npm-meta": "node scripts/validate-npm-meta.js --check", - "validate:node-tooling": "node scripts/validate-node-tooling.js", "validate:hook-markdown": "pre-commit run --hook-stage pre-commit run-staged-md-pipeline --files README.md", + "validate:llms-txt": "npm run test:llms-txt && npm run check:llms-txt", + "validate:no-plan-vocabulary": "node scripts/validate-no-plan-vocabulary.js", + "validate:node-tooling": "node scripts/validate-node-tooling.js", + "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", + "validate:runtime-settings-docs": "node scripts/validate-runtime-settings-docs.js", + "validate:untracked-policy": "node scripts/validate-untracked-policy.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run validate:node-tooling && npm run validate:hook-markdown && npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && npm run validate:changelog:coverage && pre-commit run --hook-stage pre-push script-parser-tests --all-files" + "preflight:pre-commit": "npm run validate:node-tooling && npm run validate:hook-markdown && npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && npm run validate:changelog:coverage && pre-commit run --hook-stage pre-push script-parser-tests --all-files && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy", + "prepack": "node scripts/validate-npm-meta.js --check" }, "devDependencies": { "cspell": "9.3.0", diff --git a/scripts/__tests__/validate-no-plan-vocabulary.test.js b/scripts/__tests__/validate-no-plan-vocabulary.test.js new file mode 100644 index 00000000..b97ea137 --- /dev/null +++ b/scripts/__tests__/validate-no-plan-vocabulary.test.js @@ -0,0 +1,467 @@ +/** + * @fileoverview Tests for validate-no-plan-vocabulary.js + * + * Covers: + * - scanContent: clean fixture, filename ref, tier tag, plan-section heading, + * migration-guide-style "Phase 0" allowed, mermaid `T1` allowed, allowlist. + * - In-scope filtering: tests, scripts, etc. are excluded. + * - Real-tree integration: shipping content is currently clean. + * - Tier-tag false positives like "T22.5 degrees" do NOT match (m1). + * - Code-fenced violations are NOT flagged (m2). + * + * Note on self-reference: this test file references the forbidden patterns + * inside string literals (PLAN.md, T2.4, "## Phase P0"). The validator's + * ALLOWLIST excludes this file by exact path; the integration test then + * exercises that ALLOWLIST end-to-end. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + scanContent, + isInScope, + isAllowlisted, + filterInScopeFiles, + parseArgs, + reportResult, + run, + maskCodeFences, + PATTERNS, + ALLOWLIST, + compileGlob +} = require("../validate-no-plan-vocabulary.js"); + +describe("validate-no-plan-vocabulary", () => { + describe("PATTERNS", () => { + test("declares the three rule names exactly once each", () => { + const names = PATTERNS.map((pattern) => pattern.name); + expect(names.sort()).toEqual(["plan-filename", "plan-section-heading", "tier-tag"]); + }); + }); + + describe("compileGlob", () => { + test("matches a single-segment glob", () => { + const re = compileGlob("foo*"); + expect(re.test("foo")).toBe(true); + expect(re.test("foobar")).toBe(true); + expect(re.test("nested/foo")).toBe(false); + }); + + test("matches a recursive glob", () => { + const re = compileGlob("Runtime/**/*.cs"); + expect(re.test("Runtime/Core/Foo.cs")).toBe(true); + expect(re.test("Runtime/Foo.cs")).toBe(true); + expect(re.test("Other/Foo.cs")).toBe(false); + }); + }); + + describe("maskCodeFences (m2)", () => { + test("blanks lines inside triple-backtick fence", () => { + const input = [ + "before", + "```", + "[See PLAN.md] inside fence", + "```", + "after" + ].join("\n"); + const masked = maskCodeFences(input); + // The fenced lines must be empty strings; outer lines must remain. + expect(masked.split("\n")).toEqual(["before", "", "", "", "after"]); + }); + + test("blanks lines inside triple-tilde fence", () => { + const input = [ + "before", + "~~~", + "T2.4", + "~~~", + "after" + ].join("\n"); + const masked = maskCodeFences(input); + expect(masked.split("\n")).toEqual(["before", "", "", "", "after"]); + }); + + test("preserves line numbers (length stays equal in line count)", () => { + const input = "a\n```\nb\n```\nc"; + const masked = maskCodeFences(input); + expect(masked.split("\n").length).toBe(input.split("\n").length); + }); + }); + + describe("scanContent", () => { + test("clean content has no violations", () => { + const content = "User-facing prose with no planning references.\n"; + expect(scanContent("docs/example.md", content)).toEqual([]); + }); + + test("flags PLAN.md filename references", () => { + const content = "[See " + "PLAN" + ".md] for details.\n"; + const violations = scanContent("docs/example.md", content); + const filenameViolations = violations.filter((v) => v.pattern === "plan-filename"); + expect(filenameViolations).toHaveLength(1); + expect(filenameViolations[0].file).toBe("docs/example.md"); + expect(filenameViolations[0].line).toBe(1); + }); + + test("flags PERF-PLAN.md, OLD-PLAN.md, GH-PAGES-PLAN.md filenames", () => { + const content = [ + "Refer to " + "PERF-PLAN" + ".md.", + "Older plan in " + "OLD-PLAN" + ".md.", + "Pages plan: " + "GH-PAGES-PLAN" + ".md." + ].join("\n"); + const violations = scanContent("docs/example.md", content).filter( + (v) => v.pattern === "plan-filename" + ); + expect(violations).toHaveLength(3); + expect(violations.map((v) => v.line)).toEqual([1, 2, 3]); + }); + + test("flags tier-tag occurrences (T. and P.)", () => { + const content = "Captured " + "T2" + "." + "4 baseline; rolled " + "P3" + "." + "1 changes.\n"; + const violations = scanContent("docs/example.md", content).filter( + (v) => v.pattern === "tier-tag" + ); + expect(violations).toHaveLength(2); + expect(violations.map((v) => v.match).sort()).toEqual(["P3.1", "T2.4"]); + }); + + test("flags plan-section headings (Phase P, Tier T)", () => { + const content = ["# README", "", "## Phase " + "P0 - Setup", "", "Body."].join("\n"); + const violations = scanContent("README.md", content).filter( + (v) => v.pattern === "plan-section-heading" + ); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + }); + + test("does NOT flag migration-guide-style 'Phase 0/1/2/3' headings", () => { + const content = [ + "# Migration Guide", + "", + "## Phase 0: Install", + "", + "## Phase 1: Add to a New Feature", + "", + "## Phase 2: Migrate High-Pain Areas", + "", + "## Phase 3: Adopt for All New Code", + "" + ].join("\n"); + const violations = scanContent("docs/guides/migration-guide.md", content); + expect(violations).toEqual([]); + }); + + test("does NOT flag bare T1 / P0 (Mermaid IDs, test method names)", () => { + const content = [ + "graph TD", + " T1[Foo] --> T2[Bar]", + " P0 --> P1", + "Method " + "P0_Returns_Default is fine." + ].join("\n"); + const violations = scanContent("docs/architecture/diagram.md", content); + expect(violations).toEqual([]); + }); + + test("reports column information correctly", () => { + const content = " Tag: " + "T2" + "." + "4 baseline\n"; + const violations = scanContent("docs/example.md", content).filter( + (v) => v.pattern === "tier-tag" + ); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(1); + // " Tag: T" -> the T sits at column 8 (1-indexed). + expect(violations[0].column).toBe(8); + }); + + test("handles empty input gracefully", () => { + expect(scanContent("docs/empty.md", "")).toEqual([]); + expect(scanContent("docs/empty.md", null)).toEqual([]); + }); + + test("violations are sorted by (line, column)", () => { + const content = ["First " + "T2" + "." + "4 here.", "Second " + "P0" + "." + "1 here."].join("\n"); + const violations = scanContent("docs/example.md", content); + expect(violations.map((v) => v.line)).toEqual([1, 2]); + }); + + test("does NOT flag 3-or-more-digit forms after the tighten (m1)", () => { + // The original regex `\b[TP][0-9]+\.[0-9]+\b` matched arbitrarily long + // digit sequences. The new 1-2-digit cap keeps physical quantities and + // long version numbers out of scope when any side exceeds 2 digits. + // For example, the literal "T100.5" or "P22.500" no longer match. + const content = "Constants T100.5 and P22.500 are not tier tags.\n"; + const violations = scanContent("docs/example.md", content); + expect(violations).toEqual([]); + }); + + test("DOES flag 'T2.4' in planning-style prose (m1)", () => { + // Confirm the tighter regex still catches the planning-vocabulary form. + const content = "Roll into " + "T" + "2.4" + " next.\n"; + const violations = scanContent("docs/example.md", content); + const tierTags = violations.filter((v) => v.pattern === "tier-tag"); + expect(tierTags).toHaveLength(1); + expect(tierTags[0].match).toBe("T2.4"); + }); + + test("DOES flag 2-digit-per-side tier tags (e.g. T22.5)", () => { + // The 1-2-digit cap covers up to two digits per side. `T22.5` and + // `T2.45` are still tier-tag-shaped under this cap. + const content = "Captured " + "T" + "22.5 baseline.\n"; + const violations = scanContent("docs/example.md", content).filter( + (v) => v.pattern === "tier-tag" + ); + expect(violations).toHaveLength(1); + expect(violations[0].match).toBe("T22.5"); + }); + + test("does NOT flag a tier-tag-shaped match inside a fenced code block (m2)", () => { + const content = [ + "Avoid this in your prose:", + "", + "```", + "[See " + "PLAN" + ".md] is forbidden in shipping content", + "```", + "", + "End." + ].join("\n"); + const violations = scanContent("docs/example.md", content); + // The PLAN.md reference is inside the fenced block; it must NOT fire. + expect(violations).toEqual([]); + }); + + test("DOES flag the same pattern outside a fence (m2)", () => { + const content = "[See " + "PLAN" + ".md] outside fence.\n"; + const violations = scanContent("docs/example.md", content); + const filenames = violations.filter((v) => v.pattern === "plan-filename"); + expect(filenames).toHaveLength(1); + }); + + test("tilde fences also suppress violations (m2)", () => { + const content = [ + "~~~", + "## Phase " + "P0 - inside fence", + "~~~" + ].join("\n"); + const violations = scanContent("docs/example.md", content); + expect(violations).toEqual([]); + }); + + test("a fence with a language info-string still suppresses (m2)", () => { + const content = [ + "Inline:", + "```text", + "Refer to " + "PLAN" + ".md inside this code block", + "```" + ].join("\n"); + const violations = scanContent("docs/example.md", content); + expect(violations).toEqual([]); + }); + }); + + describe("isInScope", () => { + test("includes Runtime/Editor/SourceGenerators *.cs files", () => { + expect(isInScope("Runtime/Core/Foo.cs")).toBe(true); + expect(isInScope("Editor/CustomEditors/Foo.cs")).toBe(true); + expect(isInScope("SourceGenerators/Pkg/Foo.cs")).toBe(true); + }); + + test("includes Samples~ *.cs", () => { + expect(isInScope("Samples~/Mini Combat/Boot.cs")).toBe(true); + }); + + test("includes docs/**/*.md and known root markdown", () => { + expect(isInScope("docs/guides/migration-guide.md")).toBe(true); + expect(isInScope("README.md")).toBe(true); + expect(isInScope("CHANGELOG.md")).toBe(true); + expect(isInScope("CONTRIBUTING.md")).toBe(true); + expect(isInScope("Third Party Notices.md")).toBe(true); + expect(isInScope("llms.txt")).toBe(true); + }); + + test("excludes Tests trees", () => { + expect(isInScope("Tests/Runtime/Foo.cs")).toBe(false); + expect(isInScope("Runtime/Core/Tests/Foo.cs")).toBe(false); + expect(isInScope("SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/Foo.cs")).toBe(false); + }); + + test("excludes scripts/, .llm/, .github/ and other meta paths", () => { + expect(isInScope("scripts/validate-no-plan-vocabulary.js")).toBe(false); + expect(isInScope(".llm/skills/index.md")).toBe(false); + expect(isInScope(".github/workflows/build.yml")).toBe(false); + expect(isInScope("PLAN.md")).toBe(false); + expect(isInScope("PERF-PLAN.md")).toBe(false); + }); + + test("Windows-style paths are normalized before matching", () => { + const winPath = ["docs", "guides", "migration-guide.md"].join(path.sep); + expect(isInScope(winPath)).toBe(true); + }); + }); + + describe("isAllowlisted", () => { + test("includes the validator and its test", () => { + expect(isAllowlisted("scripts/validate-no-plan-vocabulary.js")).toBe(true); + expect(isAllowlisted("scripts/__tests__/validate-no-plan-vocabulary.test.js")).toBe(true); + }); + + test("does not include arbitrary other files", () => { + expect(isAllowlisted("docs/guides/migration-guide.md")).toBe(false); + }); + + test("ALLOWLIST contains exactly two entries (m14)", () => { + // Defensive: a future PR that grows the allowlist must intentionally + // edit this assertion AND justify why the new path needs to state the + // forbidden patterns. Keeping the size capped low is the point. If a + // legitimate third file needs the allowlist, bump this to 3 with a + // comment explaining the new entry. + expect(ALLOWLIST.size).toBe(2); + expect([...ALLOWLIST].sort()).toEqual([ + "scripts/__tests__/validate-no-plan-vocabulary.test.js", + "scripts/validate-no-plan-vocabulary.js" + ]); + }); + }); + + describe("filterInScopeFiles", () => { + test("keeps in-scope, drops out-of-scope and allowlisted", () => { + const result = filterInScopeFiles([ + "Runtime/Core/Foo.cs", + "Tests/Runtime/Foo.cs", + "scripts/validate-no-plan-vocabulary.js", + "docs/guides/migration-guide.md", + ".github/workflows/build.yml" + ]); + expect(result).toEqual(["Runtime/Core/Foo.cs", "docs/guides/migration-guide.md"]); + }); + }); + + describe("run with injected file system", () => { + test("clean fixture set yields zero violations", () => { + const fakeSpawn = () => ({ + status: 0, + stdout: ["docs/clean.md", "Runtime/Core/Foo.cs"].join("\n"), + stderr: "" + }); + const fakeRead = (file) => { + if (file === "docs/clean.md") return "User-facing prose.\n"; + if (file === "Runtime/Core/Foo.cs") return "namespace X { public class Y { } }\n"; + throw new Error(`Unexpected read: ${file}`); + }; + + const result = run({ cwd: process.cwd(), spawn: fakeSpawn, readFile: fakeRead }); + expect(result.valid).toBe(true); + expect(result.violations).toEqual([]); + expect(result.scannedFiles).toEqual(["docs/clean.md", "Runtime/Core/Foo.cs"]); + }); + + test("dirty fixture surfaces violations and exits non-zero via reportResult", () => { + const fakeSpawn = () => ({ + status: 0, + stdout: ["docs/dirty.md"].join("\n"), + stderr: "" + }); + const fakeRead = () => + "See " + "PLAN" + ".md and roll " + "T2" + "." + "4 next.\n"; + + const result = run({ cwd: process.cwd(), spawn: fakeSpawn, readFile: fakeRead }); + expect(result.valid).toBe(false); + const patterns = new Set(result.violations.map((v) => v.pattern)); + expect(patterns.has("plan-filename")).toBe(true); + expect(patterns.has("tier-tag")).toBe(true); + + const messages = []; + const exit = reportResult(result, { logger: { log: (msg) => messages.push(msg) } }); + expect(exit).toBe(1); + expect(messages.join("\n")).toContain("docs/dirty.md"); + }); + + test("git not installed surfaces as a hard error", () => { + const fakeSpawn = () => ({ + error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }) + }); + const result = run({ cwd: process.cwd(), spawn: fakeSpawn }); + expect(result.valid).toBe(false); + expect(result.errors[0].type).toBe("git-not-installed"); + }); + + test("allowlisted file is skipped even when its content has violations", () => { + // The validator file lists every forbidden pattern. An end-to-end run + // that includes its real content in the in-scope set should still pass + // because the ALLOWLIST excludes it. + const fakeSpawn = () => ({ + status: 0, + stdout: ["scripts/validate-no-plan-vocabulary.js"].join("\n"), + stderr: "" + }); + const fakeRead = () => { + throw new Error("readFile should NOT be called for allowlisted files"); + }; + const result = run({ cwd: process.cwd(), spawn: fakeSpawn, readFile: fakeRead }); + expect(result.valid).toBe(true); + expect(result.scannedFiles).toEqual([]); + }); + }); + + describe("parseArgs", () => { + test("recognizes --list-files", () => { + expect(parseArgs(["--list-files"]).listFiles).toBe(true); + }); + + test("flags unknown arguments", () => { + expect(parseArgs(["--bogus"]).errors).toEqual(["Unknown argument: --bogus"]); + }); + + test("supports --help", () => { + expect(parseArgs(["--help"]).help).toBe(true); + }); + }); + + describe("real repository state", () => { + test("real repo is clean", () => { + // Run the validator against the actual working tree. This proves the + // current shipping surface contains no plan vocabulary. + const result = run(); + if (!result.valid) { + const detail = result.errors + .map((error) => `[${error.type}] ${error.message}`) + .concat( + result.violations.map( + (v) => `${v.file}:${v.line}:${v.column}: ${v.pattern}: ${v.match}` + ) + ) + .join("\n"); + throw new Error(`validate-no-plan-vocabulary failed on the real repo:\n${detail}`); + } + expect(result.valid).toBe(true); + }); + + test("real repo has at least one in-scope file (sanity)", () => { + const result = run(); + // The exact count is not stable, but the count must be > 0 or the + // include-pattern logic regressed. + expect(result.scannedFiles.length).toBeGreaterThan(0); + // A few expected paths should be present. + expect(result.scannedFiles).toContain("README.md"); + }); + + test("validator script files exist on disk (allowlist sanity)", () => { + // Confirm the allowlist references real files. If a maintainer renames + // the validator without updating the allowlist this test catches it. + for (const allowed of ALLOWLIST) { + const fullPath = path.resolve(__dirname, "..", "..", allowed); + expect(fs.existsSync(fullPath)).toBe(true); + } + }); + }); +}); + +// Defensive: prevent accidental teardown leakage of the temp directory +// pattern other tests rely on. +afterAll(() => { + // Nothing to clean; this suite uses no temp dirs. + void os; +}); diff --git a/scripts/__tests__/validate-no-plan-vocabulary.test.js.meta b/scripts/__tests__/validate-no-plan-vocabulary.test.js.meta new file mode 100644 index 00000000..476f53ca --- /dev/null +++ b/scripts/__tests__/validate-no-plan-vocabulary.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b76 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-npm-meta.test.js b/scripts/__tests__/validate-npm-meta.test.js index a1d4777d..83eed995 100644 --- a/scripts/__tests__/validate-npm-meta.test.js +++ b/scripts/__tests__/validate-npm-meta.test.js @@ -20,6 +20,8 @@ const { validateDevelopmentFilesExcluded, validateMetaFilesHaveTargets, validateFilesHaveMetaFiles, + validateNoBuildArtifactsInTarball, + validatePublishedFilesArePairedWithMetas, validateNpmMeta } = require("../validate-npm-meta.js"); @@ -115,7 +117,7 @@ describe("validate-npm-meta", () => { expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); expect(spawnSyncSpy).toHaveBeenCalledWith( toShellCommand("npm"), - ["pack", "--json", "--dry-run"], + ["pack", "--json", "--dry-run", "--ignore-scripts"], expect.objectContaining({ cwd: expect.any(String), encoding: "utf8", @@ -587,6 +589,19 @@ describe("validate-npm-meta", () => { ); }); + test("validateNpmMeta should pass against the real npm pack --dry-run output on the current branch", () => { + // Integration check: shells out to the real npm pack flow via the script's own + // getPackageFiles() and asserts the current branch is clean. This is the live + // guardrail that issue #204 (https://github.com/wallstop/DxMessaging/issues/204) + // cannot regress without the test failing. + jest.spyOn(console, "log").mockImplementation(() => {}); + + const result = validateNpmMeta(); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + test("validateNpmMeta should fail when npm pack includes development-only files", () => { jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(childProcess, "spawnSync").mockReturnValue({ @@ -634,4 +649,334 @@ describe("validate-npm-meta", () => { ]); }); }); + + // ------------------------------------------------------------------------- + // Issue #204 regression coverage + // + // GitHub issue #204 (https://github.com/wallstop/DxMessaging/issues/204) + // reported `GuidDB::CreateMetaFileMappings` warnings on every Unity asset-database + // refresh after installing the npm package. Pre-2.1.8 tarballs shipped + // SourceGenerator `bin/Debug/netstandard2.0/...` build outputs and `obj/...` files + // whose paths had no `.meta` partner. 2.1.8 patched `.npmignore`. These tests are + // the permanent guardrail that #204 cannot regress -- both the bin/obj artifacts + // (validateNoBuildArtifactsInTarball) and the missing-meta-pairings symptom + // (validatePublishedFilesArePairedWithMetas) are caught. + // ------------------------------------------------------------------------- + describe("issue #204 regression coverage", () => { + describe("validateNoBuildArtifactsInTarball", () => { + test("rejects Runtime/bin/Foo.dll", () => { + const result = validateNoBuildArtifactsInTarball(["Runtime/bin/Foo.dll"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Runtime/bin/Foo.dll"); + expect(result.errors[0].message).toContain("Issue #204"); + }); + + test("rejects SourceGenerators obj/Debug build outputs", () => { + const offending = + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/obj/Debug/Foo.cs"; + const result = validateNoBuildArtifactsInTarball([offending]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe(offending); + expect(result.errors[0].message).toContain("Issue #204"); + }); + + test("rejects Editor/Foo.pdb", () => { + const result = validateNoBuildArtifactsInTarball(["Editor/Foo.pdb"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Editor/Foo.pdb"); + expect(result.errors[0].message).toContain("Issue #204"); + }); + + test("rejects Runtime/Foo.tmp", () => { + const result = validateNoBuildArtifactsInTarball(["Runtime/Foo.tmp"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Runtime/Foo.tmp"); + }); + + test("rejects Editor/Foo.csproj.user", () => { + const result = validateNoBuildArtifactsInTarball(["Editor/Foo.csproj.user"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Editor/Foo.csproj.user"); + }); + + test("rejects Samples~/.vs/foo.txt", () => { + const result = validateNoBuildArtifactsInTarball(["Samples~/.vs/foo.txt"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Samples~/.vs/foo.txt"); + }); + + test("rejects Editor/Foo.suo", () => { + const result = validateNoBuildArtifactsInTarball(["Editor/Foo.suo"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Editor/Foo.suo"); + }); + + test("rejects com.wallstop-studios.dxmessaging.sln.DotSettings.user", () => { + const result = validateNoBuildArtifactsInTarball([ + "com.wallstop-studios.dxmessaging.sln.DotSettings.user" + ]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("com.wallstop-studios.dxmessaging.sln.DotSettings.user"); + }); + + test("accepts a clean Runtime/Foo.cs tarball with no errors", () => { + const files = ["Runtime/Foo.cs", "Runtime/Foo.cs.meta", "Runtime.meta"]; + + const result = validateNoBuildArtifactsInTarball(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe("validatePublishedFilesArePairedWithMetas", () => { + test("reproduces the exact #204 leak: a SourceGenerator bin/Debug AssemblyInfo.cs without its .meta", () => { + const offending = + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/bin/Debug/netstandard2.0/AssemblyInfo.cs"; + const files = [ + "SourceGenerators.meta", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.meta", + offending + ]; + + const metaPairingResult = validatePublishedFilesArePairedWithMetas(files); + + // The validator considers SourceGenerators/.../*.cs Unity-relevant, so the missing + // .meta partner is reported. This is the exact symptom that issue #204 surfaced. + expect(metaPairingResult.valid).toBe(false); + const missingMetaErrors = metaPairingResult.errors.filter( + (e) => e.type === "missing-meta-in-tarball" + ); + expect(missingMetaErrors.length).toBeGreaterThan(0); + const errorForFile = missingMetaErrors.find((e) => e.file === offending + ".meta"); + expect(errorForFile).toBeDefined(); + expect(errorForFile.message).toContain(offending + ".meta"); + expect(errorForFile.message).toContain("issue #204"); + + // Defense in depth: the bin/ artifact validator must also fire on the same path. + const buildArtifactResult = validateNoBuildArtifactsInTarball(files); + expect(buildArtifactResult.valid).toBe(false); + expect(buildArtifactResult.errors[0].type).toBe("build-artifact-in-tarball"); + expect(buildArtifactResult.errors[0].file).toBe(offending); + }); + + test("rejects Runtime/Core/Foo.cs and its .meta when the directory meta Runtime/Core.meta is missing", () => { + const files = [ + "Runtime.meta", + "Runtime/Core/Foo.cs", + "Runtime/Core/Foo.cs.meta" + // Missing Runtime/Core.meta + ]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(false); + const missingDirectoryMeta = result.errors.find((e) => e.file === "Runtime/Core.meta"); + expect(missingDirectoryMeta).toBeDefined(); + expect(missingDirectoryMeta.type).toBe("missing-meta-in-tarball"); + expect(missingDirectoryMeta.message).toContain("Runtime/Core.meta"); + expect(missingDirectoryMeta.message).toContain("issue #204"); + }); + + test("accepts a canonical clean Runtime/Core/Foo.cs shape with all .meta partners", () => { + const files = [ + "Runtime.meta", + "Runtime/Core.meta", + "Runtime/Core/Foo.cs", + "Runtime/Core/Foo.cs.meta" + ]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("accepts a Samples~/Demo/Foo.cs shape with all .meta neighbours", () => { + // Samples~/ paths ARE Unity-relevant; the Samples~ root itself is hidden by UPM + // but its subdirectories still need .meta partners. + const files = ["Samples~/Demo.meta", "Samples~/Demo/Foo.cs", "Samples~/Demo/Foo.cs.meta"]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("rejects Runtime/Foo.cs without its Runtime/Foo.cs.meta partner", () => { + const files = [ + "Runtime.meta", + "Runtime/Foo.cs" + // Missing Runtime/Foo.cs.meta + ]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(false); + const missing = result.errors.find((e) => e.file === "Runtime/Foo.cs.meta"); + expect(missing).toBeDefined(); + expect(missing.type).toBe("missing-meta-in-tarball"); + expect(missing.message).toContain("Runtime/Foo.cs.meta"); + expect(missing.message).toContain("issue #204"); + }); + + test("rejects ./Runtime/Foo.cs (./-prefixed paths) so leading-dot prefixes do not mask leaks", () => { + // npm pack and tar listings occasionally emit `./Runtime/Foo.cs` style entries. + // Without normalization, the `startsWith("Runtime/")` check would silently skip + // these and miss the regression. + const files = ["./Runtime/Foo.cs", "./Runtime.meta"]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(false); + const missing = result.errors.find((e) => e.file === "Runtime/Foo.cs.meta"); + expect(missing).toBeDefined(); + expect(missing.type).toBe("missing-meta-in-tarball"); + }); + + test("flags SourceGenerators/Directory.Build.props missing its .meta partner", () => { + // package.json explicitly ships SourceGenerators/Directory.Build.props.meta as a + // Unity-tracked asset. The validator must therefore require the .props file's + // .meta partner; otherwise a future drop of the .meta line in package.json would + // sail past the validator unnoticed. + const files = ["SourceGenerators.meta", "SourceGenerators/Directory.Build.props"]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(false); + const missing = result.errors.find( + (e) => e.file === "SourceGenerators/Directory.Build.props.meta" + ); + expect(missing).toBeDefined(); + expect(missing.type).toBe("missing-meta-in-tarball"); + expect(missing.message).toContain("SourceGenerators/Directory.Build.props.meta"); + expect(missing.message).toContain("issue #204"); + }); + + test("emits exactly one missing-meta error when the directory walk catches the root meta", () => { + // Mi1 regression guard: the explicit rootShippedDirectoryMetas loop was removed + // because the per-directory walk already records every ancestor of every shipped + // file. Asserting exactly one error here proves there is no double-report. + const files = ["Runtime/Core/Foo.cs", "Runtime/Core/Foo.cs.meta"]; + + const result = validatePublishedFilesArePairedWithMetas(files); + + expect(result.valid).toBe(false); + const runtimeCoreMetaErrors = result.errors.filter((e) => e.file === "Runtime/Core.meta"); + const runtimeMetaErrors = result.errors.filter((e) => e.file === "Runtime.meta"); + expect(runtimeCoreMetaErrors).toHaveLength(1); + expect(runtimeMetaErrors).toHaveLength(1); + }); + }); + + describe("validateNoBuildArtifactsInTarball edge cases", () => { + test("rejects JetBrains .idea/ state nested under Runtime/", () => { + const result = validateNoBuildArtifactsInTarball(["Runtime/.idea/workspace.xml"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Runtime/.idea/workspace.xml"); + expect(result.errors[0].message).toContain("JetBrains IDE state"); + }); + + test("rejects a generic .user file via the bare \\.user$ pattern", () => { + // The bare `\.user$` pattern is the catch-all for IDE-flavoured per-user files + // that do not match a more specific pattern (.csproj.user / .DotSettings.user). + const result = validateNoBuildArtifactsInTarball(["Runtime/Foo.user"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Runtime/Foo.user"); + expect(result.errors[0].message).toContain("per-user IDE settings file"); + }); + + test("rejects a deeply nested bin/ path", () => { + const result = validateNoBuildArtifactsInTarball(["Editor/CodeGen/bin/cache.json"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("Editor/CodeGen/bin/cache.json"); + }); + + test("rejects a root-level bin/ path via the ^bin/ branch of the alternation", () => { + const result = validateNoBuildArtifactsInTarball(["bin/Foo.dll"]); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("build-artifact-in-tarball"); + expect(result.errors[0].file).toBe("bin/Foo.dll"); + }); + + test("does not produce false positives on names that merely contain the substrings", () => { + // The regexes are word/path-anchored on purpose. A filename containing `bin`, + // `obj`, or `user` as part of a longer identifier must NOT match. + const files = [ + "Runtime/AwesomeBin.cs", + "Runtime/objective-c-bridge.cs", + "Runtime/foo.user.cs" + ]; + + const result = validateNoBuildArtifactsInTarball(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("scenario D: bin/ artifact with its .meta still flags the build artifact but not the missing meta", () => { + // Defense-in-depth: even if some future package.json change accidentally ships + // a .meta partner alongside a bin/ artifact, validateNoBuildArtifactsInTarball + // must still reject the artifact while validatePublishedFilesArePairedWithMetas + // does not synthesize a meta-pairing complaint (because the meta is present). + const files = [ + "Runtime.meta", + "Runtime/bin.meta", + "Runtime/bin/Foo.dll", + "Runtime/bin/Foo.dll.meta" + ]; + + const buildArtifactResult = validateNoBuildArtifactsInTarball(files); + expect(buildArtifactResult.valid).toBe(false); + expect(buildArtifactResult.errors).toHaveLength(2); + expect(buildArtifactResult.errors.map((e) => e.file).sort()).toEqual([ + "Runtime/bin/Foo.dll", + "Runtime/bin/Foo.dll.meta" + ]); + + const metaPairingResult = validatePublishedFilesArePairedWithMetas(files); + // Runtime/bin/Foo.dll IS Unity-relevant (under Runtime/) and HAS its meta sibling, + // so the pairing validator stays silent on this scenario. + const missingForDll = metaPairingResult.errors.find( + (e) => e.file === "Runtime/bin/Foo.dll.meta" + ); + expect(missingForDll).toBeUndefined(); + }); + }); + }); }); diff --git a/scripts/__tests__/validate-runtime-settings-docs.test.js b/scripts/__tests__/validate-runtime-settings-docs.test.js new file mode 100644 index 00000000..db3c56f9 --- /dev/null +++ b/scripts/__tests__/validate-runtime-settings-docs.test.js @@ -0,0 +1,750 @@ +/** + * @fileoverview Tests for validate-runtime-settings-docs.js + * + * Covers: + * - Happy path: matching source + doc → exits 0. + * - Drift: missing doc row, extra doc row, renamed property. + * - BOM tolerance. + * - Parse-error cases for missing files. + * - Modifier coverage: static, virtual, override, new, readonly. + * - Auto-properties: { get; }, { get; private set; }, { get; init; }. + * - Qualified types (global::), nested generics, multi-line expression body. + * - String-literal brace pitfall (M5): `=> "}";` does not break brace counter. + * - Attribute between doc-comment and `public` (m5). + * - Extra-whitespace tolerance (m6). + * - --list-properties debug flag (m8). + * - Real-repo state: gated on the doc file existing so this test does not + * block while the docs agent's deliverable is in flight. + * - Source-order vs. doc-order parity (m11): source list must match the + * doc-table order so a future reorder is loud. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + DEFAULT_SOURCE_PATH, + DEFAULT_DOC_PATH, + stripBom, + normalizeLineEndings, + stripStringsAndComments, + consumeTypeToken, + extractPropertyNameFromCandidate, + extractPublicReadOnlyProperties, + extractDocPropertyNames, + parseMarkdownTableRow, + stripBackticks, + diffPropertySets, + validate, + reportResult, + parseArgs +} = require("../validate-runtime-settings-docs.js"); + +// Snapshot of the seven public properties that exist in +// DxMessagingRuntimeSettings.cs at the time these validators were authored. +// The validator extracts them dynamically; this snapshot exists only so a +// future drift in either direction is loud. +const KNOWN_PUBLIC_PROPERTIES = [ + "IdleEvictionSeconds", + "BufferMaxDistinctEntries", + "BufferUseLruEviction", + "EnableTrimApi", + "EvictionTickIntervalSeconds", + "EvictionEnabled", + "MessageBufferSize" +]; + +let tempRoot = null; + +function makeTempRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), "dx-runtime-settings-docs-")); +} + +function writeTempFile(name, contents) { + if (!tempRoot) { + tempRoot = makeTempRoot(); + } + const filePath = path.join(tempRoot, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, "utf8"); + return filePath; +} + +function buildSource(propertyNames) { + const propertyLines = propertyNames + .map( + (name) => + ` /// ${name} property.\n` + + ` public int ${name} => _${name.charAt(0).toLowerCase() + name.slice(1)};` + ) + .join("\n\n"); + + return [ + "namespace DxMessaging.Core.Configuration", + "{", + " public sealed class DxMessagingRuntimeSettings", + " {", + propertyLines, + " }", + "}", + "" + ].join("\n"); +} + +function buildDoc(propertyNames, { useBackticks = true } = {}) { + const headerLines = [ + "# Runtime Settings", + "", + "## Parameter Reference", + "", + "| Name | C# property | Type |", + "| --- | --- | --- |" + ]; + const dataLines = propertyNames.map((name) => { + const cell = useBackticks ? `\`${name}\`` : name; + return `| Friendly ${name} | ${cell} | int |`; + }); + return [...headerLines, ...dataLines, ""].join("\n"); +} + +afterEach(() => { + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + tempRoot = null; +}); + +describe("validate-runtime-settings-docs", () => { + describe("stripBom", () => { + test("removes a leading UTF-8 BOM", () => { + const bom = ""; + expect(stripBom(`${bom}hello`)).toBe("hello"); + }); + + test("returns content unchanged when no BOM is present", () => { + expect(stripBom("hello")).toBe("hello"); + }); + + test("returns empty string for non-string input", () => { + expect(stripBom(undefined)).toBe(""); + expect(stripBom(null)).toBe(""); + }); + }); + + describe("normalizeLineEndings", () => { + test("converts CRLF to LF and strips BOM", () => { + const bom = ""; + expect(normalizeLineEndings(`${bom}a\r\nb\r\nc`)).toBe("a\nb\nc"); + }); + + test("converts lone CR to LF", () => { + expect(normalizeLineEndings("a\rb\rc")).toBe("a\nb\nc"); + }); + }); + + describe("parseMarkdownTableRow", () => { + test("splits a simple row and trims cells", () => { + expect(parseMarkdownTableRow("| a | b | c |")).toEqual(["a", "b", "c"]); + }); + + test("handles missing trailing pipe", () => { + expect(parseMarkdownTableRow("| a | b | c")).toEqual(["a", "b", "c"]); + }); + }); + + describe("stripBackticks", () => { + test("strips a single matching pair", () => { + expect(stripBackticks("`Foo`")).toBe("Foo"); + }); + + test("returns input unchanged when no backticks", () => { + expect(stripBackticks("Foo")).toBe("Foo"); + }); + }); + + describe("stripStringsAndComments", () => { + test("blanks regular string body but preserves length", () => { + const input = 'public int X => "}".Length;'; + const output = stripStringsAndComments(input); + expect(output.length).toBe(input.length); + // The closing brace inside the string must be replaced with a space. + expect(output).not.toContain("}"); + }); + + test("blanks verbatim string body including doubled quotes", () => { + const input = 'public string X => @"foo""}bar";'; + const output = stripStringsAndComments(input); + expect(output.length).toBe(input.length); + expect(output).not.toContain("}"); + }); + + test("blanks interpolated string but preserves outer braces", () => { + const input = 'var s = $"{value}"; { /* opens block */ }'; + const output = stripStringsAndComments(input); + // The braces inside the interpolation are blanked; the standalone + // braces outside remain in place. + const openCount = (output.match(/\{/g) || []).length; + const closeCount = (output.match(/\}/g) || []).length; + expect(openCount).toBe(1); + expect(closeCount).toBe(1); + }); + + test("strips line comments", () => { + const output = stripStringsAndComments("public int X => 1; // public int Y => }"); + expect(output).not.toContain("Y"); + expect(output).not.toContain("}"); + }); + + test("strips block comments while preserving newlines", () => { + const input = "public int X => 1; /* multi\nline\n} */ public int Y => 2;"; + const output = stripStringsAndComments(input); + const newlineCount = (output.match(/\n/g) || []).length; + expect(newlineCount).toBe(2); + // Y must still appear because it follows the block comment. + expect(output).toContain("Y"); + }); + }); + + describe("consumeTypeToken", () => { + test("consumes a simple type", () => { + const text = "int Foo"; + expect(consumeTypeToken(text)).toBe("int".length); + }); + + test("consumes a generic type", () => { + const text = "List Foo"; + expect(consumeTypeToken(text)).toBe("List".length); + }); + + test("consumes a nested-generic type", () => { + const text = "List> Foo"; + expect(consumeTypeToken(text)).toBe("List>".length); + }); + + test("consumes a qualified type with global::", () => { + const text = "global::System.Int32 Foo"; + expect(consumeTypeToken(text)).toBe("global::System.Int32".length); + }); + + test("consumes a nullable type", () => { + const text = "int? Foo"; + expect(consumeTypeToken(text)).toBe("int?".length); + }); + + test("consumes an array type", () => { + const text = "int[] Foo"; + expect(consumeTypeToken(text)).toBe("int[]".length); + }); + + test("returns 0 for non-type input", () => { + expect(consumeTypeToken("=> 1;")).toBe(0); + }); + }); + + describe("extractPropertyNameFromCandidate", () => { + test("extracts the simple form", () => { + expect(extractPropertyNameFromCandidate("public int Foo => _x;")).toBe("Foo"); + }); + + test("rejects methods", () => { + expect(extractPropertyNameFromCandidate("public int Foo() => 1;")).toBeNull(); + }); + + test("rejects non-public lines", () => { + expect(extractPropertyNameFromCandidate("internal int Foo => _x;")).toBeNull(); + }); + + test("rejects type declarations", () => { + expect(extractPropertyNameFromCandidate("public class Foo { }")).toBeNull(); + expect(extractPropertyNameFromCandidate("public sealed class Foo { }")).toBeNull(); + }); + }); + + describe("extractPublicReadOnlyProperties", () => { + test("extracts public expression-bodied properties only", () => { + const source = [ + "namespace X {", + " public sealed class DxMessagingRuntimeSettings {", + " internal int _idle = 0;", + " public int IdleEvictionSeconds => _idle;", + " public bool EnableTrimApi => _enable;", + " internal bool IsFallbackInstance => _isFallback;", + " private int Internal => _internal;", + " }", + "}", + "" + ].join("\n"); + + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["IdleEvictionSeconds", "EnableTrimApi"]); + }); + + test("ignores nested-type properties", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Outer => _outer;", + " public class Nested {", + " public int Inner => _inner;", + " }", + "}" + ].join("\n"); + + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Outer"]); + }); + + test("returns empty when class is missing", () => { + const { names } = extractPublicReadOnlyProperties("public class Other { public int X => 1; }"); + expect(names).toEqual([]); + }); + + test("tolerates UTF-8 BOM and CRLF", () => { + const source = + "namespace X {\r\n" + + " public class DxMessagingRuntimeSettings {\r\n" + + " public int IdleEvictionSeconds => _x;\r\n" + + " }\r\n" + + "}\r\n"; + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["IdleEvictionSeconds"]); + }); + + test("extracts public static properties (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public static int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts public virtual properties (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public virtual int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts public override properties (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public override int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts public new properties (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public new int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts public readonly properties (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public readonly int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts auto-properties: { get; } (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Foo { get; }", + " public int Bar { get; private set; }", + " public int Baz { get; init; }", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo", "Bar", "Baz"]); + }); + + test("extracts qualified type names (global::) (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public global::System.Int32 Foo => _x;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts nested-generic types (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public List> Foo => _x;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extracts multi-line expression-bodied property (M2)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Foo", + " => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("string-literal closing brace does not fool brace counter (M5)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + ' public string Stringy => "}";', + " public int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + // Both Stringy and Foo must be extracted; the brace inside the string + // literal must not close the class body early. + expect(names).toEqual(["Stringy", "Foo"]); + }); + + test("interpolated string brace does not fool brace counter (M5)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + ' public string Stringy => $"value: {{0}}";', + " public int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Stringy", "Foo"]); + }); + + test("attribute between doc-comment and public is tolerated (m5)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " /// Old.", + " [Obsolete]", + " public int Foo => _foo;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("extra-whitespace tolerance (m6)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Foo => _x ;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + + test("ignores methods that look like properties", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Foo => _foo;", + " public int GetFoo() => _foo;", + " public int Compute(int x) => x + 1;", + "}" + ].join("\n"); + const { names } = extractPublicReadOnlyProperties(source); + expect(names).toEqual(["Foo"]); + }); + }); + + describe("extractDocPropertyNames", () => { + test("extracts property names from the C# property column with backticks", () => { + const doc = buildDoc(["IdleEvictionSeconds", "EnableTrimApi"]); + const { names } = extractDocPropertyNames(doc); + expect(names).toEqual(["IdleEvictionSeconds", "EnableTrimApi"]); + }); + + test("extracts property names without backticks", () => { + const doc = buildDoc(["IdleEvictionSeconds"], { useBackticks: false }); + const { names } = extractDocPropertyNames(doc); + expect(names).toEqual(["IdleEvictionSeconds"]); + }); + + test("ignores section headings that look like property names", () => { + const doc = [ + "# Runtime Settings", + "", + "### `IdleEvictionSeconds`", + "", + "Some prose.", + "", + "## Parameter Reference", + "", + "| Name | C# property | Type |", + "| --- | --- | --- |", + "| Foo | `EnableTrimApi` | bool |", + "" + ].join("\n"); + + const { names } = extractDocPropertyNames(doc); + expect(names).toEqual(["EnableTrimApi"]); + }); + + test("returns empty when no parameter table is present", () => { + const { names } = extractDocPropertyNames("# Runtime Settings\n\nNo table.\n"); + expect(names).toEqual([]); + }); + }); + + describe("diffPropertySets", () => { + test("reports missing and extra independently", () => { + const result = diffPropertySets(["A", "B", "C"], ["B", "C", "D"]); + expect(result.missingInDoc).toEqual(["A"]); + expect(result.extraInDoc).toEqual(["D"]); + }); + }); + + describe("validate", () => { + test("happy path: source and doc agree", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + buildSource(["A", "B", "C"]) + ); + const docPath = writeTempFile( + "docs/reference/runtime-settings.md", + buildDoc(["A", "B", "C"]) + ); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.sourceNames).toEqual(["A", "B", "C"]); + expect(result.docNames).toEqual(["A", "B", "C"]); + }); + + test("missing doc row fires when source has more properties", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + buildSource(["A", "B", "C"]) + ); + const docPath = writeTempFile("docs/reference/runtime-settings.md", buildDoc(["A", "B"])); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(false); + const missing = result.errors.filter((error) => error.type === "missing-doc-row"); + expect(missing).toHaveLength(1); + expect(missing[0].name).toBe("C"); + expect(missing[0].message).toContain("'C'"); + }); + + test("extra doc row fires when doc references unknown property", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + buildSource(["A", "B"]) + ); + const docPath = writeTempFile( + "docs/reference/runtime-settings.md", + buildDoc(["A", "B", "C"]) + ); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(false); + const extras = result.errors.filter((error) => error.type === "extra-doc-row"); + expect(extras).toHaveLength(1); + expect(extras[0].name).toBe("C"); + }); + + test("renamed property: missing-doc-row + extra-doc-row both fire", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + buildSource(["A", "B", "RenamedNew"]) + ); + const docPath = writeTempFile( + "docs/reference/runtime-settings.md", + buildDoc(["A", "B", "RenamedOld"]) + ); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(false); + const types = result.errors.map((error) => error.type); + expect(types).toContain("missing-doc-row"); + expect(types).toContain("extra-doc-row"); + }); + + test("internal property is treated as removed (extra-doc-row fires)", () => { + const source = [ + "public class DxMessagingRuntimeSettings {", + " public int Kept => _kept;", + " internal int Hidden => _hidden;", + "}", + "" + ].join("\n"); + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + source + ); + const docPath = writeTempFile( + "docs/reference/runtime-settings.md", + buildDoc(["Kept", "Hidden"]) + ); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.type === "extra-doc-row" && error.name === "Hidden")).toBe(true); + expect(result.errors.some((error) => error.type === "missing-doc-row")).toBe(false); + }); + + test("BOM tolerance: source and doc with BOM still parse cleanly", () => { + const bom = ""; + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + bom + buildSource(["A"]) + ); + const docPath = writeTempFile( + "docs/reference/runtime-settings.md", + bom + buildDoc(["A"]) + ); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(true); + }); + + test("parse-error: source file missing", () => { + const docPath = writeTempFile("docs/reference/runtime-settings.md", buildDoc(["A"])); + const result = validate({ + sourcePath: path.join(os.tmpdir(), "definitely-does-not-exist-12345.cs"), + docPath + }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("parse-error"); + }); + + test("parse-error: doc file missing", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + buildSource(["A"]) + ); + const result = validate({ + sourcePath, + docPath: path.join(os.tmpdir(), "definitely-does-not-exist-67890.md") + }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("parse-error"); + }); + + test("parse-error: source file present but no public properties found", () => { + const sourcePath = writeTempFile( + "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs", + "public class DxMessagingRuntimeSettings { /* empty */ }\n" + ); + const docPath = writeTempFile("docs/reference/runtime-settings.md", buildDoc(["A"])); + + const result = validate({ sourcePath, docPath }); + expect(result.valid).toBe(false); + expect(result.errors[0].type).toBe("parse-error"); + }); + }); + + describe("reportResult", () => { + test("returns 0 and logs OK on success", () => { + const messages = []; + const fakeLogger = { log: (message) => messages.push(message) }; + const exitCode = reportResult( + { valid: true, errors: [], sourceNames: ["A"], docNames: ["A"] }, + { logger: fakeLogger } + ); + expect(exitCode).toBe(0); + expect(messages.some((message) => message.includes("OK"))).toBe(true); + }); + + test("returns 1 and lists errors on failure", () => { + const messages = []; + const fakeLogger = { log: (message) => messages.push(message) }; + const exitCode = reportResult( + { + valid: false, + errors: [{ type: "missing-doc-row", name: "X", message: "X is missing" }], + sourceNames: [], + docNames: [] + }, + { logger: fakeLogger } + ); + expect(exitCode).toBe(1); + expect(messages.join("\n")).toContain("missing-doc-row"); + }); + }); + + describe("parseArgs", () => { + test("parses --check flag", () => { + expect(parseArgs(["--check"]).check).toBe(true); + }); + + test("parses --list-properties flag (m8)", () => { + expect(parseArgs(["--list-properties"]).listProperties).toBe(true); + }); + + test("collects unknown flags as errors", () => { + const result = parseArgs(["--bogus"]); + expect(result.errors).toEqual(["Unknown argument: --bogus"]); + }); + + test("parses --help flag", () => { + const result = parseArgs(["--help"]); + expect(result.help).toBe(true); + }); + }); + + describe("real repository state", () => { + const sourceExists = fs.existsSync(DEFAULT_SOURCE_PATH); + const docExists = fs.existsSync(DEFAULT_DOC_PATH); + + if (!sourceExists) { + // The C# file is a fixture in this repo; if it disappears that itself + // is a problem worth surfacing. + test.skip("source file exists at the expected path (skipped: source file missing)", () => {}); + } else { + test("source file exposes the snapshot of seven public properties", () => { + const content = fs.readFileSync(DEFAULT_SOURCE_PATH, "utf8"); + const { names } = extractPublicReadOnlyProperties(content); + // Order-insensitive comparison so a future reorder does not flake. + expect(names.slice().sort()).toEqual(KNOWN_PUBLIC_PROPERTIES.slice().sort()); + }); + } + + if (!docExists) { + // Skip until the docs agent's deliverable lands. Mark the skip clearly + // so a CI run reports the gating reason. + test.skip("real repo: source and doc agree (skipped: docs/reference/runtime-settings.md not present yet)", () => {}); + test.skip("real repo: source-order matches doc-table order (skipped: doc not present yet) (m11)", () => {}); + } else { + test("real repo: source and doc agree", () => { + const result = validate(); + if (!result.valid) { + // Surface the names so a maintainer reading the test failure does + // not have to re-run the validator manually to see the drift. + const messages = result.errors.map((error) => `[${error.type}] ${error.message}`).join("\n"); + throw new Error(`validate-runtime-settings-docs failed:\n${messages}`); + } + expect(result.valid).toBe(true); + }); + + test("real repo: source-order matches doc-table order (m11)", () => { + // A stricter parity check than the order-insensitive snapshot above. + // Source order and doc-table order should match so a future reorder + // is loud. If the two orderings ever diverge intentionally, this + // test can be relaxed to the order-insensitive form. + const result = validate(); + expect(result.sourceNames).toEqual(result.docNames); + }); + } + }); +}); diff --git a/scripts/__tests__/validate-runtime-settings-docs.test.js.meta b/scripts/__tests__/validate-runtime-settings-docs.test.js.meta new file mode 100644 index 00000000..9c72465c --- /dev/null +++ b/scripts/__tests__/validate-runtime-settings-docs.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b74 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-untracked-policy.test.js b/scripts/__tests__/validate-untracked-policy.test.js new file mode 100644 index 00000000..565c852e --- /dev/null +++ b/scripts/__tests__/validate-untracked-policy.test.js @@ -0,0 +1,502 @@ +/** + * @fileoverview Tests for validate-untracked-policy.js + * + * Covers: + * - parseArgs: --allow flags, env var, unknown flags. + * - listUntrackedFiles: clean, untracked, gitignored, allowlisted, not-a-git-repo. + * - Non-ASCII path handling via `git ls-files -z` (M1). + * - Per-directory rollup when more than three files share a prefix (M4). + * - Remediation message mentions BOTH .gitignore AND .npmignore (m4). + * - compileGlob: simple `*`, recursive `**`, and dotted-allowlist patterns. + * - Real-repo integration: the project's working tree should be clean. + * + * Temp-dir tests use `child_process.spawnSync('git', ...)` directly to set up + * fixture repositories; the validator itself uses the project's + * `spawnPlatformCommandSync` helper internally. + */ + +"use strict"; + +const childProcess = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + ROLLUP_THRESHOLD, + compileGlob, + buildRemediationMessage, + groupByFirstSegment, + parseArgs, + parseEnvAllowList, + parseUntrackedOutput, + isAllowed, + listUntrackedFiles, + validate, + reportResult +} = require("../validate-untracked-policy.js"); + +let tempRepos = []; + +function makeTempRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dx-untracked-")); + tempRepos.push(repo); + + const initResult = childProcess.spawnSync("git", ["init", "-q"], { + cwd: repo, + encoding: "utf8" + }); + if (initResult.status !== 0) { + throw new Error( + `git init failed: status=${initResult.status} stderr=${initResult.stderr || ""}` + ); + } + // Configure a local identity so any future commit calls inside tests do + // not fail on user.email/user.name lookups in CI environments. + childProcess.spawnSync("git", ["config", "user.email", "test@example.com"], { cwd: repo }); + childProcess.spawnSync("git", ["config", "user.name", "Test User"], { cwd: repo }); + return repo; +} + +function writeFile(repo, relativePath, contents) { + const fullPath = path.join(repo, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, contents, "utf8"); + return fullPath; +} + +afterEach(() => { + for (const repo of tempRepos) { + if (fs.existsSync(repo)) { + fs.rmSync(repo, { recursive: true, force: true }); + } + } + tempRepos = []; +}); + +describe("validate-untracked-policy", () => { + describe("ROLLUP_THRESHOLD", () => { + test("is set to 3 (groups of >3 roll up)", () => { + // Locked by test so a future tweak is intentional. + expect(ROLLUP_THRESHOLD).toBe(3); + }); + }); + + describe("compileGlob", () => { + test("matches a single-segment glob with `*`", () => { + const re = compileGlob("foo*"); + expect(re.test("foo")).toBe(true); + expect(re.test("foo-tmp.txt")).toBe(true); + expect(re.test("nested/foo.txt")).toBe(false); + }); + + test("supports a recursive glob", () => { + const re = compileGlob("a/**"); + expect(re.test("a/b/c.txt")).toBe(true); + expect(re.test("a/b")).toBe(true); + }); + + test("escapes regex metacharacters", () => { + const re = compileGlob("foo+bar.txt"); + expect(re.test("foo+bar.txt")).toBe(true); + // The literal `+` in the pattern is not a wildcard; replacing it with + // an arbitrary character must NOT match. + expect(re.test("foo-bar.txt")).toBe(false); + }); + }); + + describe("parseArgs", () => { + test("collects --allow= values", () => { + const result = parseArgs(["--allow=foo*", "--allow=bar/**"]); + expect(result.allow).toEqual(["foo*", "bar/**"]); + expect(result.errors).toEqual([]); + }); + + test("supports --allow form", () => { + const result = parseArgs(["--allow", "baz/*"]); + expect(result.allow).toEqual(["baz/*"]); + }); + + test("flags an unknown argument as an error", () => { + const result = parseArgs(["--no-such-flag"]); + expect(result.errors).toEqual(["Unknown argument: --no-such-flag"]); + }); + + test("supports --help", () => { + expect(parseArgs(["--help"]).help).toBe(true); + expect(parseArgs(["-h"]).help).toBe(true); + }); + }); + + describe("parseEnvAllowList", () => { + test("splits on colon", () => { + expect(parseEnvAllowList("a:b:c")).toEqual(["a", "b", "c"]); + }); + + test("splits on semicolon for Windows", () => { + expect(parseEnvAllowList("a;b;c")).toEqual(["a", "b", "c"]); + }); + + test("returns [] for empty/missing input", () => { + expect(parseEnvAllowList(undefined)).toEqual([]); + expect(parseEnvAllowList("")).toEqual([]); + }); + + test("trims whitespace and drops empties", () => { + expect(parseEnvAllowList("a: : b ::")).toEqual(["a", "b"]); + }); + }); + + describe("parseUntrackedOutput", () => { + test("splits on NUL terminator (M1: -z output)", () => { + expect(parseUntrackedOutput("foo\0bar\0baz\0")).toEqual(["foo", "bar", "baz"]); + }); + + test("preserves whitespace inside paths", () => { + // `-z` makes spaces inside path names just bytes; the parser must keep + // them. + expect(parseUntrackedOutput("with space.txt\0other.txt\0")).toEqual([ + "with space.txt", + "other.txt" + ]); + }); + + test("accepts a Buffer with NUL terminators", () => { + const buf = Buffer.from("alpha\0beta\0", "utf8"); + expect(parseUntrackedOutput(buf)).toEqual(["alpha", "beta"]); + }); + + test("returns [] for empty input", () => { + expect(parseUntrackedOutput("")).toEqual([]); + expect(parseUntrackedOutput(null)).toEqual([]); + }); + }); + + describe("isAllowed", () => { + test("matches a single-segment glob", () => { + expect(isAllowed("foo.txt", ["foo*"])).toBe(true); + expect(isAllowed("nested/foo.txt", ["foo*"])).toBe(false); + }); + + test("matches a recursive glob", () => { + expect(isAllowed("a/b/c.txt", ["a/**"])).toBe(true); + }); + + test("returns false when no allowlist", () => { + expect(isAllowed("foo", [])).toBe(false); + }); + + test("respects dotfile-prefix matching", () => { + expect(isAllowed(".artifacts/log.txt", [".artifacts/**"])).toBe(true); + }); + }); + + describe("groupByFirstSegment (M4)", () => { + test("rolls up groups larger than ROLLUP_THRESHOLD", () => { + const result = groupByFirstSegment([ + "build/a", + "build/b", + "build/c", + "build/d", + "scratch.txt" + ]); + expect(result.groups).toHaveLength(1); + expect(result.groups[0].prefix).toBe("build"); + expect(result.groups[0].count || result.groups[0].files.length).toBe(4); + expect(result.singletons).toEqual(["scratch.txt"]); + }); + + test("keeps groups with three or fewer items as singletons", () => { + const result = groupByFirstSegment([ + "build/a", + "build/b", + "build/c", + "scratch.txt" + ]); + expect(result.groups).toEqual([]); + expect(result.singletons.sort()).toEqual([ + "build/a", + "build/b", + "build/c", + "scratch.txt" + ]); + }); + + test("root-level files are always singletons", () => { + const result = groupByFirstSegment(["a", "b", "c", "d", "e"]); + expect(result.groups).toEqual([]); + expect(result.singletons.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); + }); + + describe("buildRemediationMessage (m4)", () => { + test("file-form mentions BOTH .gitignore AND .npmignore", () => { + const message = buildRemediationMessage("scratch.txt", false); + expect(message).toContain(".gitignore"); + expect(message).toContain(".npmignore"); + expect(message).toContain("scratch.txt"); + }); + + test("directory-form mentions BOTH .gitignore AND .npmignore", () => { + const message = buildRemediationMessage("build-output", true, 47); + expect(message).toContain(".gitignore"); + expect(message).toContain(".npmignore"); + expect(message).toContain("build-output/"); + expect(message).toContain("47 files"); + }); + }); + + describe("listUntrackedFiles (with fixture repo)", () => { + test("returns empty for a clean repo", () => { + const repo = makeTempRepo(); + const result = listUntrackedFiles({ cwd: repo }); + expect(result.ok).toBe(true); + expect(result.files).toEqual([]); + }); + + test("returns the untracked path when one exists", () => { + const repo = makeTempRepo(); + writeFile(repo, "scratch.txt", "hello\n"); + const result = listUntrackedFiles({ cwd: repo }); + expect(result.ok).toBe(true); + expect(result.files).toEqual(["scratch.txt"]); + }); + + test("ignores paths covered by .gitignore", () => { + const repo = makeTempRepo(); + writeFile(repo, ".gitignore", "ignored.txt\n"); + writeFile(repo, "ignored.txt", "ignored\n"); + const result = listUntrackedFiles({ cwd: repo }); + expect(result.ok).toBe(true); + // The .gitignore is itself untracked at this point; it must surface + // because nothing else ignores it. ignored.txt must NOT surface. + expect(result.files).toContain(".gitignore"); + expect(result.files).not.toContain("ignored.txt"); + }); + + test("non-ASCII filenames are reported in their unescaped form (M1)", () => { + const repo = makeTempRepo(); + // Build the name from an explicit Unicode escape so this source file + // stays ASCII-only per the project policy. e-acute is the small + // letter e with acute. The resulting filename is "fil" + e-acute + + // ".txt". + const nonAsciiName = "fil\u00e9.txt"; + writeFile(repo, nonAsciiName, "accented filename\n"); + const result = listUntrackedFiles({ cwd: repo }); + expect(result.ok).toBe(true); + // Without `-z` and `core.quotepath=false`, git would emit + // `"fil\303\251.txt"` (escaped). The validator MUST report the real + // path so error messages and `--allow` matching make sense. + expect(result.files).toEqual([nonAsciiName]); + }); + + test("filenames with spaces survive parsing (M1)", () => { + const repo = makeTempRepo(); + writeFile(repo, "with space.txt", "spaced\n"); + const result = listUntrackedFiles({ cwd: repo }); + expect(result.ok).toBe(true); + expect(result.files).toContain("with space.txt"); + }); + + test("returns not-a-git-repository when cwd is not a repo", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dx-untracked-not-git-")); + tempRepos.push(tempDir); + const result = listUntrackedFiles({ cwd: tempDir }); + expect(result.ok).toBe(false); + expect(result.type).toBe("not-a-git-repository"); + }); + + test("returns git-not-installed when spawn fails with ENOENT", () => { + const fakeSpawn = () => ({ + error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }) + }); + const result = listUntrackedFiles({ cwd: process.cwd(), spawn: fakeSpawn }); + expect(result.ok).toBe(false); + expect(result.type).toBe("git-not-installed"); + }); + }); + + describe("validate (with fixture repo)", () => { + test("clean repo => exit 0", () => { + const repo = makeTempRepo(); + const result = validate({ cwd: repo }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test("untracked-and-unignored file fails and names the file", () => { + const repo = makeTempRepo(); + writeFile(repo, "scratch.txt", "scratch\n"); + const result = validate({ cwd: repo }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].type).toBe("untracked-path"); + expect(result.errors[0].file).toBe("scratch.txt"); + expect(result.errors[0].message).toContain("scratch.txt"); + expect(result.errors[0].message).toContain(".gitignore"); + // m4: remediation must mention .npmignore as well. + expect(result.errors[0].message).toContain(".npmignore"); + }); + + test("untracked-and-ignored file => exit 0", () => { + const repo = makeTempRepo(); + // Commit the .gitignore so it is tracked, otherwise it is itself an + // untracked path that would fail this test. + writeFile(repo, ".gitignore", "ignored.txt\n"); + childProcess.spawnSync("git", ["add", ".gitignore"], { cwd: repo }); + childProcess.spawnSync("git", ["commit", "-m", "add gitignore", "-q"], { cwd: repo }); + writeFile(repo, "ignored.txt", "ignored\n"); + const result = validate({ cwd: repo }); + expect(result.valid).toBe(true); + }); + + test("--allow= covers an otherwise-failing path", () => { + const repo = makeTempRepo(); + writeFile(repo, "foo-tmp.txt", "scratch\n"); + const result = validate({ cwd: repo, allow: ["foo*"] }); + expect(result.valid).toBe(true); + expect(result.ignoredByAllowlist).toEqual(["foo-tmp.txt"]); + }); + + test("env-var allow merges with CLI --allow", () => { + const repo = makeTempRepo(); + writeFile(repo, "foo-tmp.txt", "scratch\n"); + writeFile(repo, "bar-tmp.txt", "scratch\n"); + const result = validate({ cwd: repo, allow: ["foo*"], envAllow: ["bar*"] }); + expect(result.valid).toBe(true); + expect(result.ignoredByAllowlist.sort()).toEqual(["bar-tmp.txt", "foo-tmp.txt"]); + }); + + test("not-a-git-repository surfaces as a hard error", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dx-untracked-not-git-2-")); + tempRepos.push(tempDir); + const result = validate({ cwd: tempDir }); + expect(result.valid).toBe(false); + expect(result.errors[0].type).toBe("not-a-git-repository"); + }); + + test("rollup: 4+ files in one directory becomes ONE error (M4)", () => { + const repo = makeTempRepo(); + writeFile(repo, "build/a.txt", "a\n"); + writeFile(repo, "build/b.txt", "b\n"); + writeFile(repo, "build/c.txt", "c\n"); + writeFile(repo, "build/d.txt", "d\n"); + writeFile(repo, "build/e.txt", "e\n"); + const result = validate({ cwd: repo }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.type).toBe("untracked-directory"); + expect(error.directory).toBe("build"); + expect(error.count).toBe(5); + expect(error.message).toContain("'build/'"); + expect(error.message).toContain("5 files"); + expect(error.message).toContain(".npmignore"); + }); + + test("rollup: 3-or-fewer files stay as individual errors (M4)", () => { + const repo = makeTempRepo(); + writeFile(repo, "build/a.txt", "a\n"); + writeFile(repo, "build/b.txt", "b\n"); + writeFile(repo, "build/c.txt", "c\n"); + const result = validate({ cwd: repo }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(3); + for (const error of result.errors) { + expect(error.type).toBe("untracked-path"); + } + }); + }); + + describe("reportResult", () => { + test("logs OK and returns 0 on success", () => { + const messages = []; + const exit = reportResult( + { valid: true, errors: [], untracked: [], ignoredByAllowlist: [] }, + { logger: { log: (message) => messages.push(message) } } + ); + expect(exit).toBe(0); + expect(messages.join("\n")).toContain("OK"); + }); + + test("logs file-prefixed errors and returns 1 on failure", () => { + const messages = []; + const exit = reportResult( + { + valid: false, + errors: [{ type: "untracked-path", file: "scratch.txt", message: "violates policy" }], + untracked: ["scratch.txt"], + ignoredByAllowlist: [] + }, + { logger: { log: (message) => messages.push(message) } } + ); + expect(exit).toBe(1); + expect(messages.join("\n")).toContain("scratch.txt"); + }); + + test("logs directory-rollup errors with count", () => { + const messages = []; + const exit = reportResult( + { + valid: false, + errors: [ + { + type: "untracked-directory", + directory: "build", + count: 47, + files: [], + message: "rollup message" + } + ], + untracked: [], + ignoredByAllowlist: [] + }, + { logger: { log: (message) => messages.push(message) } } + ); + expect(exit).toBe(1); + const log = messages.join("\n"); + expect(log).toContain("build"); + expect(log).toContain("47 files"); + }); + + test("logs allowlist diagnostic and returns 0 when only allowlisted paths exist", () => { + const messages = []; + const exit = reportResult( + { + valid: true, + errors: [], + untracked: ["allowed.txt"], + ignoredByAllowlist: ["allowed.txt"] + }, + { logger: { log: (message) => messages.push(message) } } + ); + expect(exit).toBe(0); + expect(messages.join("\n")).toContain("allowlist"); + }); + }); + + describe("real repository", () => { + // m13: this test is NOT skipped by design. It only passes once the + // working tree is fully committed (every file is either tracked or + // covered by .gitignore). When this test fails locally, the cause is + // almost always uncommitted work in your tree — that's the point. Do + // not add a skip guard here. + test("real repo is clean", () => { + const result = validate(); + if (!result.valid) { + // Surface the offending paths so a maintainer reading this failure + // does not need to re-run the validator manually. + const detail = result.errors + .map((error) => { + if (error.type === "untracked-directory") { + return `[${error.type}] ${error.directory}/ (${error.count} files) ${error.message}`; + } + return `[${error.type}] ${error.file || ""} ${error.message}`; + }) + .join("\n"); + throw new Error(`validate-untracked-policy failed on the real repo:\n${detail}`); + } + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/scripts/__tests__/validate-untracked-policy.test.js.meta b/scripts/__tests__/validate-untracked-policy.test.js.meta new file mode 100644 index 00000000..e938cc60 --- /dev/null +++ b/scripts/__tests__/validate-untracked-policy.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b75 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/update-llms-txt.js b/scripts/update-llms-txt.js index eba0f9e8..006377ef 100755 --- a/scripts/update-llms-txt.js +++ b/scripts/update-llms-txt.js @@ -198,12 +198,12 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re - Anyone can listen - Example: Game settings changed -2. **Targeted Messages** - Commands to specific entities +1. **Targeted Messages** - Commands to specific entities - Has a specific GameObject/Component target - Only target and its children listen - Example: Heal this specific character -3. **Broadcast Messages** - Observable facts from a source +1. **Broadcast Messages** - Observable facts from a source - Has a source GameObject/Component - Anyone can observe what happened - Example: This enemy took damage @@ -275,12 +275,14 @@ public class HealthDisplay : MessageAwareComponent ## Documentation Structure ### Getting Started + - [Overview](https://wallstop.github.io/DxMessaging/getting-started/overview/) - [Installation](https://wallstop.github.io/DxMessaging/getting-started/install/) - [Quick Start](https://wallstop.github.io/DxMessaging/getting-started/quick-start/) - [Visual Guide](https://wallstop.github.io/DxMessaging/getting-started/visual-guide/) ### Concepts + - [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles - [Message Types](https://wallstop.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast - [Listening Patterns](https://wallstop.github.io/DxMessaging/concepts/listening-patterns/) @@ -288,30 +290,37 @@ public class HealthDisplay : MessageAwareComponent - [Interceptors & Ordering](https://wallstop.github.io/DxMessaging/concepts/interceptors-and-ordering/) ### Guides + - [Patterns](https://wallstop.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns - [Unity Integration](https://wallstop.github.io/DxMessaging/guides/unity-integration/) - [Testing](https://wallstop.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems - [Diagnostics](https://wallstop.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging +- [Memory Reclamation](https://wallstop.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters - [Migration Guide](https://wallstop.github.io/DxMessaging/guides/migration-guide/) ### Architecture + - [Design & Architecture](https://wallstop.github.io/DxMessaging/architecture/design-and-architecture/) - [Performance](https://wallstop.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) - [Comparisons](https://wallstop.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses ### Advanced Topics + - [Emit Shorthands](https://wallstop.github.io/DxMessaging/advanced/emit-shorthands/) - [Message Bus Providers](https://wallstop.github.io/DxMessaging/advanced/message-bus-providers/) - [Registration Builders](https://wallstop.github.io/DxMessaging/advanced/registration-builders/) - [Runtime Configuration](https://wallstop.github.io/DxMessaging/advanced/runtime-configuration/) ### Integrations + - [Zenject](https://wallstop.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration - [VContainer](https://wallstop.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration - [Reflex](https://wallstop.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration ### Reference + - [Quick Reference](https://wallstop.github.io/DxMessaging/reference/quick-reference/) +- [Runtime Settings](https://wallstop.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API - [FAQ](https://wallstop.github.io/DxMessaging/reference/faq/) - [Glossary](https://wallstop.github.io/DxMessaging/reference/glossary/) - [Troubleshooting](https://wallstop.github.io/DxMessaging/reference/troubleshooting/) @@ -371,18 +380,22 @@ ${skillCategoriesText} ## Common Pitfalls & Solutions ### Memory Leaks + **Problem:** Forgot to unsubscribe from events **Solution:** Use \`MessageAwareComponent\` or \`MessageHandler\` for automatic lifecycle management ### Message Not Received + **Problem:** Handler registered after message was emitted **Solution:** Messages are synchronous; ensure registration happens during \`Awake\`/\`OnEnable\` ### Wrong Message Type + **Problem:** Used Broadcast when Targeted was needed **Solution:** See [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) for type selection guidance ### Performance Issues + **Problem:** Too many handlers or heavy interceptors **Solution:** Use priority ordering, profile with Inspector diagnostics @@ -399,7 +412,9 @@ See [Performance Documentation](https://wallstop.github.io/DxMessaging/architect ## Examples ### Mini Combat Sample + Demonstrates all three message types in a simple combat scenario: + - **Untargeted:** Game settings changes - **Targeted:** Heal specific character - **Broadcast:** Enemy takes damage @@ -407,7 +422,9 @@ Demonstrates all three message types in a simple combat scenario: **Location:** \`Samples~/Mini Combat\` ### DI Integration Sample + Shows integration with Zenject, VContainer, and Reflex: + - Scoped message buses - Container lifecycle integration - IMessageRegistrationBuilder usage @@ -415,7 +432,9 @@ Shows integration with Zenject, VContainer, and Reflex: **Location:** \`Samples~/DI\` ### Inspector Diagnostics Sample + Demonstrates debugging tools: + - Global observer pattern - Message flow visualization - Timestamp and payload inspection diff --git a/scripts/validate-no-plan-vocabulary.js b/scripts/validate-no-plan-vocabulary.js new file mode 100644 index 00000000..2f0b6159 --- /dev/null +++ b/scripts/validate-no-plan-vocabulary.js @@ -0,0 +1,600 @@ +#!/usr/bin/env node +/** + * @fileoverview Fails CI if shipping content references the project's + * internal planning vocabulary. + * + * The project tracks long-running work in PLAN.md files and uses milestone + * tags of the shape `T.` and `P.` plus + * "Phase P" / "Tier T" headings. None of that vocabulary is meant for + * users; if it leaks into shipping content the user-facing surface starts + * to look like a project tracker. This validator enforces the boundary. + * + * In-scope content (only): + * - Runtime/, Editor/, SourceGenerators/ *.cs files (non-test) + * - Samples~/ *.cs files + * - docs/ *.md files + * - README.md, CHANGELOG.md, CONTRIBUTING.md, "Third Party Notices.md" + * - llms.txt + * + * Tests, build outputs, and project-internal docs (PLAN.md / PERF-PLAN.md / + * OLD-PLAN.md / GH-PAGES-PLAN.md, scripts/, .llm/, .github/) are explicitly + * out of scope: those are where the planning vocabulary lives by design. + * + * Forbidden patterns: + * 1. Filename references: PLAN.md, PERF-PLAN.md, OLD-PLAN.md, GH-PAGES-PLAN.md + * (case-sensitive; the names of the actual planning files in the repo). + * 2. Tier tags: T<1-2 digits>.<1-2 digits> and P<1-2 digits>.<1-2 digits> + * (case-sensitive). Bare T1 / P0 are NOT forbidden because Mermaid + * diagram node IDs and test method names use them legitimately. The + * digit-count cap also keeps unrelated quantities like "T22.5 degrees" + * out of the match. + * 3. Plan-section headings: lines starting with `# Phase P` or + * `# Tier T` (any heading depth). Migration-guide style + * "Phase 0/1/2/3" headings without the `P` prefix are intentionally + * allowed; see docs/guides/migration-guide.md. + * + * Markdown code-fence handling (m2): + * Documentation must be able to show "what NOT to do" inside code blocks. + * The scanner detects fenced code blocks (lines starting with ``` ` ``` or + * ` ``` ` plus the closing fence) and skips everything between fences. + * Inline code spans on a single line are NOT specially handled; if a line + * that contains a violation is also entirely an inline code span, the + * violation still fires by design. + * + * Allowlist: + * The validator itself and its test must STATE the patterns to enforce + * them, so they are excluded by exact path. New legitimate exceptions + * require an explicit edit of `ALLOWLIST` plus a comment justifying it. + * + * Exit codes: + * 0 No violations. + * 1 Violations found (or unrecoverable error). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnPlatformCommandSync } = require("./lib/shell-command"); + +const REPO_ROOT = path.resolve(__dirname, ".."); + +// In-scope file patterns (matched against repo-relative POSIX paths). +const INCLUDE_PATTERNS = [ + "Runtime/**/*.cs", + "Editor/**/*.cs", + "SourceGenerators/**/*.cs", + "Samples~/**/*.cs", + "docs/**/*.md", + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "Third Party Notices.md", + "llms.txt" +]; + +// Patterns whose match means "out of scope even if INCLUDE_PATTERNS matched". +// Test source trees are excluded because tests reference plan vocabulary in +// fixtures and method names. +const EXCLUDE_PATTERNS = [ + "**/Tests/**", + "SourceGenerators/**/*.Tests/**", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/**" +]; + +// Files where the patterns must be STATED to be enforced. New additions must +// include a comment justifying the exception. +const ALLOWLIST = new Set([ + // The validator script lists every forbidden pattern in its source. + "scripts/validate-no-plan-vocabulary.js", + // The test exercises every forbidden pattern in fixtures. + "scripts/__tests__/validate-no-plan-vocabulary.test.js" +]); + +/** + * Pattern definitions. `regex` is a global, multi-line regex used for + * line-by-line scanning; `name` is the diagnostic label. + * + * Patterns are intentionally string-literal so this file's own scan does + * not match the patterns inside its own source. (The validator script is + * additionally on the ALLOWLIST as defense in depth.) + */ +const PATTERNS = [ + { + name: "plan-filename", + // PLAN.md / PERF-PLAN.md / OLD-PLAN.md / GH-PAGES-PLAN.md + // The leading boundary excludes things like SOMEPLAN.md that aren't real + // filename refs. + regex: /\b(?:PLAN|PERF-PLAN|OLD-PLAN|GH-PAGES-PLAN)\.md\b/g + }, + { + name: "tier-tag", + // T<1-2 digits>.<1-2 digits> and P<1-2 digits>.<1-2 digits> milestone + // tags. The 1-2-digit cap keeps unrelated quantities like "T22.5 degrees" + // out of the match (m1). The `\b` boundary keeps it out of identifiers. + regex: /\b[TP][0-9]{1,2}\.[0-9]{1,2}\b/g + }, + { + name: "plan-section-heading", + // `^` is line-start because we run with the `m` flag. Matches headings + // like "## Phase P0 - Setup" or "### Tier T2: rollout". The bare + // "Phase 0/1/2/3" form (no `P` prefix) is deliberately allowed. + regex: /^#+\s+(?:Phase\s+P[0-9]+|Tier\s+T[0-9]+)\b/gm + } +]; + +/** + * Compile a glob pattern into a `RegExp`. Supports `*` (matches a single path + * segment without separators) and `**` (matches across separators including + * empty segments). The matcher is intentionally minimal so the in-scope + * patterns we use (`Runtime/**\/*.cs`, `**\/Tests/**`, `*.md`) Just Work + * without pulling in `minimatch` as a dependency. + * + * @param {string} pattern + * @returns {RegExp} + */ +function compileGlob(pattern) { + const specials = /[.+?^${}()|[\]\\]/g; + let regex = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*") { + if (pattern[i + 1] === "*") { + // `**/` matches zero or more leading path segments. Standalone `**` + // matches across separators including empty. + if (pattern[i + 2] === "/") { + regex += "(?:.*/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + continue; + } + regex += "[^/]*"; + i += 1; + continue; + } + if (ch === "?") { + regex += "[^/]"; + i += 1; + continue; + } + regex += ch.replace(specials, "\\$&"); + i += 1; + } + return new RegExp(`^${regex}$`); +} + +/** + * Strip a UTF-8 BOM at the start of a string. + * + * @param {string} content + * @returns {string} + */ +function stripBom(content) { + if (typeof content !== "string") { + return ""; + } + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; +} + +/** + * Normalize line endings so line-number coordinates are consistent across + * Windows-authored and POSIX-authored files. + * + * @param {string} content + * @returns {string} + */ +function normalizeLineEndings(content) { + return stripBom(content).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +/** + * Convert a Windows or mixed-separator path to a POSIX form so glob matching + * is deterministic. + * + * @param {string} value + * @returns {string} + */ +function toPosixPath(value) { + return String(value || "").split(path.sep).join("/").replace(/\\/g, "/"); +} + +/** + * Decide whether a repo-relative path is in scope for scanning. + * + * @param {string} relativePath + * @returns {boolean} + */ +function isInScope(relativePath) { + const posix = toPosixPath(relativePath); + if (!INCLUDE_PATTERNS.some((pattern) => compileGlob(pattern).test(posix))) { + return false; + } + if (EXCLUDE_PATTERNS.some((pattern) => compileGlob(pattern).test(posix))) { + return false; + } + return true; +} + +/** + * Decide whether a repo-relative path is on the allowlist (forbidden patterns + * may legitimately appear there). + * + * @param {string} relativePath + * @returns {boolean} + */ +function isAllowlisted(relativePath) { + return ALLOWLIST.has(toPosixPath(relativePath)); +} + +/** + * Replace every line that lies inside a fenced markdown code block with a + * blank line of equal length. Preserves line numbers so violation + * coordinates outside the fence remain accurate. + * + * Recognized fences: + * - Triple backtick: ``` followed by an optional language token + * - Triple tilde: ~~~ followed by an optional language token + * + * The opening and closing fence lines themselves are blanked because a + * violating identifier in a fence info-string would otherwise leak through. + * + * @param {string} content - LF-normalized file content + * @returns {string} + */ +function maskCodeFences(content) { + if (typeof content !== "string" || content.length === 0) { + return content || ""; + } + const lines = content.split("\n"); + const fenceRegex = /^\s*(?:`{3,}|~{3,})/; + let inFence = false; + let openFence = null; // The fence character we're matching against. + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!inFence) { + if (fenceRegex.test(lines[i])) { + inFence = true; + openFence = trimmed.startsWith("`") ? "`" : "~"; + // Blank the opening fence so a forbidden token in the info-string + // (e.g. ```` ```PLAN.md ```` ) does not slip past. + lines[i] = ""; + } + } else { + // Inside a fence. The fence closes on a line consisting only of the + // same fence character (3 or more), optionally indented. + const closingRegex = + openFence === "`" ? /^\s*`{3,}\s*$/ : /^\s*~{3,}\s*$/; + if (closingRegex.test(lines[i])) { + inFence = false; + openFence = null; + lines[i] = ""; + } else { + // Blank the line content while preserving the line position. + lines[i] = ""; + } + } + } + return lines.join("\n"); +} + +/** + * Scan a single string buffer against the forbidden patterns. + * + * @param {string} relativePath - Repo-relative path used in diagnostics + * @param {string} content - File contents to scan + * @returns {Array<{file: string, line: number, column: number, pattern: string, match: string}>} + */ +function scanContent(relativePath, content) { + if (typeof content !== "string" || content.length === 0) { + return []; + } + + const normalized = normalizeLineEndings(content); + // Mask out fenced code blocks so docs can show "what NOT to do" without + // tripping the validator (m2). + const masked = maskCodeFences(normalized); + + // Pre-compute a line-start index so a regex match offset can be converted + // to (line, column) without re-scanning the buffer for every match. + const lineStarts = [0]; + for (let i = 0; i < masked.length; i++) { + if (masked.charCodeAt(i) === 0x0a) { + lineStarts.push(i + 1); + } + } + + function offsetToLineColumn(offset) { + // Binary search for the largest line-start index that does not exceed + // the match offset; that line is where the match begins. + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return { line: lo + 1, column: offset - lineStarts[lo] + 1 }; + } + + const violations = []; + for (const { name, regex } of PATTERNS) { + // Reset lastIndex defensively in case the same regex is reused across + // calls (we instantiate fresh objects in PATTERNS, but better safe). + regex.lastIndex = 0; + let match; + while ((match = regex.exec(masked)) !== null) { + const { line, column } = offsetToLineColumn(match.index); + violations.push({ + file: relativePath, + line, + column, + pattern: name, + match: match[0] + }); + // Avoid infinite loops on zero-length matches (none of our patterns + // actually produce empty matches today, but defense in depth). + if (match.index === regex.lastIndex) { + regex.lastIndex += 1; + } + } + } + + // Sort by (line, column) so the output is deterministic regardless of the + // order patterns are evaluated. + violations.sort((a, b) => (a.line - b.line) || (a.column - b.column)); + return violations; +} + +/** + * Enumerate the in-scope tracked files via `git ls-files`. Falls through to + * a hard error if git is unavailable; the project's policy forbids silent + * permissive defaults when git metadata is missing. + * + * @param {object} [options] + * @param {string} [options.cwd] + * @param {Function} [options.spawn] + * @returns {{ok: true, files: string[]} | {ok: false, type: string, message: string}} + */ +function listTrackedFiles(options = {}) { + const cwd = options.cwd || REPO_ROOT; + const spawn = options.spawn || spawnPlatformCommandSync; + + const result = spawn("git", ["ls-files"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + + if (result && result.error) { + if (result.error.code === "ENOENT") { + return { + ok: false, + type: "git-not-installed", + message: "git was not found on PATH; cannot enumerate tracked shipping files." + }; + } + return { + ok: false, + type: "git-spawn-error", + message: `Failed to spawn git: ${result.error.message || result.error}` + }; + } + + if (!result || typeof result.status !== "number" || result.status !== 0) { + const stderr = (result && typeof result.stderr === "string" && result.stderr) || ""; + return { + ok: false, + type: "git-exit-error", + message: `git ls-files exited with status ${result && result.status}: ${stderr.trim() || "no stderr"}` + }; + } + + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + const files = stdout + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + return { ok: true, files }; +} + +/** + * Filter tracked files to the in-scope, non-allowlisted set. + * + * @param {string[]} trackedFiles + * @returns {string[]} + */ +function filterInScopeFiles(trackedFiles) { + return trackedFiles.filter((file) => isInScope(file) && !isAllowlisted(file)); +} + +/** + * Run the validator against the real working tree (or any tree provided via + * `options.cwd`). + * + * @param {object} [options] + * @param {string} [options.cwd] + * @param {Function} [options.spawn] + * @param {Function} [options.readFile] - Inject a reader for tests + * @returns {{ + * valid: boolean, + * errors: Array<{type: string, message: string}>, + * violations: ReturnType, + * scannedFiles: string[] + * }} + */ +function run(options = {}) { + const cwd = options.cwd || REPO_ROOT; + const readFile = + options.readFile || ((file) => fs.readFileSync(path.join(cwd, file), "utf8")); + + const list = listTrackedFiles({ cwd, spawn: options.spawn }); + if (!list.ok) { + return { + valid: false, + errors: [{ type: list.type, message: list.message }], + violations: [], + scannedFiles: [] + }; + } + + const scannedFiles = filterInScopeFiles(list.files); + const violations = []; + + for (const file of scannedFiles) { + let content; + try { + content = readFile(file); + } catch (error) { + return { + valid: false, + errors: [ + { + type: "read-error", + message: `Unable to read ${file}: ${(error && error.message) || error}` + } + ], + violations, + scannedFiles + }; + } + violations.push(...scanContent(file, content)); + } + + return { + valid: violations.length === 0, + errors: [], + violations, + scannedFiles + }; +} + +/** + * Pretty-print the run result. Returns the process exit code. + * + * @param {ReturnType} result + * @param {{logger?: typeof console}} [options] + * @returns {number} + */ +function reportResult(result, options = {}) { + const logger = options.logger || console; + + if (result.errors.length > 0) { + logger.log("validate-no-plan-vocabulary: FAILED"); + for (const error of result.errors) { + logger.log(` - [${error.type}] ${error.message}`); + } + return 1; + } + + if (result.valid) { + logger.log( + `validate-no-plan-vocabulary: OK (${result.scannedFiles.length} files scanned, no violations)` + ); + return 0; + } + + logger.log(`validate-no-plan-vocabulary: FAILED (${result.violations.length} violation(s))`); + for (const violation of result.violations) { + logger.log( + ` ${violation.file}:${violation.line}:${violation.column}: ${violation.pattern}: ${violation.match}` + ); + } + logger.log( + "Remediation: shipping content must not reference internal planning " + + "vocabulary. Replace plan filenames with stable user docs, replace " + + "milestone tags with descriptive prose, and replace plan-section " + + "headings with user-facing section titles." + ); + return 1; +} + +/** + * Parse CLI arguments. + * + * @param {string[]} argv + * @returns {{listFiles: boolean, help: boolean, errors: string[]}} + */ +function parseArgs(argv) { + const result = { listFiles: false, help: false, errors: [] }; + for (const arg of argv) { + if (arg === "--list-files") { + result.listFiles = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + continue; + } + result.errors.push(`Unknown argument: ${arg}`); + } + return result; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: node scripts/validate-no-plan-vocabulary.js [--list-files]\n" + + " --list-files Print the in-scope file list and exit (debugging)." + ); + return 0; + } + if (args.errors.length > 0) { + for (const message of args.errors) { + console.error(message); + } + return 1; + } + + if (args.listFiles) { + const list = listTrackedFiles(); + if (!list.ok) { + console.error(`[${list.type}] ${list.message}`); + return 1; + } + for (const file of filterInScopeFiles(list.files)) { + console.log(file); + } + return 0; + } + + const result = run(); + return reportResult(result); +} + +if (require.main === module) { + process.exitCode = main(); +} + +module.exports = { + REPO_ROOT, + INCLUDE_PATTERNS, + EXCLUDE_PATTERNS, + ALLOWLIST, + PATTERNS, + compileGlob, + stripBom, + normalizeLineEndings, + toPosixPath, + isInScope, + isAllowlisted, + maskCodeFences, + scanContent, + listTrackedFiles, + filterInScopeFiles, + run, + reportResult, + parseArgs, + main +}; diff --git a/scripts/validate-no-plan-vocabulary.js.meta b/scripts/validate-no-plan-vocabulary.js.meta new file mode 100644 index 00000000..220866a2 --- /dev/null +++ b/scripts/validate-no-plan-vocabulary.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b73 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-npm-meta.js b/scripts/validate-npm-meta.js index baa97a44..ce8bb163 100644 --- a/scripts/validate-npm-meta.js +++ b/scripts/validate-npm-meta.js @@ -20,6 +20,28 @@ const path = require("path"); const { normalizeToLf } = require("./lib/quote-parser"); const { spawnPlatformCommandSync } = require("./lib/shell-command"); +/** + * Normalize a packaged file path to a canonical form. + * + * `npm pack --json` and `tar -tzf` occasionally surface entries prefixed with + * `./` (e.g. `./Runtime/Foo.cs`). Without normalization, downstream consumers + * relying on `startsWith("Runtime/")` would silently skip those paths and miss + * real regressions. Stripping a single leading `./` keeps the validator inputs + * canonical so every consumer sees the same shape. + * + * @param {string} file - Package-relative file path + * @returns {string} Canonical package-relative file path + */ +function normalizeTarballPath(file) { + if (typeof file !== "string") { + return file; + } + if (file.startsWith("./")) { + return file.slice(2); + } + return file; +} + /** * Parse tar listing output into package-relative file paths. * @@ -31,6 +53,7 @@ function parseTarListingOutput(tarOutput) { .split("\n") .filter((line) => line.trim()) .map((line) => line.replace(/^package\//, "")) + .map((line) => normalizeTarballPath(line)) .filter((line) => line); // Remove empty strings } @@ -80,6 +103,7 @@ function parseNpmPackJsonOutput(packOutput) { return ""; }) + .map((entry) => normalizeTarballPath(entry)) .filter((entry) => entry.length > 0); if (files.length === 0) { @@ -101,11 +125,20 @@ function getPackageFiles() { try { console.log("Computing package file list via npm pack --json --dry-run..."); - const packResult = spawnPlatformCommandSync("npm", ["pack", "--json", "--dry-run"], { - encoding: "utf8", - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"] - }); + // --ignore-scripts is the recursion guard: when this validator is wired into + // the `prepack` script in package.json, the inner `npm pack` would re-trigger + // any `prepack` script and recurse into this validator indefinitely. Passing + // --ignore-scripts skips lifecycle scripts entirely so the inner pack + // resolves to a single one-shot listing. + const packResult = spawnPlatformCommandSync( + "npm", + ["pack", "--json", "--dry-run", "--ignore-scripts"], + { + encoding: "utf8", + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"] + } + ); if (packResult.error) { throw packResult.error; @@ -285,6 +318,227 @@ function validateFilesHaveMetaFiles(files) { return { valid: errors.length === 0, errors }; } +// Patterns that catch build artifacts and IDE state that must never ship in the npm tarball. +// See https://github.com/wallstop/DxMessaging/issues/204 -- pre-2.1.8 npm tarballs shipped +// SourceGenerator `bin/` and `obj/` outputs whose paths had no .meta partner. Unity then +// emitted `GuidDB::CreateMetaFileMappings` warnings on every asset-database refresh. +const buildArtifactPatterns = [ + { pattern: /(^|\/)(bin|obj)\//, label: "build output directory (bin/ or obj/)" }, + { pattern: /\.pdb$/, label: "compiler debug symbol file (.pdb)" }, + { pattern: /\.tmp$/, label: "temporary build file (.tmp)" }, + { pattern: /\.csproj\.user$/, label: "per-user MSBuild settings (.csproj.user)" }, + { pattern: /(^|\/)\.vs\//, label: "Visual Studio workspace state (.vs/)" }, + { pattern: /(^|\/)\.idea\//, label: "JetBrains IDE state (.idea/)" }, + { pattern: /\.suo$/, label: "Visual Studio solution user options (.suo)" }, + { pattern: /\.DotSettings\.user$/, label: "ReSharper per-user settings (.DotSettings.user)" }, + // Plain `.user` is checked last so the more specific .csproj.user / .DotSettings.user + // patterns above own their richer messages first. + { pattern: /\.user$/, label: "per-user IDE settings file (.user)" } +]; + +const issue204Reference = "https://github.com/wallstop/DxMessaging/issues/204"; + +/** + * Validate that no build artifacts, IDE state, or per-user files were packed. + * + * Issue #204 traced `GuidDB::CreateMetaFileMappings` warnings on every Unity + * asset-database refresh back to SourceGenerator `bin/` and `obj/` outputs that + * shipped in the npm tarball without `.meta` partners. This validator enforces + * defense-in-depth: even if `.npmignore` and the `package.json` allowlist drift, + * any build artifact reaching this stage is rejected with an explicit reference + * to the originating issue. + * + * @param {string[]} tarballFiles - Package-relative file paths that would ship in the tarball + * @returns {{valid: boolean, errors: Array<{type: string, file: string, message: string}>}} + */ +function validateNoBuildArtifactsInTarball(tarballFiles) { + const errors = []; + + // Normalize at the boundary: callers (parseNpmPackJsonOutput / parseTarListingOutput) + // already canonicalize, but the validators are also reachable directly from tests and + // any future caller. Stripping a leading `./` here is the single source of truth. + const normalizedFiles = tarballFiles.map((file) => normalizeTarballPath(file)); + + for (const file of normalizedFiles) { + for (const { pattern, label } of buildArtifactPatterns) { + if (pattern.test(file)) { + errors.push({ + type: "build-artifact-in-tarball", + file: file, + message: + `Build artifact '${file}' (${label}) must not ship in the npm package. ` + + `Issue #204 (${issue204Reference}) was caused by paths like this leaking into ` + + `the tarball without .meta partners, producing GuidDB::CreateMetaFileMappings ` + + `warnings on every Unity asset-database refresh.` + }); + break; + } + } + } + + return { valid: errors.length === 0, errors }; +} + +// Root-level files that ship in the package and require a .meta partner per package.json's +// "files" allowlist. Keep this list synchronized with package.json. +const rootShippedFilesRequiringMeta = new Set([ + "CHANGELOG.md", + "LICENSE.md", + "README.md", + "Third Party Notices.md" +]); + +// SourceGenerator-shipped files outside the canonical `*.cs` / `*.csproj` set that the +// package.json "files" allowlist explicitly publishes alongside their `.meta` partners. +// Keep this set synchronized with package.json -- adding an entry here without a matching +// allowlist entry (or vice versa) is the regression vector this validator is meant to catch. +const sourceGeneratorTrackedNonCodeFiles = new Set(["SourceGenerators/Directory.Build.props"]); + +/** + * Determine whether a packaged path is "Unity-relevant" -- i.e. Unity will look + * for a `.meta` partner for it during asset import. This intentionally mirrors + * the package.json "files" allowlist shape so the validator stays accurate when + * the allowlist evolves. + * + * @param {string} file - Package-relative file path + * @returns {boolean} `true` if `file` requires a sibling `.meta` to ship in the tarball + */ +function isUnityRelevantPackagedPath(file) { + if (file.startsWith("Editor/") || file.startsWith("Runtime/") || file.startsWith("Samples~/")) { + return true; + } + + if ( + file.startsWith("SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/") && + (file.endsWith(".cs") || file.endsWith(".csproj")) + ) { + return true; + } + + if (sourceGeneratorTrackedNonCodeFiles.has(file)) { + return true; + } + + if (rootShippedFilesRequiringMeta.has(file)) { + return true; + } + + return false; +} + +/** + * Determine whether a packaged directory must have a `.meta` partner. + * Mirrors the Unity rule that every imported folder needs a folder .meta so the + * GUID mapping is stable across installs. + * + * @param {string} directory - Package-relative directory path + * @returns {boolean} `true` if `directory` requires a sibling `.meta` to ship + */ +function isUnityRelevantPackagedDirectory(directory) { + if (directory === "Samples~") { + // Unity hides Samples~ itself from the asset tree; subdirectories still need .meta. + return false; + } + + if ( + directory === "Editor" || + directory === "Runtime" || + directory === "SourceGenerators" || + directory.startsWith("Editor/") || + directory.startsWith("Runtime/") || + directory.startsWith("Samples~/") + ) { + return true; + } + + if ( + directory === "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators" || + directory.startsWith("SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/") + ) { + return true; + } + + return false; +} + +/** + * Validate that every Unity-relevant file and directory shipped in the tarball + * has its `.meta` partner shipped alongside it. + * + * Issue #204 (https://github.com/wallstop/DxMessaging/issues/204) was triggered + * by `.cs` files inside `bin/Debug/netstandard2.0/` reaching the published + * tarball without `.meta` neighbours. Unity then logged + * `GuidDB::CreateMetaFileMappings` warnings on every asset-database refresh. + * This validator catches both the file-level and directory-level missing-meta + * cases that #204 surfaced. + * + * @param {string[]} tarballFiles - Package-relative file paths that would ship in the tarball + * @returns {{valid: boolean, errors: Array<{type: string, file: string, message: string}>}} + */ +function validatePublishedFilesArePairedWithMetas(tarballFiles) { + const errors = []; + // Normalize at the boundary so `./Runtime/Foo.cs` style entries cannot mask a missing + // .meta partner via the `startsWith("Runtime/")` checks downstream. + const normalizedFiles = tarballFiles.map((file) => normalizeTarballPath(file)); + const fileSet = new Set(normalizedFiles); + const shippedDirectories = new Set(); + + // File-level checks -- every Unity-relevant non-.meta path must have its .meta neighbour. + for (const file of normalizedFiles) { + if (file.endsWith(".meta")) { + continue; + } + + let directory = path.posix.dirname(file); + while (directory && directory !== ".") { + shippedDirectories.add(directory); + directory = path.posix.dirname(directory); + } + + if (!isUnityRelevantPackagedPath(file)) { + continue; + } + + const expectedMeta = file + ".meta"; + if (!fileSet.has(expectedMeta)) { + errors.push({ + type: "missing-meta-in-tarball", + file: expectedMeta, + message: + `Tarball is missing '${expectedMeta}' for shipped file '${file}'. ` + + `Unity requires a .meta partner for every imported asset; absence triggers ` + + `GuidDB::CreateMetaFileMappings warnings on every asset-database refresh, ` + + `which is exactly the regression filed as issue #204 (${issue204Reference}).` + }); + } + } + + // Directory-level checks -- every shipped directory under Unity-relevant roots needs `.meta`. + // The directory walk above already records every ancestor of every shipped file, so the + // package roots (`Editor/`, `Runtime/`, `SourceGenerators/`) are covered without an extra + // explicit pass: Unity-relevant subdirectories propagate up to their root directory entries. + for (const directory of shippedDirectories) { + if (!isUnityRelevantPackagedDirectory(directory)) { + continue; + } + + const expectedMeta = directory + ".meta"; + if (!fileSet.has(expectedMeta)) { + errors.push({ + type: "missing-meta-in-tarball", + file: expectedMeta, + message: + `Tarball is missing directory meta '${expectedMeta}' for shipped folder '${directory}/'. ` + + `Unity requires '.meta' alongside each imported folder; without it, the asset ` + + `database cannot resolve the folder GUID and emits GuidDB::CreateMetaFileMappings ` + + `warnings on every refresh, the regression tracked by issue #204 (${issue204Reference}).` + }); + } + } + + return { valid: errors.length === 0, errors }; +} + /** * Validate that development-only repository paths are not published. * @param {string[]} files - List of files in the package @@ -364,8 +618,40 @@ function validateNpmMeta(options = {}) { console.log(); } + // Issue #204 regression guards: bin/obj/IDE artifacts and unpaired .meta files in tarball. + console.log("Checking for build artifacts and IDE state in tarball (issue #204 guard)..."); + const buildArtifactResult = validateNoBuildArtifactsInTarball(files); + if (buildArtifactResult.valid) { + console.log("✓ No build artifacts or per-user IDE state in tarball\n"); + } else { + console.log(`✗ Found ${buildArtifactResult.errors.length} build artifact(s) in tarball:\n`); + for (const error of buildArtifactResult.errors) { + console.log(` - ${error.message}`); + } + console.log(); + } + + console.log("Checking shipped paths are paired with .meta partners (issue #204 guard)..."); + const tarballMetaPairingResult = validatePublishedFilesArePairedWithMetas(files); + if (tarballMetaPairingResult.valid) { + console.log("✓ All shipped Unity-relevant paths have their .meta partners\n"); + } else { + console.log( + `✗ Found ${tarballMetaPairingResult.errors.length} missing .meta partner(s) in tarball:\n` + ); + for (const error of tarballMetaPairingResult.errors) { + console.log(` - ${error.message}`); + } + console.log(); + } + // Summary - const allValid = orphanedResult.valid && missingResult.valid && developmentFilesResult.valid; + const allValid = + orphanedResult.valid && + missingResult.valid && + developmentFilesResult.valid && + buildArtifactResult.valid && + tarballMetaPairingResult.valid; if (allValid) { console.log("✓ NPM package meta file validation passed!"); return { valid: true, errors: [] }; @@ -374,7 +660,9 @@ function validateNpmMeta(options = {}) { const allErrors = [ ...orphanedResult.errors, ...missingResult.errors, - ...developmentFilesResult.errors + ...developmentFilesResult.errors, + ...buildArtifactResult.errors, + ...tarballMetaPairingResult.errors ]; if (options.check) { @@ -406,5 +694,7 @@ module.exports = { validateDevelopmentFilesExcluded, validateMetaFilesHaveTargets, validateFilesHaveMetaFiles, + validateNoBuildArtifactsInTarball, + validatePublishedFilesArePairedWithMetas, validateNpmMeta }; diff --git a/scripts/validate-runtime-settings-docs.js b/scripts/validate-runtime-settings-docs.js new file mode 100644 index 00000000..672fdcd4 --- /dev/null +++ b/scripts/validate-runtime-settings-docs.js @@ -0,0 +1,1017 @@ +#!/usr/bin/env node +/** + * @fileoverview Validates that the runtime-settings docs page references the + * same set of public properties exposed by `DxMessagingRuntimeSettings.cs`. + * + * Source of truth: + * - `Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs` defines the + * public read-only properties that consumers script against. + * - `docs/reference/runtime-settings.md` documents each property in a + * parameter table whose first column references the C# property name. + * + * This validator pairs the two files: every public property in the C# file + * must have a matching row in the doc table, and every doc-table row must + * reference a public property that still exists in the C# file. A drift in + * either direction is a CI failure with a clear remediation hint. + * + * Path resolution policy: + * The repo root is resolved as `path.resolve(__dirname, "..")` so the + * validator works whether invoked via `npm run` (cwd == repo root) or via a + * nested cwd (for example a subagent's worktree). This avoids any reliance + * on `git rev-parse --show-toplevel`, keeping the validator usable inside + * non-git checkouts too (the failure mode for a missing C# file is then a + * clear `parse-error` rather than an opaque git invocation error). + * + * Usage: + * node scripts/validate-runtime-settings-docs.js + * node scripts/validate-runtime-settings-docs.js --check + * node scripts/validate-runtime-settings-docs.js --list-properties + * + * Exit codes: + * 0 Source and doc table agree (or both files exist with matching names). + * 1 Drift detected, parse error, or unknown CLI flag. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, ".."); +const DEFAULT_SOURCE_PATH = path.join( + REPO_ROOT, + "Runtime", + "Core", + "Configuration", + "DxMessagingRuntimeSettings.cs" +); +const DEFAULT_DOC_PATH = path.join(REPO_ROOT, "docs", "reference", "runtime-settings.md"); + +const UTF8_BOM = ""; + +// Modifiers that may appear between `public` and the type token in a property +// declaration. The parser accepts any combination in any order; the C# +// compiler enforces ordering, so a value file that compiles will satisfy this +// list. +const PROPERTY_MODIFIERS = new Set([ + "static", + "virtual", + "override", + "new", + "readonly", + "sealed", + "abstract", + "extern", + "unsafe" +]); + +// Type-declaration keywords that distinguish a `public class Foo` line from a +// `public int Foo` property line. When one of these tokens appears at the +// modifier-or-type position, the line is NOT a property and must be skipped. +const TYPE_DECLARATION_KEYWORDS = new Set([ + "class", + "struct", + "interface", + "enum", + "record", + "delegate", + "event" +]); + +/** + * Strip a leading UTF-8 BOM so downstream parsers do not have to special-case + * it. Files written by some Windows tooling include a BOM that breaks naive + * regex anchors at the very start of the buffer. + * + * @param {string} content + * @returns {string} + */ +function stripBom(content) { + if (typeof content !== "string") { + return ""; + } + return content.startsWith(UTF8_BOM) ? content.slice(UTF8_BOM.length) : content; +} + +/** + * Normalize CRLF/CR to LF so line-by-line scanning produces consistent + * coordinates across operating systems. + * + * @param {string} content + * @returns {string} + */ +function normalizeLineEndings(content) { + return stripBom(content).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +/** + * Replace every C# string-literal body (regular, verbatim, and interpolated) + * with spaces of equal length so brace counters and other lexical scanners + * cannot be fooled by characters inside literals (`=> "}";`, `$"{0}"`, etc.). + * + * The transformation preserves the input's length so caller-side line/column + * coordinates remain valid. Newlines inside verbatim strings are also + * preserved so line-based logic still indexes the right line numbers. + * + * Supported forms: + * - Regular strings: `"..."` with backslash escapes + * - Verbatim strings: `@"..."` where `""` represents a literal `"` + * - Interpolated: `$"..."` with `\` escapes; `{{`/`}}` are + * literal braces (we strip them too) + * - Verbatim+interpolated: `$@"..."` and `@$"..."` (handled like verbatim + * interpolated; `""` is a literal `"`, + * `{{`/`}}` are literal braces) + * - Char literals: `'.'` and `'\\\\.'` + * + * Single-line `//` comments and block `/* *\/` comments are also blanked. We + * preserve the original content's length so coordinates stay valid. + * + * @param {string} content + * @returns {string} + */ +function stripStringsAndComments(content) { + if (typeof content !== "string" || content.length === 0) { + return content || ""; + } + + const out = new Array(content.length); + let i = 0; + const n = content.length; + + // Helper that copies a span unchanged into the output buffer. Used for + // tokens we do NOT need to mask (so brace counting can still see them). + function copyChar() { + out[i] = content[i]; + i += 1; + } + + // Helper that masks a single char as a space (or preserves a newline so + // line numbers remain valid). + function maskChar() { + out[i] = content[i] === "\n" ? "\n" : " "; + i += 1; + } + + while (i < n) { + const ch = content[i]; + const next = i + 1 < n ? content[i + 1] : ""; + + // Line comment: blank everything until the newline. + if (ch === "/" && next === "/") { + while (i < n && content[i] !== "\n") { + maskChar(); + } + continue; + } + + // Block comment: blank everything until `*\/`. + if (ch === "/" && next === "*") { + maskChar(); + maskChar(); + while (i < n) { + if (content[i] === "*" && i + 1 < n && content[i + 1] === "/") { + maskChar(); + maskChar(); + break; + } + maskChar(); + } + continue; + } + + // Char literal: '.' or '\\\\.'. We keep the lexical shape but blank the + // body so any brace inside cannot be miscounted. + if (ch === "'") { + copyChar(); // opening quote + while (i < n && content[i] !== "'") { + if (content[i] === "\\" && i + 1 < n) { + maskChar(); + maskChar(); + continue; + } + if (content[i] === "\n") { + // Unterminated; bail out gracefully. + break; + } + maskChar(); + } + if (i < n && content[i] === "'") { + copyChar(); // closing quote + } + continue; + } + + // Verbatim and interpolated strings: detect the prefix combinations. + let prefixLen = 0; + let isVerbatim = false; + let isInterpolated = false; + + if (ch === "@" && next === '"') { + prefixLen = 1; + isVerbatim = true; + } else if (ch === "$" && next === '"') { + prefixLen = 1; + isInterpolated = true; + } else if ( + (ch === "@" && next === "$" && i + 2 < n && content[i + 2] === '"') || + (ch === "$" && next === "@" && i + 2 < n && content[i + 2] === '"') + ) { + prefixLen = 2; + isVerbatim = true; + isInterpolated = true; + } else if (ch === '"') { + prefixLen = 0; + } else { + copyChar(); + continue; + } + + // Copy the prefix (and the opening quote) so identifiers around the + // string keep their textual layout. + for (let k = 0; k < prefixLen; k++) { + copyChar(); + } + if (i < n && content[i] === '"') { + copyChar(); // opening " + } else { + // Not actually a string literal (the @/$ token stood alone). Continue. + continue; + } + + // Walk the body, masking content but preserving newlines. + while (i < n) { + const cur = content[i]; + if (isVerbatim) { + if (cur === '"') { + if (i + 1 < n && content[i + 1] === '"') { + // Escaped quote inside verbatim string. + maskChar(); + maskChar(); + continue; + } + // Closing quote. + copyChar(); + break; + } + if (isInterpolated && cur === "{" && i + 1 < n && content[i + 1] === "{") { + maskChar(); + maskChar(); + continue; + } + if (isInterpolated && cur === "}" && i + 1 < n && content[i + 1] === "}") { + maskChar(); + maskChar(); + continue; + } + maskChar(); + } else { + if (cur === "\\" && i + 1 < n) { + // Backslash escape: blank both characters. + maskChar(); + maskChar(); + continue; + } + if (cur === '"') { + copyChar(); + break; + } + if (cur === "\n") { + // Unterminated regular string — bail out so the rest of the file + // still parses sensibly. + break; + } + if (isInterpolated && cur === "{" && i + 1 < n && content[i + 1] === "{") { + maskChar(); + maskChar(); + continue; + } + if (isInterpolated && cur === "}" && i + 1 < n && content[i + 1] === "}") { + maskChar(); + maskChar(); + continue; + } + maskChar(); + } + } + } + + return out.join(""); +} + +/** + * Locate the `class DxMessagingRuntimeSettings` declaration line. + * + * @param {string[]} lines + * @returns {number} The 0-based index, or -1 if not found. + */ +function findClassStartLine(lines) { + for (let i = 0; i < lines.length; i++) { + if (/\bclass\s+DxMessagingRuntimeSettings\b/.test(lines[i])) { + return i; + } + } + return -1; +} + +/** + * From a class-declaration line index, find the line containing the opening + * brace of the class body. Returns -1 if not found. + * + * @param {string[]} lines + * @param {number} classStartLine + * @returns {number} + */ +function findClassBraceLine(lines, classStartLine) { + for (let i = classStartLine; i < lines.length; i++) { + if (lines[i].includes("{")) { + return i; + } + } + return -1; +} + +/** + * Extract a property name from a candidate declaration line (or join of two + * adjacent lines for multi-line forms). Returns null if the line is not a + * public property declaration. + * + * The parser handles: + * - `public int Name => _x;` + * - `public static int Name => _x;` + * - `public virtual int Name => _x;` + * - `public override int Name => _x;` + * - `public new int Name => _x;` + * - `public readonly int Name => _x;` + * - `public List> Name => _x;` + * - `public global::System.Int32 Name => _x;` + * - `public int Name { get; }` / `{ get; private set; }` / `{ get; init; }` + * - `public int Name\n => _x;` (multi-line expression body) + * + * The parser rejects: + * - Methods (an `(` precedes `=>` or `{`) + * - Type declarations (`class`, `struct`, etc.) + * - Non-`public` lines + * - Lines with no recognizable identifier-before-arrow-or-brace + * + * @param {string} candidate - The candidate line, optionally joined with the + * next non-blank line for multi-line forms. + * @returns {string|null} - The extracted property name, or null on no match. + */ +function extractPropertyNameFromCandidate(candidate) { + const text = candidate.trim(); + if (!text) { + return null; + } + + // Attribute-only lines (e.g. `[Obsolete]`) are not properties on their own. + if (text.startsWith("[")) { + return null; + } + + // Must start with `public` followed by whitespace. + const publicMatch = text.match(/^public\b\s+/); + if (!publicMatch) { + return null; + } + + let rest = text.slice(publicMatch[0].length).trim(); + + // Strip recognized modifiers in any order. We loop because modifiers may + // appear in any sequence (`public static readonly`, `public readonly + // static`). + while (true) { + const tokenMatch = rest.match(/^([A-Za-z_]\w*)\b\s*/); + if (!tokenMatch) { + break; + } + const token = tokenMatch[1]; + if (PROPERTY_MODIFIERS.has(token)) { + rest = rest.slice(tokenMatch[0].length).trim(); + continue; + } + if (TYPE_DECLARATION_KEYWORDS.has(token)) { + // `public class Foo` and friends — not a property. + return null; + } + break; + } + + // Now `rest` should start with the type token. We must consume the type + // (which can include qualified names with `::`/`.`, generics with arbitrary + // nesting, and trailing `?`/`[]`) so the next token is the property name. + const typeEnd = consumeTypeToken(rest); + if (typeEnd <= 0) { + return null; + } + rest = rest.slice(typeEnd).trim(); + + // The property name is the next identifier. + const nameMatch = rest.match(/^([A-Za-z_]\w*)\b/); + if (!nameMatch) { + return null; + } + const name = nameMatch[1]; + let after = rest.slice(nameMatch[0].length); + + // Reject methods: a `(` before `=>` or `{` means this is a method + // declaration. Compute the earliest of those three tokens; if `(` wins, + // bail out. + const arrowIdx = after.indexOf("=>"); + const braceIdx = after.indexOf("{"); + const parenIdx = after.indexOf("("); + + function firstNonNegative(...indices) { + let best = -1; + for (const idx of indices) { + if (idx < 0) continue; + if (best < 0 || idx < best) best = idx; + } + return best; + } + + const earliest = firstNonNegative(arrowIdx, braceIdx, parenIdx); + if (earliest < 0) { + return null; + } + if (earliest === parenIdx) { + // It's a method or a constructor. Not a property. + return null; + } + + // For property-block form `{ get; ... }` accept a few common shapes. + if (earliest === braceIdx) { + const blockBody = after.slice(braceIdx); + // Accept `{ get; }`, `{ get; private set; }`, `{ get; init; }`, etc. + // Reject anything with a body assignment like `{ get; } = expr;` — + // accept it, since that's still a public property. + if (!/^\{\s*get\s*;/.test(blockBody)) { + return null; + } + } + + return name; +} + +/** + * Consume a C# type token from the start of `text` and return the number of + * characters consumed. Handles qualified names, `global::` prefix, generics + * (with arbitrary nesting), and trailing `?` / `[]` / `[,]` markers. + * + * Returns 0 if the start of `text` does not look like a type. + * + * @param {string} text + * @returns {number} + */ +function consumeTypeToken(text) { + let pos = 0; + const n = text.length; + + // Optional `global::` prefix. + if (text.startsWith("global::")) { + pos = "global::".length; + } + + // First identifier component is required. + const firstIdent = text.slice(pos).match(/^[A-Za-z_]\w*/); + if (!firstIdent) { + return 0; + } + pos += firstIdent[0].length; + + // Optional dotted segments: `.Foo.Bar`. + while (pos < n) { + if (text[pos] === ".") { + const dotMatch = text.slice(pos + 1).match(/^[A-Za-z_]\w*/); + if (!dotMatch) break; + pos += 1 + dotMatch[0].length; + continue; + } + break; + } + + // Optional generic argument list with arbitrary nesting. + if (pos < n && text[pos] === "<") { + let depth = 0; + while (pos < n) { + const c = text[pos]; + if (c === "<") { + depth += 1; + } else if (c === ">") { + depth -= 1; + pos += 1; + if (depth === 0) { + break; + } + continue; + } + pos += 1; + } + } + + // Optional nullable marker. + if (pos < n && text[pos] === "?") { + pos += 1; + } + + // Optional array-rank specifier: `[]`, `[,]`, `[,,]`, ... possibly multiple. + while (pos < n && text[pos] === "[") { + let close = pos + 1; + while (close < n && text[close] !== "]") { + close += 1; + } + if (close >= n || text[close] !== "]") { + break; + } + pos = close + 1; + } + + // The next character must be whitespace (so the property identifier can + // follow). If not, this isn't a valid type-then-name pattern. + if (pos < n && /\S/.test(text[pos])) { + return 0; + } + return pos; +} + +/** + * Extract the public read-only property names declared on + * `DxMessagingRuntimeSettings`. The validator matches: + * - Expression-bodied properties: `public int X => _x;` + * - Auto-properties: `public int X { get; }` + * `public int X { get; private set; }` + * `public int X { get; init; }` + * + * Modifiers (`static`, `virtual`, `override`, `new`, `readonly`, ...) are + * tolerated. Methods (`public int Foo() => 1;`) are excluded by detecting a + * `(` before `=>` or `{`. + * + * @param {string} sourceContent - Raw .cs file contents + * @returns {{names: string[], lineNumbersByName: Map}} + * The discovered public-property names in source order, plus a map from + * property name to the 1-based line number for diagnostics. + */ +function extractPublicReadOnlyProperties(sourceContent) { + const normalized = normalizeLineEndings(sourceContent); + const masked = stripStringsAndComments(normalized); + const lines = normalized.split("\n"); + const maskedLines = masked.split("\n"); + + const classStartLine = findClassStartLine(lines); + if (classStartLine === -1) { + return { names: [], lineNumbersByName: new Map() }; + } + const braceLine = findClassBraceLine(lines, classStartLine); + if (braceLine === -1) { + return { names: [], lineNumbersByName: new Map() }; + } + + let depth = 0; + let inClass = false; + const names = []; + const lineNumbersByName = new Map(); + + for (let i = braceLine; i < lines.length; i++) { + const maskedLine = maskedLines[i]; + const sourceLine = lines[i]; + + // Brace counting uses the masked line so `=> "}"` cannot fool us. + for (const ch of maskedLine) { + if (ch === "{") { + depth += 1; + inClass = true; + } else if (ch === "}") { + depth -= 1; + if (inClass && depth === 0) { + return { names, lineNumbersByName }; + } + } + } + + if (!inClass || depth !== 1) { + continue; + } + + // Build the candidate. If the masked line does not contain a terminator + // (`;`, `=>`, `{`), join with the next non-blank line so multi-line + // expression-bodied properties parse correctly. + let candidate = maskedLine; + const hasTerminator = + candidate.includes(";") || candidate.includes("=>") || candidate.includes("{"); + if (!hasTerminator) { + // Look ahead for the first non-blank line. + for (let j = i + 1; j < maskedLines.length; j++) { + const next = maskedLines[j]; + if (next.trim().length === 0) { + continue; + } + candidate = `${candidate.trimEnd()} ${next.trimStart()}`; + break; + } + } + + const name = extractPropertyNameFromCandidate(candidate); + if (name && !lineNumbersByName.has(name)) { + names.push(name); + // Record the line at which the candidate started, not the joined line. + lineNumbersByName.set(name, i + 1); + } + + // We intentionally continue using `sourceLine` only for diagnostics; the + // masked variant is what drives parsing and brace counting. + void sourceLine; + } + + return { names, lineNumbersByName }; +} + +/** + * Extract the property names referenced in the runtime-settings doc table. + * + * The doc page is markdown with a single parameter table whose first column + * may either be a friendly name (`Idle Eviction Seconds`) or the property + * name itself, and whose second column is the C# property name (sometimes + * wrapped in backticks). We treat the SECOND column as authoritative because + * it matches the C# names directly; the first column is a human-readable + * label and is not used by the validator. + * + * The table heading row contains `C# property` to identify the table; that + * heading itself is skipped, as is the separator row of dashes. Section + * headings (`### \`IdleEvictionSeconds\``) are NOT table rows and are + * intentionally ignored even when they happen to look like a property name. + * + * @param {string} docContent - Raw .md file contents + * @returns {{names: string[], lineNumbersByName: Map}} + * The discovered property names in source order plus their 1-based line + * numbers in the doc file. + */ +function extractDocPropertyNames(docContent) { + const normalized = normalizeLineEndings(docContent); + const lines = normalized.split("\n"); + + const names = []; + const lineNumbersByName = new Map(); + + let inTable = false; + let csharpColumnIndex = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed.startsWith("|")) { + // Any non-table line breaks the current table region. + if (inTable) { + inTable = false; + csharpColumnIndex = -1; + } + continue; + } + + const cells = parseMarkdownTableRow(trimmed); + + if (!inTable) { + // First `|` line is the header. Look for the C# property column. + const headerIndex = cells.findIndex((cell) => /c#\s*property/i.test(cell)); + if (headerIndex !== -1) { + inTable = true; + csharpColumnIndex = headerIndex; + } + continue; + } + + // Skip separator rows like `| --- | --- |`. + if (cells.every((cell) => /^:?-{3,}:?$/.test(cell))) { + continue; + } + + if (csharpColumnIndex < 0 || csharpColumnIndex >= cells.length) { + continue; + } + + const rawCell = cells[csharpColumnIndex]; + const propertyName = stripBackticks(rawCell); + if (!propertyName) { + continue; + } + + // Defensive: only treat valid identifier-shaped cells as property names so + // a stray sentence in the table cannot count. + if (!/^[A-Za-z_]\w*$/.test(propertyName)) { + continue; + } + + if (!lineNumbersByName.has(propertyName)) { + names.push(propertyName); + lineNumbersByName.set(propertyName, i + 1); + } + } + + return { names, lineNumbersByName }; +} + +/** + * Split a markdown table row on `|`, dropping leading/trailing empties caused + * by the surrounding pipes. Cell contents are trimmed. + * + * @param {string} row + * @returns {string[]} + */ +function parseMarkdownTableRow(row) { + // Strip the leading and trailing pipe before splitting so empty leading/ + // trailing cells do not appear in the output. + let inner = row; + if (inner.startsWith("|")) { + inner = inner.slice(1); + } + if (inner.endsWith("|")) { + inner = inner.slice(0, -1); + } + return inner.split("|").map((cell) => cell.trim()); +} + +/** + * Remove a single matching pair of backticks around the cell content if + * present. The doc table author may write the property name as + * `IdleEvictionSeconds` or simply IdleEvictionSeconds. + * + * @param {string} value + * @returns {string} + */ +function stripBackticks(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (trimmed.startsWith("`") && trimmed.endsWith("`") && trimmed.length >= 2) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +/** + * Diff the C# property set against the doc property set. + * + * @param {string[]} sourceNames - Names extracted from the C# file + * @param {string[]} docNames - Names extracted from the doc file + * @returns {{missingInDoc: string[], extraInDoc: string[]}} + */ +function diffPropertySets(sourceNames, docNames) { + const docSet = new Set(docNames); + const sourceSet = new Set(sourceNames); + + const missingInDoc = sourceNames.filter((name) => !docSet.has(name)); + const extraInDoc = docNames.filter((name) => !sourceSet.has(name)); + + return { missingInDoc, extraInDoc }; +} + +/** + * Read a file from disk if it exists, returning a structured result rather + * than throwing so callers can produce a rich diagnostic. + * + * @param {string} filePath + * @returns {{ok: true, content: string} | {ok: false, message: string}} + */ +function readFileIfExists(filePath) { + try { + const content = fs.readFileSync(filePath, "utf8"); + return { ok: true, content }; + } catch (error) { + return { + ok: false, + message: + error && error.code === "ENOENT" + ? `File not found: ${filePath}` + : `Unable to read ${filePath}: ${(error && error.message) || error}` + }; + } +} + +/** + * Run the validator and return a structured result. The CLI entry point uses + * the `errors` array to drive its exit code. + * + * @param {object} [options] + * @param {string} [options.sourcePath] - Override the default C# file location + * @param {string} [options.docPath] - Override the default doc file location + * @returns {{ + * valid: boolean, + * errors: Array<{type: string, name?: string, message: string}>, + * sourceNames: string[], + * docNames: string[] + * }} + */ +function validate(options = {}) { + const sourcePath = options.sourcePath || DEFAULT_SOURCE_PATH; + const docPath = options.docPath || DEFAULT_DOC_PATH; + + const sourceRead = readFileIfExists(sourcePath); + if (!sourceRead.ok) { + return { + valid: false, + errors: [ + { + type: "parse-error", + message: + `${sourceRead.message}. ` + + `Add the C# settings file or fix the path. The validator expects ` + + `Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs at the repo root.` + } + ], + sourceNames: [], + docNames: [] + }; + } + + const { names: sourceNames } = extractPublicReadOnlyProperties(sourceRead.content); + if (sourceNames.length === 0) { + return { + valid: false, + errors: [ + { + type: "parse-error", + message: + `No public read-only properties found in ${sourcePath}. ` + + `Either the file shape changed or the regex needs an update.` + } + ], + sourceNames: [], + docNames: [] + }; + } + + const docRead = readFileIfExists(docPath); + if (!docRead.ok) { + return { + valid: false, + errors: [ + { + type: "parse-error", + message: + `${docRead.message}. ` + + `Create docs/reference/runtime-settings.md with a parameter table ` + + `whose 'C# property' column lists each public property of ` + + `DxMessagingRuntimeSettings.` + } + ], + sourceNames, + docNames: [] + }; + } + + const { names: docNames } = extractDocPropertyNames(docRead.content); + if (docNames.length === 0) { + return { + valid: false, + errors: [ + { + type: "parse-error", + message: + `No property rows found in ${docPath}. ` + + `The validator looks for a table with a 'C# property' column. ` + + `Confirm the table heading and that each row's C# property cell ` + + `is an identifier (optionally wrapped in backticks).` + } + ], + sourceNames, + docNames: [] + }; + } + + const { missingInDoc, extraInDoc } = diffPropertySets(sourceNames, docNames); + + const errors = []; + for (const name of missingInDoc) { + errors.push({ + type: "missing-doc-row", + name, + message: + `Public property '${name}' has no row in ${docPath}. ` + + `Add a row to the parameter table referencing '${name}' in the C# property column.` + }); + } + for (const name of extraInDoc) { + errors.push({ + type: "extra-doc-row", + name, + message: + `Doc table references '${name}' but no public property by that name exists in ${sourcePath}. ` + + `Either restore the C# property or remove the doc row.` + }); + } + + return { valid: errors.length === 0, errors, sourceNames, docNames }; +} + +/** + * Pretty-print the validation result. Returns the process exit code so the + * CLI entry point can use it directly. + * + * @param {ReturnType} result + * @param {{logger?: typeof console}} [options] + * @returns {number} + */ +function reportResult(result, options = {}) { + const logger = options.logger || console; + + if (result.valid) { + logger.log( + `validate-runtime-settings-docs: OK (${result.sourceNames.length} properties; ` + + `${result.docNames.length} doc rows match)` + ); + return 0; + } + + logger.log("validate-runtime-settings-docs: FAILED"); + for (const error of result.errors) { + logger.log(` - [${error.type}] ${error.message}`); + } + logger.log( + "Remediation: keep DxMessagingRuntimeSettings.cs and " + + "docs/reference/runtime-settings.md in lockstep. Update both in the same change." + ); + return 1; +} + +/** + * Parse CLI arguments. The validator currently accepts only `--check` (which + * is the same as default mode) and treats unknown flags as a usage error so + * a typo cannot silently disable the check. + * + * @param {string[]} argv + * @returns {{check: boolean, listProperties: boolean, help: boolean, errors: string[]}} + */ +function parseArgs(argv) { + const result = { check: false, listProperties: false, help: false, errors: [] }; + for (const arg of argv) { + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--list-properties") { + result.listProperties = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + continue; + } + result.errors.push(`Unknown argument: ${arg}`); + } + return result; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: node scripts/validate-runtime-settings-docs.js [--check] [--list-properties]\n" + + " --check Same as default; provided so CI scripts can declare intent.\n" + + " --list-properties Print 'source: A,B,C' and 'doc: A,B,D' for debugging." + ); + return 0; + } + if (args.errors.length > 0) { + for (const message of args.errors) { + console.error(message); + } + return 1; + } + + if (args.listProperties) { + const result = validate(); + console.log(`source: ${result.sourceNames.join(",")}`); + console.log(`doc: ${result.docNames.join(",")}`); + return 0; + } + + const result = validate(); + return reportResult(result); +} + +if (require.main === module) { + process.exitCode = main(); +} + +module.exports = { + REPO_ROOT, + DEFAULT_SOURCE_PATH, + DEFAULT_DOC_PATH, + PROPERTY_MODIFIERS, + TYPE_DECLARATION_KEYWORDS, + stripBom, + normalizeLineEndings, + stripStringsAndComments, + consumeTypeToken, + extractPropertyNameFromCandidate, + extractPublicReadOnlyProperties, + extractDocPropertyNames, + parseMarkdownTableRow, + stripBackticks, + diffPropertySets, + readFileIfExists, + validate, + reportResult, + parseArgs, + main +}; diff --git a/scripts/validate-runtime-settings-docs.js.meta b/scripts/validate-runtime-settings-docs.js.meta new file mode 100644 index 00000000..1ac37e18 --- /dev/null +++ b/scripts/validate-runtime-settings-docs.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b71 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-untracked-policy.js b/scripts/validate-untracked-policy.js new file mode 100644 index 00000000..c2b866fd --- /dev/null +++ b/scripts/validate-untracked-policy.js @@ -0,0 +1,521 @@ +#!/usr/bin/env node +/** + * @fileoverview Fails CI if `git` reports any untracked-and-unignored path at + * the repo level. Forces the contributor of a new tooling output directory to + * either commit it or add it to `.gitignore` (with a one-line rationale) and + * to update `.npmignore` if the path should not ship in the published + * package. + * + * The validator runs `git ls-files -z --others --exclude-standard` (NUL + * separator + `core.quotepath=false`) from the repo root via the project's + * `spawnPlatformCommandSync` helper. An empty result means a clean tree; any + * non-empty result fails by default. + * + * An emergency-override surface exists for local debugging only: + * - `--allow=` (repeatable) on the CLI, OR + * - `DX_UNTRACKED_ALLOW=` in the environment. + * CI invocations and the wired npm script must pass NO allowlist; the strict + * default is the whole point of the validator. + * + * Globs use a small inline matcher that supports `*` (single-segment) and + * a doubled-star recursive form (cross-separator). The two patterns we use + * in practice (`foo*`, `/.benchmark-*`) are well within this + * subset, and inlining a ~40-line matcher keeps the validator free of the + * `minimatch` runtime dependency. + * + * Path-resolution policy: + * The repo root is `path.resolve(__dirname, "..")` so the validator works + * whether invoked from the repo root or a subdirectory. Subprocess git + * invocations always set `cwd` to the resolved repo root so the result + * reflects the package's real working tree. + * + * Reporting policy (M4): + * When more than three untracked paths share a common first path segment + * (typically a build-output directory), the validator emits ONE error + * naming the directory plus a count instead of N separate errors. Three + * or fewer paths in a group are listed individually. + * + * Exit codes: + * 0 Clean working tree (no untracked-and-unignored paths or all matched + * by an allowlist entry). + * 1 Untracked path found that does not match any allowlist entry, or git + * was unavailable, or unknown CLI flag. + */ + +"use strict"; + +const path = require("path"); +const { spawnPlatformCommandSync } = require("./lib/shell-command"); + +const REPO_ROOT = path.resolve(__dirname, ".."); + +// Threshold above which a per-directory rollup replaces per-file errors. +// Three or fewer files in the same first-segment group are listed verbatim; +// four or more roll up into a single directory-level diagnostic. +const ROLLUP_THRESHOLD = 3; + +/** + * Compile a glob pattern into a `RegExp`. Supports `*` (matches a single path + * segment without separators) and the doubled-star recursive form (matches + * across separators including empty segments). The matcher is intentionally + * minimal so the two patterns the project uses (`foo*`, recursive + * `/...` forms, and `dir/`) Just Work without + * pulling in `minimatch`. + * + * @param {string} pattern + * @returns {RegExp} + */ +function compileGlob(pattern) { + // Escape regex meta-characters except those we explicitly handle (`*`). + const specials = /[.+?^${}()|[\]\\]/g; + let regex = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*") { + if (pattern[i + 1] === "*") { + // `**` matches across separators (including empty). Trailing `/` is + // consumed when present so `dir/**` matches `dir/x` and `dir/x/y`. + if (pattern[i + 2] === "/") { + regex += "(?:.*/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + continue; + } + regex += "[^/]*"; + i += 1; + continue; + } + if (ch === "?") { + regex += "[^/]"; + i += 1; + continue; + } + regex += ch.replace(specials, "\\$&"); + i += 1; + } + return new RegExp(`^${regex}$`); +} + +/** + * Match a candidate path against an allowlist of glob patterns. + * + * @param {string} candidate - Repo-relative POSIX path + * @param {string[]} allowList + * @returns {boolean} + */ +function isAllowed(candidate, allowList) { + if (!allowList || allowList.length === 0) { + return false; + } + for (const pattern of allowList) { + if (compileGlob(pattern).test(candidate)) { + return true; + } + } + return false; +} + +/** + * Convert NUL-terminated `git ls-files -z` output into an array of paths. + * The `-z` flag avoids any quoting, so non-ASCII bytes survive intact and + * paths containing whitespace or special characters are split correctly. + * + * @param {string|Buffer} stdout + * @returns {string[]} + */ +function parseUntrackedOutput(stdout) { + if (stdout == null) { + return []; + } + const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : String(stdout); + if (text.length === 0) { + return []; + } + return text + .split("\0") + .map((entry) => entry.replace(/\r/g, "").trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Parse `--allow=` and `--allow ` flag forms into a flat array. + * Returns `errors` for any unknown argument so a typo cannot accidentally + * disable the check. + * + * @param {string[]} argv + * @returns {{allow: string[], help: boolean, errors: string[]}} + */ +function parseArgs(argv) { + const allow = []; + const errors = []; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } + + if (arg.startsWith("--allow=")) { + const value = arg.slice("--allow=".length); + if (value.length > 0) { + allow.push(value); + } + continue; + } + + if (arg === "--allow") { + const next = argv[i + 1]; + if (typeof next !== "string" || next.length === 0) { + errors.push("--allow requires a glob pattern argument"); + continue; + } + allow.push(next); + i += 1; + continue; + } + + errors.push(`Unknown argument: ${arg}`); + } + + return { allow, help, errors }; +} + +/** + * Read the env-var override into a list of globs. + * + * @param {string|undefined} value - Raw env-var value + * @returns {string[]} + */ +function parseEnvAllowList(value) { + if (typeof value !== "string" || value.length === 0) { + return []; + } + // Use the OS-natural list separator. POSIX uses `:`; Windows uses `;`. + // Both are accepted regardless of platform so a contributor copying a + // value across machines does not have to remember which one to use. + return value + .split(/[:;]/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Invoke `git -c core.quotepath=false ls-files -z --others + * --exclude-standard` and return the parsed output. The `-z` flag uses NUL + * terminators that survive any byte content; pairing it with + * `core.quotepath=false` (defense in depth) guarantees the validator sees + * the same bytes git stored in the index. + * + * Failure to invoke git is treated as a hard error per `.llm/context.md` + * policy: validators MUST NOT silently default to permissive behavior when + * git metadata is unavailable. + * + * @param {object} [options] + * @param {string} [options.cwd] - Override the cwd (used by tests) + * @param {Function} [options.spawn] - Inject a spawn implementation for tests + * @returns {{ok: true, files: string[]} | {ok: false, type: string, message: string}} + */ +function listUntrackedFiles(options = {}) { + const cwd = options.cwd || REPO_ROOT; + const spawn = options.spawn || spawnPlatformCommandSync; + + const result = spawn( + "git", + ["-c", "core.quotepath=false", "ls-files", "-z", "--others", "--exclude-standard"], + { + cwd, + // We request a Buffer for stdout so `-z`'s NUL bytes survive, but pass + // utf8 for stderr so error messages render. Spawn helpers that ignore + // the encoding split still receive the same arguments. + encoding: "buffer", + stdio: ["ignore", "pipe", "pipe"] + } + ); + + if (result && result.error) { + if (result.error.code === "ENOENT") { + return { + ok: false, + type: "git-not-installed", + message: + "git was not found on PATH. Install git or run this validator from a shell where git is available." + }; + } + return { + ok: false, + type: "git-spawn-error", + message: `Failed to spawn git: ${result.error.message || result.error}` + }; + } + + if (!result || typeof result.status !== "number") { + return { + ok: false, + type: "git-spawn-error", + message: "git ls-files produced no result object" + }; + } + + if (result.status !== 0) { + const stderrRaw = result.stderr; + const stderr = + stderrRaw == null + ? "" + : Buffer.isBuffer(stderrRaw) + ? stderrRaw.toString("utf8") + : String(stderrRaw); + if (/not a git repository/i.test(stderr)) { + return { + ok: false, + type: "not-a-git-repository", + message: + `Not a git repository (cwd=${cwd}). ` + + `validate-untracked-policy must run inside a git working tree so ` + + `it can enumerate untracked-and-unignored paths.` + }; + } + return { + ok: false, + type: "git-exit-error", + message: `git ls-files exited with status ${result.status}: ${stderr.trim() || "no stderr"}` + }; + } + + return { ok: true, files: parseUntrackedOutput(result.stdout) }; +} + +/** + * Group untracked paths by their first path segment so a directory-level + * rollup can replace N per-file errors with one diagnostic. Paths with no + * separator are placed in a synthetic `__root__` bucket and listed + * individually. + * + * @param {string[]} paths + * @returns {{singletons: string[], groups: Array<{prefix: string, files: string[]}>}} + */ +function groupByFirstSegment(paths) { + const buckets = new Map(); + const rootSingletons = []; + + for (const file of paths) { + const slashIdx = file.indexOf("/"); + if (slashIdx <= 0) { + // No directory prefix; track separately so it is always reported as + // an individual file. + rootSingletons.push(file); + continue; + } + const prefix = file.slice(0, slashIdx); + if (!buckets.has(prefix)) { + buckets.set(prefix, []); + } + buckets.get(prefix).push(file); + } + + const groups = []; + const singletons = rootSingletons.slice(); + for (const [prefix, files] of buckets.entries()) { + if (files.length > ROLLUP_THRESHOLD) { + groups.push({ prefix, files }); + } else { + singletons.push(...files); + } + } + + // Stable order: singletons by path, groups by prefix. + singletons.sort(); + groups.sort((a, b) => a.prefix.localeCompare(b.prefix)); + return { singletons, groups }; +} + +/** + * Build the per-file remediation message used by both individual paths and + * directory rollups. Mentions BOTH `.gitignore` AND `.npmignore` because a + * tooling-output directory typically needs to be excluded from BOTH. + * + * @param {string} pathOrDir + * @param {boolean} isDirectory + * @param {number} [count] + * @returns {string} + */ +function buildRemediationMessage(pathOrDir, isDirectory, count) { + if (isDirectory) { + const trailing = pathOrDir.endsWith("/") ? pathOrDir : `${pathOrDir}/`; + return ( + `Untracked-and-unignored directory '${trailing}' contains ${count} files. ` + + `Either commit them or add '${trailing}' to .gitignore ` + + `(and .npmignore if the directory should not ship in the published package). ` + + `For intentionally-local paths, add a one-line comment in your .gitignore explaining why.` + ); + } + return ( + `Untracked-and-unignored path '${pathOrDir}'. ` + + `Either commit it or add it to .gitignore ` + + `(and .npmignore if the path should not ship in the published package). ` + + `If it is intentionally local, add a one-line comment in your .gitignore explaining why.` + ); +} + +/** + * Run the validator and return a structured result. + * + * @param {object} [options] + * @param {string[]} [options.allow] - CLI-supplied allowlist globs + * @param {string[]} [options.envAllow] - Env-var-supplied allowlist globs + * @param {string} [options.cwd] - Override repo root + * @param {Function} [options.spawn] - Inject spawn implementation + * @returns {{ + * valid: boolean, + * errors: Array<{type: string, file?: string, directory?: string, count?: number, files?: string[], message: string}>, + * untracked: string[], + * ignoredByAllowlist: string[] + * }} + */ +function validate(options = {}) { + const allowList = [...(options.allow || []), ...(options.envAllow || [])]; + const list = listUntrackedFiles({ cwd: options.cwd, spawn: options.spawn }); + + if (!list.ok) { + return { + valid: false, + errors: [{ type: list.type, message: list.message }], + untracked: [], + ignoredByAllowlist: [] + }; + } + + if (list.files.length === 0) { + return { valid: true, errors: [], untracked: [], ignoredByAllowlist: [] }; + } + + const violations = []; + const ignoredByAllowlist = []; + + // Apply the allowlist first so a per-file allow can suppress an entry that + // would otherwise contribute to the rollup count. + const remaining = []; + for (const file of list.files) { + if (isAllowed(file, allowList)) { + ignoredByAllowlist.push(file); + continue; + } + remaining.push(file); + } + + const { singletons, groups } = groupByFirstSegment(remaining); + + for (const file of singletons) { + violations.push({ + type: "untracked-path", + file, + message: buildRemediationMessage(file, false) + }); + } + + for (const group of groups) { + violations.push({ + type: "untracked-directory", + directory: group.prefix, + count: group.files.length, + files: group.files, + message: buildRemediationMessage(group.prefix, true, group.files.length) + }); + } + + return { + valid: violations.length === 0, + errors: violations, + untracked: list.files, + ignoredByAllowlist + }; +} + +/** + * Pretty-print the validation result. Returns the process exit code. + * + * @param {ReturnType} result + * @param {{logger?: typeof console}} [options] + * @returns {number} + */ +function reportResult(result, options = {}) { + const logger = options.logger || console; + + if (result.valid && result.untracked.length === 0) { + logger.log("validate-untracked-policy: OK (no untracked-and-unignored paths)"); + return 0; + } + + if (result.valid && result.ignoredByAllowlist.length > 0) { + logger.log( + `validate-untracked-policy: OK (${result.ignoredByAllowlist.length} ` + + `untracked path(s) matched the allowlist; the strict default for CI is no allowlist)` + ); + return 0; + } + + logger.log("validate-untracked-policy: FAILED"); + for (const error of result.errors) { + if (error.type === "untracked-directory") { + logger.log(` - [${error.type}] ${error.directory}/ (${error.count} files): ${error.message}`); + } else if (error.file) { + logger.log(` - [${error.type}] ${error.file}: ${error.message}`); + } else { + logger.log(` - [${error.type}] ${error.message}`); + } + } + logger.log( + "Remediation: each untracked path must be either committed or covered by " + + ".gitignore (and .npmignore if it should not ship), with a one-line " + + "rationale comment for intentionally-local paths." + ); + return 1; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: node scripts/validate-untracked-policy.js [--allow=...] \n" + + "Env override (debugging only): DX_UNTRACKED_ALLOW=:...\n" + + "Strict default: any untracked-and-unignored path fails the run." + ); + return 0; + } + if (args.errors.length > 0) { + for (const message of args.errors) { + console.error(message); + } + return 1; + } + + const envAllow = parseEnvAllowList(process.env.DX_UNTRACKED_ALLOW); + const result = validate({ allow: args.allow, envAllow }); + return reportResult(result); +} + +if (require.main === module) { + process.exitCode = main(); +} + +module.exports = { + REPO_ROOT, + ROLLUP_THRESHOLD, + compileGlob, + buildRemediationMessage, + groupByFirstSegment, + parseArgs, + parseEnvAllowList, + parseUntrackedOutput, + isAllowed, + listUntrackedFiles, + validate, + reportResult, + main +}; diff --git a/scripts/validate-untracked-policy.js.meta b/scripts/validate-untracked-policy.js.meta new file mode 100644 index 00000000..71a06ae1 --- /dev/null +++ b/scripts/validate-untracked-policy.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a7c3e1b5d2f4a6e8b0c1d2e3f4a5b72 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 25f4f351cea1500af1699e052e37851861e356d2 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 16:36:25 -0700 Subject: [PATCH 13/16] Fix cross-platform scrips --- .github/workflows/script-tests.yml | 17 +- .llm/context.md | 2 + .pre-commit-config.yaml | 3 +- package.json | 1 + .../fix-csharp-underscore-methods.test.js | 943 +++++++++--------- ...nity-perf-baseline-script-contract.test.js | 76 +- .../unity-runner-script-contract.test.js | 23 +- scripts/unity/activate-license.sh | 0 scripts/unity/run-tests.sh | 0 9 files changed, 563 insertions(+), 502 deletions(-) mode change 100644 => 100755 scripts/unity/activate-license.sh mode change 100644 => 100755 scripts/unity/run-tests.sh diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml index 13955d0f..5450e700 100644 --- a/.github/workflows/script-tests.yml +++ b/.github/workflows/script-tests.yml @@ -3,7 +3,10 @@ name: Script Tests on: pull_request: paths: + - .github/workflows/script-tests.yml - package.json + - scripts/**/*.ps1 + - scripts/**/*.sh - scripts/**/*.js - scripts/__tests__/**/*.js push: @@ -11,7 +14,10 @@ on: - main - master paths: + - .github/workflows/script-tests.yml - package.json + - scripts/**/*.ps1 + - scripts/**/*.sh - scripts/**/*.js - scripts/__tests__/**/*.js workflow_dispatch: @@ -25,9 +31,15 @@ permissions: jobs: test: - name: Run script tests - runs-on: ubuntu-latest + name: Run script tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest steps: - name: Checkout repository @@ -44,6 +56,7 @@ jobs: cache-dependency-path: package.json - name: Install dependencies + shell: bash run: | if [ -f package-lock.json ]; then npm ci diff --git a/.llm/context.md b/.llm/context.md index 0c1fa244..f00be08f 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -44,6 +44,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Validate pre-commit Node tooling policy: `npm run validate:pre-commit-tooling` - Pre-commit Node tooling preflight: `npm run preflight:pre-commit` - Validate local Node tool dependency health: `npm run validate:node-tooling` +- Run Unity/devcontainer contract tests: `npm run test:unity-contracts` - Run markdown hook parity check: `npm run validate:hook-markdown` - Run parser hook suite exactly as pre-push executes it: `pre-commit run --hook-stage pre-push script-parser-tests --all-files` - Check package.json format explicitly: `npm run check:package-json-format` @@ -110,6 +111,7 @@ The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outsid - When editing `scripts/validate-npm-meta.js`, `scripts/__tests__/validate-npm-meta.test.js`, or npm package metadata, run `npm run validate:npm-meta` before finishing. - When editing `scripts/fix-csharp-underscore-methods.js` or its tests, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/fix-csharp-underscore-methods.test.js` and then `npm run preflight:pre-commit` before finishing. - For parser-script failures, verify both isolated and hook-parity execution before concluding root cause: run the focused Jest path first, then run `pre-commit run --hook-stage pre-push script-parser-tests --all-files` from the same shell used for commit operations. +- For Unity runner or perf-baseline script failures, run `npm run test:unity-contracts` before hook parity checks. On Windows, keep fake command shims platform-native (`.cmd` wrappers for PATH-resolved tools) and verify executable shell entrypoints with `git ls-files --stage` because NTFS mode bits are not the repository contract. - When editing `.pre-commit-config.yaml` or `scripts/validate-pre-commit-tooling.js`, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/pre-commit-hook-stage-policy.test.js scripts/__tests__/validate-pre-commit-tooling.test.js` before `npm run preflight:pre-commit`. - On Windows, verify `npm --version` in the active shell before running hook-related checks (especially when using nvm/fnm). - On Windows hosts, run `npm run preflight:pre-commit` in the same shell you use for `git commit` so hook PATH/init, npm version drift, package.json formatting, and yamllint issues are caught before commit. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eca6b598..d00305e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -409,6 +409,7 @@ repos: - --runTestsByPath - scripts/__tests__/unity-test-harness-contract.test.js - scripts/__tests__/unity-runner-script-contract.test.js + - scripts/__tests__/unity-perf-baseline-script-contract.test.js - scripts/__tests__/devcontainer-cache-contract.test.js - scripts/__tests__/unity-workflow-shape.test.js - scripts/__tests__/unity-perf-isolation.test.js @@ -417,7 +418,7 @@ repos: language: system pass_filenames: false files: >- - ^(\.devcontainer/|\.unity-test-project/|scripts/unity/|\.github/workflows/unity-|\.github/workflows/devcontainer-|\.claude/settings\.local\.json|\.llm/skills/unity/|\.llm/skills/github-actions/cicd-devcontainer-workflows\.md|\.llm/context\.md|Tests/.+/.+\.asmdef$) + ^(\.pre-commit-config\.yaml|package\.json|\.devcontainer/|\.unity-test-project/|scripts/unity/|scripts/__tests__/(unity-test-harness-contract|unity-runner-script-contract|unity-perf-baseline-script-contract|devcontainer-cache-contract|unity-workflow-shape|unity-perf-isolation|claude-permissions-contract|llm-skills-unity-coverage)\.test\.js|\.github/workflows/(script-tests|unity-|devcontainer-)|\.claude/settings\.local\.json|\.llm/skills/unity/|\.llm/skills/github-actions/cicd-devcontainer-workflows\.md|\.llm/context\.md|Tests/.+/.+\.asmdef$) stages: - pre-push description: >- diff --git a/package.json b/package.json index b9fb9191..7bcb3046 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "test": "node scripts/run-managed-jest.js", "test:scripts": "node scripts/run-managed-jest.js", "test:llms-txt": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/update-llms-txt.test.js", + "test:unity-contracts": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/unity-test-harness-contract.test.js scripts/__tests__/unity-runner-script-contract.test.js scripts/__tests__/unity-perf-baseline-script-contract.test.js scripts/__tests__/devcontainer-cache-contract.test.js scripts/__tests__/unity-workflow-shape.test.js scripts/__tests__/unity-perf-isolation.test.js scripts/__tests__/claude-permissions-contract.test.js scripts/__tests__/llm-skills-unity-coverage.test.js", "test:watch": "node scripts/run-managed-jest.js --watch", "test:coverage": "node scripts/run-managed-jest.js --coverage", "format:md": "node scripts/run-managed-prettier.js --write \"**/*.{md,markdown}\"", diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js index db301677..5e41c056 100644 --- a/scripts/__tests__/fix-csharp-underscore-methods.test.js +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -6,506 +6,497 @@ const path = require("path"); const childProcess = require("child_process"); const { - isCsharpSourceFile, - normalizeExplicitPathArg, - toWindowsAbsolutePathFromPosixDrivePath, - resolveCandidatePath, - convertMethodNameToPascalCase, - collectMethodRenames, - applyMethodRenames, + isCsharpSourceFile, + normalizeExplicitPathArg, + toWindowsAbsolutePathFromPosixDrivePath, + resolveCandidatePath, + convertMethodNameToPascalCase, + collectMethodRenames, + applyMethodRenames } = require("../fix-csharp-underscore-methods.js"); const FIXER_SCRIPT_PATH = path.resolve(__dirname, "../fix-csharp-underscore-methods.js"); const OUTSIDE_REPO_EXCLUDED_SEGMENTS = [ - ".git", - "node_modules", - "Library", - "Obj", - "Temp", - ".vs", - ".venv", - ".artifacts", - "site", + ".git", + "node_modules", + "Library", + "Obj", + "Temp", + ".vs", + ".venv", + ".artifacts", + "site" ]; -describe("fix-csharp-underscore-methods", () => { - test("isCsharpSourceFile supports case-insensitive .cs and rejects .meta", () => { - expect(isCsharpSourceFile("Runtime/FixMe.cs")).toBe(true); - expect(isCsharpSourceFile("Runtime/FixMe.CS")).toBe(true); - expect(isCsharpSourceFile("Runtime/FixMe.cs.meta")).toBe(false); - expect(isCsharpSourceFile("Runtime/FixMe.txt")).toBe(false); - }); - - test("normalizeExplicitPathArg trims quotes/whitespace and carriage returns", () => { - expect(normalizeExplicitPathArg(' "C:/Temp/FixMe.cs"\r ')).toBe("C:/Temp/FixMe.cs"); - expect(normalizeExplicitPathArg(" 'C:/Temp/FixMe.cs'\r\r")).toBe("C:/Temp/FixMe.cs"); - expect(normalizeExplicitPathArg("\r")).toBe(""); - }); - - test("toWindowsAbsolutePathFromPosixDrivePath converts Git-Bash style paths", () => { - expect(toWindowsAbsolutePathFromPosixDrivePath("/c/Users/dev/FixMe.cs")).toBe( - "C:\\Users\\dev\\FixMe.cs" - ); - expect(toWindowsAbsolutePathFromPosixDrivePath("/z/tmp/project/File.CS")).toBe( - "Z:\\tmp\\project\\File.CS" - ); - expect(toWindowsAbsolutePathFromPosixDrivePath("/tmp/file.cs")).toBe(""); - }); +function makeTempGitRepo(label) { + const tempRepo = fs.mkdtempSync(path.join(os.tmpdir(), `dxmsg-csharp-underscore-${label}-`)); + const initResult = childProcess.spawnSync("git", ["init", "-q"], { + cwd: tempRepo, + encoding: "utf8" + }); - test("resolveCandidatePath falls back to converted win32 path when direct resolve misses", () => { - const result = resolveCandidatePath("C:\\repo", "/c/Users/dev/FixMe.CS", { - platform: "win32", - existsSync: (candidatePath) => candidatePath === "C:\\Users\\dev\\FixMe.CS", - }); + expect(initResult.status).toBe(0); + return tempRepo; +} - expect(result).toBe("C:\\Users\\dev\\FixMe.CS"); - }); +describe("fix-csharp-underscore-methods", () => { + test("isCsharpSourceFile supports case-insensitive .cs and rejects .meta", () => { + expect(isCsharpSourceFile("Runtime/FixMe.cs")).toBe(true); + expect(isCsharpSourceFile("Runtime/FixMe.CS")).toBe(true); + expect(isCsharpSourceFile("Runtime/FixMe.cs.meta")).toBe(false); + expect(isCsharpSourceFile("Runtime/FixMe.txt")).toBe(false); + }); + + test("normalizeExplicitPathArg trims quotes/whitespace and carriage returns", () => { + expect(normalizeExplicitPathArg(' "C:/Temp/FixMe.cs"\r ')).toBe("C:/Temp/FixMe.cs"); + expect(normalizeExplicitPathArg(" 'C:/Temp/FixMe.cs'\r\r")).toBe("C:/Temp/FixMe.cs"); + expect(normalizeExplicitPathArg("\r")).toBe(""); + }); + + test("toWindowsAbsolutePathFromPosixDrivePath converts Git-Bash style paths", () => { + expect(toWindowsAbsolutePathFromPosixDrivePath("/c/Users/dev/FixMe.cs")).toBe( + "C:\\Users\\dev\\FixMe.cs" + ); + expect(toWindowsAbsolutePathFromPosixDrivePath("/z/tmp/project/File.CS")).toBe( + "Z:\\tmp\\project\\File.CS" + ); + expect(toWindowsAbsolutePathFromPosixDrivePath("/tmp/file.cs")).toBe(""); + }); - test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { - expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); - expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); - expect(convertMethodNameToPascalCase("__parse__line__")).toBe("ParseLine"); - expect(convertMethodNameToPascalCase("Parse__Line")).toBe("ParseLine"); + test("resolveCandidatePath falls back to converted win32 path when direct resolve misses", () => { + const result = resolveCandidatePath("C:\\repo", "/c/Users/dev/FixMe.CS", { + platform: "win32", + existsSync: (candidatePath) => candidatePath === "C:\\Users\\dev\\FixMe.CS" }); - test("collectMethodRenames finds method declarations and skips op_ names", () => { - const source = [ - "public sealed class NamingTests", + expect(result).toBe("C:\\Users\\dev\\FixMe.CS"); + }); + + test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { + expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); + expect(convertMethodNameToPascalCase("__parse__line__")).toBe("ParseLine"); + expect(convertMethodNameToPascalCase("Parse__Line")).toBe("ParseLine"); + }); + + test("collectMethodRenames finds method declarations and skips op_ names", () => { + const source = [ + "public sealed class NamingTests", + "{", + " [Test]", + " public void Parse_Line_Bare() { }", + "", + " private static IEnumerable Edge_Case_Test_Data() => Array.Empty();", + "", + " public void op_Custom_Method() { }", + "}" + ].join("\n"); + + const renames = collectMethodRenames(source); + + expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(renames.get("Edge_Case_Test_Data")).toBe("EdgeCaseTestData"); + expect(renames.has("op_Custom_Method")).toBe(false); + }); + + test("collectMethodRenames handles underscore return types and generic signatures", () => { + const source = [ + "public sealed class SignatureTests", + "{", + " private Custom_Type Parse_Line_Bare() => default;", + " private System.Collections.Generic.Dictionary Build_Map_Data() => default;", + "}" + ].join("\n"); + + const renames = collectMethodRenames(source); + + expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(renames.get("Build_Map_Data")).toBe("BuildMapData"); + }); + + test("applyMethodRenames updates declarations and nameof references", () => { + const source = [ + "public sealed class NamingTests", + "{", + " [Test]", + " public void Parse_Line_Bare()", + " {", + " string methodName = nameof(Parse_Line_Bare);", + " Parse_Line_Bare();", + " }", + "}" + ].join("\n"); + + const renames = new Map([["Parse_Line_Bare", "ParseLineBare"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("public void ParseLineBare()"); + expect(result.updatedContent).toContain("nameof(ParseLineBare)"); + expect(result.updatedContent).toContain("ParseLineBare();"); + }); + + test("applyMethodRenames counts unique renamed identifiers, not total occurrences", () => { + const source = [ + "public sealed class NamingTests", + "{", + " public void Method_Name()", + " {", + " Method_Name();", + " Method_Name();", + " }", + "}" + ].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).not.toContain("Method_Name"); + }); + + test("applyMethodRenames updates identifiers at the start of content", () => { + const source = ["Method_Name();", "nameof(Method_Name);"].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).toContain("nameof(MethodName);"); + }); + + test("applyMethodRenames does not rename identifier substrings", () => { + const source = [ + "public sealed class NamingTests", + "{", + " public void Method_Name()", + " {", + " Method_Name();", + " Method_Name_Helper();", + " }", + "}" + ].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).toContain("Method_Name_Helper();"); + }); + + test("--check exits non-zero when a fix is required", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-check-")); + const filePath = path.join(tempDir, "NeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + ["public sealed class NeedsFix", "{", " public void Parse_Line_Bare() { }", "}"].join( + "\n" + ), + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("NeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites file content in place", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-fix-")); + const filePath = path.join(tempDir, "FixMe.cs"); + + try { + fs.writeFileSync( + filePath, + ["public sealed class FixMe", "{", " public void Parse_Line_Bare() { }", "}"].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + }); + + expect(result.status).toBe(0); + + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("--check handles explicit file args that include trailing carriage returns", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-carriage-return-arguments-") + ); + const filePath = path.join(tempDir, "NeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + ["public sealed class NeedsFix", "{", " public void Parse_Line_Bare() { }", "}"].join( + "\n" + ), + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", `${filePath}\r`], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("NeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites uppercase .CS files", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-upper-ext-")); + const filePath = path.join(tempDir, "FixMe.CS"); + + try { + fs.writeFileSync( + filePath, + ["public sealed class FixMe", "{", " public void Parse_Line_Bare() { }", "}"].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + }); + + expect(result.status).toBe(0); + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites CRLF content without losing line ending style", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-crlf-")); + const filePath = path.join(tempDir, "CrlfFix.cs"); + + try { + fs.writeFileSync( + filePath, + ["public sealed class CrlfFix", "{", " public void Parse_Line_Bare() { }", "}"].join( + "\r\n" + ), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + }); + + expect(result.status).toBe(0); + + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).toContain("\r\n"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "--check processes explicitly passed files in outside-repo %s segment", + (excludedSegment) => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-check-") + ); + const outsideSegmentDir = path.join(tempDir, excludedSegment); + const filePath = path.join(outsideSegmentDir, "OutsideSegmentNeedsFix.cs"); + + try { + fs.mkdirSync(outsideSegmentDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideSegmentNeedsFix", "{", - " [Test]", " public void Parse_Line_Bare() { }", - "", - " private static IEnumerable Edge_Case_Test_Data() => Array.Empty();", - "", - " public void op_Custom_Method() { }", - "}", - ].join("\n"); - - const renames = collectMethodRenames(source); - - expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); - expect(renames.get("Edge_Case_Test_Data")).toBe("EdgeCaseTestData"); - expect(renames.has("op_Custom_Method")).toBe(false); - }); - - test("collectMethodRenames handles underscore return types and generic signatures", () => { - const source = [ - "public sealed class SignatureTests", - "{", - " private Custom_Type Parse_Line_Bare() => default;", - " private System.Collections.Generic.Dictionary Build_Map_Data() => default;", - "}", - ].join("\n"); - - const renames = collectMethodRenames(source); - - expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); - expect(renames.get("Build_Map_Data")).toBe("BuildMapData"); - }); - - test("applyMethodRenames updates declarations and nameof references", () => { - const source = [ - "public sealed class NamingTests", - "{", - " [Test]", - " public void Parse_Line_Bare()", - " {", - " string methodName = nameof(Parse_Line_Bare);", - " Parse_Line_Bare();", - " }", - "}", - ].join("\n"); - - const renames = new Map([["Parse_Line_Bare", "ParseLineBare"]]); - const result = applyMethodRenames(source, renames); - - expect(result.renameCount).toBe(1); - expect(result.updatedContent).toContain("public void ParseLineBare()"); - expect(result.updatedContent).toContain("nameof(ParseLineBare)"); - expect(result.updatedContent).toContain("ParseLineBare();"); - }); - - test("applyMethodRenames counts unique renamed identifiers, not total occurrences", () => { - const source = [ - "public sealed class NamingTests", - "{", - " public void Method_Name()", - " {", - " Method_Name();", - " Method_Name();", - " }", - "}", - ].join("\n"); - - const renames = new Map([["Method_Name", "MethodName"]]); - const result = applyMethodRenames(source, renames); - - expect(result.renameCount).toBe(1); - expect(result.updatedContent).toContain("MethodName();"); - expect(result.updatedContent).not.toContain("Method_Name"); - }); - - test("applyMethodRenames updates identifiers at the start of content", () => { - const source = [ - "Method_Name();", - "nameof(Method_Name);", - ].join("\n"); - - const renames = new Map([["Method_Name", "MethodName"]]); - const result = applyMethodRenames(source, renames); + "}" + ].join("\n"), + "utf8" + ); - expect(result.renameCount).toBe(1); - expect(result.updatedContent).toContain("MethodName();"); - expect(result.updatedContent).toContain("nameof(MethodName);"); - }); + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + } + ); - test("applyMethodRenames does not rename identifier substrings", () => { - const source = [ - "public sealed class NamingTests", + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("OutsideSegmentNeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + ); + + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "CLI rewrites explicitly passed files in outside-repo %s segment", + (excludedSegment) => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-rewrite-") + ); + const outsideSegmentDir = path.join(tempDir, excludedSegment); + const filePath = path.join(outsideSegmentDir, "OutsideSegmentRewrite.cs"); + + try { + fs.mkdirSync(outsideSegmentDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideSegmentRewrite", "{", - " public void Method_Name()", - " {", - " Method_Name();", - " Method_Name_Helper();", - " }", - "}", - ].join("\n"); - - const renames = new Map([["Method_Name", "MethodName"]]); - const result = applyMethodRenames(source, renames); - - expect(result.renameCount).toBe(1); - expect(result.updatedContent).toContain("MethodName();"); - expect(result.updatedContent).toContain("Method_Name_Helper();"); - }); - - test("--check exits non-zero when a fix is required", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-check-")); - const filePath = path.join(tempDir, "NeedsFix.cs"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class NeedsFix", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", filePath], - { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - } - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Found C# methods with underscores"); - expect(result.stderr).toContain("NeedsFix.cs"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); + " public void Parse_Line_Bare() { }", + "}" + ].join("\n"), + "utf8" + ); - test("CLI rewrites file content in place", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-fix-")); - const filePath = path.join(tempDir, "FixMe.cs"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class FixMe", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - }); - - expect(result.status).toBe(0); - - const updated = fs.readFileSync(filePath, "utf8"); - expect(updated).toContain("ParseLineBare"); - expect(updated).not.toContain("Parse_Line_Bare"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8" + }); - test("--check handles explicit file args that include trailing carriage returns", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-carriage-return-arguments-")); - const filePath = path.join(tempDir, "NeedsFix.cs"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class NeedsFix", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", `${filePath}\r`], - { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - } - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("NeedsFix.cs"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); + expect(result.status).toBe(0); - test("CLI rewrites uppercase .CS files", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-upper-ext-")); - const filePath = path.join(tempDir, "FixMe.CS"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class FixMe", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - }); - - expect(result.status).toBe(0); - const updated = fs.readFileSync(filePath, "utf8"); - expect(updated).toContain("ParseLineBare"); - expect(updated).not.toContain("Parse_Line_Bare"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + ); - test("CLI rewrites CRLF content without losing line ending style", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-crlf-")); - const filePath = path.join(tempDir, "CrlfFix.cs"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class CrlfFix", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\r\n"), - "utf8" - ); - - const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - }); - - expect(result.status).toBe(0); - - const updated = fs.readFileSync(filePath, "utf8"); - expect(updated).toContain("ParseLineBare"); - expect(updated).toContain("\r\n"); - expect(updated).not.toContain("Parse_Line_Bare"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + test("--check processes explicitly passed relative paths outside the repo", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-relative-check-") + ); + const filePath = path.join(tempDir, "OutsideRelativeNeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideRelativeNeedsFix", + "{", + " public void Parse_Line_Bare() { }", + "}" + ].join("\n"), + "utf8" + ); + + const repoRoot = path.resolve(__dirname, "../.."); + const relativeOutsidePath = path.relative(repoRoot, filePath); + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", relativeOutsidePath], + { + cwd: repoRoot, + encoding: "utf8" } - }); + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("OutsideRelativeNeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "explicitly passed files in repo-internal %s segment remain skipped", + (excludedSegment) => { + const repoRoot = makeTempGitRepo("repo-excluded"); + const repoInternalExcludedRoot = path.join( + repoRoot, + "Tests", + "dxmsg-csharp-underscore-repo-excluded" + ); + const excludedDir = path.join(repoInternalExcludedRoot, excludedSegment, "nested"); + const filePath = path.join(excludedDir, "RepoExcludedSkip.cs"); + + try { + fs.mkdirSync(excludedDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class RepoExcludedSkip", + "{", + " public void Parse_Line_Bare() { }", + "}" + ].join("\n"), + "utf8" + ); - test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( - "--check processes explicitly passed files in outside-repo %s segment", - (excludedSegment) => { - const tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-check-") - ); - const outsideSegmentDir = path.join(tempDir, excludedSegment); - const filePath = path.join(outsideSegmentDir, "OutsideSegmentNeedsFix.cs"); - - try { - fs.mkdirSync(outsideSegmentDir, { recursive: true }); - fs.writeFileSync( - filePath, - [ - "public sealed class OutsideSegmentNeedsFix", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", filePath], - { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - } - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Found C# methods with underscores"); - expect(result.stderr).toContain("OutsideSegmentNeedsFix.cs"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - } - ); + const checkResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: repoRoot, + encoding: "utf8" + } + ); - test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( - "CLI rewrites explicitly passed files in outside-repo %s segment", - (excludedSegment) => { - const tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-rewrite-") - ); - const outsideSegmentDir = path.join(tempDir, excludedSegment); - const filePath = path.join(outsideSegmentDir, "OutsideSegmentRewrite.cs"); - - try { - fs.mkdirSync(outsideSegmentDir, { recursive: true }); - fs.writeFileSync( - filePath, - [ - "public sealed class OutsideSegmentRewrite", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - }); - - expect(result.status).toBe(0); - - const updated = fs.readFileSync(filePath, "utf8"); - expect(updated).toContain("ParseLineBare"); - expect(updated).not.toContain("Parse_Line_Bare"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - } - ); + expect(checkResult.status).toBe(0); + expect(checkResult.stdout).toContain("No C# files to process."); - test("--check processes explicitly passed relative paths outside the repo", () => { - const tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-relative-check-") + const rewriteResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, filePath], + { + cwd: repoRoot, + encoding: "utf8" + } ); - const filePath = path.join(tempDir, "OutsideRelativeNeedsFix.cs"); - - try { - fs.writeFileSync( - filePath, - [ - "public sealed class OutsideRelativeNeedsFix", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const repoRoot = path.resolve(__dirname, "../.."); - const relativeOutsidePath = path.relative(repoRoot, filePath); - const result = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", relativeOutsidePath], - { - cwd: repoRoot, - encoding: "utf8", - } - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Found C# methods with underscores"); - expect(result.stderr).toContain("OutsideRelativeNeedsFix.cs"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( - "explicitly passed files in repo-internal %s segment remain skipped", - (excludedSegment) => { - const repoRoot = path.resolve(__dirname, "../.."); - const repoInternalExcludedRoot = path.join( - repoRoot, - "Tests", - "dxmsg-csharp-underscore-repo-excluded" - ); - const excludedDir = path.join(repoInternalExcludedRoot, excludedSegment, "nested"); - const filePath = path.join(excludedDir, "RepoExcludedSkip.cs"); - - try { - fs.mkdirSync(excludedDir, { recursive: true }); - fs.writeFileSync( - filePath, - [ - "public sealed class RepoExcludedSkip", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" - ); - - const checkResult = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", filePath], - { - cwd: repoRoot, - encoding: "utf8", - } - ); - - expect(checkResult.status).toBe(0); - expect(checkResult.stdout).toContain("No C# files to process."); - - const rewriteResult = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, filePath], - { - cwd: repoRoot, - encoding: "utf8", - } - ); - - expect(rewriteResult.status).toBe(0); - - const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); - expect(contentAfterRewrite).toContain("Parse_Line_Bare"); - expect(contentAfterRewrite).not.toContain("ParseLineBare"); - } finally { - fs.rmSync(repoInternalExcludedRoot, { recursive: true, force: true }); - } - } - ); + expect(rewriteResult.status).toBe(0); + + const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); + expect(contentAfterRewrite).toContain("Parse_Line_Bare"); + expect(contentAfterRewrite).not.toContain("ParseLineBare"); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + } + ); }); diff --git a/scripts/__tests__/unity-perf-baseline-script-contract.test.js b/scripts/__tests__/unity-perf-baseline-script-contract.test.js index 8c650ac1..171b6023 100644 --- a/scripts/__tests__/unity-perf-baseline-script-contract.test.js +++ b/scripts/__tests__/unity-perf-baseline-script-contract.test.js @@ -39,21 +39,68 @@ function makeTempToolDir() { const pwshMarker = path.join(tempRoot, "fake-pwsh.json"); const nodeMarker = path.join(tempRoot, "fake-node.json"); - const fakePwsh = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'printf \'{"commit":%s,"baseline":%s,"mode":%s}\\n\' "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_COMMIT || \"\"))\')" "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_BASELINE || \"\"))\')" "$("$FAKE_REAL_NODE" -e \'process.stdout.write(JSON.stringify(process.env.DX_PERF_BASELINE_MODE || \"\"))\')" > "$FAKE_PWSH_MARKER"', - 'if [[ "${FAKE_SKIP_BASELINE:-0}" != "1" && -n "${DX_PERF_BASELINE:-}" && ! -f "$DX_PERF_BASELINE" ]]; then mkdir -p "$(dirname "$DX_PERF_BASELINE")"; printf "%s\\n" "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs" > "$DX_PERF_BASELINE"; fi', - 'printf "%s\\n" "fake unity stdout for $DX_PERF_COMMIT"', - 'printf "%s\\n" "fake unity stderr for $DX_PERF_COMMIT" >&2', - 'exit "${FAKE_PWSH_EXIT:-0}"', + const fakePwshJs = [ + '"use strict";', + 'const fs = require("fs");', + 'const path = require("path");', + "const marker = {", + ' commit: process.env.DX_PERF_COMMIT || "",', + ' baseline: process.env.DX_PERF_BASELINE || "",', + ' mode: process.env.DX_PERF_BASELINE_MODE || ""', + "};", + "fs.writeFileSync(process.env.FAKE_PWSH_MARKER, `${JSON.stringify(marker)}\\n`);", + 'if (process.env.FAKE_SKIP_BASELINE !== "1" && marker.baseline) {', + " const baselinePath = path.resolve(process.cwd(), marker.baseline);", + " fs.mkdirSync(path.dirname(baselinePath), { recursive: true });", + " fs.writeFileSync(", + " baselinePath,", + ' "scenario,platform,commit,runIndex,emitsPerSecond,allocatedBytesDelta,wallClockMs\\n"', + " );", + "}", + "process.stdout.write(`fake unity stdout for ${marker.commit}\\n`);", + "process.stderr.write(`fake unity stderr for ${marker.commit}\\n`);", + 'process.exit(Number.parseInt(process.env.FAKE_PWSH_EXIT || "0", 10));', "" ].join("\n"); - const fakeNode = ["#!/usr/bin/env bash", "set -euo pipefail", "exit 99", ""].join("\n"); + const fakePwshSh = [ + "#!/usr/bin/env sh", + "set -eu", + 'exec "$FAKE_REAL_NODE" "$(dirname "$0")/fake-pwsh.js"', + "" + ].join("\n"); + + const fakePwshCmd = [ + "@echo off", + '"%FAKE_REAL_NODE%" "%~dp0fake-pwsh.js"', + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n"); + + const fakeNodeJs = [ + '"use strict";', + "require('fs').writeFileSync(process.env.FAKE_NODE_MARKER, 'invoked\\n');", + "process.exit(99);", + "" + ].join("\n"); + const fakeNodeSh = [ + "#!/usr/bin/env sh", + 'exec "$FAKE_REAL_NODE" "$(dirname "$0")/fake-node.js"', + "" + ].join("\n"); + const fakeNodeCmd = [ + "@echo off", + '"%FAKE_REAL_NODE%" "%~dp0fake-node.js"', + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n"); - fs.writeFileSync(path.join(binDir, "pwsh"), fakePwsh, { mode: 0o755 }); - fs.writeFileSync(path.join(binDir, "node"), fakeNode, { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, "fake-pwsh.js"), fakePwshJs); + fs.writeFileSync(path.join(binDir, "fake-node.js"), fakeNodeJs); + fs.writeFileSync(path.join(binDir, "pwsh"), fakePwshSh, { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, "pwsh.cmd"), fakePwshCmd); + fs.writeFileSync(path.join(binDir, "node"), fakeNodeSh, { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, "node.cmd"), fakeNodeCmd); return { tempRoot, binDir, pwshMarker, nodeMarker }; } @@ -68,7 +115,8 @@ function runCapture(args, tools, extraEnv = {}) { FAKE_NODE_MARKER: tools.nodeMarker, FAKE_PWSH_MARKER: tools.pwshMarker, FAKE_REAL_NODE: process.execPath, - PATH: `${tools.binDir}${path.delimiter}${process.env.PATH}` + PATH: `${tools.binDir}${path.delimiter}${process.env.PATH}`, + PATHEXT: process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD" } }); } @@ -123,7 +171,9 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(content).toContain("$BaselinePathForUnity = ConvertTo-RepoRelativePath $Output"); expect(content).toContain("[System.IO.Path]::IsPathRooted($relativePath)"); expect(content).toContain("[System.IO.Path]::GetFullPath($Path, $RepoRoot)"); - expect(content).toContain("$BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output, $RepoRoot)"); + expect(content).toContain( + "$BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output, $RepoRoot)" + ); expect(content).toContain("$baselineTimestampBeforeRun"); expect(content).toContain("$baselineTimestampAfterRun -le $baselineTimestampBeforeRun"); expect(content).toContain("did not write baseline CSV"); diff --git a/scripts/__tests__/unity-runner-script-contract.test.js b/scripts/__tests__/unity-runner-script-contract.test.js index 1f98a1d1..c9422dd1 100644 --- a/scripts/__tests__/unity-runner-script-contract.test.js +++ b/scripts/__tests__/unity-runner-script-contract.test.js @@ -17,6 +17,7 @@ const fs = require("fs"); const path = require("path"); +const childProcess = require("child_process"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const UNITY_SCRIPTS = path.join(REPO_ROOT, "scripts", "unity"); @@ -28,10 +29,16 @@ function readScript(relPath) { } function hasExecutableBit(absPath) { - const mode = fs.statSync(absPath).mode; - // Any of user/group/other execute bits set is enough; chmod tooling on - // contributor machines varies, but git+the CI runner only require one. - return (mode & 0o111) !== 0; + const relativePath = path.relative(REPO_ROOT, absPath).split(path.sep).join("/"); + const result = childProcess.spawnSync("git", ["ls-files", "--stage", "--", relativePath], { + cwd: REPO_ROOT, + encoding: "utf8" + }); + + expect(result.status).toBe(0); + const [mode] = result.stdout.trim().split(/\s+/, 1); + expect(mode).toBe("100755"); + return true; } describe("scripts/unity/run-tests.sh contract", () => { @@ -127,9 +134,7 @@ describe("scripts/unity/run-tests.sh contract", () => { expect(content).toContain("trap cleanup_ownership EXIT"); expect(content).toContain("UNITY_LIBRARY_CACHE_SOURCE="); expect(content).toContain("dxm-unity-library-%s-%s"); - expect(content).toContain( - 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true' - ); + expect(content).toContain('chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true'); expect(content).toContain('baseline_path="${DX_PERF_BASELINE}"'); expect(content).toContain('baseline_path="/workspace/${baseline_path}"'); expect(content).toContain('chown "${USER_UID}:${USER_GID}" "${baseline_path}"'); @@ -272,9 +277,7 @@ describe("scripts/unity/run-tests.ps1 contract", () => { expect(content).toContain("trap cleanup_ownership EXIT"); expect(content).toContain("$UnityLibraryCacheSource"); expect(content).toContain("dxm-unity-library-$ImageTag-$Platform"); - expect(content).toContain( - 'chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true' - ); + expect(content).toContain('chown -R "${USER_UID}:${USER_GID}" /workspace/.artifacts || true'); expect(content).toContain('baseline_path="${DX_PERF_BASELINE}"'); expect(content).toContain('baseline_path="/workspace/${baseline_path}"'); expect(content).toContain('chown "${USER_UID}:${USER_GID}" "${baseline_path}"'); diff --git a/scripts/unity/activate-license.sh b/scripts/unity/activate-license.sh old mode 100644 new mode 100755 diff --git a/scripts/unity/run-tests.sh b/scripts/unity/run-tests.sh old mode 100644 new mode 100755 From 39cc76e0282576580fda3d17c0ba340a5ee63521 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 16:48:54 -0700 Subject: [PATCH 14/16] GitHook reliability --- .cspell.json | 1 + .github/workflows/devcontainer-prebuild.yml | 3 +- .github/workflows/devcontainer-test.yml | 3 +- .llm/context.md | 2 + .../__tests__/cspell-version-parity.test.js | 7 +++ ...nity-perf-baseline-script-contract.test.js | 51 +++++++++++++++++-- scripts/unity/capture-perf-baseline.ps1 | 5 +- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.cspell.json b/.cspell.json index f4ebb958..51ad11eb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -20,6 +20,7 @@ "*.meta", "Samples~/**", "package-lock.json", + "**/packages-lock.json", "Library/**", "Temp/**", "Logs/**", diff --git a/.github/workflows/devcontainer-prebuild.yml b/.github/workflows/devcontainer-prebuild.yml index 3acaa223..37421f50 100644 --- a/.github/workflows/devcontainer-prebuild.yml +++ b/.github/workflows/devcontainer-prebuild.yml @@ -21,8 +21,7 @@ # events. Setting it to "" makes the explicit `push: always` knob the only # source of truth — without this, the weekly cron and manual dispatches # would build but never publish, defeating the whole purpose of this -# workflow. See .llm/skills/github-actions/cicd-devcontainer-workflows.md -# (ported from Shiro in Phase 4). +# workflow. See .llm/skills/github-actions/cicd-devcontainer-workflows.md. # # Permissions: # `packages: write` is required for the docker/login-action push to GHCR diff --git a/.github/workflows/devcontainer-test.yml b/.github/workflows/devcontainer-test.yml index 3833d249..785bc6c0 100644 --- a/.github/workflows/devcontainer-test.yml +++ b/.github/workflows/devcontainer-test.yml @@ -26,8 +26,7 @@ # silently SKIPS pushing the image on `schedule` / `workflow_dispatch` # events. Setting it to "" makes the `push` knob the single source of # truth (combined with `refFilterForPush: refs/heads/master`). This gotcha -# is documented in .llm/skills/github-actions/cicd-devcontainer-workflows.md -# (ported from Shiro in Phase 4). +# is documented in .llm/skills/github-actions/cicd-devcontainer-workflows.md. # ============================================================================= name: Test Devcontainer diff --git a/.llm/context.md b/.llm/context.md index f00be08f..136646db 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -112,6 +112,8 @@ The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outsid - When editing `scripts/fix-csharp-underscore-methods.js` or its tests, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/fix-csharp-underscore-methods.test.js` and then `npm run preflight:pre-commit` before finishing. - For parser-script failures, verify both isolated and hook-parity execution before concluding root cause: run the focused Jest path first, then run `pre-commit run --hook-stage pre-push script-parser-tests --all-files` from the same shell used for commit operations. - For Unity runner or perf-baseline script failures, run `npm run test:unity-contracts` before hook parity checks. On Windows, keep fake command shims platform-native (`.cmd` wrappers for PATH-resolved tools) and verify executable shell entrypoints with `git ls-files --stage` because NTFS mode bits are not the repository contract. +- For PowerShell paths exported into Docker or Unity containers, pass repo-relative paths with `/` separators; keep platform-native absolute paths only for local filesystem display and validation. +- Generated dependency lockfiles should be ignored by cspell unless the vocabulary is intentionally reviewed. - When editing `.pre-commit-config.yaml` or `scripts/validate-pre-commit-tooling.js`, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/pre-commit-hook-stage-policy.test.js scripts/__tests__/validate-pre-commit-tooling.test.js` before `npm run preflight:pre-commit`. - On Windows, verify `npm --version` in the active shell before running hook-related checks (especially when using nvm/fnm). - On Windows hosts, run `npm run preflight:pre-commit` in the same shell you use for `git commit` so hook PATH/init, npm version drift, package.json formatting, and yamllint issues are caught before commit. diff --git a/scripts/__tests__/cspell-version-parity.test.js b/scripts/__tests__/cspell-version-parity.test.js index a9328645..9c23652f 100644 --- a/scripts/__tests__/cspell-version-parity.test.js +++ b/scripts/__tests__/cspell-version-parity.test.js @@ -118,4 +118,11 @@ describe("cspell configuration exclusions", () => { expect(cspellConfig.ignorePaths).toContain(".vale/styles/Vocab/**/reject.txt"); }); + + test("ignores generated dependency lockfiles", () => { + const cspellConfig = JSON.parse(fs.readFileSync(CSPELL_CONFIG_PATH, "utf8")); + + expect(cspellConfig.ignorePaths).toContain("package-lock.json"); + expect(cspellConfig.ignorePaths).toContain("**/packages-lock.json"); + }); }); diff --git a/scripts/__tests__/unity-perf-baseline-script-contract.test.js b/scripts/__tests__/unity-perf-baseline-script-contract.test.js index 171b6023..eedcc373 100644 --- a/scripts/__tests__/unity-perf-baseline-script-contract.test.js +++ b/scripts/__tests__/unity-perf-baseline-script-contract.test.js @@ -130,6 +130,11 @@ function cleanupPerfArtifacts(commit) { } } +function expectDockerRelativePath(actual, expected) { + expect(actual).toBe(expected); + expect(actual).not.toContain("\\"); +} + describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { let content; @@ -171,6 +176,8 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(content).toContain("$BaselinePathForUnity = ConvertTo-RepoRelativePath $Output"); expect(content).toContain("[System.IO.Path]::IsPathRooted($relativePath)"); expect(content).toContain("[System.IO.Path]::GetFullPath($Path, $RepoRoot)"); + expect(content).toContain("$dockerRelativePath = $relativePath.Replace('\\', '/')"); + expect(content).toContain("$dockerRelativePath.StartsWith('../')"); expect(content).toContain( "$BaselineDisplayPath = [System.IO.Path]::GetFullPath($Output, $RepoRoot)" ); @@ -201,7 +208,7 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(result.status).toBe(0); expect(marker.commit).toBe(commit); - expect(marker.baseline).toBe(outputPath); + expectDockerRelativePath(marker.baseline, outputPath); expect(marker.mode).toBe("replace"); expect(result.stdout).toContain(`fake unity stdout for ${commit}`); expect(result.stdout).toContain(`fake unity stderr for ${commit}`); @@ -228,7 +235,7 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); expect(result.status).toBe(0); - expect(marker.baseline).toBe(path.join(".artifacts", "absolute-baseline.csv")); + expectDockerRelativePath(marker.baseline, ".artifacts/absolute-baseline.csv"); expect(result.stdout).toContain(outputPath); } finally { cleanupPerfArtifacts(commit); @@ -266,6 +273,44 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { expect(fs.existsSync(tools.pwshMarker)).toBe(false); }); + test("rejects backslash parent traversal after Docker path normalization", () => { + if (!REAL_PWSH) { + return; + } + + const tools = makeTempToolDir(); + const result = runCapture( + ["-Commit", "backslash-outside-output-test", "-Output", "..\\outside-baseline.csv"], + tools + ); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("-Output must be relative to the repo or under the repo root"); + expect(fs.existsSync(tools.pwshMarker)).toBe(false); + }); + + test("normalizes Windows-style relative output to forward slashes for Docker-safe resolution", () => { + if (!REAL_PWSH) { + return; + } + + const commit = "windows-relative-output-test"; + const tools = makeTempToolDir(); + const outputPath = ".artifacts\\windows-relative-baseline.csv"; + const cleanupPath = path.join(REPO_ROOT, ".artifacts", "windows-relative-baseline.csv"); + + try { + const result = runCapture(["-Commit", commit, "-Output", outputPath, "-Replace"], tools); + const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); + + expect(result.status).toBe(0); + expectDockerRelativePath(marker.baseline, ".artifacts/windows-relative-baseline.csv"); + } finally { + cleanupPerfArtifacts(commit); + fs.rmSync(cleanupPath, { force: true }); + } + }); + test("forwards the default baseline output as repo-relative for docker-safe resolution", () => { if (!REAL_PWSH) { return; @@ -281,7 +326,7 @@ describe("scripts/unity/capture-perf-baseline.ps1 contract", () => { const marker = JSON.parse(fs.readFileSync(tools.pwshMarker, "utf8")); expect(result.status).toBe(0); - expect(marker.baseline).toBe(".artifacts/perf-baseline.csv"); + expectDockerRelativePath(marker.baseline, ".artifacts/perf-baseline.csv"); expect(result.stdout).toContain(path.join(REPO_ROOT, ".artifacts", "perf-baseline.csv")); } finally { cleanupPerfArtifacts(commit); diff --git a/scripts/unity/capture-perf-baseline.ps1 b/scripts/unity/capture-perf-baseline.ps1 index 7f83a210..526dc45f 100644 --- a/scripts/unity/capture-perf-baseline.ps1 +++ b/scripts/unity/capture-perf-baseline.ps1 @@ -91,11 +91,12 @@ function ConvertTo-RepoRelativePath { } $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $fullPath) - if ([System.IO.Path]::IsPathRooted($relativePath) -or $relativePath -eq '..' -or $relativePath.StartsWith("..$([System.IO.Path]::DirectorySeparatorChar)") -or $relativePath.StartsWith("..$([System.IO.Path]::AltDirectorySeparatorChar)")) { + $dockerRelativePath = $relativePath.Replace('\', '/') + if ([System.IO.Path]::IsPathRooted($relativePath) -or $dockerRelativePath -eq '..' -or $dockerRelativePath.StartsWith('../')) { return $null } - return $relativePath + return $dockerRelativePath } $BaselinePathForUnity = ConvertTo-RepoRelativePath $Output From 662e872a317083ab01e27b691c2c7993e64d98b6 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 17:26:50 -0700 Subject: [PATCH 15/16] PR feedback --- .../unity-benchmarks.yml | 27 ++++---- .../unity-il2cpp.yml | 35 +++++----- .../unity-tests.yml | 29 ++++----- .github/workflows/validate-npm-meta.yml | 2 +- .llm/context.md | 10 +-- .llm/skills/index.md | 4 +- .../unity/devcontainer-cache-contract.md | 2 +- .llm/skills/unity/headless-test-runner.md | 12 ++-- .llm/skills/unity/unity-ci-matrix.md | 22 +++---- .llm/skills/unity/unity-license-bootstrap.md | 9 +-- .../skills/unity/unity-perf-test-isolation.md | 42 ++++++------ .llm/skills/unity/upm-test-harness.md | 2 +- docs/architecture/comparisons.md | 4 +- llms.txt | 2 +- .../claude-permissions-contract.test.js | 38 +++++++++-- .../fix-csharp-underscore-methods.test.js | 35 ++++++++++ .../__tests__/unity-perf-isolation.test.js | 6 +- .../unity-test-harness-contract.test.js | 4 +- .../__tests__/unity-workflow-shape.test.js | 65 ++++++++++++++----- scripts/fix-csharp-underscore-methods.js | 46 +++++++++++-- scripts/unity/lib/asmdef-discovery.js | 2 +- scripts/unity/lib/parse-test-results.py | 2 +- 22 files changed, 266 insertions(+), 134 deletions(-) rename .github/{workflows => workflows-disabled}/unity-benchmarks.yml (89%) rename .github/{workflows => workflows-disabled}/unity-il2cpp.yml (90%) rename .github/{workflows => workflows-disabled}/unity-tests.yml (92%) diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows-disabled/unity-benchmarks.yml similarity index 89% rename from .github/workflows/unity-benchmarks.yml rename to .github/workflows-disabled/unity-benchmarks.yml index c4b178e0..53b2e71d 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows-disabled/unity-benchmarks.yml @@ -1,21 +1,25 @@ # ============================================================================= -# Unity Performance Benchmarks (NIGHTLY + workflow_dispatch ONLY) +# Unity Performance Benchmarks (DISABLED IN GITHUB ACTIONS) # ============================================================================= # Purpose: -# Runs the perf / allocation test suites that are EXCLUDED from the PR gate -# (.github/workflows/unity-tests.yml). External library comparison suites -# stay opt-in until the harness manifest installs their packages. These are graded -# regression-watchers, not pass/fail PR gates: noise from cold caches, -# ubuntu-latest CPU variance, and shared-runner contention makes them -# unreliable as a merge gate. +# Disabled in GitHub Actions for now. Keep using the local runner: +# bash scripts/unity/run-tests.sh --platform playmode --include-perf +# +# GitHub jobs are guarded with `if: ${{ false }}` so manual dispatch cannot +# run game-ci while these tests are kept local-only. +# +# When re-enabled, this runs the perf / allocation test suites that are +# EXCLUDED from the PR gate (.github/workflows-disabled/unity-tests.yml). External +# library comparison suites stay opt-in until the harness manifest installs +# their packages. These are graded regression-watchers, not pass/fail PR gates. # # This workflow MUST NOT block PRs. # - No `pull_request` trigger. # - No `push` trigger to master (or any branch). -# - The only triggers are `workflow_dispatch` (manual) and a nightly cron. +# - The only trigger while disabled is `workflow_dispatch`, and the jobs skip. # This is an explicit hard requirement from the project lead; verify # compliance via: -# grep -A 3 "^on:" .github/workflows/unity-benchmarks.yml +# test ! -e .github/workflows/unity-benchmarks.yml # # Assembly include list: # Computed dynamically with `includePerf: true` on the same @@ -41,10 +45,6 @@ name: Unity Benchmarks on: - schedule: - # 06:00 UTC nightly. Pick a time outside the EditMode/IL2CPP gate's busy - # window to avoid runner contention. - - cron: "0 6 * * *" workflow_dispatch: inputs: unity-version: @@ -65,6 +65,7 @@ permissions: jobs: matrix-config: name: Resolve benchmark matrix + if: ${{ false }} runs-on: ubuntu-latest timeout-minutes: 2 outputs: diff --git a/.github/workflows/unity-il2cpp.yml b/.github/workflows-disabled/unity-il2cpp.yml similarity index 90% rename from .github/workflows/unity-il2cpp.yml rename to .github/workflows-disabled/unity-il2cpp.yml index 836c75d3..9e1a67dd 100644 --- a/.github/workflows/unity-il2cpp.yml +++ b/.github/workflows-disabled/unity-il2cpp.yml @@ -1,24 +1,28 @@ # ============================================================================= -# Unity IL2CPP Standalone Player Tests +# Unity IL2CPP Standalone Player Tests (Disabled in GitHub) # ============================================================================= # Purpose: -# Builds the StandaloneLinux64 IL2CPP test player via game-ci/unity-builder -# and runs the produced binary headlessly. IL2CPP exercises the AOT-compiled -# path that EditMode/PlayMode under Mono cannot — it catches: +# Disabled in GitHub Actions for now. Keep using the local runner: +# bash scripts/unity/run-tests.sh --platform standalone +# +# GitHub jobs are guarded with `if: ${{ false }}` so manual dispatch cannot +# run game-ci while these tests are kept local-only. +# +# When re-enabled, this builds the StandaloneLinux64 IL2CPP test player via +# game-ci/unity-builder and runs the produced binary headlessly. IL2CPP +# exercises the AOT-compiled path that EditMode/PlayMode under Mono cannot: # - Code stripping issues (linker.xml gaps, [Preserve] missing on reflected # types, generic-method instantiations stripped at link time) # - AOT generic-virtual-method (GVM) failures specific to IL2CPP # - PInvoke / native-callable signature mismatches # - C# language-feature regressions only the IL2CPP backend rejects # These regressions have historically slipped past the Mono-based EditMode -# gate (.github/workflows/unity-tests.yml) and only surfaced when a +# gate (.github/workflows-disabled/unity-tests.yml) and only surfaced when a # downstream consumer built a player. # -# Schedule: -# On every PR / push to master against a single Unity version (single-version -# keeps the gate fast), and weekly via cron for multi-version drift checks. -# Expected runtime: ~10 minutes for the build + ~3 minutes for the player run -# on the standard ubuntu-latest runner. +# Schedule when re-enabled: +# Use PR / push against a single Unity version for the gate, plus a weekly +# cron for multi-version drift checks. # # Local debugging: # Reproduce a build failure end-to-end with the local runner script: @@ -38,16 +42,6 @@ name: Unity IL2CPP on: - pull_request: - branches: - - master - push: - branches: - - master - schedule: - # Monday 05:00 UTC — weekly multi-version drift check (still single-version - # in the matrix today; expand `unity-version` here when adding versions). - - cron: "0 5 * * 1" workflow_dispatch: inputs: unity-version: @@ -67,6 +61,7 @@ permissions: jobs: matrix-config: name: Resolve IL2CPP matrix + if: ${{ false }} runs-on: ubuntu-latest timeout-minutes: 2 outputs: diff --git a/.github/workflows/unity-tests.yml b/.github/workflows-disabled/unity-tests.yml similarity index 92% rename from .github/workflows/unity-tests.yml rename to .github/workflows-disabled/unity-tests.yml index c7878035..08ca7911 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows-disabled/unity-tests.yml @@ -1,15 +1,19 @@ # ============================================================================= -# Unity EditMode + PlayMode PR Gate +# Unity EditMode + PlayMode Tests (Disabled in GitHub) # ============================================================================= # Purpose: -# Runs the DxMessaging test suite under the Unity Test Runner via the -# game-ci action across the supported Unity LTS / 6.0 versions. This is the -# canonical Unity correctness gate for pull requests targeting master. +# Disabled in GitHub Actions for now. Keep using the local runner: +# bash scripts/unity/run-tests.sh --platform editmode +# bash scripts/unity/run-tests.sh --platform playmode # -# Why this is a PR gate (not benchmarks): +# Disabled workflow contract: +# The jobs are guarded with `if: ${{ false }}` so manual dispatch cannot run +# game-ci while these tests are kept local-only. +# +# When re-enabled as a PR gate: # The default assembly include list explicitly EXCLUDES the perf, # allocation, and comparison suites — those run on a separate scheduled -# workflow (.github/workflows/unity-benchmarks.yml). DI-integration suites +# workflow (.github/workflows-disabled/unity-benchmarks.yml). DI-integration suites # (VContainer / Zenject / Reflex) are also opt-in and excluded here per # Phase 2 review fix M3. # @@ -47,20 +51,14 @@ # asmdef advertises. Run `node scripts/unity/lib/asmdef-discovery.js` # locally to diff the discovered names against the previous run. # -# Manual override: -# Use the workflow_dispatch inputs to pin a single Unity version or a -# single test mode (handy when triaging a regression on one channel). +# Manual override when re-enabled: +# Use the workflow_dispatch inputs to pin a single Unity version or a single +# test mode (handy when triaging a regression on one channel). # ============================================================================= name: Unity Tests on: - pull_request: - branches: - - master - push: - branches: - - master workflow_dispatch: inputs: unity-version: @@ -89,6 +87,7 @@ permissions: jobs: matrix-config: name: Resolve test matrix + if: ${{ false }} runs-on: ubuntu-latest timeout-minutes: 2 outputs: diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index 90ab692e..ed49115c 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -110,7 +110,7 @@ jobs: run: | set -euo pipefail npm pack - tarball="$(ls -1 *.tgz | head -n1)" + tarball="$(find . -maxdepth 1 -type f -name '*.tgz' -print | sort | head -n1)" echo "Inspecting tarball: ${tarball}" leaks="$(tar -tzf "${tarball}" | grep -E '(^|/)(bin|obj)/|\.pdb$|\.tmp$|\.csproj\.user$|(^|/)\.vs/|(^|/)\.idea/|\.suo$|\.DotSettings\.user$|\.user$' || true)" if [ -n "${leaks}" ]; then diff --git a/.llm/context.md b/.llm/context.md index 136646db..3f13e454 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -72,18 +72,18 @@ For Unity-side tests in `Tests/Editor/` or `Tests/Runtime/` (excludes Benchmarks - PlayMode: `bash scripts/unity/run-tests.sh --platform playmode` - IL2CPP standalone: `bash scripts/unity/run-tests.sh --platform standalone` - Filter: `--filter ` (passed to `-testFilter`) -- Include perf: `--include-perf` (off by default; runnable perf tests run only via `unity-benchmarks.yml`) +- Include perf: `--include-perf` (off by default; GitHub benchmark workflow template is disabled) - Include comparisons: `--include-comparisons` (off by default; requires MessagePipe/UniRx/UniTask/Zenject packages in the harness) - Include DI integrations (Reflex/Zenject/VContainer): `--include-integrations` (off by default) - Realtime log streams to stdout; XML written to `.artifacts/unity/results.xml` unless `--results` overrides it -- Bootstrap project: `.unity-test-project/` -- see [skills/unity/upm-test-harness.md](./skills/unity/upm-test-harness.md) -- License: see [skills/unity/unity-license-bootstrap.md](./skills/unity/unity-license-bootstrap.md) (Personal/GameCI: raw `.ulf` in `UNITY_LICENSE` plus credentials; Professional: `UNITY_SERIAL` plus credentials; local shells may use `UNITY_LICENSE_B64`.) -- ARM Mac (Apple Silicon): not supported locally -- use CI gates or a Codespace +- Bootstrap project: `.unity-test-project/` -- see [UPM Test Harness](./skills/unity/upm-test-harness.md) +- License: see [Unity License Bootstrap](./skills/unity/unity-license-bootstrap.md) (Personal/GameCI: raw `.ulf` in `UNITY_LICENSE` plus credentials; Professional: `UNITY_SERIAL` plus credentials; local shells may use `UNITY_LICENSE_B64`.) +- ARM Mac (Apple Silicon): not supported locally -- use a non-ARM local shell or Codespace while Unity GitHub workflows are disabled - For source-generator tests (no Unity), use `dotnet test SourceGenerators/...Tests` ## Devcontainer Workflow -The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outside-of-docker). Unity tests spawn ephemeral `unityci/editor` containers via the host docker socket; the image is pulled lazily on first use, the `.unity-test-project/Library` cache is preserved in a named volume across runs. See [skills/unity/devcontainer-cache-contract.md](./skills/unity/devcontainer-cache-contract.md) and [skills/unity/headless-test-runner.md](./skills/unity/headless-test-runner.md). +The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outside-of-docker). Unity tests spawn ephemeral `unityci/editor` containers via the host docker socket; the image is pulled lazily on first use, the `.unity-test-project/Library` cache is preserved in a named volume across runs. See [Devcontainer Cache Contract](./skills/unity/devcontainer-cache-contract.md) and [Headless Test Runner](./skills/unity/headless-test-runner.md). ## C# Conventions diff --git a/.llm/skills/index.md b/.llm/skills/index.md index d25576a4..745c6213 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -220,8 +220,8 @@ | [Headless Unity Test Runner](./unity/headless-test-runner.md) | [ok] 224 | [intermediate] | [stable] | [risk: none] | unity, testing | | [MessageAwareComponent Base-Call Contract](./unity/base-call-contract.md) | [warn] 267 | [intermediate] | [stable] | [risk: none] | unity, analyzer | | [Unity CI Matrix](./unity/unity-ci-matrix.md) | [ok] 193 | [intermediate] | [stable] | [risk: low] | unity, ci | -| [Unity License Bootstrap](./unity/unity-license-bootstrap.md) | [ok] 216 | [basic] | [stable] | [risk: none] | unity, license | -| [Unity Perf Test Isolation](./unity/unity-perf-test-isolation.md) | [ok] 211 | [intermediate] | [stable] | [risk: high] | unity, performance | +| [Unity License Bootstrap](./unity/unity-license-bootstrap.md) | [ok] 217 | [basic] | [stable] | [risk: none] | unity, license | +| [Unity Perf Test Isolation](./unity/unity-perf-test-isolation.md) | [ok] 215 | [intermediate] | [stable] | [risk: high] | unity, performance | | [UPM Test Harness](./unity/upm-test-harness.md) | [ok] 207 | [basic] | [stable] | [risk: none] | unity, upm | --- diff --git a/.llm/skills/unity/devcontainer-cache-contract.md b/.llm/skills/unity/devcontainer-cache-contract.md index 3c5b7317..7ec13b5f 100644 --- a/.llm/skills/unity/devcontainer-cache-contract.md +++ b/.llm/skills/unity/devcontainer-cache-contract.md @@ -175,5 +175,5 @@ Remove in the inverse order: devcontainer.json first (so a fresh build does not ## References - Docker volumes: https://docs.docker.com/storage/volumes/ -- Devcontainer mounts: https://containers.dev/implementors/json_reference/#mounts +- Devcontainer JSON reference: https://containers.dev/implementors/json_reference/ - Source: `.devcontainer/cache-contract.sh` diff --git a/.llm/skills/unity/headless-test-runner.md b/.llm/skills/unity/headless-test-runner.md index fbda1202..b8840d0c 100644 --- a/.llm/skills/unity/headless-test-runner.md +++ b/.llm/skills/unity/headless-test-runner.md @@ -13,7 +13,7 @@ source: - path: "scripts/unity/run-tests.ps1" - path: "scripts/unity/lib/asmdef-discovery.js" - path: "scripts/unity/lib/parse-test-results.py" - - path: ".github/workflows/unity-tests.yml" + - path: ".github/workflows-disabled/unity-tests.yml" url: "https://github.com/wallstop/DxMessaging" tags: @@ -85,7 +85,7 @@ status: "stable" ## When to Use - Iterating on Runtime/Editor code that has Unity tests under `Tests/Editor` or `Tests/Runtime`. -- Reproducing a CI failure from `unity-tests.yml` or `unity-il2cpp.yml` locally. +- Reproducing a Unity workflow-template failure from `unity-tests.yml` or `unity-il2cpp.yml` locally. - Smoke-testing a change to `scripts/unity/lib/asmdef-discovery.js` or the test harness. - Verifying the perf-isolation contract by running with and without `--include-perf`. @@ -103,13 +103,13 @@ status: "stable" | `--platform` | enum (req) | none | `editmode`, `playmode`, or `standalone`. Required. | | `--unity-version` | string | `2022.3.45f1` (or `$UNITY_VERSION`) | Pin a different Editor version when reproducing a matrix-specific failure. | | `--filter` | regex | empty | Forward to Unity's `-testFilter`. Use to narrow to a single fixture or namespace. | -| `--include-perf` | bool flag | off | Include `Benchmarks` / `Allocations` asmdefs. PR gate keeps these excluded. | +| `--include-perf` | bool flag | off | Include `Benchmarks` / `Allocations` asmdefs. Default local runs keep these excluded. | | `--include-integrations` | bool flag | off | Include `VContainer` / `Zenject` / `Reflex` suites. Requires those packages in `manifest.json`. | | `--include-comparisons` | bool flag | off | Include external comparison benchmarks. Requires MessagePipe / UniRx / UniTask / Zenject. | | `--results` | path | `.artifacts/unity/results.xml` | Override NUnit XML output path. Must live under the repo (bind-mount limit). | | `--help` | flag | - | Print usage and exit 0. | -The defaults match `defaultIncludeAssemblies(repoRoot)` from `scripts/unity/lib/asmdef-discovery.js`. That module is the single source of truth and is also called by `unity-tests.yml`. +The defaults match `defaultIncludeAssemblies(repoRoot)` from `scripts/unity/lib/asmdef-discovery.js`. That module is the single source of truth and is also called by the disabled `unity-tests.yml` workflow template. ## Expected Runtimes @@ -198,14 +198,14 @@ Pick the matching error signature in stdout, then apply the listed remediation. `unityci/editor` images are amd64-only as of 2026-05. Running them via `docker run` on Apple Silicon falls back to QEMU emulation, which is roughly 10x slower and frequently hangs the editor during domain reload. There are two sanctioned paths on M-series Macs: -1. Skip local Unity runs. Rely on the `unity-tests.yml` PR gate (free for public repos, ~5 min per matrix cell). +1. Skip local Unity runs only when GitHub Unity workflows are re-enabled. They are currently local-only. 1. Open the repo in a hosted GitHub Codespace (`gh codespace create`). The Codespace runs on amd64 hardware and the in-container Unity flow works the same as on Linux/Windows hosts. `.llm/context.md` carries a single-line warning so an agent flags this proactively when `uname -m` returns `arm64`. ## CI Parity -When `CI=true` is set, the script does NOT spawn docker locally. It prints the equivalent `game-ci/unity-test-runner@v4` parameters and exits 0. This is what `unity-tests.yml` consumes. The shape is locked by a Phase 4 contract test (`unity-runner-script-contract.test.js`) so help text, flag names, and the assembly source-of-truth cannot drift apart. +When `CI=true` is set, the script does NOT spawn docker locally. It prints the equivalent `game-ci/unity-test-runner@v4` parameters and exits 0. This is what the disabled `unity-tests.yml` template consumes when re-enabled. The shape is locked by a Phase 4 contract test (`unity-runner-script-contract.test.js`) so help text, flag names, and the assembly source-of-truth cannot drift apart. ## See Also diff --git a/.llm/skills/unity/unity-ci-matrix.md b/.llm/skills/unity/unity-ci-matrix.md index 25df3e5f..407d7ca7 100644 --- a/.llm/skills/unity/unity-ci-matrix.md +++ b/.llm/skills/unity/unity-ci-matrix.md @@ -9,9 +9,9 @@ updated: "2026-05-05" source: repository: "wallstop/DxMessaging" files: - - path: ".github/workflows/unity-tests.yml" - - path: ".github/workflows/unity-il2cpp.yml" - - path: ".github/workflows/unity-benchmarks.yml" + - path: ".github/workflows-disabled/unity-tests.yml" + - path: ".github/workflows-disabled/unity-il2cpp.yml" + - path: ".github/workflows-disabled/unity-benchmarks.yml" url: "https://github.com/wallstop/DxMessaging" tags: @@ -73,7 +73,7 @@ status: "stable" # Unity CI Matrix -> **One-line summary**: `unity-tests.yml` runs editmode and playmode against three Unity versions; `unity-il2cpp.yml` runs the AOT-compiled standalone player against a single version on every PR plus weekly cron; expand the matrix only when a new LTS ships or a user reports a version-specific bug. +> **One-line summary**: Unity game-ci workflow templates are disabled under `.github/workflows-disabled/`; when re-enabled, `unity-tests.yml` runs editmode and playmode against three Unity versions and `unity-il2cpp.yml` runs the AOT-compiled standalone player against a single version. ## When to Use @@ -89,7 +89,7 @@ status: "stable" ## Current Matrix -`unity-tests.yml` (PR gate, fast feedback): +`unity-tests.yml` (disabled template; fast feedback when re-enabled): | Axis | Values | | --------------- | ------------------------------------------- | @@ -98,7 +98,7 @@ status: "stable" Six matrix cells. Workflow_dispatch inputs let you pin a single version or single mode for triage. -`unity-il2cpp.yml` (PR gate, slower): +`unity-il2cpp.yml` (disabled template; slower AOT coverage when re-enabled): | Axis | Values | | --------------- | ------------- | @@ -106,18 +106,18 @@ Six matrix cells. Workflow_dispatch inputs let you pin a single version or singl Single cell on PRs to keep the gate under ~15 minutes; weekly cron currently uses the same single version (the matrix-config job is already shaped to expand without code changes). -`unity-benchmarks.yml` (workflow_dispatch + nightly cron, NEVER on PRs): +`unity-benchmarks.yml` (disabled template; manual/nightly when re-enabled, NEVER on PRs): | Axis | Values | | --------------- | ---------------------- | | `unity-version` | `2022.3.45f1` | | `test-mode` | `editmode`, `playmode` | -The `unity-benchmarks.yml` triggers explicitly omit `pull_request` and `push` per the perf isolation rule. +The `unity-benchmarks.yml` template explicitly omits `pull_request` and `push` per the perf isolation rule. ## When to Add a Unity Version -Add a version to `unity-tests.yml`'s `unity-versions` JSON array when one of the following is true: +After re-enabling GitHub Unity tests, add a version to `unity-tests.yml`'s `unity-versions` JSON array when one of the following is true: - A new LTS reaches general availability (e.g., when 2024.3 LTS or 7000.0 LTS ships) and the package's `package.json` `unity` field still permits it. - A user files an issue reproducing only on a specific Editor version. @@ -125,7 +125,7 @@ Add a version to `unity-tests.yml`'s `unity-versions` JSON array when one of the ## How to Add a Unity Version -1. Edit `.github/workflows/unity-tests.yml`. The matrix is computed in the `matrix-config` job: +1. Edit `.github/workflows-disabled/unity-tests.yml` before moving it back under `.github/workflows/`. The matrix is computed in the `matrix-config` job: ```yaml versions='["2021.3.45f1","2022.3.45f1","6000.0.32f1"]' @@ -189,4 +189,4 @@ The IL2CPP workflow checks the player exit code BEFORE parsing the XML so a cras - game-ci docs: https://game.ci/docs/ - Unity LTS roadmap: https://unity.com/releases/lts - Unity managed code stripping: https://docs.unity3d.com/Manual/ManagedCodeStripping.html -- Source: `.github/workflows/unity-tests.yml`, `.github/workflows/unity-il2cpp.yml` +- Source templates: `.github/workflows-disabled/unity-tests.yml`, `.github/workflows-disabled/unity-il2cpp.yml` diff --git a/.llm/skills/unity/unity-license-bootstrap.md b/.llm/skills/unity/unity-license-bootstrap.md index 0ac1c6a5..d4e2983a 100644 --- a/.llm/skills/unity/unity-license-bootstrap.md +++ b/.llm/skills/unity/unity-license-bootstrap.md @@ -12,7 +12,7 @@ source: - path: "scripts/unity/activate-license.sh" - path: "scripts/unity/run-tests.sh" - path: ".devcontainer/devcontainer.json" - - path: ".github/workflows/unity-tests.yml" + - path: ".github/workflows-disabled/unity-tests.yml" url: "https://github.com/wallstop/DxMessaging" tags: @@ -144,8 +144,9 @@ setup, not an email/password-only activation. | `UNITY_PASSWORD` | Unity account password | Yes | | `UNITY_LICENSE` | Raw `.ulf` contents | Yes | -The workflows under `.github/workflows/unity-*.yml` pass all three to -`game-ci/unity-test-runner@v4`; it picks the path from which secrets are set. +The disabled workflow templates under `.github/workflows-disabled/unity-*.yml` +pass all three to `game-ci/unity-test-runner@v4`; it picks the path from which +secrets are set after the templates are moved back under `.github/workflows/`. ## Professional Serial Path @@ -209,7 +210,7 @@ treat this as a future enhancement. ## References - Unity license activation methods: -- Unity manual activation support: +- Unity manual activation support: - GameCI activation guide: - GameCI test runner: - Source: `scripts/unity/activate-license.sh`, `scripts/unity/run-tests.sh` diff --git a/.llm/skills/unity/unity-perf-test-isolation.md b/.llm/skills/unity/unity-perf-test-isolation.md index b27c8994..334fd8a2 100644 --- a/.llm/skills/unity/unity-perf-test-isolation.md +++ b/.llm/skills/unity/unity-perf-test-isolation.md @@ -10,8 +10,8 @@ source: repository: "wallstop/DxMessaging" files: - path: "scripts/unity/lib/asmdef-discovery.js" - - path: ".github/workflows/unity-tests.yml" - - path: ".github/workflows/unity-benchmarks.yml" + - path: ".github/workflows-disabled/unity-tests.yml" + - path: ".github/workflows-disabled/unity-benchmarks.yml" - path: "scripts/unity/run-tests.sh" - path: ".llm/context.md" url: "https://github.com/wallstop/DxMessaging" @@ -31,7 +31,7 @@ complexity: impact: performance: rating: "high" - details: "Keeps the PR gate at ~5 minutes by excluding perf suites that would otherwise dominate the runtime" + details: "Keeps the default local run small by excluding perf suites that would otherwise dominate the runtime" maintainability: rating: "high" details: "Single regex governs classification across runner, CI, and contract test" @@ -72,18 +72,18 @@ related: status: "stable" --- - + # Unity Perf Test Isolation -> **One-line summary**: Asmdefs whose name matches `Benchmarks|Allocations` are classified as `perf`; `Comparisons` assemblies are a separate external-package opt-in. Both are excluded from the PR gate by `scripts/unity/lib/asmdef-discovery.js`. +> **One-line summary**: Asmdefs whose name matches `Benchmarks|Allocations` are classified as `perf`; `Comparisons` assemblies are a separate external-package opt-in. Both are excluded from default local Unity runs by `scripts/unity/lib/asmdef-discovery.js`. ## When to Use - Adding a new benchmark, allocation-counting, or library-comparison test suite. - Investigating why a perf-looking asmdef does or does not run on a PR. - Debugging a "0 tests ran" CI failure when the suite name pattern is suspect. -- Verifying the PR gate still excludes perf after a refactor. +- Verifying the default Unity run still excludes perf after a refactor. ## When NOT to Use @@ -124,8 +124,8 @@ Three callers consume this module: - `scripts/unity/run-tests.sh` builds its assembly list at startup and passes it to Unity via `-assemblyNames`. - `scripts/unity/run-tests.ps1` does the same on Windows. -- `unity-tests.yml` and `unity-il2cpp.yml` shell out to `node -e "...defaultIncludeAssemblies(process.cwd())..."` in the `Compute test assembly list` step. -- `unity-benchmarks.yml` calls `defaultIncludeAssemblies(process.cwd(), { includePerf: true })` and skips integrations plus external comparisons. +- Disabled workflow templates under `.github/workflows-disabled/` still shell out to the same asmdef-discovery module so they can be re-enabled without hand-maintained lists. +- The disabled `unity-benchmarks.yml` template calls `defaultIncludeAssemblies(process.cwd(), { includePerf: true })` and skips integrations plus external comparisons. Because every caller goes through the same module, adding a new perf asmdef requires no edits to the workflows or runner scripts. @@ -143,7 +143,7 @@ Because every caller goes through the same module, adding a new perf asmdef requ The output groups asmdefs by category. Confirm the new entry shows `[perf]`. -1. Confirm the PR gate excludes it: +1. Confirm the default run excludes it: ```bash bash scripts/unity/run-tests.sh --platform editmode @@ -163,16 +163,20 @@ If the asmdef ends up in the `core` bucket instead, the most common cause is the ## Where Perf Actually Runs -| Workflow | Triggers | Includes Perf? | -| ---------------------- | ---------------------------------------- | -------------- | -| `unity-tests.yml` | `pull_request`, `push: master`, dispatch | NO | -| `unity-il2cpp.yml` | `pull_request`, `push: master`, weekly | NO | -| `unity-benchmarks.yml` | `workflow_dispatch`, nightly cron | YES | +| Workflow | Triggers | Includes Perf? | +| ---------------------- | -------------------------------- | -------------- | +| `unity-tests.yml` | Moved out of `.github/workflows` | NO | +| `unity-il2cpp.yml` | Moved out of `.github/workflows` | NO | +| `unity-benchmarks.yml` | Moved out of `.github/workflows` | YES | -`unity-benchmarks.yml` deliberately omits `pull_request` and `push` triggers. Verify any time you edit it: +Unity game-ci jobs are temporarily disabled in GitHub by moving them to +`.github/workflows-disabled/` and kept local-only via `scripts/unity/run-tests.sh`. +Verify any time you edit the workflow templates: ```bash -grep -A 3 "^on:" .github/workflows/unity-benchmarks.yml +test ! -e .github/workflows/unity-tests.yml +test ! -e .github/workflows/unity-il2cpp.yml +test ! -e .github/workflows/unity-benchmarks.yml ``` ## Comparison Suites @@ -191,8 +195,8 @@ The runner should print `comparisons=true` and include the comparison asmdef in - Every asmdef matching the perf regex is classified as `perf`. - Every asmdef NOT matching the perf or integration regex is classified as `core` and appears in `defaultIncludeAssemblies(repo)`. -- `unity-tests.yml` and `unity-il2cpp.yml` resolve their assembly lists via `defaultIncludeAssemblies` rather than hand-rolled YAML. -- `unity-benchmarks.yml` opts into perf via `{ includePerf: true }`. +- Disabled `unity-tests.yml` and `unity-il2cpp.yml` templates resolve their assembly lists via `defaultIncludeAssemblies` rather than hand-rolled YAML. +- Disabled `unity-benchmarks.yml` template opts into perf via `{ includePerf: true }`. The test catches the silent regression "I added a new perf asmdef and forgot to update the exclusion list" because the exclusion list is computed, not hand-maintained. @@ -207,4 +211,4 @@ The test catches the silent regression "I added a new perf asmdef and forgot to - Source: `scripts/unity/lib/asmdef-discovery.js` - Source-of-truth: `.llm/context.md` -- Workflows: `.github/workflows/unity-tests.yml`, `.github/workflows/unity-benchmarks.yml` +- Workflow templates: `.github/workflows-disabled/unity-tests.yml`, `.github/workflows-disabled/unity-benchmarks.yml` diff --git a/.llm/skills/unity/upm-test-harness.md b/.llm/skills/unity/upm-test-harness.md index 6fdc2844..dcc74d6c 100644 --- a/.llm/skills/unity/upm-test-harness.md +++ b/.llm/skills/unity/upm-test-harness.md @@ -191,7 +191,7 @@ When a new test suite needs an additional UPM package (a DI container, a third-p 1. Open the harness in Unity once locally so UPM can resolve and write `packages-lock.json`. Commit the regenerated lock. 1. Re-run the headless runner to confirm the new dependency loads cleanly. -Avoid adding heavyweight runtime dependencies unless the corresponding tests can opt-in via the `--include-integrations` flag. The default suite stays lean so the PR gate stays under ~5 minutes. +Avoid adding heavyweight runtime dependencies unless the corresponding tests can opt in via the `--include-integrations` flag. The default suite stays lean for local runs and for the Unity gate when it is re-enabled. ## See Also diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 30ef5ec3..982c0d7d 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -28,7 +28,7 @@ ## Performance Benchmarks -These sections are auto-updated by the PlayMode comparison benchmarks in the [Comparison Performance PlayMode tests](https://github.com/wallstop/DxMessaging/blob/master/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs). Run the suite locally to refresh the tables. +These sections are auto-updated by the PlayMode comparison benchmarks in the [Comparison Performance PlayMode tests](https://github.com/wallstop/DxMessaging/blob/master/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs). Run the suite locally to refresh the tables. ### Comparisons (Windows) @@ -317,7 +317,7 @@ public class AchievementSystem #### What Problems It Solves -- [x] **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/wallstop/DxMessaging/tree/master/Tests/Runtime/Benchmarks) for comparison data) +- [x] **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/wallstop/DxMessaging/tree/master/Tests/Editor/Comparisons) for comparison data) - [x] **DI integration:** First-class support for dependency injection - [x] **Async messaging:** Native async/await without blocking - [x] **Leak detection:** Analyzer catches forgotten subscriptions at compile-time diff --git a/llms.txt b/llms.txt index a434425b..98503b58 100644 --- a/llms.txt +++ b/llms.txt @@ -306,5 +306,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-06 +**Last Updated:** 2026-05-07 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/scripts/__tests__/claude-permissions-contract.test.js b/scripts/__tests__/claude-permissions-contract.test.js index 73a8ad9b..f4c88f31 100644 --- a/scripts/__tests__/claude-permissions-contract.test.js +++ b/scripts/__tests__/claude-permissions-contract.test.js @@ -1,10 +1,10 @@ /** - * @fileoverview Contract test for the .claude/settings.local.json allowlist. + * @fileoverview Optional local diagnostics for the .claude/settings.local.json allowlist. * * The local Claude Code settings file pre-authorizes the canonical Unity-side * commands so contributors don't get a permission prompt every time they ask - * the agent to run the headless test runner. The plan (Layer C.1) defines the - * exact entry strings; this test makes sure they survive merges and edits. + * the agent to run the headless test runner. The file is intentionally + * gitignored, so CI must not require it to exist. */ "use strict"; @@ -88,18 +88,40 @@ const REQUIRED_ENTRIES = [ describe(".claude/settings.local.json contract", () => { let parsed; + let raw; beforeAll(() => { - expect(fs.existsSync(SETTINGS_PATH)).toBe(true); - const raw = fs.readFileSync(SETTINGS_PATH, "utf8"); - parsed = parseJsonc(raw); + if (fs.existsSync(SETTINGS_PATH)) { + raw = fs.readFileSync(SETTINGS_PATH, "utf8"); + parsed = parseJsonc(raw); + } + }); + + test("is optional because .claude/ is intentionally gitignored", () => { + const relativeSettingsPath = path.relative(REPO_ROOT, SETTINGS_PATH); + + if (!fs.existsSync(SETTINGS_PATH)) { + expect(relativeSettingsPath).toBe(path.join(".claude", "settings.local.json")); + expect(parsed).toBeUndefined(); + return; + } + + expect(raw.length).toBeGreaterThan(0); }); test("is structurally valid JSON / JSONC", () => { + if (!parsed) { + return; + } + expect(parsed).toEqual(expect.any(Object)); }); test("declares a permissions.allow array", () => { + if (!parsed) { + return; + } + expect(parsed.permissions).toBeDefined(); expect(Array.isArray(parsed.permissions.allow)).toBe(true); }); @@ -107,6 +129,10 @@ describe(".claude/settings.local.json contract", () => { test.each(REQUIRED_ENTRIES.map((entry) => [entry]))( "permissions.allow contains the canonical entry %s", (entry) => { + if (!parsed) { + return; + } + expect(parsed.permissions.allow).toContain(entry); } ); diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js index 5e41c056..c3d5a42c 100644 --- a/scripts/__tests__/fix-csharp-underscore-methods.test.js +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -10,6 +10,8 @@ const { normalizeExplicitPathArg, toWindowsAbsolutePathFromPosixDrivePath, resolveCandidatePath, + isPathInsideRoot, + isExcludedRepoLocalPath, convertMethodNameToPascalCase, collectMethodRenames, applyMethodRenames @@ -72,6 +74,39 @@ describe("fix-csharp-underscore-methods", () => { expect(result).toBe("C:\\Users\\dev\\FixMe.CS"); }); + test.each([ + ["posix repo file", "/tmp/repo", "/tmp/repo/Runtime/FixMe.cs", true, false], + ["posix excluded repo file", "/tmp/repo", "/tmp/repo/Library/FixMe.cs", true, true], + ["posix outside excluded segment", "/tmp/repo", "/tmp/outside/Library/FixMe.cs", false, false], + [ + "win32 repo file", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\repo", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\repo\\Runtime\\FixMe.cs", + true, + false + ], + [ + "win32 excluded repo file", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\repo", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\repo\\.git\\nested\\FixMe.cs", + true, + true + ], + [ + "win32 outside excluded segment", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\repo", + "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\outside\\Library\\FixMe.cs", + false, + false + ] + ])( + "repo-local exclusions use paths relative to repo root: %s", + (_label, repoRoot, filePath, expectedInside, expectedExcluded) => { + expect(isPathInsideRoot(repoRoot, filePath)).toBe(expectedInside); + expect(isExcludedRepoLocalPath(repoRoot, filePath)).toBe(expectedExcluded); + } + ); + test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); diff --git a/scripts/__tests__/unity-perf-isolation.test.js b/scripts/__tests__/unity-perf-isolation.test.js index 04e133b8..9a5abe06 100644 --- a/scripts/__tests__/unity-perf-isolation.test.js +++ b/scripts/__tests__/unity-perf-isolation.test.js @@ -3,7 +3,7 @@ * * The .llm/context.md "perf isolation" line (114) requires that the * Benchmarks/Allocations asmdefs run ONLY on the scheduled - * benchmarks workflow, never on the PR gate. The single source of truth for + * benchmarks workflow template, never on the default local run. The single source of truth for * that decision is scripts/unity/lib/asmdef-discovery.js — both the run-tests * scripts and the Unity Tests workflow shell out to it via `node -e`. This * test locks the contract end-to-end: @@ -134,8 +134,8 @@ describe("unity perf-isolation contract", () => { } }); - test("unity-tests.yml shells out to defaultIncludeAssemblies (no hardcoded asmdef list)", () => { - const workflowPath = path.join(REPO_ROOT, ".github", "workflows", "unity-tests.yml"); + test("disabled unity-tests.yml template shells out to defaultIncludeAssemblies (no hardcoded asmdef list)", () => { + const workflowPath = path.join(REPO_ROOT, ".github", "workflows-disabled", "unity-tests.yml"); const workflow = fs.readFileSync(workflowPath, "utf8"); // The single source of truth contract: the workflow must shell out to diff --git a/scripts/__tests__/unity-test-harness-contract.test.js b/scripts/__tests__/unity-test-harness-contract.test.js index 6ada4015..bd7d6083 100644 --- a/scripts/__tests__/unity-test-harness-contract.test.js +++ b/scripts/__tests__/unity-test-harness-contract.test.js @@ -83,13 +83,13 @@ describe("unity test harness contract (.unity-test-project/)", () => { expect(content).toMatch(/m_EditorVersion:/); }); - test("editor version matches one of the unity-tests.yml matrix entries", () => { + test("editor version matches one of the disabled unity-tests.yml template matrix entries", () => { const content = fs.readFileSync(versionPath, "utf8"); const match = content.match(/m_EditorVersion:\s*(\S+)/); expect(match).not.toBeNull(); const projectVersion = match[1].trim(); - const workflowPath = path.join(REPO_ROOT, ".github", "workflows", "unity-tests.yml"); + const workflowPath = path.join(REPO_ROOT, ".github", "workflows-disabled", "unity-tests.yml"); const workflowText = fs.readFileSync(workflowPath, "utf8"); // The matrix is generated dynamically inside a shell heredoc, so a // structural YAML walk would skip those values; the canonical list diff --git a/scripts/__tests__/unity-workflow-shape.test.js b/scripts/__tests__/unity-workflow-shape.test.js index 1015b351..aa4bf586 100644 --- a/scripts/__tests__/unity-workflow-shape.test.js +++ b/scripts/__tests__/unity-workflow-shape.test.js @@ -1,13 +1,12 @@ /** - * @fileoverview Contract tests for the shape of the Unity-related GitHub - * Actions workflows. + * @fileoverview Contract tests for the shape of Unity-related GitHub Actions + * workflow templates. * * These workflows have non-obvious invariants that, if violated, cause silent * regressions: - * - unity-benchmarks.yml MUST be schedule + workflow_dispatch only (no - * pull_request, no push). Adding either trigger would convert the noisy - * perf suite into a PR-blocking gate, which is an explicit project-lead - * directive (see header of the file). + * - game-ci backed Unity workflows are temporarily moved out of + * .github/workflows while the GitHub-hosted game-ci jobs are disabled. + * Local runners remain available. * - All Unity workflows must include manifest, packages-lock, and * ProjectVersion in the exact Library cache key, with no broad restore * keys — otherwise stale Library/ dirs from a prior Unity version corrupt @@ -28,6 +27,8 @@ const yaml = require("js-yaml"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows"); +const DISABLED_WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows-disabled"); +const DISABLED_UNITY_WORKFLOWS = ["unity-tests.yml", "unity-il2cpp.yml", "unity-benchmarks.yml"]; function readWorkflow(name) { const abs = path.join(WORKFLOWS_DIR, name); @@ -35,6 +36,12 @@ function readWorkflow(name) { return fs.readFileSync(abs, "utf8"); } +function readDisabledWorkflow(name) { + const abs = path.join(DISABLED_WORKFLOWS_DIR, name); + expect(fs.existsSync(abs)).toBe(true); + return fs.readFileSync(abs, "utf8"); +} + function loadWorkflowYaml(name) { const text = readWorkflow(name); // js-yaml interprets the bare `on` key as the YAML 1.1 boolean `true` @@ -43,6 +50,10 @@ function loadWorkflowYaml(name) { return yaml.load(text); } +function loadDisabledWorkflowYaml(name) { + return yaml.load(readDisabledWorkflow(name)); +} + function expectExactUnityLibraryCache(text) { expect(text).toContain("actions/cache@v4"); expect(text).toContain("manifest.json"); @@ -52,11 +63,26 @@ function expectExactUnityLibraryCache(text) { expect(text).not.toContain("restore-keys:"); } -describe(".github/workflows/unity-tests.yml", () => { +describe("Unity game-ci workflows disabled in GitHub", () => { + test.each(DISABLED_UNITY_WORKFLOWS)("%s is not an active GitHub workflow", (name) => { + expect(fs.existsSync(path.join(WORKFLOWS_DIR, name))).toBe(false); + expect(fs.existsSync(path.join(DISABLED_WORKFLOWS_DIR, name))).toBe(true); + }); +}); + +describe(".github/workflows-disabled/unity-tests.yml", () => { let text; + let parsed; beforeAll(() => { - text = readWorkflow("unity-tests.yml"); + text = readDisabledWorkflow("unity-tests.yml"); + parsed = loadDisabledWorkflowYaml("unity-tests.yml"); + }); + + test("stays workflow_dispatch only as a disabled template", () => { + const onBlock = parsed.on || parsed[true]; + expect(Object.keys(onBlock).sort()).toEqual(["workflow_dispatch"]); + expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); }); test("uses game-ci/unity-test-runner@v4", () => { @@ -80,11 +106,19 @@ describe(".github/workflows/unity-tests.yml", () => { }); }); -describe(".github/workflows/unity-il2cpp.yml", () => { +describe(".github/workflows-disabled/unity-il2cpp.yml", () => { let text; + let parsed; beforeAll(() => { - text = readWorkflow("unity-il2cpp.yml"); + text = readDisabledWorkflow("unity-il2cpp.yml"); + parsed = loadDisabledWorkflowYaml("unity-il2cpp.yml"); + }); + + test("stays workflow_dispatch only as a disabled template", () => { + const onBlock = parsed.on || parsed[true]; + expect(Object.keys(onBlock).sort()).toEqual(["workflow_dispatch"]); + expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); }); test("uses game-ci/unity-builder@v4", () => { @@ -112,16 +146,16 @@ describe(".github/workflows/unity-il2cpp.yml", () => { }); }); -describe(".github/workflows/unity-benchmarks.yml", () => { +describe(".github/workflows-disabled/unity-benchmarks.yml", () => { let text; let parsed; beforeAll(() => { - text = readWorkflow("unity-benchmarks.yml"); - parsed = loadWorkflowYaml("unity-benchmarks.yml"); + text = readDisabledWorkflow("unity-benchmarks.yml"); + parsed = loadDisabledWorkflowYaml("unity-benchmarks.yml"); }); - test("`on:` block has ONLY schedule and workflow_dispatch (no pull_request, no push)", () => { + test("`on:` block has ONLY workflow_dispatch as a disabled template", () => { // YAML 1.1 turns the bare `on` key into `true`; check both keys to // tolerate either representation across yaml/parser versions. const onBlock = parsed.on || parsed[true]; @@ -129,7 +163,8 @@ describe(".github/workflows/unity-benchmarks.yml", () => { expect(typeof onBlock).toBe("object"); const triggerKeys = Object.keys(onBlock).sort(); - expect(triggerKeys).toEqual(["schedule", "workflow_dispatch"]); + expect(triggerKeys).toEqual(["workflow_dispatch"]); + expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); // Belt-and-suspenders text grep: a stray `pull_request:` or `push:` // anywhere in the on: block (even commented-out or otherwise missed diff --git a/scripts/fix-csharp-underscore-methods.js b/scripts/fix-csharp-underscore-methods.js index 22553810..f5251fdd 100644 --- a/scripts/fix-csharp-underscore-methods.js +++ b/scripts/fix-csharp-underscore-methods.js @@ -157,17 +157,51 @@ function isExcludedPath(fullPath) { return EXCLUDED_DIRECTORY_PATTERNS.some((pattern) => pattern.test(fullPath)); } +function pathModuleForPath(value) { + return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\") ? path.win32 : path.posix; +} + function isPathInsideRoot(rootDir, fullPath) { - const normalizedRootDir = path.resolve(rootDir); - const normalizedFullPath = path.resolve(fullPath); - const relativePath = path.relative(normalizedRootDir, normalizedFullPath); + const pathModule = + pathModuleForPath(rootDir) === path.win32 ? path.win32 : pathModuleForPath(fullPath); + const normalizedRootDir = pathModule.resolve(rootDir); + const normalizedFullPath = pathModule.resolve(fullPath); + const relativePath = pathModule.relative(normalizedRootDir, normalizedFullPath); if (relativePath === "") { return true; } // On Windows, different drive letters can yield an absolute relative path. - return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + return !relativePath.startsWith("..") && !pathModule.isAbsolute(relativePath); +} + +function isExcludedRepoLocalPath(repoRoot, fullPath) { + const pathModule = + pathModuleForPath(repoRoot) === path.win32 ? path.win32 : pathModuleForPath(fullPath); + const relativePath = pathModule.relative(pathModule.resolve(repoRoot), pathModule.resolve(fullPath)); + + if (relativePath === "" || relativePath.startsWith("..") || pathModule.isAbsolute(relativePath)) { + return false; + } + + return relativePath + .split(/[\\/]+/) + .filter(Boolean) + .some((segment) => + [ + ".git", + "node_modules", + "Library", + "Obj", + "obj", + "Temp", + ".vs", + ".venv", + ".artifacts", + "site", + ].includes(segment) + ); } // INTERNAL ONLY: rootDir is expected to be the repository root. @@ -220,7 +254,7 @@ function resolveExplicitFiles(repoRoot, fileArgs) { } // Apply excluded-directory patterns only for repo-local paths. - if (isPathInsideRoot(repoRoot, candidatePath) && isExcludedPath(candidatePath)) { + if (isPathInsideRoot(repoRoot, candidatePath) && isExcludedRepoLocalPath(repoRoot, candidatePath)) { continue; } @@ -457,6 +491,8 @@ module.exports = { normalizeExplicitPathArg, toWindowsAbsolutePathFromPosixDrivePath, resolveCandidatePath, + isPathInsideRoot, + isExcludedRepoLocalPath, convertMethodNameToPascalCase, collectMethodRenames, applyMethodRenames, diff --git a/scripts/unity/lib/asmdef-discovery.js b/scripts/unity/lib/asmdef-discovery.js index e2b5c694..9490bb21 100644 --- a/scripts/unity/lib/asmdef-discovery.js +++ b/scripts/unity/lib/asmdef-discovery.js @@ -9,7 +9,7 @@ * - scripts/unity/run-tests.sh (default include / exclude assembly list) * - scripts/unity/run-tests.ps1 (PowerShell parity) * - scripts/__tests__/unity-perf-isolation.test.js (Phase 4 contract) - * - .github/workflows/unity-tests.yml (Phase 3 customParameters) + * - .github/workflows-disabled/unity-tests.yml (customParameters template) * * No filesystem mutation. Pure functions only. * diff --git a/scripts/unity/lib/parse-test-results.py b/scripts/unity/lib/parse-test-results.py index 7ebf8811..6a3fc4a9 100644 --- a/scripts/unity/lib/parse-test-results.py +++ b/scripts/unity/lib/parse-test-results.py @@ -4,7 +4,7 @@ # ============================================================================= # Tiny NUnit XML summary extractor. The single source of truth for parsing the # first element from a Unity Test Framework results.xml. Used by: -# - .github/workflows/unity-il2cpp.yml (parse step) +# - .github/workflows-disabled/unity-il2cpp.yml (parse step template) # - scripts/unity/run-tests.sh (print_results_summary) # - scripts/unity/run-tests.ps1 (Write-ResultsSummary) # All three callers consume the same one-line "OK ..." format below, so any From 6232d10da82242a989a89eea58fdb95ee6168e96 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 6 May 2026 17:56:20 -0700 Subject: [PATCH 16/16] PR feedback --- .../fix-csharp-underscore-methods.test.js | 78 +++++++++++++++---- scripts/fix-csharp-underscore-methods.js | 51 ++++++++++-- 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js index c3d5a42c..aa9d87d9 100644 --- a/scripts/__tests__/fix-csharp-underscore-methods.test.js +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -6,10 +6,12 @@ const path = require("path"); const childProcess = require("child_process"); const { + DEBUG_ENV_VAR, isCsharpSourceFile, normalizeExplicitPathArg, toWindowsAbsolutePathFromPosixDrivePath, resolveCandidatePath, + canonicalPathForComparison, isPathInsideRoot, isExcludedRepoLocalPath, convertMethodNameToPascalCase, @@ -37,10 +39,22 @@ function makeTempGitRepo(label) { encoding: "utf8" }); - expect(initResult.status).toBe(0); + expectSpawnStatus(initResult, 0); return tempRepo; } +function expectSpawnStatus(result, expectedStatus) { + if (result.status !== expectedStatus) { + throw new Error( + [ + `Expected subprocess status ${expectedStatus}, received ${result.status}.`, + `stdout:\n${result.stdout || ""}`, + `stderr:\n${result.stderr || ""}` + ].join("\n") + ); + } +} + describe("fix-csharp-underscore-methods", () => { test("isCsharpSourceFile supports case-insensitive .cs and rejects .meta", () => { expect(isCsharpSourceFile("Runtime/FixMe.cs")).toBe(true); @@ -107,6 +121,40 @@ describe("fix-csharp-underscore-methods", () => { } ); + test("repo-local exclusions canonicalize filesystem aliases before comparing paths", () => { + const realpathSync = jest.fn((candidatePath) => { + const normalizedPath = candidatePath.replace(/\\/g, "/"); + + if (normalizedPath === "/private/var/folders/repo") { + return "/private/var/folders/repo"; + } + + if (normalizedPath === "/var/folders/repo") { + return "/private/var/folders/repo"; + } + + if (normalizedPath === "/var/folders/repo/.git/nested/FixMe.cs") { + return "/private/var/folders/repo/.git/nested/FixMe.cs"; + } + + return candidatePath; + }); + + expect( + canonicalPathForComparison("/var/folders/repo/.git/nested/FixMe.cs", { realpathSync }) + ).toBe("/private/var/folders/repo/.git/nested/FixMe.cs"); + expect( + isPathInsideRoot("/private/var/folders/repo", "/var/folders/repo/.git/nested/FixMe.cs", { + realpathSync + }) + ).toBe(true); + expect( + isExcludedRepoLocalPath("/private/var/folders/repo", "/var/folders/repo/.git/nested/FixMe.cs", { + realpathSync + }) + ).toBe(true); + }); + test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); @@ -244,7 +292,7 @@ describe("fix-csharp-underscore-methods", () => { } ); - expect(result.status).toBe(1); + expectSpawnStatus(result, 1); expect(result.stderr).toContain("Found C# methods with underscores"); expect(result.stderr).toContain("NeedsFix.cs"); } finally { @@ -268,7 +316,7 @@ describe("fix-csharp-underscore-methods", () => { encoding: "utf8" }); - expect(result.status).toBe(0); + expectSpawnStatus(result, 0); const updated = fs.readFileSync(filePath, "utf8"); expect(updated).toContain("ParseLineBare"); @@ -302,7 +350,7 @@ describe("fix-csharp-underscore-methods", () => { } ); - expect(result.status).toBe(1); + expectSpawnStatus(result, 1); expect(result.stderr).toContain("NeedsFix.cs"); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -325,7 +373,7 @@ describe("fix-csharp-underscore-methods", () => { encoding: "utf8" }); - expect(result.status).toBe(0); + expectSpawnStatus(result, 0); const updated = fs.readFileSync(filePath, "utf8"); expect(updated).toContain("ParseLineBare"); expect(updated).not.toContain("Parse_Line_Bare"); @@ -352,7 +400,7 @@ describe("fix-csharp-underscore-methods", () => { encoding: "utf8" }); - expect(result.status).toBe(0); + expectSpawnStatus(result, 0); const updated = fs.readFileSync(filePath, "utf8"); expect(updated).toContain("ParseLineBare"); @@ -394,7 +442,7 @@ describe("fix-csharp-underscore-methods", () => { } ); - expect(result.status).toBe(1); + expectSpawnStatus(result, 1); expect(result.stderr).toContain("Found C# methods with underscores"); expect(result.stderr).toContain("OutsideSegmentNeedsFix.cs"); } finally { @@ -430,7 +478,7 @@ describe("fix-csharp-underscore-methods", () => { encoding: "utf8" }); - expect(result.status).toBe(0); + expectSpawnStatus(result, 0); const updated = fs.readFileSync(filePath, "utf8"); expect(updated).toContain("ParseLineBare"); @@ -470,7 +518,7 @@ describe("fix-csharp-underscore-methods", () => { } ); - expect(result.status).toBe(1); + expectSpawnStatus(result, 1); expect(result.stderr).toContain("Found C# methods with underscores"); expect(result.stderr).toContain("OutsideRelativeNeedsFix.cs"); } finally { @@ -508,23 +556,27 @@ describe("fix-csharp-underscore-methods", () => { [FIXER_SCRIPT_PATH, "--check", filePath], { cwd: repoRoot, - encoding: "utf8" + encoding: "utf8", + env: { ...process.env, [DEBUG_ENV_VAR]: "1" } } ); - expect(checkResult.status).toBe(0); + expectSpawnStatus(checkResult, 0); expect(checkResult.stdout).toContain("No C# files to process."); + expect(checkResult.stderr).toContain("Skipping repo-local excluded path"); const rewriteResult = childProcess.spawnSync( process.execPath, [FIXER_SCRIPT_PATH, filePath], { cwd: repoRoot, - encoding: "utf8" + encoding: "utf8", + env: { ...process.env, [DEBUG_ENV_VAR]: "1" } } ); - expect(rewriteResult.status).toBe(0); + expectSpawnStatus(rewriteResult, 0); + expect(rewriteResult.stderr).toContain("Skipping repo-local excluded path"); const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); expect(contentAfterRewrite).toContain("Parse_Line_Bare"); diff --git a/scripts/fix-csharp-underscore-methods.js b/scripts/fix-csharp-underscore-methods.js index f5251fdd..9539440f 100644 --- a/scripts/fix-csharp-underscore-methods.js +++ b/scripts/fix-csharp-underscore-methods.js @@ -18,6 +18,7 @@ const METHOD_DECLARATION_PATTERN = const CSHARP_SOURCE_FILE_PATTERN = /\.cs$/i; const META_FILE_PATTERN = /\.meta$/i; const WINDOWS_POSIX_DRIVE_PATH_PATTERN = /^\/([A-Za-z])\/(.+)$/; +const DEBUG_ENV_VAR = "DXMSG_CSHARP_UNDERSCORE_DEBUG"; const EXCLUDED_DIRECTORY_PATTERNS = [ /(^|[\\/])\.git([\\/]|$)/i, @@ -157,15 +158,40 @@ function isExcludedPath(fullPath) { return EXCLUDED_DIRECTORY_PATTERNS.some((pattern) => pattern.test(fullPath)); } +function debugLog(message) { + if (process.env[DEBUG_ENV_VAR] === "1") { + console.error(`[fix-csharp-underscore-methods] ${message}`); + } +} + function pathModuleForPath(value) { return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\") ? path.win32 : path.posix; } -function isPathInsideRoot(rootDir, fullPath) { +function realpathIfExists(fullPath, { realpathSync = fs.realpathSync } = {}) { + try { + if (typeof realpathSync.native === "function") { + return realpathSync.native(fullPath); + } + + return realpathSync(fullPath); + } catch { + return fullPath; + } +} + +function canonicalPathForComparison(fullPath, options = {}) { + const pathModule = pathModuleForPath(fullPath); + return realpathIfExists(pathModule.resolve(fullPath), options); +} + +function isPathInsideRoot(rootDir, fullPath, options = {}) { + const canonicalRootDir = canonicalPathForComparison(rootDir, options); + const canonicalFullPath = canonicalPathForComparison(fullPath, options); const pathModule = - pathModuleForPath(rootDir) === path.win32 ? path.win32 : pathModuleForPath(fullPath); - const normalizedRootDir = pathModule.resolve(rootDir); - const normalizedFullPath = pathModule.resolve(fullPath); + pathModuleForPath(canonicalRootDir) === path.win32 ? path.win32 : pathModuleForPath(canonicalFullPath); + const normalizedRootDir = pathModule.resolve(canonicalRootDir); + const normalizedFullPath = pathModule.resolve(canonicalFullPath); const relativePath = pathModule.relative(normalizedRootDir, normalizedFullPath); if (relativePath === "") { @@ -176,10 +202,15 @@ function isPathInsideRoot(rootDir, fullPath) { return !relativePath.startsWith("..") && !pathModule.isAbsolute(relativePath); } -function isExcludedRepoLocalPath(repoRoot, fullPath) { +function isExcludedRepoLocalPath(repoRoot, fullPath, options = {}) { + const canonicalRepoRoot = canonicalPathForComparison(repoRoot, options); + const canonicalFullPath = canonicalPathForComparison(fullPath, options); const pathModule = - pathModuleForPath(repoRoot) === path.win32 ? path.win32 : pathModuleForPath(fullPath); - const relativePath = pathModule.relative(pathModule.resolve(repoRoot), pathModule.resolve(fullPath)); + pathModuleForPath(canonicalRepoRoot) === path.win32 ? path.win32 : pathModuleForPath(canonicalFullPath); + const relativePath = pathModule.relative( + pathModule.resolve(canonicalRepoRoot), + pathModule.resolve(canonicalFullPath) + ); if (relativePath === "" || relativePath.startsWith("..") || pathModule.isAbsolute(relativePath)) { return false; @@ -250,16 +281,19 @@ function resolveExplicitFiles(repoRoot, fileArgs) { } if (!fs.existsSync(candidatePath)) { + debugLog(`Skipping missing explicit path: raw=${JSON.stringify(rawArg)} resolved=${candidatePath}`); continue; } // Apply excluded-directory patterns only for repo-local paths. if (isPathInsideRoot(repoRoot, candidatePath) && isExcludedRepoLocalPath(repoRoot, candidatePath)) { + debugLog(`Skipping repo-local excluded path: ${candidatePath}`); continue; } const stats = fs.statSync(candidatePath); if (!stats.isFile() || !isCsharpSourceFile(candidatePath)) { + debugLog(`Skipping non-file or non-C# explicit path: ${candidatePath}`); continue; } @@ -487,10 +521,13 @@ module.exports = { CSHARP_SOURCE_FILE_PATTERN, META_FILE_PATTERN, WINDOWS_POSIX_DRIVE_PATH_PATTERN, + DEBUG_ENV_VAR, isCsharpSourceFile, normalizeExplicitPathArg, toWindowsAbsolutePathFromPosixDrivePath, resolveCandidatePath, + realpathIfExists, + canonicalPathForComparison, isPathInsideRoot, isExcludedRepoLocalPath, convertMethodNameToPascalCase,