From 1d0e3140e0ae4a79366c227f121c02cc4f32614e Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 05:47:22 +0000 Subject: [PATCH 001/147] Cache passive settings lookups and bound harvester rescans (#613) --- CHANGELOG.md | 2 + .../Analyzers/DxMessagingConsoleHarvester.cs | 69 ++-- Editor/Settings/DxMessagingSettings.cs | 162 +++++++- .../DxMessagingSettingsAssetPostprocessor.cs | 25 ++ ...essagingSettingsAssetPostprocessor.cs.meta | 11 + .../Editor/DxMessagingSettingsLookupTests.cs | 390 ++++++++++++++++++ .../DxMessagingSettingsLookupTests.cs.meta | 11 + 7 files changed, 619 insertions(+), 51 deletions(-) create mode 100644 Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs create mode 100644 Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta create mode 100644 Tests/Editor/DxMessagingSettingsLookupTests.cs create mode 100644 Tests/Editor/DxMessagingSettingsLookupTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 545542ed1..163809f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stop repeated console-harvester settings searches and scans when the console bridge is disabled + ([#613](https://github.com/Ambiguous-Interactive/DxMessaging/issues/613)). - Fix provider fallback selection in registration builders, components, dependency-injection adapters, and emission helpers, including destroyed Unity-backed providers, deferred per-build resolution, scoped container fallbacks, and live serialized-handle fallbacks diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs index d493ff7f2..27617ef1a 100644 --- a/Editor/Analyzers/DxMessagingConsoleHarvester.cs +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -189,6 +189,9 @@ does not expose the expected LogEntries shape. private static double _lastTickTime; private static int _lastSeenCount; + private static int _lastSettingsRevision = -1; + + internal static int RescanCount { get; private set; } /* Latch flipped on by `OnAssemblyCompilationFinished` to coalesce the burst of one-event- @@ -372,10 +375,7 @@ until Unity is idle so settings load doesn't fight a transitional asset-import ScheduleRescanWhenIdle(); AssemblyReloadEvents.afterAssemblyReload += ScheduleRescanWhenIdle; CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished; - if (!_logEntriesDisabled) - { - EditorApplication.update += Tick; - } + EditorApplication.update += Tick; } catch (Exception ex) { @@ -408,9 +408,8 @@ public static void RescanNow() Critical: NEVER touch LogEntries reflection or AssetDatabase while Unity is mid- compile or mid-asset-update. Reading LogEntries during compilation contends with the compiler's own log-buffer lock and can deadlock the editor. Touching AssetDatabase - (via TryLoadSettings → GetOrCreateSettings → CreateAsset) during compilation - schedules an import that re-triggers compilation; an infinite-loop trap that - permanently freezes script-compilation startup. Defer to the post-compile state + during compilation risks importer reentrancy. Settings reads stay passive and + never create an asset. Defer to the post-compile state and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. */ if (EditorApplication.isCompiling || EditorApplication.isUpdating) @@ -418,7 +417,10 @@ and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. return; } + int settingsRevision = DxMessagingSettings.SettingsRevision; + RescanCount++; DxMessagingSettings settings = TryLoadSettings(); + _lastSettingsRevision = settingsRevision; if (settings != null && !settings._baseCallCheckEnabled) { bool wasNonEmpty = 0 < SnapshotInternal.Count; @@ -831,30 +833,14 @@ Dictionary> drained /// public static void RequestRescan() { - if (!IsAvailable) - { - return; - } - /* - Setting _lastSeenCount to a sentinel forces the next Tick to see a count delta and - call RescanNow on the editor's update thread (when LogEntries is wired). When - LogEntries is unavailable, Tick is not registered, so we fall back to delayCall. - */ - if (_logEntriesDisabled) - { - ScheduleRescanWhenIdle(); - return; - } - _lastSeenCount = -1; + ScheduleRescanWhenIdle(); } private static void Tick() { /* - Tick is only registered when the LogEntries reflection layer is available, so we - do NOT need to re-check _logEntriesDisabled here; but the IsAvailable guard - protects against a future failure mode where IsAvailable is flipped to false at - runtime. + Settings invalidation is observed even when the optional LogEntries bridge is + unavailable. Asset loading and rescans remain on the idle editor main thread. */ if (!IsAvailable) { @@ -881,6 +867,25 @@ touches the same buffer. } _lastTickTime = now; + if (_lastSettingsRevision != DxMessagingSettings.SettingsRevision) + { + RescanNow(); + return; + } + if (_logEntriesDisabled) + { + return; + } + DxMessagingSettings settings = TryLoadSettings(); + if ( + settings == null + || !settings._baseCallCheckEnabled + || !settings._useConsoleBridge + ) + { + return; + } + int currentCount; try { @@ -1031,17 +1036,7 @@ exist yet (fresh project, first compile), the harvester treats the snapshot as */ try { - string[] guids = AssetDatabase.FindAssets($"t:{nameof(DxMessagingSettings)}"); - if (guids == null || guids.Length == 0) - { - return null; - } - string assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); - if (string.IsNullOrEmpty(assetPath)) - { - return null; - } - return AssetDatabase.LoadAssetAtPath(assetPath); + return DxMessagingSettings.LoadSettingsPassive(); } catch (Exception ex) { diff --git a/Editor/Settings/DxMessagingSettings.cs b/Editor/Settings/DxMessagingSettings.cs index 0c298369d..6a1d712c9 100644 --- a/Editor/Settings/DxMessagingSettings.cs +++ b/Editor/Settings/DxMessagingSettings.cs @@ -1,8 +1,10 @@ namespace DxMessaging.Editor.Settings { #if UNITY_EDITOR + using System; using System.Collections.Generic; using System.Linq; + using System.Threading; using Core.MessageBus; using UnityEditor; using UnityEngine; @@ -19,6 +21,89 @@ public sealed class DxMessagingSettings : ScriptableObject { private const string SettingsPath = "Assets/Editor/DxMessagingSettings.asset"; + private static readonly Func PassiveLoadAtPath = + AssetDatabase.LoadAssetAtPath; + private static readonly Func PassiveFindGuids = FindSettingsGuids; + private static readonly Func PassiveGuidToPath = + AssetDatabase.GUIDToAssetPath; + + private static int _settingsRevision; + private static int _cachedSettingsRevision = -1; + private static DxMessagingSettings _cachedSettings; + private static string _cachedSettingsPath; + + internal static int SettingsRevision => Volatile.Read(ref _settingsRevision); + internal static int SettingsLookupCount { get; private set; } + internal static int SettingsSearchCount { get; private set; } + + internal static void InvalidateSettingsCache() + { + // OnValidate can signal from a loading thread; perform no Unity work here. + Interlocked.Increment(ref _settingsRevision); + } + + internal static void NotifyAssetsChanged( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths + ) + { + if ( + !ContainsSettingsChange(importedAssets) + && !ContainsSettingsChange(deletedAssets) + && !ContainsSettingsChange(movedAssets) + && !ContainsSettingsChange(movedFromAssetPaths) + ) + { + return; + } + + InvalidateSettingsCache(); + Analyzers.DxMessagingConsoleHarvester.ScheduleRescanWhenIdle(); + } + + private static bool ContainsSettingsChange(string[] paths) + { + if (paths == null) + { + return false; + } + foreach (string path in paths) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + /* + Any .asset can introduce a settings asset after a cached miss. Also retain + moves/deletes of the selected asset or its folder, including legacy paths. + Do not query AssetDatabase from an import callback to classify a new asset. + */ + if ( + path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase) + || IsAssetOrParentPath(path, SettingsPath) + || IsAssetOrParentPath(path, _cachedSettingsPath) + ) + { + return true; + } + } + return false; + } + + private static bool IsAssetOrParentPath(string changedPath, string settingsPath) + { + return !string.IsNullOrEmpty(settingsPath) + && ( + string.Equals(changedPath, settingsPath, StringComparison.OrdinalIgnoreCase) + || settingsPath.StartsWith( + changedPath.TrimEnd('/') + "/", + StringComparison.OrdinalIgnoreCase + ) + ); + } + [SerializeField] internal DiagnosticsTarget _diagnosticsTargets = DiagnosticsTarget.Off; @@ -94,9 +179,9 @@ public bool SuppressDomainReloadWarning /// compile-time analyzer warnings remain unless explicitly suppressed via .editorconfig. /// /// - /// S3: toggling from false back to true pokes + /// Changing this toggle pokes /// on the next editor - /// tick so the snapshot repopulates without waiting for the user to clear/re-emit warnings + /// tick so the snapshot clears or repopulates without waiting for the user to clear/re-emit warnings /// or to manually invoke Tools/Wallstop Studios/DxMessaging/Rescan Base-Call Warnings. The round-trip /// is intentionally indirect (delayCall > RescanNow) to keep this property setter cheap and /// safe to invoke from any editor context -- including OnValidate, where AssetDatabase may @@ -109,12 +194,12 @@ public bool BaseCallCheckEnabled { bool previous = _baseCallCheckEnabled; _baseCallCheckEnabled = value; - if (!previous && value) + if (previous != value) { /* A master-toggle flip doesn't need a synchronous reflective harvest right now; - the polled tick (~250ms) will pick up the sentinel cheaply on the editor's - own update thread, avoiding a heavy reflection sweep on the main thread when + the idle callback coalesces the request on the editor's update thread, + avoiding a heavy reflection sweep on the main thread when the user has just clicked a checkbox. Indirected through delayCall so the setter is safe to invoke from any editor context (OnValidate, button click, etc.) without risking AssetDatabase reentrancy. @@ -195,7 +280,8 @@ internal DiagnosticsTarget EffectiveDiagnosticsTargets /// /// Loads the settings asset without creating, saving, or migrating it. Returns - /// null when no asset exists. + /// null when no asset exists. Caches both a loaded asset and its absence until + /// settings validation or a potential settings asset import, move, or deletion. /// /// /// Unlike , this performs NO AssetDatabase mutation @@ -210,19 +296,63 @@ internal DiagnosticsTarget EffectiveDiagnosticsTargets /// internal static DxMessagingSettings LoadSettingsPassive() { - DxMessagingSettings settings = AssetDatabase.LoadAssetAtPath( - SettingsPath - ); + return LoadSettingsPassive(PassiveLoadAtPath, PassiveFindGuids, PassiveGuidToPath); + } + + private static string[] FindSettingsGuids() + { + return AssetDatabase.FindAssets($"t:{nameof(DxMessagingSettings)}"); + } + + internal static DxMessagingSettings LoadSettingsPassive( + Func loadAtPath, + Func findGuids, + Func guidToPath + ) + { + SettingsLookupCount++; + int revision = SettingsRevision; + if ( + _cachedSettingsRevision == revision + && (ReferenceEquals(_cachedSettings, null) || _cachedSettings != null) + ) + { + return _cachedSettings; + } + DxMessagingSettings settings = loadAtPath(SettingsPath); + string settingsPath = SettingsPath; if (settings == null) { - settings = AssetDatabase - .FindAssets($"t:{nameof(DxMessagingSettings)}") - .Select(AssetDatabase.GUIDToAssetPath) - .Select(AssetDatabase.LoadAssetAtPath) - .FirstOrDefault(asset => asset != null); + SettingsSearchCount++; + string[] guids = findGuids(); + if (guids != null) + { + foreach (string guid in guids) + { + string path = guidToPath(guid); + if (string.IsNullOrEmpty(path)) + { + continue; + } + settings = loadAtPath(path); + if (settings != null) + { + settingsPath = path; + break; + } + } + } } + _cachedSettings = settings; + _cachedSettingsPath = settings != null ? settingsPath : null; + /* + Publish only after a complete successful lookup. If OnValidate or another + invalidation occurs during loading, this older revision causes the next read + to retry. A destroyed Unity object also retries rather than becoming a miss. + */ + _cachedSettingsRevision = revision; return settings; } @@ -253,6 +383,9 @@ internal static DxMessagingSettings GetOrCreateSettings() } AssetDatabase.CreateAsset(settings, SettingsPath); AssetDatabase.SaveAssets(); + _cachedSettings = settings; + _cachedSettingsPath = SettingsPath; + _cachedSettingsRevision = SettingsRevision; } if (settings.ApplyLegacyDiagnosticsMigration()) @@ -301,6 +434,7 @@ only from OnValidate (user-driven edits) and from explicit Add/RemoveIgnoredType private void OnValidate() { + InvalidateSettingsCache(); EnsureIgnoreListInitialized(); /* Issue #210: OnValidate fires during asset deserialization, including the domain-load diff --git a/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs new file mode 100644 index 000000000..0b81ca39e --- /dev/null +++ b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs @@ -0,0 +1,25 @@ +namespace DxMessaging.Editor.Settings +{ +#if UNITY_EDITOR + using UnityEditor; + + // Invalidate after import without loading settings or mutating assets in the callback. + internal sealed class DxMessagingSettingsAssetPostprocessor : AssetPostprocessor + { + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths + ) + { + DxMessagingSettings.NotifyAssetsChanged( + importedAssets, + deletedAssets, + movedAssets, + movedFromAssetPaths + ); + } + } +#endif +} diff --git a/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta new file mode 100644 index 000000000..deb543851 --- /dev/null +++ b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 723fec350c874576b730d9b1afe030e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/DxMessagingSettingsLookupTests.cs b/Tests/Editor/DxMessagingSettingsLookupTests.cs new file mode 100644 index 000000000..af758ddca --- /dev/null +++ b/Tests/Editor/DxMessagingSettingsLookupTests.cs @@ -0,0 +1,390 @@ +#if UNITY_EDITOR +namespace DxMessaging.Tests.Editor +{ + using System; + using System.Collections.Generic; + using DxMessaging.Editor.Analyzers; + using DxMessaging.Editor.Settings; + using NUnit.Framework; + using UnityEditor; + using UnityEngine; + using Object = UnityEngine.Object; + + [TestFixture] + [NonParallelizable] + public sealed class DxMessagingSettingsLookupTests + { + private const string DefaultPath = "Assets/Editor/DxMessagingSettings.asset"; + private const string LegacyPath = "Assets/Legacy/Settings.asset"; + private readonly Dictionary _assets = new(); + private readonly Dictionary _guidPaths = new(); + private readonly List _createdObjects = new(); + private string[] _guids; + private int _loads; + private int _searches; + private int _resolutions; + + [SetUp] + public void SetUp() + { + DxMessagingSettings.InvalidateSettingsCache(); + _assets.Clear(); + _guidPaths.Clear(); + _guids = Array.Empty(); + _loads = 0; + _searches = 0; + _resolutions = 0; + } + + [TearDown] + public void TearDown() + { + DxMessagingSettings.InvalidateSettingsCache(); + foreach (Object instance in _createdObjects) + { + if (instance != null) + { + Object.DestroyImmediate(instance); + } + } + _createdObjects.Clear(); + } + + /// + /// Issue #613: the harvester searched all project assets on every rescan, including + /// when settings were absent. A completed miss must remain cached until invalidated. + /// + [Test] + public void RepeatedAbsentSettingsReadsPerformOneSearchAndNoFurtherLoads() + { + for (int i = 0; i < 64; i++) + { + Assert.That(Read() == null, Is.True, "Absent settings must remain absent."); + } + Assert.That(_searches, Is.EqualTo(1), "A stable miss must query exactly once."); + Assert.That(_loads, Is.EqualTo(1), "A cached miss must not reload the default path."); + Assert.That(_resolutions, Is.Zero, "An empty search has no GUIDs to resolve."); + } + + [Test] + public void DefaultSettingsArePreferredAndRemainCachedWithoutSearching() + { + DxMessagingSettings settings = CreateSettings(DefaultPath); + AddLegacySettings(); + for (int i = 0; i < 64; i++) + { + Assert.That( + Read(), + Is.SameAs(settings), + "The canonical asset must take precedence." + ); + } + Assert.That(_loads, Is.EqualTo(1), "An unchanged live asset must load once."); + Assert.That(_searches, Is.Zero, "The canonical asset requires no project-wide search."); + } + + [Test] + public void LegacySettingsAreResolvedOnceAndReturnLiveModifiedValues() + { + DxMessagingSettings settings = AddLegacySettings(); + Assert.That(Read(), Is.SameAs(settings), "Legacy settings must remain discoverable."); + settings._baseCallCheckEnabled = false; + settings._useConsoleBridge = true; + DxMessagingSettings current = Read(); + Assert.That( + current._baseCallCheckEnabled, + Is.False, + "Cached assets retain live edits." + ); + Assert.That( + current._useConsoleBridge, + Is.True, + "Bridge edits must be visible immediately." + ); + Assert.That( + _searches, + Is.EqualTo(1), + "Live reads must not repeat the fallback search." + ); + Assert.That(_loads, Is.EqualTo(2), "Only canonical and legacy paths should be loaded."); + } + + [Test] + public void HarvesterRescansReuseTheSharedPassiveSettingsCache() + { + DxMessagingSettings settings = CreateSettings(DefaultPath); + settings._baseCallCheckEnabled = false; + Read(); + int rescans = DxMessagingConsoleHarvester.RescanCount; + int lookups = DxMessagingSettings.SettingsLookupCount; + DxMessagingConsoleHarvester.RescanNow(); + DxMessagingConsoleHarvester.RescanNow(); + Assert.That( + DxMessagingConsoleHarvester.RescanCount, + Is.EqualTo(rescans + 2), + "Both idle rescan calls must execute the production settings path." + ); + Assert.That( + DxMessagingSettings.SettingsLookupCount, + Is.EqualTo(lookups + 2), + "Harvester rescans must use the shared passive loader." + ); + Assert.That( + _loads, + Is.EqualTo(1), + "The harvester must reuse the already-loaded asset." + ); + Assert.That(_searches, Is.Zero, "The harvester must not add a project-wide search."); + } + + [TestCase(true, false)] + [TestCase(false, true)] + public void MasterCheckChangesDeferOneRescanAndUnchangedValuesDoNot( + bool initialValue, + bool changedValue + ) + { + DxMessagingSettings settings = CreateSettings(DefaultPath); + settings._baseCallCheckEnabled = initialValue; + int callbacks = EditorApplication.delayCall?.GetInvocationList().Length ?? 0; + try + { + settings.BaseCallCheckEnabled = changedValue; + Assert.That( + EditorApplication.delayCall?.GetInvocationList().Length ?? 0, + Is.EqualTo(callbacks + 1), + "Enabling or disabling checks must defer a rescan without running it synchronously." + ); + settings.BaseCallCheckEnabled = changedValue; + Assert.That( + EditorApplication.delayCall?.GetInvocationList().Length ?? 0, + Is.EqualTo(callbacks + 1), + "Assigning an unchanged value must not add another callback." + ); + } + finally + { + EditorApplication.delayCall -= DxMessagingConsoleHarvester.RequestRescan; + } + } + + [TestCase("import")] + [TestCase("delete")] + [TestCase("move")] + [TestCase("move-from")] + public void AssetNotificationsInvalidateWithoutPerformingSettingsLookup(string channel) + { + Assert.That(Read() == null, Is.True, "Start from a cached miss."); + DxMessagingSettings settings = AddLegacySettings(); + int revision = DxMessagingSettings.SettingsRevision; + string[] changed = { LegacyPath }; + string[] empty = Array.Empty(); + DxMessagingSettings.NotifyAssetsChanged( + channel == "import" ? changed : empty, + channel == "delete" ? changed : empty, + channel == "move" ? changed : empty, + channel == "move-from" ? changed : empty + ); + Assert.That( + DxMessagingSettings.SettingsRevision, + Is.Not.EqualTo(revision), + "Every settings asset-change channel must invalidate the cached revision." + ); + Assert.That(_searches, Is.EqualTo(1), "Import notification must not query settings."); + Assert.That(_loads, Is.EqualTo(1), "Import notification must not load settings."); + Assert.That(Read(), Is.SameAs(settings), "The next read must discover the new asset."); + Assert.That( + _searches, + Is.EqualTo(2), + "The next revision permits exactly one new search." + ); + } + + [Test] + public void EmptyAndUnrelatedNotificationsPreserveCachedAbsence() + { + Read(); + int revision = DxMessagingSettings.SettingsRevision; + DxMessagingSettings.NotifyAssetsChanged(null, null, null, null); + DxMessagingSettings.NotifyAssetsChanged( + new[] { "Assets/Unrelated.cs", "Assets/Unrelated", "", null }, + Array.Empty(), + Array.Empty(), + Array.Empty() + ); + Assert.That( + DxMessagingSettings.SettingsRevision, + Is.EqualTo(revision), + "Empty paths and unrelated source/folder changes must not invalidate settings." + ); + Read(); + Assert.That(_searches, Is.EqualTo(1), "Unrelated changes must not amplify searches."); + } + + [Test] + public void ParentFolderMovesInvalidateTheSelectedLegacyPath() + { + DxMessagingSettings settings = AddLegacySettings(); + Read(); + _assets.Remove(LegacyPath); + const string newPath = "Assets/Renamed/Settings.asset"; + _assets[newPath] = settings; + _guidPaths["legacy"] = newPath; + DxMessagingSettings.NotifyAssetsChanged( + Array.Empty(), + Array.Empty(), + new[] { "Assets/Renamed" }, + new[] { "Assets/Legacy" } + ); + Assert.That( + Read(), + Is.SameAs(settings), + "Folder moves must re-resolve the same asset." + ); + Assert.That(_searches, Is.EqualTo(2), "A folder move must invalidate the legacy path."); + } + + [Test] + public void DeletedSettingsBecomeACachedMiss() + { + AddLegacySettings(); + Read(); + _assets.Remove(LegacyPath); + _guids = Array.Empty(); + DxMessagingSettings.NotifyAssetsChanged(null, new[] { LegacyPath }, null, null); + Assert.That( + Read() == null, + Is.True, + "A deleted setting must not retain its live reference." + ); + Assert.That(Read() == null, Is.True, "The new miss must be cached."); + Assert.That(_searches, Is.EqualTo(2), "Deletion must produce one replacement lookup."); + } + + [Test] + public void DestroyedUnityObjectIsReloadedWithoutAnAssetNotification() + { + DxMessagingSettings settings = AddLegacySettings(); + Read(); + Object.DestroyImmediate(settings); + DxMessagingSettings replacement = AddLegacySettings(); + Assert.That( + Read(), + Is.SameAs(replacement), + "Unity's destroyed-object null must trigger a new lookup, not a cached miss." + ); + Assert.That(_searches, Is.EqualTo(2), "Destroyed objects must be resolved again."); + } + + [Test] + public void InvalidGuidPathsAndNullAssetsDoNotHideALaterValidSettingsAsset() + { + DxMessagingSettings settings = AddLegacySettings(); + _guids = new[] { "missing-path", "missing-asset", "legacy" }; + _guidPaths["missing-asset"] = "Assets/Missing.asset"; + Assert.That( + Read(), + Is.SameAs(settings), + "Invalid early GUIDs must not hide a valid asset." + ); + Assert.That( + _resolutions, + Is.EqualTo(3), + "Every earlier invalid entry must be examined." + ); + Assert.That(_loads, Is.EqualTo(3), "Do not load an empty GUID path."); + } + + [Test] + public void FailedSearchDoesNotPublishACompletedMiss() + { + Assert.Throws( + () => + DxMessagingSettings.LoadSettingsPassive( + Load, + () => throw new InvalidOperationException("injected search failure"), + Resolve + ), + "The injected lookup failure must escape before publishing a cache entry." + ); + DxMessagingSettings settings = AddLegacySettings(); + Assert.That(Read(), Is.SameAs(settings), "A failed search must remain retryable."); + Assert.That(_searches, Is.EqualTo(1), "The successful retry must execute its search."); + } + + [Test] + public void InvalidationDuringLoadingRequiresAnotherCompletedLookup() + { + DxMessagingSettings settings = CreateSettings(DefaultPath); + DxMessagingSettings first = DxMessagingSettings.LoadSettingsPassive( + path => + { + DxMessagingSettings.InvalidateSettingsCache(); + return Load(path); + }, + Search, + Resolve + ); + Assert.That(first, Is.SameAs(settings), "The current load may return the live object."); + Assert.That(Read(), Is.SameAs(settings), "The invalidated revision must reload."); + Assert.That( + _loads, + Is.EqualTo(2), + "An in-flight invalidation must not be overwritten." + ); + } + + [Test] + public void ExplicitInvalidationRefreshesCachedAbsence() + { + Read(); + DxMessagingSettings settings = CreateSettings(DefaultPath); + DxMessagingSettings.InvalidateSettingsCache(); + Assert.That( + Read(), + Is.SameAs(settings), + "A fresh revision must discover canonical settings." + ); + Assert.That(_loads, Is.EqualTo(2), "A reset revision requires a fresh canonical load."); + } + + private DxMessagingSettings Read() + { + return DxMessagingSettings.LoadSettingsPassive(Load, Search, Resolve); + } + + private DxMessagingSettings Load(string path) + { + _loads++; + return _assets.TryGetValue(path, out DxMessagingSettings settings) ? settings : null; + } + + private string[] Search() + { + _searches++; + return _guids; + } + + private string Resolve(string guid) + { + _resolutions++; + return _guidPaths.TryGetValue(guid, out string path) ? path : null; + } + + private DxMessagingSettings AddLegacySettings() + { + _guids = new[] { "legacy" }; + _guidPaths["legacy"] = LegacyPath; + return CreateSettings(LegacyPath); + } + + private DxMessagingSettings CreateSettings(string path) + { + DxMessagingSettings settings = ScriptableObject.CreateInstance(); + _createdObjects.Add(settings); + _assets[path] = settings; + return settings; + } + } +} +#endif diff --git a/Tests/Editor/DxMessagingSettingsLookupTests.cs.meta b/Tests/Editor/DxMessagingSettingsLookupTests.cs.meta new file mode 100644 index 000000000..d6d934659 --- /dev/null +++ b/Tests/Editor/DxMessagingSettingsLookupTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 609ae24449284b30bf730fdbc8ca0254 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From c0b589010e22080c1a3701dade06dd759b4c6cb2 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 05:47:30 +0000 Subject: [PATCH 002/147] Use repository-relative obsolete API scan exclusions (#617) --- .docs-tests/DocsObsoleteApiReferenceTests.cs | 57 +++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.docs-tests/DocsObsoleteApiReferenceTests.cs b/.docs-tests/DocsObsoleteApiReferenceTests.cs index 5ae3eacdd..9370b3759 100644 --- a/.docs-tests/DocsObsoleteApiReferenceTests.cs +++ b/.docs-tests/DocsObsoleteApiReferenceTests.cs @@ -40,6 +40,8 @@ namespace WallstopStudios.DxMessaging.Docs.Tests; internal sealed class DocsObsoleteApiReferenceTests { private static readonly string[] SourceScanRoots = { "Runtime", "Editor" }; + private static readonly string[] GeneratedDirectoryNames = { ".artifacts", "obj", "bin" }; + private static readonly string[] ExpectedFixtureObsoleteMembers = { "RealApi.OldValue" }; // Resolve + parse once per test-run process: both tests share the same Roslyn scan of // Runtime/ and Editor/ (the same once-per-domain reflection-walk caching used elsewhere in @@ -130,6 +132,59 @@ public void ObsoleteMemberScanFindsKnownObsoleteApis() }); } + /// + /// Issue #617: absolute-path exclusions rejected real source whenever a checkout's parent + /// was named .artifacts, obj, or bin. Exclusions apply only inside the repository root. + /// + [TestCase(".artifacts")] + [TestCase("obj")] + [TestCase("bin")] + public void ObsoleteMemberScanUsesRepositoryRelativeGeneratedDirectoryExclusions( + string parentDirectory + ) + { + string temporaryRoot = Path.Combine( + Path.GetTempPath(), + "dxm-obsolete-worktree-" + Guid.NewGuid().ToString("N") + ); + string repoRoot = Path.Combine(temporaryRoot, parentDirectory, "checkout"); + string runtimeRoot = Path.Combine(repoRoot, "Runtime"); + try + { + Directory.CreateDirectory(runtimeRoot); + File.WriteAllText( + Path.Combine(runtimeRoot, "RealApi.cs"), + "class RealApi { [System.Obsolete] public int OldValue; }" + ); + foreach (string generatedDirectory in GeneratedDirectoryNames) + { + string generatedRoot = Path.Combine(runtimeRoot, generatedDirectory); + Directory.CreateDirectory(generatedRoot); + File.WriteAllText( + Path.Combine(generatedRoot, "GeneratedApi.cs"), + "class GeneratedApi { [System.Obsolete] public int OldValue; }" + ); + } + + string[] discovered = CollectObsoleteMembers(repoRoot) + .Select(member => member.QualifiedName) + .ToArray(); + Assert.That( + discovered, + Is.EqualTo(ExpectedFixtureObsoleteMembers), + "An enclosing generated-directory name must preserve real sources; repository-local " + + "generated directories must still be excluded." + ); + } + finally + { + if (Directory.Exists(temporaryRoot)) + { + Directory.Delete(temporaryRoot, recursive: true); + } + } + } + private static List CollectObsoleteMembers(string repoRoot) { // Keyed by qualified name so the same obsolete member found in multiple partial @@ -152,7 +207,7 @@ string sourcePath in Directory.GetFiles( ) ) { - string normalized = sourcePath.Replace('\\', '/'); + string normalized = Path.GetRelativePath(repoRoot, sourcePath).Replace('\\', '/'); if ( normalized.Contains("/obj/", StringComparison.Ordinal) || normalized.Contains("/bin/", StringComparison.Ordinal) From ae79805245bc2fbcbf3479354260bb1908b08899 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 16:58:14 +0000 Subject: [PATCH 003/147] Verify settings invalidation through native asset imports (#613) --- .../DxMessagingSettingsAssetImportTests.cs | 187 ++++++++++++++++++ ...xMessagingSettingsAssetImportTests.cs.meta | 11 ++ .../Editor/DxMessagingSettingsLookupTests.cs | 1 - 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 Tests/Editor/DxMessagingSettingsAssetImportTests.cs create mode 100644 Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta diff --git a/Tests/Editor/DxMessagingSettingsAssetImportTests.cs b/Tests/Editor/DxMessagingSettingsAssetImportTests.cs new file mode 100644 index 000000000..e3c1f0ce1 --- /dev/null +++ b/Tests/Editor/DxMessagingSettingsAssetImportTests.cs @@ -0,0 +1,187 @@ +#if UNITY_EDITOR +namespace DxMessaging.Tests.Editor +{ + using System; + using System.Collections.Generic; + using System.IO; + using DxMessaging.Editor.Settings; + using NUnit.Framework; + using UnityEditor; + using UnityEngine; + using Object = UnityEngine.Object; + + [TestFixture] + public sealed class DxMessagingSettingsAssetImportTests + { + private readonly List _createdObjects = new(); + private readonly List _deferredSidecarWork = new(); + private Action _originalSidecarScheduler; + private string _folder; + private string _assetPath; + + [SetUp] + public void SetUp() + { + _originalSidecarScheduler = DxMessagingBaseCallIgnoreSync.DeferralScheduler; + DxMessagingBaseCallIgnoreSync.DeferralScheduler = _deferredSidecarWork.Add; + string name = "__DxMessagingSettingsImportTests_" + Guid.NewGuid().ToString("N"); + _folder = "Assets/" + name; + Assert.That(Directory.Exists(_folder), Is.False, "The scratch folder must be new."); + Assert.That( + AssetDatabase.CreateFolder("Assets", name), + Is.Not.Empty, + "Create only the owned scratch folder." + ); + _assetPath = _folder + "/Settings.asset"; + DxMessagingSettings.InvalidateSettingsCache(); + } + + [TearDown] + public void TearDown() + { + try + { + if (!string.IsNullOrEmpty(_folder) && AssetDatabase.IsValidFolder(_folder)) + { + Assert.That( + AssetDatabase.DeleteAsset(_folder), + Is.True, + "Delete only this test's owned scratch folder." + ); + } + foreach (Object instance in _createdObjects) + { + if (instance != null && !EditorUtility.IsPersistent(instance)) + { + Object.DestroyImmediate(instance); + } + } + } + finally + { + _createdObjects.Clear(); + _deferredSidecarWork.Clear(); + DxMessagingBaseCallIgnoreSync.DeferralScheduler = _originalSidecarScheduler; + DxMessagingSettings.InvalidateSettingsCache(); + _folder = null; + } + } + + [Test] + public void ImportedSettingsReplaceACachedMissWithoutDomainReload() + { + DxMessagingSettings settings = CreateTransientSettings(); + DxMessagingSettings.InvalidateSettingsCache(); + Assert.That(Read() == null, Is.True, "The owned folder starts with a cached miss."); + int searches = DxMessagingSettings.SettingsSearchCount; + AssetDatabase.CreateAsset(settings, _assetPath); + AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceSynchronousImport); + + Assert.That( + DxMessagingSettings.SettingsSearchCount, + Is.EqualTo(searches), + "Native import callbacks must not search for settings synchronously." + ); + Assert.That(Read(), Is.SameAs(settings), "An import must invalidate cached absence."); + Assert.That( + DxMessagingSettings.SettingsSearchCount, + Is.EqualTo(searches + 1), + "The first passive read after import performs one replacement search." + ); + } + + [TestCase("delete")] + [TestCase("asset-move")] + [TestCase("folder-move")] + public void NativeAssetChangesInvalidateTheSelectedSettingsPath(string change) + { + DxMessagingSettings settings = CreateTransientSettings(); + AssetDatabase.CreateAsset(settings, _assetPath); + AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceSynchronousImport); + Assert.That(Read(), Is.SameAs(settings), "Start with the owned imported settings."); + int revision = DxMessagingSettings.SettingsRevision; + int searches = DxMessagingSettings.SettingsSearchCount; + + if (change == "delete") + { + Assert.That( + AssetDatabase.DeleteAsset(_assetPath), + Is.True, + "Delete owned settings." + ); + } + else if (change == "asset-move") + { + string destination = _folder + "/Renamed.asset"; + Assert.That( + AssetDatabase.MoveAsset(_assetPath, destination), + Is.Empty, + "Move the owned settings asset." + ); + _assetPath = destination; + } + else + { + string destination = _folder + "_Moved"; + Assert.That( + AssetDatabase.MoveAsset(_folder, destination), + Is.Empty, + "Move only the owned parent folder." + ); + _folder = destination; + _assetPath = _folder + "/Settings.asset"; + } + + Assert.That( + DxMessagingSettings.SettingsRevision, + Is.Not.EqualTo(revision), + $"The native {change} callback must invalidate the selected path." + ); + Assert.That( + DxMessagingSettings.SettingsSearchCount, + Is.EqualTo(searches), + $"The native {change} callback must defer settings lookup." + ); + DxMessagingSettings current = Read(); + Assert.That( + current == null, + Is.EqualTo(change == "delete"), + $"The next passive read must reflect native {change}." + ); + Assert.That( + Read(), + Is.SameAs(current), + $"The resolved native {change} result must remain cached." + ); + Assert.That( + DxMessagingSettings.SettingsSearchCount, + Is.EqualTo(searches + 1), + $"Native {change} must permit exactly one replacement search." + ); + } + + private DxMessagingSettings CreateTransientSettings() + { + DxMessagingSettings settings = ScriptableObject.CreateInstance(); + _createdObjects.Add(settings); + return settings; + } + + private DxMessagingSettings Read() + { + /* + Keep real AssetDatabase loads/searches inside the owned folder. Existing user + settings must not be removed or selected to manufacture an absent environment. + */ + return DxMessagingSettings.LoadSettingsPassive( + path => + path.StartsWith(_folder + "/", StringComparison.Ordinal) + ? AssetDatabase.LoadAssetAtPath(path) + : null, + () => AssetDatabase.FindAssets("t:DxMessagingSettings", new[] { _folder }), + AssetDatabase.GUIDToAssetPath + ); + } + } +} +#endif diff --git a/Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta b/Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta new file mode 100644 index 000000000..6eaf82999 --- /dev/null +++ b/Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc84783c66f947e3858c8126141f5787 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/DxMessagingSettingsLookupTests.cs b/Tests/Editor/DxMessagingSettingsLookupTests.cs index af758ddca..940ba0107 100644 --- a/Tests/Editor/DxMessagingSettingsLookupTests.cs +++ b/Tests/Editor/DxMessagingSettingsLookupTests.cs @@ -11,7 +11,6 @@ namespace DxMessaging.Tests.Editor using Object = UnityEngine.Object; [TestFixture] - [NonParallelizable] public sealed class DxMessagingSettingsLookupTests { private const string DefaultPath = "Assets/Editor/DxMessagingSettings.asset"; From f90dca2f2e1b388ce2946a92248f6643433a9955 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 18:22:54 +0000 Subject: [PATCH 004/147] Retain replayable native settings cache evidence --- CHANGELOG.md | 3 + docs/runbooks/perf-evidence-bundles.md | 57 + .../fixtures/editor-settings-cache.json | 1138 +++++++++++++++++ .../fixtures/editor-settings-cache.json.meta | 7 + .../fixtures/perf-evidence-examples.json | 192 +++ .../fixtures/perf-evidence-examples.json.meta | 7 + .../__tests__/perf-evidence-bundle.test.js | 237 ++-- scripts/__tests__/perf-test-vectors.json | 427 ++++++- .../__tests__/reduce-paired-bracket.test.js | 234 +--- .../unity/editor-settings-cache-contract.json | 627 +++++++++ .../editor-settings-cache-contract.json.meta | 7 + scripts/unity/perf-evidence-bundle.js | 5 + scripts/unity/perf-evidence-reducers.js | 222 +++- scripts/unity/shipping-evidence-fields.json | 18 + .../unity/shipping-evidence-fields.json.meta | 7 + 15 files changed, 2838 insertions(+), 350 deletions(-) create mode 100644 scripts/__tests__/fixtures/editor-settings-cache.json create mode 100644 scripts/__tests__/fixtures/editor-settings-cache.json.meta create mode 100644 scripts/__tests__/fixtures/perf-evidence-examples.json create mode 100644 scripts/__tests__/fixtures/perf-evidence-examples.json.meta create mode 100644 scripts/unity/editor-settings-cache-contract.json create mode 100644 scripts/unity/editor-settings-cache-contract.json.meta create mode 100644 scripts/unity/shipping-evidence-fields.json create mode 100644 scripts/unity/shipping-evidence-fields.json.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 163809f5b..3f6c03d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add replayable native Editor settings cache evidence, retaining stopped measurements, + descriptive counters, native test results, and cleanup/source provenance + ([#613](https://github.com/Ambiguous-Interactive/DxMessaging/issues/613)). - Add **Tools / Wallstop Studios / DxMessaging / Upgrade 3.x Fast Handlers to 4.0** to preview and update consumer callbacks under `Assets`. The command preserves source encoding and line endings, applies the batch transactionally, preserves inherited string-handler behavior, upgrades matching diff --git a/docs/runbooks/perf-evidence-bundles.md b/docs/runbooks/perf-evidence-bundles.md index 45f59087f..17ba4d8c4 100644 --- a/docs/runbooks/perf-evidence-bundles.md +++ b/docs/runbooks/perf-evidence-bundles.md @@ -34,6 +34,7 @@ Each reducer accepts only its registered artifact class: | `paired-throughput-screen` | `paired-throughput-screen-v1` | | `allocation-subunsub-observations` | `allocation-subunsub-observations-v1` | | `differential-replay-failure` | `differential-replay-failure-v1` | +| `editor-settings-cache` | `editor-settings-cache-v1` | Seal, verify, replay, and manifest writes reject a different class, even when its digest was recomputed. The paired screen retains the existing exploratory bracket decision. It does not @@ -262,6 +263,62 @@ categories as the C# oracle. It requires the original operation-kind sequence, a kinds, exact profile/source agreement, and non-empty candidate and replay inputs. A future generator profile needs a new reducer version; do not weaken this contract to admit it. +## Retaining native Editor settings cache evidence + +The `editor-settings-cache-v1` reducer retains the specific #613 native Editor characterization +profile in `scripts/unity/editor-settings-cache-contract.json`. It requires the stopped 64-call +legacy phase and the separately preregistered 8-call followup: four complete ABBA/BAAB blocks, +one warmup call per arm, the two-second stopping rule, and the 128 MiB growth limit. It checks +every call, found-object and search counter, schedule position, numeric value, and stopping reason. +Its block timing check sums the recorded phase times; the original probe did not retain the +whole-block stopwatch values. The producer's complete block timing cannot be reconstructed. + +Retain these inputs at the bundle root: + +- `editor-settings-profile.json`: the contract's exact `profile` object. +- `stopped-baseline.json` and `followup.json`: the original MCP `result` objects, including all + memory readings and the stopped row. Keep the original requests alongside them. +- For each of `focused`, `import`, `control`, `affected`, and `full`: `.result.json`, + `.run.json`, `.cleanup.json`, `.status.txt`, and `.cleanup.status.txt`. +- `source-proof.json` and `candidate/.txt`: measured commit/tree, complete overlay + inventory, original and retained source hashes, redaction counts, and package restoration proof. +- `freshness.json`, `samples.json`, `preflight.json`, and `restoration.json`: assembly timestamps, + imported sample/source equality, saved original scene, and final fixture cleanup observations. +- `original-samples.json` and `restored-samples.json`: all original file hashes and root metadata. + +The reducer derives leaf counts from native result nodes and checks the declared summaries: +22 focused passes, four import passes, the expected three control failures and one control pass, +117 affected passes, and 975 full Editor passes. It requires matching run ownership, terminal +status, an inactive framework, and restoration of the clean original scene. The control's cleanup +status remains its original expected test-failure error; it is not changed to `done`. +The full run also requires fresh package and sample assemblies before its preflight. Earlier +focused runs used selected Editor/test overlays; only the full run used the complete candidate +Unity tree. A different workload or coverage profile requires a new reducer version. + +The normalized result retains the stopped baseline and every followup row, with signed live-memory +changes and search counts. These are descriptive observations. They establish no speedup ratio, +confidence interval, managed allocation count, leak, OOM cause, or player performance result. +The existing settings asset was preserved, so absence transitions have native test coverage but +no native absence timing. Background processes and same-object equivalence of the two present +settings lookups were not measured. + +Privacy preparation can redact ordinary token assignments in a source excerpt. Keep its original +`candidateSha256`, the scrubbed `retainedSha256`, and explicit `redactionCounts`; replay verifies +the retained bytes and the declared redaction relationship. During independent restoration, +compare every original candidate source hash with the measured commit's checkout. A retained +redacted excerpt is not the original compiled source. Keep the original hash and sanitized hash +provenance for diagnostic outputs too; never include license-bearing historical crash logs. + +```bash +node scripts/unity/perf-evidence-bundle.js seal .artifacts/editor-settings-cache \ + --experiment-id editor-settings-cache-613-6000.4.6f1 \ + --artifact-class editor-settings-cache \ + --reducer editor-settings-cache-v1 \ + --source-commit "$MEASURED_SOURCE_COMMIT" +node scripts/unity/perf-evidence-bundle.js replay \ + .artifacts/editor-settings-cache/evidence-manifest.json +``` + ## Adding a reducer A reducer must be a pure function of the bundle's bytes. Read only from the supplied content map, diff --git a/scripts/__tests__/fixtures/editor-settings-cache.json b/scripts/__tests__/fixtures/editor-settings-cache.json new file mode 100644 index 000000000..843f00ba9 --- /dev/null +++ b/scripts/__tests__/fixtures/editor-settings-cache.json @@ -0,0 +1,1138 @@ +{ + "options": { + "experimentId": "editor-settings-cache-test", + "artifactClass": "editor-settings-cache", + "reducer": "editor-settings-cache-v1", + "sourceCommit": "98b47536a0eb1445fcd2a9700899aab0be24897f" + }, + "files": { + "editor-settings-profile.json": { + "schemaVersion": 1, + "measurementClass": "characterization", + "testMode": "EditMode", + "orders": ["ABBA", "BAAB", "ABBA", "BAAB"], + "baselineCalls": 64, + "followupCalls": 8, + "phaseAndBlockLimitMs": 2000, + "blockGrowthLimitBytes": 134217728, + "warmupCallsPerArm": 1, + "scope": "native Editor; selected overlays before full candidate tree", + "claim": "descriptive counters and live-memory changes; no speedup ratio, interval, retention or OOM cause" + }, + "source-proof.json": { + "sourceCommit": "98b47536a0eb1445fcd2a9700899aab0be24897f", + "sourceTree": "dddddddddddddddddddddddddddddddddddddddd", + "allOtherTrackedUnityAssetsIdentical": true, + "overlay": [ + { + "path": "Editor/Analyzers/DxMessagingConsoleHarvester.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Editor/Settings/DxMessagingSettings.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Editor/DxMessagingSettingsAssetImportTests.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Editor/DxMessagingSettingsLookupTests.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Editor/DxMessagingSettingsLookupTests.cs.meta", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Runtime/Benchmarks/BenchmarkProtocol.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Runtime/Comparisons/DxMessagingBridge.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Runtime/Comparisons/PairedBenchmarkProtocolContractTests.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + }, + { + "path": "Tests/Runtime/Comparisons/PairedComparisonHarness.cs", + "candidateSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "retainedSha256": "f2764a30bea370f9a7f3ba1e360dd8933c4f4c64974b1b29b32fc1848c15e4c6", + "redactionCounts": {} + } + ], + "packageRestored": [ + { + "path": "Editor/Analyzers/DxMessagingConsoleHarvester.cs", + "matchesOriginal": true + }, + { + "path": "Editor/Settings/DxMessagingSettings.cs", + "matchesOriginal": true + }, + { + "path": "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs", + "matchesOriginal": true + }, + { + "path": "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta", + "matchesOriginal": true + }, + { + "path": "Tests/Editor/DxMessagingSettingsAssetImportTests.cs", + "matchesOriginal": true + }, + { + "path": "Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta", + "matchesOriginal": true + }, + { + "path": "Tests/Editor/DxMessagingSettingsLookupTests.cs", + "matchesOriginal": true + }, + { + "path": "Tests/Editor/DxMessagingSettingsLookupTests.cs.meta", + "matchesOriginal": true + }, + { + "path": "Tests/Runtime/Benchmarks/BenchmarkProtocol.cs", + "matchesOriginal": true + }, + { + "path": "Tests/Runtime/Comparisons/DxMessagingBridge.cs", + "matchesOriginal": true + }, + { + "path": "Tests/Runtime/Comparisons/PairedBenchmarkProtocolContractTests.cs", + "matchesOriginal": true + }, + { + "path": "Tests/Runtime/Comparisons/PairedComparisonHarness.cs", + "matchesOriginal": true + } + ] + }, + "candidate/Editor/Analyzers/DxMessagingConsoleHarvester.cs.txt": "synthetic candidate bytes\n", + "candidate/Editor/Settings/DxMessagingSettings.cs.txt": "synthetic candidate bytes\n", + "candidate/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.txt": "synthetic candidate bytes\n", + "candidate/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta.txt": "synthetic candidate bytes\n", + "candidate/Tests/Editor/DxMessagingSettingsAssetImportTests.cs.txt": "synthetic candidate bytes\n", + "candidate/Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta.txt": "synthetic candidate bytes\n", + "candidate/Tests/Editor/DxMessagingSettingsLookupTests.cs.txt": "synthetic candidate bytes\n", + "candidate/Tests/Editor/DxMessagingSettingsLookupTests.cs.meta.txt": "synthetic candidate bytes\n", + "candidate/Tests/Runtime/Benchmarks/BenchmarkProtocol.cs.txt": "synthetic candidate bytes\n", + "candidate/Tests/Runtime/Comparisons/DxMessagingBridge.cs.txt": "synthetic candidate bytes\n", + "candidate/Tests/Runtime/Comparisons/PairedBenchmarkProtocolContractTests.cs.txt": "synthetic candidate bytes\n", + "candidate/Tests/Runtime/Comparisons/PairedComparisonHarness.cs.txt": "synthetic candidate bytes\n", + "freshness.json": { + "fresh": true, + "sourceUtc": "2026-10-07T17:00:00Z", + "assemblyUtc": "2026-10-07T17:01:00Z" + }, + "samples.json": { + "rows": [ + { + "source": "synthetic/0", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/1", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/2", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/3", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/4", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/5", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/6", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/7", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/8", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/9", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/10", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/11", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/12", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/13", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/14", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/15", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/16", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/17", + "same": true, + "sameMeta": true + }, + { + "source": "synthetic/18", + "same": true, + "sameMeta": true + } + ], + "assemblies": [ + { + "name": "WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample", + "assemblyUtc": "2026-10-07T17:01:00Z" + }, + { + "name": "WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample.Editor", + "assemblyUtc": "2026-10-07T17:01:00Z" + }, + { + "name": "WallstopStudios.DxMessaging.MiniCombat.Sample", + "assemblyUtc": "2026-10-07T17:01:00Z" + }, + { + "name": "WallstopStudios.DxMessaging.UIButtons.Sample", + "assemblyUtc": "2026-10-07T17:01:00Z" + } + ] + }, + "original-samples.json": { + "rootMetaExists": true, + "rootMetaSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "files": [ + { + "path": "synthetic-0", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-1", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-2", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-3", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-4", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-5", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-6", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-7", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-8", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-9", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-10", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-11", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-12", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-13", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-14", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-15", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-16", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-17", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-18", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-19", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-20", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-21", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-22", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-23", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-24", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-25", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-26", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-27", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-28", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-29", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-30", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-31", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-32", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-33", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-34", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-35", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-36", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-37", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-38", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-39", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-40", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-41", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-42", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "restored-samples.json": { + "rootMetaExists": true, + "rootMetaSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "files": [ + { + "path": "synthetic-0", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-1", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-2", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-3", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-4", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-5", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-6", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-7", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-8", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-9", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-10", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-11", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-12", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-13", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-14", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-15", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-16", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-17", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-18", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-19", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-20", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-21", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-22", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-23", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-24", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-25", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-26", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-27", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-28", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-29", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-30", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-31", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-32", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-33", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-34", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-35", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-36", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-37", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-38", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-39", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-40", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-41", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "path": "synthetic-42", + "bytes": 1, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "restoration.json": { + "newFixtureLoaded": false, + "scratchFolders": [], + "sampleFixtureCreated": false, + "assemblyUtc": "2026-10-07T17:04:00Z", + "restoredSourceUtc": "2026-10-07T17:03:00Z" + }, + "preflight.json": { + "observedUtc": "2026-10-07T17:01:30Z", + "observationError": "", + "frameworkErrors": "", + "frameworkActive": false, + "playing": false, + "compiling": false, + "updating": false, + "mainStage": true, + "activeScene": "Assets/original.unity", + "scenes": [ + { + "path": "Assets/original.unity", + "dirty": false, + "loaded": true + } + ] + }, + "stopped-baseline.json": { + "observedUtc": "2026-10-07T16:00:00Z", + "unityVersion": "6000.4.6f1", + "operatingSystem": "synthetic OS", + "assetPathCount": 31324, + "settingsPresent": true, + "legacySettingsPresent": true, + "canonicalPresent": true, + "warmupCachedCalls": 1, + "warmupLegacyCalls": 1, + "armA": "legacy harvester FindAssets plus first GUID load", + "armB": "shared passive cached settings lookup", + "absentEnvironment": "not available; existing settings preserved", + "stopped": "lookup-block-exceeded-two-seconds", + "rows": [ + { + "block": 0, + "order": "ABBA", + "arm": "A", + "calls": 64, + "found": 64, + "elapsedMilliseconds": 2100, + "legacySearches": 64, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + } + ] + }, + "followup.json": { + "observedUtc": "2026-10-07T16:01:00Z", + "unityVersion": "6000.4.6f1", + "operatingSystem": "synthetic OS", + "assetPathCount": 31324, + "settingsPresent": true, + "legacySettingsPresent": true, + "canonicalPresent": true, + "warmupCachedCalls": 1, + "warmupLegacyCalls": 1, + "armA": "legacy harvester FindAssets plus first GUID load", + "armB": "shared passive cached settings lookup", + "absentEnvironment": "not available; existing settings preserved", + "stopped": "", + "rows": [ + { + "block": 0, + "order": "ABBA", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 0, + "order": "ABBA", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 0, + "order": "ABBA", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 0, + "order": "ABBA", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 1, + "order": "BAAB", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 1, + "order": "BAAB", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 1, + "order": "BAAB", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 1, + "order": "BAAB", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 2, + "order": "ABBA", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 2, + "order": "ABBA", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 2, + "order": "ABBA", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 2, + "order": "ABBA", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 3, + "order": "BAAB", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + }, + { + "block": 3, + "order": "BAAB", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 3, + "order": "BAAB", + "arm": "A", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 100, + "legacySearches": 8, + "passiveSearches": 0, + "passiveLookups": 0, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1100, + "managedBefore": 1000, + "managedAfter": 1050 + }, + { + "block": 3, + "order": "BAAB", + "arm": "B", + "calls": 8, + "found": 8, + "elapsedMilliseconds": 0.001, + "legacySearches": 0, + "passiveSearches": 0, + "passiveLookups": 8, + "workingBefore": 1000, + "workingAfter": 900, + "nativeBefore": 1000, + "nativeAfter": 1000, + "managedBefore": 1000, + "managedAfter": 1000 + } + ] + } + }, + "mutations": [ + ["source-proof.json", "sourceCommit", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["source-proof.json", "allOtherTrackedUnityAssetsIdentical", false], + [ + "source-proof.json", + "overlay.0.candidateSha256", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + ["source-proof.json", "packageRestored.0.matchesOriginal", false], + ["freshness.json", "assemblyUtc", "2026-10-07T16:59:00Z"], + ["freshness.json", "assemblyUtc", "invalid"], + ["samples.json", "rows.0.sameMeta", false], + ["samples.json", "assemblies.0.name", "wrong"], + [ + "restored-samples.json", + "files.0.sha256", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + ["restoration.json", "newFixtureLoaded", true], + ["preflight.json", "frameworkActive", true], + ["full.cleanup.json", "scenes.0.dirty", true], + ["full.cleanup.json", "runGuid", "different"], + ["full.cleanup.json", "ownedResultPath", "different"], + ["full.cleanup.json", "observedUtc", "2026-10-07T16:00:00Z"], + ["full.result.json", "passCount", 974], + ["full.result.json", "nodes.0.status", "Inconclusive"], + ["control.result.json", "nodes.0.name", "different"], + ["stopped-baseline.json", "stopped", ""], + ["stopped-baseline.json", "rows.0.elapsedMilliseconds", 1999], + ["followup.json", "rows.0.arm", "B"], + ["followup.json", "rows.0.calls", 64], + ["followup.json", "rows.0.found", 7], + ["followup.json", "rows.0.legacySearches", 0], + ["followup.json", "rows.1.passiveSearches", 1], + ["followup.json", "rows.1.elapsedMilliseconds", null], + ["followup.json", "rows.1.nativeBefore", -1], + ["followup.json", "stopped", "editor-busy"], + ["followup.json", "rows.0.order", "BAAB"], + ["followup.json", "rows.0.elapsedMilliseconds", 2500], + ["followup.json", "rows.0.nativeAfter", 134218729], + ["full.status.txt", "", "error: partial"], + ["control.cleanup.status.txt", "", "done"], + ["editor-settings-profile.json", "followupCalls", 64], + [ + "source-proof.json", + "overlay.0.redactionCounts", + { + "credential-assignment": 1 + } + ] + ], + "leaf": { + "isSuite": false, + "status": "Passed", + "startTime": "2026-10-07T17:01:40Z", + "endTime": "2026-10-07T17:01:41Z" + }, + "result": { + "skipCount": 0, + "inconclusiveCount": 0, + "durationSeconds": 1 + } +} diff --git a/scripts/__tests__/fixtures/editor-settings-cache.json.meta b/scripts/__tests__/fixtures/editor-settings-cache.json.meta new file mode 100644 index 000000000..311f30660 --- /dev/null +++ b/scripts/__tests__/fixtures/editor-settings-cache.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 62d1fa0aa5674eea9f956101723c3894 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/fixtures/perf-evidence-examples.json b/scripts/__tests__/fixtures/perf-evidence-examples.json new file mode 100644 index 000000000..2feb8e220 --- /dev/null +++ b/scripts/__tests__/fixtures/perf-evidence-examples.json @@ -0,0 +1,192 @@ +{ + "sealOptions": { + "experimentId": "shipping-fidelity-matrix-6000.5.2f1", + "artifactClass": "shipping-fidelity-matrix", + "reducer": "shipping-fidelity-matrix-v1", + "sourceCommit": "98b47536a0eb1445fcd2a9700899aab0be24897f" + }, + "matrix": { + "schemaVersion": 1, + "measurementClass": "characterization", + "unityVersion": "6000.5.2f1", + "cellCount": 4, + "completedCellCount": 4, + "failedCells": [], + "unreadableEvidenceCells": [], + "cells": [ + { + "cellId": "high-semantic-18", + "schemaVersion": 1, + "measurementClass": "characterization", + "profileId": "shipping-fidelity-il2cpp-high-player-v1", + "managedStrippingLevel": "High", + "topologyId": "semantic-18-v1", + "messageTypeCount": 18, + "unityVersion": "6000.5.2f1", + "libraryState": "cold", + "diagnostics": ["first", "second"], + "buildDurationMs": 120000, + "editorBuildWallClockMs": 130000, + "playerTotalBytes": 40000000, + "gameAssemblyBytes": 9000000, + "timings": { + "engineStartToRunMs": 300, + "firstTypedDispatchUs": 40, + "dispatchLoopNsPerOp": 21, + "dispatchLoopShape": "class" + } + }, + { + "cellId": "high-cardinality-16", + "schemaVersion": 1, + "measurementClass": "characterization", + "profileId": "shipping-fidelity-il2cpp-high-player-v1", + "managedStrippingLevel": "High", + "topologyId": "cardinality-16-v1", + "messageTypeCount": 16, + "unityVersion": "6000.5.2f1", + "libraryState": "cold", + "diagnostics": ["first", "second"], + "buildDurationMs": 120001, + "editorBuildWallClockMs": 130001, + "playerTotalBytes": 40001000, + "gameAssemblyBytes": 9000100, + "timings": { + "engineStartToRunMs": 301, + "firstTypedDispatchUs": 41, + "dispatchLoopNsPerOp": 22, + "dispatchLoopShape": "class" + } + }, + { + "cellId": "minimal-semantic-18", + "schemaVersion": 1, + "measurementClass": "characterization", + "profileId": "shipping-fidelity-il2cpp-minimal-player-v1", + "managedStrippingLevel": "Minimal", + "topologyId": "semantic-18-v1", + "messageTypeCount": 18, + "unityVersion": "6000.5.2f1", + "libraryState": "cold", + "diagnostics": ["first", "second"], + "buildDurationMs": 120002, + "editorBuildWallClockMs": 130002, + "playerTotalBytes": 40002000, + "gameAssemblyBytes": 9000200, + "timings": { + "engineStartToRunMs": 302, + "firstTypedDispatchUs": 42, + "dispatchLoopNsPerOp": 23, + "dispatchLoopShape": "class" + } + }, + { + "cellId": "minimal-cardinality-16", + "schemaVersion": 1, + "measurementClass": "characterization", + "profileId": "shipping-fidelity-il2cpp-minimal-player-v1", + "managedStrippingLevel": "Minimal", + "topologyId": "cardinality-16-v1", + "messageTypeCount": 16, + "unityVersion": "6000.5.2f1", + "libraryState": "cold", + "diagnostics": ["first", "second"], + "buildDurationMs": 120003, + "editorBuildWallClockMs": 130003, + "playerTotalBytes": 40003000, + "gameAssemblyBytes": 9000300, + "timings": { + "engineStartToRunMs": 303, + "firstTypedDispatchUs": 43, + "dispatchLoopNsPerOp": 24, + "dispatchLoopShape": "class" + } + } + ] + }, + "subunsubRows": [ + { + "scenario": "Comparison_DxMessaging_SubUnsub", + "platform": "PlayMode Mono (LinuxEditor; Unity 6000.5.2f1)", + "commit": "98b47536a0eb1445fcd2a9700899aab0be24897f", + "runIndex": "-1", + "emitsPerSecond": "1000.000", + "gcAllocations": "12", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "384" + }, + { + "scenario": "Comparison_CsEvent_SubUnsub", + "platform": "PlayMode Mono (LinuxEditor; Unity 6000.5.2f1)", + "commit": "98b47536a0eb1445fcd2a9700899aab0be24897f", + "runIndex": "-1", + "emitsPerSecond": "1000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + } + ], + "subunsubOptions": { + "experimentId": "subunsub-observations", + "artifactClass": "allocation-subunsub-observations", + "reducer": "allocation-subunsub-observations-v1" + }, + "differentialOptions": { + "experimentId": "native-lifecycle-replay-failure", + "artifactClass": "differential-replay-failure", + "reducer": "differential-replay-failure-v1" + }, + "differentialMutations": [ + ["missing message kind", "replays/broadcast.json", null, null, "broadcast\\.json is required"], + [ + "wrong generator profile", + "differential-replay-profile.json", + "generatorVersion", + 11, + "reviewed native lifecycle profile" + ], + [ + "source disagreement", + "differential-replay-environment.json", + "sourceCommit", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source identity" + ], + [ + "false mismatch", + "replays/untargeted.json", + "minimized.candidateTrace", + [ + { + "callbacks": [], + "exception": null, + "trimResult": null, + "occupiedTypeSlots": 0, + "occupiedTargetSlots": 0, + "finalEmissions": [], + "unmatchedDiagnostics": [], + "state": "control-0" + } + ], + "first observable mismatch" + ], + [ + "non-minimal operation", + "replays/targeted.json", + "minimized.operations.0.kind", + "Emit", + "deletion-minimal" + ] + ], + "differentialExpectedCases": [ + ["Untargeted", 6, 1], + ["Targeted", 6, 1], + ["Broadcast", 6, 1] + ], + "shippingDefects": [ + "missing raw cell", + "duplicate row", + "summary timing", + "missing summary column" + ] +} diff --git a/scripts/__tests__/fixtures/perf-evidence-examples.json.meta b/scripts/__tests__/fixtures/perf-evidence-examples.json.meta new file mode 100644 index 000000000..8c321b3ee --- /dev/null +++ b/scripts/__tests__/fixtures/perf-evidence-examples.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f10dc2f6e2824d3683f6e5e534f2ef40 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/perf-evidence-bundle.test.js b/scripts/__tests__/perf-evidence-bundle.test.js index 33b7d2730..68cec4ca2 100644 --- a/scripts/__tests__/perf-evidence-bundle.test.js +++ b/scripts/__tests__/perf-evidence-bundle.test.js @@ -24,74 +24,26 @@ const { reduceDifferentialReplayFailure, reduceShippingFidelityMatrix } = require("../unity/perf-evidence-reducers.js"); -const SEAL_OPTIONS = Object.freeze({ - experimentId: "shipping-fidelity-matrix-6000.5.2f1", - artifactClass: "shipping-fidelity-matrix", - reducer: "shipping-fidelity-matrix-v1", - sourceCommit: "98b47536a0eb1445fcd2a9700899aab0be24897f" -}); -const STRIPPING_LEVELS = ["High", "Minimal"]; -const TOPOLOGIES = [ - ["semantic-18", 18], - ["cardinality-16", 16] -]; +const EXAMPLES = require("./fixtures/perf-evidence-examples.json"); +const SEAL_OPTIONS = Object.freeze(EXAMPLES.sealOptions); function temporaryDirectory() { return fs.mkdtempSync(path.join(os.tmpdir(), "perf-evidence-bundle-test-")); } function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -function cellEvidence(level, topologyId, messageTypeCount, index) { - return { - schemaVersion: 1, - measurementClass: "characterization", - profileId: `shipping-fidelity-il2cpp-${level.toLowerCase()}-player-v1`, - managedStrippingLevel: level, - topologyId: `${topologyId}-v1`, - messageTypeCount, - unityVersion: "6000.5.2f1", - libraryState: "cold", - diagnostics: ["first", "second"], - buildDurationMs: 120000 + index, - editorBuildWallClockMs: 130000 + index, - playerTotalBytes: 40000000 + index * 1000, - gameAssemblyBytes: 9000000 + index * 100, - timings: { - engineStartToRunMs: 300 + index, - firstTypedDispatchUs: 40 + index, - dispatchLoopNsPerOp: 21 + index, - dispatchLoopShape: "class" - } - }; -} -/** A miniature but structurally faithful two-level, two-topology matrix bundle. */ +/** Fixed matrix data stays in JSON; every invocation owns a fresh copy. */ function writeMatrixBundle(root, { extraFiles = {} } = {}) { - const cells = []; - let index = 0; - for (const level of STRIPPING_LEVELS) { - for (const [topologyId, messageTypeCount] of TOPOLOGIES) { - const cellId = `${level.toLowerCase()}-${topologyId}`; - const evidence = cellEvidence(level, topologyId, messageTypeCount, index++); - const cellDirectory = path.join(root, cellId); - fs.mkdirSync(cellDirectory, { recursive: true }); - writeJson(path.join(cellDirectory, "shipping-cell-evidence.json"), evidence); - fs.writeFileSync( - path.join(cellDirectory, "shipping-positive-player.log"), - `cell ${cellId} completed\n` - ); - cells.push({ cellId, ...evidence }); - } + const matrix = structuredClone(EXAMPLES.matrix); + for (const { cellId, ...evidence } of matrix.cells) { + fs.mkdirSync(path.join(root, cellId), { recursive: true }); + writeJson(path.join(root, cellId, "shipping-cell-evidence.json"), evidence); + fs.writeFileSync( + path.join(root, cellId, "shipping-positive-player.log"), + `cell ${cellId} completed\n` + ); } - writeJson(path.join(root, "shipping-matrix-evidence.json"), { - schemaVersion: 1, - measurementClass: "characterization", - unityVersion: "6000.5.2f1", - cellCount: cells.length, - completedCellCount: cells.length, - failedCells: [], - unreadableEvidenceCells: [], - cells - }); + writeJson(path.join(root, "shipping-matrix-evidence.json"), matrix); for (const [relativePath, content] of Object.entries(extraFiles)) { const absolute = path.join(root, ...relativePath.split("/")); fs.mkdirSync(path.dirname(absolute), { recursive: true }); @@ -662,12 +614,7 @@ for (const [target, field, value, remove] of VECTORS.invalidShippingInputs) { ); }); } -for (const defect of [ - "missing raw cell", - "duplicate row", - "summary timing", - "missing summary column" -]) { +for (const defect of EXAMPLES.shippingDefects) { test(`shipping reducer rejects ${defect}`, () => { const root = writeMatrixBundle(temporaryDirectory()); const file = path.join(root, "shipping-matrix-evidence.json"); @@ -719,24 +666,8 @@ test("shipping reducer preserves explicit failed outcomes and ignores row order" }); const { buildCsv } = require("../unity/extract-perf-baseline.js"); -const SUBUNSUB_OPTIONS = { - ...SEAL_OPTIONS, - experimentId: "subunsub-observations", - artifactClass: "allocation-subunsub-observations", - reducer: "allocation-subunsub-observations-v1" -}; -function subunsubRows() { - return ["DxMessaging", "CsEvent"].map((technology, index) => ({ - scenario: `Comparison_${technology}_SubUnsub`, - platform: "PlayMode Mono (LinuxEditor; Unity 6000.5.2f1)", - commit: SEAL_OPTIONS.sourceCommit, - runIndex: "-1", - emitsPerSecond: "1000.000", - gcAllocations: index ? "-1" : "12", - wallClockMs: "5000.000", - gcAllocatedBytes: index ? "-1" : "384" - })); -} +const SUBUNSUB_OPTIONS = { ...SEAL_OPTIONS, ...EXAMPLES.subunsubOptions }; +const subunsubRows = () => structuredClone(EXAMPLES.subunsubRows); test("SubUnsub observations replay measured and unmeasured allocation fields exactly", (t) => { const root = temporaryDirectory(); t.after(() => fs.rmSync(root, { recursive: true, force: true })); @@ -835,12 +766,7 @@ for (const [label, raw] of [ }); } -const DIFFERENTIAL_OPTIONS = { - ...SEAL_OPTIONS, - experimentId: "native-lifecycle-replay-failure", - artifactClass: "differential-replay-failure", - reducer: "differential-replay-failure-v1" -}; +const DIFFERENTIAL_OPTIONS = { ...SEAL_OPTIONS, ...EXAMPLES.differentialOptions }; const reduceDifferential = (root) => reduceDifferentialReplayFailure(contentsOf(root), DIFFERENTIAL_OPTIONS); const observation = (state) => ({ ...DIFFERENTIAL_FIXTURE.observation, state }); @@ -887,65 +813,104 @@ test("differential replay failures retain and replay three deletion-minimal nati entry.originalOperationCount, entry.minimizedOperationCount ]), - [ - ["Untargeted", 6, 1], - ["Targeted", 6, 1], - ["Broadcast", 6, 1] - ] + EXAMPLES.differentialExpectedCases ); assert.equal(normalized.classification, "state"); assert.equal(normalized.replayCommand, "run the focused native lifecycle fixture"); assert.match(normalized.candidateSha256, /^[0-9a-f]{64}$/); }); -for (const [label, mutate, expected] of [ - [ - "missing message kind", - (root) => fs.rmSync(path.join(root, "replays/broadcast.json")), - /broadcast\.json is required/ - ], - [ - "wrong generator profile", - (root) => - mutateJson(root, "differential-replay-profile.json", (value) => - Object.assign(value, { generatorVersion: 11 }) - ), - /reviewed native lifecycle profile/ - ], - [ - "source disagreement", - (root) => - mutateJson(root, "differential-replay-environment.json", (value) => - Object.assign(value, { sourceCommit: "a".repeat(40) }) - ), - /source identity/ - ], - [ - "false mismatch", - (root) => - mutateJson(root, "replays/untargeted.json", (value) => - Object.assign(value.minimized, { candidateTrace: [observation("control-0")] }) - ), - /first observable mismatch/ - ], - [ - "non-minimal operation", - (root) => - mutateJson(root, "replays/targeted.json", (value) => - Object.assign(value.minimized.operations[0], { kind: "Emit" }) - ), - /deletion-minimal/ - ] -]) { +for (const [label, file, field, value, expected] of EXAMPLES.differentialMutations) { test(`differential replay evidence rejects ${label}`, (t) => { const root = writeDifferentialBundle(); t.after(() => fs.rmSync(root, { recursive: true, force: true })); - mutate(root); - assert.throws(() => reduceDifferential(root), expected); + if (!field) fs.rmSync(path.join(root, file)); + else mutateJson(root, file, (data) => setJsonField(data, field, structuredClone(value))); + assert.throws(() => reduceDifferential(root), new RegExp(expected)); }); } +function setJsonField(data, field, value) { + const keys = field.split("."); + keys.slice(0, -1).reduce((owner, key) => owner[key], data)[keys.at(-1)] = value; +} function mutateJson(root, relativePath, mutate) { const file = path.join(root, relativePath); const value = JSON.parse(fs.readFileSync(file, "utf8")); mutate(value); writeJson(file, value); } +const SETTINGS_FIXTURE = require("./fixtures/editor-settings-cache.json"); +const SETTINGS_CONTRACT = require("../unity/editor-settings-cache-contract.json"); +function writeSettingsBundle() { + const root = temporaryDirectory(); + for (const [file, value] of Object.entries(SETTINGS_FIXTURE.files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + if (typeof value === "string") fs.writeFileSync(path.join(root, file), value); + else writeJson(path.join(root, file), value); + } + for (const [id, counts] of Object.entries(SETTINGS_CONTRACT.runs)) { + const nodes = SETTINGS_CONTRACT.controlFailures + .slice(0, counts.failCount) + .map((name) => ({ ...SETTINGS_FIXTURE.leaf, name, status: "Failed" })); + nodes.push( + ...Array.from({ length: counts.passCount }, (_, i) => ({ + ...SETTINGS_FIXTURE.leaf, + name: `${id}.SyntheticCase${i}` + })) + ); + writeJson(path.join(root, `${id}.result.json`), { + ...SETTINGS_FIXTURE.result, + ...counts, + nodes + }); + const run = { runGuid: `synthetic-${id}`, resultPath: `relative/${id}.json` }; + writeJson(path.join(root, `${id}.run.json`), run); + writeJson(path.join(root, `${id}.cleanup.json`), { + ...SETTINGS_FIXTURE.files["preflight.json"], + ...run, + ownedResultPath: run.resultPath, + observedUtc: "2026-10-07T17:02:00Z" + }); + for (const suffix of ["status", "cleanup.status"]) + fs.writeFileSync( + path.join(root, `${id}.${suffix}.txt`), + id === "control" && suffix === "cleanup.status" + ? "error: result has no passes or contains failed/inconclusive nodes" + : "done" + ); + } + return root; +} +test("native settings evidence replays counters, signed memory changes and all test leaves", (t) => { + const root = writeSettingsBundle(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const manifest = sealBundle(root, SETTINGS_FIXTURE.options); + const replayed = replayBundle(writeBundleManifest(root, manifest)); + assert.deepEqual(replayed.normalized, manifest.normalized); + assert.equal(manifest.normalized.tests.full.passCount, 975); + assert.deepEqual(manifest.normalized.tests.control.failures, SETTINGS_CONTRACT.controlFailures); + assert.equal(manifest.normalized.followup.arms.A.searches, 64); + assert.equal(manifest.normalized.followup.arms.B.searches, 0); + assert.equal(manifest.normalized.followup.arms.B.workingDeltaBytes, -800); + assert.equal(manifest.normalized.stoppedBaseline.rows[0].calls, 64); + assert.equal(manifest.normalized.followup.rows.length, 16); +}); +for (const [file, field, value] of SETTINGS_FIXTURE.mutations) { + test(`native settings evidence refuses ${file} ${field}`, (t) => { + const root = writeSettingsBundle(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + if (!field) fs.writeFileSync(path.join(root, file), value); + else mutateJson(root, file, (data) => setJsonField(data, field, value)); + assert.throws(() => sealBundle(root, SETTINGS_FIXTURE.options), /editor settings/); + }); +} +test("native settings evidence requires every retained input", (t) => { + const root = writeSettingsBundle(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const file of listBundleFiles(root)) { + const target = path.join(root, file), + bytes = fs.readFileSync(target); + fs.unlinkSync(target); + assert.throws(() => sealBundle(root, SETTINGS_FIXTURE.options), /required by this reducer/); + fs.writeFileSync(target, bytes); + } +}); diff --git a/scripts/__tests__/perf-test-vectors.json b/scripts/__tests__/perf-test-vectors.json index da27c0a9a..3dd2e8c37 100644 --- a/scripts/__tests__/perf-test-vectors.json +++ b/scripts/__tests__/perf-test-vectors.json @@ -193,5 +193,430 @@ "description": "Changed capacity" } ] - ] + ], + "invalidManifestCases": [ + [ + "empty candidate source scope", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": [], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "StructNoBox", + "role": "sentinel" + } + ] + }, + "candidatePaths must be a non-empty array" + ], + [ + "candidate path outside Runtime", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["docs/architecture/performance.md"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "StructNoBox", + "role": "sentinel" + } + ] + }, + "normalized path below Runtime" + ], + [ + "fewer than two sentinels", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "affected" + }, + { + "scenario": "KeyedToOne", + "role": "affected" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "affected" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "StructNoBox", + "role": "affected" + } + ] + }, + "at least two sentinel" + ], + [ + "no target", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "sentinel" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "sentinel" + }, + { + "scenario": "StructNoBox", + "role": "sentinel" + } + ] + }, + "at least one target" + ], + [ + "omitted scenario", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + } + ] + }, + "classify every paired scenario" + ], + [ + "reordered scenario", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "StructNoBox", + "role": "sentinel" + } + ] + }, + "classify every paired scenario" + ], + [ + "unknown scenario", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "sentinel" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "Unknown", + "role": "sentinel" + } + ] + }, + "classify every paired scenario" + ], + [ + "canonical-only role", + { + "schemaVersion": 1, + "bracketId": "test-bracket", + "materialityBandPercent": 3, + "candidatePaths": ["Runtime/Core/MessageBus/MessageBus.cs"], + "orientation": "candidate-control-candidate", + "rows": [ + { + "scenario": "GlobalToOne", + "role": "sentinel" + }, + { + "scenario": "GlobalToMany", + "role": "canonical-only" + }, + { + "scenario": "KeyedToOne", + "role": "sentinel" + }, + { + "scenario": "Filtered", + "role": "target" + }, + { + "scenario": "PostProcess", + "role": "sentinel" + }, + { + "scenario": "FilteredPostProcess", + "role": "affected" + }, + { + "scenario": "StructNoBox", + "role": "sentinel" + } + ] + }, + "unsupported role" + ] + ], + "invalidSummaryProfiles": [ + ["missing commit", "commit", null, "summary commit", true], + ["missing source tree", "sourceTree", null, "sourceTree", true], + ["missing candidate source", "candidateSourceSha256", null, "candidateSourceSha256", true], + ["Mono platform", "platform", "PlayMode Mono", "platform"], + ["missing profile", "executionProfile", null, "execution profile", true], + ["CPU model", "executionProfile.cpuModel", "other", "profile topology"], + ["missing CPU model", "executionProfile.cpuModel", null, "must contain exactly", true], + ["profile source", "executionProfile.source", "other", "profile topology"], + ["selection policy", "executionProfile.selectionPolicy", "other", "profile topology"], + ["efficiency class", "executionProfile.selectedEfficiencyClass", -1, "profile topology"], + [ + "logical processors", + "executionProfile.selectedLogicalProcessorIndices", + [1, 2], + "profile topology" + ], + ["profile", "executionProfile.id", "other", "execution profile"], + ["affinity", "executionProfile.affinityMask", "0xFFFFFFFF", "execution profile"], + ["priority", "executionProfile.priorityClass", "High", "execution profile"], + ["missing protocol", "protocol", null, "protocol constants", true], + ["protocol", "protocol", "other", "protocol constants"], + ["cycles", "cycles", 1, "protocol constants"], + ["active time", "minimumCycleActiveMilliseconds", 1, "protocol constants"], + ["batch", "batchOperations", 1, "protocol constants"] + ], + "summaryEvidenceMutations": [ + ["omitted row", "pop", "0.rows", null, null, "row count"], + [ + "extra row", + "push", + "0.rows", + { + "scenario": "Extra", + "firstToSecondRatio": 1, + "cycleRatioSpreadPercent": 1, + "cycleRatios": [1, 1, 1, 1] + }, + null, + "row count" + ], + ["reordered row", "reverse", "0.rows", null, null, "summary row 0"], + ["duplicated row", "set", "0.rows.1", null, "0.rows.0", "summary row 1"], + [ + "manifest digest mismatch", + "set", + "1.bracketManifestSha256", + "0000000000000000000000000000000000000000000000000000000000000000", + null, + "manifest digest" + ], + [ + "execution profile mismatch", + "set", + "2.executionProfile.affinityMask", + "0xFFFFFFFF", + null, + "execution profile" + ], + ["duplicate commit provenance", "set", "2.commit", null, "0.commit", "distinct commits"], + [ + "different outer source trees", + "set", + "2.sourceTree", + "cccccccccccccccccccccccccccccccccccccccc", + null, + "outer summaries.*same source tree" + ], + [ + "identical center source tree", + "set", + "1.sourceTree", + null, + "0.sourceTree", + "outer and center.*different source trees" + ], + [ + "different outer candidate source", + "set", + "2.candidateSourceSha256", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + null, + "outer summaries.*same candidate-source digest" + ], + [ + "unchanged candidate source in center", + "set", + "1.candidateSourceSha256", + null, + "0.candidateSourceSha256", + "outer and center.*different candidate-source digests" + ] + ], + "commits": [ + "98b47536a0eb1445fcd2a9700899aab0be24897f", + "261b1867e052723517db8a77048c209a20108204", + "da5439ac21e60d5e04e4f453e3766e03daff0486" + ], + "positions": ["first", "center", "last"] } diff --git a/scripts/__tests__/reduce-paired-bracket.test.js b/scripts/__tests__/reduce-paired-bracket.test.js index 5d7a4bca8..c0ca6cf34 100644 --- a/scripts/__tests__/reduce-paired-bracket.test.js +++ b/scripts/__tests__/reduce-paired-bracket.test.js @@ -37,11 +37,7 @@ const DEFAULT_ROWS = PERF_TEST_VECTORS.defaultRows; const DEFAULT_FACTORS = Object.fromEntries(DEFAULT_ROWS.map((row) => [row.scenario, 1])); DEFAULT_FACTORS[TARGET] = 1.06; DEFAULT_FACTORS[AFFECTED] = 0.99; -const COMMITS = [ - "98b47536a0eb1445fcd2a9700899aab0be24897f", - "261b1867e052723517db8a77048c209a20108204", - "da5439ac21e60d5e04e4f453e3766e03daff0486" -]; +const COMMITS = PERF_TEST_VECTORS.commits; const OUTER_TREE = "a".repeat(40); const CENTER_TREE = "b".repeat(40); const OUTER_CANDIDATE_SOURCE = "c".repeat(64); @@ -250,6 +246,14 @@ function writeBracket(directory, bracket) { return { manifestPath, summaryPaths }; } +function bracketArguments({ manifestPath, summaryPaths }) { + return [ + "--manifest", + manifestPath, + ...summaryPaths.flatMap((file, index) => [`--${PERF_TEST_VECTORS.positions[index]}`, file]) + ]; +} + function spawnReducer(arguments_) { return spawnSync( process.execPath, @@ -271,16 +275,7 @@ test("CLI validates manifests and preserves the accepted, rejected, and invalid await t.test("accepted stdout", () => { const { manifestPath, summaryPaths } = writeBracket(directory, makeBracket()); - const result = spawnReducer([ - "--manifest", - manifestPath, - "--first", - summaryPaths[0], - "--center", - summaryPaths[1], - "--last", - summaryPaths[2] - ]); + const result = spawnReducer(bracketArguments({ manifestPath, summaryPaths })); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).status, "accepted"); }); @@ -290,14 +285,7 @@ test("CLI validates manifests and preserves the accepted, rejected, and invalid const { manifestPath, summaryPaths } = writeBracket(directory, bracket); const outputPath = path.join(directory, "verdict.json"); const result = spawnReducer([ - "--manifest", - manifestPath, - "--first", - summaryPaths[0], - "--center", - summaryPaths[1], - "--last", - summaryPaths[2], + ...bracketArguments({ manifestPath, summaryPaths }), "--output", outputPath ]); @@ -308,16 +296,7 @@ test("CLI validates manifests and preserves the accepted, rejected, and invalid await t.test("malformed evidence status 1", () => { const { manifestPath, summaryPaths } = writeBracket(directory, makeBracket()); fs.writeFileSync(summaryPaths[1], "not-json\n"); - const result = spawnReducer([ - "--manifest", - manifestPath, - "--first", - summaryPaths[0], - "--center", - summaryPaths[1], - "--last", - summaryPaths[2] - ]); + const result = spawnReducer(bracketArguments({ manifestPath, summaryPaths })); assert.equal(result.status, 1); assert.notEqual(result.stderr.trim(), ""); }); @@ -440,66 +419,10 @@ test("the session 240 artifact-shaped bracket fails its sentinel gate", () => { }); test("manifest validation rejects incomplete, reordered, unknown, and unsupported classifications", async (t) => { - const cases = [ - [ - "empty candidate source scope", - { ...makeManifest(), candidatePaths: [] }, - /candidatePaths must be a non-empty array/ - ], - [ - "candidate path outside Runtime", - { ...makeManifest(), candidatePaths: ["docs/architecture/performance.md"] }, - /normalized path below Runtime/ - ], - [ - "fewer than two sentinels", - makeManifest( - undefined, - DEFAULT_ROWS.map((row, index) => - row.role === "sentinel" && index > 0 ? { ...row, role: "affected" } : row - ) - ), - /at least two sentinel/ - ], - [ - "no target", - makeManifest( - undefined, - DEFAULT_ROWS.map((row) => ({ ...row, role: "sentinel" })) - ), - /at least one target/ - ], - [ - "omitted scenario", - makeManifest(undefined, DEFAULT_ROWS.slice(0, -1)), - /classify every paired scenario/ - ], - [ - "reordered scenario", - makeManifest(undefined, [DEFAULT_ROWS[1], DEFAULT_ROWS[0], ...DEFAULT_ROWS.slice(2)]), - /classify every paired scenario/ - ], - [ - "unknown scenario", - makeManifest( - undefined, - DEFAULT_ROWS.map((row, index) => - index === DEFAULT_ROWS.length - 1 ? { ...row, scenario: "Unknown" } : row - ) - ), - /classify every paired scenario/ - ], - [ - "canonical-only role", - makeManifest( - undefined, - DEFAULT_ROWS.map((row, index) => (index === 1 ? { ...row, role: "canonical-only" } : row)) - ), - /unsupported role/ - ] - ]; - for (const [name, manifest, pattern] of cases) { - await t.test(name, () => assert.throws(() => validateManifest(manifest), pattern)); + for (const [name, manifest, pattern] of PERF_TEST_VECTORS.invalidManifestCases) { + await t.test(name, () => + assert.throws(() => validateManifest(structuredClone(manifest)), new RegExp(pattern)) + ); } }); @@ -536,82 +459,24 @@ test("retained artifact reduction does not depend on the current HEAD path inven test("summary validation rejects omitted, extra, reordered, and mismatched-manifest evidence", async (t) => { const bracket = makeBracket(); - const cases = [ - ["omitted row", (summaries) => summaries[0].rows.pop(), /row count/], - [ - "extra row", - (summaries) => - summaries[0].rows.push({ - scenario: "Extra", - firstToSecondRatio: 1, - cycleRatioSpreadPercent: 1, - cycleRatios: [1, 1, 1, 1] - }), - /row count/ - ], - ["reordered row", (summaries) => summaries[0].rows.reverse(), /summary row 0/], - [ - "duplicated row", - (summaries) => { - summaries[0].rows[1] = clone(summaries[0].rows[0]); - }, - /summary row 1/ - ], - [ - "manifest digest mismatch", - (summaries) => { - summaries[1].bracketManifestSha256 = "0".repeat(64); - }, - /manifest digest/ - ], - [ - "execution profile mismatch", - (summaries) => { - summaries[2].executionProfile.affinityMask = "0xFFFFFFFF"; - }, - /execution profile/ - ], - [ - "duplicate commit provenance", - (summaries) => { - summaries[2].commit = summaries[0].commit; - }, - /distinct commits/ - ], - [ - "different outer source trees", - (summaries) => { - summaries[2].sourceTree = "c".repeat(40); - }, - /outer summaries.*same source tree/ - ], - [ - "identical center source tree", - (summaries) => { - summaries[1].sourceTree = summaries[0].sourceTree; - }, - /outer and center.*different source trees/ - ], - [ - "different outer candidate source", - (summaries) => { - summaries[2].candidateSourceSha256 = "e".repeat(64); - }, - /outer summaries.*same candidate-source digest/ - ], - [ - "unchanged candidate source in center", - (summaries) => { - summaries[1].candidateSourceSha256 = summaries[0].candidateSourceSha256; - }, - /outer and center.*different candidate-source digests/ - ] - ]; - for (const [name, mutate, pattern] of cases) { + for (const [ + name, + operation, + field, + literal, + from, + pattern + ] of PERF_TEST_VECTORS.summaryEvidenceMutations) { await t.test(name, () => { const summaries = clone(bracket.summaries); - mutate(summaries); - assert.throws(() => reducePairedBracket(bracket.manifestBytes, summaries), pattern); + const resolve = (path) => path.split(".").reduce((value, key) => value[key], summaries); + if (operation === "set") mutateField(summaries, field, from ? clone(resolve(from)) : literal); + else if (operation === "push") resolve(field).push(structuredClone(literal)); + else resolve(field)[operation](); + assert.throws( + () => reducePairedBracket(bracket.manifestBytes, summaries), + new RegExp(pattern) + ); }); } }); @@ -668,37 +533,14 @@ test("extreme finite inputs cannot overflow or underflow into an accepted verdic test("three consistently wrong summaries cannot define their own profile or protocol", async (t) => { const bracket = makeBracket(); - const cases = [ - ["missing commit", "commit", undefined, /summary commit/], - ["missing source tree", "sourceTree", undefined, /sourceTree/], - ["missing candidate source", "candidateSourceSha256", undefined, /candidateSourceSha256/], - ["Mono platform", "platform", "PlayMode Mono", /platform/], - ["missing profile", "executionProfile", undefined, /execution profile/], - ["CPU model", "executionProfile.cpuModel", "other", /profile topology/], - ["missing CPU model", "executionProfile.cpuModel", undefined, /must contain exactly/], - ["profile source", "executionProfile.source", "other", /profile topology/], - ["selection policy", "executionProfile.selectionPolicy", "other", /profile topology/], - ["efficiency class", "executionProfile.selectedEfficiencyClass", -1, /profile topology/], - [ - "logical processors", - "executionProfile.selectedLogicalProcessorIndices", - [1, 2], - /profile topology/ - ], - ["profile", "executionProfile.id", "other", /execution profile/], - ["affinity", "executionProfile.affinityMask", "0xFFFFFFFF", /execution profile/], - ["priority", "executionProfile.priorityClass", "High", /execution profile/], - ["missing protocol", "protocol", undefined, /protocol constants/], - ["protocol", "protocol", "other", /protocol constants/], - ["cycles", "cycles", 1, /protocol constants/], - ["active time", "minimumCycleActiveMilliseconds", 1, /protocol constants/], - ["batch", "batchOperations", 1, /protocol constants/] - ]; - for (const [name, field, value, pattern] of cases) { + for (const [name, field, value, pattern, remove] of PERF_TEST_VECTORS.invalidSummaryProfiles) { await t.test(name, () => { const summaries = clone(bracket.summaries); - summaries.forEach((summary) => mutateField(summary, field, value)); - assert.throws(() => reducePairedBracket(bracket.manifestBytes, summaries), pattern); + summaries.forEach((summary) => mutateField(summary, field, remove ? undefined : value)); + assert.throws( + () => reducePairedBracket(bracket.manifestBytes, summaries), + new RegExp(pattern) + ); }); } }); diff --git a/scripts/unity/editor-settings-cache-contract.json b/scripts/unity/editor-settings-cache-contract.json new file mode 100644 index 000000000..34758a9b7 --- /dev/null +++ b/scripts/unity/editor-settings-cache-contract.json @@ -0,0 +1,627 @@ +{ + "profile": { + "schemaVersion": 1, + "measurementClass": "characterization", + "testMode": "EditMode", + "orders": ["ABBA", "BAAB", "ABBA", "BAAB"], + "baselineCalls": 64, + "followupCalls": 8, + "phaseAndBlockLimitMs": 2000, + "blockGrowthLimitBytes": 134217728, + "warmupCallsPerArm": 1, + "scope": "native Editor; selected overlays before full candidate tree", + "claim": "descriptive counters and live-memory changes; no speedup ratio, interval, retention or OOM cause" + }, + "runs": { + "focused": { + "passCount": 22, + "failCount": 0 + }, + "import": { + "passCount": 4, + "failCount": 0 + }, + "control": { + "passCount": 1, + "failCount": 3 + }, + "affected": { + "passCount": 117, + "failCount": 0 + }, + "full": { + "passCount": 975, + "failCount": 0 + } + }, + "controlFailures": [ + "DxMessaging.Tests.Editor.DxMessagingSettingsAssetImportTests.ImportedSettingsReplaceACachedMissWithoutDomainReload", + "DxMessaging.Tests.Editor.DxMessagingSettingsAssetImportTests.NativeAssetChangesInvalidateTheSelectedSettingsPath(\"delete\")", + "DxMessaging.Tests.Editor.DxMessagingSettingsAssetImportTests.NativeAssetChangesInvalidateTheSelectedSettingsPath(\"folder-move\")" + ], + "overlayPaths": [ + "Editor/Analyzers/DxMessagingConsoleHarvester.cs", + "Editor/Settings/DxMessagingSettings.cs", + "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs", + "Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta", + "Tests/Editor/DxMessagingSettingsAssetImportTests.cs", + "Tests/Editor/DxMessagingSettingsAssetImportTests.cs.meta", + "Tests/Editor/DxMessagingSettingsLookupTests.cs", + "Tests/Editor/DxMessagingSettingsLookupTests.cs.meta", + "Tests/Runtime/Benchmarks/BenchmarkProtocol.cs", + "Tests/Runtime/Comparisons/DxMessagingBridge.cs", + "Tests/Runtime/Comparisons/PairedBenchmarkProtocolContractTests.cs", + "Tests/Runtime/Comparisons/PairedComparisonHarness.cs" + ], + "sampleAssemblies": [ + "WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample", + "WallstopStudios.DxMessaging.DiagnosticsToolingExerciser.Sample.Editor", + "WallstopStudios.DxMessaging.MiniCombat.Sample", + "WallstopStudios.DxMessaging.UIButtons.Sample" + ], + "measurementIntegerFields": [ + "block", + "calls", + "found", + "legacySearches", + "passiveSearches", + "passiveLookups", + "workingBefore", + "workingAfter", + "nativeBefore", + "nativeAfter", + "managedBefore", + "managedAfter" + ], + "schemas": { + "idle": { + "type": "object", + "required": [ + "observedUtc", + "frameworkActive", + "playing", + "compiling", + "updating", + "mainStage", + "observationError", + "frameworkErrors", + "activeScene", + "scenes" + ], + "properties": { + "observedUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + }, + "frameworkActive": { + "const": false + }, + "playing": { + "const": false + }, + "compiling": { + "const": false + }, + "updating": { + "const": false + }, + "mainStage": { + "const": true + }, + "observationError": { + "const": "" + }, + "frameworkErrors": { + "const": "" + }, + "activeScene": { + "type": "string", + "minLength": 1, + "pattern": "^Assets/.+\\.unity$" + }, + "scenes": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "dirty", "loaded"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "dirty": { + "const": false + }, + "loaded": { + "const": true + } + }, + "additionalProperties": true + }, + "minItems": 1, + "maxItems": 1 + } + }, + "additionalProperties": true + }, + "run": { + "type": "object", + "required": ["runGuid", "resultPath"], + "properties": { + "runGuid": { + "type": "string", + "minLength": 1 + }, + "resultPath": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + }, + "result": { + "type": "object", + "required": [ + "passCount", + "failCount", + "skipCount", + "inconclusiveCount", + "durationSeconds", + "nodes" + ], + "properties": { + "passCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "skipCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inconclusiveCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "durationSeconds": { + "type": "number", + "minimum": 0 + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["isSuite", "name", "status", "startTime", "endTime"], + "properties": { + "isSuite": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": ["Passed", "Failed", "Skipped", "Inconclusive"] + }, + "startTime": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + }, + "endTime": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + } + }, + "additionalProperties": true + }, + "minItems": 1 + } + }, + "additionalProperties": true + }, + "source": { + "type": "object", + "required": [ + "sourceCommit", + "sourceTree", + "allOtherTrackedUnityAssetsIdentical", + "overlay", + "packageRestored" + ], + "properties": { + "sourceCommit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceTree": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "allOtherTrackedUnityAssetsIdentical": { + "const": true + }, + "overlay": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "candidateSha256", "retainedSha256", "redactionCounts"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "candidateSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "retainedSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "redactionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + } + }, + "additionalProperties": true + }, + "minItems": 12, + "maxItems": 12 + }, + "packageRestored": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "matchesOriginal"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "matchesOriginal": { + "const": true + } + }, + "additionalProperties": true + }, + "minItems": 12, + "maxItems": 12 + } + }, + "additionalProperties": true + }, + "freshness": { + "type": "object", + "required": ["fresh", "sourceUtc", "assemblyUtc"], + "properties": { + "fresh": { + "const": true + }, + "sourceUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + }, + "assemblyUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + } + }, + "additionalProperties": true + }, + "samples": { + "type": "object", + "required": ["rows", "assemblies"], + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "same", "sameMeta"], + "properties": { + "source": { + "type": "string", + "minLength": 1 + }, + "same": { + "const": true + }, + "sameMeta": { + "const": true + } + }, + "additionalProperties": true + }, + "minItems": 19, + "maxItems": 19 + }, + "assemblies": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "assemblyUtc"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "assemblyUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + } + }, + "additionalProperties": true + }, + "minItems": 4, + "maxItems": 4 + } + }, + "additionalProperties": true + }, + "inventory": { + "type": "object", + "required": ["rootMetaExists", "rootMetaSha256", "files"], + "properties": { + "rootMetaExists": { + "const": true + }, + "rootMetaSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "bytes", "sha256"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "additionalProperties": true + }, + "minItems": 43, + "maxItems": 43 + } + }, + "additionalProperties": true + }, + "restoration": { + "type": "object", + "required": [ + "newFixtureLoaded", + "scratchFolders", + "sampleFixtureCreated", + "restoredSourceUtc", + "assemblyUtc" + ], + "properties": { + "newFixtureLoaded": { + "const": false + }, + "scratchFolders": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 0, + "maxItems": 0 + }, + "sampleFixtureCreated": { + "const": false + }, + "restoredSourceUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + }, + "assemblyUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + } + }, + "additionalProperties": true + }, + "measurement": { + "type": "object", + "required": [ + "observedUtc", + "unityVersion", + "operatingSystem", + "assetPathCount", + "settingsPresent", + "legacySettingsPresent", + "canonicalPresent", + "warmupCachedCalls", + "warmupLegacyCalls", + "armA", + "armB", + "absentEnvironment", + "stopped", + "rows" + ], + "properties": { + "observedUtc": { + "type": "string", + "minLength": 1, + "pattern": "Z$" + }, + "unityVersion": { + "type": "string", + "minLength": 1 + }, + "operatingSystem": { + "type": "string", + "minLength": 1 + }, + "assetPathCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "settingsPresent": { + "const": true + }, + "legacySettingsPresent": { + "const": true + }, + "canonicalPresent": { + "const": true + }, + "warmupCachedCalls": { + "const": 1 + }, + "warmupLegacyCalls": { + "const": 1 + }, + "armA": { + "const": "legacy harvester FindAssets plus first GUID load" + }, + "armB": { + "const": "shared passive cached settings lookup" + }, + "absentEnvironment": { + "const": "not available; existing settings preserved" + }, + "stopped": { + "type": "string" + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": [ + "block", + "calls", + "found", + "legacySearches", + "passiveSearches", + "passiveLookups", + "workingBefore", + "workingAfter", + "nativeBefore", + "nativeAfter", + "managedBefore", + "managedAfter", + "order", + "arm", + "elapsedMilliseconds" + ], + "properties": { + "block": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "calls": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "found": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "legacySearches": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "passiveSearches": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "passiveLookups": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "workingBefore": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "workingAfter": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "nativeBefore": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "nativeAfter": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "managedBefore": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "managedAfter": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "order": { + "enum": ["ABBA", "BAAB"] + }, + "arm": { + "enum": ["A", "B"] + }, + "elapsedMilliseconds": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "workloadNote": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "testStatusFields": { + "Passed": "passCount", + "Failed": "failCount", + "Skipped": "skipCount", + "Inconclusive": "inconclusiveCount" + } +} diff --git a/scripts/unity/editor-settings-cache-contract.json.meta b/scripts/unity/editor-settings-cache-contract.json.meta new file mode 100644 index 000000000..438130292 --- /dev/null +++ b/scripts/unity/editor-settings-cache-contract.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cedc1a49aaaa4886aa3544ab903ddac9 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/perf-evidence-bundle.js b/scripts/unity/perf-evidence-bundle.js index 44beb8cd0..119df0331 100644 --- a/scripts/unity/perf-evidence-bundle.js +++ b/scripts/unity/perf-evidence-bundle.js @@ -13,6 +13,7 @@ const { } = require("./credential-patterns.js"); const { reduceDifferentialReplayFailure, + reduceEditorSettingsCache, reduceShippingFidelityMatrix, reducePairedThroughputScreen, reduceSubUnsubObservations @@ -25,6 +26,10 @@ const COMMIT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const MAXIMUM_SCANNED_BYTES = 256 * 1024 * 1024; const REDUCERS = Object.freeze({ + "editor-settings-cache-v1": { + artifactClass: "editor-settings-cache", + reduce: reduceEditorSettingsCache + }, "differential-replay-failure-v1": { artifactClass: "differential-replay-failure", reduce: reduceDifferentialReplayFailure diff --git a/scripts/unity/perf-evidence-reducers.js b/scripts/unity/perf-evidence-reducers.js index 2264da175..7f2f6c411 100644 --- a/scripts/unity/perf-evidence-reducers.js +++ b/scripts/unity/perf-evidence-reducers.js @@ -1,7 +1,13 @@ "use strict"; const crypto = require("crypto"); const { isDeepStrictEqual } = require("node:util"); +const { CELL_FIELDS, TIMING_FIELDS } = require("./shipping-evidence-fields.json"); const DIFFERENTIAL_CONTRACT = require("./differential-replay-contract.json"); +const SETTINGS_CONTRACT = require("./editor-settings-cache-contract.json"); +const ajv = new (require("ajv"))({ strict: true }); +const SETTINGS_SCHEMAS = Object.fromEntries( + Object.entries(SETTINGS_CONTRACT.schemas).map(([name, schema]) => [name, ajv.compile(schema)]) +); const { extractRows, buildCsv, deriveScope } = require("./extract-perf-baseline.js"); const { reducePairedBracket } = require("./reduce-paired-bracket.js"); // Reducers use only supplied bytes and ordinal ordering. Replay requires exact JSON equality. @@ -11,23 +17,6 @@ const NORMALIZED_SCHEMA_VERSION = 1; const DIFFERENTIAL_ENVIRONMENT_NAME = "differential-replay-environment.json"; const DIFFERENTIAL_PROFILE_NAME = "differential-replay-profile.json"; const DIFFERENTIAL_KINDS = DIFFERENTIAL_CONTRACT.profile.messageKinds; -/** Copied verbatim from each cell. These are the columns the matrix characterization publishes. */ -const CELL_FIELDS = Object.freeze([ - "managedStrippingLevel", - "topologyId", - "messageTypeCount", - "libraryState", - "buildDurationMs", - "editorBuildWallClockMs", - "playerTotalBytes", - "gameAssemblyBytes" -]); -const TIMING_FIELDS = Object.freeze([ - "engineStartToRunMs", - "firstTypedDispatchUs", - "dispatchLoopNsPerOp", - "dispatchLoopShape" -]); function requireBytes(contents, relativePath) { const bytes = contents.get(relativePath); if (bytes === undefined) { @@ -398,12 +387,211 @@ function reduceShippingFidelityMatrix(contents) { cells }; } +function reduceEditorSettingsCache(contents, { sourceCommit } = {}) { + const check = (condition, label) => { + if (!condition) throw new Error(`editor settings: invalid ${label}.`); + }; + const same = (actual, expected, label) => check(isDeepStrictEqual(actual, expected), label); + const read = (file, schema) => { + const value = parseJsonObject(contents, file, false); + if (schema) check(SETTINGS_SCHEMAS[schema](value), `${file} schema`); + return value; + }; + const timestamp = (value) => { + check( + typeof value === "string" && value.endsWith("Z") && Number.isFinite(Date.parse(value)), + "timestamp" + ); + return Date.parse(value); + }; + const profile = read("editor-settings-profile.json"); + same(profile, SETTINGS_CONTRACT.profile, "frozen profile"); + const source = read("source-proof.json", "source"), + freshness = read("freshness.json", "freshness"); + same(source.sourceCommit, sourceCommit, "source identity"); + const paths = (rows) => rows.map((row) => row.path); + same(paths(source.overlay), SETTINGS_CONTRACT.overlayPaths, "overlay paths"); + for (const row of source.overlay) { + same( + contentSha256(contents, `candidate/${row.path}.txt`), + row.retainedSha256, + "retained source bytes" + ); + same( + row.candidateSha256 === row.retainedSha256, + Object.keys(row.redactionCounts).length === 0, + "source redaction provenance" + ); + } + same(paths(source.packageRestored), SETTINGS_CONTRACT.overlayPaths, "restored paths"); + check(timestamp(freshness.sourceUtc) < timestamp(freshness.assemblyUtc), "fresh assembly"); + const samples = read("samples.json", "samples"), + original = read("original-samples.json", "inventory"), + restored = read("restored-samples.json", "inventory"); + same(original.files, restored.files, "original sample restoration"); + same(original.rootMetaSha256, restored.rootMetaSha256, "root metadata restoration"); + check(new Set(original.files.map((row) => row.path)).size === 43, "original inventory"); + check(new Set(samples.rows.map((row) => row.source)).size === 19, "sample source equality"); + same( + samples.assemblies.map((row) => row.name).sort(), + SETTINGS_CONTRACT.sampleAssemblies, + "sample assemblies" + ); + const restoration = read("restoration.json", "restoration"); + check( + timestamp(restoration.restoredSourceUtc) < timestamp(restoration.assemblyUtc), + "restored assembly freshness" + ); + const preflight = read("preflight.json", "idle"), + tests = {}; + same(preflight.scenes[0].path, preflight.activeScene, "preflight scene"); + for (const [id, expected] of Object.entries(SETTINGS_CONTRACT.runs)) { + const result = read(`${id}.result.json`, "result"), + run = read(`${id}.run.json`, "run"), + cleanup = read(`${id}.cleanup.json`, "idle"); + same(cleanup.activeScene, preflight.activeScene, "restored active scene"); + same(cleanup.scenes, preflight.scenes, "restored scenes"); + check( + run.runGuid === cleanup.runGuid && + run.resultPath === cleanup.resultPath && + run.resultPath === cleanup.ownedResultPath, + "run ownership" + ); + same( + requireBytes(contents, `${id}.status.txt`).toString().trim(), + "done", + "terminal result status" + ); + same( + requireBytes(contents, `${id}.cleanup.status.txt`).toString().trim(), + id === "control" + ? "error: result has no passes or contains failed/inconclusive nodes" + : "done", + "terminal cleanup status" + ); + const leaves = result.nodes.filter((node) => !node.isSuite), + counts = { passCount: 0, failCount: 0, skipCount: 0, inconclusiveCount: 0 }; + check(new Set(leaves.map((node) => node.name)).size === leaves.length, "unique test leaves"); + for (const leaf of leaves) counts[SETTINGS_CONTRACT.testStatusFields[leaf.status]]++; + same(counts, { ...expected, skipCount: 0, inconclusiveCount: 0 }, "expected leaf counts"); + for (const [field, count] of Object.entries(counts)) + same(result[field], count, "reported leaf counts"); + const failures = leaves + .filter((node) => node.status === "Failed") + .map((node) => node.name) + .sort(); + same( + failures, + id === "control" ? SETTINGS_CONTRACT.controlFailures : [], + "control failure set" + ); + check( + timestamp(result.nodes[0].endTime) <= timestamp(cleanup.observedUtc), + "cleanup chronology" + ); + if (id === "full") { + check( + timestamp(preflight.observedUtc) <= timestamp(result.nodes[0].startTime) && + timestamp(freshness.assemblyUtc) < timestamp(preflight.observedUtc), + "full suite freshness" + ); + for (const assembly of samples.assemblies) + check( + timestamp(freshness.sourceUtc) < timestamp(assembly.assemblyUtc) && + timestamp(assembly.assemblyUtc) < timestamp(preflight.observedUtc), + "sample assembly freshness" + ); + } + tests[id] = { + ...counts, + failures, + durationSeconds: result.durationSeconds, + runGuid: run.runGuid + }; + } + const stoppedBaseline = read("stopped-baseline.json", "measurement"), + followup = read("followup.json", "measurement"); + check( + timestamp(stoppedBaseline.observedUtc) < timestamp(followup.observedUtc), + "measurement chronology" + ); + for (const data of [stoppedBaseline, followup]) { + check( + Array.isArray(data.rows) && data.rows.length === (data === stoppedBaseline ? 1 : 16), + "measurement row count" + ); + data.rows.forEach((row, index) => { + const block = Math.floor(index / 4), + order = profile.orders[block], + calls = data === stoppedBaseline ? 64 : 8; + same( + [row.block, row.order, row.arm, row.calls, row.found], + [block, order, order[index % 4], calls, calls], + "measurement schedule" + ); + same( + [row.legacySearches, row.passiveSearches, row.passiveLookups], + [row.arm === "A" ? calls : 0, 0, row.arm === "B" ? calls : 0], + "search counters" + ); + }); + } + same( + [stoppedBaseline.unityVersion, stoppedBaseline.operatingSystem, stoppedBaseline.assetPathCount], + [followup.unityVersion, followup.operatingSystem, followup.assetPathCount], + "measurement environment agreement" + ); + same(stoppedBaseline.stopped, "lookup-block-exceeded-two-seconds", "retained stopping reason"); + check( + stoppedBaseline.rows[0].elapsedMilliseconds > profile.phaseAndBlockLimitMs, + "stopped baseline timing" + ); + same(followup.stopped, "", "completed followup"); + for (let block = 0; block < 4; block++) { + const rows = followup.rows.slice(block * 4, block * 4 + 4); + check( + rows.reduce((sum, row) => sum + row.elapsedMilliseconds, 0) <= profile.phaseAndBlockLimitMs, + "block timed phases" + ); + for (const row of rows) + for (const prefix of ["working", "native"]) + check( + row[`${prefix}After`] - rows[0][`${prefix}Before`] <= profile.blockGrowthLimitBytes, + "block memory growth" + ); + } + const arms = {}; + for (const arm of ["A", "B"]) { + const rows = followup.rows.filter((row) => row.arm === arm), + sum = (value) => rows.reduce((total, row) => total + value(row), 0); + arms[arm] = { + calls: sum((row) => row.calls), + searches: sum((row) => row.legacySearches + row.passiveSearches), + elapsedMilliseconds: sum((row) => row.elapsedMilliseconds), + workingDeltaBytes: sum((row) => row.workingAfter - row.workingBefore), + unityAllocatedDeltaBytes: sum((row) => row.nativeAfter - row.nativeBefore), + managedLiveDeltaBytes: sum((row) => row.managedAfter - row.managedBefore) + }; + } + return { + schemaVersion: 1, + reducer: "editor-settings-cache-v1", + measurementClass: "characterization", + sourceCommit, + sourceTree: source.sourceTree, + profile, + tests, + stoppedBaseline, + followup: { ...followup, arms } + }; +} module.exports = { CELL_EVIDENCE_SUFFIX, DIFFERENTIAL_ENVIRONMENT_NAME, DIFFERENTIAL_PROFILE_NAME, MATRIX_EVIDENCE_NAME, reduceDifferentialReplayFailure, + reduceEditorSettingsCache, reducePairedThroughputScreen, reduceSubUnsubObservations, reduceShippingFidelityMatrix, diff --git a/scripts/unity/shipping-evidence-fields.json b/scripts/unity/shipping-evidence-fields.json new file mode 100644 index 000000000..16e61f8ff --- /dev/null +++ b/scripts/unity/shipping-evidence-fields.json @@ -0,0 +1,18 @@ +{ + "CELL_FIELDS": [ + "managedStrippingLevel", + "topologyId", + "messageTypeCount", + "libraryState", + "buildDurationMs", + "editorBuildWallClockMs", + "playerTotalBytes", + "gameAssemblyBytes" + ], + "TIMING_FIELDS": [ + "engineStartToRunMs", + "firstTypedDispatchUs", + "dispatchLoopNsPerOp", + "dispatchLoopShape" + ] +} diff --git a/scripts/unity/shipping-evidence-fields.json.meta b/scripts/unity/shipping-evidence-fields.json.meta new file mode 100644 index 000000000..e968917de --- /dev/null +++ b/scripts/unity/shipping-evidence-fields.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0b182fa8a1e54b14a81430edd51eb75b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 929a7359a43ddfaedab4027850ec9e114d87ef58 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 18:37:44 +0000 Subject: [PATCH 005/147] Index restored native settings cache evidence --- docs/runbooks/perf-evidence-bundles.md | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/runbooks/perf-evidence-bundles.md b/docs/runbooks/perf-evidence-bundles.md index 17ba4d8c4..3b7c6a550 100644 --- a/docs/runbooks/perf-evidence-bundles.md +++ b/docs/runbooks/perf-evidence-bundles.md @@ -431,6 +431,43 @@ Repository-wide immutable releases were enabled with maintainer approval on 2026 authenticated setting read back `enabled: true`. Publication remains an operator procedure, not an automatic upload of every workflow artifact. Retain all indexed revisions without expiry. +### editor-settings-cache-613-6000.4.6f1, revision 1 + +Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains +the #613 native Unity 6000.4.6f1 Editor characterization, its stopped baseline, adaptive descriptive +followup, native test/control results, and source/fixture restoration observations. It is not +supported-version matrix, player performance, allocation-recorder, leak, or OOM-cause evidence. + +| Identity | Value | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release | [Immutable Editor settings cache prerelease](https://github.com/Ambiguous-Interactive/DxMessaging/releases/tag/perf-evidence-editor-settings-cache-613-6000.4.6f1-r1-ab70b721) | +| Release ID | `406044866` | +| Asset | [Editor settings cache archive](https://github.com/Ambiguous-Interactive/DxMessaging/releases/download/perf-evidence-editor-settings-cache-613-6000.4.6f1-r1-ab70b721/editor-settings-cache-613-6000.4.6f1-r1.tar.gz) | +| Exact asset name | `editor-settings-cache-613-6000.4.6f1-r1.tar.gz` | +| Asset ID and size | `619463560`, 110,982 bytes | +| Archive SHA-256 | `9ceee5073dc0b093f3ebf793b58b1a1df32e8bceb91b62f73b6d7bd9a2c919d9` | +| Manifest SHA-256 | `ab70b72136a2d26845b19dfd6a260087b05ca4e3780a7e1545b9df9ba58c3c88` | +| Bundle digest | `cf39d348d6b4be154fbf4ad8d55877b77d479490c251f7f417589d405b45db1f` | +| Measured source | `ae79805245bc2fbcbf3479354260bb1908b08899` | +| Measured source tree | `d011bc50e868c33ac9f4d9b59775a274ec7a6ef5` | +| Verifier commit | `f90dca2f2e1b388ce2946a92248f6643433a9955` | +| Inventory | 70 declared files plus the manifest; archive members are relative regular files | +| Reviewer commands | Fresh remote clone; clean checkout; `npm ci --ignore-scripts`; Node.js `v24.21.0`; `verify` exit 0; `replay` exit 0 | +| Publication checks | Authenticated setting `enabled: true`; complete draft download verified before publication; published `draft: false`, `prerelease: true`, `immutable: true`; tag resolves to verifier commit | +| Independent restore | New published-asset download; full archive/manifest digests, inventory and identity match; normalized replay identical; verifier checkout remains clean | +| Original source restore | All 12 original overlay source hashes match a separate checkout of the measured commit, including the one scrubbed source excerpt | +| Denied access | Actual required asset `619463560` returned HTTP 401 and retrieval exit 1 with isolated invalid authentication; no fallback; experiment remained incomplete in that context | +| Verifier CI | [All 17 static jobs succeeded at the verifier commit](https://github.com/Ambiguous-Interactive/DxMessaging/actions/runs/37666819976) | + +The full ordinary Editor suite passed 975 leaves, including all 13 imported sample quality cases. +The postprocessor negative control retains three expected failures; candidate import tests passed. +The original 64-call legacy phase stopped at 3,931.2884 ms before any paired B phase. The separately +preregistered 8-call followup retains all four ABBA/BAAB blocks: 64 legacy searches, 64 cached calls +and zero cached searches. Signed working-set and live-memory changes remain in the normalized +result. No speedup ratio or confirmatory interval is reported. Original samples, package sources +and the clean saved scene were restored. Supported shipping Editor/player gates and maintainer +review remain pending; this publication does not close #613. + ### native-lifecycle-replay-failure, revision 1 Status: published and restored from GitHub in a fresh verifier clone on 2026-09-13. This is a From 657c3da91acd7da7b884541e49b0c7e6a94a9eca Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 19:01:57 +0000 Subject: [PATCH 006/147] docs: retain session 244 negative screen evidence --- docs/runbooks/perf-evidence-bundles.md | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/runbooks/perf-evidence-bundles.md b/docs/runbooks/perf-evidence-bundles.md index 3b7c6a550..c7de5a96d 100644 --- a/docs/runbooks/perf-evidence-bundles.md +++ b/docs/runbooks/perf-evidence-bundles.md @@ -431,6 +431,48 @@ Repository-wide immutable releases were enabled with maintainer approval on 2026 authenticated setting read back `enabled: true`. Publication remains an operator procedure, not an automatic upload of every workflow artifact. Retain all indexed revisions without expiry. +### session244-paired-screen-replay, revision 1 + +Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains +the historical borrowed untargeted-interceptor screen's original `uninterpretable` verdict. +The candidate was reverted. Four sentinels exceeded the fixed 3% band; the target's apparent +gain does not establish a causal performance improvement. + +| Identity | Value | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release | [Immutable borrowed-interceptor negative screen](https://github.com/Ambiguous-Interactive/DxMessaging/releases/tag/perf-evidence-session244-paired-screen-replay-r1-89f5f341) | +| Release ID | `406067060` | +| Asset | [Session 244 paired-screen archive](https://github.com/Ambiguous-Interactive/DxMessaging/releases/download/perf-evidence-session244-paired-screen-replay-r1-89f5f341/session244-paired-screen-replay-r1.tar.gz) | +| Asset ID | `619526436` | +| Revision and reducer | `1`, `paired-throughput-screen-v1` | +| First measured source commit | `e09622c9262fd924e2e2b597486ab992f141dd20` | +| Center measured source commit | `12324ec4dd0a9c80f4fa36da5b0117fa4f60e3cb` | +| Last measured source commit | `be7df7820553360244567747b53259a551e12a46` | +| Manifest SHA-256 | `89f5f3411a31d0f8fc91f24a0ce54e5cebf5f54dca8cbcfb4166637f2de7bd75` | +| Archive SHA-256 | `3577e21df4de66b4dae2b00ab93f2d67f97aca939cabc6a6dea9b8040e2659bb` | +| Archive bytes and file count | `7226` bytes; `6` declared files plus the manifest | +| Bundle digest | `c061da941ae6e9a18769f988dcd7a9f7c7add74add021a93138f0638228d184d` | +| Verifier and release tag commit | `f90dca2f2e1b388ce2946a92248f6643433a9955` | + +The declaration and all three raw-cycle summaries match the original committed declaration and +retained artifact ZIPs. The reducer reproduces the original verdict exactly, including the +`GlobalToOne`, `GlobalToMany`, `PostProcess`, and `StructNoBox` sentinel failures. Candidate one +uploaded its measurements before a failed post-upload license-cleanup gate; the later retained +arms passed cleanup. That failed outcome remains explicit in the retention provenance. + +The complete draft inventory was downloaded and verified before publication. Published metadata +reported `draft: false`, `prerelease: true`, and `immutable: true`; the tag resolves to the verifier +commit. A fresh remote clone with `npm ci --ignore-scripts` verified the separately downloaded +published archive, exact file inventory and hashes, and all three source trees and candidate-source +digests fetched from GitHub. Both `verify` and `replay` exited zero; the verifier stayed clean. +The actual required asset returned HTTP 401 in an isolated invalid-credential context, with no +archive or fallback; that context's experiment remained incomplete. + +This is legacy-screen retention. The reviewed text bundle excludes the locally retained original +ZIPs, native binaries, symbol maps, codegen, and full job logs. It does not establish calibrated +independent-build confirmation, native-cost attribution, allocation behavior, or production +acceptance. The original decision is recorded in [the session 244 result](https://github.com/Ambiguous-Interactive/DxMessaging/issues/414#issuecomment-5434491863). + ### editor-settings-cache-613-6000.4.6f1, revision 1 Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains From e7e82fe71b07b63b36e226ab89d7452e226afc62 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 19:16:32 +0000 Subject: [PATCH 007/147] docs: retain cached route and post screen evidence --- docs/runbooks/perf-evidence-bundles.md | 82 ++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/runbooks/perf-evidence-bundles.md b/docs/runbooks/perf-evidence-bundles.md index c7de5a96d..f16bf7553 100644 --- a/docs/runbooks/perf-evidence-bundles.md +++ b/docs/runbooks/perf-evidence-bundles.md @@ -431,6 +431,88 @@ Repository-wide immutable releases were enabled with maintainer approval on 2026 authenticated setting read back `enabled: true`. Publication remains an operator procedure, not an automatic upload of every workflow artifact. Retain all indexed revisions without expiry. +### session242-paired-screen-replay, revision 1 + +Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains +the cached untargeted route experiment's original `uninterpretable` verdict. `Filtered`, +`PostProcess`, and `FilteredPostProcess` sentinels exceeded the fixed 3% band. The candidate +was reverted; its apparent target gains do not establish a causal performance improvement. + +| Identity | Value | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release | [Immutable cached-route negative screen](https://github.com/Ambiguous-Interactive/DxMessaging/releases/tag/perf-evidence-session242-paired-screen-replay-r1-bf5b4770) | +| Release ID | `406078044` | +| Asset | [Session 242 paired-screen archive](https://github.com/Ambiguous-Interactive/DxMessaging/releases/download/perf-evidence-session242-paired-screen-replay-r1-bf5b4770/session242-paired-screen-replay-r1.tar.gz) | +| Asset ID | `619557204` | +| Revision and reducer | `1`, `paired-throughput-screen-v1` | +| First measured source commit | `ef7e5b799ec5b43f823c9c22c6e31295d51ea443` | +| Center measured source commit | `4fdd1df3bd82f97d97a209ad0cab3dd585eea7f6` | +| Last measured source commit | `fc038a1ddb1f86dd2b7f834330f739494eb89944` | +| Manifest SHA-256 | `bf5b4770c5992028b8ce97cc8fdf98c8b1b322685b0037a26bb6ecf6b276e01f` | +| Archive SHA-256 | `08321d7744c3dc01ea3e570819f1327635c6d4c71f12a53a403ad1abf36c9514` | +| Archive bytes and file count | `7534` bytes; `6` declared files plus the manifest | +| Bundle digest | `f85cadc3010ebfd515c555e235b4c971a3f94fb3d9a900cd45a770ca594d868d` | +| Verifier and release tag commit | `f90dca2f2e1b388ce2946a92248f6643433a9955` | + +All three raw-cycle summaries match their retained ZIP members and original committed declaration. +The original workflow artifact listings are now empty; the recorded local ZIP hashes cannot be +checked against GitHub's historical artifact digests. The control's measurements and upload +completed before its license-cleanup gate failed. Candidate two recovered the runner. Those +provenance limits remain explicit; immutable publication does not repair missing original metadata. + +The complete draft inventory and download passed before publication. Published metadata reported +`draft: false`, `prerelease: true`, and `immutable: true`; the tag resolves to the verifier commit. +A new remote clone with `npm ci --ignore-scripts` checked the separately downloaded published +archive, manifest, every file, and all three source trees and candidate-source digests fetched +from GitHub. Both `verify` and `replay` exited zero and reproduced the original verdict exactly; +the verifier stayed clean. The actual required asset returned HTTP 401 in an isolated +invalid-credential context, with no archive or fallback; that context remained incomplete. + +This reviewed text bundle excludes the locally retained original ZIPs, native binaries, maps, +codegen, and full logs. It establishes legacy-screen retention, with no calibrated confirmation, +native-cost attribution, allocation, or production-acceptance claim. See [the original session 242 decision](https://github.com/Ambiguous-Interactive/DxMessaging/issues/414#issuecomment-5430405546). + +### session243-paired-screen-replay, revision 1 + +Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains +the borrowed post-route experiment's original `uninterpretable` verdict. `GlobalToOne`, +`GlobalToMany`, and `StructNoBox` sentinels exceeded the fixed 3% band. The candidate was +reverted; its apparent target gain does not establish a causal performance improvement. + +| Identity | Value | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release | [Immutable borrowed-post negative screen](https://github.com/Ambiguous-Interactive/DxMessaging/releases/tag/perf-evidence-session243-paired-screen-replay-r1-6891c434) | +| Release ID | `406078051` | +| Asset | [Session 243 paired-screen archive](https://github.com/Ambiguous-Interactive/DxMessaging/releases/download/perf-evidence-session243-paired-screen-replay-r1-6891c434/session243-paired-screen-replay-r1.tar.gz) | +| Asset ID | `619557318` | +| Revision and reducer | `1`, `paired-throughput-screen-v1` | +| First measured source commit | `3d44378feebf4e0b84f066ca94dc620cfafe90c2` | +| Center measured source commit | `47f7ed6b537ccf82df273e7e599cdb5199366629` | +| Last measured source commit | `26f314740c7ca82f4d9852f80b36a2cee3703c1a` | +| Manifest SHA-256 | `6891c4344882c00979a854f4934d21f5ed6959c091dc3df79dfef5869ccfdc33` | +| Archive SHA-256 | `a81cacd01133bf8de1f7b6a3f211a86ff5ff6a9b702c9f3e86335d08151b1d58` | +| Archive bytes and file count | `7540` bytes; `6` declared files plus the manifest | +| Bundle digest | `bebb1b31ae0c96ee5e51651d7bcd954f581176cb4626381b3a5e0cac9da5afaa` | +| Verifier and release tag commit | `f90dca2f2e1b388ce2946a92248f6643433a9955` | + +The original raw-cycle summaries match retained ZIP members; ZIP hashes match the original +experiment record. All three comparison jobs passed cleanup. Separate intermediate Unity +workflows were cancelled after the performance arms began, and the first candidate's oldest-Unity +job passed its test modes before a cleanup failure. Later jobs recovered. These original outcomes +remain explicit in the retention provenance. + +The complete draft inventory and download passed before publication. Published metadata reported +`draft: false`, `prerelease: true`, and `immutable: true`; the tag resolves to the verifier commit. +A new remote clone with `npm ci --ignore-scripts` checked the separately downloaded published +archive, manifest, every file, and all three source trees and candidate-source digests fetched +from GitHub. Both `verify` and `replay` exited zero and reproduced the original verdict exactly; +the verifier stayed clean. The actual required asset returned HTTP 401 in an isolated +invalid-credential context, with no archive or fallback; that context remained incomplete. + +This reviewed text bundle excludes the locally retained original ZIPs, native binaries, maps, +codegen, and full logs. It establishes legacy-screen retention, with no calibrated confirmation, +native-cost attribution, allocation, or production-acceptance claim. See [the original session 243 decision](https://github.com/Ambiguous-Interactive/DxMessaging/issues/414#issuecomment-5432013543). + ### session244-paired-screen-replay, revision 1 Status: published and restored from GitHub in a fresh verifier clone on 2026-10-07. This retains From e404585a1a0d17f7dfb9afee139ff1bad27c8a51 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 20:10:27 +0000 Subject: [PATCH 008/147] Validate raw work and time in paired evidence replay --- docs/runbooks/perf-evidence-bundles.md | 12 + scripts/__tests__/perf-test-vectors.json | 1223 ++++++++++++++++- .../__tests__/reduce-paired-bracket.test.js | 103 +- scripts/__tests__/unity-perf.test.js | 116 +- scripts/unity/paired-replay-contract.json | 29 + .../unity/paired-replay-contract.json.meta | 7 + scripts/unity/reduce-paired-bracket.js | 110 +- scripts/unity/require-comparison-rows.ps1 | 1 + 8 files changed, 1435 insertions(+), 166 deletions(-) create mode 100644 scripts/unity/paired-replay-contract.json create mode 100644 scripts/unity/paired-replay-contract.json.meta diff --git a/docs/runbooks/perf-evidence-bundles.md b/docs/runbooks/perf-evidence-bundles.md index f16bf7553..c9f1fd16c 100644 --- a/docs/runbooks/perf-evidence-bundles.md +++ b/docs/runbooks/perf-evidence-bundles.md @@ -184,6 +184,18 @@ node scripts/unity/perf-evidence-bundle.js replay \ .artifacts/paired-screen/evidence-manifest.json ``` +Retain four `cycleMeasurements` per row. Each cycle must have positive safe integer operation +counts, equal work for both arms, and a multiple of 40,000 operations per arm. Both elapsed times +must be finite and at least 0.625 seconds. The retained cycle ratio and corresponding `cycleRatios` +entry must match the rates derived from that work and time within relative error `1e-12`. +`aggregateRateRatio` must match total work divided by total active time for each arm. Totals must +remain finite, and operation sums must remain safe integers. + +Verifier revisions before [the raw cycle fix](https://github.com/Ambiguous-Interactive/DxMessaging/issues/618) +checked cycle ratios without validating raw work, active time, or the aggregate ratio. Historical +publication tags retain their original verifier. Check raw cycle consistency with a revision +containing that fix, and record the analysis revision separately from the unchanged archive. + The adapter calls `reduce-paired-bracket.js` directly. It validates all three positions, raw cycle consistency, source relationships, declaration identity, and the shared execution profile before reproducing effects and the `accepted`, `rejected`, or `uninterpretable` screen decision. Replaying diff --git a/scripts/__tests__/perf-test-vectors.json b/scripts/__tests__/perf-test-vectors.json index 3dd2e8c37..9554b7e4f 100644 --- a/scripts/__tests__/perf-test-vectors.json +++ b/scripts/__tests__/perf-test-vectors.json @@ -618,5 +618,1226 @@ "261b1867e052723517db8a77048c209a20108204", "da5439ac21e60d5e04e4f453e3766e03daff0486" ], - "positions": ["first", "center", "last"] + "positions": ["first", "center", "last"], + "invalidRawCycles": [ + ["missing measurements", "cycleMeasurements", null, "exactly 4", true], + ["null measurements", "cycleMeasurements", null, "exactly 4"], + ["wrong type measurements", "cycleMeasurements", {}, "exactly 4"], + ["short measurements", "cycleMeasurements", [], "exactly 4"], + ["null raw cycle", "cycleMeasurements.0", null, "cycleMeasurements.*object"], + ["array raw cycle", "cycleMeasurements.3", [], "cycleMeasurements.*object"], + [ + "missing firstOperations", + "cycleMeasurements.0.firstOperations", + null, + "positive safe integer", + true + ], + ["zero firstOperations", "cycleMeasurements.0.firstOperations", 0, "positive safe integer"], + [ + "negative firstOperations", + "cycleMeasurements.0.firstOperations", + -40000, + "positive safe integer" + ], + [ + "fractional firstOperations", + "cycleMeasurements.0.firstOperations", + 40000.5, + "positive safe integer" + ], + [ + "string firstOperations", + "cycleMeasurements.0.firstOperations", + "40000", + "positive safe integer" + ], + [ + "unsafe firstOperations", + "cycleMeasurements.0.firstOperations", + 9007199254760000, + "positive safe integer", + false, + "unsupported structured data" + ], + [ + "unbalanced firstOperations", + "cycleMeasurements.0.firstOperations", + 80000, + "balanced ABBA/BAAB" + ], + [ + "missing secondOperations", + "cycleMeasurements.0.secondOperations", + null, + "positive safe integer", + true + ], + ["zero secondOperations", "cycleMeasurements.0.secondOperations", 0, "positive safe integer"], + [ + "negative secondOperations", + "cycleMeasurements.0.secondOperations", + -40000, + "positive safe integer" + ], + [ + "fractional secondOperations", + "cycleMeasurements.0.secondOperations", + 40000.5, + "positive safe integer" + ], + [ + "string secondOperations", + "cycleMeasurements.0.secondOperations", + "40000", + "positive safe integer" + ], + [ + "unsafe secondOperations", + "cycleMeasurements.0.secondOperations", + 9007199254760000, + "positive safe integer", + false, + "unsupported structured data" + ], + [ + "unbalanced secondOperations", + "cycleMeasurements.0.secondOperations", + 80000, + "balanced ABBA/BAAB" + ], + [ + "missing firstActiveSeconds", + "cycleMeasurements.0.firstActiveSeconds", + null, + "finite number", + true + ], + ["null firstActiveSeconds", "cycleMeasurements.0.firstActiveSeconds", null, "finite number"], + ["string firstActiveSeconds", "cycleMeasurements.0.firstActiveSeconds", "1", "finite number"], + [ + "negative firstActiveSeconds", + "cycleMeasurements.0.firstActiveSeconds", + -1, + "minimum active time" + ], + ["zero firstActiveSeconds", "cycleMeasurements.0.firstActiveSeconds", 0, "minimum active time"], + [ + "below minimum firstActiveSeconds", + "cycleMeasurements.0.firstActiveSeconds", + 0.6249999999999999, + "minimum active time" + ], + [ + "wrong raw time firstActiveSeconds", + "cycleMeasurements.0.firstActiveSeconds", + 2, + "work and active time" + ], + [ + "missing secondActiveSeconds", + "cycleMeasurements.0.secondActiveSeconds", + null, + "finite number", + true + ], + ["null secondActiveSeconds", "cycleMeasurements.0.secondActiveSeconds", null, "finite number"], + ["string secondActiveSeconds", "cycleMeasurements.0.secondActiveSeconds", "1", "finite number"], + [ + "negative secondActiveSeconds", + "cycleMeasurements.0.secondActiveSeconds", + -1, + "minimum active time" + ], + [ + "zero secondActiveSeconds", + "cycleMeasurements.0.secondActiveSeconds", + 0, + "minimum active time" + ], + [ + "below minimum secondActiveSeconds", + "cycleMeasurements.0.secondActiveSeconds", + 0.6249999999999999, + "minimum active time" + ], + [ + "wrong raw time secondActiveSeconds", + "cycleMeasurements.0.secondActiveSeconds", + 2, + "work and active time" + ], + [ + "missing cycleMeasurements.0.firstToSecondRatio", + "cycleMeasurements.0.firstToSecondRatio", + null, + "finite number", + true + ], + [ + "zero cycleMeasurements.0.firstToSecondRatio", + "cycleMeasurements.0.firstToSecondRatio", + 0, + "positive" + ], + [ + "negative cycleMeasurements.0.firstToSecondRatio", + "cycleMeasurements.0.firstToSecondRatio", + -1, + "positive" + ], + [ + "string cycleMeasurements.0.firstToSecondRatio", + "cycleMeasurements.0.firstToSecondRatio", + "1", + "finite number" + ], + [ + "wrong cycleMeasurements.0.firstToSecondRatio", + "cycleMeasurements.0.firstToSecondRatio", + 99, + "work and active time|aggregate ratio" + ], + ["missing aggregateRateRatio", "aggregateRateRatio", null, "finite number", true], + ["zero aggregateRateRatio", "aggregateRateRatio", 0, "positive"], + ["negative aggregateRateRatio", "aggregateRateRatio", -1, "positive"], + ["string aggregateRateRatio", "aggregateRateRatio", "1", "finite number"], + ["wrong aggregateRateRatio", "aggregateRateRatio", 99, "work and active time|aggregate ratio"], + ["long measurements", "cycleMeasurements", [null, null, null, null, null], "exactly 4"], + [ + "incomplete balanced supercycle", + "cycleMeasurements.0", + { + "firstOperations": 60000, + "secondOperations": 60000, + "firstActiveSeconds": 1.004987562112089, + "secondActiveSeconds": 1, + "firstToSecondRatio": 0.9950371902099893 + }, + "balanced ABBA/BAAB" + ], + [ + "NaN cycleMeasurements.3.firstOperations", + "cycleMeasurements.3.firstOperations", + { + "number": "NaN" + }, + "positive safe integer" + ], + [ + "Infinity cycleMeasurements.3.firstOperations", + "cycleMeasurements.3.firstOperations", + { + "number": "Infinity" + }, + "positive safe integer" + ], + [ + "-Infinity cycleMeasurements.3.firstOperations", + "cycleMeasurements.3.firstOperations", + { + "number": "-Infinity" + }, + "positive safe integer" + ], + [ + "NaN cycleMeasurements.3.secondOperations", + "cycleMeasurements.3.secondOperations", + { + "number": "NaN" + }, + "positive safe integer" + ], + [ + "Infinity cycleMeasurements.3.secondOperations", + "cycleMeasurements.3.secondOperations", + { + "number": "Infinity" + }, + "positive safe integer" + ], + [ + "-Infinity cycleMeasurements.3.secondOperations", + "cycleMeasurements.3.secondOperations", + { + "number": "-Infinity" + }, + "positive safe integer" + ], + [ + "NaN cycleMeasurements.3.firstActiveSeconds", + "cycleMeasurements.3.firstActiveSeconds", + { + "number": "NaN" + }, + "finite number" + ], + [ + "Infinity cycleMeasurements.3.firstActiveSeconds", + "cycleMeasurements.3.firstActiveSeconds", + { + "number": "Infinity" + }, + "finite number" + ], + [ + "-Infinity cycleMeasurements.3.firstActiveSeconds", + "cycleMeasurements.3.firstActiveSeconds", + { + "number": "-Infinity" + }, + "finite number" + ], + [ + "NaN cycleMeasurements.3.secondActiveSeconds", + "cycleMeasurements.3.secondActiveSeconds", + { + "number": "NaN" + }, + "finite number" + ], + [ + "Infinity cycleMeasurements.3.secondActiveSeconds", + "cycleMeasurements.3.secondActiveSeconds", + { + "number": "Infinity" + }, + "finite number" + ], + [ + "-Infinity cycleMeasurements.3.secondActiveSeconds", + "cycleMeasurements.3.secondActiveSeconds", + { + "number": "-Infinity" + }, + "finite number" + ], + [ + "NaN cycleMeasurements.3.firstToSecondRatio", + "cycleMeasurements.3.firstToSecondRatio", + { + "number": "NaN" + }, + "finite number" + ], + [ + "Infinity cycleMeasurements.3.firstToSecondRatio", + "cycleMeasurements.3.firstToSecondRatio", + { + "number": "Infinity" + }, + "finite number" + ], + [ + "-Infinity cycleMeasurements.3.firstToSecondRatio", + "cycleMeasurements.3.firstToSecondRatio", + { + "number": "-Infinity" + }, + "finite number" + ], + [ + "NaN aggregateRateRatio", + "aggregateRateRatio", + { + "number": "NaN" + }, + "finite number" + ], + [ + "Infinity aggregateRateRatio", + "aggregateRateRatio", + { + "number": "Infinity" + }, + "finite number" + ], + [ + "-Infinity aggregateRateRatio", + "aggregateRateRatio", + { + "number": "-Infinity" + }, + "finite number" + ], + [ + "retained ratio agrees with work but differs from cycleRatios", + "cycleMeasurements", + [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + } + ], + "work and active time" + ], + [ + "raw cycle ratio beyond tolerance", + "cycleMeasurements.0.firstToSecondRatio", + 0.9950371902119793, + "work and active time" + ], + ["aggregate beyond tolerance", "aggregateRateRatio", 1.000000000002, "aggregate ratio"], + [ + "inexact operation sum", + "cycleMeasurements", + [ + { + "firstOperations": 2400000000000000, + "secondOperations": 2400000000000000, + "firstActiveSeconds": 1.004987562112089, + "secondActiveSeconds": 1, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 2400000000000000, + "secondOperations": 2400000000000000, + "firstActiveSeconds": 1.004987562112089, + "secondActiveSeconds": 1, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 2400000000000000, + "secondOperations": 2400000000000000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1.004987562112089, + "firstToSecondRatio": 1.004987562112089 + }, + { + "firstOperations": 2400000000000000, + "secondOperations": 2400000000000000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1.004987562112089, + "firstToSecondRatio": 1.004987562112089 + } + ], + "totals must remain finite and exact", + false + ], + [ + "overflowed time sum", + "cycleMeasurements", + [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1.0049875621120889e308, + "secondActiveSeconds": 1e308, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1.0049875621120889e308, + "secondActiveSeconds": 1e308, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1e308, + "secondActiveSeconds": 1.0049875621120889e308, + "firstToSecondRatio": 1.004987562112089 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1e308, + "secondActiveSeconds": 1.0049875621120889e308, + "firstToSecondRatio": 1.004987562112089 + } + ], + "totals must remain finite and exact", + false, + "unsupported structured data" + ], + [ + "unbalanced cycles with consistent ratios and totals", + "cycleMeasurements", + [ + { + "firstOperations": 80000, + "secondOperations": 40000, + "firstActiveSeconds": 2.009975124224178, + "secondActiveSeconds": 1, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1.004987562112089, + "secondActiveSeconds": 1, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 80000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 2.009975124224178, + "firstToSecondRatio": 1.004987562112089 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1.004987562112089, + "firstToSecondRatio": 1.004987562112089 + } + ], + "balanced ABBA/BAAB" + ], + [ + "short balanced cycles with consistent ratios and totals", + "cycleMeasurements", + [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.6281172263200555, + "secondActiveSeconds": 0.6249999999999999, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.6281172263200555, + "secondActiveSeconds": 0.6249999999999999, + "firstToSecondRatio": 0.9950371902099893 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.6249999999999999, + "secondActiveSeconds": 0.6281172263200555, + "firstToSecondRatio": 1.004987562112089 + }, + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.6249999999999999, + "secondActiveSeconds": 0.6281172263200555, + "firstToSecondRatio": 1.004987562112089 + } + ], + "minimum active time" + ] + ], + "pairedSealOptions": { + "experimentId": "paired-screen-test", + "artifactClass": "paired-throughput-screen", + "reducer": "paired-throughput-screen-v1" + }, + "commonModeRegression": { + "factors": { + "Filtered": 1.08, + "FilteredPostProcess": 0.998, + "GlobalToOne": 1.029, + "GlobalToMany": 1.029, + "KeyedToOne": 1.029, + "PostProcess": 1.029, + "StructNoBox": 1.029 + }, + "spreads": { + "first": {}, + "center": {}, + "last": {} + } + }, + "indexedRows": [ + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "TargetedFlood_OneListener", + "platform": "Unity 2021.3.45f1 EditMode", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "2000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + } + ], + "performanceSummaryRows": [ + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "37500000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "30000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_DxMessaging_SubUnsub", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "2000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "90000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + } + ], + "twoScopeRows": [ + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "37500000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "BroadcastFlood_OneHandler", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "18000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "4096" + }, + { + "scenario": "BroadcastFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "9000000.000", + "gcAllocations": "1234", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "98304" + } + ], + "comparisonScopeRows": [ + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "28000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "68000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "40000000.000", + "gcAllocations": "110806", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "20000" + } + ], + "comparisonBytesCases": [ + [ + [ + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "28000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "4096" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "68000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "8192" + }, + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "40000000.000", + "gcAllocations": "110806", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + } + ], + "GC allocations per 10k ops (PlayMode (Mono))", + "110,806", + "GC allocated bytes per 10k ops (Standalone (IL2CPP))" + ], + [ + [ + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "28000000.000", + "gcAllocations": "111", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "68000000.000", + "gcAllocations": "222", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + }, + { + "scenario": "Comparison_DxMessaging_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "4096" + }, + { + "scenario": "Comparison_MessagePipe_GlobalToOne", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "40000000.000", + "gcAllocations": "-1", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "8192" + } + ], + "GC allocations per 10k ops (Standalone (IL2CPP))", + "222", + "GC allocated bytes per 10k ops (PlayMode (Mono))" + ] + ], + "dispatchBytesRows": [ + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "BroadcastFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "9000000.000", + "gcAllocations": "1234", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "98304" + } + ], + "partialDispatchBytesRows": [ + { + "scenario": "UntargetedFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "20000000.000", + "gcAllocations": "0", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "8192" + }, + { + "scenario": "BroadcastFlood_OneHandler", + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "emitsPerSecond": "9000000.000", + "gcAllocations": "42", + "wallClockMs": "5000.000", + "gcAllocatedBytes": "-1" + } + ], + "regressionCases": [ + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "500000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + true + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "900000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + false + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "128", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + true + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + false + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "1", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + true + ], + [ + { + "scenario": "MessageBusConstruction_1000", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "-1", + "wallClockMs": "10.400", + "gcAllocatedBytes": "0" + }, + { + "scenario": "MessageBusConstruction_1000", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "0.000", + "gcAllocations": "-1", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + false + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "-1", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "0", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + false + ], + [ + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "5", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + { + "scenario": "x", + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0", + "emitsPerSecond": "1000000.000", + "gcAllocations": "-1", + "wallClockMs": "10.000", + "gcAllocatedBytes": "0" + }, + false + ] + ], + "validRawRows": [ + [ + "minimum time and different cycle workloads", + { + "scenario": "GlobalToOne", + "firstToSecondRatio": 1, + "aggregateRateRatio": 1, + "cycleRatioSpreadPercent": 0, + "withinMaterialityBand": true, + "cycleRatios": [1, 1, 1, 1], + "cycleMeasurements": [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 80000, + "secondOperations": 80000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 120000, + "secondOperations": 120000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 160000, + "secondOperations": 160000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1 + } + ] + }, + "accepted" + ], + [ + "summed rates differ from geometric headline", + { + "scenario": "GlobalToOne", + "firstToSecondRatio": 1.4142135623730951, + "aggregateRateRatio": 1.5, + "cycleRatioSpreadPercent": 100, + "withinMaterialityBand": false, + "cycleRatios": [1, 1, 2, 2], + "cycleMeasurements": [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 80000, + "secondOperations": 80000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 1, + "firstToSecondRatio": 1 + }, + { + "firstOperations": 120000, + "secondOperations": 120000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 2, + "firstToSecondRatio": 2 + }, + { + "firstOperations": 160000, + "secondOperations": 160000, + "firstActiveSeconds": 1, + "secondActiveSeconds": 2, + "firstToSecondRatio": 2 + } + ] + }, + "uninterpretable" + ], + [ + "ratios within numeric tolerance", + { + "scenario": "GlobalToOne", + "firstToSecondRatio": 1, + "aggregateRateRatio": 1.0000000000005, + "cycleRatioSpreadPercent": 0, + "withinMaterialityBand": true, + "cycleRatios": [1, 1, 1, 1], + "cycleMeasurements": [ + { + "firstOperations": 40000, + "secondOperations": 40000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1.0000000000005 + }, + { + "firstOperations": 80000, + "secondOperations": 80000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1.0000000000005 + }, + { + "firstOperations": 120000, + "secondOperations": 120000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1.0000000000005 + }, + { + "firstOperations": 160000, + "secondOperations": 160000, + "firstActiveSeconds": 0.625, + "secondActiveSeconds": 0.625, + "firstToSecondRatio": 1.0000000000005 + } + ] + }, + "accepted" + ] + ], + "pairedDecisionCases": [ + ["accepted", {}], + [ + "rejected", + { + "factors": { + "Filtered": 1 + } + } + ], + [ + "uninterpretable", + { + "spreads": { + "first": { + "Filtered": 5 + } + } + } + ] + ], + "monoRowDefaults": { + "platform": "Unity 6000.3.16f1 Linux PlayMode Mono", + "commit": "abc1234", + "runIndex": "0" + }, + "standaloneRowDefaults": { + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1", + "wallClockMs": "5000.000" + }, + "playModeRowDefaults": { + "platform": "Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", + "commit": "abc1234", + "runIndex": "-1" + }, + "summaryDefaults": { + "schemaVersion": 2, + "platform": "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.5.2f1)" + }, + "defaultFactors": { + "GlobalToOne": 1, + "GlobalToMany": 1, + "KeyedToOne": 1, + "Filtered": 1.06, + "PostProcess": 1, + "FilteredPostProcess": 0.99, + "StructNoBox": 1 + }, + "scopeCases": [ + ["Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.3.16f1)", "Standalone"], + ["Editor PlayMode Mono x64 Release (WindowsEditor; Unity 6000.3.16f1)", "PlayMode"], + ["Unity 6000.3.16f1 EditMode Mono", "EditMode"], + ["Unity 6000.3.16f1 Linux", null], + ["Standalone PlayMode mix", "Standalone"] + ], + "invalidCandidatePaths": [ + ["wrong case", "Runtime/Core/MessageBus/messageBus.cs"], + ["untracked", "Runtime/UntrackedCandidate.cs"] + ] } diff --git a/scripts/__tests__/reduce-paired-bracket.test.js b/scripts/__tests__/reduce-paired-bracket.test.js index c0ca6cf34..0fe17404a 100644 --- a/scripts/__tests__/reduce-paired-bracket.test.js +++ b/scripts/__tests__/reduce-paired-bracket.test.js @@ -34,9 +34,7 @@ test("manifest-only validation has an explicit CLI shape", () => { const TARGET = "Filtered"; const AFFECTED = "FilteredPostProcess"; const DEFAULT_ROWS = PERF_TEST_VECTORS.defaultRows; -const DEFAULT_FACTORS = Object.fromEntries(DEFAULT_ROWS.map((row) => [row.scenario, 1])); -DEFAULT_FACTORS[TARGET] = 1.06; -DEFAULT_FACTORS[AFFECTED] = 0.99; +const DEFAULT_FACTORS = PERF_TEST_VECTORS.defaultFactors; const COMMITS = PERF_TEST_VECTORS.commits; const OUTER_TREE = "a".repeat(40); const CENTER_TREE = "b".repeat(40); @@ -72,8 +70,7 @@ function makeSummary( } = {} ) { return { - schemaVersion: 2, - platform: "Standalone IL2CPP x64 Release (WindowsPlayer; Unity 6000.5.2f1)", + ...PERF_TEST_VECTORS.summaryDefaults, commit, sourceTree, candidateSourceSha256, @@ -83,13 +80,24 @@ function makeSummary( rows: manifest.rows.map((row) => { const headline = ratios[row.scenario]; const spread = spreads[row.scenario] ?? 1; + const cycleRatios = makeCycleRatios(headline, spread); + const cycleMeasurements = cycleRatios.map((ratio) => ({ + firstOperations: 40000, + secondOperations: 40000, + firstActiveSeconds: Math.max(1, 1 / ratio), + secondActiveSeconds: Math.max(1, ratio), + firstToSecondRatio: ratio + })); return { scenario: row.scenario, firstToSecondRatio: headline, - aggregateRateRatio: headline, + aggregateRateRatio: + cycleMeasurements.reduce((sum, cycle) => sum + cycle.secondActiveSeconds, 0) / + cycleMeasurements.reduce((sum, cycle) => sum + cycle.firstActiveSeconds, 0), cycleRatioSpreadPercent: spread, withinMaterialityBand: spread <= 3, - cycleRatios: makeCycleRatios(headline, spread) + cycleRatios, + cycleMeasurements }; }) }; @@ -195,11 +203,7 @@ test("paired bundle reducer requires all positions and validates retained raw cy assert.throws(() => reducePairedThroughputScreen(swapped), /outer|tree|source/i); }); -for (const [status, options] of [ - ["accepted", {}], - ["rejected", { factors: { [TARGET]: 1 } }], - ["uninterpretable", { spreads: { first: { [TARGET]: 5 } } }] -]) { +for (const [status, options] of PERF_TEST_VECTORS.pairedDecisionCases) { test(`sealed paired screens replay ${status} decisions from retained cycles`, (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "paired-evidence-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); @@ -212,9 +216,7 @@ for (const [status, options] of [ ); } const manifest = sealBundle(root, { - experimentId: "paired-screen-test", - artifactClass: "paired-throughput-screen", - reducer: "paired-throughput-screen-v1", + ...PERF_TEST_VECTORS.pairedSealOptions, sourceCommit: COMMITS[0] }); assert.equal(manifest.normalized.status, status); @@ -235,6 +237,49 @@ for (const [status, options] of [ }); } +// 2026-10-07: replay must reject raw work/time that the producer would refuse (#618). +for (const [ + index, + [name, field, value, pattern, remove, sealPattern] +] of PERF_TEST_VECTORS.invalidRawCycles.entries()) { + test(`paired reducer and sealing reject ${name}`, (t) => { + const bracket = makeBracket(); + mutateField( + bracket.summaries[index % 3].rows[0], + field, + remove ? undefined : value?.number ? Number(value.number) : value + ); + assert.throws( + () => reducePairedBracket(bracket.manifestBytes, bracket.summaries), + new RegExp(pattern) + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paired-invalid-cycles-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, "bracket-manifest.json"), bracket.manifestBytes); + for (const [position, summary] of bracket.summaries.entries()) + fs.writeFileSync( + path.join(root, `${PERF_TEST_VECTORS.positions[position]}.json`), + JSON.stringify(summary) + ); + assert.throws( + () => + sealBundle(root, { + ...PERF_TEST_VECTORS.pairedSealOptions, + sourceCommit: COMMITS[0] + }), + new RegExp(sealPattern ?? pattern) + ); + }); +} + +for (const [name, row, status] of PERF_TEST_VECTORS.validRawRows) { + test(`paired replay accepts valid raw evidence: ${name}`, () => { + const bracket = makeBracket(); + bracket.summaries[0].rows[0] = structuredClone(row); + assert.equal(reducePairedBracket(bracket.manifestBytes, bracket.summaries).status, status); + }); +} + function writeBracket(directory, bracket) { const manifestPath = path.join(directory, "manifest.json"); fs.writeFileSync(manifestPath, bracket.manifestBytes); @@ -346,18 +391,7 @@ test("a stable bracket rejects an affected-row regression beyond three percent", }); test("affected regressions are normalized against the same common-mode sentinel shift", () => { - const bracket = makeBracket({ - factors: { - [TARGET]: 1.08, - [AFFECTED]: 0.998, - GlobalToOne: 1.029, - GlobalToMany: 1.029, - KeyedToOne: 1.029, - PostProcess: 1.029, - StructNoBox: 1.029 - }, - spreads: { first: {}, center: {}, last: {} } - }); + const bracket = makeBracket(PERF_TEST_VECTORS.commonModeRegression); const result = reducePairedBracket(bracket.manifestBytes, bracket.summaries); const affected = result.rows.find((row) => row.scenario === AFFECTED); assert.equal(result.status, "rejected"); @@ -427,10 +461,7 @@ test("manifest validation rejects incomplete, reordered, unknown, and unsupporte }); test("manifest preflight rejects wrong-case and untracked candidate paths", async (t) => { - const cases = [ - ["wrong case", "Runtime/Core/MessageBus/messageBus.cs"], - ["untracked", "Runtime/UntrackedCandidate.cs"] - ]; + const cases = PERF_TEST_VECTORS.invalidCandidatePaths; for (const [name, candidatePath] of cases) { await t.test(name, () => { const manifest = { ...makeManifest(), candidatePaths: [candidatePath] }; @@ -518,11 +549,19 @@ test("extreme finite inputs cannot overflow or underflow into an accepted verdic await t.test("bracket factor overflow", () => { const bracket = makeBracket(); - for (const [index, value] of [Number.MAX_VALUE, Number.MIN_VALUE, Number.MAX_VALUE].entries()) { + for (const [index, value] of [1e200, 1e-200, 1e200].entries()) { const row = bracket.summaries[index].rows.find((candidate) => candidate.scenario === TARGET); row.firstToSecondRatio = value; row.cycleRatioSpreadPercent = 0; row.cycleRatios = [value, value, value, value]; + row.aggregateRateRatio = value; + row.cycleMeasurements = row.cycleRatios.map((ratio) => ({ + firstOperations: 40000, + secondOperations: 40000, + firstActiveSeconds: Math.max(1, 1 / ratio), + secondActiveSeconds: Math.max(1, ratio), + firstToSecondRatio: ratio + })); } assert.throws( () => reducePairedBracket(bracket.manifestBytes, bracket.summaries), diff --git a/scripts/__tests__/unity-perf.test.js b/scripts/__tests__/unity-perf.test.js index 456ce42fe..61cfae425 100644 --- a/scripts/__tests__/unity-perf.test.js +++ b/scripts/__tests__/unity-perf.test.js @@ -98,9 +98,7 @@ function row( ) { return { scenario, - platform: PLATFORM, - commit: "abc1234", - runIndex: "0", + ...PERF_TEST_VECTORS.monoRowDefaults, emitsPerSecond, gcAllocations, wallClockMs, @@ -110,12 +108,9 @@ function row( function comparisonRow(emitsPerSecond, gcAllocations = "0", gcAllocatedBytes = "0") { return { - platform: STANDALONE_PLATFORM, - commit: "abc1234", - runIndex: "-1", + ...PERF_TEST_VECTORS.standaloneRowDefaults, emitsPerSecond, gcAllocations, - wallClockMs: "5000.000", gcAllocatedBytes }; } @@ -123,12 +118,9 @@ function comparisonRow(emitsPerSecond, gcAllocations = "0", gcAllocatedBytes = " function dispatchRow(scenario, emitsPerSecond, gcAllocations = "0", gcAllocatedBytes = "0") { return { scenario, - platform: STANDALONE_PLATFORM, - commit: "abc1234", - runIndex: "-1", + ...PERF_TEST_VECTORS.standaloneRowDefaults, emitsPerSecond, gcAllocations, - wallClockMs: "5000.000", gcAllocatedBytes }; } @@ -142,9 +134,7 @@ function playModeRow( ) { return { scenario, - platform: EDITOR_PLAYMODE_PLATFORM, - commit: "abc1234", - runIndex: "-1", + ...PERF_TEST_VECTORS.playModeRowDefaults, emitsPerSecond, gcAllocations, wallClockMs, @@ -226,11 +216,7 @@ test("extract-perf-baseline CSV and args helpers cover round-trip and errors", ( }); test("indexDxMessagingRows filters by scope, platform substring, and technology", () => { - const rows = [ - row("UntargetedFlood_OneHandler", "1000000.000"), - { ...row("TargetedFlood_OneListener", "2000000.000"), platform: "Unity 2021.3.45f1 EditMode" }, - row("Comparison_MessagePipe_GlobalToOne", "1.000") - ]; + const rows = structuredClone(PERF_TEST_VECTORS.indexedRows); assert.deepEqual( [...indexDxMessagingRows(rows, "PlayMode", "Unity 6000.3.16f1").keys()], ["UntargetedFlood_OneHandler"] @@ -295,21 +281,7 @@ test("delta sign is normalized so + is always better and - is always worse", () }); test("isRegression trips on large throughput drops or allocation growth", () => { - const baseline = row("x", "1000000.000"); - const cases = [ - [row("x", "500000.000"), baseline, true], - [row("x", "900000.000"), baseline, false], - [row("x", "1000000.000", "128"), baseline, true], - [row("x", "0.000"), row("x", "0.000"), false], - [row("x", "0.000", "1"), row("x", "0.000", "0"), true], - [ - row("MessageBusConstruction_1000", "0.000", "-1", "10.400"), - row("MessageBusConstruction_1000", "0.000", "-1", "10.000"), - false - ], - [row("x", "1000000.000", "-1"), row("x", "1000000.000", "0"), false], - [row("x", "1000000.000", "5"), row("x", "1000000.000", "-1"), false] - ]; + const cases = structuredClone(PERF_TEST_VECTORS.regressionCases); for (const [current, base, expected] of cases) { assert.equal(isRegression(current, base, 0.33), expected); } @@ -373,20 +345,12 @@ test("byte reporting mirrors allocs: real byte delta is goodness-signed, unmeasu }); test("dispatch table renders a GC bytes column with real values and a per-row n/a", () => { - const scenario = "UntargetedFlood_OneHandler"; - const broadcast = "BroadcastFlood_OneHandler"; - const rows = [ - playModeRow(scenario, "20000000.000", "0", "5000.000", "0"), - playModeRow(broadcast, "9000000.000", "1234", "5000.000", "98304") - ]; + const rows = structuredClone(PERF_TEST_VECTORS.dispatchBytesRows); const block = buildBlock(selectRowsForVersion(rows, "Unity 6000.3.16f1"), "6000.3.16f1"); assert.ok(block.includes("GC bytes"), block); assert.ok(block.includes("98,304"), block); - const partialRows = [ - playModeRow(scenario, "20000000.000", "0", "5000.000", "8192"), - { ...playModeRow(broadcast, "9000000.000", "42"), gcAllocatedBytes: "-1" } - ]; + const partialRows = structuredClone(PERF_TEST_VECTORS.partialDispatchBytesRows); const partialBlock = buildBlock( selectRowsForVersion(partialRows, "Unity 6000.3.16f1"), "6000.3.16f1" @@ -535,12 +499,7 @@ test("buildComparisonSections bolds a sole present tech and display-precision ti }); test("generated performance block summarizes comparisons before internal dispatch", () => { - const rows = [ - dispatchRow("UntargetedFlood_OneHandler", "37500000.000"), - dispatchRow("Comparison_DxMessaging_GlobalToOne", "30000000.000", "-1", "-1"), - dispatchRow("Comparison_DxMessaging_SubUnsub", "2000000.000", "-1", "-1"), - dispatchRow("Comparison_MessagePipe_GlobalToOne", "90000000.000", "-1", "-1") - ]; + const rows = structuredClone(PERF_TEST_VECTORS.performanceSummaryRows); const block = buildBlock(selectRowsForVersion(rows, "Unity 6000.3.16f1"), "6000.3.16f1"); const summaryAt = block.indexOf("### DxMessaging comparison summary"); @@ -562,29 +521,16 @@ test("generated performance block summarizes comparisons before internal dispatc }); test("deriveScope reads the execution scope from platform strings", () => { - const cases = [ - [STANDALONE_PLATFORM, "Standalone"], - [EDITOR_PLAYMODE_PLATFORM, "PlayMode"], - ["Unity 6000.3.16f1 EditMode Mono", "EditMode"], - ["Unity 6000.3.16f1 Linux", null], - [undefined, null], - ["Standalone PlayMode mix", "Standalone"] - ]; + const cases = PERF_TEST_VECTORS.scopeCases; for (const [platform, expected] of cases) { assert.equal(deriveScope(platform), expected, `${platform}`); } + assert.equal(deriveScope(undefined), null, "undefined"); assert.equal(extractorDeriveScope, deriveScope); }); test("two scopes render separate dispatch tables; only the Mono leg shows real allocs", () => { - const scenario = "UntargetedFlood_OneHandler"; - const broadcast = "BroadcastFlood_OneHandler"; - const rows = [ - { ...dispatchRow(scenario, "37500000.000"), gcAllocations: "-1", gcAllocatedBytes: "-1" }, - { ...dispatchRow(broadcast, "18000000.000"), gcAllocations: "-1", gcAllocatedBytes: "-1" }, - playModeRow(scenario, "20000000.000", "0", "5000.000", "4096"), - playModeRow(broadcast, "9000000.000", "1234", "5000.000", "98304") - ]; + const rows = structuredClone(PERF_TEST_VECTORS.twoScopeRows); const block = buildBlock(selectRowsForVersion(rows, "Unity 6000.3.16f1"), "6000.3.16f1"); const standaloneHeading = "### Dispatch throughput - Standalone (IL2CPP)"; @@ -615,12 +561,7 @@ test("two scopes render separate dispatch tables; only the Mono leg shows real a }); test("comparison throughput stays on Standalone while GC count+bytes come from the Mono leg", () => { - const rows = [ - dispatchRow("Comparison_DxMessaging_GlobalToOne", "28000000.000", "-1", "-1"), - dispatchRow("Comparison_MessagePipe_GlobalToOne", "68000000.000", "-1", "-1"), - playModeRow("Comparison_DxMessaging_GlobalToOne", "20000000.000", "0", "5000.000", "0"), - playModeRow("Comparison_MessagePipe_GlobalToOne", "40000000.000", "110806", "5000.000", "20000") - ]; + const rows = structuredClone(PERF_TEST_VECTORS.comparisonScopeRows); const sections = comparisonSectionsFor(rows); assert.equal(sections.length, 4); const [, throughput, allocations, bytes] = sections; @@ -640,36 +581,7 @@ test("comparison throughput stays on Standalone while GC count+bytes come from t }); test("comparison bytes choose their own measured scope", () => { - const cases = [ - [ - [ - dispatchRow("Comparison_DxMessaging_GlobalToOne", "28000000.000", "-1", "4096"), - dispatchRow("Comparison_MessagePipe_GlobalToOne", "68000000.000", "-1", "8192"), - playModeRow("Comparison_DxMessaging_GlobalToOne", "20000000.000", "0", "5000.000", "-1"), - playModeRow( - "Comparison_MessagePipe_GlobalToOne", - "40000000.000", - "110806", - "5000.000", - "-1" - ) - ], - "GC allocations per 10k ops (PlayMode (Mono))", - "110,806", - "GC allocated bytes per 10k ops (Standalone (IL2CPP))" - ], - [ - [ - dispatchRow("Comparison_DxMessaging_GlobalToOne", "28000000.000", "111", "-1"), - dispatchRow("Comparison_MessagePipe_GlobalToOne", "68000000.000", "222", "-1"), - playModeRow("Comparison_DxMessaging_GlobalToOne", "20000000.000", "-1", "5000.000", "4096"), - playModeRow("Comparison_MessagePipe_GlobalToOne", "40000000.000", "-1", "5000.000", "8192") - ], - "GC allocations per 10k ops (Standalone (IL2CPP))", - "222", - "GC allocated bytes per 10k ops (PlayMode (Mono))" - ] - ]; + const cases = structuredClone(PERF_TEST_VECTORS.comparisonBytesCases); for (const [rows, allocHeading, allocValue, bytesHeading] of cases) { const [, , allocations, bytes] = comparisonSectionsFor(rows); assert.ok(allocations.includes(allocHeading) && allocations.includes(allocValue), allocations); diff --git a/scripts/unity/paired-replay-contract.json b/scripts/unity/paired-replay-contract.json new file mode 100644 index 000000000..c05a145cd --- /dev/null +++ b/scripts/unity/paired-replay-contract.json @@ -0,0 +1,29 @@ +{ + "manifestFields": [ + "schemaVersion", + "bracketId", + "orientation", + "materialityBandPercent", + "candidatePaths", + "rows" + ], + "executionProfileFields": [ + "id", + "cpuModel", + "source", + "selectionPolicy", + "selectedEfficiencyClass", + "selectedLogicalProcessorIndices", + "affinityMask", + "priorityClass" + ], + "cliDefaults": { + "manifest": "", + "first": "", + "center": "", + "last": "", + "output": "", + "validateManifest": false, + "help": false + } +} diff --git a/scripts/unity/paired-replay-contract.json.meta b/scripts/unity/paired-replay-contract.json.meta new file mode 100644 index 000000000..8c7a52c3a --- /dev/null +++ b/scripts/unity/paired-replay-contract.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: efcc1576add54c7a8b1fdc1ded0f98fd +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/reduce-paired-bracket.js b/scripts/unity/reduce-paired-bracket.js index 309820d74..28ef686aa 100644 --- a/scripts/unity/reduce-paired-bracket.js +++ b/scripts/unity/reduce-paired-bracket.js @@ -4,6 +4,7 @@ const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); +const PAIRED_CONTRACT = require("./paired-replay-contract.json"); const supportedScenarios = require("./comparison-supported-scenarios.json"); const SCHEMA_VERSION = 1; @@ -39,15 +40,7 @@ Use --validate-manifest with --manifest to validate the declaration before run o } function parseArgs(argv) { - const options = { - manifest: "", - first: "", - center: "", - last: "", - output: "", - validateManifest: false, - help: false - }; + const options = { ...PAIRED_CONTRACT.cliDefaults }; for (let index = 2; index < argv.length; index++) { const argument = argv[index]; if (argument === "--help" || argument === "-h") { @@ -116,18 +109,7 @@ function validateManifest(manifest, { requireTrackedCandidatePaths = false } = { if (!isObject(manifest)) { throw new Error("The bracket manifest must be an object."); } - requireExactKeys( - manifest, - [ - "schemaVersion", - "bracketId", - "orientation", - "materialityBandPercent", - "candidatePaths", - "rows" - ], - "The bracket manifest" - ); + requireExactKeys(manifest, PAIRED_CONTRACT.manifestFields, "The bracket manifest"); if (manifest.schemaVersion !== SCHEMA_VERSION) { throw new Error(`The bracket manifest schemaVersion must be ${SCHEMA_VERSION}.`); } @@ -251,6 +233,81 @@ function requireCycleEvidence(row, label, ratio, spread) { ) { throw new Error(`${label} spread does not match its cycleRatios.`); } + // SYNC: require-comparison-rows.ps1 paired cycleMeasurements validation block. + // JavaScript also requires safe integers so JSON counts and their sums remain exact. + if (!Array.isArray(row.cycleMeasurements) || row.cycleMeasurements.length !== CYCLES) { + throw new Error(`${label} cycleMeasurements must contain exactly ${CYCLES} values.`); + } + const totals = { + firstOperations: 0, + secondOperations: 0, + firstActiveSeconds: 0, + secondActiveSeconds: 0 + }; + for (const [index, cycle] of row.cycleMeasurements.entries()) { + const cycleLabel = `${label} cycleMeasurements[${index}]`; + if (!isObject(cycle)) throw new Error(`${cycleLabel} must be an object.`); + for (const arm of ["first", "second"]) { + const operations = cycle[`${arm}Operations`]; + if (!Number.isSafeInteger(operations) || operations <= 0) { + throw new Error(`${cycleLabel} ${arm}Operations must be a positive safe integer.`); + } + if (operations % (4 * BATCH_OPERATIONS) !== 0) { + throw new Error(`${cycleLabel} must retain balanced ABBA/BAAB operation counts.`); + } + const seconds = requireFiniteNumber( + cycle[`${arm}ActiveSeconds`], + `${cycleLabel} ${arm}ActiveSeconds` + ); + if (seconds < MINIMUM_CYCLE_ACTIVE_MILLISECONDS / 1000) { + throw new Error(`${cycleLabel} did not reach the minimum active time.`); + } + totals[`${arm}Operations`] += operations; + totals[`${arm}ActiveSeconds`] += seconds; + } + if (cycle.firstOperations !== cycle.secondOperations) { + throw new Error(`${cycleLabel} must retain balanced ABBA/BAAB operation counts.`); + } + const retained = requireFiniteNumber( + cycle.firstToSecondRatio, + `${cycleLabel} firstToSecondRatio` + ); + if (retained <= 0) throw new Error(`${cycleLabel} retained ratio must be positive.`); + const recomputed = + cycle.firstOperations / + cycle.firstActiveSeconds / + (cycle.secondOperations / cycle.secondActiveSeconds); + for (const value of [retained, cycleRatios[index]]) { + if ( + !Number.isFinite(recomputed) || + recomputed <= 0 || + Math.abs(value - recomputed) / recomputed > RELATIVE_TOLERANCE + ) { + throw new Error(`${cycleLabel} ratio does not match its work and active time.`); + } + } + } + if ( + !Number.isSafeInteger(totals.firstOperations) || + !Number.isSafeInteger(totals.secondOperations) || + !Number.isFinite(totals.firstActiveSeconds) || + !Number.isFinite(totals.secondActiveSeconds) + ) { + throw new Error(`${label} retained cycle totals must remain finite and exact.`); + } + const aggregate = requireFiniteNumber(row.aggregateRateRatio, `${label} aggregateRateRatio`); + if (aggregate <= 0) throw new Error(`${label} aggregate ratio must be positive.`); + const recomputedAggregate = + totals.firstOperations / + totals.firstActiveSeconds / + (totals.secondOperations / totals.secondActiveSeconds); + if ( + !Number.isFinite(recomputedAggregate) || + recomputedAggregate <= 0 || + Math.abs(aggregate - recomputedAggregate) / recomputedAggregate > RELATIVE_TOLERANCE + ) { + throw new Error(`${label} aggregate ratio does not match its retained cycles.`); + } } function validateSummary(summary, label, manifest, expectedDigest) { @@ -290,16 +347,7 @@ function validateSummary(summary, label, manifest, expectedDigest) { } requireExactKeys( summary.executionProfile, - [ - "id", - "cpuModel", - "source", - "selectionPolicy", - "selectedEfficiencyClass", - "selectedLogicalProcessorIndices", - "affinityMask", - "priorityClass" - ], + PAIRED_CONTRACT.executionProfileFields, `${label} summary execution profile` ); if ( diff --git a/scripts/unity/require-comparison-rows.ps1 b/scripts/unity/require-comparison-rows.ps1 index cbbe3b2ae..87dc36bd5 100644 --- a/scripts/unity/require-comparison-rows.ps1 +++ b/scripts/unity/require-comparison-rows.ps1 @@ -512,6 +512,7 @@ try { throw "Paired comparison '$scenario' contains a non-positive or non-finite cycle ratio." } + # SYNC: reduce-paired-bracket.js requireCycleEvidence raw cycle validation. $cycleMeasurements = @($record.cycleMeasurements) if ($cycleMeasurements.Count -ne $pairedCycles) { throw "Paired comparison '$scenario' must retain exactly $pairedCycles cycle measurements." From f745b6c3366612640a5775d65164f36f9f111b18 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 22:52:00 +0000 Subject: [PATCH 009/147] Update tooling dependencies with published security patches --- package-lock.json | 140 +++++++++++++++++++++++++++------------------- package.json | 6 +- 2 files changed, 84 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index f8d95e910..fa9368462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "3.3.0", "license": "MIT", "devDependencies": { - "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/sdk": "1.32.1", "@xmldom/xmldom": "0.9.12", "ajv": "8.20.0", "cspell": "10.2.2", "jsonc-parser": "3.3.1", - "markdownlint-cli2": "0.23.2", + "markdownlint-cli2": "0.23.3", "prettier": "3.9.6", - "smol-toml": "1.8.0", + "smol-toml": "1.9.0", "yaml": "^2.9.0" } }, @@ -663,9 +663,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "dev": true, "license": "MIT", "dependencies": { @@ -1423,13 +1423,13 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1644,9 +1644,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -1661,9 +1661,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -1852,9 +1852,9 @@ } }, "node_modules/globby": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz", - "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", + "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", "dev": true, "license": "MIT", "dependencies": { @@ -1862,6 +1862,7 @@ "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", + "micromatch": "^4.0.8", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" }, @@ -1960,9 +1961,9 @@ } }, "node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2011,9 +2012,9 @@ } }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "dev": true, "license": "MIT", "engines": { @@ -2162,9 +2163,9 @@ } }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "dev": true, "funding": [ { @@ -2243,9 +2244,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", "dev": true, "funding": [ { @@ -2259,13 +2260,13 @@ ], "license": "MIT", "dependencies": { - "uc.micro": "^2.0.0" + "uc.micro": "^3.0.0" } }, "node_modules/markdown-it": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", - "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz", + "integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==", "dev": true, "funding": [ { @@ -2279,17 +2280,34 @@ ], "license": "MIT", "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.5.0", - "linkify-it": "^5.0.2", - "mdurl": "^2.0.0", + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" + "uc.micro": "^3.0.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.2.tgz", + "integrity": "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, "node_modules/markdownlint": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.1.tgz", @@ -2315,21 +2333,21 @@ } }, "node_modules/markdownlint-cli2": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.2.tgz", - "integrity": "sha512-eUhcnkSpzURo/o4htSqc7LPDszgOOTknhU4eY/sPHvMCLxnTCYscv1gw1/js/idmaZPisv9ECVEIORcllqjTUw==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.3.tgz", + "integrity": "sha512-xAr5o/TGpC3v6lE6cKIW4b5eOFRrRX5u7Vtjae9ix3RALv8nNOd94XMkD/1OXXBtpMcJ4uQGbpSo3hv5UqS4uQ==", "dev": true, "license": "MIT", "dependencies": { - "globby": "16.2.2", - "js-yaml": "5.2.2", + "globby": "16.2.4", + "js-yaml": "5.4.1", "jsonc-parser": "3.3.1", "jsonpointer": "5.0.1", - "markdown-it": "14.3.0", + "markdown-it": "15.0.1", "markdownlint": "0.41.1", "markdownlint-cli2-formatter-default": "0.0.6", "micromatch": "4.0.8", - "smol-toml": "1.7.0" + "smol-toml": "1.8.0" }, "bin": { "markdownlint-cli2": "markdownlint-cli2-bin.mjs" @@ -3152,9 +3170,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3163,6 +3181,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode.js": { @@ -3502,9 +3524,9 @@ } }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3631,16 +3653,16 @@ } }, "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", - "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", + "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index df02b5108..908bff02a 100644 --- a/package.json +++ b/package.json @@ -82,14 +82,14 @@ "validate:all": "npm run validate:asmdef-references && npm run validate:unity-versions && npm run validate:unity-pr-policy && npm run validate:js-loc-budget && npm run check:banner && npm run validate:npm-meta && npm run check:analyzers && npm run check:llms-txt && npm run llm:check && npm run check:issue-template-versions && npm run check:upm-changelog" }, "devDependencies": { - "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/sdk": "1.32.1", "@xmldom/xmldom": "0.9.12", "ajv": "8.20.0", "cspell": "10.2.2", "jsonc-parser": "3.3.1", - "markdownlint-cli2": "0.23.2", + "markdownlint-cli2": "0.23.3", "prettier": "3.9.6", - "smol-toml": "1.8.0", + "smol-toml": "1.9.0", "yaml": "^2.9.0" }, "overrides": { From 30cf18c2d16feef96cfbed5dcc3567361da0a422 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 23:11:51 +0000 Subject: [PATCH 010/147] Remove the unpatched Markdown lint dependency --- .cspell.json | 1 + .github/workflows/ci.yml | 2 +- .github/workflows/format-on-demand.yml | 8 +- .github/workflows/perf-numbers.yml | 2 +- .github/workflows/prettier-autofix.yml | 2 +- .llm/context.md | 6 +- .llm/skills/documentation-style/SKILL.md | 2 +- .../documentation-update-workflow.md | 4 +- .../github-actions-version-consistency.md | 2 +- .markdownlint-cli2.jsonc | 28 - .markdownlintignore | 28 +- .npmignore | 1 - .pre-commit-config.yaml | 4 +- .vale/styles/Vocab/DxMessaging/accept.txt | 4 +- CONTRIBUTING.md | 4 +- package-lock.json | 587 +++++------------- package.json | 12 +- .../__tests__/ci-aggregate-workflow.test.js | 4 +- 18 files changed, 215 insertions(+), 486 deletions(-) delete mode 100644 .markdownlint-cli2.jsonc diff --git a/.cspell.json b/.cspell.json index 7868091fa..6f89b730b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -196,6 +196,7 @@ "isinst", "ispc", "jlumbroso", + "KaTeX", "KHTML", "kubepods", "kwds", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8234c3b..e3aa5b844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: set_output llms \ "(^package\.json$|^\.llm/|^scripts/update-llms-txt\.js$|^scripts/llm/|^llms\.txt$|$ci_workflow)" set_output markdown \ - "(\.md$|\.markdown$|^\.markdownlint\.json$|^\.markdownlint\.jsonc$|^\.markdownlint-cli2\.jsonc$|^\.markdownlintignore$|^package\.json$|$ci_workflow)" + "(\.md$|\.markdown$|^\.markdownlint\.json$|^\.markdownlint\.jsonc$|^\.markdownlintignore$|^package\.json$|$ci_workflow)" scripts_pattern='(^\.github/workflows/[^/]*\.ya?ml$' scripts_pattern="$scripts_pattern"'|^\.github/actions/.*\.ya?ml$' diff --git a/.github/workflows/format-on-demand.yml b/.github/workflows/format-on-demand.yml index 1f955cba3..db168f59b 100644 --- a/.github/workflows/format-on-demand.yml +++ b/.github/workflows/format-on-demand.yml @@ -70,7 +70,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '8.0.x' + dotnet-version: "8.0.x" - name: Restore .NET tools run: dotnet tool restore - name: Install dependencies @@ -90,7 +90,7 @@ jobs: npx prettier --write "**/*.{yml,yaml}" - name: Apply markdownlint fixes run: | - npx --no-install markdownlint-cli2 "**/*.md" "**/*.markdown" --fix + npx --no-install markdownlint --dot "**/*.md" "**/*.markdown" --fix - name: Apply CSharpier formatting # The trailing "." is required: without a path, "csharpier format" # reads stdin (empty in a run step) and formats nothing. @@ -194,7 +194,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '8.0.x' + dotnet-version: "8.0.x" - name: Restore .NET tools run: dotnet tool restore - name: Install dependencies @@ -214,7 +214,7 @@ jobs: npx prettier --write "**/*.{yml,yaml}" - name: Apply markdownlint fixes run: | - npx --no-install markdownlint-cli2 "**/*.md" "**/*.markdown" --fix + npx --no-install markdownlint --dot "**/*.md" "**/*.markdown" --fix - name: Apply CSharpier formatting # The trailing "." is required: without a path, "csharpier format" # reads stdin (empty in a run step) and formats nothing. diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index b3ee00ea9..bcce9fb61 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -1874,7 +1874,7 @@ jobs: run: | npx prettier --check "${PERF_DOC}" npx prettier --check "${PERF_BASELINE_PROFILE}" - npx --no-install markdownlint-cli2 "${PERF_DOC}" + npx --no-install markdownlint --dot "${PERF_DOC}" - name: Commit and push refreshed perf doc if: steps.default-branch.outputs.fresh == 'true' && steps.render.outputs.changed == 'true' diff --git a/.github/workflows/prettier-autofix.yml b/.github/workflows/prettier-autofix.yml index 188545ae0..253a1bcbc 100644 --- a/.github/workflows/prettier-autofix.yml +++ b/.github/workflows/prettier-autofix.yml @@ -75,7 +75,7 @@ jobs: - name: Markdownlint (auto-fix) if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} - run: npx --no-install markdownlint-cli2 "**/*.md" "**/*.markdown" --fix + run: npx --no-install markdownlint --dot "**/*.md" "**/*.markdown" --fix - name: Renormalize line endings if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} diff --git a/.llm/context.md b/.llm/context.md index 943032f47..9bcf95544 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -35,10 +35,10 @@ index to select and read the canonical instructions. ## Tooling Philosophy (read this before adding any script) -- JS tooling is intentionally minimal (a hard line budget is enforced by `scripts/validate-js-loc-budget.js` / `npm run validate:js-loc-budget`, which is the single source for the current ceiling and its changelog -- do not restate that number here or elsewhere); prefer off-the-shelf tools (prettier, cspell, markdownlint-cli2, csharpier, actionlint, lychee, yamllint, pre-commit built-ins); do not add bespoke validators, wrappers, preflight/doctor machinery, or custom git-hook plumbing. +- JS tooling is intentionally minimal (a hard line budget is enforced by `scripts/validate-js-loc-budget.js` / `npm run validate:js-loc-budget`, which is the single source for the current ceiling and its changelog -- do not restate that number here or elsewhere); prefer off-the-shelf tools (prettier, cspell, markdownlint-cli, csharpier, actionlint, lychee, yamllint, pre-commit built-ins); do not add bespoke validators, wrappers, preflight/doctor machinery, or custom git-hook plumbing. - Git hooks are managed solely by the standard pre-commit framework: `pipx install pre-commit` (or pip), then `pre-commit install`. Do not set `core.hooksPath`, write hooks into `.git/hooks` by hand, or wrap pre-commit in Node scripts. - Script tests use the built-in `node --test` runner (`npm test`); there is no jest. Pre-push runs only the fast script-test subset and excludes real subprocess/archive integration tests, so agents must run full `npm test` when changing `scripts/**/*.js` or GitHub composite action scripts. -- Run tools directly (`npx prettier`, `npx cspell`, `npx markdownlint-cli2`); never reintroduce "managed" runner wrappers. +- Run tools directly (`npx prettier`, `npx cspell`, `npx --no-install markdownlint --dot`); never reintroduce "managed" runner wrappers. - A script with both a fixer/generator mode and a `--check` mode must converge: running the fixer either makes `--check` pass or exits non-zero naming the file a human must fix. Share one validator between the modes, and either refuse to write an unfixable state or re-verify the post-write state; never report success while leaving a `--check`-failing state (the trap is a fixer that silently no-ops on input its own `--check` rejects). See `collectValidationErrors` (post-write re-verify) in `scripts/update-llms-txt.js` and the refuse-when-unfixable drift report in `scripts/llm/harness.js`. - When workflows, docs, scripts, or issue templates reference package-script aliases, update `package.json` in the same change; `scripts/__tests__/package-script-contract.test.js` guards the common reference surfaces and the `validate:all` issue-template-version gate. @@ -247,7 +247,7 @@ fallback. A failed `gh auth status` is not a blocker while either earlier path w README text, brand artwork, CI, and repository tooling never earn entries, even when the files ship in the package. See the changelog-management skill. - For `## [Unreleased]` entries, mutate existing bullets as behavior evolves; do not stack separate `Added` then `Fixed` bullets for the same unreleased change. -- For edited Markdown files, run `npx prettier --write` and `npx markdownlint-cli2` before finishing. +- For edited Markdown files, run `npx prettier --write` and `npx --no-install markdownlint --dot` before finishing. - Ordered lists must follow MD029 `one` style (`1.` for each item). - Internal fragment links must match GitHub/markdownlint heading slugs exactly (MD051). - Documentation and `///` XML doc comments must be pure ASCII; see [ASCII-Only Documentation Policy](./skills/documentation-style/references/ascii-only-docs.md). diff --git a/.llm/skills/documentation-style/SKILL.md b/.llm/skills/documentation-style/SKILL.md index 3bb45600d..317ff0a23 100644 --- a/.llm/skills/documentation-style/SKILL.md +++ b/.llm/skills/documentation-style/SKILL.md @@ -81,7 +81,7 @@ and every `///` XML doc comment in `Runtime/`, `Editor/`, and `SourceGenerators/ `[Obsolete]` plus migration notes. A perf change updates `docs/architecture/performance.md` and `CHANGELOG.md`. - Before commit, run `npx prettier --write ` then - `npx markdownlint-cli2 `. + `npx --no-install markdownlint --dot `. - Ordered lists use MD029 `one` style (every item prefixed `1.`). Internal fragment links must resolve (MD051). diff --git a/.llm/skills/documentation-style/references/documentation-update-workflow.md b/.llm/skills/documentation-style/references/documentation-update-workflow.md index 7e19f1380..d420fde55 100644 --- a/.llm/skills/documentation-style/references/documentation-update-workflow.md +++ b/.llm/skills/documentation-style/references/documentation-update-workflow.md @@ -22,7 +22,7 @@ Without a workflow, documentation updates are inconsistent, and important refere 1. **Update CHANGELOG**: Add entry under appropriate section 1. **Cross-reference**: Ensure links and "See Also" sections are current 1. **Format Markdown**: Run `npx prettier --write ` -1. **Lint Markdown before commit**: Run `npx markdownlint-cli2 ` +1. **Lint Markdown before commit**: Run `npx --no-install markdownlint --dot ` ### Example: Adding a New Emit Overload @@ -60,7 +60,7 @@ Documentation updates needed: - [ ] Ordered lists use MD029 `one` style (`1.` prefixes) - [ ] Internal fragment links resolve correctly (MD051) - [ ] `npx prettier --write` run on changed docs -- [ ] `npx markdownlint-cli2` passes for changed docs +- [ ] `npx --no-install markdownlint --dot` passes for changed docs ## Performance Notes diff --git a/.llm/skills/unity-editor-ci/references/github-actions-version-consistency.md b/.llm/skills/unity-editor-ci/references/github-actions-version-consistency.md index 1b8a4e074..6bfb8940e 100644 --- a/.llm/skills/unity-editor-ci/references/github-actions-version-consistency.md +++ b/.llm/skills/unity-editor-ci/references/github-actions-version-consistency.md @@ -113,7 +113,7 @@ Bad: `See [README.md](../README.md)` <- Inline code, should be skipped ### Do Not Write New Bespoke Documentation Linters -The repository deliberately uses off-the-shelf linters (markdownlint-cli2, prettier, cspell, lychee) instead of bespoke documentation-linter scripts. See the Tooling Philosophy section of `.llm/context.md` before adding any new linting script; the code-block-skipping guidance above exists only for the rare case where an existing kept script must be modified. +The repository deliberately uses off-the-shelf linters (markdownlint-cli, prettier, cspell, lychee) instead of bespoke documentation-linter scripts. See the Tooling Philosophy section of `.llm/context.md` before adding any new linting script; the code-block-skipping guidance above exists only for the rare case where an existing kept script must be modified. ## Validation Checklist diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc deleted file mode 100644 index cdab82a51..000000000 --- a/.markdownlint-cli2.jsonc +++ /dev/null @@ -1,28 +0,0 @@ -{ - // NOTE: no "fix" here -- `npm run lint:markdown` is check-only. Auto-fix is - // opt-in via `--fix` (the pre-commit hook and format-on-demand pass it). - "ignores": [ - // Exclude third-party and generated content - "**/node_modules/**", - "**/.git/**", - "**/Library/**", - "**/obj/**", - "**/Temp/**", - "**/Samples~/**", - "**/design-system*/**", - "**/.github/**", - "**/.venv/**", - "**/site/**", - "**/_site/**", - // Generated by scripts/llm/harness.js (`npm run llm:index`). markdownlint-cli2 reads ONLY this - // array (it does not support a .markdownlintignore file), so these entries are what keeps the - // generator and its --check mode convergent: an autofix bot rewriting a generated file would - // make `npm run llm:check` fail with no way to recover except regenerating. - ".llm/index.md", - // Git-ignored local scratch areas - "progress/**", - ".artifacts/**", - "*PLAN.md", - "pr-description.md*" - ] -} diff --git a/.markdownlintignore b/.markdownlintignore index f961454ba..58aa85b63 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -1,14 +1,18 @@ -node_modules/ -.git/ -Library/ -obj/ -Temp/ -Samples~/ -design-system*/ -/.github/ -/progress/ -/.artifacts/ -/site/ -/_site/ +# Keep third-party, generated, and local scratch files outside lint and fix passes. +# .llm/index.md is generated by npm run llm:index and must remain unchanged. +**/node_modules/** +**/.git/** +**/Library/** +**/obj/** +**/Temp/** +**/Samples~/** +**/design-system*/** +**/.github/** +**/.venv/** +**/site/** +**/_site/** +.llm/index.md +progress/** +.artifacts/** *PLAN.md pr-description.md* diff --git a/.npmignore b/.npmignore index 59f486e03..5a4a5b275 100644 --- a/.npmignore +++ b/.npmignore @@ -114,7 +114,6 @@ __pycache__.meta .prettierignore .markdownlint.json .markdownlint.jsonc -.markdownlint-cli2.jsonc .markdownlintignore .yamllint.yaml .cspell.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db2799f2b..256771aab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,8 +80,8 @@ repos: stages: - pre-commit - id: markdownlint - name: markdownlint-cli2 - entry: npx --no-install markdownlint-cli2 --fix + name: markdownlint + entry: npx --no-install markdownlint --dot --fix language: system files: '(?i)\.(md|markdown)$' stages: diff --git a/.vale/styles/Vocab/DxMessaging/accept.txt b/.vale/styles/Vocab/DxMessaging/accept.txt index 03a78bd00..71f315a03 100644 --- a/.vale/styles/Vocab/DxMessaging/accept.txt +++ b/.vale/styles/Vocab/DxMessaging/accept.txt @@ -25,7 +25,7 @@ csharpier markdownlint yamllint cspell -markdownlint-cli2 +markdownlint-cli preflight OpenUPM VContainer @@ -50,7 +50,7 @@ ScriptableObject ScriptableObjects GFM yaml -markdownlint-cli2 +markdownlint-cli runtime dxmessaging wallstop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbb4b1035..12172e702 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ The pre-commit `mixed-line-ending` hook keeps line endings honest at commit time What runs locally (via `pre-commit`, see `.pre-commit-config.yaml`): -- Markdown style and formatting: markdownlint-cli2 + Prettier +- Markdown style and formatting: markdownlint-cli + Prettier - JSON/.asmdef formatting: Prettier (2-space indent) - YAML formatting: Prettier (2-space indent) + yamllint - C# formatting and naming: CSharpier + the underscore-method auto-fixer @@ -52,7 +52,7 @@ On pull requests, CI checks markdown links with lychee in two passes. An offline Handy commands: -- Lint markdown: `npm run lint:markdown` (auto-fix: `npx markdownlint-cli2 --fix "**/*.md"`) +- Lint markdown: `npm run lint:markdown` (auto-fix: `npx --no-install markdownlint --dot --fix "**/*.md"`) - Format markdown/JSON/.asmdef/YAML: `npm run format` (check-only: `npm run format:check`) - Run the yamllint hook directly: `pre-commit run yamllint --all-files` - Format C#: `dotnet tool restore && dotnet tool run csharpier format .` (the trailing `.` is required; without a path, `csharpier format` reads stdin and formats nothing) diff --git a/package-lock.json b/package-lock.json index fa9368462..b183665d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "ajv": "8.20.0", "cspell": "10.2.2", "jsonc-parser": "3.3.1", - "markdownlint-cli2": "0.23.3", + "markdownlint-cli": "0.49.1", "prettier": "3.9.6", "smol-toml": "1.9.0", "yaml": "^2.9.0" @@ -703,57 +703,6 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -871,6 +820,16 @@ "dev": true, "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -910,17 +869,17 @@ "url": "https://opencollective.com/express" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/bytes": { @@ -1356,6 +1315,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1422,19 +1391,6 @@ "node": ">= 0.8" } }, - "node_modules/entities": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", @@ -1619,23 +1575,6 @@ "node": ">=6.0.0" } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1660,16 +1599,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1688,19 +1617,6 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1822,19 +1738,6 @@ "node": ">= 0.4" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/global-directory": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", @@ -1851,28 +1754,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", - "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.5", - "is-path-inside": "^4.0.0", - "micromatch": "^4.0.8", - "slash": "^5.1.0", - "unicorn-magic": "^0.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2068,29 +1949,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -2102,29 +1960,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2217,9 +2052,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz", + "integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==", "dev": true, "funding": [ "https://opencollective.com/katex", @@ -2243,71 +2078,6 @@ "node": ">= 12" } }, - "node_modules/linkify-it": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", - "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^3.0.0" - } - }, - "node_modules/markdown-it": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz", - "integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", - "mdurl": "^2.1.0", - "punycode.js": "^2.3.1", - "uc.micro": "^3.0.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.2.tgz", - "integrity": "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "PSF-2.0" - }, "node_modules/markdownlint": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.1.tgz", @@ -2332,46 +2102,101 @@ "url": "https://github.com/sponsors/DavidAnson" } }, - "node_modules/markdownlint-cli2": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.3.tgz", - "integrity": "sha512-xAr5o/TGpC3v6lE6cKIW4b5eOFRrRX5u7Vtjae9ix3RALv8nNOd94XMkD/1OXXBtpMcJ4uQGbpSo3hv5UqS4uQ==", + "node_modules/markdownlint-cli": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.49.1.tgz", + "integrity": "sha512-qpYqJbSYf3jv57bdnFmCaZ/Wlu6IYHp2b6SOKrKBJ7OnPrDHIKmx4NERWH49QH9viTI6yO6raVDDn5nrf60VQQ==", "dev": true, "license": "MIT", "dependencies": { - "globby": "16.2.4", - "js-yaml": "5.4.1", - "jsonc-parser": "3.3.1", - "jsonpointer": "5.0.1", - "markdown-it": "15.0.1", - "markdownlint": "0.41.1", - "markdownlint-cli2-formatter-default": "0.0.6", - "micromatch": "4.0.8", - "smol-toml": "1.8.0" + "commander": "~15.0.0", + "deep-extend": "~0.6.0", + "ignore": "~7.0.6", + "js-yaml": "~5.2.1", + "jsonc-parser": "~3.3.1", + "jsonpointer": "~5.0.1", + "markdown-it": "~14.3.0", + "markdownlint": "~0.41.1", + "minimatch": "~10.2.5", + "run-con": "~1.3.3", + "smol-toml": "~1.7.0", + "tinyglobby": "~0.2.17" }, "bin": { - "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + "markdownlint": "markdownlint.js" }, "engines": { "node": ">=22" + } + }, + "node_modules/markdownlint-cli/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/DavidAnson" + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/markdownlint-cli/node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" } }, - "node_modules/markdownlint-cli2-formatter-default": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", - "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "node_modules/markdownlint-cli/node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/DavidAnson" + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, - "peerDependencies": { - "markdownlint-cli2": ">=0.0.4" + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdownlint-cli/node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2416,16 +2241,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -2962,33 +2777,6 @@ ], "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -3016,6 +2804,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3214,27 +3028,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -3285,17 +3078,6 @@ "node": ">=8" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3313,28 +3095,30 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/run-con": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.3.tgz", + "integrity": "sha512-Lb7OKM9aaykzyoNiHGhSVCjZsvbyy6qDMp2vDXL+MoCfz3GfNJtHYH7uYsU3QNMyInBk++xx+EZ8xZ8Sxs5fNQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { - "queue-microtask": "^1.2.2" + "deep-extend": "^0.6.0", + "ini": "~7.0.0", + "minimist": "^1.2.8", + "strip-json-comments": "~3.1.1" + }, + "bin": { + "run-con": "cli.js" + } + }, + "node_modules/run-con/node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/safer-buffer": { @@ -3510,19 +3294,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/smol-toml": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", @@ -3579,6 +3350,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3596,19 +3380,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3652,26 +3423,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/uc.micro": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", - "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", - "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 908bff02a..7f9676e83 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "test": "node --test scripts/ && python3 scripts/__tests__/test_create_unitypackage.py", "format": "prettier --write \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", "format:check": "prettier --check \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", - "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", + "lint:markdown": "markdownlint --dot \"**/*.md\" \"**/*.markdown\"", "check:spelling": "cspell --no-progress --no-summary .", "sync:banner": "node scripts/sync-banner-version.js", "check:banner": "node scripts/sync-banner-version.js --check", @@ -87,15 +87,17 @@ "ajv": "8.20.0", "cspell": "10.2.2", "jsonc-parser": "3.3.1", - "markdownlint-cli2": "0.23.3", + "markdownlint-cli": "0.49.1", "prettier": "3.9.6", "smol-toml": "1.9.0", "yaml": "^2.9.0" }, "overrides": { - "markdownlint-cli2": { - "smol-toml": "$smol-toml" - } + "markdownlint-cli": { + "smol-toml": "$smol-toml", + "js-yaml": "5.4.1" + }, + "katex": "0.18.2" }, "samples": [ { diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index a486911a4..f0803a7b1 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -110,9 +110,9 @@ test("active workflows keep the shared safety contract", () => { const document = readWorkflowDocument(file); assert.equal(document.errors.length, 0, `${file} must parse as YAML`); // prettier-ignore - assert.doesNotMatch(source, /npx(?! --no-install markdownlint-cli2(?: |$))[^\n]*markdownlint-cli2|markdownlint-cli2@/, file); + assert.doesNotMatch(source, /npx(?! --no-install markdownlint --dot(?: |$))[^\n]*markdownlint|markdownlint@/, file); // prettier-ignore - assert.equal(source.match(/npm audit --audit-level=high/g)?.length || 0, source.match(/npx --no-install markdownlint-cli2/g)?.length || 0, file); + assert.equal(source.match(/npm audit --audit-level=high/g)?.length || 0, source.match(/npx --no-install markdownlint --dot/g)?.length || 0, file); const keys = document.contents.items.map((item) => String(item.key.value)); assert.deepEqual( From 24613d120048981df771ab1164b31b5e74a6d01e Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 23:30:53 +0000 Subject: [PATCH 011/147] Clarify centrally managed Unity admission and cleanup --- .llm/skills/unity-licensing/SKILL.md | 4 +++- .../references/unity-license-return-guarantee.md | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.llm/skills/unity-licensing/SKILL.md b/.llm/skills/unity-licensing/SKILL.md index cb31ef113..eafd4e550 100644 --- a/.llm/skills/unity-licensing/SKILL.md +++ b/.llm/skills/unity-licensing/SKILL.md @@ -40,7 +40,9 @@ Local Unity verification needs NO license. The devcontainer ships no Unity build ### Per-job flow -Validate the secrets with `./.github/actions/validate-unity-license` BEFORE acquiring the lock, so a misconfiguration fails with a clear diagnostic before Unity starts or blocks the shared seat. Then acquire the org build lock (`wallstop-organization-builds`, `max-parallel: 1`), return-at-start, activate, run Unity (editmode / playmode / standalone IL2CPP) against the generated project, return in the `finally`, and run the `if: always()` return step before releasing the lock. +Validate the secrets with `./.github/actions/validate-unity-license` BEFORE acquiring the lock, so a misconfiguration fails with a clear diagnostic before Unity starts or blocks the shared seat. Then acquire `wallstop-organization-builds` through the pinned central action with the physical `runner-id`, return-at-start, activate, run Unity (editmode / playmode / standalone IL2CPP) against the generated project, and return in the `finally`. Finish with the acquired-scoped central return, classification, release, and confirmed-cleanup gate. + +The central configuration owns admission across repositories: at most two holders, one holder per physical runner, resource lifecycle checks, and account health checks. Consumers dispatch the workflow and let this chain acquire, work, and release automatically. Keep normal admission separate from recovery of an actual active incident; do not maintain a second lock or require portal evidence for normal CI. ### Contract invariants to check on every change diff --git a/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md b/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md index 9d6febd2c..04a0e151c 100644 --- a/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md +++ b/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md @@ -79,7 +79,9 @@ Each Unity job follows this order: 1. Validate the Unity secrets via `./.github/actions/validate-unity-license` (checks `UNITY_SERIAL` / `UNITY_EMAIL` / `UNITY_PASSWORD` presence and rejects the retired `UNITY_LICENSING_SERVER`) BEFORE acquiring the org lock. -1. Acquire the org build lock (`wallstop-organization-builds`, `max-parallel: 1`). +1. Acquire `wallstop-organization-builds` through the pinned central action, + supplying the physical `runner-id`. The shared configuration controls the + two-holder limit and serializes each runner across repositories. 1. Return-at-start: `run-ci-tests.ps1` calls `Invoke-UnityLicenseReturn` to reclaim any seat a prior killed run leaked on this persistent runner. 1. Activate: `Invoke-UnityLicenseActivate` runs the serial activation (throws on @@ -92,6 +94,12 @@ Each Unity job follows this order: followed by cleanup classification, exact lock release, and the final fail-closed cleanup gate. +The [central lock configuration](https://github.com/Ambiguous-Interactive/ambiguous-organization-build-lock/blob/main/locks/wallstop-organization-builds.config.json) +owns admission and lifecycle policy. Normal CI dispatch uses the automatic chain +above. Portal reconciliation is recovery work for an actual active account +incident; it is not a prerequisite for ordinary admission. Consumers keep no +separate lock state or runner reservation policy. + ## The Seat-Limit Tradeoff (documented honestly) This is the accepted cost of leaving the floating licensing server behind: From 84883ad69c7647ae679b4605ce43bae725c1e42c Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 7 Oct 2026 23:41:40 +0000 Subject: [PATCH 012/147] Wait for editor readiness in the harvester cache regression --- .../Editor/DxMessagingSettingsLookupTests.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Tests/Editor/DxMessagingSettingsLookupTests.cs b/Tests/Editor/DxMessagingSettingsLookupTests.cs index 940ba0107..ed8773b31 100644 --- a/Tests/Editor/DxMessagingSettingsLookupTests.cs +++ b/Tests/Editor/DxMessagingSettingsLookupTests.cs @@ -2,12 +2,15 @@ namespace DxMessaging.Tests.Editor { using System; + using System.Collections; using System.Collections.Generic; + using DxMessaging.Editor; using DxMessaging.Editor.Analyzers; using DxMessaging.Editor.Settings; using NUnit.Framework; using UnityEditor; using UnityEngine; + using UnityEngine.TestTools; using Object = UnityEngine.Object; [TestFixture] @@ -108,11 +111,33 @@ public void LegacySettingsAreResolvedOnceAndReturnLiveModifiedValues() Assert.That(_loads, Is.EqualTo(2), "Only canonical and legacy paths should be loaded."); } - [Test] - public void HarvesterRescansReuseTheSharedPassiveSettingsCache() + /// + /// 2026-10-07, issue #613: Unity 2021 CI can execute the fixture while the editor is + /// busy. RescanNow correctly declines that work. Poll the shared idle condition and + /// execute both calls in the admitted update; delayCall need not run during UnityTest. + /// + [UnityTest] + [Category("Integration")] + public IEnumerator HarvesterRescansReuseTheSharedPassiveSettingsCache() { + const int frameBudget = 64; DxMessagingSettings settings = CreateSettings(DefaultPath); settings._baseCallCheckEnabled = false; + for ( + int frame = 0; + !DxMessagingEditorIdle.CanMutateAssetDatabase() && frame < frameBudget; + frame++ + ) + { + yield return null; + } + Assert.That( + DxMessagingEditorIdle.CanMutateAssetDatabase(), + Is.True, + $"The editor must become idle within {frameBudget} editor updates. " + + $"Compiling={EditorApplication.isCompiling}, updating={EditorApplication.isUpdating}." + ); + DxMessagingSettings.InvalidateSettingsCache(); Read(); int rescans = DxMessagingConsoleHarvester.RescanCount; int lookups = DxMessagingSettings.SettingsLookupCount; From d4c06af73a8a5477aebe912facb7d05f3743ba20 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 8 Oct 2026 00:32:28 +0000 Subject: [PATCH 013/147] Complete pending compilation before cache integration tests --- .../Editor/DxMessagingSettingsLookupTests.cs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Tests/Editor/DxMessagingSettingsLookupTests.cs b/Tests/Editor/DxMessagingSettingsLookupTests.cs index ed8773b31..68b43bae3 100644 --- a/Tests/Editor/DxMessagingSettingsLookupTests.cs +++ b/Tests/Editor/DxMessagingSettingsLookupTests.cs @@ -112,31 +112,27 @@ public void LegacySettingsAreResolvedOnceAndReturnLiveModifiedValues() } /// - /// 2026-10-07, issue #613: Unity 2021 CI can execute the fixture while the editor is - /// busy. RescanNow correctly declines that work. Poll the shared idle condition and - /// execute both calls in the admitted update; delayCall need not run during UnityTest. + /// 2026-10-07, issue #613: CI can start compilation before EditMode tests lock + /// assembly reloads. Ordinary yields cannot finish that pending reload. Let the + /// framework complete compilation and restore the test before constructing settings. + /// RescanNow retains its busy guard, and both calls execute in the same idle update. /// [UnityTest] [Category("Integration")] public IEnumerator HarvesterRescansReuseTheSharedPassiveSettingsCache() { - const int frameBudget = 64; - DxMessagingSettings settings = CreateSettings(DefaultPath); - settings._baseCallCheckEnabled = false; - for ( - int frame = 0; - !DxMessagingEditorIdle.CanMutateAssetDatabase() && frame < frameBudget; - frame++ - ) + if (EditorApplication.isCompiling) { - yield return null; + yield return new WaitForDomainReload(); } Assert.That( DxMessagingEditorIdle.CanMutateAssetDatabase(), Is.True, - $"The editor must become idle within {frameBudget} editor updates. " + "Compilation must finish before the guarded rescan calls. " + $"Compiling={EditorApplication.isCompiling}, updating={EditorApplication.isUpdating}." ); + DxMessagingSettings settings = CreateSettings(DefaultPath); + settings._baseCallCheckEnabled = false; DxMessagingSettings.InvalidateSettingsCache(); Read(); int rescans = DxMessagingConsoleHarvester.RescanCount; From 5c74fd4cef7956d027f5a061caf96dffde026343 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 8 Oct 2026 01:07:32 +0000 Subject: [PATCH 014/147] Resolve documentation capture fonts without IMGUI initialization --- Tests/Editor/EditorToolingDocumentationCaptureTests.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/Editor/EditorToolingDocumentationCaptureTests.cs b/Tests/Editor/EditorToolingDocumentationCaptureTests.cs index f95aa0ff5..bb8e02810 100644 --- a/Tests/Editor/EditorToolingDocumentationCaptureTests.cs +++ b/Tests/Editor/EditorToolingDocumentationCaptureTests.cs @@ -230,6 +230,11 @@ public void MessageMonitorCaptureSurfacesContainTrafficAndSelectedDetails() ); } + /// + /// 2026-10-08: Resolve the inspector TextField font from its built-in skin. + /// EditorStyles depends on a prior IMGUI skin event and can be uninitialized + /// after a domain reload in headless Unity 2021, while UI Toolkit is ready. + /// [Test] public void CompilerDiagnosticCapturesPairExactOutputWithTriggeringCode() { @@ -1092,7 +1097,10 @@ DocumentationCompilerDiagnosticState state Label code = new(triggeringCode) { name = CompilerDiagnosticCodeLabelName }; code.AddToClassList(DxMessagingEditorTheme.CardClassName); - code.style.unityFont = EditorStyles.textArea.font; + code.style.unityFont = EditorGUIUtility + .GetBuiltinSkin(EditorSkin.Inspector) + .GetStyle("TextField") + .font; code.style.fontSize = 13; code.style.whiteSpace = WhiteSpace.Normal; code.style.paddingTop = 10; From 4ae48bd939ba0f80495d22fd0d02fc302c181cee Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 8 Oct 2026 11:46:52 +0000 Subject: [PATCH 015/147] fix(ci): require central Unity resource lifecycle before admission --- .github/workflows/perf-numbers.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/unity-benchmarks.yml | 2 ++ .github/workflows/unity-tests.yml | 2 ++ docs/ops/ambiguous-release-migration.md | 15 +++++++++++---- docs/ops/ci-and-github-settings.md | 7 +++++++ scripts/__tests__/ci-aggregate-workflow.test.js | 3 ++- 7 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index bcce9fb61..bfeade103 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -439,6 +439,8 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-suite }} runner-id: ${{ runner.name }} github-token: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54e0d64cb..54216c664 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,6 +281,8 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" holder-id-suffix: release-editmode runner-id: ${{ runner.name }} github-token: ${{ github.token }} diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 68d8c38c5..a26996677 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -166,6 +166,8 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} runner-id: ${{ runner.name }} github-token: ${{ github.token }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 9cd0b8c10..2f1911610 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -325,6 +325,8 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" # Every matrix axis is part of the holder identity, so each expanded # leg owns a distinct lock holder. holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }}-all-modes diff --git a/docs/ops/ambiguous-release-migration.md b/docs/ops/ambiguous-release-migration.md index de1818174..2446ee8fe 100644 --- a/docs/ops/ambiguous-release-migration.md +++ b/docs/ops/ambiguous-release-migration.md @@ -104,6 +104,8 @@ organization lock. uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" runner-id: ${{ runner.name }} github-token: ${{ github.token }} pull-request-number: ${{ github.event.pull_request.number }} @@ -120,10 +122,15 @@ organization lock. ignore the build-lock actions, so bump their pins manually in one commit across `.github/workflows` and this page. - The matching release step uses the same immutable lock commit with `if: -always()`. Never use `wallstop-organization-builds` as a native GitHub - `concurrency.group`; the native primitive is repository-scoped and would - silently fail to serialize across repositories. + The lifecycle requirement rejects missing, invalid, or disabled central configuration. + The cooldown input accepts the centrally configured duration. Licensed work runs only + after acquisition succeeds. Every acquired job returns the Unity license, classifies + cleanup, releases the central lock, and requires confirmed cleanup on every exit path. + + The matching release step uses the same immutable lock commit with `if: always()`. + Never use `wallstop-organization-builds` as a native GitHub `concurrency.group`; + the native primitive is repository-scoped and would silently fail to serialize + across repositories. - Each Unity-credential-using job runs `./.github/actions/validate-unity-license` before acquiring the central lock diff --git a/docs/ops/ci-and-github-settings.md b/docs/ops/ci-and-github-settings.md index cb6b12af4..e0141fc83 100644 --- a/docs/ops/ci-and-github-settings.md +++ b/docs/ops/ci-and-github-settings.md @@ -39,6 +39,8 @@ the central lock immediately before the licensed Unity section: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 with: lock-name: wallstop-organization-builds + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" runner-id: ${{ runner.name }} github-token: ${{ github.token }} pull-request-number: ${{ github.event.pull_request.number }} @@ -56,6 +58,11 @@ The pinned commit above is part of the example. Dependabot is configured to ignore the build-lock actions, so bump their pins manually in one commit across `.github/workflows` and this page. +The lifecycle requirement rejects missing, invalid, or disabled central configuration. +The cooldown input accepts the centrally configured duration. Licensed work runs only +after acquisition succeeds. Every acquired job returns the Unity license, classifies +cleanup, releases the central lock, and requires confirmed cleanup on every exit path. + The matching release step uses the same immutable lock commit with `if: always()`. This lets checkout, cache, Node setup, and assembly discovery split across eligible runners while the organization lock enforces the two-seat diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index f0803a7b1..3e66b954e 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -436,7 +436,7 @@ test("copyable build-lock documentation follows the runner and App credential co ).exec(source); assert.ok(acquireExample, `${relativePath} must contain a copyable acquire example`); for (const binding of [ - /runner-id: \$\{\{ runner\.name \}\}/, + /require-resource-lifecycle: "true"\n\s+minimum-release-cooldown-seconds: "0"\n\s+[\s\S]*runner-id: \$\{\{ runner\.name \}\}/, /github-token: \$\{\{ github\.token \}\}/, /pull-request-number: \$\{\{ github\.event\.pull_request\.number \}\}/, /expected-head-sha: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/, @@ -594,6 +594,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { [validationStep, new RegExp(`provisioning-profile: ${escapeRegExp(UNITY_EDITOR_PROFILES[file])}\\n`), `${label}: the gate must use the reviewed provisioning profile`], [credentialStep, /uses: \.\/\.github\/actions\/validate-unity-license/], [acquireStep, /\n id: acquire_lock\n/], + [acquireStep, /\n require-resource-lifecycle: "true"\n minimum-release-cooldown-seconds: "0"\n/, `${label}: require central lifecycle configuration and accept its cooldown`], [requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/], [workStep, new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), label], [workStep, /-LicenseReturnOwner Central/, `${label}: the trusted central action must own the post-activation return`], From 31b36ce895cbdb0e461629ecb553b04c5b00425a Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 9 Oct 2026 18:39:59 +0000 Subject: [PATCH 016/147] Add bounded central Unity shipping Editor verification --- .github/workflows/runner-bootstrap.yml | 264 +++++++++++++++++- .../__tests__/ci-aggregate-workflow.test.js | 5 +- .../unity-artifact-redaction.test.js | 2 +- scripts/__tests__/workflow-test-vectors.json | 15 +- scripts/validate-unity-pr-policy.py | 2 + 5 files changed, 278 insertions(+), 10 deletions(-) diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index 6d7b7a579..42f6a9b94 100644 --- a/.github/workflows/runner-bootstrap.yml +++ b/.github/workflows/runner-bootstrap.yml @@ -26,6 +26,10 @@ name: Runner Audit (Windows) on: workflow_dispatch: inputs: + shipping-editor-verification: + description: "Run the complete shipping Editor and sample scope on Unity 2021.3." + type: boolean + default: false runner-label: description: >- (HARD-FAIL on wrong target) Name of the runner you want to audit @@ -44,9 +48,9 @@ on: # is intentionally NOT parameterized by `inputs.runner-label` so that two # dispatches (one per machine) cannot overlap. This is stricter than per-runner # concurrency: any in-flight audit anywhere blocks another audit from starting. -# Distinct from `wallstop-organization-builds` (the Unity build lock) -- this -# workflow does not enter the Unity license seat path, so it must not block -# Unity jobs and Unity jobs must not block it. +# Distinct from `wallstop-organization-builds` (the Unity build lock). Default +# audits are license-free; the optional shipping Editor job acquires the central +# lock and completes its automatic license-return and cleanup sequence. concurrency: group: runner-audit-windows cancel-in-progress: false @@ -76,6 +80,7 @@ jobs: required-label-sets: '[["self-hosted","Windows","RAM-64GB"]]' bootstrap: + if: ${{ !inputs['shipping-editor-verification'] }} # F9: surface the mode (Audit vs Maintenance) in the job display name so # operators reading the Actions UI immediately see which mode this run is # without expanding the inputs. @@ -231,3 +236,256 @@ jobs: path: .artifacts/runner-bootstrap/** if-no-files-found: error retention-days: 30 + + shipping-editor-runner-preflight: + name: Shipping Editor ELI registration preflight + if: ${{ github.event_name == 'workflow_dispatch' && inputs['shipping-editor-verification'] }} + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Require the registered ELI runner labels + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 + with: + reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} + reader-app-private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }} + required-label-sets: '[["self-hosted","Windows","RAM-64GB","fast"]]' + + shipping-editor-verification: + name: Unity 2021.3 shipping Editor and samples on ELI + if: ${{ github.event_name == 'workflow_dispatch' && inputs['shipping-editor-verification'] }} + needs: + - runner-preflight + - shipping-editor-runner-preflight + runs-on: [self-hosted, Windows, RAM-64GB, fast] + timeout-minutes: 600 + steps: + - name: Require ELI request and runner + timeout-minutes: 1 + shell: pwsh + env: + DXM_REQUESTED_RUNNER: ${{ inputs.runner-label }} + run: | + if ($env:DXM_REQUESTED_RUNNER -cne 'ELI-MACHINE' -or $env:RUNNER_NAME -cne 'ELI-MACHINE') { + throw 'Shipping Editor verification requires the ELI-MACHINE request and runner.' + } + + - name: Require manually installed Unity editor + id: ensure_unity_editor + timeout-minutes: 10 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/ensure-unity-editor@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 + with: + unity-version: 2021.3.45f1 + install-root: ${{ runner.tool_cache }}\u6-v3 + provisioning-profile: StandaloneWindowsIl2Cpp + diagnostics-path: unity-editor-check.json + ci-managed-only: true + require-healthy-existing: true + + - name: Enable Git long paths + timeout-minutes: 2 + shell: pwsh + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig + run: git config --global core.longpaths true + + - name: Checkout + timeout-minutes: 30 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig + with: + lfs: true + persist-credentials: false + + - name: Setup Node.js + id: setup_node + timeout-minutes: 5 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22.18.0" + + - name: Install artifact tooling dependencies + id: install_dependencies + timeout-minutes: 2 + shell: pwsh + run: | + $tooling = Join-Path $env:GITHUB_WORKSPACE '.artifacts/node-tooling' + New-Item -ItemType Directory -Path $tooling -Force | Out-Null + Copy-Item .github/artifact-tooling/package.json, .github/artifact-tooling/package-lock.json -Destination $tooling + npm ci --prefix "$tooling" --userconfig "$env:GITHUB_WORKSPACE/.npmrc" --ignore-scripts --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "NODE_PATH=$(Join-Path $tooling 'node_modules')" >> $env:GITHUB_ENV + + - name: Compute EditMode test assembly list + id: compute + timeout-minutes: 5 + uses: ./.github/actions/compute-unity-assemblies + with: + target: editmode + include-integrations: "true" + + - name: Require a shipping Editor test scope + timeout-minutes: 1 + shell: pwsh + env: + DXM_SELECTED_ASSEMBLIES: ${{ steps.compute.outputs.assemblies }} + DXM_EMPTY_SCOPE: ${{ steps.compute.outputs.is-empty }} + run: | + if ($env:DXM_EMPTY_SCOPE -cne 'false' -or [string]::IsNullOrWhiteSpace($env:DXM_SELECTED_ASSEMBLIES)) { + throw 'Shipping Editor verification requires a nonempty complete shipping selection.' + } + + - name: Validate Unity license secrets + timeout-minutes: 2 + uses: ./.github/actions/validate-unity-license + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Acquire organization Unity lock + id: acquire_lock + timeout-minutes: 305 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: shipping-editor-${{ github.run_id }}-${{ github.run_attempt }} + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" + runner-id: ${{ runner.name }} + github-token: ${{ github.token }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 + shell: pwsh + run: exit 1 + + - name: Run shipping Editor and sample tests + id: run_editor + if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 120 + shell: pwsh + env: + UNITY_EDITOR_PATH: ${{ steps.ensure_unity_editor.outputs.editor-path }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} + DXM_TEST_ASSEMBLIES: ${{ steps.compute.outputs.assemblies }} + DXM_UNITY_TEST_CATEGORY: "!Stress;!Performance;!Allocation;!MemoryReclaim;!UnityRuntime;!PerfBench;!PerfGate;!PerfBaseline" + DX_PERF_COMMIT: ${{ github.sha }} + run: | + $artifactsPath = '.artifacts/unity/shipping-editor/${{ github.run_id }}-${{ github.run_attempt }}' + $projectPath = Join-Path $env:RUNNER_WORKSPACE 'dxm-u\t\se${{ github.run_id }}a${{ github.run_attempt }}' + $cachePath = Join-Path $env:RUNNER_WORKSPACE 'dxm-c\shipping-editor-2021.3.45f1' + if (Test-Path -LiteralPath $projectPath) { throw 'Shipping Editor verification requires a fresh owned project.' } + New-Item -ItemType Directory -Force -Path $artifactsPath | Out-Null + @{ + sourceCommit = $env:DX_PERF_COMMIT + sourceTree = [string](& git rev-parse 'HEAD^{tree}') + unityVersion = '2021.3.45f1' + assemblies = $env:DXM_TEST_ASSEMBLIES + categories = $env:DXM_UNITY_TEST_CATEGORY + projectWasAbsentBefore = $true + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $artifactsPath 'scope-inputs.json') -Encoding UTF8 + if ($LASTEXITCODE -ne 0) { throw 'Shipping Editor source tree lookup failed.' } + $arguments = @{ + UnityVersion = '2021.3.45f1' + TestMode = 'editmode' + AssemblyNames = $env:DXM_TEST_ASSEMBLIES + ArtifactsPath = $artifactsPath + ProjectPath = $projectPath + CachePath = $cachePath + } + ./scripts/unity/run-ci-tests.ps1 -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') -LicenseReturnOwner Central @arguments + + - name: Verify shipping Editor tests actually ran + if: ${{ !cancelled() && steps.compute.outcome == 'success' && steps.run_editor.outcome != 'skipped' }} + timeout-minutes: 2 + uses: ./.github/actions/verify-unity-results + with: + results-dir: .artifacts/unity/shipping-editor/${{ github.run_id }}-${{ github.run_attempt }} + results-file: .artifacts/unity/shipping-editor/${{ github.run_id }}-${{ github.run_attempt }}/results.xml + label: Shipping Editor and samples + expected-empty: "false" + + - name: Return Unity license + id: return_unity_license + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@03518ac5ac2a4a223243c593cc9b453688c92b99 + with: + unity-version: 2021.3.45f1 + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: shipping-editor + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@03518ac5ac2a4a223243c593cc9b453688c92b99 + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} + + - name: Release organization Unity lock + id: release_unity_lock + if: always() + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@03518ac5ac2a4a223243c593cc9b453688c92b99 # v1.15.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: shipping-editor-${{ github.run_id }}-${{ github.run_attempt }} + runner-id: ${{ runner.name }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require confirmed Unity cleanup + if: always() + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@03518ac5ac2a4a223243c593cc9b453688c92b99 + with: + acquired: ${{ steps.acquire_lock.outputs.acquired }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + release-outcome: ${{ steps.release_unity_lock.outcome }} + cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} + released: ${{ steps.release_unity_lock.outputs.released }} + release-health: ${{ steps.release_unity_lock.outputs.resource-health }} + release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} + reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} + reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} + + - name: Redact shipping Editor artifacts + id: redact_editor + if: ${{ always() && steps.setup_node.outcome == 'success' && steps.install_dependencies.outcome == 'success' }} + timeout-minutes: 2 + uses: ./.github/actions/redact-unity-artifacts + with: + paths: .artifacts/unity/shipping-editor/${{ github.run_id }}-${{ github.run_attempt }} + + - name: Upload shipping Editor artifacts + if: ${{ always() && steps.redact_editor.outcome == 'success' }} + timeout-minutes: 10 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: shipping-editor-${{ github.run_id }}-${{ github.run_attempt }} + path: .artifacts/unity/shipping-editor/${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + retention-days: 14 diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 3e66b954e..dfa60dccb 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -533,7 +533,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { // run step became this typed output binding). const editorPathBinding = "${{ steps.ensure_unity_editor.outputs.editor-path }}"; - for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { + for (const [file, jobId, licensedWorkName, emptyAware, jobTimeout] of UNITY_LOCK_WINDOWS) { const label = `${file}:${jobId}`; const licensedCondition = `${file === "perf-numbers.yml" ? "success\\(\\) && " : ""}${file === "unity-tests.yml" ? "!cancelled\\(\\) && " : ""}${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; const job = getJobBlock(readWorkflow(file), jobId, file); @@ -557,9 +557,8 @@ test("every Unity lock window releases with explicit cleanup proof", () => { `${label}: failed installs cannot authorize redaction or uploads` ); } - const expectedJobTimeout = file === "unity-tests.yml" ? 1050 : 900; // prettier-ignore - assert.match(job, new RegExp(`\\n timeout-minutes: ${expectedJobTimeout}\\n`), `${label}: lifecycle budget`); + assert.match(job, new RegExp(`\\n timeout-minutes: ${jobTimeout ?? (file === "unity-tests.yml" ? 1050 : 900)}\\n`), `${label}: lifecycle budget`); if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { assert.match(job, /\n fail-fast: false\n max-parallel: 1\n/, `${label} fairness`); } diff --git a/scripts/__tests__/unity-artifact-redaction.test.js b/scripts/__tests__/unity-artifact-redaction.test.js index 34356229a..eb137c562 100644 --- a/scripts/__tests__/unity-artifact-redaction.test.js +++ b/scripts/__tests__/unity-artifact-redaction.test.js @@ -117,7 +117,7 @@ test("every Unity artifact upload is preceded by sensitive-data redaction in the const uploads = unityUploads(); assert.equal( uploads.length, - 8, + 9, "the number of Unity artifact uploads changed; confirm each one is still redacted" ); const unprotected = uploads.filter((upload) => !covers(upload.inForce, upload.uploadedPath)); diff --git a/scripts/__tests__/workflow-test-vectors.json b/scripts/__tests__/workflow-test-vectors.json index 686fa9563..7e350393f 100644 --- a/scripts/__tests__/workflow-test-vectors.json +++ b/scripts/__tests__/workflow-test-vectors.json @@ -3,13 +3,21 @@ ["unity-tests.yml", "unity-tests", "Run Unity Test Runner", true], ["unity-benchmarks.yml", "benchmarks", "Run Unity Test Runner", true], ["release.yml", "unity-checks", "Run Unity Test Runner", true], - ["perf-numbers.yml", "perf-benchmarks", "Run Unity Test Runner", true] + ["perf-numbers.yml", "perf-benchmarks", "Run Unity Test Runner", true], + [ + "runner-bootstrap.yml", + "shipping-editor-verification", + "Run shipping Editor and sample tests", + false, + 600 + ] ], "UNITY_EDITOR_PROFILES": { "unity-tests.yml": "${{ fromJSON('{\"editmode\":\"EditorOnly\",\"playmode\":\"EditorOnly\",\"standalone\":\"StandaloneWindowsIl2Cpp\"}')[matrix.test-mode] }}", "unity-benchmarks.yml": "${{ fromJSON('{\"editmode\":\"EditorOnly\",\"playmode\":\"EditorOnly\",\"standalone\":\"StandaloneWindowsIl2Cpp\"}')[matrix.test-mode] }}", "release.yml": "EditorOnly", - "perf-numbers.yml": "${{ fromJSON('{\"editmode\":\"EditorOnly\",\"playmode\":\"EditorOnly\",\"standalone\":\"StandaloneWindowsIl2Cpp\"}')[matrix.test-mode] }}" + "perf-numbers.yml": "${{ fromJSON('{\"editmode\":\"EditorOnly\",\"playmode\":\"EditorOnly\",\"standalone\":\"StandaloneWindowsIl2Cpp\"}')[matrix.test-mode] }}", + "runner-bootstrap.yml": "StandaloneWindowsIl2Cpp" }, "UNITY_EDITOR_CONSUMERS": [ [ @@ -19,7 +27,8 @@ ], ["unity-benchmarks.yml", "benchmarks", ["run_tests"]], ["release.yml", "unity-checks", ["run_tests"]], - ["perf-numbers.yml", "perf-benchmarks", ["run_allocation_contracts", "run_tests"]] + ["perf-numbers.yml", "perf-benchmarks", ["run_allocation_contracts", "run_tests"]], + ["runner-bootstrap.yml", "shipping-editor-verification", ["run_editor"]] ], "DOCS_ONLY_PATH_IGNORES": [ "docs/**", diff --git a/scripts/validate-unity-pr-policy.py b/scripts/validate-unity-pr-policy.py index cbe548a0d..dfd5ca538 100644 --- a/scripts/validate-unity-pr-policy.py +++ b/scripts/validate-unity-pr-policy.py @@ -24,6 +24,7 @@ SHIPPING_MATRIX = Path("scripts/unity/run-shipping-fidelity-matrix.ps1") LOCK_ACTION_PREFIX = "Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/" REGISTERED_UNITY_AUTOMATION = { + ".github/workflows/runner-bootstrap.yml", ".github/actions/validate-unity-license/action.yml", ".github/workflows/perf-numbers.yml", ".github/workflows/release.yml", @@ -32,6 +33,7 @@ } # SYNC: Keep scripts/__tests__/ci-aggregate-workflow.test.js UNITY_LOCK_WINDOWS aligned. LICENSED_LOCK_WINDOWS = ( + (Path(".github/workflows/runner-bootstrap.yml"), "shipping-editor-verification"), (Path(".github/workflows/unity-tests.yml"), "unity-tests"), (Path(".github/workflows/unity-benchmarks.yml"), "benchmarks"), (Path(".github/workflows/perf-numbers.yml"), "perf-benchmarks"), From 8a6edc8a007b6e480b70f63f896bde88f9dcfef5 Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 9 Oct 2026 21:50:03 +0000 Subject: [PATCH 017/147] Keep Pipeline controls separate from shipping tests --- .../compute-unity-assemblies/action.yml | 34 +- .llm/skills/unity-test-execution/SKILL.md | 4 + Tests/Editor/PipelineTransportResearch.meta | 8 + .../PipelineLifecycleTests.cs | 1062 +++++++++++++++++ .../PipelineLifecycleTests.cs.meta | 11 + ...ts.Editor.PipelineTransportResearch.asmdef | 25 + ...itor.PipelineTransportResearch.asmdef.meta | 7 + docs/runbooks/pipeline-host-repair.md | 154 +++ docs/runbooks/pipeline-host-repair.md.meta | 7 + scripts/__tests__/asmdef-discovery.test.js | 114 ++ .../__tests__/asmdef-discovery.test.js.meta | 7 + .../__tests__/editor-window-test-host.test.js | 17 +- .../mcp/pipeline-0.8.0-exp.1-lifecycle.patch | 122 ++ .../pipeline-0.8.0-exp.1-lifecycle.patch.meta | 7 + scripts/unity/lib/asmdef-discovery.js | 271 +---- scripts/wiki/__tests__/transform-cases.json | 12 + scripts/wiki/transform-docs-to-wiki.js | 15 +- 17 files changed, 1667 insertions(+), 210 deletions(-) create mode 100644 Tests/Editor/PipelineTransportResearch.meta create mode 100644 Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs create mode 100644 Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs.meta create mode 100644 Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef create mode 100644 Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef.meta create mode 100644 docs/runbooks/pipeline-host-repair.md create mode 100644 docs/runbooks/pipeline-host-repair.md.meta create mode 100644 scripts/__tests__/asmdef-discovery.test.js create mode 100644 scripts/__tests__/asmdef-discovery.test.js.meta create mode 100644 scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch create mode 100644 scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch.meta diff --git a/.github/actions/compute-unity-assemblies/action.yml b/.github/actions/compute-unity-assemblies/action.yml index f8a3b02a9..ddd1a79e6 100644 --- a/.github/actions/compute-unity-assemblies/action.yml +++ b/.github/actions/compute-unity-assemblies/action.yml @@ -17,6 +17,18 @@ inputs: description: "Include the Zenject, VContainer, and Reflex integration test assemblies." required: false default: "false" + include-transport-controls: + description: "Include Pipeline controls on the separately admitted host; the shipping harness does not install Pipeline." + required: false + default: "false" + transport-package-path: + description: "Actual resolved Pipeline package path from the admitted host's Package Manager." + required: false + default: "" + unity-version: + description: "Observed editor version, required when transport controls are selected." + required: false + default: "" runtime-only: description: "Drop editor-only asmdefs (standalone player flow cannot run EditMode tests)." required: false @@ -34,21 +46,32 @@ outputs: 'true' when no DxMessaging-owned test assembly matched this target -- the caller should skip the Unity run/verify steps. 'false' otherwise. value: ${{ steps.compute.outputs.is-empty }} + excluded-transport-controls: + description: "Editor transport assemblies excluded from this scope; they require separate native verification." + value: ${{ steps.compute.outputs.excluded-transport-controls }} runs: using: composite steps: - name: Compute assembly list id: compute shell: pwsh + env: + DXM_TRANSPORT_PACKAGE_PATH: ${{ inputs.transport-package-path }} + DXM_TRANSPORT_UNITY_VERSION: ${{ inputs.unity-version }} run: | $parts = @() if ("${{ inputs.include-perf }}" -eq "true") { $parts += "includePerf: true" } if ("${{ inputs.include-comparisons }}" -eq "true") { $parts += "includeComparisons: true" } if ("${{ inputs.include-integrations }}" -eq "true") { $parts += "includeIntegrations: true" } + if ("${{ inputs.include-transport-controls }}" -eq "true") { + $parts += "includeTransportControls: true" + $parts += "transportPackagePath: process.env.DXM_TRANSPORT_PACKAGE_PATH" + $parts += "unityVersion: process.env.DXM_TRANSPORT_UNITY_VERSION" + } if ("${{ inputs.runtime-only }}" -eq "true") { $parts += "runtimeOnly: true" } if ("${{ inputs.target }}" -ne "") { $parts += "target: '${{ inputs.target }}'" } $opts = if ($parts.Count) { "{ " + ($parts -join ", ") + " }" } else { "{}" } - $assemblies = node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(m.defaultIncludeAssemblies(process.cwd(), $opts).join(';'))" + $selectionJson = node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(JSON.stringify(m.resolveTestAssemblySelection(process.cwd(), $opts)))" $discoveryExit = $LASTEXITCODE # A non-zero exit means the discovery SCRIPT itself failed (bad repo # checkout, syntax error, throw) -- a real error, not an empty list. Fail @@ -57,7 +80,14 @@ runs: Write-Host "::error::asmdef discovery script failed (exit code $discoveryExit). The Unity test assembly list could not be resolved." exit 1 } - $assemblies = ([string]$assemblies).Trim() + $selection = $selectionJson | ConvertFrom-Json -ErrorAction Stop + $assemblies = @($selection.assemblies) -join ';' + $excludedTransport = @($selection.excludedTransportControls) -join ';' + Add-Content -Path $env:GITHUB_OUTPUT -Value "excluded-transport-controls=$excludedTransport" -Encoding utf8 + if ($excludedTransport) { + Write-Host ("::notice::Transport controls are excluded from this shipping scope: $excludedTransport. " + + "Verify them separately with the admitted Pipeline host in docs/runbooks/pipeline-host-repair.md.") + } if ([string]::IsNullOrWhiteSpace($assemblies)) { # SKIP, do not fail: the discovery succeeded but no DxMessaging-owned # test assembly matched this target (for example a runtime-only diff --git a/.llm/skills/unity-test-execution/SKILL.md b/.llm/skills/unity-test-execution/SKILL.md index d0b0fb096..f64001317 100644 --- a/.llm/skills/unity-test-execution/SKILL.md +++ b/.llm/skills/unity-test-execution/SKILL.md @@ -26,6 +26,10 @@ How the DxMessaging Unity suites are hosted, selected, and executed, plus the le - The repo root is a UPM package, not a Unity project. `scripts/unity/run-ci-tests.ps1` generates a thin host whose `Packages/manifest.json` declares `com.wallstop-studios.dxmessaging` as a local `file:` dependency and lists it under `testables`. Local runs default to `.artifacts/u/-/`; CI passes a short, scope-isolated path under `$RUNNER_WORKSPACE/dxm-u/{t,b,p}/`. Never add `Assets/` content to the package root. - `testables` exposes every asmdef under `Tests/` automatically, so a new suite needs no harness change: create the asmdef, give it a stable name such as `WallstopStudios.DxMessaging.Tests.Editor.NewSuite`, then confirm discovery with `node scripts/unity/lib/asmdef-discovery.js`. - Name perf and DI-integration assemblies `*Benchmarks*`, `*Allocations*`, `*Comparisons*`, `*VContainer*`, `*Zenject*`, or `*Reflex*`. The classification regex in `scripts/unity/lib/asmdef-discovery.js` excludes those from the default include list. +- Pipeline transport controls are a separate opt-in. Shipping discovery records their exclusion; + `includeTransportControls` requires the actual resolved package directory and admitted editor + version. Preserve the Unity 2021.3 floor; do not inject Pipeline into the general harness. + Follow [the host repair scope contract](../../../docs/runbooks/pipeline-host-repair.md). - A new test-only UPM dependency goes into `New-ManifestJson` in `scripts/unity/run-ci-tests.ps1`; inspect the result with the runner's `-GenerateOnly` mode. Heavyweight runtime dependencies must be opt-in behind `--include-integrations`. - Committed source of truth: `scripts/unity/run-ci-tests.ps1` plus `Runtime/`, `Editor/`, `Tests/`. Generated and ignored: the host project's `Library/`, `Temp/`, `Logs/`, and `UserSettings/`. Fixed CI runners reuse scope-isolated projects and per-version package caches outside the checkout; do not upload or restore Unity `Library` directories through `actions/cache`. diff --git a/Tests/Editor/PipelineTransportResearch.meta b/Tests/Editor/PipelineTransportResearch.meta new file mode 100644 index 000000000..76fd4afd1 --- /dev/null +++ b/Tests/Editor/PipelineTransportResearch.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6536271a1490420a84793b8b3b24e1c0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs b/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs new file mode 100644 index 000000000..151e70ad3 --- /dev/null +++ b/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs @@ -0,0 +1,1062 @@ +#if UNITY_EDITOR && DXM_621_PIPELINE_PRESENT +namespace DxMessaging.Tests.Editor.PipelineTransportResearch +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Net; + using System.Net.Sockets; + using System.Reflection; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + using Unity.Pipeline; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class PipelineLifecycleTests : UnityFixtureBase + { + private readonly List _owners = new(); + private static PipelineLifecycleTests _retainedFixture; + private const string Token = "dxm-621-fixture-token"; + + private static Task _phaseControlTask; + + [Serializable] + private sealed class PhaseControlResult + { + public string Outcome; + public string Failure; + public List Rows = new(); + } + + [Serializable] + private sealed class PhaseControlRow + { + public string Kind; + public int Index; + public string PeerBefore; + public string Phase; + public string OperationFailure; + public string[] TaskTerminalErrors; + public int StatusCalls; + public int CommandMetadataCalls; + public string Response; + public string[] Errors; + public string[] ResponseEvents; + public int AdmissionEvents; + public int ReleaseCount; + public int PendingTasks; + public bool PortRebound; + } + + public static string StartOwnedResponsePhaseControls( + bool expectResponseCandidate, + bool negativeOnly = false, + bool closedResponseOnly = false + ) + { + if (_phaseControlTask != null && !_phaseControlTask.IsCompleted) + throw new InvalidOperationException("Owned phase diagnostic is already active."); + string path = Path.GetFullPath( + Path.Combine( + Application.dataPath, + "../Packages/com.wallstop-studios.dxmessaging/.artifacts/perf-lab/621-response-ownership-s397/" + + ( + closedResponseOnly + ? ( + expectResponseCandidate + ? "closed-response-candidate-polling.json" + : "closed-response-baseline-polling.json" + ) + : negativeOnly + ? "candidate-handler-negative-controls-final-polling.json" + : expectResponseCandidate ? "candidate-controls-final-polling.json" + : "baseline-controls-v2-polling.json" + ) + ) + ); + if (File.Exists(path) || File.Exists(path + ".status")) + throw new InvalidOperationException( + "Owned phase evidence must not be overwritten." + ); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path + ".status", "running"); + _phaseControlTask = RunControls(); + async Task RunControls() + { + PhaseControlResult result = new(); + try + { + foreach ( + string kind in closedResponseOnly ? new[] { "CommandsResponseClosed" } + : negativeOnly + ? new[] + { + "CommandsIoFault", + "CommandsDisposedFault", + "CommandsListenerFault", + } + : new[] + { + "HandlerFault", + "CommandsMetadataFault", + "CommandsSocketDisposed", + "CommandsHeadersSent", + "LiveCommands", + } + ) + { + int count = 50; + for (int index = 0; index < count; ++index) + { + TransportOwner owner = new() { OutsideNUnitDiagnostic = true }; + PhaseControlRow row = new() + { + Kind = kind, + Index = index, + Phase = "create", + }; + try + { + Task accept = owner.Observe( + owner.Listener.GetContextAsync() + ); + await WithinOperationDeadline( + owner.Observe( + owner.Client.ConnectAsync(IPAddress.Loopback, owner.Port) + ) + ); + string endpoint = + kind == "HandlerFault" + ? "/api/status" + : "/api/commands?detail=compact"; + byte[] request = Encoding.ASCII.GetBytes( + $"GET {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{owner.Port}\r\nAuthorization: Bearer {Token}\r\nConnection: close\r\n\r\n" + ); + await WithinOperationDeadline( + owner.Observe( + owner + .Client.GetStream() + .WriteAsync(request, 0, request.Length) + ) + ); + await WithinOperationDeadline(accept); + owner.Context = accept.Result; + row.PeerBefore = + owner.Context.Request.RemoteEndPoint.Address.ToString(); + Assert.That(row.PeerBefore, Is.EqualTo("127.0.0.1")); + if (kind == "HandlerFault") + owner.Server.StatusControl = () => + throw new InvalidOperationException( + "controlled-handler-fault-621" + ); + if (kind == "CommandsMetadataFault") + owner.Server.CommandMetadataControl = () => + throw new InvalidOperationException( + "controlled-metadata-fault-621" + ); + if (kind == "CommandsIoFault") + owner.Server.CommandMetadataControl = () => + throw new IOException("controlled-metadata-fault-621"); + if (kind == "CommandsDisposedFault") + owner.Server.CommandMetadataControl = () => + throw new ObjectDisposedException( + "controlled-metadata-fault-621" + ); + if (kind == "CommandsListenerFault") + owner.Server.CommandMetadataControl = () => + throw new HttpListenerException( + 5, + "controlled-metadata-fault-621" + ); + if (kind == "CommandsResponseClosed") + owner.Server.CommandMetadataControl = () => + owner.Context.Response.Abort(); + if (kind == "CommandsSocketDisposed") + owner.Server.CommandMetadataControl = () => + { + object connection = typeof(HttpListenerContext) + .GetProperty( + "Connection", + BindingFlags.Instance | BindingFlags.NonPublic + ) + .GetValue(owner.Context); + Socket socket = (Socket) + connection + .GetType() + .GetField( + "sock", + BindingFlags.Instance | BindingFlags.NonPublic + ) + .GetValue(connection); + Assert.That( + ((IPEndPoint)socket.LocalEndPoint).Port, + Is.EqualTo(owner.Port) + ); + Assert.That( + ((IPEndPoint)socket.RemoteEndPoint).Address, + Is.EqualTo(IPAddress.Loopback) + ); + socket.Dispose(); + }; + if (kind == "CommandsHeadersSent") + owner.Server.CommandMetadataControl = () => + { + owner.Context.Response.SendChunked = true; + owner.Context.Response.OutputStream.Write( + new byte[] { 120 }, + 0, + 1 + ); + }; + Task read = owner.Observe( + new StreamReader( + owner.Client.GetStream(), + Encoding.UTF8 + ).ReadToEndAsync() + ); + row.Phase = "dispatch"; + owner.BeginObservation(); + MethodInfo actual = typeof(BasePipelineServer).GetMethod( + "ProcessRequestDetached", + BindingFlags.Instance | BindingFlags.NonPublic + ); + await WithinOperationDeadline( + owner.Observe( + (Task) + actual.Invoke( + owner.Server, + new object[] { owner.Context } + ) + ) + ); + row.Phase = "post-dispatch-abort"; + owner.Context.Response.Abort(); + row.Phase = "client-read"; + await WithinOperationDeadline(read); + row.Response = read.Result; + owner.EndObservation(); + row.StatusCalls = owner.Server.StatusCalls; + row.CommandMetadataCalls = owner.Server.CommandMetadataCalls; + row.Errors = owner.Errors.ToArray(); + row.ResponseEvents = owner.ResponseEvents.ToArray(); + row.AdmissionEvents = owner.AdmissionEvents.Count; + Assert.That( + row.AdmissionEvents, + Is.Zero, + "These actions occur after actual peer admission." + ); + if (kind == "HandlerFault") + { + Assert.That(row.StatusCalls, Is.EqualTo(1)); + Assert.That(row.Errors.Length, Is.EqualTo(1)); + Assert.That( + row.Errors[0], + Does.Contain("controlled-handler-fault-621") + ); + Assert.That(row.Response, Does.StartWith("HTTP/1.1 400")); + } + else + { + Assert.That(row.CommandMetadataCalls, Is.EqualTo(1)); + if (kind == "LiveCommands") + { + Assert.That(row.Response, Does.StartWith("HTTP/1.1 200")); + Assert.That(row.Errors, Is.Empty); + } + else if (kind == "CommandsMetadataFault" || negativeOnly) + { + Assert.That(row.Errors.Length, Is.EqualTo(1)); + Assert.That( + row.Errors[0], + Does.Contain("controlled-metadata-fault-621") + ); + Assert.That(row.Response, Does.StartWith("HTTP/1.1 500")); + Assert.That(row.ResponseEvents, Is.Empty); + if (expectResponseCandidate) + { + string expectedType = kind switch + { + "CommandsIoFault" => "System.IO.IOException", + "CommandsDisposedFault" => + "System.ObjectDisposedException", + "CommandsListenerFault" => + "System.Net.HttpListenerException", + _ => "System.InvalidOperationException", + }; + Assert.That(row.Errors[0], Does.Contain(expectedType)); + } + } + else if (closedResponseOnly) + { + Assert.That( + row.Errors.Length, + Is.EqualTo(expectResponseCandidate ? 0 : 1) + ); + Assert.That( + row.ResponseEvents.Length, + Is.EqualTo(expectResponseCandidate ? 1 : 0) + ); + string actualFailure = expectResponseCandidate + ? row.ResponseEvents[0] + : row.Errors[0]; + Assert.That( + actualFailure, + Does.Contain("System.ObjectDisposedException") + ); + Assert.That(actualFailure, Does.Contain("SendJsonPayload")); + } + else if ( + expectResponseCandidate + && kind == "CommandsSocketDisposed" + ) + { + Assert.That(row.Errors, Is.Empty); + Assert.That(row.ResponseEvents.Length, Is.EqualTo(1)); + Assert.That( + row.ResponseEvents[0], + Does.Contain("System.IO.IOException") + ); + Assert.That( + row.ResponseEvents[0], + Does.Contain("SendJsonPayload") + ); + } + else + { + Assert.That( + row.Errors.Length, + Is.EqualTo(expectResponseCandidate ? 1 : 2) + ); + if (expectResponseCandidate) + Assert.That( + row.Errors[0], + Does.Contain("System.InvalidOperationException") + ); + Assert.That(row.ResponseEvents, Is.Empty); + } + } + } + catch (Exception operationError) + { + row.OperationFailure = operationError.ToString(); + throw; + } + finally + { + owner.Client.Close(); + owner.Server.Stop(); + owner.Listener.Close(); + List taskErrors = new(); + foreach (Task task in owner._tasks) + { + await WithinOperationDeadline( + task, + propagateTaskFailure: false + ); + if (task.IsFaulted) + taskErrors.Add(task.Exception.ToString()); + } + row.TaskTerminalErrors = taskErrors.ToArray(); + owner.EndObservation(); + row.StatusCalls = owner.Server.StatusCalls; + row.CommandMetadataCalls = owner.Server.CommandMetadataCalls; + row.Errors = owner.Errors.ToArray(); + row.ResponseEvents = owner.ResponseEvents.ToArray(); + row.AdmissionEvents = owner.AdmissionEvents.Count; + result.Rows.Add(row); + File.WriteAllText(path, JsonUtility.ToJson(result, true)); + owner.Dispose(); + owner.Dispose(); + row.ReleaseCount = owner.ReleaseCount; + row.PendingTasks = owner.PendingCount; + Assert.That(row.ReleaseCount, Is.EqualTo(1)); + Assert.That(row.PendingTasks, Is.Zero); + Assert.That(owner.Server.IsRunning, Is.False); + Assert.That(owner.ObservationAttached, Is.False); + TcpListener proof = new(IPAddress.Loopback, owner.Port); + try + { + proof.Start(); + row.PortRebound = true; + } + finally + { + proof.Stop(); + } + File.WriteAllText(path, JsonUtility.ToJson(result, true)); + } + } + } + result.Outcome = "observed"; + } + catch (Exception error) + { + result.Outcome = "unexpected-failure"; + result.Failure = error.ToString(); + } + finally + { + File.WriteAllText(path, JsonUtility.ToJson(result, true)); + File.WriteAllText( + path + ".status", + result.Outcome == "observed" ? "done" : "error" + ); + } + } + return path; + } + + private static async Task WithinOperationDeadline( + Task task, + bool propagateTaskFailure = true + ) + { + Stopwatch deadline = Stopwatch.StartNew(); + while (!task.IsCompleted) + { + if (5000 <= deadline.ElapsedMilliseconds) + throw new TimeoutException( + "Owned diagnostic operation exceeded the existing five-second deadline." + ); + await Task.Yield(); + } + if (propagateTaskFailure) + task.GetAwaiter().GetResult(); + } + + public sealed class Case + { + internal readonly string Kind; + internal readonly int Index; + internal readonly bool ProbeCleanup; + + internal Case(string kind, int index = 0, bool probeCleanup = false) + { + Kind = kind; + Index = index; + ProbeCleanup = probeCleanup; + } + + public override string ToString() => $"{Kind}-{Index}"; + } + + public static IEnumerable Cases + { + get + { + for (int i = 0; i < 50; ++i) + yield return new Case("ClosedResponse", i); + for (int i = 0; i < 50; ++i) + yield return new Case("SocketDisposed", i); + foreach ( + string kind in new[] + { + "Authorized", + "MissingToken", + "WrongToken", + "UntrustedOrigin", + "Shutdown", + } + ) + yield return new Case(kind); + } + } + + [OneTimeSetUp] + public void RetainFixtureUntilResourcesRelease() + { + Assert.That(_retainedFixture, Is.Null); + _retainedFixture = this; + Assert.That( + typeof(BasePipelineServer).Assembly.GetName().Name, + Is.EqualTo("Unity.Pipeline") + ); + } + + [OneTimeTearDown] + public void RetireFixtureAfterResourcesRelease() + { + Assert.That(_owners, Is.Empty); + _retainedFixture = null; + } + + public override void TearDownManagedResources() + { + List failures = new(); + foreach (TransportOwner owner in _owners) + { + try + { + owner.ReportTerminalState(); + owner.Dispose(); + owner.Dispose(); + Assert.That(owner.ReleaseCount, Is.EqualTo(1)); + Assert.That(owner.Server.IsRunning, Is.False); + Assert.That(owner.ObservationAttached, Is.False); + Assert.That(owner.PendingCount, Is.Zero); + TcpListener proof = new(IPAddress.Loopback, owner.Port); + try + { + proof.Start(); + } + finally + { + proof.Stop(); + } + } + catch (Exception error) + { + failures.Add(error); + } + } + TestContext.WriteLine( + $"Pipeline fixture cleanup: owners={_owners.Count},allKnownTasksObserved=true; ports rebound; each owner released once." + ); + if (failures.Count == 0) + _owners.Clear(); + base.TearDownManagedResources(); + if (failures.Count != 0) + throw new AggregateException(failures); + } + + [UnityTest] + public IEnumerator ActualDetachedRequestRespectsContextLifetimeAndAuthorization( + [ValueSource(nameof(Cases))] Case scenario + ) + { + TransportOwner owner = TrackDisposable(new TransportOwner()); + _owners.Add(owner); + owner.Scenario = scenario; + Task accept = owner.Observe(owner.Listener.GetContextAsync()); + Task connect = owner.Observe(owner.Client.ConnectAsync(IPAddress.Loopback, owner.Port)); + yield return Await(connect); + string authorization = + scenario.Kind == "MissingToken" + ? "" + : $"Authorization: Bearer {(scenario.Kind == "WrongToken" ? "wrong-fixture-token" : Token)}\r\n"; + string origin = + scenario.Kind == "UntrustedOrigin" ? "Origin: https://untrusted.invalid\r\n" : ""; + byte[] request = Encoding.ASCII.GetBytes( + $"GET /api/status HTTP/1.1\r\nHost: 127.0.0.1:{owner.Port}\r\n{authorization}{origin}Connection: close\r\n\r\n" + ); + yield return Await( + owner.Observe(owner.Client.GetStream().WriteAsync(request, 0, request.Length)) + ); + yield return Await(accept); + owner.Context = accept.Result; + owner.ResponseBefore = ObserveResponse(owner.Context); + string peerBefore = owner.Context.Request.RemoteEndPoint?.Address.ToString(); + owner.PeerBefore = peerBefore; + Assert.That(peerBefore, Is.EqualTo("127.0.0.1")); + if (scenario.Kind == "ClosedResponse") + { + owner.Context.Response.KeepAlive = false; + owner.Context.Response.ContentLength64 = 0; + owner.Context.Response.Close(); + Assert.That( + owner.Server.IsRunning, + Is.True, + "The listener remains active while this context terminates." + ); + } + else if (scenario.Kind == "SocketDisposed" || scenario.Kind == "SocketShutdown") + { + object connection = typeof(HttpListenerContext) + .GetProperty("Connection", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(owner.Context); + Socket socket = (Socket) + connection + .GetType() + .GetField("sock", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(connection); + Assert.That(((IPEndPoint)socket.LocalEndPoint).Port, Is.EqualTo(owner.Port)); + Assert.That( + ((IPEndPoint)socket.RemoteEndPoint).Address, + Is.EqualTo(IPAddress.Loopback) + ); + if (scenario.Kind == "SocketDisposed") + socket.Dispose(); + else + socket.Shutdown(SocketShutdown.Both); + Assert.That(owner.Server.IsRunning, Is.True); + } + else if (scenario.Kind == "Shutdown") + owner.Server.Stop(); + string peerAfter = null; + string endpointError = null; + try + { + peerAfter = owner.Context.Request.RemoteEndPoint?.Address.ToString(); + } + catch (Exception error) + { + endpointError = error.ToString(); + if (error is SocketException socketError) + endpointError += + $"\nSocketErrorCode={socketError.SocketErrorCode},NativeErrorCode={socketError.NativeErrorCode},ErrorCode={socketError.ErrorCode}"; + } + owner.PeerAfter = peerAfter; + owner.EndpointError = endpointError; + owner.ResponseAfter = ObserveResponse(owner.Context); + if (scenario.ProbeCleanup) + { + TestContext.WriteLine("Pipeline abort endpoint: " + (endpointError ?? "none")); + owner.Context.Response.Abort(); + TestContext.WriteLine( + $"Pipeline abort completed: kind={scenario.Kind},active={owner.Server.IsRunning},handlerCalls={owner.Server.StatusCalls}" + ); + Assert.That(owner.Server.IsRunning, Is.True); + Assert.That(owner.Server.StatusCalls, Is.Zero); + yield break; + } + owner.BeginObservation(); + try + { + MethodInfo actual = typeof(BasePipelineServer).GetMethod( + "ProcessRequestDetached", + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.That(actual, Is.Not.Null); + Task handling = owner.Observe( + (Task)actual.Invoke(owner.Server, new object[] { owner.Context }) + ); + yield return Await(handling); + } + finally + { + owner.EndObservation(); + } + string response = "terminal context"; + if (scenario.Kind != "Shutdown") + { + Task read = owner.Observe( + new StreamReader(owner.Client.GetStream(), Encoding.UTF8).ReadToEndAsync() + ); + yield return Await(read); + response = read.Result; + } + int expectedCalls = scenario.Kind == "Authorized" ? 1 : 0; + TestContext.WriteLine( + $"Pipeline lifecycle row: kind={scenario.Kind},index={scenario.Index},active={owner.Server.IsRunning},peerBefore={peerBefore},peerAfter={peerAfter ?? "null"},statusCalls={owner.Server.StatusCalls},errors={owner.Errors.Count}." + ); + TestContext.WriteLine("Pipeline endpoint observation: " + (endpointError ?? "none")); + foreach (string error in owner.Errors) + TestContext.WriteLine("Pipeline actual error: " + error); + Assert.That( + owner.Server.StatusCalls, + Is.EqualTo(expectedCalls), + "Only a live authorized request reaches the real status handler." + ); + if (scenario.Kind == "Authorized") + Assert.That(response, Does.StartWith("HTTP/1.1 200")); + if (scenario.Kind == "MissingToken" || scenario.Kind == "WrongToken") + Assert.That(response, Does.StartWith("HTTP/1.1 401")); + if (scenario.Kind == "UntrustedOrigin") + Assert.That(response, Does.StartWith("HTTP/1.1 403")); + Assert.That( + owner.Errors, + Is.Empty, + "Terminal contexts must not cause an unhandled error; all logs remain observable." + ); + int expectedRejections = + scenario.Kind == "ClosedResponse" + || scenario.Kind == "SocketDisposed" + || scenario.Kind == "SocketShutdown" + || scenario.Kind == "Shutdown" + ? 1 + : 0; + Assert.That( + owner.AdmissionEvents.Count, + Is.EqualTo(expectedRejections), + "Terminated contexts must retain one explicit pre-admission rejection event." + ); + } + + public static IEnumerable AbortCases + { + get + { + foreach ( + string kind in new[] { "ClosedResponse", "SocketDisposed", "SocketShutdown" } + ) + yield return new Case(kind, probeCleanup: true); + } + } + + [UnityTest] + public IEnumerator ActualAbortReleasesTerminatedOwnedContext( + [ValueSource(nameof(AbortCases))] Case scenario + ) + { + yield return ActualDetachedRequestRespectsContextLifetimeAndAuthorization(scenario); + } + + public static IEnumerable ShutdownCases + { + get + { + for (int index = 0; index < 50; ++index) + yield return new Case("SocketShutdown", index); + } + } + + /// + /// October 8, 2026: socket shutdown and disposal are separate lifecycle phases. + /// Keep the actual endpoint, dispatch and response failures attributable by phase. + /// + [UnityTest] + public IEnumerator ActualDetachedRequestRejectsShutdownSocket( + [ValueSource(nameof(ShutdownCases))] Case scenario + ) + { + yield return ActualDetachedRequestRespectsContextLifetimeAndAuthorization(scenario); + } + + public static IEnumerable AcceptCases + { + get + { + foreach (string kind in new[] { "CapturedAccept", "ThreadPoolAccept" }) + for (int index = 0; index < 50; ++index) + yield return new Case(kind, index); + } + } + + /// + /// October 8, 2026: actual Pipeline admission awaits GetContextAsync. + /// This assay distinguishes caller-context capture from thread-pool admission; + /// it does not establish the cause or frequency of naturally expired sockets. + /// + [UnityTest] + public IEnumerator ActualAcceptLoopUsesExpectedSynchronizationContext( + [ValueSource(nameof(AcceptCases))] Case scenario + ) + { + SynchronizationContext original = SynchronizationContext.Current; + Assert.That(original, Is.Not.Null, "A live Unity caller context is required."); + int callerThread = Thread.CurrentThread.ManagedThreadId; + TransportOwner owner = TrackDisposable(new TransportOwner()); + _owners.Add(owner); + owner.Scenario = scenario; + RecordingContext recording = new(original); + MethodInfo actual = typeof(BasePipelineServer).GetMethod( + "HandleRequests", + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.That(actual, Is.Not.Null); + Task loop; + try + { + SynchronizationContext.SetSynchronizationContext( + scenario.Kind == "CapturedAccept" ? recording : null + ); + loop = owner.Observe((Task)actual.Invoke(owner.Server, null)); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + Assert.That(SynchronizationContext.Current, Is.SameAs(original)); + Assert.That(loop.IsCompleted, Is.False, "Accept must begin before a request exists."); + Task read = null; + owner.BeginObservation(); + try + { + yield return Await( + owner.Observe(owner.Client.ConnectAsync(IPAddress.Loopback, owner.Port)) + ); + byte[] request = Encoding.ASCII.GetBytes( + $"GET /api/status HTTP/1.1\r\nHost: 127.0.0.1:{owner.Port}\r\nAuthorization: Bearer {Token}\r\nConnection: close\r\n\r\n" + ); + yield return Await( + owner.Observe(owner.Client.GetStream().WriteAsync(request, 0, request.Length)) + ); + read = owner.Observe( + new StreamReader(owner.Client.GetStream(), Encoding.UTF8).ReadToEndAsync() + ); + yield return Await(read); + owner.AcceptDiagnostic = + $"callerThread={callerThread},handlerThread={owner.Server.StatusThread},callerContext={original.GetType().FullName},handlerContext={owner.Server.StatusContext},postsBeforeStop={recording.PostCount},loopPendingBeforeStop={!loop.IsCompleted}"; + } + finally + { + owner.Server.Stop(); + owner.EndObservation(); + } + yield return Await(loop); + TestContext.WriteLine("Pipeline accept row: " + owner.AcceptDiagnostic); + Assert.That(read.Result, Does.StartWith("HTTP/1.1 200")); + Assert.That(owner.Server.StatusCalls, Is.EqualTo(1)); + Assert.That(owner.Errors, Is.Empty); + Assert.That(owner.Server.IsRunning, Is.False); + Assert.That(owner.PendingCount, Is.Zero); + if (scenario.Kind == "CapturedAccept") + { + Assert.That(1, Is.LessThanOrEqualTo(recording.PostCount)); + Assert.That(owner.Server.StatusThread, Is.EqualTo(callerThread)); + } + else + { + Assert.That(recording.PostCount, Is.Zero); + Assert.That(owner.Server.StatusThread, Is.Not.EqualTo(callerThread)); + Assert.That(owner.Server.StatusContext, Is.EqualTo("null")); + } + } + + private sealed class RecordingContext : SynchronizationContext + { + private readonly SynchronizationContext _target; + private int _posts; + internal int PostCount => Volatile.Read(ref _posts); + + internal RecordingContext(SynchronizationContext target) => _target = target; + + public override void Post(SendOrPostCallback callback, object state) + { + Interlocked.Increment(ref _posts); + _target.Post(callback, state); + } + } + + private static string ObserveResponse(HttpListenerContext context) + { + try + { + return "CanWrite=" + context.Response.OutputStream.CanWrite; + } + catch (Exception error) + { + return error.GetType().FullName + ": " + error.Message; + } + } + + private static IEnumerator Await(Task task) + { + Stopwatch deadline = Stopwatch.StartNew(); + while (!task.IsCompleted) + { + Assert.That( + deadline.ElapsedMilliseconds, + Is.LessThan(5000), + "Owned networking must terminate within the bounded operation deadline." + ); + yield return null; + } + task.GetAwaiter().GetResult(); + } + + private sealed class FixtureServer : BasePipelineServer + { + internal int StatusCalls; + internal int StatusThread; + internal string StatusContext; + internal Action StatusControl; + internal Action CommandMetadataControl; + internal int CommandMetadataCalls; + private readonly DateTime _startedAt = DateTime.UtcNow; + public override DateTime StartedAt + { + get + { + Interlocked.Increment(ref CommandMetadataCalls); + CommandMetadataControl?.Invoke(); + return _startedAt; + } + } + protected override bool WritesDescriptor => false; + + protected override void CreateInstanceDescriptor() => + throw new InvalidOperationException("Fixture descriptor write forbidden."); + + protected override void DeleteInstanceDescriptor() => + throw new InvalidOperationException("Fixture descriptor delete forbidden."); + + protected override void UpdateHeartBeat() { } + + protected override string GetToken() => Token; + + protected override object GetServerStatus() + { + Interlocked.Increment(ref StatusCalls); + StatusControl?.Invoke(); + StatusThread = Thread.CurrentThread.ManagedThreadId; + StatusContext = SynchronizationContext.Current?.GetType().FullName ?? "null"; + return new { fixture = "owned-loopback" }; + } + } + + private sealed class TransportOwner : IDisposable + { + internal readonly HttpListener Listener = new(); + internal readonly TcpClient Client = new(); + internal readonly FixtureServer Server = new(); + internal readonly int Port; + internal readonly List Errors = new(); + internal readonly List AdmissionEvents = new(); + internal readonly List ResponseEvents = new(); + internal HttpListenerContext Context; + internal Case Scenario; + internal string AcceptDiagnostic; + internal string PeerBefore, + PeerAfter, + EndpointError, + ResponseBefore, + ResponseAfter; + internal readonly List _tasks = new(); + internal bool OutsideNUnitDiagnostic; + private readonly object _logGate = new(); + internal int ReleaseCount { get; private set; } + internal bool ObservationAttached { get; private set; } + internal int PendingCount + { + get + { + int pending = 0; + foreach (Task task in _tasks) + if (!task.IsCompleted) + ++pending; + return pending; + } + } + + internal TransportOwner() + { + TcpListener reservation = new(IPAddress.Loopback, 0); + try + { + reservation.Start(); + Port = ((IPEndPoint)reservation.LocalEndpoint).Port; + } + finally + { + reservation.Stop(); + } + try + { + Listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); + Listener.Start(); + Type actual = typeof(BasePipelineServer); + actual + .GetField("m_HttpListener", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(Server, Listener); + actual + .GetField("m_Port", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(Server, Port); + actual + .GetField("m_IsRunning", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(Server, true); + Assert.That(Server.IsRunning, Is.True); + Assert.That(Server.WatchdogEnabled, Is.False); + } + catch + { + Dispose(); + throw; + } + } + + internal T Observe(T task) + where T : Task + { + _tasks.Add(task); + return task; + } + + internal void BeginObservation() + { + Application.logMessageReceivedThreaded += Capture; + ObservationAttached = true; + } + + internal void EndObservation() + { + if (!ObservationAttached) + return; + Application.logMessageReceivedThreaded -= Capture; + ObservationAttached = false; + } + + private void Capture(string text, string stack, LogType kind) + { + if ( + kind == LogType.Log + && text.StartsWith( + "Pipeline: rejected request before peer admission because transport ended:", + StringComparison.Ordinal + ) + ) + { + lock (_logGate) + AdmissionEvents.Add(text + "\n" + stack); + } + if ( + kind == LogType.Log + && text.StartsWith( + "Pipeline: response transport ended during JSON ", + StringComparison.Ordinal + ) + ) + { + lock (_logGate) + ResponseEvents.Add(text + "\n" + stack); + } + if (kind != LogType.Error && kind != LogType.Exception && kind != LogType.Assert) + return; + lock (_logGate) + Errors.Add(kind + ": " + text + "\n" + stack); + } + + internal void ReportTerminalState() + { + EndObservation(); + lock (_logGate) + { + TestContext.WriteLine( + $"Pipeline teardown row: kind={Scenario?.Kind},index={Scenario?.Index},active={Server.IsRunning},peerBefore={PeerBefore},peerAfter={PeerAfter ?? "null"},statusCalls={Server.StatusCalls},errors={Errors.Count},responseBefore={ResponseBefore},responseAfter={ResponseAfter}." + ); + TestContext.WriteLine("Pipeline admission events: " + AdmissionEvents.Count); + foreach (string admission in AdmissionEvents) + TestContext.WriteLine("Pipeline actual admission: " + admission); + TestContext.WriteLine( + "Pipeline accept teardown: " + (AcceptDiagnostic ?? "none") + ); + TestContext.WriteLine( + "Pipeline teardown endpoint: " + (EndpointError ?? "none") + ); + foreach (string error in Errors) + TestContext.WriteLine("Pipeline teardown actual error: " + error); + } + } + + public void Dispose() + { + if (ReleaseCount != 0) + return; + EndObservation(); + Client.Close(); + Server.Stop(); + Listener.Close(); + foreach (Task task in _tasks) + { + if (!task.IsCompleted) + task.Wait(5000); + Assert.That( + task.IsCompleted, + Is.True, + "Disposal must terminate every known task." + ); + if (task.IsFaulted && !OutsideNUnitDiagnostic) + TestContext.WriteLine("Pipeline owned task fault: " + task.Exception); + } + ++ReleaseCount; + } + } + } +} +#endif diff --git a/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs.meta b/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs.meta new file mode 100644 index 000000000..d10c34e54 --- /dev/null +++ b/Tests/Editor/PipelineTransportResearch/PipelineLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ee7a20756314a9fb3484239cd5c0f7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef b/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef new file mode 100644 index 000000000..4872fcdb7 --- /dev/null +++ b/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef @@ -0,0 +1,25 @@ +{ + "name": "WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch", + "rootNamespace": "DxMessaging.Tests.Editor.PipelineTransportResearch", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "WallstopStudios.DxMessaging.Tests.Runtime", + "Unity.Pipeline" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": true, + "defineConstraints": ["UNITY_INCLUDE_TESTS", "DXM_621_PIPELINE_PRESENT"], + "versionDefines": [ + { + "name": "com.unity.pipeline", + "expression": "0.8.0-exp.1", + "define": "DXM_621_PIPELINE_PRESENT" + } + ], + "noEngineReferences": false +} diff --git a/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef.meta b/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef.meta new file mode 100644 index 000000000..45d560c29 --- /dev/null +++ b/Tests/Editor/PipelineTransportResearch/WallstopStudios.DxMessaging.Tests.Editor.PipelineTransportResearch.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c19572681aad4d6ead8008e0ca96ee28 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/runbooks/pipeline-host-repair.md b/docs/runbooks/pipeline-host-repair.md new file mode 100644 index 000000000..4a87b22e2 --- /dev/null +++ b/docs/runbooks/pipeline-host-repair.md @@ -0,0 +1,154 @@ +# Review and retain the Pipeline host repair + +Review [issue #621](https://github.com/Ambiguous-Interactive/DxMessaging/issues/621) before +installing this candidate. Its native controls and affected Unity scopes passed locally; +durable installation and wallstop review remain open. This repair affects the local Unity +verification transport. It is outside the DxMessaging runtime package. + +## Exact inputs + +Use [the retained patch](../../scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch) only with +`com.unity.pipeline` version `0.8.0-exp.1`, repository revision +`dde7080264d32a1091171d4b358351279fa10ddb`. The tested host uses Unity `6000.4.6f1`, +ARM64 Mono. Other package versions and runtimes require separate verification. + +The patch changes only `Runtime/Common/BasePipelineServer.cs`. Admit that file by its +SHA256, preserving its original LF bytes: + +| State | SHA256 | +| ------------------------- | ------------------------------------------------------------------ | +| Original vendor source | `18dbf11eedefbd355cfdb2fb7dcd1342ecd6a404011ee4bf3f0d84ac0c3d3d1f` | +| Complete tested candidate | `3cf8b76e43158994847e993d1fa0cd536b72e53b49bc533105ea4b50d8060a2d` | + +Patch SHA256: `75777c97be7b6871556809b9895ef50e77849a791ef908a6677082b8dc2df4b2`. + +Stop if the source matches neither hash. Preserve unexpected changes for review. Keep +the vendor's license and notices intact. The patch preserves loopback, authentication, +execution admission and scheduling. Unknown peer identity still fails closed; genuine +handler faults remain full Errors. Ended transports retain full diagnostics. + +## Prepare the reviewed installation + +1. Require an inactive Test Framework, stopped Play Mode, no compilation or update, + the main stage, and clean original scenes. Preserve the project manifest, lock file, + package inventory, full source hashes and original vendor source outside `Assets`. +1. Confirm the resolved package identity through Unity's Package Manager. A virtual + `Packages/com.unity.pipeline` path or a successful file read alone does not prove + embedding; record the actual package source and resolved path. +1. Obtain wallstop's review of the patch, native evidence, installation and rollback. +1. Use the Package Manager's **Manage > Customize** operation to embed the admitted + version. Unity documents that an embedded package overrides the cached dependency + and should be tracked with the host project. Preserve existing embedded content; + stop if the destination is already owned by someone else. +1. Compare the embedded package against the recorded inventory. Apart from this one + admitted file, all source and metadata must remain exact. Record any Package Manager + changes to the manifest and lock file for review. + +Follow [Unity's embedding instructions](https://docs.unity.com/en-us/engine/6000.3/manual/packages-list/dependencies-lp/upm-embed). +The tested cache edit is temporary; editing the registry cache does not establish a +durable installation. + +## Keep shipping and transport verification explicit + +Pipeline `0.8.0-exp.1` declares Unity `6000.0` as its minimum editor version. +Do not add it to the general shipping harness, which supports Unity `2021.3`. +The runtime package remains dependency-free. Keep every transport control in +`Tests/Editor/PipelineTransportResearch`; ordinary shipping selection does not run it. + +The assembly selector classifies these controls separately. The composite action +reports their names through `excluded-transport-controls` and a notice when they +are excluded from EditMode. A passing shipping result does not qualify transport. +PlayMode and Standalone cannot select these Editor controls. + +After the reviewed installation, select transport explicitly on the admitted +Unity `6000.4.6f1` ARM64 Mono host. Read its actual editor version and the resolved +package path through Package Manager. Require the exact package revision and +the source hash in this runbook before compilation or execution. A declared +package version alone does not prove the loaded repair. + +From the DxMessaging checkout on that host, pass the observed version and +resolved Pipeline directory through environment variables: + +```powershell +$env:DXM_TRANSPORT_PACKAGE_PATH = $packagePath +$env:DXM_TRANSPORT_UNITY_VERSION = '6000.4.6f1' +node -e "const m=require('./scripts/unity/lib/asmdef-discovery'); console.log(JSON.stringify(m.resolveTestAssemblySelection(process.cwd(), {target:'editmode', includeTransportControls:true, transportPackagePath:process.env.DXM_TRANSPORT_PACKAGE_PATH, unityVersion:process.env.DXM_TRANSPORT_UNITY_VERSION})))" +if ($LASTEXITCODE -ne 0) { throw 'Transport scope admission failed.' } +``` + +The composite action uses `include-transport-controls: true`, `transport-package-path` +and `unity-version` for the same admission. Missing dependencies, a different +package revision, an unqualified editor or an absent fixture fail discovery. +This only verifies selection. Run the full named transport fixture and all retained +endpoint/response controls through the maintained MCP runner. Retain their raw +identities, context release and Error classification separately from the affected +shipping Editor/PlayMode results. Require both scopes and the installation/cleanup +evidence before closing #621. No control is removed or weakened by the opt-in. + +## Apply and verify + +Run these PowerShell commands on the host against the admitted embedded package. +Set `$packagePath` to its resolved filesystem path and `$patchPath` to the patch's +absolute path. Run the source hash check immediately before applying: + +```powershell +$sourcePath = Join-Path $packagePath 'Runtime/Common/BasePipelineServer.cs' +$originalHash = '18dbf11eedefbd355cfdb2fb7dcd1342ecd6a404011ee4bf3f0d84ac0c3d3d1f' +$candidateHash = '3cf8b76e43158994847e993d1fa0cd536b72e53b49bc533105ea4b50d8060a2d' +if ((Get-FileHash -LiteralPath $patchPath -Algorithm SHA256).Hash.ToLowerInvariant() -ne '75777c97be7b6871556809b9895ef50e77849a791ef908a6677082b8dc2df4b2') { + throw 'Pipeline patch hash mismatch.' +} +$actualHash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actualHash -eq $originalHash) { + git -C $packagePath apply --check $patchPath + if ($LASTEXITCODE -ne 0) { throw 'The patch does not match the admitted source.' } + git -C $packagePath apply $patchPath + if ($LASTEXITCODE -ne 0) { throw 'Pipeline patch application failed.' } +} elseif ($actualHash -ne $candidateHash) { + throw 'Unexpected Pipeline source; preserve it for review.' +} +if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $candidateHash) { + throw 'Pipeline candidate hash mismatch.' +} +``` + +Refresh through the connected backend's menu after the safe preflight. Confirm the +loaded package now resolves as embedded, the compiled source hash matches, and +authorized requests still work. Repeat the retained endpoint and response controls +and the affected shipping and optional scopes. Require the same case identities, +zero failures or inconclusive results, matching run GUID/path, both result and cleanup +sidecars done, no unexpected transport Errors and original clean scenes restored. +Keep existing interactive optimization skips explicit; they do not verify release +optimization. Qualify samples in a clean fixture without overwriting user imports. + +Record the host-project revision and package inventory with the evidence. No accepted +durable installation has been executed yet. A successful patch application alone +does not close #621 or establish its full verification criteria. + +## Roll back owned changes + +After the same safe preflight, require the current source to match the candidate hash +and all other package files to match the owned inventory. Preserve later edits rather +than overwriting them. Check and reverse the patch: + +```powershell +if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $candidateHash) { + throw 'Pipeline source changed; preserve it for review.' +} +if ((Get-FileHash -LiteralPath $patchPath -Algorithm SHA256).Hash.ToLowerInvariant() -ne '75777c97be7b6871556809b9895ef50e77849a791ef908a6677082b8dc2df4b2') { + throw 'Pipeline patch hash mismatch.' +} +git -C $packagePath apply --reverse --check $patchPath +if ($LASTEXITCODE -ne 0) { throw 'Pipeline rollback preflight failed.' } +git -C $packagePath apply --reverse $patchPath +if ($LASTEXITCODE -ne 0) { throw 'Pipeline rollback failed.' } +if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $originalHash) { + throw 'Original Pipeline hash was not restored.' +} +``` + +Refresh and verify the original source, resolved package and scene state. Remove an +embedded package only if the whole directory is still owned and byte exact; Unity +then resolves the registry dependency again. Restore an owned cache prototype only +after checking its current hash against the recorded candidate. Do not delete user +changes or replace a whole package to repair one file. diff --git a/docs/runbooks/pipeline-host-repair.md.meta b/docs/runbooks/pipeline-host-repair.md.meta new file mode 100644 index 000000000..b8be1696d --- /dev/null +++ b/docs/runbooks/pipeline-host-repair.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 76823ded8710470a9077fa9237d6aed1 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/asmdef-discovery.test.js b/scripts/__tests__/asmdef-discovery.test.js new file mode 100644 index 000000000..538bee822 --- /dev/null +++ b/scripts/__tests__/asmdef-discovery.test.js @@ -0,0 +1,114 @@ +"use strict"; + +const { test, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { + defaultIncludeAssemblies, + resolveTestAssemblySelection +} = require("../unity/lib/asmdef-discovery"); + +const root = path.resolve(__dirname, "../.."); +const prefix = "WallstopStudios.DxMessaging.Tests."; +const transport = prefix + "Editor.PipelineTransportResearch"; +const integrations = ["Runtime.Reflex", "Runtime.VContainer", "Runtime.Zenject"]; +const dependencyPath = fs.mkdtempSync(path.join(os.tmpdir(), "dxm-pipeline-")); +fs.writeFileSync( + path.join(dependencyPath, "package.json"), + JSON.stringify({ + name: "com.unity.pipeline", + version: "0.8.0-exp.1", + repository: { revision: "dde7080264d32a1091171d4b358351279fa10ddb" } + }) +); +const admitted = { transportPackagePath: dependencyPath, unityVersion: "6000.4.6f1" }; +after(() => fs.rmSync(dependencyPath, { recursive: true, force: true })); +for (const row of [ + { name: "default", options: {}, suffixes: ["Editor", "Runtime"], excluded: true }, + { + name: "shipping with DI", + options: { target: "editmode", includeIntegrations: true }, + suffixes: ["Editor", "Runtime", ...integrations], + excluded: true + }, + { + name: "explicit transport", + options: { target: "editmode", includeTransportControls: true, ...admitted }, + suffixes: ["Editor", "Editor.PipelineTransportResearch", "Runtime"], + excluded: false + }, + ...["playmode", "standalone"].map((target) => ({ + name: target, + options: { target, includeIntegrations: true }, + suffixes: ["Runtime"], + excluded: false + })), + { + name: "legacy runtime-only", + options: { runtimeOnly: true }, + suffixes: ["Runtime"], + excluded: false + } +]) { + test("assembly discovery preserves the declared " + row.name + " scope", () => { + const expected = row.suffixes.map((s) => prefix + s).sort(); + assert.deepEqual(defaultIncludeAssemblies(root, row.options), expected); + const selection = resolveTestAssemblySelection(root, row.options); + assert.deepEqual(selection.assemblies, expected); + assert.deepEqual(selection.excludedTransportControls, row.excluded ? [transport] : []); + }); +} +for (const target of ["playmode", "standalone"]) { + test("explicit transport refuses " + target, () => { + assert.throws( + () => defaultIncludeAssemblies(root, { target, includeTransportControls: true }), + /Transport controls require the EditMode target/ + ); + }); +} +test("explicit transport refuses an absent fixture", () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), "dxm-transport-")); + try { + assert.throws( + () => defaultIncludeAssemblies(empty, { includeTransportControls: true, ...admitted }), + /Transport controls were requested but no compatible owned transport assembly exists/ + ); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } +}); +test("transport selection refuses missing or mismatched host dependencies", () => { + assert.throws( + () => defaultIncludeAssemblies(root, { includeTransportControls: true }), + /resolved Pipeline package path/ + ); + assert.throws( + () => + defaultIncludeAssemblies(root, { + includeTransportControls: true, + ...admitted, + unityVersion: "2021.3.45f1" + }), + /admitted Unity/ + ); + const manifestPath = path.join(dependencyPath, "package.json"); + const original = fs.readFileSync(manifestPath, "utf8"); + try { + for (const override of [ + { name: "other.pipeline" }, + { version: "0.8.1" }, + { repository: { revision: "different-source" } } + ]) { + fs.writeFileSync(manifestPath, JSON.stringify({ ...JSON.parse(original), ...override })); + assert.throws( + () => defaultIncludeAssemblies(root, { includeTransportControls: true, ...admitted }), + /admitted Unity/, + JSON.stringify(override) + ); + } + } finally { + fs.writeFileSync(manifestPath, original); + } +}); diff --git a/scripts/__tests__/asmdef-discovery.test.js.meta b/scripts/__tests__/asmdef-discovery.test.js.meta new file mode 100644 index 000000000..d5525170d --- /dev/null +++ b/scripts/__tests__/asmdef-discovery.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b13b3cb9f7104f34ae70e87a55229a55 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/editor-window-test-host.test.js b/scripts/__tests__/editor-window-test-host.test.js index dfd44a383..6d106ff0a 100644 --- a/scripts/__tests__/editor-window-test-host.test.js +++ b/scripts/__tests__/editor-window-test-host.test.js @@ -9,6 +9,12 @@ const path = require("node:path"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const TEST_HOST_PATH = "Tests/Editor/EditorWindowTestUtility.cs"; +function hasRawWindowClose(content) { + return [...content.matchAll(/\b\w*Window\??\s+(\w+)\b/g)].some((match) => + new RegExp(`\\b${match[1]}\\??\\.Close\\(\\);`).test(content) + ); +} + function extractMethodBody(content, methodName) { const signatureIndex = content.indexOf(` ${methodName}(`); assert.notEqual(signatureIndex, -1, `${methodName} must exist`); @@ -54,7 +60,7 @@ test("editor tests use the stable test host for shown windows", () => { if (/\.Show\(\);/.test(content)) { violations.push(`${relativePath}: use EditorWindowTestUtility.ShowWindow(window)`); } - if (/\.Close\(\);/.test(content)) { + if (hasRawWindowClose(content)) { violations.push(`${relativePath}: use EditorWindowTestUtility.CloseWindow(window)`); } } @@ -66,6 +72,15 @@ test("editor tests use the stable test host for shown windows", () => { ); }); +test("closing guard distinguishes windows from network transports", () => { + assert.equal(hasRawWindowClose("EditorWindow window; window.Close();"), true); + assert.equal(hasRawWindowClose("DxMessagingMonitorWindow? monitor; monitor?.Close();"), true); + assert.equal( + hasRawWindowClose("TcpClient Client; Client.Close(); HttpListener Listener; Listener.Close();"), + false + ); +}); + test("tracked editor-window cleanup avoids the global Resources leak sweep", () => { const content = fs.readFileSync(path.join(REPO_ROOT, TEST_HOST_PATH), "utf8"); const createWindowBody = extractMethodBody(content, "CreateWindow"); diff --git a/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch b/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch new file mode 100644 index 000000000..b96c844c1 --- /dev/null +++ b/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch @@ -0,0 +1,122 @@ +--- a/Runtime/Common/BasePipelineServer.cs ++++ b/Runtime/Common/BasePipelineServer.cs +@@ -528,7 +528,26 @@ + { + try + { +- var remoteAddress = context.Request.RemoteEndPoint?.Address; ++ var request = context.Request ++ ?? throw new InvalidOperationException("Accepted context has no request object."); ++ IPEndPoint remoteEndPoint; ++ try ++ { ++ remoteEndPoint = request.RemoteEndPoint; ++ } ++ catch (Exception endpointError) when ( ++ endpointError is ObjectDisposedException ++ || endpointError is NullReferenceException ++ || (endpointError is System.Net.Sockets.SocketException socketError ++ && socketError.SocketErrorCode == System.Net.Sockets.SocketError.InvalidArgument)) ++ { ++ // Only the endpoint getter is guarded: no handler has been admitted. ++ // Mono's retained context can outlive its socket or its native connection. ++ Debug.Log($"Pipeline: rejected request before peer admission because transport ended: {endpointError}"); ++ context.Response.Abort(); ++ return; ++ } ++ var remoteAddress = remoteEndPoint?.Address; + if (remoteAddress == null || !IPAddress.IsLoopback(remoteAddress)) + { + await SendStatusResponse( +@@ -1107,19 +1126,9 @@ + } + catch (Exception ex) + { +- Debug.LogError($"Failed to handle /api/commands request: {ex.Message}"); +- +- // Return error response +- response.StatusCode = 500; +- response.ContentType = "application/json"; +- +- var errorResponse = new { error = "Internal server error", message = ex.Message }; +- var errorJson = JsonConvert.SerializeObject(errorResponse); +- var errorBuffer = Encoding.UTF8.GetBytes(errorJson); +- response.ContentLength64 = errorBuffer.Length; +- +- await response.OutputStream.WriteAsync(errorBuffer, 0, errorBuffer.Length); +- response.OutputStream.Close(); ++ Debug.LogError($"Failed to handle /api/commands request: {ex}"); ++ await SendJsonPayload(response, ++ new { error = "Internal server error", message = ex.Message }, 500); + } + } + +@@ -1137,20 +1146,57 @@ + } + + /// +- /// Serialize a 200 response body and write it out. +- /// +- private static async Task SendJsonPayload(HttpListenerResponse response, object payload) ++ /// Prepare a JSON body, then own exactly one response at the selected HTTP status. ++ /// ++ private static async Task SendJsonPayload(HttpListenerResponse response, object payload, int statusCode = 200) + { + var json = JsonConvert.SerializeObject(payload, Formatting.Indented); +- +- response.StatusCode = 200; +- response.ContentType = "application/json"; +- + var buffer = Encoding.UTF8.GetBytes(json); +- response.ContentLength64 = buffer.Length; +- +- await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); +- response.OutputStream.Close(); ++ ++ /* ++ * Preparation faults still reach the handler's first error response. Once configuration ++ * begins, this writer owns completion: another response cannot repair committed headers. ++ * Closed response properties and actual stream failures below identify ended transport; ++ * other configuration faults remain full Errors. Only those response operations ++ * are guarded, so handler faults still reach the existing Error path. Abort failures propagate. ++ */ ++ try ++ { ++ response.StatusCode = statusCode; ++ response.ContentType = "application/json"; ++ response.ContentLength64 = buffer.Length; ++ } ++ catch (ObjectDisposedException configurationError) ++ { ++ Debug.Log($"Pipeline: response transport ended during JSON configuration: {configurationError}"); ++ response.Abort(); ++ return; ++ } ++ catch (Exception configurationError) ++ { ++ Debug.LogError($"Pipeline: JSON response configuration failed: {configurationError}"); ++ response.Abort(); ++ return; ++ } ++ ++ try ++ { ++ await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); ++ response.OutputStream.Close(); ++ } ++ catch (Exception writeError) when ( ++ writeError is System.IO.IOException ++ || writeError is ObjectDisposedException ++ || writeError is HttpListenerException) ++ { ++ Debug.Log($"Pipeline: response transport ended during JSON delivery: {writeError}"); ++ response.Abort(); ++ } ++ catch (Exception writeError) ++ { ++ Debug.LogError($"Pipeline: JSON response delivery failed: {writeError}"); ++ response.Abort(); ++ } + } + + /// diff --git a/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch.meta b/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch.meta new file mode 100644 index 000000000..237e97c9f --- /dev/null +++ b/scripts/mcp/pipeline-0.8.0-exp.1-lifecycle.patch.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dfffbb450af1482f8333bcdef77142ff +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib/asmdef-discovery.js b/scripts/unity/lib/asmdef-discovery.js index e788b9736..8be6e96f1 100644 --- a/scripts/unity/lib/asmdef-discovery.js +++ b/scripts/unity/lib/asmdef-discovery.js @@ -1,74 +1,17 @@ "use strict"; -/** - * @file asmdef-discovery.js - * - * Shared, deterministic discovery + classification of Unity test asmdef files. - * - * Used by: - * - .github/actions/compute-unity-assemblies (primary CI consumer) - * - .github/workflows-disabled/unity-tests.yml (customParameters template) - * - * No filesystem mutation. Pure functions only. - * - * Exports: - * - defaultIncludeAssemblies(repoRoot, options?) - * - * The enumeration/classification helpers are module-internal; run this file - * directly (`node scripts/unity/lib/asmdef-discovery.js`) for a self-test - * that prints every discovered asmdef with its classification. - * - * 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 }. - */ +// Deterministic test selection. See the Unity test execution skill for scope and dependency rules. const fs = require("fs"); const path = require("path"); const { walkFiles } = require("../../lib/repo-files"); -/** - * 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 TRANSPORT_NAME_REGEX = /\.PipelineTransportResearch$/; const INTEGRATION_NAME_REGEX = /(?:VContainer|Zenject|Reflex)/; -/** - * Assembly-name prefix that marks an asmdef as owned by DxMessaging. The Unity - * Test Runner is invoked with an explicit `-assemblyNames` list, so a foreign - * test asmdef that happens to live under `Tests/` (for example one pulled in by - * an external comparison package, or a stray sample) must never be added to the - * list -- it would not compile against the harness manifest and would fail the - * run for a reason unrelated to DxMessaging. Every real DxMessaging test - * assembly is named `WallstopStudios.DxMessaging.Tests*`, so this owner prefix - * is a safe, future-proof gate that is a no-op for the current asmdef set. - * - * @type {string} - */ const DXMESSAGING_ASSEMBLY_PREFIX = "WallstopStudios.DxMessaging."; const STANDALONE_PLATFORM_NAMES = new Set([ "Standalone", @@ -78,53 +21,19 @@ const STANDALONE_PLATFORM_NAMES = new Set([ "OSXStandalone" ]); -/** - * True when `name` is a DxMessaging-owned assembly (see - * {@link DXMESSAGING_ASSEMBLY_PREFIX}). Non-string / empty input is treated as - * NOT owned so a malformed asmdef can never slip through the include gate. - * - * @param {string} name - Asmdef assembly name (no extension) - * @returns {boolean} True iff the name carries the DxMessaging owner prefix - */ function isDxMessagingOwnedAssembly(name) { return typeof name === "string" && name.startsWith(DXMESSAGING_ASSEMBLY_PREFIX); } -/** - * 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"; @@ -142,32 +51,13 @@ function classifyAsmdef(name) { return "integration"; } + if (TRANSPORT_NAME_REGEX.test(name)) { + return "transport"; + } + 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" - * @property {boolean} isEditorOnly - True iff includePlatforms is exactly ["Editor"] - * @property {boolean} isForeign - True when the assembly is NOT DxMessaging-owned - * (name lacks the `WallstopStudios.DxMessaging.` prefix). Such - * assemblies are never added to the Unity `-assemblyNames` list. - */ - -/** - * Read an asmdef's `includePlatforms` array and decide whether the assembly is - * editor-only. An assembly is editor-only iff `includePlatforms` is exactly - * `["Editor"]`. Editor-only test assemblies (EditMode suites + Editor - * benchmarks/integrations) cannot run inside a built player, so the standalone - * runtime-only flow must exclude them. - * - * @param {string} asmdefPath - Absolute path to an .asmdef file - * @returns {boolean} True when includePlatforms === ["Editor"] - */ function readAsmdefPlatforms(asmdefPath) { const raw = fs.readFileSync(asmdefPath, "utf8"); const parsed = JSON.parse(raw); @@ -177,12 +67,6 @@ function readAsmdefPlatforms(asmdefPath) { }; } -/** - * @param {string[]} includePlatforms - * @param {string[]} excludePlatforms - * @param {"editmode" | "playmode" | "standalone"} target - * @returns {boolean} - */ function isAsmdefCompatibleWithTarget(includePlatforms, excludePlatforms, target) { const includes = new Set(includePlatforms); const excludes = new Set(excludePlatforms); @@ -222,13 +106,6 @@ function isAsmdefCompatibleWithTarget(includePlatforms, excludePlatforms, target return includes.size === 0 || includes.has("Editor"); } -/** - * 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"); @@ -239,7 +116,6 @@ function enumerateTestAsmdefs(repoRoot) { match: (full, dirent) => dirent.name.endsWith(".asmdef") }); - /** @type {AsmdefEntry[]} */ const entries = asmdefPaths.map((asmdefPath) => { const name = readAsmdefName(asmdefPath); const classification = classifyAsmdef(name); @@ -250,6 +126,7 @@ function enumerateTestAsmdefs(repoRoot) { isPerf: classification === "perf", isComparison: classification === "comparison", isInteg: classification === "integration", + isTransport: classification === "transport", includePlatforms: platforms.includePlatforms, excludePlatforms: platforms.excludePlatforms, isEditorOnly: @@ -263,52 +140,53 @@ function enumerateTestAsmdefs(repoRoot) { } /** - * @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. - * @property {"editmode" | "playmode" | "standalone"} [target=editmode] - * Select assemblies compatible with the Unity test target. - * PlayMode and standalone omit editor-only asmdefs. - * @property {boolean} [runtimeOnly=false] Back-compat alias for - * target: "standalone". Applied before the perf/comparison/ - * integration gating so it composes. + * Resolve selected assemblies and explicitly excluded Editor transport controls. + * Dependency and host admission: docs/runbooks/pipeline-host-repair.md. + * Transport selection also requires transportPackagePath and the observed unityVersion. + * target defaults to editmode; runtimeOnly retains its standalone alias. + * @returns {{assemblies: string[], excludedTransportControls: string[]}} */ - -/** - * 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) { +function resolveTestAssemblySelection(repoRoot, options) { const opts = options || {}; const includePerf = opts.includePerf === true; const includeComparisons = opts.includeComparisons === true; const includeIntegrations = opts.includeIntegrations === true; + const includeTransportControls = opts.includeTransportControls === true; const target = opts.target || (opts.runtimeOnly === true ? "standalone" : "editmode"); - - return enumerateTestAsmdefs(repoRoot) + if (includeTransportControls && target !== "editmode") { + throw new Error("Transport controls require the EditMode target."); + } + if (includeTransportControls) { + if (typeof opts.transportPackagePath !== "string" || !opts.transportPackagePath) { + throw new Error("Transport controls require the resolved Pipeline package path."); + } + const dependency = JSON.parse( + fs.readFileSync(path.join(opts.transportPackagePath, "package.json"), "utf8") + ); + if ( + opts.unityVersion !== "6000.4.6f1" || + dependency.name !== "com.unity.pipeline" || + dependency.version !== "0.8.0-exp.1" || + dependency.repository?.revision !== "dde7080264d32a1091171d4b358351279fa10ddb" + ) { + throw new Error( + "Transport controls require the admitted Unity 6000.4.6f1 / Pipeline 0.8.0-exp.1 host." + ); + } + } + const entries = enumerateTestAsmdefs(repoRoot).filter( + (entry) => + !entry.isForeign && + isAsmdefCompatibleWithTarget(entry.includePlatforms, entry.excludePlatforms, target) + ); + const transport = entries.filter((entry) => entry.isTransport).map((entry) => entry.name); + if (includeTransportControls && transport.length === 0) { + throw new Error( + "Transport controls were requested but no compatible owned transport assembly exists." + ); + } + const assemblies = entries .filter((entry) => { - // Foreign (non-DxMessaging-owned) asmdefs are never added to the Unity - // -assemblyNames list: they would not compile against the harness - // manifest and would fail the run for a reason unrelated to DxMessaging. - // Gated first, ahead of every other decision. A no-op for the current - // asmdef set (all entries are DxMessaging-owned). - if (entry.isForeign) { - return false; - } - if (!isAsmdefCompatibleWithTarget(entry.includePlatforms, entry.excludePlatforms, target)) { - return false; - } if (entry.isPerf) { return includePerf; } @@ -318,51 +196,32 @@ function defaultIncludeAssemblies(repoRoot, options) { if (entry.isInteg) { return includeIntegrations; } + if (entry.isTransport) { + return includeTransportControls; + } return true; }) .map((entry) => entry.name); + return { assemblies, excludedTransportControls: includeTransportControls ? [] : transport }; +} + +// Preserve the array API for existing callers; CI also records excluded transport controls. +function defaultIncludeAssemblies(repoRoot, options) { + return resolveTestAssemblySelection(repoRoot, options).assemblies; } -// Only defaultIncludeAssemblies has external consumers -// (compute-unity-assemblies/action.yml). The other helpers are internal; the -// self-test block below uses them directly. module.exports = { - defaultIncludeAssemblies + defaultIncludeAssemblies, + resolveTestAssemblySelection }; 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); - - 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`); - } - - // Diagnostic: runtime-only include list (used by the standalone player flow, - // where EditMode/editor-only asmdefs cannot run). - const runtimeInclude = defaultIncludeAssemblies(repoRoot, { target: "standalone" }); - process.stdout.write( - `\nruntime-only include (${runtimeInclude.length}, drops editor-only asmdefs):\n` - ); - for (const name of runtimeInclude) { - process.stdout.write(` * ${name}\n`); - } + const report = { + repoRoot, + discovered: enumerateTestAsmdefs(repoRoot), + shipping: resolveTestAssemblySelection(repoRoot), + standalone: resolveTestAssemblySelection(repoRoot, { target: "standalone" }) + }; + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); } diff --git a/scripts/wiki/__tests__/transform-cases.json b/scripts/wiki/__tests__/transform-cases.json index 45cec3592..337d84ebf 100644 --- a/scripts/wiki/__tests__/transform-cases.json +++ b/scripts/wiki/__tests__/transform-cases.json @@ -36,6 +36,18 @@ ["..\\concepts\\message-types.md", "getting-started\\a.md", "Concepts-Message-Types"] ], "lineTransforms": [ + [ + "[Patch](../scripts/mcp/repair.patch)", + "[Patch](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/scripts/mcp/repair.patch)" + ], + [ + "[Data](reference/data.json?raw=1#row)", + "[Data](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/docs/reference/data.json?raw=1#row)" + ], + [ + "[File](../scripts/My%20File.txt)", + "[File](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/scripts/My%20File.txt)" + ], ["[Self]()", "[Self]()"], ["[Section](#section)", "[Section](#section)"], ["[GitHub](https://github.com)", "[GitHub](https://github.com)"], diff --git a/scripts/wiki/transform-docs-to-wiki.js b/scripts/wiki/transform-docs-to-wiki.js index 70f56744d..6d0cb673e 100644 --- a/scripts/wiki/transform-docs-to-wiki.js +++ b/scripts/wiki/transform-docs-to-wiki.js @@ -239,7 +239,20 @@ function transformLine(line, currentFilePath) { const queryIndex = href.indexOf("?"); if (queryIndex !== -1) href = href.substring(0, queryIndex); - const wikiPage = docsPathToWikiPage(resolveDocsLink(href, currentFilePath)); + const target = resolveDocsLink(href, currentFilePath); + if (path.posix.extname(target) && !/\.md$/i.test(target)) { + const repoPath = path.posix.normalize(path.posix.join("docs", target)); + const suffix = link.href.match(/[?#].*$/)?.[0] || ""; + const url = + new URL(repoPath, "https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/") + .href + suffix; + result = + result.substring(0, link.index) + + `[${link.text}](${url})` + + result.substring(link.index + link.match.length); + continue; + } + const wikiPage = docsPathToWikiPage(target); let wikiLink; if (anchor) { From cf3328a4769389c9e5511d92e207e9073c34966a Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 9 Oct 2026 21:52:08 +0000 Subject: [PATCH 018/147] Retain child-tree fixture exit and PID diagnostics --- scripts/unity/__tests__/editor-process-watchdog.test.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 index fa84c6023..9391087c3 100644 --- a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 +++ b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 @@ -67,7 +67,9 @@ Wait-Event -Timeout 30 | Out-Null -Arguments @('-NoLogo', '-NoProfile', '-File', $childScript, '-PidPath', $treePidPath) ` -Label 'shutdown process tree' -LogPath $treeLog -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 $treeText = Get-Content -LiteralPath $treeLog -Raw - if ($treeExit -ne 124 -or $treeText -notmatch 'child-pid=(\d+)') { throw 'Child-tree fixture did not run.' } + if ($treeExit -ne 124 -or $treeText -notmatch 'child-pid=(\d+)') { + throw "Child-tree fixture did not run: exit=$treeExit; pidFileExists=$(Test-Path -LiteralPath $treePidPath); log=$treeText" + } $childProcess = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue if ($childProcess) { try { From 58b7a902b9d44db8ad39cf4ed1f43b2addac1df5 Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 9 Oct 2026 22:10:15 +0000 Subject: [PATCH 019/147] Preserve window-close guard coverage for inferred receivers --- .../__tests__/editor-window-test-host.test.js | 39 ++++++++++++++----- scripts/unity/lib/asmdef-discovery.js | 37 ++++-------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/scripts/__tests__/editor-window-test-host.test.js b/scripts/__tests__/editor-window-test-host.test.js index 6d106ff0a..517de0c26 100644 --- a/scripts/__tests__/editor-window-test-host.test.js +++ b/scripts/__tests__/editor-window-test-host.test.js @@ -10,9 +10,19 @@ const REPO_ROOT = path.resolve(__dirname, "..", ".."); const TEST_HOST_PATH = "Tests/Editor/EditorWindowTestUtility.cs"; function hasRawWindowClose(content) { - return [...content.matchAll(/\b\w*Window\??\s+(\w+)\b/g)].some((match) => - new RegExp(`\\b${match[1]}\\??\\.Close\\(\\);`).test(content) + const transports = new Set( + [...content.matchAll(/\b(?:TcpClient|HttpListener)\??\s+(\w+)\b/g)].map((match) => match[1]) ); + const contexts = new Set( + [...content.matchAll(/\bHttpListenerContext\??\s+(\w+)\b/g)].map((match) => match[1]) + ); + return [...content.matchAll(/(\w+(?:\??\.\w+)*)?\??\.Close\(\);/g)].some((match) => { + const receiver = (match[1] || "").split(/\??\./); + return ( + !transports.has(receiver.at(-1)) && + !(receiver.at(-1) === "Response" && contexts.has(receiver.at(-2))) + ); + }); } function extractMethodBody(content, methodName) { @@ -72,13 +82,24 @@ test("editor tests use the stable test host for shown windows", () => { ); }); -test("closing guard distinguishes windows from network transports", () => { - assert.equal(hasRawWindowClose("EditorWindow window; window.Close();"), true); - assert.equal(hasRawWindowClose("DxMessagingMonitorWindow? monitor; monitor?.Close();"), true); - assert.equal( - hasRawWindowClose("TcpClient Client; Client.Close(); HttpListener Listener; Listener.Close();"), - false - ); +test("closing guard distinguishes windows from typed network transports", () => { + for (const content of [ + "EditorWindow window; window.Close();", + "DxMessagingMonitorWindow? monitor; monitor?.Close();", + "var window = CreateWindow(); window.Close();", + "windows[0].Close();", + "GetWindow().Close();", + "owner.Window.Close();", + "Response.Close();", + "owner.Context.Response.Close();", + "TcpClient Client; Client.Close(); var window = CreateWindow(); window.Close();" + ]) + assert.equal(hasRawWindowClose(content), true, content); + for (const content of [ + "TcpClient Client; owner.Client.Close(); HttpListener Listener; Listener.Close();", + "HttpListenerContext Context; owner.Context.Response.Close();" + ]) + assert.equal(hasRawWindowClose(content), false, content); }); test("tracked editor-window cleanup avoids the global Resources leak sweep", () => { diff --git a/scripts/unity/lib/asmdef-discovery.js b/scripts/unity/lib/asmdef-discovery.js index 8be6e96f1..b15139ff3 100644 --- a/scripts/unity/lib/asmdef-discovery.js +++ b/scripts/unity/lib/asmdef-discovery.js @@ -4,7 +4,6 @@ const fs = require("fs"); const path = require("path"); - const { walkFiles } = require("../../lib/repo-files"); const PERF_NAME_REGEX = /(?:Benchmarks|Allocations)/; @@ -35,26 +34,14 @@ function readAsmdefName(asmdefPath) { } 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"; + for (const [pattern, classification] of [ + [PERF_NAME_REGEX, "perf"], + [COMPARISON_NAME_REGEX, "comparison"], + [INTEGRATION_NAME_REGEX, "integration"], + [TRANSPORT_NAME_REGEX, "transport"] + ]) { + if (typeof name === "string" && pattern.test(name)) return classification; } - - if (INTEGRATION_NAME_REGEX.test(name)) { - return "integration"; - } - - if (TRANSPORT_NAME_REGEX.test(name)) { - return "transport"; - } - return "core"; } @@ -129,8 +116,6 @@ function enumerateTestAsmdefs(repoRoot) { isTransport: classification === "transport", includePlatforms: platforms.includePlatforms, excludePlatforms: platforms.excludePlatforms, - isEditorOnly: - platforms.includePlatforms.length === 1 && platforms.includePlatforms[0] === "Editor", isForeign: !isDxMessagingOwnedAssembly(name) }; }); @@ -139,13 +124,7 @@ function enumerateTestAsmdefs(repoRoot) { return entries; } -/** - * Resolve selected assemblies and explicitly excluded Editor transport controls. - * Dependency and host admission: docs/runbooks/pipeline-host-repair.md. - * Transport selection also requires transportPackagePath and the observed unityVersion. - * target defaults to editmode; runtimeOnly retains its standalone alias. - * @returns {{assemblies: string[], excludedTransportControls: string[]}} - */ +// Scope and observed-host admission: docs/runbooks/pipeline-host-repair.md. function resolveTestAssemblySelection(repoRoot, options) { const opts = options || {}; const includePerf = opts.includePerf === true; From fd597ea4fbfa7e8cef49ee118ae3008f72610f8f Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 9 Oct 2026 23:38:55 +0000 Subject: [PATCH 020/147] Bind editor cleanup guard to actual Close methods --- .docs-tests/EditorWindowCleanupTests.cs | 277 ++++++++++++++++++ .../__tests__/editor-window-test-host.test.js | 39 --- 2 files changed, 277 insertions(+), 39 deletions(-) create mode 100644 .docs-tests/EditorWindowCleanupTests.cs diff --git a/.docs-tests/EditorWindowCleanupTests.cs b/.docs-tests/EditorWindowCleanupTests.cs new file mode 100644 index 000000000..02a146ba4 --- /dev/null +++ b/.docs-tests/EditorWindowCleanupTests.cs @@ -0,0 +1,277 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.Docs.Tests; + +/// +/// 2026-10-09: a file-wide network receiver-name exemption hid window closures +/// when another scope, lambda parameter, or anonymous member reused that name. +/// Bind the called method instead. Unresolved Close calls remain rejected. +/// +[TestFixture] +internal sealed class EditorWindowCleanupTests +{ + private static readonly MetadataReference[] References = ( + (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") + ?? throw new InvalidOperationException("Runtime reference assemblies are unavailable.") + ) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)) + .ToArray(); + + private static IEnumerable ReceiverCases() + { + yield return Case("window", "EditorWindow window = new();", "window.Close();", true); + yield return Case( + "nullable", + "DxMessagingMonitorWindow? monitor = new();", + "monitor?.Close();", + true + ); + yield return Case("inferred", "var window = GetWindow();", "window.Close();", true); + yield return Case( + "array", + "EditorWindow[] windows = { new() };", + "windows[0].Close();", + true + ); + yield return Case("chained", "", "GetWindow().Close();", true); + yield return Case( + "member", + "var owner = new { Window = GetWindow() };", + "owner.Window.Close();", + true + ); + yield return Case("unknown-response", "", "Response.Close();", true); + yield return Case("unknown-context", "", "owner.Context.Response.Close();", true); + yield return Case( + "mixed", + "TcpClient Client = new(); var window = GetWindow();", + "Client.Close(); window.Close();", + true + ); + yield return Case( + "typed-shadow", + "{ TcpClient client = new(); }", + "{ EditorWindow client = GetWindow(); client.Close(); }", + true + ); + yield return Case( + "inferred-shadow", + "{ TcpClient client = new(); }", + "{ var client = GetWindow(); client.Close(); }", + true + ); + yield return Case( + "context-shadow", + "{ HttpListenerContext ctx = null!; }", + "{ WindowContext ctx = new(); ctx.Response.Close(); }", + true + ); + yield return Case( + "lambda-shadow", + "TcpClient Client = new();", + "Action cleanup = Client => Client.Close();", + true + ); + yield return Case( + "anonymous-shadow", + "TcpClient Client = new();", + "var owner = new { Client = GetWindow() }; owner.Client.Close();", + true + ); + yield return Case( + "network", + "TcpClient Client = new(); HttpListener Listener = new();", + "Client.Close(); Listener.Close();", + false + ); + yield return Case( + "network-member", + "NetworkOwner owner = new();", + "owner.Client.Close();", + false + ); + yield return Case( + "network-context", + "NetworkOwner owner = new();", + "owner.Context.Response.Close();", + false + ); + yield return Case( + "qualified-network", + "System.Net.Sockets.TcpClient Client = new();", + "Client.Close();", + false + ); + yield return Case( + "network-alias", + "NetworkClient Client = new();", + "Client.Close();", + false + ); + yield return Case( + "nullable-network", + "TcpClient? Client = new();", + "Client?.Close();", + false + ); + yield return Case( + "custom-network-name", + "Pretend.TcpClient Client = new();", + "Client.Close();", + true + ); + yield return Case("whitespace", "EditorWindow window = new();", "window.Close ( );", true); + } + + private static TestCaseData Case( + string name, + string declarations, + string body, + bool rejected + ) => new TestCaseData(declarations, body, rejected).SetName($"CloseReceiver_{name}"); + + [TestCaseSource(nameof(ReceiverCases))] + public void CleanupRejectsWindowAndUnresolvedCalls( + string declarations, + string body, + bool rejected + ) + { + SyntaxTree tree = CSharpSyntaxTree.ParseText( + "using System; using System.Net; using System.Net.Sockets; using UnityEditor; " + + "using NetworkClient = System.Net.Sockets.TcpClient; " + + "namespace UnityEditor { class EditorWindow { public void Close() { } } } " + + "namespace Pretend { class TcpClient { public void Close() { } } } " + + "class DxMessagingMonitorWindow : EditorWindow { } " + + "class WindowContext { public EditorWindow Response = new(); } " + + "class NetworkOwner { public TcpClient Client = new(); public HttpListenerContext Context = null!; } " + + "class Fixture { static EditorWindow GetWindow() => new(); void Run() { " + + declarations + + body + + " } }" + ); + CSharpCompilation compilation = Compile(new[] { tree }); + bool actual = RejectedCalls(compilation, tree).Any(); + Assert.That(actual, Is.EqualTo(rejected), $"declarations={declarations}; body={body}"); + if ( + !body.Contains("Response.Close();", StringComparison.Ordinal) + || 0 < declarations.Length + ) + { + Assert.That( + compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Known receiver controls must be valid C#; only the two explicit unresolved controls may lack context." + ); + } + } + + [TestCase(false)] + [TestCase(true)] + public void EditorSourcesUseTheOwnedWindowCleanup(bool newerEditorSymbols) + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "package.json"))) + { + directory = directory.Parent; + } + Assert.That(directory, Is.Not.Null, "Repository root must be found."); + string root = directory!.FullName; + string[] symbols = newerEditorSymbols + ? new[] + { + "UNITY_EDITOR", + "UNITY_INCLUDE_TESTS", + "DXM_621_PIPELINE_PRESENT", + "UNITY_2021_3_OR_NEWER", + "UNITY_2022_3_OR_NEWER", + "UNITY_6000_0_OR_NEWER", + "UNITY_6000_3_OR_NEWER", + "UNITY_6000_4_OR_NEWER", + "UNITY_6000_5_OR_NEWER", + } + : new[] + { + "UNITY_EDITOR", + "UNITY_INCLUDE_TESTS", + "DXM_621_PIPELINE_PRESENT", + "UNITY_2021_3_OR_NEWER", + }; + CSharpParseOptions options = new(preprocessorSymbols: symbols); + SyntaxTree[] trees = Directory + .EnumerateFiles( + Path.Combine(root, "Tests", "Editor"), + "*.cs", + SearchOption.AllDirectories + ) + .Where(path => + path != Path.Combine(root, "Tests", "Editor", "EditorWindowTestUtility.cs") + ) + .Select(path => CSharpSyntaxTree.ParseText(File.ReadAllText(path), options, path)) + .ToArray(); + CSharpCompilation compilation = Compile(trees); + string[] rejected = trees.SelectMany(tree => RejectedCalls(compilation, tree)).ToArray(); + Assert.That( + rejected, + Is.Empty, + "Use EditorWindowTestUtility.CloseWindow(window); unresolved Close targets also fail closed." + ); + Assert.That( + trees + .SelectMany(tree => + tree.GetRoot().DescendantNodes().OfType() + ) + .Count(IsCloseCall), + Is.GreaterThan(0), + "The admitted transport control must be active; an empty conditional source cannot qualify the guard." + ); + } + + private static CSharpCompilation Compile(IEnumerable trees) => + CSharpCompilation.Create( + "EditorWindowCleanupGuard", + trees, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + private static bool IsCloseCall(InvocationExpressionSyntax call) => + call.ArgumentList.Arguments.Count == 0 + && call.Expression switch + { + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText == "Close", + MemberBindingExpressionSyntax member => member.Name.Identifier.ValueText == "Close", + _ => false, + }; + + private static IEnumerable RejectedCalls(CSharpCompilation compilation, SyntaxTree tree) + { + SemanticModel model = compilation.GetSemanticModel(tree); + foreach ( + InvocationExpressionSyntax call in tree.GetRoot() + .DescendantNodes() + .OfType() + .Where(IsCloseCall) + ) + { + IMethodSymbol? method = model.GetSymbolInfo(call).Symbol as IMethodSymbol; + string? type = method?.ContainingType.ToDisplayString(); + bool network = + type + is "System.Net.Sockets.TcpClient" + or "System.Net.HttpListener" + or "System.Net.HttpListenerResponse" + && method?.ContainingAssembly.Name + is "System.Net.Sockets" + or "System.Net.HttpListener"; + if (!network) + { + yield return $"{tree.FilePath}:{call.GetLocation().GetLineSpan().StartLinePosition.Line + 1}: {call}"; + } + } + } +} diff --git a/scripts/__tests__/editor-window-test-host.test.js b/scripts/__tests__/editor-window-test-host.test.js index 517de0c26..02ddd3644 100644 --- a/scripts/__tests__/editor-window-test-host.test.js +++ b/scripts/__tests__/editor-window-test-host.test.js @@ -9,22 +9,6 @@ const path = require("node:path"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const TEST_HOST_PATH = "Tests/Editor/EditorWindowTestUtility.cs"; -function hasRawWindowClose(content) { - const transports = new Set( - [...content.matchAll(/\b(?:TcpClient|HttpListener)\??\s+(\w+)\b/g)].map((match) => match[1]) - ); - const contexts = new Set( - [...content.matchAll(/\bHttpListenerContext\??\s+(\w+)\b/g)].map((match) => match[1]) - ); - return [...content.matchAll(/(\w+(?:\??\.\w+)*)?\??\.Close\(\);/g)].some((match) => { - const receiver = (match[1] || "").split(/\??\./); - return ( - !transports.has(receiver.at(-1)) && - !(receiver.at(-1) === "Response" && contexts.has(receiver.at(-2))) - ); - }); -} - function extractMethodBody(content, methodName) { const signatureIndex = content.indexOf(` ${methodName}(`); assert.notEqual(signatureIndex, -1, `${methodName} must exist`); @@ -70,9 +54,6 @@ test("editor tests use the stable test host for shown windows", () => { if (/\.Show\(\);/.test(content)) { violations.push(`${relativePath}: use EditorWindowTestUtility.ShowWindow(window)`); } - if (hasRawWindowClose(content)) { - violations.push(`${relativePath}: use EditorWindowTestUtility.CloseWindow(window)`); - } } assert.deepEqual( @@ -82,26 +63,6 @@ test("editor tests use the stable test host for shown windows", () => { ); }); -test("closing guard distinguishes windows from typed network transports", () => { - for (const content of [ - "EditorWindow window; window.Close();", - "DxMessagingMonitorWindow? monitor; monitor?.Close();", - "var window = CreateWindow(); window.Close();", - "windows[0].Close();", - "GetWindow().Close();", - "owner.Window.Close();", - "Response.Close();", - "owner.Context.Response.Close();", - "TcpClient Client; Client.Close(); var window = CreateWindow(); window.Close();" - ]) - assert.equal(hasRawWindowClose(content), true, content); - for (const content of [ - "TcpClient Client; owner.Client.Close(); HttpListener Listener; Listener.Close();", - "HttpListenerContext Context; owner.Context.Response.Close();" - ]) - assert.equal(hasRawWindowClose(content), false, content); -}); - test("tracked editor-window cleanup avoids the global Resources leak sweep", () => { const content = fs.readFileSync(path.join(REPO_ROOT, TEST_HOST_PATH), "utf8"); const createWindowBody = extractMethodBody(content, "CreateWindow"); From 92b666946856a328a4f8566447e7ae7672ff5556 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 10 Oct 2026 00:51:22 +0000 Subject: [PATCH 021/147] Retain complete bounded native disassembly ranges --- scripts/unity/capture-dispatch-codegen.ps1 | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/unity/capture-dispatch-codegen.ps1 b/scripts/unity/capture-dispatch-codegen.ps1 index c8d5da7e6..068b9646d 100644 --- a/scripts/unity/capture-dispatch-codegen.ps1 +++ b/scripts/unity/capture-dispatch-codegen.ps1 @@ -1187,7 +1187,7 @@ function Add-NativeLayoutInventory { '^[ \t]*(?i:{0}):' -f [regex]::Escape($addressHex) $addressMatches = @( $rangeOutput | - Select-String -Pattern $addressPattern -CaseSensitive -Context 2, 80 + Select-String -Pattern $addressPattern -CaseSensitive ) if ($addressMatches.Count -ne 1) { throw ( @@ -1201,6 +1201,7 @@ function Add-NativeLayoutInventory { Match = $addressMatches[0] Label = $addressTargetsByHex[$addressHex] -join ',' Range = $rangeArgument + Lines = $rangeOutput } ) } @@ -1261,12 +1262,11 @@ function Add-NativeLayoutInventory { "matchKind=address matchLabel=$($capturedMatch.Label) " + "range=$($capturedMatch.Range) line=$($match.LineNumber) path=$($match.Path)" ) - foreach ($contextLine in @($match.Context.PreContext)) { - $nativeEvidence.Add(" $contextLine") - } - $nativeEvidence.Add("> $($match.Line)") - foreach ($contextLine in @($match.Context.PostContext)) { - $nativeEvidence.Add(" $contextLine") + # 2026-10-10: a second line-count cap discarded instructions inside the + # mapped source extent. Keep every line of the already byte-bounded range. + for ($rangeLineIndex = 0; $rangeLineIndex -lt $capturedMatch.Lines.Count; $rangeLineIndex++) { + $prefix = if (($rangeLineIndex + 1) -eq $match.LineNumber) { '> ' } else { ' ' } + $nativeEvidence.Add("$prefix$($capturedMatch.Lines[$rangeLineIndex])") } } $nativeDisassemblyPath = Join-Path $ArtifactsRoot 'native-disassembly.txt' @@ -1848,6 +1848,10 @@ if ($SelfTestOnly) { ' if ($behavior -eq "ambiguous-inline-proof-dumpbin") {', ' Write-SyntheticAddress ([uint64]0x180001040) "call InterceptorCache_1_RebuildFlat_mD007_gshared"', ' }', + ' for ($address = [uint64]0x180001050; $address -lt [uint64]0x1800010F0; $address++) {', + ' Write-SyntheticAddress $address "nop"', + ' }', + ' Write-SyntheticAddress ([uint64]0x180001100) "mov eax, 99"', ' exit 0', '}', 'exit 0' @@ -1922,7 +1926,8 @@ if ($SelfTestOnly) { '> 0000000180001010:', '> 0000000180001020:', 'range=/range:0x0000000180001000,0x0000000180001400', - 'mov eax, 3' + 'mov eax, 3', + '0000000180001100: mov eax, 99' ) ) { if (!$nativeDisassembly.Contains($expectedDisassemblyEvidence)) { From b1416918caea3f618b69476baf2102febc585c12 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 10 Oct 2026 01:14:21 +0000 Subject: [PATCH 022/147] Retain native binary input hashes in codegen evidence --- scripts/unity/capture-dispatch-codegen.ps1 | 44 ++++++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/unity/capture-dispatch-codegen.ps1 b/scripts/unity/capture-dispatch-codegen.ps1 index 068b9646d..409e79fd9 100644 --- a/scripts/unity/capture-dispatch-codegen.ps1 +++ b/scripts/unity/capture-dispatch-codegen.ps1 @@ -1005,6 +1005,11 @@ function Add-NativeLayoutInventory { $inventory.Add('gameAssemblyPdbPath:') $inventory.Add("matchedGameAssemblyPdb=$matchedPdbFullName") $inventory.Add("matchedGameAssemblyPdbBytes=$($matchedPdb.Length)") + # Exact input digests distinguish native evidence when paths and sizes are unchanged. + $gameAssemblySha256 = (Get-FileHash -LiteralPath $gameAssemblies[0].FullName -Algorithm SHA256).Hash + $matchedPdbSha256 = (Get-FileHash -LiteralPath $matchedPdbFullName -Algorithm SHA256).Hash + $inventory.Add("gameAssemblySha256=$gameAssemblySha256") + $inventory.Add("matchedGameAssemblyPdbSha256=$matchedPdbSha256") foreach ($line in $pdbPathOutput) { $inventory.Add("$line") } @@ -1210,6 +1215,8 @@ function Add-NativeLayoutInventory { $nativeEvidence = [System.Collections.Generic.List[string]]::new() $nativeEvidence.Add("gameAssembly=$($gameAssemblies[0].FullName)") $nativeEvidence.Add("matchedGameAssemblyPdb=$matchedPdbFullName") + $nativeEvidence.Add("gameAssemblySha256=$gameAssemblySha256") + $nativeEvidence.Add("matchedGameAssemblyPdbSha256=$matchedPdbSha256") $nativeEvidence.Add("selectedDumpbin=$($selectedDumpbin.FullName)") $nativeEvidence.Add("disassemblyLineCount=$disassemblyLineCount") $nativeEvidence.Add("rangeInvocationCount=$($addressTargetsByHex.Count)") @@ -1647,14 +1654,17 @@ if ($SelfTestOnly) { $playerDir = Join-Path $integrationTestRoot 'Build\DxmTestPlayer' $backupDir = Join-Path $playerDir 'DxmTestPlayer_BackUpThisFolder_ButDontShipItWithYourGame' New-Item -ItemType Directory -Path $backupDir | Out-Null - 'fake native image' | Set-Content -LiteralPath (Join-Path $playerDir 'GameAssembly.dll') + [System.IO.File]::WriteAllBytes( + (Join-Path $playerDir 'GameAssembly.dll'), + [byte[]]@(1, 2, 3, 4) + ) $primarySymbolMapPath = Join-Path $backupDir 'SymbolMap' @( '0000000000001000 16 OtherSymbol', '0000000000002000 32 InventoryProbeSymbol' ) | Set-Content -LiteralPath $primarySymbolMapPath $gameAssemblyPdbPath = Join-Path $backupDir 'GameAssembly.pdb' - 'fake pdb' | Set-Content -LiteralPath $gameAssemblyPdbPath + [System.IO.File]::WriteAllBytes($gameAssemblyPdbPath, [byte[]]@(5, 6, 7, 8)) 'fake object' | Set-Content -LiteralPath (Join-Path $integrationCppRoot 'gEnErAtEd.cpp.obj') $nativeProbeMethod = [pscustomobject]@{ Label = 'Native source-line probe' @@ -1877,6 +1887,8 @@ if ($SelfTestOnly) { -Raw foreach ( $expectedInventoryEvidence in @( + 'gameAssemblySha256=9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A', + 'matchedGameAssemblyPdbSha256=55E5509F8052998294266EE5B50CB592938191FB5D67F73CAC2E60B0276B1BDD', 'symbolMapCount=1', 'symbolMapMatchCount=1', 'InventoryProbeSymbol', @@ -1905,6 +1917,8 @@ if ($SelfTestOnly) { -Raw foreach ( $expectedDisassemblyEvidence in @( + 'gameAssemblySha256=9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A', + 'matchedGameAssemblyPdbSha256=55E5509F8052998294266EE5B50CB592938191FB5D67F73CAC2E60B0276B1BDD', 'nativeAddressLineMatchCount=3', 'rangeInvocationCount=3', 'maximumRangeBytesAfterAddress=1024', @@ -2055,6 +2069,12 @@ if ($SelfTestOnly) { } } + # Same paths and lengths must identify different replacement bytes. + [System.IO.File]::WriteAllBytes( + (Join-Path $playerDir 'GameAssembly.dll'), + [byte[]]@(4, 3, 2, 1) + ) + [System.IO.File]::WriteAllBytes($gameAssemblyPdbPath, [byte[]]@(8, 7, 6, 5)) Remove-Item -LiteralPath $primarySymbolMapPath Add-NativeLayoutInventory ` -ProjectRoot $integrationTestRoot ` @@ -2068,6 +2088,10 @@ if ($SelfTestOnly) { -Raw foreach ( $expectedNoSymbolMapEvidence in @( + 'gameAssemblyBytes=4', + 'matchedGameAssemblyPdbBytes=4', + 'gameAssemblySha256=EE10DA4AEFE61A37DF1DEE937CA3221AFA3B2351F9EA34EDBBB769573C6785F7', + 'matchedGameAssemblyPdbSha256=952B50FD4FE30AEE9420F479FF3F4C6268F2865EE65A82E1F8E157ABC1455272', 'symbolMapCount=0', 'symbolMap=absent', 'symbolMapMatchCount=0', @@ -2084,6 +2108,18 @@ if ($SelfTestOnly) { } } + $replacementDisassembly = Get-Content ` + -LiteralPath (Join-Path $integrationArtifacts 'native-disassembly.txt') ` + -Raw + foreach ($replacementDigest in @( + 'gameAssemblySha256=EE10DA4AEFE61A37DF1DEE937CA3221AFA3B2351F9EA34EDBBB769573C6785F7', + 'matchedGameAssemblyPdbSha256=952B50FD4FE30AEE9420F479FF3F4C6268F2865EE65A82E1F8E157ABC1455272' + )) { + if (!$replacementDisassembly.Contains($replacementDigest)) { + throw "Replacement native disassembly omitted '$replacementDigest'." + } + } + $missingNestedNativeProbeMethod = [pscustomobject]@{ Label = 'Missing nested native source-line probe' Path = $integrationCppPath @@ -2289,7 +2325,7 @@ if ($SelfTestOnly) { -DumpbinPaths @($fakeDumpbin) ` -NativeLineReader $fakeNativeLineReader } - 'fake pdb' | Set-Content -LiteralPath $gameAssemblyPdbPath + [System.IO.File]::WriteAllBytes($gameAssemblyPdbPath, [byte[]]@(5, 6, 7, 8)) Remove-Item -LiteralPath $gameAssemblyPdbPath Assert-NativeInventoryFailure ` @@ -2303,7 +2339,7 @@ if ($SelfTestOnly) { -DumpbinPaths @($fakeDumpbin) ` -NativeLineReader $fakeNativeLineReader } - 'fake pdb' | Set-Content -LiteralPath $gameAssemblyPdbPath + [System.IO.File]::WriteAllBytes($gameAssemblyPdbPath, [byte[]]@(5, 6, 7, 8)) $alternateEvidenceDir = Join-Path $playerDir 'AlternateEvidence' New-Item -ItemType Directory -Path $alternateEvidenceDir | Out-Null From 40e707d9020719ba41478b189f80be85bf247115 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 10 Oct 2026 01:43:45 +0000 Subject: [PATCH 023/147] Record elapsed timing in child-tree failure diagnostics --- scripts/unity/__tests__/editor-process-watchdog.test.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 index 9391087c3..d04917a4c 100644 --- a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 +++ b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 @@ -62,13 +62,18 @@ Write-Output 'Test run completed. Exiting with code 0 (Ok). Run completed.' Wait-Event -Timeout 30 | Out-Null '@) $treeLog = Join-Path $fixture 'child-tree.log' + $treeStartedUtc = [DateTime]::UtcNow.ToString('O') + $treeClock = [Diagnostics.Stopwatch]::StartNew() try { $treeExit = Invoke-UnityEditor -EditorPath $pwsh ` -Arguments @('-NoLogo', '-NoProfile', '-File', $childScript, '-PidPath', $treePidPath) ` -Label 'shutdown process tree' -LogPath $treeLog -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 + $treeClock.Stop() + $treeEndedUtc = [DateTime]::UtcNow.ToString('O') $treeText = Get-Content -LiteralPath $treeLog -Raw if ($treeExit -ne 124 -or $treeText -notmatch 'child-pid=(\d+)') { - throw "Child-tree fixture did not run: exit=$treeExit; pidFileExists=$(Test-Path -LiteralPath $treePidPath); log=$treeText" + $elapsed = $treeClock.Elapsed.TotalSeconds.ToString('R', [Globalization.CultureInfo]::InvariantCulture) + throw "Child-tree fixture did not run: exit=$treeExit; pidFileExists=$(Test-Path -LiteralPath $treePidPath); startedUtc=$treeStartedUtc; endedUtc=$treeEndedUtc; elapsedSeconds=$elapsed; log=$treeText" } $childProcess = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue if ($childProcess) { @@ -77,6 +82,7 @@ Wait-Event -Timeout 30 | Out-Null } finally { $childProcess.Dispose() } } } finally { + $treeClock.Stop() if (Test-Path -LiteralPath $treePidPath) { $childProcess = Get-Process -Id ([int][IO.File]::ReadAllText($treePidPath)) -ErrorAction SilentlyContinue if ($childProcess) { From 31e2225266364d7af96f8d3a1da92a88ae1c714a Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 10 Oct 2026 03:29:09 +0000 Subject: [PATCH 024/147] Retain watchdog branch details in failure diagnostics --- .../__tests__/editor-process-watchdog.test.ps1 | 17 ++++++++++++++--- scripts/unity/run-ci-tests.ps1 | 10 +++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 index d04917a4c..ac640e175 100644 --- a/scripts/unity/__tests__/editor-process-watchdog.test.ps1 +++ b/scripts/unity/__tests__/editor-process-watchdog.test.ps1 @@ -32,12 +32,20 @@ try { } [System.IO.File]::WriteAllText($scriptPath, $body) $timer = [System.Diagnostics.Stopwatch]::StartNew() + $processDetails = $null $exitCode = Invoke-UnityEditor -EditorPath $pwsh ` -Arguments @('-NoLogo', '-NoProfile', '-File', $scriptPath) ` - -Label $case -LogPath $logPath -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 + -Label $case -LogPath $logPath -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 ` + -ProcessResultRef ([ref]$processDetails) $timer.Stop() $expected = switch ($case) { 'normal' { 0 } 'shutdown' { 124 } 'stderr-shutdown' { 124 } default { 7 } } if ($exitCode -ne $expected) { throw "$case returned $exitCode instead of $expected" } + if ($processDetails.ExitCode -ne $exitCode -or + $processDetails.TimedOut -ne ($expected -eq 124) -or + $processDetails.CompletionObserved -ne ($case -ne 'lookalike') -or + -not $processDetails.ProcessSettingsVerified -or $processDetails.ProcessId -le 0) { + throw "$case lost process-owner evidence: $($processDetails | ConvertTo-Json -Compress)" + } if ($case -in @('shutdown', 'stderr-shutdown') -and $timer.Elapsed.TotalSeconds -ge 10) { throw 'Completed work waited for the total timeout instead of the shutdown deadline.' } @@ -64,16 +72,19 @@ Wait-Event -Timeout 30 | Out-Null $treeLog = Join-Path $fixture 'child-tree.log' $treeStartedUtc = [DateTime]::UtcNow.ToString('O') $treeClock = [Diagnostics.Stopwatch]::StartNew() + $treeProcessDetails = $null try { $treeExit = Invoke-UnityEditor -EditorPath $pwsh ` -Arguments @('-NoLogo', '-NoProfile', '-File', $childScript, '-PidPath', $treePidPath) ` - -Label 'shutdown process tree' -LogPath $treeLog -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 + -Label 'shutdown process tree' -LogPath $treeLog -TimeoutSeconds 15 -ShutdownTimeoutSeconds 1 ` + -ProcessResultRef ([ref]$treeProcessDetails) $treeClock.Stop() $treeEndedUtc = [DateTime]::UtcNow.ToString('O') $treeText = Get-Content -LiteralPath $treeLog -Raw if ($treeExit -ne 124 -or $treeText -notmatch 'child-pid=(\d+)') { $elapsed = $treeClock.Elapsed.TotalSeconds.ToString('R', [Globalization.CultureInfo]::InvariantCulture) - throw "Child-tree fixture did not run: exit=$treeExit; pidFileExists=$(Test-Path -LiteralPath $treePidPath); startedUtc=$treeStartedUtc; endedUtc=$treeEndedUtc; elapsedSeconds=$elapsed; log=$treeText" + $details = $treeProcessDetails | ConvertTo-Json -Compress + throw "Child-tree fixture did not run: exit=$treeExit; pidFileExists=$(Test-Path -LiteralPath $treePidPath); startedUtc=$treeStartedUtc; endedUtc=$treeEndedUtc; elapsedSeconds=$elapsed; processResult=$details; log=$treeText" } $childProcess = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue if ($childProcess) { diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 index 5ff1f18e6..14b9cf309 100644 --- a/scripts/unity/run-ci-tests.ps1 +++ b/scripts/unity/run-ci-tests.ps1 @@ -5331,7 +5331,8 @@ function Invoke-UnityEditor { # The shortest caller has a 30-minute step and at most two UPM attempts. # Ten minutes per attempt leaves ten minutes for setup and license cleanup. [ValidateRange(0, [int]::MaxValue)][int]$TimeoutSeconds = 600, - [ValidateRange(1, 300)][int]$ShutdownTimeoutSeconds = 30 + [ValidateRange(1, 300)][int]$ShutdownTimeoutSeconds = 30, + [ref]$ProcessResultRef ) # A native pipeline can remain blocked in shutdown (or on a descendant's @@ -5340,6 +5341,9 @@ function Invoke-UnityEditor { # Exact, case-sensitive whole-line terminal messages start a 30-second shutdown # deadline. Results written earlier do not start it: framework cleanup still runs. # The existing caller validates the fresh result/marker even after a forced exit. + if ($null -ne $ProcessResultRef) { + $ProcessResultRef.Value = $null + } $completionPattern = '^(?:Test run completed\. Exiting with code [0-9]+ \([^\r\n]+\)\. Run completed\.|Batchmode quit successfully invoked - shutting down!)$' $processResult = Invoke-ProcessWithTreeKillTimeout ` -FilePath $EditorPath ` @@ -5349,6 +5353,10 @@ function Invoke-UnityEditor { -CompletionPattern $completionPattern ` -LogPath $LogPath ` -Label $Label + # Preserve branch evidence for callers that need it without changing the scalar exit code. + if ($null -ne $ProcessResultRef) { + $ProcessResultRef.Value = $processResult + } $exitCode = $processResult.ExitCode Clear-NonFatalNativeExitCode -Context $Label if ($exitCode -ne 0) { From ef4ac84a003bfb5e3370258e77103da0fa8e3e79 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 10 Oct 2026 03:50:25 +0000 Subject: [PATCH 025/147] Wait for bound settings controls before editor capture --- Tests/Editor/EditorSurfaceCapture.cs | 108 ++++++++++++- Tests/Editor/EditorSurfaceCaptureTests.cs | 137 ++++++++++++++++ .../EditorToolingDocumentationCaptureTests.cs | 153 ++++++++++++++++-- docs/images/inspector-overlay/README.md | 4 + 4 files changed, 382 insertions(+), 20 deletions(-) diff --git a/Tests/Editor/EditorSurfaceCapture.cs b/Tests/Editor/EditorSurfaceCapture.cs index 75eac14ac..e64ec9b34 100644 --- a/Tests/Editor/EditorSurfaceCapture.cs +++ b/Tests/Editor/EditorSurfaceCapture.cs @@ -2,6 +2,7 @@ namespace DxMessaging.Tests.Editor { using System; + using System.Collections; using System.IO; using System.Reflection; using UnityEditor; @@ -69,6 +70,65 @@ internal static EditorSurfaceCaptureResult Capture( int canvasHeight, string outputPath ) + { + return CaptureOnHost(content, canvasWidth, canvasHeight, outputPath, null); + } + + /// + /// Lets serialized binding create controls while attached, then captures the same panel. + /// + /// + /// Hidden preparation prevents an empty initial layout from becoming the captured frame. + /// Only actual readiness advances rendering; yields let the editor perform binding work. + /// No shared render state is held across yields, and disposal closes the owned host. + /// + internal static IEnumerator CaptureWhenReady( + VisualElement content, + int canvasWidth, + int canvasHeight, + string outputPath, + Func isReady, + Action completed + ) + { + ValidateCaptureArguments(content, canvasWidth, canvasHeight, outputPath); + if (isReady == null) + { + throw new ArgumentNullException(nameof(isReady)); + } + if (completed == null) + { + throw new ArgumentNullException(nameof(completed)); + } + StyleEnum originalDisplay = content.style.display; + EditorWindow host = null; + try + { + content.style.display = DisplayStyle.None; + host = CreateCaptureHost(content, canvasWidth, canvasHeight); + while (!isReady()) + { + yield return null; + } + content.style.display = originalDisplay; + completed(CaptureOnHost(content, canvasWidth, canvasHeight, outputPath, host)); + } + finally + { + content.style.display = originalDisplay; + if (host != null) + { + EditorWindowTestUtility.CloseWindow(host); + } + } + } + + private static void ValidateCaptureArguments( + VisualElement content, + int canvasWidth, + int canvasHeight, + string outputPath + ) { if (content == null) { @@ -94,15 +154,17 @@ string outputPath "EditorSurfaceCapture needs a graphics device; this editor is running with -nographics." ); } + } - RenderTexture previousTarget = RenderTexture.active; - bool previousSrgbWrite = GL.sRGBWrite; - EditorWindow host = null; - RenderTexture target = null; - Texture2D readback = null; + private static EditorWindow CreateCaptureHost( + VisualElement content, + int canvasWidth, + int canvasHeight + ) + { + EditorWindow host = EditorWindowTestUtility.CreateWindow(); try { - host = EditorWindowTestUtility.CreateWindow(); host.minSize = new Vector2(canvasWidth, canvasHeight); host.position = new Rect(0f, 0f, canvasWidth, canvasHeight); /* @@ -127,6 +189,38 @@ into that panel on macOS. Showing it does not make the capture read the ); } + return host; + } + catch + { + EditorWindowTestUtility.CloseWindow(host); + throw; + } + } + + private static EditorSurfaceCaptureResult CaptureOnHost( + VisualElement content, + int canvasWidth, + int canvasHeight, + string outputPath, + EditorWindow preparedHost + ) + { + ValidateCaptureArguments(content, canvasWidth, canvasHeight, outputPath); + + RenderTexture previousTarget = RenderTexture.active; + bool previousSrgbWrite = GL.sRGBWrite; + EditorWindow host = preparedHost; + RenderTexture target = null; + Texture2D readback = null; + try + { + if (host == null) + { + host = CreateCaptureHost(content, canvasWidth, canvasHeight); + } + IPanel panel = host.rootVisualElement.panel; + target = new RenderTexture( canvasWidth, canvasHeight, @@ -225,7 +319,7 @@ Stopping earlier can yield a valid PNG with blank scroll bodies or partially Object.DestroyImmediate(target); } - if (host != null) + if (host != null && preparedHost == null) { EditorWindowTestUtility.CloseWindow(host); } diff --git a/Tests/Editor/EditorSurfaceCaptureTests.cs b/Tests/Editor/EditorSurfaceCaptureTests.cs index f35ea563f..6c88fbfe3 100644 --- a/Tests/Editor/EditorSurfaceCaptureTests.cs +++ b/Tests/Editor/EditorSurfaceCaptureTests.cs @@ -2,6 +2,7 @@ namespace DxMessaging.Tests.Editor { using System; + using System.Collections; using System.Collections.Generic; using System.IO; using DxMessaging.Editor; @@ -10,7 +11,9 @@ namespace DxMessaging.Tests.Editor using DxMessaging.Unity; using NUnit.Framework; using UnityEditor; + using UnityEditor.UIElements; using UnityEngine; + using UnityEngine.TestTools; using UnityEngine.UIElements; using Object = UnityEngine.Object; @@ -307,6 +310,140 @@ defect the screenshot manifest asks reviewers to catch. ); } + [TestCase(false)] + [TestCase(true)] + public void CaptureReadyRestoresStateWhenStopped(bool readinessThrows) + { + int windowsBefore = Resources.FindObjectsOfTypeAll().Length; + RenderTexture previousTarget = RenderTexture.active; + bool previousSrgbWrite = GL.sRGBWrite; + VisualElement surface = CreateOpaqueProbe(); + StyleEnum originalDisplay = surface.style.display; + string path = ResolveOutputPath(nameof(CaptureReadyRestoresStateWhenStopped)); + _createdFiles.Add(path); + IEnumerator capture = EditorSurfaceCapture.CaptureWhenReady( + surface, + CanvasWidth, + CanvasHeight, + path, + () => + readinessThrows + ? throw new InvalidOperationException("Readiness failed.") + : false, + _ => Assert.Fail("A stopped capture cannot finish rendering.") + ); + try + { + if (readinessThrows) + { + Assert.Throws(() => capture.MoveNext()); + } + else + { + Assert.That(capture.MoveNext(), Is.True); + Assert.That( + Resources.FindObjectsOfTypeAll().Length, + Is.EqualTo(windowsBefore + 1) + ); + } + Assert.That(RenderTexture.active, Is.SameAs(previousTarget)); + Assert.That(GL.sRGBWrite, Is.EqualTo(previousSrgbWrite)); + } + finally + { + ((IDisposable)capture).Dispose(); + } + Assert.That(surface.style.display, Is.EqualTo(originalDisplay)); + Assert.That( + Resources.FindObjectsOfTypeAll().Length, + Is.EqualTo(windowsBefore) + ); + Assert.That(File.Exists(path), Is.False); + } + + /// + /// Serialized binding can create PropertyField controls after attachment. A capture + /// with a valid PNG must still show all five settings fields, rather than blank rows. + /// + [UnityTest] + public IEnumerator CaptureRendersBoundProjectSettingsControls() + { + DxMessagingSettings settings = ScriptableObject.CreateInstance(); + settings.hideFlags = HideFlags.HideAndDontSave; + _createdObjects.Add(settings); + VisualElement surface = new(); + surface.style.width = 720; + surface.style.height = 600; + DxMessagingSettingsProvider.BuildSettingsUi(surface, new SerializedObject(settings)); + Dictionary childrenBeforeFirstLayout = new(); + surface.RegisterCallback(evt => + { + if (ReferenceEquals(evt.target, surface) && childrenBeforeFirstLayout.Count == 0) + { + foreach (PropertyField field in surface.Query().ToList()) + { + childrenBeforeFirstLayout[field.name] = field.childCount; + } + } + }); + string path = ResolveOutputPath(nameof(CaptureRendersBoundProjectSettingsControls)); + _createdFiles.Add(path); + yield return EditorSurfaceCapture.CaptureWhenReady( + surface, + 800, + 680, + path, + () => + EditorToolingDocumentationCaptureTests.AreProjectSettingsControlsReady( + surface, + settings + ), + result => Assert.That(result.ByteCount, Is.GreaterThan(0)) + ); + foreach (string propertyName in childrenBeforeFirstLayout.Keys) + { + Assert.That( + childrenBeforeFirstLayout[propertyName], + Is.GreaterThan(0), + $"{propertyName} must initialize its control before capture layout begins." + ); + } + Assert.That( + childrenBeforeFirstLayout.Count, + Is.EqualTo(5), + "The first capture layout must observe all five settings fields." + ); + AssertBoundProjectSettingsControls(surface); + } + + private static void AssertBoundProjectSettingsControls(VisualElement surface) + { + foreach ( + string propertyName in new[] + { + "_diagnosticsTargets", + "_diagnosticsStackTraces", + "_messageBufferSize", + "_suppressDomainReloadWarning", + "_baseCallIgnoredTypes", + } + ) + { + PropertyField field = surface.Q(propertyName); + Assert.That(field, Is.Not.Null, $"Settings surface must contain {propertyName}."); + Assert.That( + field.childCount, + Is.GreaterThan(0), + $"{propertyName} must contain its real bound control." + ); + Assert.That( + field.layout.height, + Is.GreaterThan(0), + $"{propertyName} must occupy visible space at capture." + ); + } + } + [Test] public void CaptureRejectsAnEmptyOutputPath() { diff --git a/Tests/Editor/EditorToolingDocumentationCaptureTests.cs b/Tests/Editor/EditorToolingDocumentationCaptureTests.cs index bb8e02810..5fe637e74 100644 --- a/Tests/Editor/EditorToolingDocumentationCaptureTests.cs +++ b/Tests/Editor/EditorToolingDocumentationCaptureTests.cs @@ -2,9 +2,11 @@ namespace DxMessaging.Tests.Editor { using System; + using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; + using System.Linq; using DxMessaging.Editor; using DxMessaging.Editor.Analyzers; using DxMessaging.Editor.CustomEditors; @@ -13,7 +15,9 @@ namespace DxMessaging.Tests.Editor using DxMessaging.Unity; using NUnit.Framework; using UnityEditor; + using UnityEditor.UIElements; using UnityEngine; + using UnityEngine.TestTools; using UnityEngine.UIElements; using Object = UnityEngine.Object; @@ -401,6 +405,11 @@ public void MessageMonitorFilterCaptureShowsTypedAndRouteKindFilters() ); } + /// + /// 2026-10-09: shortened example frames omitted Unity's source-location syntax, + /// so the formatter displayed the file suffix instead of the emitting method. + /// Preserve standard captured frames and assert both visible source identities. + /// [Test] public void FlowGraphCaptureSurfacesContainSourceDestinationAndRouteDetails() { @@ -437,11 +446,32 @@ public void FlowGraphCaptureSurfacesContainSourceDestinationAndRouteDetails() "Every selected Flow Graph capture must expand its evidence disclosure." ); } + foreach (VisualElement surface in new[] { source, route }) + { + Foldout evidence = surface.Q( + DxMessagingFlowGraphWindow.DetailsEvidenceFoldoutName + ); + string[] labels = evidence + .Query protected IMessageBus Inner => _inner ?? throw new NotImplementedException(); + /// + /// Cold leak accounting can inspect the forwarded built-in bus. A partial + /// stub has no source; doubles with independent storage can override this. + /// + internal virtual IMessageBus RegistrationCountSource => _inner; + public virtual bool DiagnosticsMode => Inner.DiagnosticsMode; public virtual int RegisteredGlobalSequentialIndex => Inner.RegisteredGlobalSequentialIndex; diff --git a/Tests/Runtime/TestUtilities/LeakWatcher.cs b/Tests/Runtime/TestUtilities/LeakWatcher.cs index 2f6b0127f..877de785f 100644 --- a/Tests/Runtime/TestUtilities/LeakWatcher.cs +++ b/Tests/Runtime/TestUtilities/LeakWatcher.cs @@ -5,9 +5,10 @@ namespace DxMessaging.Tests.Runtime using DxMessaging.Core; using DxMessaging.Core.MessageBus; using NUnit.Framework; + using BusType = DxMessaging.Core.MessageBus.MessageBus; /// - /// Snapshots the public registration counters of an + /// Snapshots live registrations and public counters of an /// on construction and asserts on /// that the counters returned to their starting /// values. Intended to bracket a unit of work that should leave the bus @@ -15,16 +16,23 @@ namespace DxMessaging.Tests.Runtime /// /// /// - /// Counter source: this watcher reads every public registration counter on + /// Built-in buses and forwarding test doubles also expose a cold internal live + /// registration count, including duplicate reference counts and callbacks sharing + /// one priority container. Arbitrary custom buses supply only their public + /// counters, which may not detect additions within an existing container. + /// Every individual counter pair must return to baseline, even when their total + /// delta is zero. Equal-count replacements within one category remain outside + /// a count-based check; behavioral assertions must pin ownership and callbacks. + /// This watcher reads every public registration counter on /// : , /// , /// , /// , /// , and /// . Every counter is part of - /// the public surface, so registration accounting does not inspect internal storage. If a - /// future API adds a seventh registration kind, and - /// need to be extended in lock-step. + /// the public surface; those counter checks do not inspect internal storage. If a + /// future API adds a seventh registration kind, extend the public counter + /// snapshots, pair invariant, custom-bus fallback, and built-in live query. /// Slot occupancy counters ( and /// ) are also captured and /// reported. They are enforced only by so the @@ -35,12 +43,10 @@ namespace DxMessaging.Tests.Runtime /// reads internal storage only at snapshot boundaries or on explicit queries. /// /// - /// Allocation note: the getter and the - /// getter both walk the per-message-type - /// caches that back and - /// . Each call is O(types), - /// so callers reading these properties in tight loops should snapshot at - /// region boundaries instead of polling every frame. + /// Allocation note: live and + /// queries traverse built-in registration + /// stores or the custom bus's public counters. Capture at region boundaries; + /// do not poll these properties in a dispatch or allocation measurement window. /// /// /// @@ -58,6 +64,9 @@ namespace DxMessaging.Tests.Runtime public sealed class LeakWatcher : IDisposable { private readonly IMessageBus _bus; + private readonly BusType _registrationCountBus; + private readonly int _initialLiveRegistrations; + private int _finalLiveRegistrations; private readonly bool _throwOnLeak; private readonly string _label; private readonly int _initialUntargeted; @@ -121,6 +130,16 @@ public LeakWatcher( ); } + IMessageBus registrationCountSource = _bus; + while ( + registrationCountSource is DelegatingMessageBus forwardingBus + && forwardingBus.RegistrationCountSource != null + ) + { + registrationCountSource = forwardingBus.RegistrationCountSource; + } + _registrationCountBus = registrationCountSource as BusType; + _throwOnLeak = throwOnLeak; _label = label; _watchSlots = watchSlots; @@ -131,6 +150,7 @@ public LeakWatcher( _initialInterceptors = _bus.RegisteredInterceptors; _initialPostProcessors = _bus.RegisteredPostProcessors; _initialGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + _initialLiveRegistrations = Snapshot; _initialTypeSlotCount = _bus.OccupiedTypeSlots; _initialTargetSlotCount = _bus.OccupiedTargetSlots; if (_handler != null) @@ -197,15 +217,17 @@ public static LeakWatcher WatchWithSlots( /// can compare against . /// /// - /// Each access walks the per-message-type caches behind - /// and - /// , so the call is - /// O(types). Avoid polling this property inside a tight loop. + /// Built-in buses walk live registration references; other buses sum their six + /// public counters. Avoid polling this property inside a tight loop. /// public int Snapshot { get { + if (_registrationCountBus != null) + { + return _registrationCountBus.LiveRegistrationCountForTesting; + } return _bus.RegisteredUntargeted + _bus.RegisteredTargeted + _bus.RegisteredBroadcast @@ -216,16 +238,10 @@ public int Snapshot } /// - /// The total registration count captured at construction. Frozen for + /// The live registration count captured at construction. Frozen for /// the lifetime of the watcher. /// - public int InitialSnapshot => - _initialUntargeted - + _initialTargeted - + _initialBroadcast - + _initialInterceptors - + _initialPostProcessors - + _initialGlobalAcceptAll; + public int InitialSnapshot => _initialLiveRegistrations; /// /// The live occupied-slot count across per-message-type and @@ -248,35 +264,11 @@ public int Snapshot /// which the disposal-time delta is returned. /// /// - /// Each pre-disposal access pays the same O(types) walk as + /// Each pre-disposal access pays the same cold registration walk as /// . Snapshot at region boundaries. /// - public int LeakedRegistrations - { - get - { - if (_disposed) - { - return TotalDelta( - _finalUntargeted, - _finalTargeted, - _finalBroadcast, - _finalInterceptors, - _finalPostProcessors, - _finalGlobalAcceptAll - ); - } - - return TotalDelta( - _bus.RegisteredUntargeted, - _bus.RegisteredTargeted, - _bus.RegisteredBroadcast, - _bus.RegisteredInterceptors, - _bus.RegisteredPostProcessors, - _bus.RegisteredGlobalAcceptAll - ); - } - } + public int LeakedRegistrations => + (_disposed ? _finalLiveRegistrations : Snapshot) - _initialLiveRegistrations; /// /// Number of occupied per-message-type slots leaked relative to the @@ -333,7 +325,7 @@ public int LeakedHandlerPriorityCaches /// /// /// Allocates one string per call. Pre-disposal calls walk the live bus - /// counters (O(types)); post-disposal calls read the snapshot taken + /// counters and registration references; post-disposal calls read the snapshot taken /// inside . Safe to call before or after disposal. /// public string DescribeDelta() @@ -353,21 +345,15 @@ public string DescribeDelta() ? _finalTargetSlotCount : _bus.OccupiedTargetSlots; - int delta = TotalDelta( - currentUntargeted, - currentTargeted, - currentBroadcast, - currentInterceptors, - currentPostProcessors, - currentGlobalAcceptAll - ); + int currentLiveRegistrations = _disposed ? _finalLiveRegistrations : Snapshot; + int delta = currentLiveRegistrations - _initialLiveRegistrations; string scope = string.IsNullOrEmpty(_label) ? string.Empty : $" ({_label})"; return string.Format( CultureInfo.InvariantCulture, "LeakWatcher{0}: delta={1} (Untargeted {2}->{3}, Targeted {4}->{5}, " + "Broadcast {6}->{7}, Interceptors {8}->{9}, PostProcessors {10}->{11}, " - + "GlobalAcceptAll {12}->{13}, TypeSlots {14}->{15}, TargetSlots {16}->{17}).{18}", + + "GlobalAcceptAll {12}->{13}, TypeSlots {14}->{15}, TargetSlots {16}->{17}, LiveRegistrations {19}->{20}).{18}", scope, delta, _initialUntargeted, @@ -386,7 +372,9 @@ public string DescribeDelta() currentTypeSlotCount, _initialTargetSlotCount, currentTargetSlotCount, - DescribeHandlerStorage() + DescribeHandlerStorage(), + _initialLiveRegistrations, + currentLiveRegistrations ); } @@ -410,6 +398,7 @@ public void Dispose() _finalInterceptors = _bus.RegisteredInterceptors; _finalPostProcessors = _bus.RegisteredPostProcessors; _finalGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + _finalLiveRegistrations = Snapshot; _finalTypeSlotCount = _bus.OccupiedTypeSlots; _finalTargetSlotCount = _bus.OccupiedTargetSlots; if (_handler != null) @@ -421,21 +410,26 @@ out _finalHandlerPriorityCaches ); } - int delta = TotalDelta( - _finalUntargeted, - _finalTargeted, - _finalBroadcast, - _finalInterceptors, - _finalPostProcessors, - _finalGlobalAcceptAll - ); + int delta = _finalLiveRegistrations - _initialLiveRegistrations; + bool registrationCountersAreClean = + _initialUntargeted == _finalUntargeted + && _initialTargeted == _finalTargeted + && _initialBroadcast == _finalBroadcast + && _initialInterceptors == _finalInterceptors + && _initialPostProcessors == _finalPostProcessors + && _initialGlobalAcceptAll == _finalGlobalAcceptAll; int typeSlotDelta = _finalTypeSlotCount - _initialTypeSlotCount; int targetSlotDelta = _finalTargetSlotCount - _initialTargetSlotCount; bool slotDeltaIsClean = !_watchSlots || (typeSlotDelta == 0 && targetSlotDelta == 0); bool handlerStorageIsClean = _finalHandlerContexts == _initialHandlerContexts && _finalHandlerPriorityCaches == _initialHandlerPriorityCaches; - if (delta == 0 && slotDeltaIsClean && handlerStorageIsClean) + if ( + delta == 0 + && registrationCountersAreClean + && slotDeltaIsClean + && handlerStorageIsClean + ) { return; } @@ -458,7 +452,7 @@ private string BuildFailureMessage(int delta, int typeSlotDelta, int targetSlotD + "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}, " - + "TypeSlots {16}->{17}, TargetSlots {18}->{19}.{20}", + + "TypeSlots {16}->{17}, TargetSlots {18}->{19}, LiveRegistrations {21}->{22}.{20}", scope, delta, _initialUntargeted, @@ -479,7 +473,9 @@ private string BuildFailureMessage(int delta, int typeSlotDelta, int targetSlotD _finalTypeSlotCount, _initialTargetSlotCount, _finalTargetSlotCount, - DescribeHandlerStorage() + DescribeHandlerStorage(), + _initialLiveRegistrations, + _finalLiveRegistrations ); } @@ -509,26 +505,5 @@ private string DescribeHandlerStorage() priorities ); } - - private int TotalDelta( - int untargeted, - int targeted, - int broadcast, - int interceptors, - int postProcessors, - int globalAcceptAll - ) - { - int sumNow = - untargeted + targeted + broadcast + interceptors + postProcessors + globalAcceptAll; - int sumThen = - _initialUntargeted - + _initialTargeted - + _initialBroadcast - + _initialInterceptors - + _initialPostProcessors - + _initialGlobalAcceptAll; - return sumNow - sumThen; - } } } From 3dfda5c85fe27dc0d2f10ab773925c442419dfaf Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 11 Oct 2026 13:01:33 +0000 Subject: [PATCH 035/147] Keep leak accounting compatible with CI test hosts --- .../references/leak-watcher-usage.md | 12 ++++++++++++ .net-tests/DxMessaging.Net.Tests.csproj | 4 ++++ .../Runtime/Core/RegistrationLeakAccountingTests.cs | 3 +++ Tests/Runtime/TestUtilities/DelegatingMessageBus.cs | 2 -- scripts/unity/comparison-evidence-catalog-v1.json | 2 +- 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md b/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md index aacbc14eb..4194c6312 100644 --- a/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md +++ b/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md @@ -221,6 +221,18 @@ When the bus grows a new public registration counter: A skipped watcher extension can under-count silently. The new registration regression must prove detection; the public type-set snapshot cannot prove it. +## Test Host Integration + +On 2026-10-11, the cold accounting change exposed two integration requirements. +The plain .NET test project links `LeakWatcher` through an explicit `Compile` list. +Keep its forwarding helper in that list and free of Unity-only compile guards. +Run `.net-tests/DxMessaging.Net.Tests.csproj` in Release when changing these helpers. + +Changing `MessageBus.cs` also changes its comparison evidence source hash. +Refresh only the matching entry in `scripts/unity/comparison-evidence-catalog-v1.json` +from source bytes normalized to LF. Run the existing PowerShell profile contract +and full script suite; preserve their hash checks and comparison contracts. + ## When NOT to Use - Inside a tight loop. Use one watcher around the loop body, not one per diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index 833daebb2..e6c1aac91 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -23,5 +23,9 @@ Link="ScenarioHarness.cs" /> + diff --git a/Tests/Runtime/Core/RegistrationLeakAccountingTests.cs b/Tests/Runtime/Core/RegistrationLeakAccountingTests.cs index 5e0668aed..568886269 100644 --- a/Tests/Runtime/Core/RegistrationLeakAccountingTests.cs +++ b/Tests/Runtime/Core/RegistrationLeakAccountingTests.cs @@ -278,6 +278,8 @@ public Callback(List trace, string label) _label = label; } + // 2026-10-11: Fast callback APIs require readonly references to mutable test messages. +#pragma warning disable RCS1242 public void Untargeted(in SimpleUntargetedMessage message) => _trace.Add(_label); public void Targeted(in SimpleTargetedMessage message) => _trace.Add(_label); @@ -291,6 +293,7 @@ public void BroadcastContext( in InstanceId context, in SimpleBroadcastMessage message ) => _trace.Add(_label); +#pragma warning restore RCS1242 public void GlobalUntargeted(in IUntargetedMessage message) => _trace.Add(_label); diff --git a/Tests/Runtime/TestUtilities/DelegatingMessageBus.cs b/Tests/Runtime/TestUtilities/DelegatingMessageBus.cs index c6a055688..3ae214316 100644 --- a/Tests/Runtime/TestUtilities/DelegatingMessageBus.cs +++ b/Tests/Runtime/TestUtilities/DelegatingMessageBus.cs @@ -1,4 +1,3 @@ -#if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime { using System; @@ -194,4 +193,3 @@ ref TMessage typedMessage Inner.SourcedBroadcast(ref source, ref typedMessage); } } -#endif diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index 1b901ffc0..54196293d 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -38,7 +38,7 @@ "dxBus": { "origin": "repository", "path": "Runtime/Core/MessageBus/MessageBus.cs", - "sha256": "f7a850d15ea2f493b3b5b1296e0c9062745ea7ea152137260563e9c623b8b332" + "sha256": "45b6bb2b0c8bb5d9f0b7a1129b7db10184e2b537aa7d89011590bfe6a739d9c7" }, "dxInterface": { "origin": "repository", From 9274b2361fd82800ac7bf4ce69846a7e71a3ee09 Mon Sep 17 00:00:00 2001 From: wallstop Date: Mon, 12 Oct 2026 05:54:29 +0000 Subject: [PATCH 036/147] Observe inherited test categories through Unity callbacks --- .../Core/SuiteCategoryObservationTests.cs | 256 ++++++++++++++++++ .../SuiteCategoryObservationTests.cs.meta | 11 + .../Runtime/Core/SuiteWallClockBudgetTest.cs | 71 +++-- 3 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 Tests/Runtime/Core/SuiteCategoryObservationTests.cs create mode 100644 Tests/Runtime/Core/SuiteCategoryObservationTests.cs.meta diff --git a/Tests/Runtime/Core/SuiteCategoryObservationTests.cs b/Tests/Runtime/Core/SuiteCategoryObservationTests.cs new file mode 100644 index 000000000..eb21d0343 --- /dev/null +++ b/Tests/Runtime/Core/SuiteCategoryObservationTests.cs @@ -0,0 +1,256 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections.Generic; + using System.Reflection; + using NUnit.Framework; + using NUnit.Framework.Internal; + using UnityEngine.TestRunner; + + public sealed class SuiteCategoryObservationTests + { + public static IEnumerable SelectedCategoryCases() + { + (string category, bool gated)[] categories = + { + ("Stress", true), + ("Performance", true), + ("Allocation", true), + ("MemoryReclaim", true), + ("sTrEsS", true), + ("UnityRuntime", false), + ("unityruntime", false), + ("Stressful", false), + ("", false), + (null, false), + }; + foreach ((string category, bool gated) in categories) + { + for (int level = 0; level < 4; ++level) + { + foreach (bool previouslyObserved in new[] { false, true }) + { + yield return new TestCaseData( + category, + level, + previouslyObserved, + previouslyObserved || gated + ); + } + } + } + } + + /// + /// NUnit category bags contain only local values. Fixture and parameterized-method + /// categories must be observed through the selected leaf's parent chain, while an + /// unselected gated sibling must not exempt the default suite budget. + /// + [TestCaseSource(nameof(SelectedCategoryCases))] + public void CategoryObservationUsesSelectedAncestors( + string category, + int level, + bool previouslyObserved, + bool expected + ) + { + ObserveSelectedAncestors(category, level, previouslyObserved, expected, false); + } + + [TestCaseSource(nameof(SelectedCategoryCases))] + public void UnityCallbackObservesSelectedAncestors( + string category, + int level, + bool previouslyObserved, + bool expected + ) + { + ObserveSelectedAncestors(category, level, previouslyObserved, expected, true); + } + + [Test] + public void AssemblyRegistersUnityCallback() + { + object[] callbacks = typeof(SuiteWallClockBudgetTest).Assembly.GetCustomAttributes( + typeof(TestRunCallbackAttribute), + false + ); + Assert.That( + callbacks.Length, + Is.EqualTo(1), + "The assembly must register its Unity observer." + ); +#if UNITY_EDITOR || UNITY_STANDALONE + Assert.That( + NoteGatedCategoryAttribute.LastStartedTestIdForTesting, + Is.EqualTo(TestContext.CurrentContext.Test.ID), + "Unity must notify the registered observer for this executed leaf." + ); +#endif + } + + [TestCase(false)] + [TestCase(true)] + public void UnityCallbackIgnoresSuiteAndWholeRunNotifications(bool previouslyObserved) + { + TestSuite suite = new("unselected gated tree"); + suite.Properties.Add("Category", "MemoryReclaim"); + AssertObservation( + suite, + previouslyObserved, + previouslyObserved, + "suite notification", + true + ); + } + + [TestCase(false)] + [TestCase(true)] + public void UnityCallbackIgnoresTestsFromAnotherAssembly(bool previouslyObserved) + { + TestMethod foreign = new(new MethodWrapper(typeof(TestSuite), nameof(TestSuite.Add))); + foreign.Properties.Add("Category", "MemoryReclaim"); + AssertObservation( + foreign, + previouslyObserved, + previouslyObserved, + $"foreign assembly, previous={previouslyObserved}", + true + ); + } + + private static void ObserveSelectedAncestors( + string category, + int level, + bool previouslyObserved, + bool expected, + bool unityCallback + ) + { + TestMethod leaf = new( + new MethodWrapper( + typeof(SuiteCategoryObservationTests), + nameof(CategoryObservationUsesSelectedAncestors) + ) + ); + TestSuite method = new("parameterized method"); + TestSuite fixture = new("fixture"); + TestSuite assembly = new("assembly"); + method.Add(leaf); + fixture.Add(method); + assembly.Add(fixture); + Test[] hierarchy = { leaf, method, fixture, assembly }; + if (category != null) + { + hierarchy[level].Properties.Add("Category", category); + } + TestSuite sibling = new("unselected gated sibling"); + sibling.Properties.Add("Category", "MemoryReclaim"); + assembly.Add(sibling); + + AssertObservation( + leaf, + previouslyObserved, + expected, + $"category={category ?? ""}, level={level}, previous={previouslyObserved}, unityCallback={unityCallback}", + unityCallback + ); + } + + [TestCase(false)] + [TestCase(true)] + public void NullTestPreservesPreviouslyObservedCategory(bool previouslyObserved) + { + AssertObservation( + null, + previouslyObserved, + previouslyObserved, + $"null test, previous={previouslyObserved}" + ); + } + + [TestCase(false)] + [TestCase(true)] + public void UnityCallbackIgnoresNullTest(bool previouslyObserved) + { + AssertObservation( + null, + previouslyObserved, + previouslyObserved, + $"null callback test, previous={previouslyObserved}", + true + ); + } + + [TestCase(false)] + [TestCase(true)] + public void MixedCategoryBagObservesEveryValue(bool gated) + { + TestSuite selected = new("mixed categories"); + selected.Properties.Add("Category", "UnityRuntime"); + selected.Properties.Add("Category", 7); + selected.Properties.Add("Category", gated ? "Allocation" : "Stressful"); + AssertObservation(selected, false, gated, $"mixed category bag, gated={gated}"); + } + + private static void AssertObservation( + NUnit.Framework.Interfaces.ITest selected, + bool previouslyObserved, + bool expected, + string context, + bool unityCallback = false + ) + { + FieldInfo detected = typeof(SuiteWallClockBudgetTest).GetField( + "_gatedCategoryDetected", + BindingFlags.NonPublic | BindingFlags.Static + ); + Assert.That( + detected, + Is.Not.Null, + "[{0}] The suite observation field must exist.", + context + ); + bool original = (bool)detected.GetValue(null); + try + { + detected.SetValue(null, previouslyObserved); + NoteGatedCategoryAttribute observer = new(); + if (unityCallback) + { + ITestRunCallback callback = (object)observer as ITestRunCallback; + Assert.That( + callback, + Is.Not.Null, + "[{0}] The observer implements Unity callbacks.", + context + ); + callback.RunStarted(selected); + Assert.That( + (bool)detected.GetValue(null), + Is.EqualTo(previouslyObserved), + "[{0}] Whole-run discovery must preserve the previous gate state.", + context + ); + callback.TestStarted(selected); + } + else + { + observer.BeforeTest(selected); + } + Assert.That( + (bool)detected.GetValue(null), + Is.EqualTo(expected), + "[{0}] Selected ancestry must produce gated={1} without observing siblings.", + context, + expected + ); + } + finally + { + detected.SetValue(null, original); + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/SuiteCategoryObservationTests.cs.meta b/Tests/Runtime/Core/SuiteCategoryObservationTests.cs.meta new file mode 100644 index 000000000..d32400e89 --- /dev/null +++ b/Tests/Runtime/Core/SuiteCategoryObservationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f4929cb7f4e34998a4a7f113840eb687 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs index 000a8b11e..23d0f690c 100644 --- a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs +++ b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs @@ -1,5 +1,8 @@ #if UNITY_2021_3_OR_NEWER [assembly: DxMessaging.Tests.Runtime.NoteGatedCategoryAttribute] +[assembly: UnityEngine.TestRunner.TestRunCallback( + typeof(DxMessaging.Tests.Runtime.NoteGatedCategoryAttribute) +)] namespace DxMessaging.Tests.Runtime { @@ -8,6 +11,7 @@ namespace DxMessaging.Tests.Runtime using System.Globalization; using NUnit.Framework; using NUnit.Framework.Interfaces; + using UnityEngine.TestRunner; /// /// Suite-level wall-clock budget covering every test under the @@ -43,8 +47,8 @@ namespace DxMessaging.Tests.Runtime /// runner. /// /// - /// Gated-category detection: an assembly-scoped - /// runs for every test in the run. + /// Gated-category detection: observes executed + /// leaves through Unity's test-run callback and NUnit's assembly action. /// The action reads the test's NUnit categories (from /// with the "Category" key, as /// defined by NUnit's internal PropertyNames table) and forwards @@ -235,48 +239,67 @@ public static void NoteGatedCategoryObserved(string category) } /// - /// Assembly-scoped that fires before every - /// test runs, scans the test's NUnit categories, and forwards each one + /// Assembly-scoped observer that scans each executed leaf's selected ancestry + /// for NUnit categories and forwards each one /// to . - /// Combined with the assembly-level attribute application (see the - /// [assembly: NoteGatedCategory] declaration at the top - /// of this file) this covers every test in every fixture in the - /// assembly without requiring a base class. + /// Unity's action command does not load assembly attributes, so its public + /// test-run callback supplies the leaf notification. NUnit runners retain + /// the assembly action. Suite notifications do not exempt a run because they + /// can include unselected gated descendants and precede namespace setup. /// [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] - public sealed class NoteGatedCategoryAttribute : Attribute, ITestAction + [UnityEngine.Scripting.Preserve] + public sealed class NoteGatedCategoryAttribute : Attribute, ITestAction, ITestRunCallback { + internal static string LastStartedTestIdForTesting { get; private set; } + public ActionTargets Targets => ActionTargets.Test; public void BeforeTest(ITest test) { - if (test == null) - { - return; - } - /* ITest.Properties is a flat IPropertyBag; categories live under the well-known "Category" key (NUnit 3.x's PropertyNames.Category - resolves to the same literal). Each test may have multiple - categories, and NUnit applies fixture-level [Category] - attributes to each child test automatically, so a class-level - [Category("Allocation")] also shows up here. + resolves to the same literal). Inherited fixture and parameterized-method + categories stay on their parent nodes. Follow only the selected ancestry; + categories on unselected siblings must not exempt the default suite. */ const string CategoryPropertyName = "Category"; - System.Collections.IList categories = test.Properties[CategoryPropertyName]; - if (categories == null) + for (ITest current = test; current != null; current = current.Parent) { - return; + System.Collections.IList categories = current.Properties[CategoryPropertyName]; + if (categories == null) + { + continue; + } + + for (int i = 0; i < categories.Count; ++i) + { + SuiteWallClockBudgetTest.NoteGatedCategoryObserved(categories[i] as string); + } } + } + + public void AfterTest(ITest test) { } - for (int i = 0; i < categories.Count; ++i) + public void RunStarted(ITest testsToRun) { } + + public void RunFinished(ITestResult testResults) { } + + public void TestStarted(ITest test) + { + if ( + test != null + && !test.IsSuite + && test.TypeInfo?.Type?.Assembly == typeof(SuiteWallClockBudgetTest).Assembly + ) { - SuiteWallClockBudgetTest.NoteGatedCategoryObserved(categories[i] as string); + LastStartedTestIdForTesting = test.Id; + BeforeTest(test); } } - public void AfterTest(ITest test) { } + public void TestFinished(ITestResult result) { } } } #endif From 600e77d80eac89eac4260ccee09b881911186fc6 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 15 Oct 2026 07:53:01 +0000 Subject: [PATCH 037/147] Give cyclic buffer array cases distinct names --- Tests/Runtime/Core/CyclicBufferTests.cs | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Tests/Runtime/Core/CyclicBufferTests.cs b/Tests/Runtime/Core/CyclicBufferTests.cs index af894efdc..077025ac7 100644 --- a/Tests/Runtime/Core/CyclicBufferTests.cs +++ b/Tests/Runtime/Core/CyclicBufferTests.cs @@ -100,27 +100,37 @@ public void CapacityBehaviorDataDriven(int capacity, int itemsToAdd, int expecte new[] { 1, 2, 3 }, 1, new[] { 2, 3 }, - Description = "Remove first element by value" + Description = "Remove first element by value", + TestName = nameof(RemoveBehaviorDataDriven) + "(FirstElement)" )] [TestCase( new[] { 1, 2, 3 }, 2, new[] { 1, 3 }, - Description = "Remove middle element by value" + Description = "Remove middle element by value", + TestName = nameof(RemoveBehaviorDataDriven) + "(MiddleElement)" )] [TestCase( new[] { 1, 2, 3 }, 3, new[] { 1, 2 }, - Description = "Remove last element by value" + Description = "Remove last element by value", + TestName = nameof(RemoveBehaviorDataDriven) + "(LastElement)" )] [TestCase( new[] { 1, 2, 3 }, 99, new[] { 1, 2, 3 }, - Description = "Remove non-existent element" + Description = "Remove non-existent element", + TestName = nameof(RemoveBehaviorDataDriven) + "(MissingElement)" + )] + [TestCase( + new[] { 1 }, + 1, + new int[0], + Description = "Remove only element", + TestName = nameof(RemoveBehaviorDataDriven) + "(OnlyElement)" )] - [TestCase(new[] { 1 }, 1, new int[0], Description = "Remove only element")] public void RemoveBehaviorDataDriven(int[] initial, int toRemove, int[] expected) { CyclicBuffer buffer = new(initial.Length, initial); @@ -152,19 +162,22 @@ public void RemoveBehaviorDataDriven(int[] initial, int toRemove, int[] expected new[] { 1, 2, 2, 3 }, 2, new[] { 1, 2, 3 }, - Description = "Remove first occurrence of duplicate" + Description = "Remove first occurrence of duplicate", + TestName = nameof(RemoveDuplicatesBehavior) + "(FirstDuplicate)" )] [TestCase( new[] { 2, 2, 2 }, 2, new[] { 2, 2 }, - Description = "Remove first of all duplicates" + Description = "Remove first of all duplicates", + TestName = nameof(RemoveDuplicatesBehavior) + "(AllDuplicates)" )] [TestCase( new[] { 1, 1, 1 }, 1, new[] { 1, 1 }, - Description = "Remove first of identical elements" + Description = "Remove first of identical elements", + TestName = nameof(RemoveDuplicatesBehavior) + "(IdenticalElements)" )] public void RemoveDuplicatesBehavior(int[] initial, int toRemove, int[] expected) { From 0ff400809e3f78ad2a4f6444a3aafa0758967549 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 14 Oct 2026 15:18:33 +0000 Subject: [PATCH 038/147] Fix migration bridge cleanup for destroyed publishers (#634) --- .docs-tests/DocsSnippetCompilationTests.cs | 44 ++++++++++++++++++---- docs/guides/migration-guide.md | 7 +++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.docs-tests/DocsSnippetCompilationTests.cs b/.docs-tests/DocsSnippetCompilationTests.cs index e7e4df0d2..ae933cca8 100644 --- a/.docs-tests/DocsSnippetCompilationTests.cs +++ b/.docs-tests/DocsSnippetCompilationTests.cs @@ -11,6 +11,7 @@ internal sealed class DocsSnippetCompilationTests [TestCase(0)] [TestCase(1)] [TestCase(2)] + [TestCase(3)] public void MigrationEventBridgeOwnsItsEnabledLifetime(int publisherChange) { string migrationPath = Path.Combine(ResolveDocsRoot(), "guides", "migration-guide.md"); @@ -28,15 +29,32 @@ public void MigrationEventBridgeOwnsItsEnabledLifetime(int publisherChange) "Expected exactly one documented LegacyBridge sample." ); - // Execute the documented bridge unchanged. Only its external publisher, message emission, - // and Unity types are fixtures; this tests C# event ownership, not native Unity callbacks. + /* + Execute the documented bridge unchanged. The fixtures supply its publisher, + message emission, and Unity types. Null comparisons model a destroyed Unity + publisher; native PlayMode evidence verifies the real lifecycle separately. + */ string fixtures = """ namespace UnityEngine { - public class MonoBehaviour { } + public class MonoBehaviour + { + public bool IsDestroyed; + public static bool IsNullForComparison(MonoBehaviour value) => + ReferenceEquals(value, null) || value.IsDestroyed; + public static bool operator ==(MonoBehaviour left, MonoBehaviour right) + { + if (ReferenceEquals(left, null)) return IsNullForComparison(right); + if (ReferenceEquals(right, null)) return IsNullForComparison(left); + return ReferenceEquals(left, right); + } + public static bool operator !=(MonoBehaviour left, MonoBehaviour right) => !(left == right); + public override bool Equals(object other) => ReferenceEquals(this, other); + public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this); + } public sealed class SerializeField : System.Attribute { } } - public sealed class LegacySystem + public sealed class LegacySystem : UnityEngine.MonoBehaviour { public event System.Action OnSomethingHappened; public int SubscriptionCount => OnSomethingHappened?.GetInvocationList().Length ?? 0; @@ -131,6 +149,18 @@ public static class EmissionFixture _ => originalPublisher, }; publisherField.SetValue(bridge, nextPublisher); + if (publisherChange == 3) + { + publisherType.GetField("IsDestroyed")!.SetValue(originalPublisher, true); + Assert.That(ReferenceEquals(originalPublisher, null), Is.False); + Assert.That( + publisherType + .BaseType!.GetMethod("IsNullForComparison")! + .Invoke(null, new[] { originalPublisher }), + Is.True, + "The destroyed publisher keeps its C# event while comparing equal to Unity null." + ); + } bridgeType.GetMethod("OnDisable", instanceMembers)?.Invoke(bridge, null); raise.Invoke(originalPublisher, new object[] { 22 }); raise.Invoke(replacementPublisher, new object[] { 33 }); @@ -139,7 +169,7 @@ public static class EmissionFixture Assert.That( subscriptions.GetValue(originalPublisher), Is.EqualTo(0), - "Disable must remove the original subscription even after publisher reassignment." + "Disable must remove the original subscription after reassignment or publisher destruction." ); Assert.That( values, @@ -154,13 +184,13 @@ public static class EmissionFixture int[] expected = publisherChange switch { 1 => new[] { 11, 55 }, - 2 => new[] { 11 }, + 2 or 3 => new[] { 11 }, _ => new[] { 11, 44 }, }; Assert.That( values, Is.EqualTo(expected), - "Re-enable must subscribe only to the currently configured publisher; null configuration forwards nothing." + "Re-enable must subscribe only to the configured live publisher; null or destroyed publishers forward nothing." ); bridgeType.GetMethod("OnDisable", instanceMembers)?.Invoke(bridge, null); Assert.That( diff --git a/docs/guides/migration-guide.md b/docs/guides/migration-guide.md index 328b95139..4a42cdf18 100644 --- a/docs/guides/migration-guide.md +++ b/docs/guides/migration-guide.md @@ -343,6 +343,11 @@ This example assumes a legacy publisher exposing `event Action OnSomethingH an untargeted `SomethingHappened(int value)` message. DxMessaging tokens manage message subscriptions; they do not remove subscriptions to another object's C# events. +Use `ReferenceEquals` when removing the C# event. A destroyed Unity publisher can still +have a managed wrapper, even though Unity compares it equal to null. Cleanup must remove +the callback from that wrapper. The enable check keeps using Unity's null comparison +to avoid subscribing to a destroyed publisher. + ```csharp public class LegacyBridge : MonoBehaviour { [SerializeField] private LegacySystem legacySystem; @@ -355,7 +360,7 @@ public class LegacyBridge : MonoBehaviour { } void OnDisable() { - if (_subscribedSystem != null) { + if (!ReferenceEquals(_subscribedSystem, null)) { _subscribedSystem.OnSomethingHappened -= OnSomethingHappened; } _subscribedSystem = null; From 55c0136551a9aa00b587e856b9ea6bf87df0c678 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 15 Oct 2026 12:22:18 +0000 Subject: [PATCH 039/147] Keep documentation checks active in nested worktrees Classify generated paths within each scanned root. Preserve source discovery beneath .artifacts, obj and bin checkout parents and cover all four documentation scanners. Refs #617. --- .docs-tests/DocsSnippetCompilationTests.cs | 160 +++++++++++++++++++-- 1 file changed, 150 insertions(+), 10 deletions(-) diff --git a/.docs-tests/DocsSnippetCompilationTests.cs b/.docs-tests/DocsSnippetCompilationTests.cs index ae933cca8..f7ac8a31c 100644 --- a/.docs-tests/DocsSnippetCompilationTests.cs +++ b/.docs-tests/DocsSnippetCompilationTests.cs @@ -1312,6 +1312,126 @@ public void XmlDocCodeBlocksCompile(string sourcePath, string snippet) } } + [TestCase("checkout-parent")] + [TestCase(".artifacts")] + [TestCase("obj")] + [TestCase("bin")] + public void DocumentationScansPreserveSourcesUnderGeneratedDirectoryAncestors( + string parentDirectory + ) + { + string temporaryRoot = Path.Combine( + Path.GetTempPath(), + "dxm-docs-scan-" + Guid.NewGuid().ToString("N") + ); + string repoRoot = Path.Combine(temporaryRoot, parentDirectory, "checkout"); + string[] allowedDirectories = { "", "object", "binary", ".artifacts-extra" }; + string[] generatedDirectories = { ".artifacts", "obj", "bin" }; + List xmlPaths = new(); + List allPaths = new(); + List markdownPaths = new(); + const string xmlSource = + "/// int value = 1;\nclass Sample {}"; + try + { + foreach (string root in CSharpScanRoots.Append("Samples~")) + { + foreach (string directory in allowedDirectories) + { + string path = Path.Combine(root, directory, "Real.cs"); + WriteFixtureFile(path, xmlSource); + allPaths.Add(path); + if (root != "Samples~") + { + xmlPaths.Add(path); + } + } + foreach (string directory in generatedDirectories) + { + WriteFixtureFile(Path.Combine(root, directory, "Generated.cs"), xmlSource); + } + } + foreach (string path in new[] { "docs/real.md", "Samples~/real.md", "README.md" }) + { + WriteFixtureFile(path, "A real documentation input."); + allPaths.Add(path); + markdownPaths.Add(path); + } + foreach (string path in new[] { "docs/real.csharp", "docs/overrides/real.html" }) + { + WriteFixtureFile(path, "int value = 1;"); + allPaths.Add(path); + } + foreach (string directory in generatedDirectories) + { + WriteFixtureFile( + Path.Combine("Samples~", directory, "generated.md"), + "Generated input." + ); + } + + Assert.Multiple(() => + { + AssertPaths( + GetXmlDocCodeBlocksFrom(repoRoot).Select(data => (string)data.Arguments[0]!), + xmlPaths, + "XML code blocks" + ); + AssertPaths( + GetTemporaryEmitDocumentationSourcesFrom(repoRoot) + .Select(source => source.Path), + allPaths, + "temporary emit documentation" + ); + AssertPaths( + GetBroadcastExampleSourcesFrom(repoRoot).Select(source => source.Path), + allPaths, + "broadcast examples" + ); + AssertPaths( + EnumeratePublishedMarkdownFilesFrom(repoRoot), + markdownPaths, + "published Markdown" + ); + }); + } + finally + { + if (Directory.Exists(temporaryRoot)) + { + Directory.Delete(temporaryRoot, recursive: true); + } + } + + void WriteFixtureFile(string relativePath, string text) + { + string path = Path.Combine(repoRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, text); + } + + void AssertPaths( + IEnumerable actualPaths, + IEnumerable expectedPaths, + string scan + ) + { + string[] actual = actualPaths + .Select(path => Path.GetRelativePath(repoRoot, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = expectedPaths + .Select(path => path.Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + Assert.That( + actual, + Is.EqualTo(expected), + $"[{parentDirectory}] {scan} must retain real sources and exclude generated files inside the checkout." + ); + } + } + private static readonly string[] CSharpScanRoots = new[] { "Runtime", @@ -1321,7 +1441,11 @@ public void XmlDocCodeBlocksCompile(string sourcePath, string snippet) private static IEnumerable GetXmlDocCodeBlocks() { - string repoRoot = ResolveRepoRoot(); + return GetXmlDocCodeBlocksFrom(ResolveRepoRoot()); + } + + private static IEnumerable GetXmlDocCodeBlocksFrom(string repoRoot) + { int testIndex = 0; foreach (string root in CSharpScanRoots) { @@ -1337,7 +1461,7 @@ string sourcePath in Directory.GetFiles( ) { // Skip generated/cache directories. - string normalized = sourcePath.Replace('\\', '/'); + string normalized = Path.GetRelativePath(repoRoot, sourcePath).Replace('\\', '/'); if ( normalized.Contains("/obj/", StringComparison.Ordinal) || normalized.Contains("/bin/", StringComparison.Ordinal) @@ -1757,8 +1881,14 @@ private static bool LooksLikeCSharpSnippet(string sourceName, string hint, strin private static IEnumerable<(string Path, string Text)> GetTemporaryEmitDocumentationSources() { - string docsRoot = ResolveDocsRoot(); - string repoRoot = ResolveRepoRoot(); + return GetTemporaryEmitDocumentationSourcesFrom(ResolveRepoRoot()); + } + + private static IEnumerable<(string Path, string Text)> GetTemporaryEmitDocumentationSourcesFrom( + string repoRoot + ) + { + string docsRoot = Path.Combine(repoRoot, "docs"); foreach ( string markdownPath in Directory.GetFiles(docsRoot, "*.md", SearchOption.AllDirectories) ) @@ -1828,7 +1958,7 @@ string sourcePath in Directory.GetFiles( ) ) { - string normalized = sourcePath.Replace('\\', '/'); + string normalized = Path.GetRelativePath(repoRoot, sourcePath).Replace('\\', '/'); if ( normalized.Contains("/obj/", StringComparison.Ordinal) || normalized.Contains("/bin/", StringComparison.Ordinal) @@ -1849,8 +1979,14 @@ string sourcePath in Directory.GetFiles( private static IEnumerable<(string Path, string Text)> GetBroadcastExampleSources() { - string repoRoot = ResolveRepoRoot(); - string docsRoot = ResolveDocsRoot(); + return GetBroadcastExampleSourcesFrom(ResolveRepoRoot()); + } + + private static IEnumerable<(string Path, string Text)> GetBroadcastExampleSourcesFrom( + string repoRoot + ) + { + string docsRoot = Path.Combine(repoRoot, "docs"); foreach (string path in EnumerateTextFiles(docsRoot, "*.md")) { @@ -1912,7 +2048,7 @@ private static IEnumerable EnumerateTextFiles(string root, string patter .GetFiles(root, pattern, SearchOption.AllDirectories) .Where(path => { - string normalized = path.Replace('\\', '/'); + string normalized = "/" + Path.GetRelativePath(root, path).Replace('\\', '/'); return !normalized.Contains("/obj/", StringComparison.Ordinal) && !normalized.Contains("/bin/", StringComparison.Ordinal) && !normalized.Contains("/.artifacts/", StringComparison.Ordinal); @@ -1922,8 +2058,12 @@ private static IEnumerable EnumerateTextFiles(string root, string patter private static IEnumerable EnumeratePublishedMarkdownFiles() { - string repoRoot = ResolveRepoRoot(); - string docsRoot = ResolveDocsRoot(); + return EnumeratePublishedMarkdownFilesFrom(ResolveRepoRoot()); + } + + private static IEnumerable EnumeratePublishedMarkdownFilesFrom(string repoRoot) + { + string docsRoot = Path.Combine(repoRoot, "docs"); string samplesRoot = Path.Combine(repoRoot, "Samples~"); string readmePath = Path.Combine(repoRoot, "README.md"); From 2a32e6f808be0887888983a4a8a0f25b1d01ed0c Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 15 Oct 2026 09:30:27 +0000 Subject: [PATCH 040/147] Keep stride benchmark key streams disjoint --- .../Runtime/Benchmarks/TargetMapBenchmarks.cs | 144 ++++++++++++++++-- 1 file changed, 134 insertions(+), 10 deletions(-) diff --git a/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs b/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs index c5ee21ec9..f7b6e166b 100644 --- a/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs +++ b/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs @@ -717,6 +717,9 @@ int expectedCapacity internal static class TargetMapBenchmarkKeys { + // A stride of 4096 yields 2^20 distinct values in the 32-bit keyspace. + // Four disjoint streams can therefore hold at most 2^18 keys each. + private const int MaximumStrideKeysPerStream = 1 << 18; private const uint HashMultiplierOne = 0x7FEB352Du; private const uint HashMultiplierTwo = 0x846CA68Bu; private static readonly uint HashMultiplierOneInverse = MultiplicativeInverse( @@ -736,6 +739,17 @@ internal static TargetMapKeySet Create(TargetMapKeyFamily family, int keyCount) { throw new ArgumentOutOfRangeException(nameof(family), family, null); } + if ( + family == TargetMapKeyFamily.PowerOfTwoStride + && keyCount > MaximumStrideKeysPerStream + ) + { + throw new ArgumentOutOfRangeException( + nameof(keyCount), + keyCount, + $"Stride key counts must be at most {MaximumStrideKeysPerStream} to keep all four streams disjoint." + ); + } int capacity = CapacityForKeyCount(keyCount); HashSet used = new(); @@ -787,7 +801,8 @@ HashSet used key = (index & 1) == 0 ? int.MinValue + offset : int.MaxValue - offset; break; case TargetMapKeyFamily.PowerOfTwoStride: - key = 0x0100_0000 + stream * 0x0100_0000 + index * 4096; + int strideWidth = Math.Max(4096, keyCount); + key = unchecked(0x0100_0000 + (stream * strideWidth + index) * 4096); break; case TargetMapKeyFamily.UniformSeededRandom: do @@ -1492,15 +1507,112 @@ public void ExactTopologyAttributionExplainsDesignedCollisionChurn() [TestCase(TargetMapKeyFamily.DesignedMixerCollision)] public void KeyFamiliesAreDeterministicUniqueAndDisjoint(TargetMapKeyFamily family) { - const int keyCount = 256; + AssertKeyFamiliesAreDeterministicUniqueAndDisjoint(family, 256); + } + + [TestCase(4095)] + [TestCase(4096)] + [TestCase(4097)] + [TestCase(6144)] + [TestCase(6145)] + [TestCase(262144)] + public void StrideKeyStreamsRemainDisjointAcrossRangeBoundaries(int keyCount) + { + AssertKeyFamiliesAreDeterministicUniqueAndDisjoint( + TargetMapKeyFamily.PowerOfTwoStride, + keyCount + ); + TargetMapKeySet keys = TargetMapBenchmarkKeys.Create( + TargetMapKeyFamily.PowerOfTwoStride, + keyCount + ); + Assert.AreEqual( + 0x0100_0000, + keys.RegisteredKeys[0], + $"[PowerOfTwoStride/{keyCount}] Registered stream must keep its original origin." + ); + foreach ( + int[] stream in new[] + { + keys.RegisteredKeys, + keys.ReplacementKeys, + keys.InsideClusterMisses, + keys.OutsideClusterMisses, + } + ) + { + for (int index = 0; index < stream.Length; ++index) + { + Assert.AreEqual( + 0, + stream[index] & 0xFFF, + $"[PowerOfTwoStride/{keyCount}] Key at {index} must retain aligned low bits." + ); + if (index > 0) + { + Assert.AreEqual( + 4096, + unchecked(stream[index] - stream[index - 1]), + $"[PowerOfTwoStride/{keyCount}] Adjacent keys at {index} must retain stride4096, including signed wrap." + ); + } + } + } + } + + [Test] + public void StrideKeyCountBeyondDistinctKeySpaceIsRejected() + { + const int keyCount = 262145; + ArgumentOutOfRangeException error = Assert.Throws( + () => TargetMapBenchmarkKeys.Create(TargetMapKeyFamily.PowerOfTwoStride, keyCount), + $"[PowerOfTwoStride/{keyCount}] Four streams exceed the finite aligned32-bit keyspace." + ); + Assert.AreEqual( + nameof(keyCount), + error.ParamName, + $"[PowerOfTwoStride/{keyCount}] Invalid count parameter must be identified." + ); + Assert.AreEqual( + keyCount, + error.ActualValue, + $"[PowerOfTwoStride/{keyCount}] Invalid count value must be retained." + ); + } + + private static void AssertKeyFamiliesAreDeterministicUniqueAndDisjoint( + TargetMapKeyFamily family, + int keyCount + ) + { TargetMapKeySet first = TargetMapBenchmarkKeys.Create(family, keyCount); TargetMapKeySet second = TargetMapBenchmarkKeys.Create(family, keyCount); - CollectionAssert.AreEqual(first.RegisteredKeys, second.RegisteredKeys); - CollectionAssert.AreEqual(first.ReplacementKeys, second.ReplacementKeys); - CollectionAssert.AreEqual(first.InsideClusterMisses, second.InsideClusterMisses); - CollectionAssert.AreEqual(first.OutsideClusterMisses, second.OutsideClusterMisses); - Assert.AreEqual(first.ExpectedCapacity, second.ExpectedCapacity); + CollectionAssert.AreEqual( + first.RegisteredKeys, + second.RegisteredKeys, + $"[{family}/{keyCount}] Registered keys must be deterministic." + ); + CollectionAssert.AreEqual( + first.ReplacementKeys, + second.ReplacementKeys, + $"[{family}/{keyCount}] Replacement keys must be deterministic." + ); + CollectionAssert.AreEqual( + first.InsideClusterMisses, + second.InsideClusterMisses, + $"[{family}/{keyCount}] Inside misses must be deterministic." + ); + CollectionAssert.AreEqual( + first.OutsideClusterMisses, + second.OutsideClusterMisses, + $"[{family}/{keyCount}] Outside misses must be deterministic." + ); + Assert.AreEqual( + first.ExpectedCapacity, + second.ExpectedCapacity, + $"[{family}/{keyCount}] Expected capacity must be deterministic." + ); HashSet unique = new(); foreach ( @@ -1513,14 +1625,26 @@ int[] stream in new[] } ) { - Assert.AreEqual(keyCount, stream.Length); + Assert.AreEqual( + keyCount, + stream.Length, + $"[{family}/{keyCount}] Every stream must contain the requested count." + ); foreach (int key in stream) { - Assert.That(unique.Add(key), Is.True, $"Duplicate key {key} in {family}."); + Assert.That( + unique.Add(key), + Is.True, + $"[{family}/{keyCount}] Duplicate key {key}." + ); } } - Assert.AreEqual(keyCount * 4, unique.Count); + Assert.AreEqual( + keyCount * 4, + unique.Count, + $"[{family}/{keyCount}] All four streams must be mutually disjoint." + ); } [Test] From b050264cb05cd67e8fb558bf2a06f3d44b67bfac Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 15 Oct 2026 12:42:57 +0000 Subject: [PATCH 041/147] Match benchmark guards to C# style rules --- Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs b/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs index f7b6e166b..e3ba204d9 100644 --- a/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs +++ b/Tests/Runtime/Benchmarks/TargetMapBenchmarks.cs @@ -717,8 +717,10 @@ int expectedCapacity internal static class TargetMapBenchmarkKeys { - // A stride of 4096 yields 2^20 distinct values in the 32-bit keyspace. - // Four disjoint streams can therefore hold at most 2^18 keys each. + /* + A stride of 4096 yields 2^20 distinct values in the 32-bit keyspace. + Four disjoint streams can therefore hold at most 2^18 keys each. + */ private const int MaximumStrideKeysPerStream = 1 << 18; private const uint HashMultiplierOne = 0x7FEB352Du; private const uint HashMultiplierTwo = 0x846CA68Bu; @@ -741,7 +743,7 @@ internal static TargetMapKeySet Create(TargetMapKeyFamily family, int keyCount) } if ( family == TargetMapKeyFamily.PowerOfTwoStride - && keyCount > MaximumStrideKeysPerStream + && MaximumStrideKeysPerStream < keyCount ) { throw new ArgumentOutOfRangeException( @@ -1548,7 +1550,7 @@ int[] stream in new[] stream[index] & 0xFFF, $"[PowerOfTwoStride/{keyCount}] Key at {index} must retain aligned low bits." ); - if (index > 0) + if (0 < index) { Assert.AreEqual( 4096, From 0a1953156a5078628453f44018721816c5841141 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 15 Oct 2026 13:38:53 +0000 Subject: [PATCH 042/147] Cover untargeted global listener addition during dispatch --- Tests/Runtime/Core/MutationGlobalAddTests.cs | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Tests/Runtime/Core/MutationGlobalAddTests.cs b/Tests/Runtime/Core/MutationGlobalAddTests.cs index 014b69960..bc0824a14 100644 --- a/Tests/Runtime/Core/MutationGlobalAddTests.cs +++ b/Tests/Runtime/Core/MutationGlobalAddTests.cs @@ -16,16 +16,11 @@ public sealed class MutationGlobalAddTests : MessagingTestBase /// A global-accept-all listener registered from inside a handler's body /// during emission must not fire in the current emission (snapshot /// semantics) but must fire on the next emission. Parameterized over the - /// kinds that carry a component target (Targeted, Broadcast) via - /// ; the prior - /// Targeted/Broadcast pair collapsed into this single method. + /// three message kinds via . /// [Test] public void GlobalAcceptAllAddDuringEmission( - [ValueSource( - typeof(MessageScenarios), - nameof(MessageScenarios.KindsWithComponentTarget) - )] + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] MessageScenario scenario ) { @@ -96,6 +91,14 @@ Action onInvoked { switch (scenario.Kind) { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (in SimpleUntargetedMessage _) => onInvoked() + ); + } case MessageKind.Targeted: { return ScenarioHarness.RegisterTargeted( @@ -119,7 +122,7 @@ Action onInvoked throw new ArgumentOutOfRangeException( nameof(scenario), scenario.Kind, - "MutationGlobalAddTests only covers component-target kinds." + "MutationGlobalAddTests requires a supported message kind." ); } } @@ -129,6 +132,12 @@ private static void EmitForScenario(MessageScenario scenario, InstanceId target) { switch (scenario.Kind) { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } case MessageKind.Targeted: { SimpleTargetedMessage message = new(); @@ -146,7 +155,7 @@ private static void EmitForScenario(MessageScenario scenario, InstanceId target) throw new ArgumentOutOfRangeException( nameof(scenario), scenario.Kind, - "MutationGlobalAddTests only covers component-target kinds." + "MutationGlobalAddTests requires a supported message kind." ); } } From 4926c7a96a3d2705e771ec6b3bfbfd6b42c53b4a Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 16 Oct 2026 06:28:45 +0000 Subject: [PATCH 043/147] Fix registration handles used with a different bus --- .net-tests/DxMessaging.Net.Tests.csproj | 4 + .net-tests/RuntimeContractTests.cs | 64 ++ CHANGELOG.md | 2 + Runtime/Core/MessageBus/IMessageBus.cs | 3 +- Runtime/Core/MessageBus/MessageBus.cs | 17 + .../Core/MessageBus/MessageBusRegistration.cs | 14 +- .../Core/ForeignBusRegistrationTests.cs | 569 ++++++++++++++++++ .../Core/ForeignBusRegistrationTests.cs.meta | 11 + docs/reference/quick-reference.md | 5 +- 9 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 Tests/Runtime/Core/ForeignBusRegistrationTests.cs create mode 100644 Tests/Runtime/Core/ForeignBusRegistrationTests.cs.meta diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index e6c1aac91..c7a700f93 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -27,5 +27,9 @@ Include="../Tests/Runtime/TestUtilities/DelegatingMessageBus.cs" Link="DelegatingMessageBus.cs" /> + diff --git a/.net-tests/RuntimeContractTests.cs b/.net-tests/RuntimeContractTests.cs index eac355395..3f65ca2ab 100644 --- a/.net-tests/RuntimeContractTests.cs +++ b/.net-tests/RuntimeContractTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using DxMessaging.Core; using DxMessaging.Core.MessageBus; using DxMessaging.Core.Messages; @@ -263,6 +264,69 @@ void Emit() } } + [Test] + public void ConsumedBusHandleDoesNotRetainItsOwner() + { + ConsumedHandleProbe empty = CreateConsumedHandleProbe(retainHandle: false); + ConsumedHandleProbe retained = CreateConsumedHandleProbe(retainHandle: true); + CollectConsumedHandleOwners(); + bool emptyOwnerAlive = empty.Owner.IsAlive; + bool retainedOwnerAlive = retained.Owner.IsAlive; + retained.Handle = default; + CollectConsumedHandleOwners(); + bool ownerAliveAfterClear = retained.Owner.IsAlive; + GC.KeepAlive(empty); + GC.KeepAlive(retained); + Assert.Multiple(() => + { + Assert.That( + emptyOwnerAlive, + Is.False, + "The empty-handle control must release its private bus." + ); + Assert.That( + retainedOwnerAlive, + Is.False, + "A consumed raw handle must not retain its bus and unrelated subscriptions." + ); + Assert.That( + ownerAliveAfterClear, + Is.False, + "Clearing the handle must also release the owner." + ); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ConsumedHandleProbe CreateConsumedHandleProbe(bool retainHandle) + { + MessageBus differentBus = new MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(704), differentBus); + MessageBus origin = new MessageBus(); + using LeakWatcher leaks = new LeakWatcher(origin); + MessageBusRegistration registration = origin.RegisterUntargeted(handler); + origin.Deregister(in registration); + return new ConsumedHandleProbe + { + Handle = retainHandle ? registration : default, + Owner = new WeakReference(origin), + }; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void CollectConsumedHandleOwners() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private sealed class ConsumedHandleProbe + { + internal MessageBusRegistration Handle; + internal WeakReference Owner; + } + private readonly struct StructMessage : IUntargetedMessage, ITargetedMessage, IBroadcastMessage { public readonly int Number; diff --git a/CHANGELOG.md b/CHANGELOG.md index b26f47d6d..29aa2cd38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix direct registration handles so passing one to a different bus preserves both buses, and global + registrations on different buses keep distinct identities ([#637](https://github.com/Ambiguous-Interactive/DxMessaging/issues/637)). - Fix source generation when generic and nongeneric types have colliding generated file names ([#624](https://github.com/Ambiguous-Interactive/DxMessaging/issues/624)). - Fix generated message declarations and constructors failing for escaped C# identifiers diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index f3a1cd611..6e5081902 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -581,7 +581,8 @@ MessageBusRegistration RegisterBroadcastWithoutSourcePostProcessor( /// /// Deregistration is idempotent at the handle level only in the sense that a handle that /// has outlived a reset/domain-reload, or a / - /// foreign handle, is a silent no-op. Calling it twice for the same live registration is a + /// foreign handle, is a silent no-op. Built-in handles passed to a different bus instance + /// also leave both buses unchanged. Calling it twice for the same live registration is a /// genuine over-deregistration and is logged (matching the pre-v4 behaviour of invoking /// the returned deregistration action twice). /// diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 52f7a4eae..582d4ce99 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -1390,6 +1390,12 @@ the lifecycle of the typed sinks (cleared together in ResetState, private readonly BusGlobalSlot _globalSlots = new(); + /* + A retained handle keeps this identity marker alive without retaining its bus. + Reset preserves the marker; reset generations reject pre-reset registrations. + */ + private readonly object _registrationOwner = new object(); + /* P1 emit-preamble plans: one per (bus, type, kind), validated by a single bus-wide version stamp (see DispatchPlan / @@ -3905,6 +3911,7 @@ liveCount mirrors sharedHandlers.Count at every stable long capturedGeneration = _resetGeneration; long capturedSweepGeneration = _globalSlotSweepGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.GlobalAcceptAll, RegistrationMethod.GlobalAcceptAll, 0, @@ -4059,6 +4066,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.UntargetedInterceptor, RegistrationMethod.Interceptor, priority, @@ -4131,6 +4139,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.TargetedInterceptor, RegistrationMethod.Interceptor, priority, @@ -4203,6 +4212,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.BroadcastInterceptor, RegistrationMethod.Interceptor, priority, @@ -6212,6 +6222,7 @@ NOT move it. long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.Handler, registrationMethod, priority, @@ -6441,6 +6452,7 @@ NOT move it. long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.Handler, registrationMethod, priority, @@ -6578,6 +6590,11 @@ bucket currently at this priority -- the NEW live bucket after a full deregister public void Deregister(in MessageBusRegistration registration) where T : IMessage { + if (!ReferenceEquals(registration.owner, _registrationOwner)) + { + return; + } + switch (registration.kind) { case MessageBusRegistration.Kind.Handler: diff --git a/Runtime/Core/MessageBus/MessageBusRegistration.cs b/Runtime/Core/MessageBus/MessageBusRegistration.cs index 686fd0b15..63d9af837 100644 --- a/Runtime/Core/MessageBus/MessageBusRegistration.cs +++ b/Runtime/Core/MessageBus/MessageBusRegistration.cs @@ -14,7 +14,9 @@ namespace DxMessaging.Core.MessageBus /// copy it freely, but do NOT inspect or persist its internals. Handles are /// process-local, single-thread, and NOT serializable; a handle is meaningless after a /// domain reload (its captured generation no longer matches, so deregistration becomes a - /// silent no-op) and is not portable across implementations. + /// silent no-op). A built-in handle belongs to its originating bus instance; passing it to + /// another bus is a silent no-op. Custom handles are not portable across + /// implementations. /// /// /// The default value is the sentinel; deregistering it is a @@ -47,6 +49,9 @@ internal enum Kind : byte /// => an invalid/empty handle; deregistration no-ops. internal readonly Kind kind; + /// The originating bus identity marker; null for None and External handles. + internal readonly object owner; + /// /// Sink/scalar-vs-keyed discriminator AND the row key for /// the shape. Unused (default) for the other kinds. @@ -102,6 +107,7 @@ internal enum Kind : byte internal readonly InstanceId context; internal MessageBusRegistration( + object owner, Kind kind, RegistrationMethod method, int priority, @@ -114,6 +120,7 @@ internal MessageBusRegistration( InstanceId context ) { + this.owner = owner; this.kind = kind; this.method = method; this.priority = priority; @@ -136,6 +143,7 @@ InstanceId context /// public MessageBusRegistration(long externalId, object externalState) : this( + null, Kind.External, default, 0, @@ -162,7 +170,8 @@ public MessageBusRegistration(long externalId, object externalState) /// public bool Equals(MessageBusRegistration other) => - kind == other.kind + ReferenceEquals(owner, other.owner) + && kind == other.kind && method == other.method && priority == other.priority && generation == other.generation @@ -188,6 +197,7 @@ public override int GetHashCode() unnecessary collisions those overrides would introduce. (null -> 0.) */ int hash = (int)kind; + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(owner); hash = (hash * 397) ^ (int)method; hash = (hash * 397) ^ priority; hash = (hash * 397) ^ generation.GetHashCode(); diff --git a/Tests/Runtime/Core/ForeignBusRegistrationTests.cs b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs new file mode 100644 index 000000000..3d47ed6c9 --- /dev/null +++ b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs @@ -0,0 +1,569 @@ +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using NUnit.Framework; + + /// Registration handles belong to the bus that produced them. + /// + /// 2026-10-16: a foreign handle could remove an origin handler route or a receiver global + /// registration because fresh buses shared reset generations. Copies retain the same owner. + /// + [TestFixture] +#if !UNITY_2021_3_OR_NEWER + [NonParallelizable] +#endif + public sealed class ForeignBusRegistrationTests + { + private static readonly InstanceId Context = new InstanceId(702); + + [Test] + public void ForeignHandlerHandlePreservesBothBuses( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext) + )] + MessageScenario scenario, + [Values(false, true)] bool receiverHasRegistration, + [Values(false, true)] bool diagnostics + ) + { + using DebugCapture capture = new DebugCapture(diagnostics); + MessageBus origin = new MessageBus(); + MessageBus receiver = new MessageBus(); + using LeakWatcher originLeaks = new LeakWatcher(origin); + using LeakWatcher receiverLeaks = new LeakWatcher(receiver); + MessageHandler handler = new MessageHandler(new InstanceId(701), origin); + MessageBusRegistration original = RegisterHandler(origin, handler, scenario); + MessageBusRegistration other = receiverHasRegistration + ? RegisterHandler(receiver, handler, scenario) + : MessageBusRegistration.None; + try + { + Assert.That(CountHandlers(origin, scenario), Is.EqualTo(1), scenario.ToString()); + Assert.That( + CountHandlers(receiver, scenario), + Is.EqualTo(receiverHasRegistration ? 1 : 0), + scenario.ToString() + ); + capture.Messages.Clear(); + MessageBusRegistration copy = original; + receiver.Deregister(in copy); + int originAfterForeign = CountHandlers(origin, scenario); + int receiverAfterForeign = CountHandlers(receiver, scenario); + int foreignLogs = capture.Messages.Count; + origin.Deregister(in original); + original = MessageBusRegistration.None; + AssertAll(() => + { + string label = + $"[{scenario}, receiver={receiverHasRegistration}, diagnostics={diagnostics}]"; + Assert.That( + originAfterForeign, + Is.EqualTo(1), + label + " origin route changed." + ); + Assert.That( + receiverAfterForeign, + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " receiver route changed." + ); + Assert.That(foreignLogs, Is.Zero, label + " foreign deregistration logged."); + Assert.That( + CountHandlers(origin, scenario), + Is.Zero, + label + " owner cleanup failed." + ); + Assert.That( + CountHandlers(receiver, scenario), + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " owner cleanup changed receiver." + ); + }); + } + finally + { + origin.Deregister(in original); + receiver.Deregister(in other); + origin.Trim(force: true); + receiver.Trim(force: true); + } + } + + [Test] + public void ForeignGlobalHandlePreservesBothBuses( + [Values(false, true)] bool receiverHasRegistration, + [Values(false, true)] bool diagnostics + ) + { + using DebugCapture capture = new DebugCapture(diagnostics); + MessageBus origin = new MessageBus(); + MessageBus receiver = new MessageBus(); + using LeakWatcher originLeaks = new LeakWatcher(origin); + using LeakWatcher receiverLeaks = new LeakWatcher(receiver); + MessageHandler handler = new MessageHandler(new InstanceId(701), origin); + MessageBusRegistration original = origin.RegisterGlobalAcceptAll(handler); + MessageBusRegistration other = receiverHasRegistration + ? receiver.RegisterGlobalAcceptAll(handler) + : MessageBusRegistration.None; + try + { + capture.Messages.Clear(); + MessageBusRegistration copy = original; + receiver.Deregister(in copy); + int originAfterForeign = origin.RegisteredGlobalAcceptAll; + int receiverAfterForeign = receiver.RegisteredGlobalAcceptAll; + int foreignLogs = capture.Messages.Count; + origin.Deregister(in original); + original = MessageBusRegistration.None; + AssertAll(() => + { + string label = + $"[receiver={receiverHasRegistration}, diagnostics={diagnostics}]"; + Assert.That( + originAfterForeign, + Is.EqualTo(1), + label + " origin global route changed." + ); + Assert.That( + receiverAfterForeign, + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " receiver global route changed." + ); + Assert.That( + foreignLogs, + Is.Zero, + label + " foreign global deregistration logged." + ); + Assert.That( + origin.RegisteredGlobalAcceptAll, + Is.Zero, + label + " owner cleanup failed." + ); + Assert.That( + receiver.RegisteredGlobalAcceptAll, + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " owner cleanup changed receiver." + ); + }); + } + finally + { + origin.Deregister(in original); + receiver.Deregister(in other); + origin.Trim(force: true); + receiver.Trim(force: true); + } + } + + [Test] + public void ForeignInterceptorHandlePreservesCallbacksAndOwnerCleanup( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool receiverHasRegistration, + [Values(false, true)] bool diagnostics + ) + { + using DebugCapture capture = new DebugCapture(diagnostics); + MessageBus origin = new MessageBus(); + MessageBus receiver = new MessageBus(); + using LeakWatcher originLeaks = new LeakWatcher(origin); + using LeakWatcher receiverLeaks = new LeakWatcher(receiver); + int calls = 0; + bool Intercept(ref ProbeMessage message) + { + calls++; + return false; + } + bool InterceptContext(ref InstanceId context, ref ProbeMessage message) + { + calls++; + return false; + } + IMessageBus.UntargetedInterceptor untargeted = Intercept; + IMessageBus.TargetedInterceptor targeted = InterceptContext; + IMessageBus.BroadcastInterceptor broadcast = InterceptContext; + MessageBusRegistration Register(MessageBus bus) => + scenario.Kind switch + { + MessageKind.Untargeted => bus.RegisterUntargetedInterceptor(untargeted), + MessageKind.Targeted => bus.RegisterTargetedInterceptor(targeted), + MessageKind.Broadcast => bus.RegisterBroadcastInterceptor(broadcast), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + MessageBusRegistration original = Register(origin); + MessageBusRegistration other = receiverHasRegistration + ? Register(receiver) + : MessageBusRegistration.None; + try + { + capture.Messages.Clear(); + MessageBusRegistration copy = original; + receiver.Deregister(in copy); + int foreignLogs = capture.Messages.Count; + Emit(origin, scenario); + if (receiverHasRegistration) + { + Emit(receiver, scenario); + } + int callsAfterForeign = calls; + int originAfterForeign = origin.RegisteredInterceptors; + int receiverAfterForeign = receiver.RegisteredInterceptors; + origin.Deregister(in original); + original = MessageBusRegistration.None; + AssertAll(() => + { + string label = + $"[{scenario}, receiver={receiverHasRegistration}, diagnostics={diagnostics}]"; + Assert.That( + originAfterForeign, + Is.EqualTo(1), + label + " origin interceptor changed." + ); + Assert.That( + receiverAfterForeign, + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " receiver interceptor changed." + ); + Assert.That( + callsAfterForeign, + Is.EqualTo(receiverHasRegistration ? 2 : 1), + label + " callbacks changed." + ); + Assert.That( + foreignLogs, + Is.Zero, + label + " foreign interceptor deregistration logged." + ); + Assert.That( + origin.RegisteredInterceptors, + Is.Zero, + label + " owner cleanup failed." + ); + }); + } + finally + { + origin.Deregister(in original); + receiver.Deregister(in other); + origin.Trim(force: true); + receiver.Trim(force: true); + } + } + + [Test] + public void ForeignHandleCannotConsumeTheOwnersLaterValidDeregistration( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool receiverHasRegistration, + [Values(false, true)] bool diagnostics + ) + { + using DebugCapture capture = new DebugCapture(diagnostics); + MessageBus origin = new MessageBus(); + MessageBus receiver = new MessageBus(); + using LeakWatcher originLeaks = new LeakWatcher(origin); + using LeakWatcher receiverLeaks = new LeakWatcher(receiver); + MessageHandler handler = new MessageHandler(new InstanceId(701), origin) + { + active = true, + }; + using MessageRegistrationToken token = MessageRegistrationToken.Create(handler, origin); + using MessageRegistrationToken otherToken = MessageRegistrationToken.Create( + handler, + receiver + ); + int originCalls = 0; + int receiverCalls = 0; + void ReceiveOrigin(in ProbeMessage message) => originCalls++; + void ReceiveReceiver(in ProbeMessage message) => receiverCalls++; + RegisterCallback(token, scenario, ReceiveOrigin); + token.Enable(); + if (receiverHasRegistration) + { + RegisterCallback(otherToken, scenario, ReceiveReceiver); + otherToken.Enable(); + } + MessageBusRegistration original = RegisterHandler(origin, handler, scenario); + try + { + Emit(origin, scenario); + Assert.That(originCalls, Is.EqualTo(1), "Initial origin callback must be live."); + originCalls = 0; + MessageBusRegistration copy = original; + receiver.Deregister(in copy); + origin.Deregister(in original); + original = MessageBusRegistration.None; + Emit(origin, scenario); + if (receiverHasRegistration) + { + Emit(receiver, scenario); + } + AssertAll(() => + { + string label = + $"[{scenario}, receiver={receiverHasRegistration}, diagnostics={diagnostics}]"; + Assert.That( + originCalls, + Is.EqualTo(1), + label + + " foreign handle consumed the owner registration and lost its peer callback." + ); + Assert.That( + receiverCalls, + Is.EqualTo(receiverHasRegistration ? 1 : 0), + label + " receiver callback changed." + ); + }); + } + finally + { + origin.Deregister(in original); + token.UnregisterAll(); + otherToken.UnregisterAll(); + origin.Trim(force: true); + receiver.Trim(force: true); + handler.active = false; + } + } + + [Test] + public void GlobalHandlesFromDifferentBusesHaveDistinctValueIdentity() + { + using DebugCapture capture = new DebugCapture(false); + MessageBus firstBus = new MessageBus(); + MessageBus secondBus = new MessageBus(); + using LeakWatcher firstLeaks = new LeakWatcher(firstBus); + using LeakWatcher secondLeaks = new LeakWatcher(secondBus); + MessageHandler handler = new MessageHandler(new InstanceId(701), firstBus); + MessageBusRegistration first = firstBus.RegisterGlobalAcceptAll(handler); + MessageBusRegistration second = secondBus.RegisterGlobalAcceptAll(handler); + try + { + MessageBusRegistration copy = first; + HashSet handles = new HashSet + { + first, + copy, + second, + }; + AssertAll(() => + { + Assert.That( + first, + Is.EqualTo(copy), + "A copy must retain registration identity." + ); + Assert.That( + first.GetHashCode(), + Is.EqualTo(copy.GetHashCode()), + "Equal copies must have equal hashes." + ); + Assert.That( + first, + Is.Not.EqualTo(second), + "Global registrations from different buses must have distinct identities." + ); + Assert.That( + handles.Count, + Is.EqualTo(2), + "A handle set must retain both bus registrations and deduplicate only the copy." + ); + }); + } + finally + { + firstBus.Deregister(in first); + secondBus.Deregister(in second); + firstBus.Trim(force: true); + secondBus.Trim(force: true); + } + } + + [Test] + public void EmptyAndExternalHandlesPreserveLiveRegistrations( + [Values(false, true)] bool diagnostics + ) + { + using DebugCapture capture = new DebugCapture(diagnostics); + MessageBus bus = new MessageBus(); + using LeakWatcher leaks = new LeakWatcher(bus); + MessageHandler handler = new MessageHandler(new InstanceId(701), bus); + MessageBusRegistration live = bus.RegisterUntargeted(handler); + try + { + MessageBusRegistration none = MessageBusRegistration.None; + object state = new object(); + MessageBusRegistration external = new MessageBusRegistration(703, state); + capture.Messages.Clear(); + bus.Deregister(in none); + bus.Deregister(in external); + AssertAll(() => + { + string label = $"[diagnostics={diagnostics}]"; + Assert.That( + bus.RegisteredUntargeted, + Is.EqualTo(1), + label + " empty/external handle changed live route." + ); + Assert.That( + capture.Messages.Count, + Is.Zero, + label + " empty/external handle logged." + ); + Assert.That( + external.ExternalId, + Is.EqualTo(703), + label + " external id round trip changed." + ); + Assert.That( + external.ExternalState, + Is.SameAs(state), + label + " external state round trip changed." + ); + }); + } + finally + { + bus.Deregister(in live); + bus.Trim(force: true); + } + } + + private static MessageBusRegistration RegisterHandler( + MessageBus bus, + MessageHandler handler, + MessageScenario scenario + ) => + (scenario.Kind, scenario.UsePostProcessor) switch + { + (MessageKind.Untargeted, false) => bus.RegisterUntargeted(handler), + (MessageKind.Untargeted, true) => bus.RegisterUntargetedPostProcessor( + handler + ), + (MessageKind.Targeted, false) => bus.RegisterTargeted( + Context, + handler + ), + (MessageKind.Targeted, true) => bus.RegisterTargetedPostProcessor( + Context, + handler + ), + (MessageKind.Broadcast, false) => bus.RegisterSourcedBroadcast( + Context, + handler + ), + (MessageKind.Broadcast, true) => bus.RegisterBroadcastPostProcessor( + Context, + handler + ), + (MessageKind.TargetedWithoutTargeting, false) => + bus.RegisterTargetedWithoutTargeting(handler), + (MessageKind.TargetedWithoutTargeting, true) => + bus.RegisterTargetedWithoutTargetingPostProcessor(handler), + (MessageKind.BroadcastWithoutSource, false) => + bus.RegisterSourcedBroadcastWithoutSource(handler), + (MessageKind.BroadcastWithoutSource, true) => + bus.RegisterBroadcastWithoutSourcePostProcessor(handler), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + + private static int CountHandlers(MessageBus bus, MessageScenario scenario) => + scenario.UsePostProcessor + ? bus.RegisteredPostProcessors + : scenario.Kind switch + { + MessageKind.Untargeted => bus.RegisteredUntargeted, + MessageKind.Targeted or MessageKind.TargetedWithoutTargeting => + bus.RegisteredTargeted, + MessageKind.Broadcast or MessageKind.BroadcastWithoutSource => + bus.RegisteredBroadcast, + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + + private static void RegisterCallback( + MessageRegistrationToken token, + MessageScenario scenario, + MessageHandler.FastHandler callback + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + ScenarioHarness.RegisterUntargeted(scenario, token, callback); + break; + case MessageKind.Targeted: + ScenarioHarness.RegisterTargeted(scenario, token, Context, callback); + break; + case MessageKind.Broadcast: + ScenarioHarness.RegisterBroadcast(scenario, token, Context, callback); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + + private static void Emit(MessageBus bus, MessageScenario scenario) + { + ProbeMessage message = default; + InstanceId context = Context; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + bus.UntargetedBroadcast(ref message); + break; + case MessageKind.Targeted: + bus.TargetedBroadcast(ref context, ref message); + break; + case MessageKind.Broadcast: + bus.SourcedBroadcast(ref context, ref message); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + + /* + Unity's bundled NUnit reports the first failure; .NET NUnit aggregates failures. + Both branches execute the same assertion predicates in the same order. + */ + private static void AssertAll(Action assertions) + { +#if UNITY_2021_3_OR_NEWER + assertions(); +#else + Assert.Multiple(assertions); +#endif + } + + private readonly struct ProbeMessage + : IUntargetedMessage, + ITargetedMessage, + IBroadcastMessage + { + public Type MessageType => typeof(ProbeMessage); + } + + private sealed class DebugCapture : IDisposable + { + private readonly bool _enabled = MessagingDebug.enabled; + private readonly Action _logger = MessagingDebug.LogFunction; + internal readonly List Messages = new List(); + + internal DebugCapture(bool enabled) + { + MessagingDebug.enabled = enabled; + MessagingDebug.LogFunction = (_, text) => Messages.Add(text); + } + + public void Dispose() + { + MessagingDebug.LogFunction = _logger; + MessagingDebug.enabled = _enabled; + } + } + } +} diff --git a/Tests/Runtime/Core/ForeignBusRegistrationTests.cs.meta b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs.meta new file mode 100644 index 000000000..152a859c2 --- /dev/null +++ b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c59c15ed4f4d41939c73829d0a569f67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/reference/quick-reference.md b/docs/reference/quick-reference.md index fd857c511..87ad77e2e 100644 --- a/docs/reference/quick-reference.md +++ b/docs/reference/quick-reference.md @@ -160,8 +160,9 @@ _ = token.RegisterUntargetedPostProcessor( ``` Direct bus registrations are not token-owned. Retain each `MessageBusRegistration` on the -owner and call `Deregister(in registration)` with the same message type during teardown. -Use `IMessage` as `T` for `RegisterGlobalAcceptAll`. +owner and call `Deregister(in registration)` on the same bus instance with the same message type +during teardown. Use `IMessage` as `T` for `RegisterGlobalAcceptAll`. Since 4.0, passing a built-in +handle to another bus is a no-op; it leaves both buses unchanged. ## Lifecycle From 0edfe3a6a7f82c6d56e083725b9809d9c27cdad9 Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 16 Oct 2026 11:51:27 +0000 Subject: [PATCH 044/147] Align Node CI with dependency engine requirements --- .github/workflows/ci.yml | 18 +++++++++--------- .github/workflows/format-on-demand.yml | 4 ++-- .github/workflows/perf-numbers.yml | 6 +++--- .github/workflows/post-merge-maintenance.yml | 2 +- .github/workflows/prettier-autofix.yml | 2 +- .github/workflows/release-drafter.yml | 2 +- .github/workflows/release-prepare.yml | 2 +- .github/workflows/release.yml | 8 ++++---- .github/workflows/runner-bootstrap.yml | 2 +- .github/workflows/sync-wiki.yml | 2 +- .github/workflows/unity-benchmarks.yml | 2 +- .github/workflows/unity-tests.yml | 2 +- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3aa5b844..04f649c64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,7 +223,7 @@ jobs: if: ${{ needs.changes.outputs.actionlint != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Validate Unity version single-source if: ${{ needs.changes.outputs.actionlint != 'false' }} @@ -257,7 +257,7 @@ jobs: if: ${{ needs.changes.outputs.markdown != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json @@ -355,7 +355,7 @@ jobs: if: ${{ needs.changes.outputs.dotnet != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install reportgenerator tool if: ${{ needs.changes.outputs.dotnet != 'false' }} @@ -496,7 +496,7 @@ jobs: if: ${{ needs.changes.outputs.json != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json @@ -580,7 +580,7 @@ jobs: if: ${{ needs.changes.outputs.spellcheck != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json @@ -616,7 +616,7 @@ jobs: if: ${{ needs.changes.outputs.banner != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Validate banner metadata if: ${{ needs.changes.outputs.banner != 'false' }} @@ -651,7 +651,7 @@ jobs: if: ${{ needs.changes.outputs.llms != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json @@ -696,7 +696,7 @@ jobs: if: ${{ needs.changes.outputs.yaml != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json @@ -763,7 +763,7 @@ jobs: if: ${{ needs.changes.outputs.scripts != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json diff --git a/.github/workflows/format-on-demand.yml b/.github/workflows/format-on-demand.yml index db168f59b..7f24f2cc8 100644 --- a/.github/workflows/format-on-demand.yml +++ b/.github/workflows/format-on-demand.yml @@ -66,7 +66,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -190,7 +190,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index bfeade103..7fb9c944d 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -328,7 +328,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install artifact tooling dependencies id: install_dependencies @@ -1368,7 +1368,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install reporting dependencies if: needs.perf-benchmarks.result == 'success' @@ -1766,7 +1766,7 @@ jobs: if: steps.default-branch.outputs.fresh == 'true' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: npm cache-dependency-path: package.json diff --git a/.github/workflows/post-merge-maintenance.yml b/.github/workflows/post-merge-maintenance.yml index 3754e249a..4b59dd59a 100644 --- a/.github/workflows/post-merge-maintenance.yml +++ b/.github/workflows/post-merge-maintenance.yml @@ -129,7 +129,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: npm cache-dependency-path: package.json diff --git a/.github/workflows/prettier-autofix.yml b/.github/workflows/prettier-autofix.yml index 253a1bcbc..9e754cf4e 100644 --- a/.github/workflows/prettier-autofix.yml +++ b/.github/workflows/prettier-autofix.yml @@ -53,7 +53,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" cache: "npm" cache-dependency-path: package.json diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index ed637ba99..9313b7468 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -32,7 +32,7 @@ jobs: # under Node; pin it instead of the runner's ambient version. uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Diagnose release inputs run: | diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 3951d3f25..c4f5e2939 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -105,7 +105,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Setup .NET # validate:all runs check:analyzers, which builds the source diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54216c664..8999a23ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Setup .NET # validate:all runs check:analyzers, which builds the source @@ -238,7 +238,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install artifact tooling dependencies id: install_dependencies @@ -477,7 +477,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Create the .unitypackage id: create_unitypackage @@ -560,7 +560,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" registry-url: "https://registry.npmjs.org" - name: Download release package diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index 42f6a9b94..79914318c 100644 --- a/.github/workflows/runner-bootstrap.yml +++ b/.github/workflows/runner-bootstrap.yml @@ -302,7 +302,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install artifact tooling dependencies id: install_dependencies diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml index ce69a1381..a0ff56ca5 100644 --- a/.github/workflows/sync-wiki.yml +++ b/.github/workflows/sync-wiki.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Transform and copy documentation run: | diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index a26996677..de9ea5b2d 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -111,7 +111,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install artifact tooling dependencies id: install_dependencies diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 2f1911610..6983919e8 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -234,7 +234,7 @@ jobs: timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.18.0" + node-version: "22.22.2" - name: Install artifact tooling dependencies id: install_dependencies From 885f51dedc40d27ac3f0e7a1f3abcf42232ef344 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 14 Oct 2026 10:20:13 +0000 Subject: [PATCH 045/147] Fix object searches on Unity 6000.4 --- CHANGELOG.md | 2 ++ Editor/Windows/DxMessagingFlowGraphWindow.cs | 2 +- Editor/Windows/DxMessagingMessageMonitorWindow.cs | 2 +- .../DiagnosticsToolingExerciser.cs | 2 +- .../DiagnosticsToolingReceiver.cs | 2 +- .../Editor/DiagnosticsToolingGuideWindow.cs | 4 ++-- Tests/Editor/DxMessagingFlowGraphWindowTests.cs | 6 ++++-- Tests/Editor/DxMessagingMessageMonitorWindowTests.cs | 8 ++++++-- 8 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29aa2cd38..fd4db3a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Remove obsolete object-search warnings from Message Monitor, Flow Graph, and the diagnostics + sample on Unity 6000.4 ([#628](https://github.com/Ambiguous-Interactive/DxMessaging/issues/628)). - Fix direct registration handles so passing one to a different bus preserves both buses, and global registrations on different buses keep distinct identities ([#637](https://github.com/Ambiguous-Interactive/DxMessaging/issues/637)). - Fix source generation when generic and nongeneric types have colliding generated file names diff --git a/Editor/Windows/DxMessagingFlowGraphWindow.cs b/Editor/Windows/DxMessagingFlowGraphWindow.cs index 68dcf9f8e..26414ebf0 100644 --- a/Editor/Windows/DxMessagingFlowGraphWindow.cs +++ b/Editor/Windows/DxMessagingFlowGraphWindow.cs @@ -2170,7 +2170,7 @@ private static bool IsSceneComponent(MessagingComponent component) private static MessagingComponent[] FindMessagingComponentsInLoadedScenes() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER return UnityEngine.Object.FindObjectsByType( FindObjectsInactive.Include ); diff --git a/Editor/Windows/DxMessagingMessageMonitorWindow.cs b/Editor/Windows/DxMessagingMessageMonitorWindow.cs index 69d0af80e..1883b4f36 100644 --- a/Editor/Windows/DxMessagingMessageMonitorWindow.cs +++ b/Editor/Windows/DxMessagingMessageMonitorWindow.cs @@ -2004,7 +2004,7 @@ private static bool IsSceneComponent(MessagingComponent component) private static MessagingComponent[] FindMessagingComponentsInLoadedScenes() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER return UnityEngine.Object.FindObjectsByType( FindObjectsInactive.Include ); diff --git a/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs b/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs index e5fbe121d..f3ba49575 100644 --- a/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs +++ b/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingExerciser.cs @@ -81,7 +81,7 @@ private static void BeginPlayGeneration() [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void InitializeAfterSceneLoad() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER DiagnosticsToolingExerciser[] runners = FindObjectsByType( FindObjectsInactive.Exclude ); diff --git a/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs b/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs index 1461b0641..cc763dbf5 100644 --- a/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs +++ b/Samples~/Diagnostics Tooling Exerciser/DiagnosticsToolingReceiver.cs @@ -83,7 +83,7 @@ private static void BeginPlayGeneration() [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void RestoreRegistrationsAfterSceneLoad() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER DiagnosticsToolingReceiver[] receivers = FindObjectsByType( FindObjectsInactive.Exclude ); diff --git a/Samples~/Diagnostics Tooling Exerciser/Editor/DiagnosticsToolingGuideWindow.cs b/Samples~/Diagnostics Tooling Exerciser/Editor/DiagnosticsToolingGuideWindow.cs index b0e774dae..11239c3e0 100644 --- a/Samples~/Diagnostics Tooling Exerciser/Editor/DiagnosticsToolingGuideWindow.cs +++ b/Samples~/Diagnostics Tooling Exerciser/Editor/DiagnosticsToolingGuideWindow.cs @@ -313,7 +313,7 @@ string listenerLabel private static DiagnosticsToolingExerciser FindRunner() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER return Object.FindAnyObjectByType( FindObjectsInactive.Exclude ); @@ -328,7 +328,7 @@ private static DiagnosticsToolingExerciser FindRunner() private static DiagnosticsToolingReceiver[] FindReceivers() { -#if UNITY_6000_5_OR_NEWER +#if UNITY_6000_4_OR_NEWER return Object.FindObjectsByType( FindObjectsInactive.Exclude ); diff --git a/Tests/Editor/DxMessagingFlowGraphWindowTests.cs b/Tests/Editor/DxMessagingFlowGraphWindowTests.cs index 76f286a16..c2484b873 100644 --- a/Tests/Editor/DxMessagingFlowGraphWindowTests.cs +++ b/Tests/Editor/DxMessagingFlowGraphWindowTests.cs @@ -11102,8 +11102,9 @@ public void CaptureSnapshotDoesNotResolveSerializedProviders() Assert.That(snapshot.Warnings, Is.Empty); } - [Test] - public void CaptureSnapshotFindsSceneComponentsAndSkipsPersistentAssets() + [TestCase(true)] + [TestCase(false)] + public void CaptureSnapshotFindsSceneComponentsAndSkipsPersistentAssets(bool active) { string suffix = Guid.NewGuid().ToString("N"); string sceneName = "FlowGraphSceneComponentHost-" + suffix; @@ -11120,6 +11121,7 @@ public void CaptureSnapshotFindsSceneComponentsAndSkipsPersistentAssets() testScene.Activate(); GameObject sceneHost = testScene.CreateGameObject(sceneName); MessagingComponent sceneComponent = sceneHost.AddComponent(); + sceneHost.SetActive(active); prefabHost = testScene.CreateGameObject(prefabName); prefabHost.AddComponent(); GameObject prefabAsset = null; diff --git a/Tests/Editor/DxMessagingMessageMonitorWindowTests.cs b/Tests/Editor/DxMessagingMessageMonitorWindowTests.cs index 919e20b12..bbbd2fb8a 100644 --- a/Tests/Editor/DxMessagingMessageMonitorWindowTests.cs +++ b/Tests/Editor/DxMessagingMessageMonitorWindowTests.cs @@ -3511,8 +3511,11 @@ public void CaptureComponentSnapshotsReadsMessagingComponentHarnessState() ); } - [Test] - public void CaptureComponentSnapshotsFindsSceneComponentsAndSkipsPersistentAssets() + [TestCase(true)] + [TestCase(false)] + public void CaptureComponentSnapshotsFindsSceneComponentsAndSkipsPersistentAssets( + bool active + ) { string suffix = Guid.NewGuid().ToString("N"); string sceneName = "SceneComponentHost-" + suffix; @@ -3533,6 +3536,7 @@ editor has an unsaved untitled scene. Open this package-owned fixture instead. testScene.Activate(); GameObject sceneHost = testScene.CreateGameObject(sceneName); MessagingComponent sceneComponent = sceneHost.AddComponent(); + sceneHost.SetActive(active); prefabHost = testScene.CreateGameObject(prefabName); prefabHost.AddComponent(); GameObject prefabAsset = null; From 710f130e4ec20afc08f474273c8b6b88bc373345 Mon Sep 17 00:00:00 2001 From: wallstop Date: Fri, 16 Oct 2026 12:30:50 +0000 Subject: [PATCH 046/147] Bind comparison catalog to current ownership sources --- scripts/unity/comparison-evidence-catalog-v1.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index 54196293d..a3b060a06 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -38,12 +38,12 @@ "dxBus": { "origin": "repository", "path": "Runtime/Core/MessageBus/MessageBus.cs", - "sha256": "45b6bb2b0c8bb5d9f0b7a1129b7db10184e2b537aa7d89011590bfe6a739d9c7" + "sha256": "86408cc744fcbf13991376de48af300ad788acbad87fe9373ad6eb721ec078a1" }, "dxInterface": { "origin": "repository", "path": "Runtime/Core/MessageBus/IMessageBus.cs", - "sha256": "4cff1b369b86d8b75a76f95f9f58cb54603de38477783496c29c119d11baf6a4" + "sha256": "81f65224b5460bfa283919ac58df33d967ed03f2f1fdf1f226a369c16c498a94" }, "dxHandler": { "origin": "repository", From a4cb3a987f3478d9bbebe7cf9b2c699b3bdc5b62 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 05:37:23 +0000 Subject: [PATCH 047/147] Discard rejected immediate token registrations --- .net-tests/DxMessaging.Net.Tests.csproj | 4 + CHANGELOG.md | 2 + Runtime/Core/MessageRegistrationToken.cs | 18 +- .../Core/TokenRegistrationFailureTests.cs | 637 ++++++++++++++++++ .../TokenRegistrationFailureTests.cs.meta | 11 + docs/guides/advanced.md | 4 + .../unity/comparison-evidence-catalog-v1.json | 2 +- 7 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 Tests/Runtime/Core/TokenRegistrationFailureTests.cs create mode 100644 Tests/Runtime/Core/TokenRegistrationFailureTests.cs.meta diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index c7a700f93..8c7fa4018 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -31,5 +31,9 @@ Include="../Tests/Runtime/Core/ForeignBusRegistrationTests.cs" Link="ForeignBusRegistrationTests.cs" /> + diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4db3a01..cb6021036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Prevent rejected registrations on enabled tokens from reappearing after Disable/Enable + ([#638](https://github.com/Ambiguous-Interactive/DxMessaging/issues/638)). - Remove obsolete object-search warnings from Message Monitor, Flow Graph, and the diagnostics sample on Unity 6000.4 ([#628](https://github.com/Ambiguous-Interactive/DxMessaging/issues/628)). - Fix direct registration handles so passing one to a different bus preserves both buses, and global diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index 90f93e989..0e2e442c0 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -15,6 +15,8 @@ namespace DxMessaging.Core /// Staged registrations are created via the various Register* methods and are activated when /// is called; they are torn down on . /// This pattern works especially well with Unity lifecycles. + /// A bus rejection during an enabled token's Register call removes that call's staged entry. + /// A failed Enable retains previously staged handles so their registrations can be retried. /// /// /// @@ -1353,8 +1355,20 @@ the Registration object is retained in _registrations to lazily */ if (_enabled) { - MessageHandler.HandlerDeregistration actualDeregistration = - registration.RegisterValidated(); + MessageHandler.HandlerDeregistration actualDeregistration; + try + { + actualDeregistration = registration.RegisterValidated(); + } + catch + { + /* + No handle reached the caller. A reentrant bus may already have removed or + reused this slot, so retire only the failed registration's original identity. + */ + RemoveRegistrationState(slotIndex, handle.Id); + throw; + } AddDeregistration(slotIndex, handle.Id, actualDeregistration); } diff --git a/Tests/Runtime/Core/TokenRegistrationFailureTests.cs b/Tests/Runtime/Core/TokenRegistrationFailureTests.cs new file mode 100644 index 000000000..b0d024976 --- /dev/null +++ b/Tests/Runtime/Core/TokenRegistrationFailureTests.cs @@ -0,0 +1,637 @@ +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using NUnit.Framework; + + /// Immediate registration failures must not create inaccessible staged callbacks. + /// + /// 2026-10-17: enabled registration published an arena slot before the bus accepted it. + /// A rejected call returned no handle but its callback reappeared after Disable/Enable. + /// Disabled staging returns a handle before replay and deliberately remains retryable. + /// + [TestFixture] +#if !UNITY_2021_3_OR_NEWER + [NonParallelizable] +#endif + public sealed class TokenRegistrationFailureTests + { + private static readonly InstanceId Context = new InstanceId(872); + + private static IEnumerable HandlerCases() + { + foreach ( + MessageScenario scenario in MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext + ) + { + foreach (bool action in new[] { false, true }) + { + if ( + scenario.Kind == MessageKind.Untargeted + && scenario.UsePostProcessor + && action + ) + { + continue; // Untargeted post-processors expose only the fast overload. + } + foreach (bool diagnostics in new[] { false, true }) + { + yield return new TestCaseData(scenario, action, diagnostics); + } + } + } + } + + [TestCaseSource(nameof(HandlerCases))] + public void RejectedHandlerDoesNotBecomeStaged( + MessageScenario scenario, + bool action, + bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyImmediateFailure( + state, + scenario, + record => RegisterHandler(state.Token, scenario, action, record), + scenario + ", action=" + action + ", diagnostics=" + diagnostics + ); + } + + [TestCaseSource(nameof(HandlerCases))] + public void FailedDisabledHandlerReplayKeepsReturnedHandle( + MessageScenario scenario, + bool action, + bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyDisabledReplay( + state, + scenario, + record => RegisterHandler(state.Token, scenario, action, record), + scenario + ", action=" + action + ", diagnostics=" + diagnostics + ); + } + + [Test] + public void RejectedInterceptorDoesNotBecomeStaged( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyImmediateFailure( + state, + scenario, + record => RegisterInterceptor(state.Token, scenario, record), + scenario + ", diagnostics=" + diagnostics, + recordsDiagnostics: false + ); + } + + [Test] + public void FailedDisabledInterceptorReplayKeepsReturnedHandle( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyDisabledReplay( + state, + scenario, + record => RegisterInterceptor(state.Token, scenario, record), + scenario + ", diagnostics=" + diagnostics + ); + } + + [Test] + public void RejectedGlobalDoesNotBecomeStaged( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool action, + [Values(false, true)] bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyImmediateFailure( + state, + scenario, + record => RegisterGlobal(state.Token, action, record), + scenario + ", action=" + action + ", diagnostics=" + diagnostics + ); + } + + [Test] + public void FailedDisabledGlobalReplayKeepsReturnedHandle( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(false, true)] bool action, + [Values(false, true)] bool diagnostics + ) + { + using CaseState state = new CaseState(diagnostics); + VerifyDisabledReplay( + state, + scenario, + record => RegisterGlobal(state.Token, action, record), + scenario + ", action=" + action + ", diagnostics=" + diagnostics + ); + } + + [TestCase(false, 1)] + [TestCase(false, 8)] + [TestCase(true, 1)] + [TestCase(true, 8)] + public void FailurePreservesReentrantRegistrations(bool clearAndReuse, int nestedCount) + { + string label = "clearAndReuse=" + clearAndReuse + ", nestedCount=" + nestedCount; + using CaseState state = new CaseState(true); + MessageScenario scenario = MessageScenario.Untargeted(); + List nested = new List(); + state.Token.Enable(); + state.Bus.BeforeRejection = () => + { + if (clearAndReuse) + { + state.Token.UnregisterAll(); + state.Token.Enable(); + } + for (int index = 0; index < nestedCount; ++index) + { + int captured = index; + nested.Add( + RegisterHandler( + state.Token, + scenario, + false, + () => state.Calls.Add(captured.ToString()) + ) + ); + } + }; + state.Bus.RejectNext = true; + InvalidOperationException exception = Assert.Throws(() => + RegisterHandler(state.Token, scenario, false, () => state.Calls.Add("rejected")) + ); + state.Emit(scenario); + string[] expected = new string[nestedCount]; + for (int index = 0; index < expected.Length; ++index) + { + expected[index] = index.ToString(); + } + TestContext.WriteLine( + label + + ": metadata=" + + state.Token._metadata.Count + + "; calls=" + + string.Join(",", state.Calls) + ); + Assert.That(exception, Is.SameAs(state.Bus.Failure), label); + Assert.That(state.Token._metadata.Count, Is.EqualTo(nestedCount), label); + Assert.That(state.Calls, Is.EqualTo(expected), label); + foreach (MessageRegistrationHandle handle in nested) + { + state.Token.RemoveRegistration(handle); + } + state.Token.Disable(); + state.Token.Enable(); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label); + Assert.That(state.Token._metadata.Count, Is.Zero, label); + } + + private static void VerifyImmediateFailure( + CaseState state, + MessageScenario scenario, + Func register, + string label, + bool recordsDiagnostics = true + ) + { + state.Token.Enable(); + MessageRegistrationHandle first = register(() => state.Calls.Add("first")); + state.Emit(scenario); + Assert.That(state.Calls, Is.EqualTo(new[] { "first" }), label + ": accepted control"); + // Interceptors are forwarded directly; only augmented handler/global invokers record. + int expectedDiagnosticCount = state.Token.DiagnosticMode && recordsDiagnostics ? 1 : 0; + int beforeCount = state.Token._callCounts.TryGetValue(first, out int recorded) + ? recorded + : 0; + Assert.That( + beforeCount, + Is.EqualTo(expectedDiagnosticCount), + label + ": accepted diagnostic control" + ); + state.Calls.Clear(); + state.Bus.RejectNext = true; + InvalidOperationException exception = Assert.Throws( + () => register(() => state.Calls.Add("rejected")), + label + ); + int metadataAfterFailure = state.Token._metadata.Count; + bool enabledAfterFailure = state.Token.Enabled; + int firstCallCountAfterFailure = state.Token._callCounts.TryGetValue( + first, + out int count + ) + ? count + : 0; + state.Emit(scenario); + string[] immediately = state.Calls.ToArray(); + state.Calls.Clear(); + MessageRegistrationHandle next = register(() => state.Calls.Add("next")); + state.Token.Disable(); + state.Token.Enable(); + state.Emit(scenario); + string[] afterReplay = state.Calls.ToArray(); + state.Token.RemoveRegistration(first); + state.Token.RemoveRegistration(next); + state.Token.Disable(); + state.Token.Enable(); + state.Calls.Clear(); + state.Emit(scenario); + TestContext.WriteLine( + label + + ": metadataAfterFailure=" + + metadataAfterFailure + + "; immediate=" + + string.Join(",", immediately) + + "; replay=" + + string.Join(",", afterReplay) + + "; afterRemovingReturnedHandles=" + + string.Join(",", state.Calls) + ); + Assert.That(exception, Is.SameAs(state.Bus.Failure), label); + Assert.That(enabledAfterFailure, Is.True, label); + Assert.That( + firstCallCountAfterFailure, + Is.EqualTo(expectedDiagnosticCount), + label + ": existing diagnostics" + ); + Assert.That(metadataAfterFailure, Is.EqualTo(1), label); + Assert.That(immediately, Is.EqualTo(new[] { "first" }), label); + Assert.That(afterReplay, Is.EqualTo(new[] { "first", "next" }), label); + Assert.That(state.Calls, Is.Empty, label); + Assert.That(state.Token._metadata.Count, Is.Zero, label); + Assert.That(state.Token._callCounts, Is.Empty, label + ": diagnostic counts clear"); + Assert.That( + state.Token._emissionBuffer.Count, + Is.Zero, + label + ": diagnostic history clears" + ); + } + + private static void VerifyDisabledReplay( + CaseState state, + MessageScenario scenario, + Func register, + string label + ) + { + MessageRegistrationHandle handle = register(() => state.Calls.Add("staged")); + state.Bus.RejectNext = true; + InvalidOperationException exception = Assert.Throws( + state.Token.Enable, + label + ); + Assert.That(exception, Is.SameAs(state.Bus.Failure), label); + Assert.That(state.Token.Enabled, Is.False, label); + Assert.That(state.Token._metadata.Count, Is.EqualTo(1), label); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label); + state.Token.Enable(); + state.Emit(scenario); + Assert.That(state.Calls, Is.EqualTo(new[] { "staged" }), label); + state.Token.RemoveRegistration(handle); + state.Token.Disable(); + state.Token.Enable(); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label); + Assert.That(state.Token._metadata.Count, Is.Zero, label); + } + + private static MessageRegistrationHandle RegisterHandler( + MessageRegistrationToken token, + MessageScenario scenario, + bool action, + Action record + ) + { + Action scalar = _ => record(); + Action context = (_, __) => record(); + MessageHandler.FastHandler fast = (in ProbeMessage _) => record(); + MessageHandler.FastHandlerWithContext fastContext = ( + in InstanceId _, + in ProbeMessage __ + ) => record(); + return (scenario.Kind, scenario.UsePostProcessor, action) switch + { + (MessageKind.Untargeted, false, true) => token.RegisterUntargeted(scalar), + (MessageKind.Untargeted, false, false) => token.RegisterUntargeted(fast), + (MessageKind.Untargeted, true, _) => token.RegisterUntargetedPostProcessor(fast), + (MessageKind.Targeted, false, true) => token.RegisterTargeted(Context, scalar), + (MessageKind.Targeted, false, false) => token.RegisterTargeted(Context, fast), + (MessageKind.Targeted, true, true) => token.RegisterTargetedPostProcessor( + Context, + scalar + ), + (MessageKind.Targeted, true, false) => token.RegisterTargetedPostProcessor( + Context, + fast + ), + (MessageKind.Broadcast, false, true) => token.RegisterBroadcast(Context, scalar), + (MessageKind.Broadcast, false, false) => token.RegisterBroadcast(Context, fast), + (MessageKind.Broadcast, true, true) => token.RegisterBroadcastPostProcessor( + Context, + scalar + ), + (MessageKind.Broadcast, true, false) => token.RegisterBroadcastPostProcessor( + Context, + fast + ), + (MessageKind.TargetedWithoutTargeting, false, true) => + token.RegisterTargetedWithoutTargeting(context), + (MessageKind.TargetedWithoutTargeting, false, false) => + token.RegisterTargetedWithoutTargeting(fastContext), + (MessageKind.TargetedWithoutTargeting, true, true) => + token.RegisterTargetedWithoutTargetingPostProcessor(context), + (MessageKind.TargetedWithoutTargeting, true, false) => + token.RegisterTargetedWithoutTargetingPostProcessor(fastContext), + (MessageKind.BroadcastWithoutSource, false, true) => + token.RegisterBroadcastWithoutSource(context), + (MessageKind.BroadcastWithoutSource, false, false) => + token.RegisterBroadcastWithoutSource(fastContext), + (MessageKind.BroadcastWithoutSource, true, true) => + token.RegisterBroadcastWithoutSourcePostProcessor(context), + (MessageKind.BroadcastWithoutSource, true, false) => + token.RegisterBroadcastWithoutSourcePostProcessor(fastContext), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterInterceptor( + MessageRegistrationToken token, + MessageScenario scenario, + Action record + ) + { + bool Scalar(ref ProbeMessage message) + { + record(); + return true; + } + bool Contextual(ref InstanceId context, ref ProbeMessage message) + { + record(); + return true; + } + return scenario.Kind switch + { + MessageKind.Untargeted => token.RegisterUntargetedInterceptor(Scalar), + MessageKind.Targeted => token.RegisterTargetedInterceptor(Contextual), + MessageKind.Broadcast => token.RegisterBroadcastInterceptor( + Contextual + ), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterGlobal( + MessageRegistrationToken token, + bool action, + Action record + ) + { + if (action) + { + return token.RegisterGlobalAcceptAll( + (IUntargetedMessage _) => record(), + (InstanceId _, ITargetedMessage __) => record(), + (InstanceId _, IBroadcastMessage __) => record() + ); + } + return token.RegisterGlobalAcceptAll( + (in IUntargetedMessage _) => record(), + (in InstanceId _, in ITargetedMessage __) => record(), + (in InstanceId _, in IBroadcastMessage __) => record() + ); + } + + private sealed class CaseState : IDisposable + { + internal readonly MessageBus Inner = new MessageBus(); + internal readonly RejectingBus Bus; + internal readonly MessageHandler Handler; + internal readonly MessageRegistrationToken Token; + internal readonly List Calls = new List(); + private readonly LeakWatcher _leaks; + + internal CaseState(bool diagnostics) + { + _leaks = new LeakWatcher(Inner); + Bus = new RejectingBus(Inner); + Handler = new MessageHandler(new InstanceId(871), Bus) { active = true }; + Token = MessageRegistrationToken.Create(Handler, Bus); + Token.DiagnosticMode = diagnostics; + } + + internal void Emit(MessageScenario scenario) + { + ProbeMessage message = default; + InstanceId context = Context; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + Bus.UntargetedBroadcast(ref message); + break; + case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: + Bus.TargetedBroadcast(ref context, ref message); + break; + case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: + Bus.SourcedBroadcast(ref context, ref message); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + + public void Dispose() + { + Token.Dispose(); + Handler.active = false; + Inner.Trim(true); + _leaks.Dispose(); + } + } + + private sealed class RejectingBus : DelegatingMessageBus + { + internal readonly InvalidOperationException Failure = new InvalidOperationException( + "Registration rejected before forwarding." + ); + internal bool RejectNext; + internal Action BeforeRejection; + + internal RejectingBus(IMessageBus inner) + : base(inner) { } + + private void Before() + { + if (!RejectNext) + { + return; + } + RejectNext = false; + Action callback = BeforeRejection; + BeforeRejection = null; + callback?.Invoke(); + throw Failure; + } + + public override MessageBusRegistration RegisterUntargeted( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterUntargeted(handler, priority); + } + + public override MessageBusRegistration RegisterUntargetedPostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterUntargetedPostProcessor(handler, priority); + } + + public override MessageBusRegistration RegisterTargeted( + InstanceId target, + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterTargeted(target, handler, priority); + } + + public override MessageBusRegistration RegisterTargetedPostProcessor( + InstanceId target, + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterTargetedPostProcessor(target, handler, priority); + } + + public override MessageBusRegistration RegisterTargetedWithoutTargeting( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterTargetedWithoutTargeting(handler, priority); + } + + public override MessageBusRegistration RegisterTargetedWithoutTargetingPostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterTargetedWithoutTargetingPostProcessor(handler, priority); + } + + public override MessageBusRegistration RegisterSourcedBroadcast( + InstanceId source, + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterSourcedBroadcast(source, handler, priority); + } + + public override MessageBusRegistration RegisterBroadcastPostProcessor( + InstanceId source, + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterBroadcastPostProcessor(source, handler, priority); + } + + public override MessageBusRegistration RegisterSourcedBroadcastWithoutSource( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterSourcedBroadcastWithoutSource(handler, priority); + } + + public override MessageBusRegistration RegisterBroadcastWithoutSourcePostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + Before(); + return base.RegisterBroadcastWithoutSourcePostProcessor(handler, priority); + } + + public override MessageBusRegistration RegisterGlobalAcceptAll(MessageHandler handler) + { + Before(); + return base.RegisterGlobalAcceptAll(handler); + } + + public override MessageBusRegistration RegisterUntargetedInterceptor( + IMessageBus.UntargetedInterceptor interceptor, + int priority = 0 + ) + { + Before(); + return base.RegisterUntargetedInterceptor(interceptor, priority); + } + + public override MessageBusRegistration RegisterTargetedInterceptor( + IMessageBus.TargetedInterceptor interceptor, + int priority = 0 + ) + { + Before(); + return base.RegisterTargetedInterceptor(interceptor, priority); + } + + public override MessageBusRegistration RegisterBroadcastInterceptor( + IMessageBus.BroadcastInterceptor interceptor, + int priority = 0 + ) + { + Before(); + return base.RegisterBroadcastInterceptor(interceptor, priority); + } + } + + private readonly struct ProbeMessage + : IUntargetedMessage, + ITargetedMessage, + IBroadcastMessage + { + public Type MessageType => typeof(ProbeMessage); + } + } +} diff --git a/Tests/Runtime/Core/TokenRegistrationFailureTests.cs.meta b/Tests/Runtime/Core/TokenRegistrationFailureTests.cs.meta new file mode 100644 index 000000000..11ea6c8f1 --- /dev/null +++ b/Tests/Runtime/Core/TokenRegistrationFailureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5cb6dd22e59c421da4be523ede14f270 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/advanced.md b/docs/guides/advanced.md index a62d6d000..bd7022e7d 100644 --- a/docs/guides/advanced.md +++ b/docs/guides/advanced.md @@ -23,6 +23,10 @@ Lifecycles and tokens - Enable/Disable - Call `token.Enable()` in `OnEnable` and `token.Disable()` in `OnDisable`. - Idempotent; safe to call multiple times. + - If a custom bus rejects a registration on an enabled token before changing its registrations, + that call leaves no staged entry to replay later. + - A failed `Enable()` retains handles returned while the token was disabled. Retry `Enable()` + after the bus recovers, or remove those handles. - Clearing - A successful `token.UnregisterAll()` disables and clears staged registrations plus token-local diagnostics. - `token.RemoveRegistration(handle)` removes a single registration. diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index a3b060a06..2746d366c 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -53,7 +53,7 @@ "dxToken": { "origin": "repository", "path": "Runtime/Core/MessageRegistrationToken.cs", - "sha256": "46df800af7f7ba6faadd1243a07ac420fd4f08080ec7d9c0a65a0c84466c2ff5" + "sha256": "5e2a41f16e58fcbbbe7e2a18f5f5764d8a5ef0df71dbfa9085bf48aa9b962eb1" }, "dxFlat": { "origin": "repository", From bd8027eb2dc5188eabb024453d05a3a649246d19 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 06:22:57 +0000 Subject: [PATCH 048/147] Fix analyzer errors in registration callback tests --- Tests/Runtime/Core/ForeignBusRegistrationTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/Runtime/Core/ForeignBusRegistrationTests.cs b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs index 3d47ed6c9..50e5b75a0 100644 --- a/Tests/Runtime/Core/ForeignBusRegistrationTests.cs +++ b/Tests/Runtime/Core/ForeignBusRegistrationTests.cs @@ -11,6 +11,8 @@ namespace DxMessaging.Tests.Runtime.Core /// /// 2026-10-16: a foreign handle could remove an origin handler route or a receiver global /// registration because fresh buses shared reset generations. Copies retain the same owner. + /// 2026-10-17: block-bodied callback counters preserve captured updates under the CI Sonar + /// analyzer, which reports expression-bodied postfix increments as S2123. /// [TestFixture] #if !UNITY_2021_3_OR_NEWER @@ -278,8 +280,14 @@ public void ForeignHandleCannotConsumeTheOwnersLaterValidDeregistration( ); int originCalls = 0; int receiverCalls = 0; - void ReceiveOrigin(in ProbeMessage message) => originCalls++; - void ReceiveReceiver(in ProbeMessage message) => receiverCalls++; + void ReceiveOrigin(in ProbeMessage message) + { + originCalls++; + } + void ReceiveReceiver(in ProbeMessage message) + { + receiverCalls++; + } RegisterCallback(token, scenario, ReceiveOrigin); token.Enable(); if (receiverHasRegistration) From 61a83816c3d98d1bc860c68870eb3cd401bd0fa6 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 09:12:27 +0000 Subject: [PATCH 049/147] Skip withdrawn token registrations during replay --- .net-tests/DxMessaging.Net.Tests.csproj | 4 + CHANGELOG.md | 2 + Runtime/Core/MessageRegistrationToken.cs | 4 +- .../Core/TokenReplayWithdrawalTests.cs | 543 ++++++++++++++++++ .../Core/TokenReplayWithdrawalTests.cs.meta | 11 + docs/guides/advanced.md | 3 + 6 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 Tests/Runtime/Core/TokenReplayWithdrawalTests.cs create mode 100644 Tests/Runtime/Core/TokenReplayWithdrawalTests.cs.meta diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index 8c7fa4018..6919bd3a3 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -35,5 +35,9 @@ Include="../Tests/Runtime/Core/TokenRegistrationFailureTests.cs" Link="TokenRegistrationFailureTests.cs" /> + diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6021036..373fde22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep registrations removed by an earlier custom-bus callback from activating later in the + same token replay ([#639](https://github.com/Ambiguous-Interactive/DxMessaging/issues/639)). - Prevent rejected registrations on enabled tokens from reappearing after Disable/Enable ([#638](https://github.com/Ambiguous-Interactive/DxMessaging/issues/638)). - Remove obsolete object-search warnings from Message Monitor, Flow Graph, and the diagnostics diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index 0e2e442c0..073f5f106 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -1806,6 +1806,8 @@ private void QueueRegistrationsWithoutRetryableDeregistrationsInOrder() } } + /// Replays registrations still staged when their turn begins. + /// 2026-10-17: Skip a later entry removed or replaced by an earlier registration callback. private void InvokeRegistrationReplayQueue() { try @@ -1813,7 +1815,7 @@ private void InvokeRegistrationReplayQueue() foreach (StagedRegistration staged in GetRegistrationReplayQueue()) { Registration registration = staged.Registration; - if (registration == null) + if (registration == null || !TryGetSlot(staged.Slot, staged.Id, out _)) { continue; } diff --git a/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs new file mode 100644 index 000000000..79f3cbb55 --- /dev/null +++ b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs @@ -0,0 +1,543 @@ +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + using BusType = DxMessaging.Core.MessageBus.MessageBus; + + /// Checks cancellation of staged registrations during token replay. + /// 2026-10-17: A queued callback could activate after an earlier bus callback removed its handle. + [TestFixture] + public sealed class TokenReplayWithdrawalTests + { + private static readonly InstanceId Context = new InstanceId(841); + + private static IEnumerable<(MessageScenario Scenario, bool Action, int Family)> Shapes() + { + foreach ( + MessageScenario scenario in MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext + ) + { + foreach (bool action in new[] { false, true }) + { + if ( + scenario.Kind == MessageKind.Untargeted + && scenario.UsePostProcessor + && action + ) + { + continue; + } + yield return (scenario, action, 0); + } + } + foreach (MessageScenario scenario in MessageScenarios.AllKinds) + { + yield return (scenario, false, 1); + yield return (scenario, false, 2); + yield return (scenario, true, 2); + } + } + + private static IEnumerable ReplayCases() + { + foreach (var shape in Shapes()) + { + foreach (bool diagnostics in new[] { false, true }) + { + foreach (int count in new[] { 2, 8 }) + { + foreach (int mutation in new[] { 0, 1, 2 }) + { + yield return new TestCaseData( + shape.Scenario, + shape.Action, + shape.Family, + diagnostics, + count, + mutation + ).SetName( + "ReplayWithdrawal_family" + + shape.Family + + "_" + + shape.Scenario + + "_action" + + shape.Action + + "_diagnostics" + + diagnostics + + "_count" + + count + + "_mutation" + + mutation + ); + } + } + } + } + } + + [TestCaseSource(nameof(ReplayCases))] + public void ReplaySkipsRegistrationsRemovedBeforeTheirTurn( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + int count, + int mutation + ) + { + string label = + scenario + + ", action=" + + action + + ", family=" + + family + + ", diagnostics=" + + diagnostics + + ", count=" + + count + + ", mutation=" + + mutation; + using CaseState state = new CaseState(diagnostics); + MessageRegistrationHandle[] handles = new MessageRegistrationHandle[count]; + List all = new List(count); + for (int index = 0; index < count; ++index) + { + string callback = + index == 0 ? "first" + : index == count - 1 ? "removed" + : "keep-" + index; + all.Add(callback); + handles[index] = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add(callback) + ); + } + MessageRegistrationHandle victim = handles[count - 1]; + MessageRegistrationHandle replacement = default; + int mutations = 0; + state.Bus.OnFirstRegistration = () => + { + ++mutations; + if (mutation == 0) + { + return; + } + state.Token.RemoveRegistration(victim); + if (mutation == 2) + { + replacement = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("replacement") + ); + } + }; + state.Token.Enable(); + Assert.That(mutations, Is.EqualTo(1), label); + if (mutation == 2) + { + Assert.That( + replacement.Slot, + Is.EqualTo(victim.Slot), + label + ": actual slot reuse" + ); + Assert.That(replacement, Is.Not.EqualTo(victim), label + ": fresh opaque identity"); + } + state.Emit(scenario); + List firstExpected = new List(all); + if (mutation != 0) + { + firstExpected.RemoveAt(firstExpected.Count - 1); + } + TestContext.WriteLine( + label + + "; first=" + + string.Join(",", state.Calls) + + "; metadata=" + + state.Token._metadata.Count + ); + Assert.That( + state.Calls, + Is.EqualTo(firstExpected), + label + ": a registration removed before its replay turn must stay absent" + ); + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(mutation == 1 ? count - 1 : count), + label + ); + + state.Token.Disable(); + state.Token.Enable(); + state.Calls.Clear(); + state.Emit(scenario); + List replayExpected = new List(firstExpected); + if (mutation == 2) + { + replayExpected.Add("replacement"); + } + Assert.That( + state.Calls, + Is.EqualTo(replayExpected), + label + ": only current staged registrations activate on the next cycle" + ); + if (mutation != 0) + { + state.Token.RemoveRegistration(victim); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(replayExpected), + label + ": the removed handle cannot affect a reused slot" + ); + } + } + + private static MessageRegistrationHandle Register( + MessageRegistrationToken token, + MessageScenario scenario, + bool action, + int family, + Action record + ) + { + return family switch + { + 0 => RegisterHandler(token, scenario, action, record), + 1 => RegisterInterceptor(token, scenario, record), + 2 => RegisterGlobal(token, action, record), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + } + + private sealed class CaseState : IDisposable + { + internal readonly BusType Inner; + internal readonly ReplayBus Bus; + internal readonly MessageHandler Handler; + internal readonly MessageRegistrationToken Token; + internal readonly List Calls = new List(); + private readonly LeakWatcher _leaks; + + internal CaseState(bool diagnostics) + { + Inner = new BusType { DiagnosticsMode = diagnostics }; + Bus = new ReplayBus(Inner); + Handler = new MessageHandler(new InstanceId(840), Bus) { active = true }; + _leaks = LeakWatcher.WatchWithSlots(Bus, handler: Handler); + Token = MessageRegistrationToken.Create(Handler, Bus); + Token.DiagnosticMode = diagnostics; + } + + internal void Emit(MessageScenario scenario) + { + ProbeMessage message = default; + InstanceId context = Context; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + Bus.UntargetedBroadcast(ref message); + break; + case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: + Bus.TargetedBroadcast(ref context, ref message); + break; + case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: + Bus.SourcedBroadcast(ref context, ref message); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + + public void Dispose() + { + try + { + Token.Dispose(); + Assert.That( + Token._metadata.Count, + Is.Zero, + "Successful disposal clears token metadata." + ); + Assert.That( + Token._callCounts.Count, + Is.Zero, + "Successful disposal clears token call counts." + ); + Assert.That( + Token._emissionBuffer.Count, + Is.Zero, + "Successful disposal clears token emission history." + ); + } + finally + { + Handler.active = false; + Inner.Trim(force: true); + _leaks.Dispose(); + } + } + } + + private static MessageRegistrationHandle RegisterHandler( + MessageRegistrationToken token, + MessageScenario scenario, + bool action, + Action record + ) + { + Action scalar = _ => record(); + Action context = (_, __) => record(); + MessageHandler.FastHandler fast = (in ProbeMessage _) => record(); + MessageHandler.FastHandlerWithContext fastContext = ( + in InstanceId _, + in ProbeMessage __ + ) => record(); + return (scenario.Kind, scenario.UsePostProcessor, action) switch + { + (MessageKind.Untargeted, false, true) => token.RegisterUntargeted(scalar), + (MessageKind.Untargeted, false, false) => token.RegisterUntargeted(fast), + (MessageKind.Untargeted, true, _) => token.RegisterUntargetedPostProcessor(fast), + (MessageKind.Targeted, false, true) => token.RegisterTargeted(Context, scalar), + (MessageKind.Targeted, false, false) => token.RegisterTargeted(Context, fast), + (MessageKind.Targeted, true, true) => token.RegisterTargetedPostProcessor( + Context, + scalar + ), + (MessageKind.Targeted, true, false) => token.RegisterTargetedPostProcessor( + Context, + fast + ), + (MessageKind.Broadcast, false, true) => token.RegisterBroadcast(Context, scalar), + (MessageKind.Broadcast, false, false) => token.RegisterBroadcast(Context, fast), + (MessageKind.Broadcast, true, true) => token.RegisterBroadcastPostProcessor( + Context, + scalar + ), + (MessageKind.Broadcast, true, false) => token.RegisterBroadcastPostProcessor( + Context, + fast + ), + (MessageKind.TargetedWithoutTargeting, false, true) => + token.RegisterTargetedWithoutTargeting(context), + (MessageKind.TargetedWithoutTargeting, false, false) => + token.RegisterTargetedWithoutTargeting(fastContext), + (MessageKind.TargetedWithoutTargeting, true, true) => + token.RegisterTargetedWithoutTargetingPostProcessor(context), + (MessageKind.TargetedWithoutTargeting, true, false) => + token.RegisterTargetedWithoutTargetingPostProcessor(fastContext), + (MessageKind.BroadcastWithoutSource, false, true) => + token.RegisterBroadcastWithoutSource(context), + (MessageKind.BroadcastWithoutSource, false, false) => + token.RegisterBroadcastWithoutSource(fastContext), + (MessageKind.BroadcastWithoutSource, true, true) => + token.RegisterBroadcastWithoutSourcePostProcessor(context), + (MessageKind.BroadcastWithoutSource, true, false) => + token.RegisterBroadcastWithoutSourcePostProcessor(fastContext), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterInterceptor( + MessageRegistrationToken token, + MessageScenario scenario, + Action record + ) + { + bool Scalar(ref ProbeMessage message) + { + record(); + return true; + } + bool Contextual(ref InstanceId context, ref ProbeMessage message) + { + record(); + return true; + } + return scenario.Kind switch + { + MessageKind.Untargeted => token.RegisterUntargetedInterceptor(Scalar), + MessageKind.Targeted => token.RegisterTargetedInterceptor(Contextual), + MessageKind.Broadcast => token.RegisterBroadcastInterceptor( + Contextual + ), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterGlobal( + MessageRegistrationToken token, + bool action, + Action record + ) + { + if (action) + { + return token.RegisterGlobalAcceptAll( + (IUntargetedMessage _) => record(), + (InstanceId _, ITargetedMessage __) => record(), + (InstanceId _, IBroadcastMessage __) => record() + ); + } + return token.RegisterGlobalAcceptAll( + (in IUntargetedMessage _) => record(), + (in InstanceId _, in ITargetedMessage __) => record(), + (in InstanceId _, in IBroadcastMessage __) => record() + ); + } + + private sealed class ReplayBus : DelegatingMessageBus + { + internal Action OnFirstRegistration; + + internal ReplayBus(IMessageBus inner) + : base(inner) { } + + private MessageBusRegistration After(MessageBusRegistration registration) + { + Action callback = OnFirstRegistration; + OnFirstRegistration = null; + callback?.Invoke(); + return registration; + } + + public override MessageBusRegistration RegisterUntargeted( + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterUntargeted(handler, priority)); + } + + public override MessageBusRegistration RegisterUntargetedPostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterUntargetedPostProcessor(handler, priority)); + } + + public override MessageBusRegistration RegisterTargeted( + InstanceId target, + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterTargeted(target, handler, priority)); + } + + public override MessageBusRegistration RegisterTargetedPostProcessor( + InstanceId target, + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterTargetedPostProcessor(target, handler, priority)); + } + + public override MessageBusRegistration RegisterTargetedWithoutTargeting( + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterTargetedWithoutTargeting(handler, priority)); + } + + public override MessageBusRegistration RegisterTargetedWithoutTargetingPostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + return After( + base.RegisterTargetedWithoutTargetingPostProcessor(handler, priority) + ); + } + + public override MessageBusRegistration RegisterSourcedBroadcast( + InstanceId source, + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterSourcedBroadcast(source, handler, priority)); + } + + public override MessageBusRegistration RegisterBroadcastPostProcessor( + InstanceId source, + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterBroadcastPostProcessor(source, handler, priority)); + } + + public override MessageBusRegistration RegisterSourcedBroadcastWithoutSource( + MessageHandler handler, + int priority = 0 + ) + { + return After(base.RegisterSourcedBroadcastWithoutSource(handler, priority)); + } + + public override MessageBusRegistration RegisterBroadcastWithoutSourcePostProcessor( + MessageHandler handler, + int priority = 0 + ) + { + return After( + base.RegisterBroadcastWithoutSourcePostProcessor(handler, priority) + ); + } + + public override MessageBusRegistration RegisterGlobalAcceptAll(MessageHandler handler) + { + return After(base.RegisterGlobalAcceptAll(handler)); + } + + public override MessageBusRegistration RegisterUntargetedInterceptor( + IMessageBus.UntargetedInterceptor interceptor, + int priority = 0 + ) + { + return After(base.RegisterUntargetedInterceptor(interceptor, priority)); + } + + public override MessageBusRegistration RegisterTargetedInterceptor( + IMessageBus.TargetedInterceptor interceptor, + int priority = 0 + ) + { + return After(base.RegisterTargetedInterceptor(interceptor, priority)); + } + + public override MessageBusRegistration RegisterBroadcastInterceptor( + IMessageBus.BroadcastInterceptor interceptor, + int priority = 0 + ) + { + return After(base.RegisterBroadcastInterceptor(interceptor, priority)); + } + } + + private readonly struct ProbeMessage + : IUntargetedMessage, + ITargetedMessage, + IBroadcastMessage + { + public Type MessageType => typeof(ProbeMessage); + } + } +} diff --git a/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs.meta b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs.meta new file mode 100644 index 000000000..2661da801 --- /dev/null +++ b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2551b42c6a0483fafe259cf03e8c891 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/advanced.md b/docs/guides/advanced.md index bd7022e7d..e4a84ace8 100644 --- a/docs/guides/advanced.md +++ b/docs/guides/advanced.md @@ -23,6 +23,9 @@ Lifecycles and tokens - Enable/Disable - Call `token.Enable()` in `OnEnable` and `token.Disable()` in `OnDisable`. - Idempotent; safe to call multiple times. + - Removing a staged handle from a custom-bus registration callback keeps that entry inactive + during the current replay. A replacement staged while the token is disabled activates on + the next Disable/Enable cycle. - If a custom bus rejects a registration on an enabled token before changing its registrations, that call leaves no staged entry to replay later. - A failed `Enable()` retains handles returned while the token was disabled. Retry `Enable()` From fb43d0e6d69550fc6bf0d574d562af9fbd645e85 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 09:28:19 +0000 Subject: [PATCH 050/147] Refresh token source hash after replay correction --- scripts/unity/comparison-evidence-catalog-v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index 2746d366c..6f2ed1c1c 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -53,7 +53,7 @@ "dxToken": { "origin": "repository", "path": "Runtime/Core/MessageRegistrationToken.cs", - "sha256": "5e2a41f16e58fcbbbe7e2a18f5f5764d8a5ef0df71dbfa9085bf48aa9b962eb1" + "sha256": "726cb49d0f94e3f282359b061007cc4904c50611ee4bd5b27d5d3426993b2f1f" }, "dxFlat": { "origin": "repository", From 36aff4a7e0277293cebb7e40e320e5ee6a7c250c Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 12:31:39 +0000 Subject: [PATCH 051/147] Preserve token replay when callbacks clear registrations --- CHANGELOG.md | 3 +++ Runtime/Core/MessageRegistrationToken.cs | 9 ++++--- .../Core/TokenReplayWithdrawalTests.cs | 27 ++++++++++++++++--- .../unity/comparison-evidence-catalog-v1.json | 2 +- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 373fde22b..38cbabd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep token replay valid when a custom-bus registration callback calls `UnregisterAll()` or `Dispose()`; cleared future entries stay inactive and accepted registrations keep their teardown + ([#641](https://github.com/Ambiguous-Interactive/DxMessaging/issues/641)). + - Keep registrations removed by an earlier custom-bus callback from activating later in the same token replay ([#639](https://github.com/Ambiguous-Interactive/DxMessaging/issues/639)). - Prevent rejected registrations on enabled tokens from reappearing after Disable/Enable diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index 073f5f106..c3f8295ec 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -78,8 +78,8 @@ The central replay loop pairs the Registration with its handle and performs the AddDeregistration. The re-entrancy snapshot semantics are unchanged: the replay queue captures the Registration reference (exactly as it previously captured the staging Func), - so a registration removed mid-replay is still replayed if it was already - snapshotted. + so an accepted registration removed during its own bus callback retains + its teardown, while an entry removed before its replay turn stays inactive. Token-owned arena. A handle identifies a slot and the globally unique id that occupied it. Both are validated, so stale handles remain harmless after slot reuse. The doubly-linked live list preserves registration order while the singly-linked @@ -2223,7 +2223,10 @@ private void ClearRegistrationArena() _registrationTail = -1; _registrationCount = 0; _deregistrationCount = 0; - _registrationReplayQueue?.Clear(); + /* + Replay owns its snapshot until its finally block. Clearing that list here + would invalidate an active enumerator when a bus callback clears the token. + */ _teardownQueue?.Clear(); _rollbackCounts?.Clear(); _activeRetargetHandles?.Clear(); diff --git a/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs index 79f3cbb55..900988896 100644 --- a/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs +++ b/Tests/Runtime/Core/TokenReplayWithdrawalTests.cs @@ -51,7 +51,7 @@ private static IEnumerable ReplayCases() { foreach (int count in new[] { 2, 8 }) { - foreach (int mutation in new[] { 0, 1, 2 }) + foreach (int mutation in new[] { 0, 1, 2, 3, 4 }) { yield return new TestCaseData( shape.Scenario, @@ -130,6 +130,16 @@ int mutation { return; } + if (mutation == 3) + { + state.Token.UnregisterAll(); + return; + } + if (mutation == 4) + { + state.Token.Dispose(); + return; + } state.Token.RemoveRegistration(victim); if (mutation == 2) { @@ -155,7 +165,11 @@ int mutation } state.Emit(scenario); List firstExpected = new List(all); - if (mutation != 0) + if (3 <= mutation) + { + firstExpected.RemoveRange(1, firstExpected.Count - 1); + } + else if (mutation != 0) { firstExpected.RemoveAt(firstExpected.Count - 1); } @@ -173,7 +187,11 @@ int mutation ); Assert.That( state.Token._metadata.Count, - Is.EqualTo(mutation == 1 ? count - 1 : count), + Is.EqualTo( + 3 <= mutation ? 0 + : mutation == 1 ? count - 1 + : count + ), label ); @@ -181,7 +199,8 @@ int mutation state.Token.Enable(); state.Calls.Clear(); state.Emit(scenario); - List replayExpected = new List(firstExpected); + List replayExpected = + 3 <= mutation ? new List() : new List(firstExpected); if (mutation == 2) { replayExpected.Add("replacement"); diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index 6f2ed1c1c..707c75e74 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -53,7 +53,7 @@ "dxToken": { "origin": "repository", "path": "Runtime/Core/MessageRegistrationToken.cs", - "sha256": "726cb49d0f94e3f282359b061007cc4904c50611ee4bd5b27d5d3426993b2f1f" + "sha256": "597fccd97b85d412e346a810b40d91434e956133c240f3d41205b4fea464c1a2" }, "dxFlat": { "origin": "repository", From a2c199c52a1d26ca03b4093b125cc5bbf2db3c99 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 18 Oct 2026 08:47:04 +0000 Subject: [PATCH 052/147] Fix reentrant token teardown ownership --- .net-tests/DxMessaging.Net.Tests.csproj | 4 + CHANGELOG.md | 2 + Runtime/Core/MessageRegistrationToken.cs | 194 ++-- .../Core/TokenTeardownReentrancyTests.cs | 998 ++++++++++++++++++ .../Core/TokenTeardownReentrancyTests.cs.meta | 11 + docs/guides/advanced.md | 7 +- .../unity/comparison-evidence-catalog-v1.json | 2 +- 7 files changed, 1154 insertions(+), 64 deletions(-) create mode 100644 Tests/Runtime/Core/TokenTeardownReentrancyTests.cs create mode 100644 Tests/Runtime/Core/TokenTeardownReentrancyTests.cs.meta diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index 6919bd3a3..69f7fdc06 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -39,5 +39,9 @@ Include="../Tests/Runtime/Core/TokenReplayWithdrawalTests.cs" Link="TokenReplayWithdrawalTests.cs" /> + diff --git a/CHANGELOG.md b/CHANGELOG.md index 38cbabd46..6a7ebe0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve token cleanup and retryable failures when a custom-bus deregistration callback clears, + disables, or registers with the token ([#642](https://github.com/Ambiguous-Interactive/DxMessaging/issues/642)). - Keep token replay valid when a custom-bus registration callback calls `UnregisterAll()` or `Dispose()`; cleared future entries stay inactive and accepted registrations keep their teardown ([#641](https://github.com/Ambiguous-Interactive/DxMessaging/issues/641)). diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index c3f8295ec..76f763ed4 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -117,7 +117,7 @@ read simply materializes an empty collection. The only production writers are the dispatch-time AugmentedHandler bodies, all guarded by _diagnosticMode, so production-with-diagnostics-off never triggers the allocation. Teardown (ClearDiagnosticState / RemoveRegistrationState / - PruneRegistrationStateToFailedDeregistrations) clears through the backing + PruneRegistrationStateToPendingDeregistrations) clears through the backing fields to avoid materializing a collection just to empty it. */ private Dictionary _callCountsBacking; @@ -1439,8 +1439,11 @@ public void Disable() } /// - /// Disables the token and clears all registrations, de-registrations, and token-local diagnostic state. + /// Removes registrations and token-local diagnostic state while preserving any cleanup accepted during the call. /// + /// + /// Registrations accepted during teardown retain their cleanup for a later pass. + /// /// /// /// var h = token.RegisterUntargeted<SceneLoaded>(OnScene); @@ -1451,7 +1454,7 @@ public void Disable() public void UnregisterAll() { Exception deregistrationException = InvokeDeregistrationQueue(); - if (deregistrationException == null) + if (deregistrationException == null && _deregistrationCount == 0) { _enabled = false; ClearRegistrationArena(); @@ -1460,8 +1463,11 @@ public void UnregisterAll() } _enabled = 0 < _deregistrationCount; - PruneRegistrationStateToFailedDeregistrations(); - ExceptionDispatchInfo.Capture(deregistrationException).Throw(); + PruneRegistrationStateToPendingDeregistrations(); + if (deregistrationException != null) + { + ExceptionDispatchInfo.Capture(deregistrationException).Throw(); + } } /// @@ -1567,47 +1573,98 @@ snapshot was executing. Preserve the returned teardown under the original return; } - if (existing is PendingDeregistration pending) - { - pending.Add(deregistration); - return; - } - - /* - A second de-registration accumulated on this handle: promote the inline object to a - PendingDeregistration holder that preserves the ordering / partial-failure / rollback - semantics for the multi-de-registration (retarget-recovery replay) case. - */ - PendingDeregistration promoted = new(); - promoted.Add((MessageHandler.HandlerDeregistration)existing); - promoted.Add(deregistration); - _slots[slotIndex].Deregistration = promoted; + _slots[slotIndex].Deregistration = MergeDeregistrations(existing, deregistration); } private void AddOrphanDeregistration( MessageRegistrationHandle handle, - MessageHandler.HandlerDeregistration deregistration + object value, + bool prepend = false ) { Dictionary orphans = _orphanDeregistrations ??= new Dictionary(); if (!orphans.TryGetValue(handle, out object existing)) { - orphans.Add(handle, deregistration); + orphans.Add(handle, value); ++_deregistrationCount; return; } - if (existing is PendingDeregistration pending) + orphans[handle] = prepend + ? MergeDeregistrations(value, existing) + : MergeDeregistrations(existing, value); + } + + private static PendingDeregistration MergeDeregistrations(object first, object second) + { + PendingDeregistration combined = first as PendingDeregistration; + if (combined == null) { - pending.Add(deregistration); - return; + combined = new PendingDeregistration(); + combined.Add((MessageHandler.HandlerDeregistration)first); } - PendingDeregistration promoted = new(); - promoted.Add((MessageHandler.HandlerDeregistration)existing); - promoted.Add(deregistration); - orphans[handle] = promoted; + if (second is PendingDeregistration pending) + { + combined.AddRange(pending); + } + else + { + combined.Add((MessageHandler.HandlerDeregistration)second); + } + return combined; + } + + /// Temporarily transfers one cleanup value to its invoking owner. + /// 2026-10-18: Nested cleanup must not invoke or consume an in-flight value twice. + private Exception InvokeOwnedDeregistration( + MessageRegistrationHandle handle, + object value, + int startIndex, + bool isOrphan + ) + { + if (isOrphan) + { + _orphanDeregistrations.Remove(handle); + } + else + { + _slots[handle.Slot].Deregistration = null; + } + --_deregistrationCount; + + Exception exception = InvokeDeregistration(value, startIndex, out bool shouldRemove); + if (!shouldRemove) + { + /* + A callback may clear or reuse the original slot, or accept another cleanup + under the same handle. Keep the older unconsumed value first and preserve + both obligations; a removed identity becomes an orphan instead of attaching + its cleanup to a replacement slot. + */ + if (TryGetSlot(handle.Slot, handle.Id, out RegistrationSlot slot)) + { + if (slot.Deregistration == null) + { + _slots[handle.Slot].Deregistration = value; + ++_deregistrationCount; + } + else + { + _slots[handle.Slot].Deregistration = MergeDeregistrations( + value, + slot.Deregistration + ); + } + } + else + { + AddOrphanDeregistration(handle, value, prepend: true); + } + } + return exception; } // Logical de-registration count for a _deregistrations value (inline object == 1). @@ -1897,6 +1954,11 @@ to the next pass rather than joining this snapshot. { teardownQueue.Sort(TeardownIdentityComparer.Instance); } + /* + The current invocation owns this snapshot until its finally block. Nested + cleanup borrows another list, and an arena clear cannot invalidate this one. + */ + _teardownQueue = null; Exception firstException = null; try { @@ -1945,34 +2007,22 @@ to the next pass rather than joining this snapshot. } } - Exception exception = InvokeDeregistration( + Exception exception = InvokeOwnedDeregistration( + handle, value, startIndex, - out bool shouldRemove + identity.IsOrphan ); if (exception != null) { firstException ??= exception; } - - if (shouldRemove) - { - if (identity.IsOrphan) - { - _orphanDeregistrations.Remove(handle); - --_deregistrationCount; - } - else if (TryGetSlot(identity.Slot, identity.Id, out _)) - { - _slots[identity.Slot].Deregistration = null; - --_deregistrationCount; - } - } } } finally { teardownQueue.Clear(); + _teardownQueue = teardownQueue; } return firstException; @@ -2025,7 +2075,7 @@ diagnostics collection is not allocated merely to be emptied. _emissionBufferBacking?.Clear(); } - private void PruneRegistrationStateToFailedDeregistrations() + private void PruneRegistrationStateToPendingDeregistrations() { int slotIndex = _registrationTail; while (0 <= slotIndex) @@ -2076,6 +2126,19 @@ private bool RemoveRegistrationState(int slotIndex, long id) _registrationTail = slot.Previous; } + if (slot.Deregistration != null) + { + /* + A teardown callback may accept another cleanup under this handle. Removing + staging must keep that accepted obligation reachable through orphan cleanup. + */ + --_deregistrationCount; + AddOrphanDeregistration( + MessageRegistrationHandle.FromIdentity(id, slotIndex), + slot.Deregistration + ); + } + MessageRegistrationHandle handle = MessageRegistrationHandle.FromIdentity( id, slotIndex @@ -2116,19 +2179,16 @@ public void RemoveRegistration(MessageRegistrationHandle handle) object value = slot.Deregistration; if (value != null) { - Exception deregistrationException = InvokeDeregistration( + Exception deregistrationException = InvokeOwnedDeregistration( + handle, value, 0, - out bool shouldRemove + isOrphan: false ); - if (shouldRemove && TryGetSlot(handle.Slot, handle.Id, out _)) - { - _slots[handle.Slot].Deregistration = null; - --_deregistrationCount; - } if (deregistrationException != null) { + _enabled = 0 < _deregistrationCount; ExceptionDispatchInfo.Capture(deregistrationException).Throw(); } } @@ -2151,15 +2211,11 @@ private void RemoveOrphanDeregistration(MessageRegistrationHandle handle) return; } - Exception exception = InvokeDeregistration(value, 0, out bool shouldRemove); - if (shouldRemove) - { - _orphanDeregistrations.Remove(handle); - --_deregistrationCount; - } + Exception exception = InvokeOwnedDeregistration(handle, value, 0, isOrphan: true); if (exception != null) { + _enabled = 0 < _deregistrationCount; ExceptionDispatchInfo.Capture(exception).Throw(); } } @@ -2422,9 +2478,8 @@ head has been consumed but overflow survivors remain (before they are exactly as the List form did, rather than jumping the new entry ahead of the survivors into the head slot. The overflow loop re-reads its Count, so such a tail-appended entry is still invoked in the same pass -- the - List form's behavior preserved. (The stored Actions are pure bus - de-registration callbacks, so this re-entrancy is not reachable through - the public API today; the guard keeps the invariant honest regardless.) + List form's behavior preserved. Appending during an invocation must + preserve the logical tail rather than replacing an already consumed head. */ if (!_hasHead && (_overflow == null || _overflow.Count == 0)) { @@ -2436,6 +2491,21 @@ so such a tail-appended entry is still invoked in the same pass -- the (_overflow ??= new List()).Add(action); } + internal void AddRange(PendingDeregistration pending) + { + if (pending._hasHead) + { + Add(pending._head); + } + if (pending._overflow != null) + { + for (int index = 0; index < pending._overflow.Count; ++index) + { + Add(pending._overflow[index]); + } + } + } + internal Exception InvokeFrom(int startIndex) { if (startIndex < 0) diff --git a/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs b/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs new file mode 100644 index 000000000..b8e60ff2d --- /dev/null +++ b/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs @@ -0,0 +1,998 @@ +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + using BusType = DxMessaging.Core.MessageBus.MessageBus; + + /// Probes actual nested teardown through custom bus callbacks. + /// 2026-10-18: Preserve outer cleanup ownership when a custom bus reenters token teardown. + [TestFixture] + public sealed class TokenTeardownReentrancyTests + { + private static readonly InstanceId Context = new InstanceId(841); + + private static IEnumerable<(MessageScenario Scenario, bool Action, int Family)> Shapes() + { + foreach ( + MessageScenario scenario in MessageScenarios.WithAndWithoutPostProcessorIncludingWithoutContext + ) + { + foreach (bool action in new[] { false, true }) + { + if ( + scenario.Kind == MessageKind.Untargeted + && scenario.UsePostProcessor + && action + ) + { + continue; + } + yield return (scenario, action, 0); + } + } + foreach (MessageScenario scenario in MessageScenarios.AllKinds) + { + yield return (scenario, false, 1); + yield return (scenario, false, 2); + yield return (scenario, true, 2); + } + } + + private static IEnumerable Cases() + { + foreach ((MessageScenario Scenario, bool Action, int Family) shape in Shapes()) + { + foreach (bool diagnostics in new[] { false, true }) + { + foreach (int count in new[] { 2, 8 }) + { + foreach (bool outerClear in new[] { false, true }) + { + foreach (int mutation in new[] { 0, 1, 2, 3 }) + { + yield return new TestCaseData( + shape.Scenario, + shape.Action, + shape.Family, + diagnostics, + count, + outerClear, + mutation + ).SetName( + "Teardown_family" + + shape.Family + + "_" + + shape.Scenario + + "_action" + + shape.Action + + "_diagnostics" + + diagnostics + + "_count" + + count + + "_clear" + + outerClear + + "_mutation" + + mutation + ); + } + } + } + } + } + } + + [TestCaseSource(nameof(Cases))] + public void NestedCleanupPreservesOuterOwnership( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + int count, + bool outerClear, + int mutation + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics + + ",count=" + + count + + ",outerClear=" + + outerClear + + ",mutation=" + + mutation; + using CaseState state = new CaseState(diagnostics); + for (int index = 0; index < count; ++index) + { + string name = "handler-" + index; + _ = Register(state.Token, scenario, action, family, () => state.Calls.Add(name)); + } + state.Token.Enable(); + state.Emit(scenario); + Assert.That(state.Calls.Count, Is.EqualTo(count), label + ": control dispatch"); + state.Calls.Clear(); + int mutations = 0; + state.Bus.OnFirstDeregistration = () => + { + ++mutations; + if (mutation == 1) + { + state.Token.Disable(); + } + else if (mutation == 2) + { + state.Token.UnregisterAll(); + } + else if (mutation == 3) + { + state.Token.Dispose(); + } + }; + Exception observed = null; + try + { + if (outerClear) + { + state.Token.UnregisterAll(); + } + else + { + state.Token.Disable(); + } + } + catch (Exception exception) + { + observed = exception; + } + state.Emit(scenario); + TestContext.WriteLine( + label + + "; exception=" + + observed + + "; enabled=" + + state.Token.Enabled + + "; metadata=" + + state.Token._metadata.Count + + "; after=" + + string.Join(",", state.Calls) + ); + Assert.That( + observed, + Is.Null, + label + ": cleanup must not throw an implementation collection exception" + ); + Assert.That(mutations, Is.EqualTo(1), label + ": one-shot mutation"); + Assert.That(state.CleanupCount, Is.Zero, label + ": cleanup count returns to zero"); + Assert.That( + state.Bus.Attempts.Count, + Is.EqualTo(count), + label + ": exactly one attempt per accepted handle" + ); + Assert.That( + state.Bus.Attempts, + Is.Unique, + label + ": in-flight cleanup must not be reentered" + ); + Assert.That(state.Token.Enabled, Is.False, label + ": accepted teardown completes"); + Assert.That(state.Calls, Is.Empty, label + ": no cleaned registration dispatches"); + bool cleared = outerClear || 2 <= mutation; + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(cleared ? 0 : count), + label + ": staged survivors" + ); + state.Token.Enable(); + state.Emit(scenario); + Assert.That( + state.Calls.Count, + Is.EqualTo(cleared ? 0 : count), + label + ": next enable replays each surviving staged entry once" + ); + state.Token.UnregisterAll(); + } + + private static IEnumerable FailureCases() => + CasesWithOptions(new object[] { false, true }, new object[] { 1, 2, 3 }); + + private static IEnumerable AddedCases() => + CasesWithOptions(new object[] { false, true }); + + private static IEnumerable OverlapCases() => + CasesWithOptions(new object[] { false, true }, new object[] { false, true }); + + private static IEnumerable CasesWithOptions( + object[] firstOptions, + object[] secondOptions = null + ) + { + foreach ((MessageScenario Scenario, bool Action, int Family) shape in Shapes()) + { + foreach (bool diagnostics in new[] { false, true }) + { + foreach (object first in firstOptions) + { + if (secondOptions == null) + { + yield return new TestCaseData( + shape.Scenario, + shape.Action, + shape.Family, + diagnostics, + first + ); + } + else + { + foreach (object second in secondOptions) + { + yield return new TestCaseData( + shape.Scenario, + shape.Action, + shape.Family, + diagnostics, + first, + second + ); + } + } + } + } + } + } + + [TestCaseSource(nameof(FailureCases))] + public void NestedCleanupFailureKeepsOriginalIdentityRetryable( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + bool outerClear, + int mutation + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics + + ",outerClear=" + + outerClear + + ",mutation=" + + mutation; + using CaseState state = new CaseState(diagnostics); + _ = Register(state.Token, scenario, action, family, () => state.Calls.Add("first")); + _ = Register(state.Token, scenario, action, family, () => state.Calls.Add("second")); + state.Token.Enable(); + state.Bus.RejectNext = true; + state.Bus.OnFirstDeregistration = () => + { + if (mutation == 1) + { + state.Token.Disable(); + } + else if (mutation == 2) + { + state.Token.UnregisterAll(); + } + else + { + state.Token.Dispose(); + } + }; + Exception observed = Capture(() => + { + if (outerClear) + { + state.Token.UnregisterAll(); + } + else + { + state.Token.Disable(); + } + }); + TestContext.WriteLine( + label + "; observed=" + observed + "; cleanup=" + state.CleanupCount + ); + Assert.That( + observed, + Is.SameAs(state.Bus.Failure), + label + ": preserve the original rejected teardown" + ); + Assert.That( + state.Token.Enabled, + Is.True, + label + ": pending cleanup keeps ownership active" + ); + Assert.That( + state.CleanupCount, + Is.EqualTo(1), + label + ": exactly the rejected in-flight cleanup remains" + ); + Assert.That( + state.Bus.Attempts.Count, + Is.EqualTo(2), + label + ": each original handle attempted once" + ); + Assert.That( + state.Bus.Attempts, + Is.Unique, + label + ": nested cleanup cannot invoke the in-flight action again" + ); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(new[] { "first" }), + label + ": only the rejected registration stays live" + ); + int expectedStaged = + 2 <= mutation ? 0 + : outerClear ? 1 + : 2; + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(expectedStaged), + label + ": preserve only requested staging" + ); + state.Bus.Attempts.Clear(); + if (outerClear) + { + state.Token.UnregisterAll(); + } + else + { + state.Token.Disable(); + } + Assert.That( + state.Bus.Attempts.Count, + Is.EqualTo(1), + label + ": retry through owning cleanup API" + ); + Assert.That( + state.CleanupCount, + Is.Zero, + label + ": retry consumes exactly one retained cleanup" + ); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label + ": retry removes the live registration"); + state.Token.Enable(); + state.Emit(scenario); + Assert.That( + state.Calls.Count, + Is.EqualTo(outerClear ? 0 : expectedStaged), + label + ": staged survivors replay exactly once" + ); + state.Token.UnregisterAll(); + } + + [TestCaseSource(nameof(AddedCases))] + public void AcceptedReentrantRegistrationRetainsCleanupForNextPass( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + bool outerClear + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics + + ",outerClear=" + + outerClear; + using CaseState state = new CaseState(diagnostics); + _ = Register(state.Token, scenario, action, family, () => state.Calls.Add("old")); + state.Token.Enable(); + MessageRegistrationHandle added = default; + state.Bus.OnFirstDeregistration = () => + { + added = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("added") + ); + }; + if (outerClear) + { + state.Token.UnregisterAll(); + } + else + { + state.Token.Disable(); + } + Assert.That( + state.Token.Enabled, + Is.True, + label + ": accepted reentrant registration keeps cleanup active" + ); + Assert.That(state.CleanupCount, Is.EqualTo(1), label + ": one deferred cleanup"); + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(outerClear ? 1 : 2), + label + ": retain staged ownership of the accepted addition" + ); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(new[] { "added" }), + label + ": the new registration is live" + ); + state.Token.RemoveRegistration(added); + Assert.That( + state.CleanupCount, + Is.Zero, + label + ": returned handle owns accepted cleanup" + ); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label + ": removed addition stays absent"); + state.Token.UnregisterAll(); + } + + [TestCaseSource(nameof(OverlapCases))] + public void OverlappingCleanupPreservesOriginalBeforeReenabledRegistration( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + bool removeHandle, + bool reject + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics + + ",remove=" + + removeHandle + + ",reject=" + + reject; + using CaseState state = new CaseState(diagnostics); + MessageRegistrationHandle handle = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("live") + ); + state.Token.Enable(); + state.Bus.RejectNext = reject; + state.Bus.OnFirstDeregistration = () => + { + state.Token.Disable(); + state.Token.Enable(); + }; + Exception observed = Capture(() => + { + if (removeHandle) + { + state.Token.RemoveRegistration(handle); + } + else + { + state.Token.Disable(); + } + }); + Assert.That( + observed, + reject ? Is.SameAs(state.Bus.Failure) : Is.Null, + label + ": original failure or successful cleanup" + ); + Assert.That( + state.Bus.Attempts.Count, + Is.EqualTo(1), + label + ": nested cleanup excludes the current action" + ); + MessageBusRegistration original = state.Bus.Attempts[0]; + Assert.That( + state.CleanupCount, + Is.EqualTo(1), + label + ": one owned handle, including an overlapping holder" + ); + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(removeHandle && !reject ? 0 : 1), + label + ": successful removal drops staging only" + ); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(new[] { "live" }), + label + ": duplicate registration refcounts dispatch once" + ); + state.Bus.Attempts.Clear(); + state.Token.UnregisterAll(); + Assert.That( + state.Bus.Attempts.Count, + Is.EqualTo(reject ? 2 : 1), + label + ": every remaining accepted handle is consumed" + ); + Assert.That(state.Bus.Attempts, Is.Unique, label + ": cleanup handles remain distinct"); + if (reject) + { + Assert.That( + state.Bus.Attempts[0], + Is.EqualTo(original), + label + ": failed original precedes reenabled cleanup" + ); + } + Assert.That(state.CleanupCount, Is.Zero, label + ": all overlap ownership consumed"); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label + ": no overlapping registration leaks"); + } + + [TestCaseSource(nameof(OverlapCases))] + public void SlotReusePreservesFailedOrphanAndReplacementCleanup( + MessageScenario scenario, + bool action, + int family, + bool diagnostics, + bool removeHandle, + bool reject + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics + + ",remove=" + + removeHandle + + ",reject=" + + reject; + using CaseState state = new CaseState(diagnostics); + MessageRegistrationHandle original = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("original") + ); + MessageRegistrationHandle replacement = default; + state.Token.Enable(); + state.Bus.RejectNext = reject; + state.Bus.OnFirstDeregistration = () => + { + state.Token.UnregisterAll(); + replacement = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("replacement") + ); + state.Token.Enable(); + }; + Exception observed = Capture(() => + { + if (removeHandle) + { + state.Token.RemoveRegistration(original); + } + else + { + state.Token.Disable(); + } + }); + Assert.That( + observed, + reject ? Is.SameAs(state.Bus.Failure) : Is.Null, + label + ": preserve cleanup outcome" + ); + Assert.That(replacement.Slot, Is.EqualTo(original.Slot), label + ": actual slot reuse"); + Assert.That( + replacement, + Is.Not.EqualTo(original), + label + ": distinct opaque identity" + ); + Assert.That( + state.Token._metadata.Count, + Is.EqualTo(1), + label + ": replacement staging survives" + ); + Assert.That( + state.CleanupCount, + Is.EqualTo(reject ? 2 : 1), + label + ": failed original becomes an independent orphan" + ); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(reject ? new[] { "original", "replacement" } : new[] { "replacement" }), + label + ": exact accepted callbacks" + ); + state.Token.RemoveRegistration(original); + state.Token.RemoveRegistration(original); + Assert.That( + state.CleanupCount, + Is.EqualTo(1), + label + ": retry and stale copy preserve replacement" + ); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(new[] { "replacement" }), + label + ": original handle cannot remove replacement" + ); + state.Token.RemoveRegistration(replacement); + Assert.That( + state.CleanupCount, + Is.Zero, + label + ": replacement cleanup remains reachable" + ); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label + ": both original and replacement cleaned"); + } + + private static IEnumerable BasicCases() + { + foreach ((MessageScenario Scenario, bool Action, int Family) shape in Shapes()) + { + foreach (bool diagnostics in new[] { false, true }) + { + yield return new TestCaseData( + shape.Scenario, + shape.Action, + shape.Family, + diagnostics + ); + } + } + } + + [TestCaseSource(nameof(BasicCases))] + public void FailedDirectAndOrphanRemovalKeepTokenEnabledForRetry( + MessageScenario scenario, + bool action, + int family, + bool diagnostics + ) + { + string label = + scenario + + ",action=" + + action + + ",family=" + + family + + ",diagnostics=" + + diagnostics; + using CaseState state = new CaseState(diagnostics); + MessageRegistrationHandle handle = Register( + state.Token, + scenario, + action, + family, + () => state.Calls.Add("retained") + ); + state.Token.Enable(); + for (int attempt = 0; attempt < 2; ++attempt) + { + state.Bus.RejectNext = true; + state.Bus.OnFirstDeregistration = state.Token.UnregisterAll; + Exception observed = Capture(() => state.Token.RemoveRegistration(handle)); + Assert.That( + observed, + Is.SameAs(state.Bus.Failure), + label + ": direct/orphan rejection identity, attempt=" + attempt + ); + Assert.That( + state.Token.Enabled, + Is.True, + label + ": accepted failed cleanup keeps token enabled, attempt=" + attempt + ); + Assert.That( + state.CleanupCount, + Is.EqualTo(1), + label + ": one retryable orphan, attempt=" + attempt + ); + Assert.That( + state.Token._metadata.Count, + Is.Zero, + label + ": nested clear removed staging, attempt=" + attempt + ); + } + state.Emit(scenario); + Assert.That( + state.Calls, + Is.EqualTo(new[] { "retained" }), + label + ": failed original remains live" + ); + state.Token.RemoveRegistration(handle); + state.Token.Disable(); + Assert.That( + state.CleanupCount, + Is.Zero, + label + ": successful owner retry consumes orphan" + ); + Assert.That( + state.Token.Enabled, + Is.False, + label + ": completed cleanup disables token" + ); + state.Calls.Clear(); + state.Emit(scenario); + Assert.That(state.Calls, Is.Empty, label + ": final retry removes accepted callback"); + } + + private static Exception Capture(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static MessageRegistrationHandle Register( + MessageRegistrationToken token, + MessageScenario scenario, + bool action, + int family, + Action record + ) + { + return family switch + { + 0 => RegisterHandler(token, scenario, action, record), + 1 => RegisterInterceptor(token, scenario, record), + 2 => RegisterGlobal(token, action, record), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + } + + private sealed class CaseState : IDisposable + { + private static readonly FieldInfo CleanupCountField = + typeof(MessageRegistrationToken).GetField( + "_deregistrationCount", + BindingFlags.Instance | BindingFlags.NonPublic + ); + internal int CleanupCount => (int)CleanupCountField.GetValue(Token); + internal readonly BusType Inner; + internal readonly ReplayBus Bus; + internal readonly MessageHandler Handler; + internal readonly MessageRegistrationToken Token; + internal readonly List Calls = new List(); + private readonly LeakWatcher _leaks; + + internal CaseState(bool diagnostics) + { + Inner = new BusType { DiagnosticsMode = diagnostics }; + Bus = new ReplayBus(Inner); + Handler = new MessageHandler(new InstanceId(840), Bus) { active = true }; + _leaks = LeakWatcher.WatchWithSlots(Bus, handler: Handler); + Token = MessageRegistrationToken.Create(Handler, Bus); + Token.DiagnosticMode = diagnostics; + } + + internal void Emit(MessageScenario scenario) + { + ProbeMessage message = default; + InstanceId context = Context; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + Bus.UntargetedBroadcast(ref message); + break; + case MessageKind.Targeted: + case MessageKind.TargetedWithoutTargeting: + Bus.TargetedBroadcast(ref context, ref message); + break; + case MessageKind.Broadcast: + case MessageKind.BroadcastWithoutSource: + Bus.SourcedBroadcast(ref context, ref message); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + + public void Dispose() + { + try + { + Token.Dispose(); + Assert.That( + Token._metadata.Count, + Is.Zero, + "Successful disposal clears token metadata." + ); + Assert.That( + Token._callCounts.Count, + Is.Zero, + "Successful disposal clears token call counts." + ); + Assert.That( + Token._emissionBuffer.Count, + Is.Zero, + "Successful disposal clears token emission history." + ); + } + finally + { + Handler.active = false; + Inner.Trim(force: true); + _leaks.Dispose(); + } + } + } + + private static MessageRegistrationHandle RegisterHandler( + MessageRegistrationToken token, + MessageScenario scenario, + bool action, + Action record + ) + { + Action scalar = _ => record(); + Action context = (_, __) => record(); + MessageHandler.FastHandler fast = (in ProbeMessage _) => record(); + MessageHandler.FastHandlerWithContext fastContext = ( + in InstanceId _, + in ProbeMessage __ + ) => record(); + return (scenario.Kind, scenario.UsePostProcessor, action) switch + { + (MessageKind.Untargeted, false, true) => token.RegisterUntargeted(scalar), + (MessageKind.Untargeted, false, false) => token.RegisterUntargeted(fast), + (MessageKind.Untargeted, true, _) => token.RegisterUntargetedPostProcessor(fast), + (MessageKind.Targeted, false, true) => token.RegisterTargeted(Context, scalar), + (MessageKind.Targeted, false, false) => token.RegisterTargeted(Context, fast), + (MessageKind.Targeted, true, true) => token.RegisterTargetedPostProcessor( + Context, + scalar + ), + (MessageKind.Targeted, true, false) => token.RegisterTargetedPostProcessor( + Context, + fast + ), + (MessageKind.Broadcast, false, true) => token.RegisterBroadcast(Context, scalar), + (MessageKind.Broadcast, false, false) => token.RegisterBroadcast(Context, fast), + (MessageKind.Broadcast, true, true) => token.RegisterBroadcastPostProcessor( + Context, + scalar + ), + (MessageKind.Broadcast, true, false) => token.RegisterBroadcastPostProcessor( + Context, + fast + ), + (MessageKind.TargetedWithoutTargeting, false, true) => + token.RegisterTargetedWithoutTargeting(context), + (MessageKind.TargetedWithoutTargeting, false, false) => + token.RegisterTargetedWithoutTargeting(fastContext), + (MessageKind.TargetedWithoutTargeting, true, true) => + token.RegisterTargetedWithoutTargetingPostProcessor(context), + (MessageKind.TargetedWithoutTargeting, true, false) => + token.RegisterTargetedWithoutTargetingPostProcessor(fastContext), + (MessageKind.BroadcastWithoutSource, false, true) => + token.RegisterBroadcastWithoutSource(context), + (MessageKind.BroadcastWithoutSource, false, false) => + token.RegisterBroadcastWithoutSource(fastContext), + (MessageKind.BroadcastWithoutSource, true, true) => + token.RegisterBroadcastWithoutSourcePostProcessor(context), + (MessageKind.BroadcastWithoutSource, true, false) => + token.RegisterBroadcastWithoutSourcePostProcessor(fastContext), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterInterceptor( + MessageRegistrationToken token, + MessageScenario scenario, + Action record + ) + { + bool Scalar(ref ProbeMessage message) + { + record(); + return true; + } + bool Contextual(ref InstanceId context, ref ProbeMessage message) + { + record(); + return true; + } + return scenario.Kind switch + { + MessageKind.Untargeted => token.RegisterUntargetedInterceptor(Scalar), + MessageKind.Targeted => token.RegisterTargetedInterceptor(Contextual), + MessageKind.Broadcast => token.RegisterBroadcastInterceptor( + Contextual + ), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + } + + private static MessageRegistrationHandle RegisterGlobal( + MessageRegistrationToken token, + bool action, + Action record + ) + { + if (action) + { + return token.RegisterGlobalAcceptAll( + (IUntargetedMessage _) => record(), + (InstanceId _, ITargetedMessage __) => record(), + (InstanceId _, IBroadcastMessage __) => record() + ); + } + return token.RegisterGlobalAcceptAll( + (in IUntargetedMessage _) => record(), + (in InstanceId _, in ITargetedMessage __) => record(), + (in InstanceId _, in IBroadcastMessage __) => record() + ); + } + + private sealed class ReplayBus : DelegatingMessageBus + { + internal Action OnFirstDeregistration; + internal bool RejectNext; + internal readonly InvalidOperationException Failure = new InvalidOperationException( + "Deregistration rejected before forwarding." + ); + internal readonly List Attempts = + new List(); + + internal ReplayBus(BusType inner) + : base(inner) { } + + public override void Deregister(in MessageBusRegistration registration) + { + Attempts.Add(registration); + bool reject = RejectNext; + RejectNext = false; + Action callback = OnFirstDeregistration; + OnFirstDeregistration = null; + callback?.Invoke(); + if (reject) + { + throw Failure; + } + base.Deregister(in registration); + } + } + + private readonly struct ProbeMessage + : IUntargetedMessage, + ITargetedMessage, + IBroadcastMessage + { + public Type MessageType => typeof(ProbeMessage); + } + } +} diff --git a/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs.meta b/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs.meta new file mode 100644 index 000000000..5c0346a67 --- /dev/null +++ b/Tests/Runtime/Core/TokenTeardownReentrancyTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca4bc9ed0e6c47d9b90959311da5ae45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/advanced.md b/docs/guides/advanced.md index e4a84ace8..0c217c252 100644 --- a/docs/guides/advanced.md +++ b/docs/guides/advanced.md @@ -31,7 +31,12 @@ Lifecycles and tokens - A failed `Enable()` retains handles returned while the token was disabled. Retry `Enable()` after the bus recovers, or remove those handles. - Clearing - - A successful `token.UnregisterAll()` disables and clears staged registrations plus token-local diagnostics. + - `token.UnregisterAll()` removes current registrations and clears token-local diagnostics when + cleanup finishes. + - Custom-bus deregistration callbacks may call token cleanup again. In-flight cleanup runs once; + a failed action keeps its original handle retryable, even if a callback clears or reuses its slot. + - A registration accepted during teardown keeps the token enabled and retains cleanup for the + next pass. Call `UnregisterAll()` again after the callback stops adding registrations. - `token.RemoveRegistration(handle)` removes a single registration. - `token.AsDisposable(handle)` returns a copy-safe concrete value type. Keep that concrete type to avoid `IDisposable` boxing. Only the first successful removal has an effect; a failed removal diff --git a/scripts/unity/comparison-evidence-catalog-v1.json b/scripts/unity/comparison-evidence-catalog-v1.json index 707c75e74..2af6129bf 100644 --- a/scripts/unity/comparison-evidence-catalog-v1.json +++ b/scripts/unity/comparison-evidence-catalog-v1.json @@ -53,7 +53,7 @@ "dxToken": { "origin": "repository", "path": "Runtime/Core/MessageRegistrationToken.cs", - "sha256": "597fccd97b85d412e346a810b40d91434e956133c240f3d41205b4fea464c1a2" + "sha256": "089047a486a82afa76f1a41cf6ea3a6340c82418c36cec0d189d4d7d78259e49" }, "dxFlat": { "origin": "repository", From d2378cad4a4a0e75142a9e266066f41116e30a40 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 18 Oct 2026 08:55:34 +0000 Subject: [PATCH 053/147] Move test and benchmark controls out of production assemblies --- .github/workflows/perf-numbers.yml | 10 +- .llm/context.md | 2 +- .../references/leak-watcher-usage.md | 8 +- .llm/skills/dispatch-hot-path/SKILL.md | 3 +- .../references/sweep-gate-must-be-cheap.md | 4 +- .llm/skills/memory-reclamation/SKILL.md | 8 +- .../references/memory-reclamation.md | 10 +- .net-tests/DxMessaging.Net.Tests.csproj | 53 ++ CHANGELOG.md | 4 + .../Analyzers/DxMessagingConsoleHarvester.cs | 5 +- .../MessageAwareComponentFallbackEditor.cs | 5 +- .../MessageAwareComponentInspectorOverlay.cs | 57 +- .../CustomEditors/MessagingComponentEditor.cs | 3 +- .../MessagingComponentInspectorSnapshot.cs} | 6 +- ...ssagingComponentInspectorSnapshot.cs.meta} | 0 Editor/DxMessagingEditorInitializer.cs | 46 +- Editor/DxMessagingEmissionCaptureNotice.cs | 13 +- .../Settings/DxMessagingBaseCallIgnoreSync.cs | 69 +- Editor/Settings/DxMessagingSettings.cs | 4 - Editor/Testing.meta | 3 - .../Windows/DxMessagingEditorSourceLinks.cs | 57 +- Editor/Windows/DxMessagingFlowGraphWindow.cs | 4 +- .../DxMessagingMessageMonitorLiveView.cs | 3 +- .../DxMessagingMessageMonitorWindow.cs | 4 +- .../DxMessagingRuntimeSettings.cs | 5 +- .../DxMessagingRuntimeSettingsProvider.cs | 151 +--- Runtime/Core/DataStructure/IntKeyMap.cs | 122 +-- Runtime/Core/DxMessagingStaticState.cs | 2 +- Runtime/Core/Internal/FlatDispatch.cs | 28 - Runtime/Core/Internal/TypedSlots.cs | 5 - Runtime/Core/MessageBus/Internal/BusSlots.cs | 20 +- .../Core/MessageBus/Internal/ISweepable.cs | 15 - Runtime/Core/MessageBus/MessageBus.cs | 731 +----------------- Runtime/Core/MessageHandler.cs | 278 +------ Runtime/Core/Pooling/StopwatchClock.cs | 2 +- .../Allocations/AllocationMatrixTests.cs | 24 +- .../BenchmarkHarnessRobustnessTests.cs | 81 +- .../Contract/BusGlobalSlotLiveCountTests.cs | 64 +- .../Editor/Contract/CounterBasedTouchTests.cs | 98 +-- .../Contract/EvictionSweepContractTests.cs | 343 ++++---- .../Contract/MessageBusInvariantTests.cs | 86 ++- .../ProductionEditorAssemblySurfaceTests.cs | 56 ++ ...oductionEditorAssemblySurfaceTests.cs.meta | 2 +- .../Contract/SweepRegistryInspection.cs | 78 ++ .../Contract/SweepRegistryInspection.cs.meta | 11 + .../Contract/TypedSlotIndexCoverageTests.cs | 78 +- Tests/Editor/Contract/TypedSlotShapeTests.cs | 71 +- .../DxMessagingBaseCallIgnoreSyncTests.cs | 724 +++++++---------- .../DxMessagingEmissionCaptureNoticeTests.cs | 6 +- .../Editor/DxMessagingFlowGraphWindowTests.cs | 199 ++--- .../DxMessagingMessageMonitorWindowTests.cs | 45 +- .../DxMessagingSettingsAssetImportTests.cs | 29 +- .../Editor/DxMessagingSettingsLookupTests.cs | 27 +- Tests/Editor/EditorAssetOpenScope.cs | 54 ++ Tests/Editor/EditorAssetOpenScope.cs.meta | 11 + Tests/Editor/EditorDelayCallScope.cs | 155 ++++ Tests/Editor/EditorDelayCallScope.cs.meta | 11 + Tests/Editor/EditorProductionCalls.cs | 58 ++ Tests/Editor/EditorProductionCalls.cs.meta | 11 + Tests/Editor/EditorSettingsStateScope.cs | 153 ++++ Tests/Editor/EditorSettingsStateScope.cs.meta | 11 + Tests/Editor/EditorSourceIndexInspection.cs | 144 ++++ .../EditorSourceIndexInspection.cs.meta | 11 + Tests/Editor/EditorTestCleanup.cs | 30 + Tests/Editor/EditorTestCleanup.cs.meta | 11 + ...essageAwareComponentFallbackEditorTests.cs | 358 +++++---- ...ssagingComponentInspectorSnapshotTests.cs} | 15 +- ...ngComponentInspectorSnapshotTests.cs.meta} | 0 Tests/Runtime/Benchmarks/BenchmarkProtocol.cs | 2 +- .../DispatchThroughputBenchmarks.cs | 18 +- ...andlerCardinalityBenchmarkContractTests.cs | 34 +- .../HandlerCardinalityBenchmarks.cs | 56 +- .../RegistrationLifecycleBenchmarks.cs | 6 +- .../Benchmarks/SnapshotRebuildBenchmarks.cs | 12 +- .../Runtime/Benchmarks/TargetMapBenchmarks.cs | 189 +++-- Tests/Runtime/Core/DiagnosticsTests.cs | 2 +- Tests/Runtime/Core/IntKeyMapTests.cs | 28 +- .../Core/InterceptorRetargetingTests.cs | 20 +- Tests/Runtime/Core/LeakWatcherSelfTests.cs | 5 +- .../MessageBusRegistrationContractTests.cs | 18 +- .../Core/ProductionAssemblySurfaceTests.cs | 75 ++ .../ProductionAssemblySurfaceTests.cs.meta | 11 + Tests/Runtime/Core/ReflexiveTests.cs | 63 +- Tests/Runtime/Core/RegistrationLogTests.cs | 5 +- .../Runtime/Core/UntargetedPrefreezeTests.cs | 5 +- .../MemoryReclaim/MemoryReclamationTests.cs | 350 +++++---- .../TestUtilities/BenchmarkStorageScopes.cs | 227 ++++++ .../BenchmarkStorageScopes.cs.meta | 11 + .../TestUtilities/EntryStorageInspection.cs | 103 +++ .../EntryStorageInspection.cs.meta | 11 + .../HandlerDeregistrationActions.cs | 38 + .../HandlerDeregistrationActions.cs.meta | 11 + Tests/Runtime/TestUtilities/LeakWatcher.cs | 4 +- Tests/Runtime/TestUtilities/MapInspection.cs | 123 +++ .../TestUtilities/MapInspection.cs.meta | 11 + .../TestUtilities/MessageBusInspection.cs | 194 +++++ .../MessageBusInspection.cs.meta | 11 + .../OverlayScriptNavigationProbe.cs | 10 + .../OverlayScriptNavigationProbe.cs.meta | 11 + .../ReflexiveStorageInspection.cs | 63 ++ .../ReflexiveStorageInspection.cs.meta | 11 + .../TestUtilities/RuntimeSettingsFixture.cs | 187 +++++ .../RuntimeSettingsFixture.cs.meta | 11 + .../TestUtilities/StorageInspection.cs | 209 +++++ .../TestUtilities/StorageInspection.cs.meta | 11 + .../TestUtilities/StorageObservations.cs | 143 ++++ .../TestUtilities/StorageObservations.cs.meta | 11 + docs/guides/memory-reclamation.md | 9 +- docs/guides/migration-guide.md | 8 + docs/reference/runtime-settings.md | 45 +- docs/runbooks/perf-benchmark-methodology.md | 19 + .../unity/comparison-evidence-catalog-v1.json | 8 +- scripts/validate-unity-pr-policy.py | 16 +- 113 files changed, 4136 insertions(+), 3074 deletions(-) rename Editor/{Testing/MessagingComponentEditorHarness.cs => CustomEditors/MessagingComponentInspectorSnapshot.cs} (97%) rename Editor/{Testing/MessagingComponentEditorHarness.cs.meta => CustomEditors/MessagingComponentInspectorSnapshot.cs.meta} (100%) delete mode 100644 Editor/Testing.meta delete mode 100644 Runtime/Core/MessageBus/Internal/ISweepable.cs create mode 100644 Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs rename Runtime/Core/MessageBus/Internal/ISweepable.cs.meta => Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta (83%) create mode 100644 Tests/Editor/Contract/SweepRegistryInspection.cs create mode 100644 Tests/Editor/Contract/SweepRegistryInspection.cs.meta create mode 100644 Tests/Editor/EditorAssetOpenScope.cs create mode 100644 Tests/Editor/EditorAssetOpenScope.cs.meta create mode 100644 Tests/Editor/EditorDelayCallScope.cs create mode 100644 Tests/Editor/EditorDelayCallScope.cs.meta create mode 100644 Tests/Editor/EditorProductionCalls.cs create mode 100644 Tests/Editor/EditorProductionCalls.cs.meta create mode 100644 Tests/Editor/EditorSettingsStateScope.cs create mode 100644 Tests/Editor/EditorSettingsStateScope.cs.meta create mode 100644 Tests/Editor/EditorSourceIndexInspection.cs create mode 100644 Tests/Editor/EditorSourceIndexInspection.cs.meta create mode 100644 Tests/Editor/EditorTestCleanup.cs create mode 100644 Tests/Editor/EditorTestCleanup.cs.meta rename Tests/Editor/{MessagingComponentEditorHarnessTests.cs => MessagingComponentInspectorSnapshotTests.cs} (96%) rename Tests/Editor/{MessagingComponentEditorHarnessTests.cs.meta => MessagingComponentInspectorSnapshotTests.cs.meta} (100%) create mode 100644 Tests/Runtime/Core/ProductionAssemblySurfaceTests.cs create mode 100644 Tests/Runtime/Core/ProductionAssemblySurfaceTests.cs.meta create mode 100644 Tests/Runtime/TestUtilities/BenchmarkStorageScopes.cs create mode 100644 Tests/Runtime/TestUtilities/BenchmarkStorageScopes.cs.meta create mode 100644 Tests/Runtime/TestUtilities/EntryStorageInspection.cs create mode 100644 Tests/Runtime/TestUtilities/EntryStorageInspection.cs.meta create mode 100644 Tests/Runtime/TestUtilities/HandlerDeregistrationActions.cs create mode 100644 Tests/Runtime/TestUtilities/HandlerDeregistrationActions.cs.meta create mode 100644 Tests/Runtime/TestUtilities/MapInspection.cs create mode 100644 Tests/Runtime/TestUtilities/MapInspection.cs.meta create mode 100644 Tests/Runtime/TestUtilities/MessageBusInspection.cs create mode 100644 Tests/Runtime/TestUtilities/MessageBusInspection.cs.meta create mode 100644 Tests/Runtime/TestUtilities/OverlayScriptNavigationProbe.cs create mode 100644 Tests/Runtime/TestUtilities/OverlayScriptNavigationProbe.cs.meta create mode 100644 Tests/Runtime/TestUtilities/ReflexiveStorageInspection.cs create mode 100644 Tests/Runtime/TestUtilities/ReflexiveStorageInspection.cs.meta create mode 100644 Tests/Runtime/TestUtilities/RuntimeSettingsFixture.cs create mode 100644 Tests/Runtime/TestUtilities/RuntimeSettingsFixture.cs.meta create mode 100644 Tests/Runtime/TestUtilities/StorageInspection.cs create mode 100644 Tests/Runtime/TestUtilities/StorageInspection.cs.meta create mode 100644 Tests/Runtime/TestUtilities/StorageObservations.cs create mode 100644 Tests/Runtime/TestUtilities/StorageObservations.cs.meta diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 7fb9c944d..66a0b7640 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -1543,8 +1543,8 @@ jobs: : > .artifacts/perf-target-map-current.txt while IFS= read -r file; do - grep 'DXM_TARGET_MAP_' "${file}" \ - | sed -E 's/^.*(DXM_TARGET_MAP_(BENCHMARK|CONSTRUCTION))/\1/' \ + grep -E 'DXM_(TARGET_MAP_|INT_KEY_MAP_CONSTRUCTION)' "${file}" \ + | sed -E 's/^.*(DXM_(TARGET_MAP_BENCHMARK|INT_KEY_MAP_CONSTRUCTION))/\1/' \ | sed 's/^/[Standalone] /' \ >> .artifacts/perf-target-map-current.txt || true done < <(find .artifacts/perf-download -type f \ @@ -1553,7 +1553,7 @@ jobs: awk ' $1 == "[Standalone]" && $2 == "DXM_TARGET_MAP_BENCHMARK" { print $2, $4, $5 } - $1 == "[Standalone]" && $2 == "DXM_TARGET_MAP_CONSTRUCTION" { print $2, $3 } + $1 == "[Standalone]" && $2 == "DXM_INT_KEY_MAP_CONSTRUCTION" { print $2, $3 } ' .artifacts/perf-target-map-current.txt \ > .artifacts/perf-target-map-Standalone-identities.txt duplicate_target_keys="$(sort \ @@ -1570,11 +1570,11 @@ jobs: expected_target_keys=.artifacts/perf-target-map-expected-keys.txt : > "${expected_target_keys}" for key_count in 1 4 16 256 4096; do - for operation in Hit Miss Churn; do + for operation in Hit Miss ChurnWithTrim; do echo "DXM_TARGET_MAP_BENCHMARK keyCount=${key_count} operation=${operation}" \ >> "${expected_target_keys}" done - echo "DXM_TARGET_MAP_CONSTRUCTION keyCount=${key_count}" \ + echo "DXM_INT_KEY_MAP_CONSTRUCTION keyCount=${key_count}" \ >> "${expected_target_keys}" done sort -u -o "${expected_target_keys}" "${expected_target_keys}" diff --git a/.llm/context.md b/.llm/context.md index 9bcf95544..9b810e744 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -236,7 +236,7 @@ fallback. A failed `gh auth status` is not a blocker while either earlier path w - Lease/component cleanup-failure tests should retry through the owning wrapper API (`lease.Deactivate()` / `lease.Dispose()` / `MessagingComponent.Release()`), not only through the raw token; include `lease.Activate()` replay failures and `ActivateOnBuild` failures that leave the token live after rollback cleanup failure. - 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/memory-reclamation/references/memory-reclaim-coverage.md). - Benchmark and performance/allocation tests stay isolated under `Tests/Runtime/Benchmarks` in asmdef `WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks`; keep `BenchmarkAssemblyContractTests` green when adding or moving perf tests. Benchmarks warm up then measure ONE continuous window; the published headline is Standalone IL2CPP (Release player, Release C++ config), .NET Standard 2.1 + Release code optimization; PlayMode/EditMode stay as local/CI test scopes, not published numbers. See [Benchmark Methodology: Total Over One Window](./skills/benchmark-methodology/references/benchmark-methodology-total-over-window.md) and [Perf Config: IL2CPP Release, .NET Standard 2.1](./skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md). -- 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/memory-reclamation/references/memory-reclamation.md). +- When adding a `MessageCache<>` storage field to `MessageBus`, update `MessageBusInvariantTests.ExpectedMessageCacheFieldCount`, add the field to `MessageBus.SweepableTypeCacheRegistry`, and add reclamation coverage; see [DxMessaging Memory Reclamation](./skills/memory-reclamation/references/memory-reclamation.md). ## Documentation Expectations diff --git a/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md b/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md index 4194c6312..445d6e927 100644 --- a/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md +++ b/.llm/skills/allocation-and-leak-coverage/references/leak-watcher-usage.md @@ -31,10 +31,10 @@ so several callbacks at one priority can leave those counters unchanged. An equa and opposite change in two categories must still fail disposal even when the aggregate total is zero. -For built-in `MessageBus` instances, `Snapshot` also uses the cold internal -`LiveRegistrationCountForTesting` query. It sums registration reference counts +For built-in `MessageBus` instances, `Snapshot` also uses +`MessageBusInspection.CountLiveRegistrations` in the test assembly. It sums registration reference counts from existing handler, interceptor and global stores, including shared-priority -callbacks and duplicate registrations. The query adds no bus fields and never runs on dispatch. +callbacks and duplicate registrations. The inspection adds no production members and never runs on dispatch. `DelegatingMessageBus.RegistrationCountSource` exposes the forwarded source for test doubles; a double with independent storage can return null to keep public-counter accounting. @@ -150,7 +150,7 @@ retained storage on the supplied bus. Use return to baseline. Always supply the intended bus explicitly when a handler participates in more than one bus. -The internal `MessageHandler.GetRetainedStorageCounts` query counts context keys +The test-only `EntryStorageInspection.GetRetainedStorageCounts` query counts context keys across typed slots and retained priority caches in both scalar and context-keyed maps. Keys in separate typed slots count separately even when they hold the same `InstanceId`. Several delegates sharing one priority cache count as one cache. diff --git a/.llm/skills/dispatch-hot-path/SKILL.md b/.llm/skills/dispatch-hot-path/SKILL.md index 59f25ecf6..1433c5d21 100644 --- a/.llm/skills/dispatch-hot-path/SKILL.md +++ b/.llm/skills/dispatch-hot-path/SKILL.md @@ -91,8 +91,7 @@ These apply to `DispatchFlatSnapshot`, `DispatchContextFlatSnapshot`, - Headless hosts, dedicated servers, and non-Unity consumers have no PlayerLoop hook (`#if UNITY_2021_3_OR_NEWER`) and must call `bus.Trim()` themselves. - Tests inject a probe clock through `MessageBus.CreateForInternalUse(probeClock, ...)`. - `DxMessagingRuntimeSettingsProvider.Override` cannot inject it; the clock is - constructor-injected. + The clock is constructor-injected. ### AggressiveInlining diff --git a/.llm/skills/dispatch-hot-path/references/sweep-gate-must-be-cheap.md b/.llm/skills/dispatch-hot-path/references/sweep-gate-must-be-cheap.md index 6305d4488..a80913344 100644 --- a/.llm/skills/dispatch-hot-path/references/sweep-gate-must-be-cheap.md +++ b/.llm/skills/dispatch-hot-path/references/sweep-gate-must-be-cheap.md @@ -69,9 +69,7 @@ non-Unity consumer: ## 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. +...)`. The clock is constructor-injected; the settings asset does not carry it. ## Per-emit clock-read budget diff --git a/.llm/skills/memory-reclamation/SKILL.md b/.llm/skills/memory-reclamation/SKILL.md index 503644262..3a0767812 100644 --- a/.llm/skills/memory-reclamation/SKILL.md +++ b/.llm/skills/memory-reclamation/SKILL.md @@ -44,7 +44,7 @@ state, not stale cache. slot touch is a single counter write, and active dispatch snapshots are leased so a forced trim cannot return an array still being iterated. - Every holder must reach one of the three reclaim paths: - `MessageBus.SweepableTypeCaches` (bus scalar sinks, context sinks, interceptor + `MessageBus.SweepableTypeCacheRegistry` (bus scalar sinks, context sinks, interceptor caches), `MessageHandler.ResetEmptyTypedSlotsForSweep` / `MessageBus.SweepGlobalSlot` (handler slots), or `DxPools.TrimAll` (shared collection pools). @@ -62,8 +62,8 @@ state, not stale cache. ### Adding a MessageCache field -1. Bump `MessageBus.ExpectedMessageCacheFieldCount`. -1. Add the matching row to `MessageBus.SweepableTypeCaches`. +1. Bump `MessageBusInvariantTests.ExpectedMessageCacheFieldCount`. +1. Add the matching row to `MessageBus.SweepableTypeCacheRegistry`. 1. Extend `MessageBusInvariantTests` for the new field. 1. Add a `MemoryReclamationTests` row proving the cache trims. 1. Update `LeakWatcher` when the cache adds a public leak counter. @@ -84,7 +84,7 @@ state, not stale cache. measured against the watcher's own snapshot, so the bus need not be empty. - For typed-handler reclamation, also pass `handler: handler` to `LeakWatcher.WatchWithSlots(bus, ...)`. This checks retained context keys and - priority caches through a cold internal query; those deltas are separate from + priority caches through cold inspection in the test assembly; those deltas are separate from public bus counters and freeze on disposal. Default watchers skip this query. - Put direct reclamation fixtures in `Tests/Runtime/MemoryReclaim` with `[Category("MemoryReclaim")]`. The category is opt-in and, like `Stress`, diff --git a/.llm/skills/memory-reclamation/references/memory-reclamation.md b/.llm/skills/memory-reclamation/references/memory-reclamation.md index f85eec711..efd1c70f7 100644 --- a/.llm/skills/memory-reclamation/references/memory-reclamation.md +++ b/.llm/skills/memory-reclamation/references/memory-reclamation.md @@ -30,9 +30,9 @@ pool trim path. | 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` | +| Bus scalar sinks | message type | `MessageBus.SweepableTypeCacheRegistry` | +| Bus context sinks | message type and `InstanceId` | `MessageBus.SweepableTypeCacheRegistry` | +| Interceptor caches | message type | `MessageBus.SweepableTypeCacheRegistry` | | 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` | @@ -82,8 +82,8 @@ the same settings in `MessageBus.ApplyRuntimeSettings`. When adding a new `MessageCache<>` storage field to `MessageBus`: -1. Bump `MessageBus.ExpectedMessageCacheFieldCount`. -1. Add a matching row to `MessageBus.SweepableTypeCaches`. +1. Bump `MessageBusInvariantTests.ExpectedMessageCacheFieldCount`. +1. Add a matching row to `MessageBus.SweepableTypeCacheRegistry`. 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. diff --git a/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index 69f7fdc06..724b2ddb8 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -22,6 +22,31 @@ Include="../Tests/Runtime/TestUtilities/ScenarioHarness.cs" Link="ScenarioHarness.cs" /> + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7ebe0ca..60ce1fef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Remove the test-only `DxMessagingRuntimeSettingsProvider.Override` API. Configure + the runtime settings resource asset for application behavior and keep fixture configuration in + test assemblies; see the [4.0 migration guide](docs/guides/migration-guide.md#migrating-to-400). - Reduce recurring Unity correctness setup by installing only the locked Node.js runtime dependencies used to redact and seal test artifacts ([#572](https://github.com/Ambiguous-Interactive/DxMessaging/issues/572)). - Add disabled, missing-token, destroyed-receiver, stale-selection, and separate-bus visibility @@ -51,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve token cleanup and retryable failures when a custom-bus deregistration callback clears, disables, or registers with the token ([#642](https://github.com/Ambiguous-Interactive/DxMessaging/issues/642)). + - Keep token replay valid when a custom-bus registration callback calls `UnregisterAll()` or `Dispose()`; cleared future entries stay inactive and accepted registrations keep their teardown ([#641](https://github.com/Ambiguous-Interactive/DxMessaging/issues/641)). diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs index 27617ef1a..c10e76906 100644 --- a/Editor/Analyzers/DxMessagingConsoleHarvester.cs +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -191,8 +191,6 @@ does not expose the expected LogEntries shape. private static int _lastSeenCount; private static int _lastSettingsRevision = -1; - internal static int RescanCount { get; private set; } - /* Latch flipped on by `OnAssemblyCompilationFinished` to coalesce the burst of one-event- per-assembly callbacks Unity fires during a build. We schedule a single deferred @@ -270,7 +268,7 @@ public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReport /// true when the legacy UnityEditor.LogEntries reflection layer resolved /// successfully on this Unity version. The harvester does not require this to be true to /// function -- Unity 2021's analyzer warnings flow through the CompilerMessage feed - /// instead. Exposed primarily for diagnostics / tests. + /// instead. Available for editor diagnostics. /// public static bool LogEntriesAvailable => !_logEntriesDisabled; @@ -418,7 +416,6 @@ and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. } int settingsRevision = DxMessagingSettings.SettingsRevision; - RescanCount++; DxMessagingSettings settings = TryLoadSettings(); _lastSettingsRevision = settingsRevision; if (settings != null && !settings._baseCallCheckEnabled) diff --git a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs index 93d623d3f..f502e3154 100644 --- a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs +++ b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs @@ -315,10 +315,7 @@ private void ScheduleRefreshRetry() } _refreshRetryScheduled = true; - System.Action schedule = - MessageAwareComponentInspectorOverlay.TransientRefreshScheduler - ?? DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - schedule(() => + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation(() => { _refreshRetryScheduled = false; Refresh(); diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index 2113a85ed..0f75fa26a 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -61,25 +61,6 @@ fires once per visible inspector per frame. */ private static readonly HashSet _renderedThisRepaint = new(); - internal static System.Action AssetDatabaseMutationScheduler { get; set; } = - DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - - internal static System.Action TransientRefreshScheduler { get; set; } = - DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - - internal static System.Action InspectorRepaintAction { get; set; } = RepaintAllInspectors; - - internal static System.Func InspectorResolutionTransientBlocker { get; set; } = - () => EditorApplication.isCompiling || EditorApplication.isUpdating; - - internal static System.Func ImGuiRenderAction { get; set; } = - BuildAndRenderOverlay; - - internal static System.Action< - MessageAwareComponent, - BaseCallReportEntry - > OpenScriptAction { get; set; } = OpenScriptForComponentCore; - internal static event System.Action InspectorStateInvalidated; static MessageAwareComponentInspectorOverlay() @@ -175,9 +156,7 @@ private static bool TryRenderForHeaderHook(Object target, EventType? eventType) return false; } - System.Func render = - ImGuiRenderAction ?? BuildAndRenderOverlay; - return render.Invoke(messageAwareComponent); + return BuildAndRenderOverlay(messageAwareComponent); } /// @@ -264,24 +243,9 @@ internal static VisualElement CreateInspectorView(MessageAwareComponentInspector return MessageAwareComponentInspectorView.Create(state, CreateInspectorViewActions()); } - internal static void ResetTestSeams() - { - _renderedThisRepaint.Clear(); - AssetDatabaseMutationScheduler = DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - TransientRefreshScheduler = DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - InspectorRepaintAction = RepaintAllInspectors; - InspectorResolutionTransientBlocker = () => - EditorApplication.isCompiling || EditorApplication.isUpdating; - ImGuiRenderAction = BuildAndRenderOverlay; - OpenScriptAction = OpenScriptForComponentCore; - } - internal static bool IsInspectorResolutionTransientlyBlocked() { - System.Func isBlocked = - InspectorResolutionTransientBlocker - ?? (() => EditorApplication.isCompiling || EditorApplication.isUpdating); - return isBlocked.Invoke(); + return EditorApplication.isCompiling || EditorApplication.isUpdating; } internal static MessageAwareComponentInspectorState ResolveInspectorState(Object target) @@ -422,10 +386,9 @@ private static MessageAwareComponentInspectorViewActions CreateInspectorViewActi private static void NotifyInspectorStateInvalidated() { - System.Action repaint = InspectorRepaintAction ?? RepaintAllInspectors; try { - repaint.Invoke(); + RepaintAllInspectors(); } catch (System.Exception ex) { @@ -543,9 +506,7 @@ private static void OpenScriptForComponent( BaseCallReportEntry entry ) { - System.Action openScript = - OpenScriptAction ?? OpenScriptForComponentCore; - openScript.Invoke(component, entry); + OpenScriptForComponentCore(component, entry); } private static void OpenScriptForComponentCore( @@ -585,10 +546,7 @@ would flip the overlay's shape between Layout and Repaint passes of the SAME current GUI cycle, so the next frame's Layout pass sees the new state and both passes emit consistent control counts. */ - System.Action schedule = - AssetDatabaseMutationScheduler - ?? DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - schedule(() => + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation(() => { if (settings == null) { @@ -616,10 +574,7 @@ private static void TryRemoveIgnoredType(DxMessagingSettings settings, string fu the overlay's shape gating remains identical on Layout and Repaint passes of THIS frame. The next frame's Layout pass observes the new state; both passes agree. */ - System.Action schedule = - AssetDatabaseMutationScheduler - ?? DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - schedule(() => + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation(() => { if (settings == null) { diff --git a/Editor/CustomEditors/MessagingComponentEditor.cs b/Editor/CustomEditors/MessagingComponentEditor.cs index 69de150b3..b91058007 100644 --- a/Editor/CustomEditors/MessagingComponentEditor.cs +++ b/Editor/CustomEditors/MessagingComponentEditor.cs @@ -8,7 +8,6 @@ namespace DxMessaging.Editor.CustomEditors using Core.MessageBus; using Core.Messages; using DxMessaging.Editor; - using DxMessaging.Editor.Testing; using DxMessaging.Unity; using UnityEditor; using UnityEngine; @@ -104,7 +103,7 @@ close to unreadable as label text on the light skin. } MessagingComponentInspectorState inspectorState = - MessagingComponentEditorHarness.Capture(component); + MessagingComponentInspectorSnapshot.Capture(component); ProviderDiagnosticsView providerDiagnostics = inspectorState.ProviderDiagnostics; if (providerDiagnostics.SerializedProviderMissingWarning) diff --git a/Editor/Testing/MessagingComponentEditorHarness.cs b/Editor/CustomEditors/MessagingComponentInspectorSnapshot.cs similarity index 97% rename from Editor/Testing/MessagingComponentEditorHarness.cs rename to Editor/CustomEditors/MessagingComponentInspectorSnapshot.cs index 0a91bc85c..a392d3f0f 100644 --- a/Editor/Testing/MessagingComponentEditorHarness.cs +++ b/Editor/CustomEditors/MessagingComponentInspectorSnapshot.cs @@ -1,5 +1,5 @@ #if UNITY_EDITOR -namespace DxMessaging.Editor.Testing +namespace DxMessaging.Editor.CustomEditors { using System; using System.Collections.Generic; @@ -12,9 +12,9 @@ namespace DxMessaging.Editor.Testing /// /// Captures inspector-oriented diagnostics for instances without relying on GUI APIs. - /// Intended for automated editor tests that need to validate inspector state transitions. + /// Supplies the component inspector, Message Monitor, and Flow Graph with consistent state. /// - internal static class MessagingComponentEditorHarness + internal static class MessagingComponentInspectorSnapshot { private static readonly MessageEmissionData[] EmptyEmissions = Array.Empty(); diff --git a/Editor/Testing/MessagingComponentEditorHarness.cs.meta b/Editor/CustomEditors/MessagingComponentInspectorSnapshot.cs.meta similarity index 100% rename from Editor/Testing/MessagingComponentEditorHarness.cs.meta rename to Editor/CustomEditors/MessagingComponentInspectorSnapshot.cs.meta diff --git a/Editor/DxMessagingEditorInitializer.cs b/Editor/DxMessagingEditorInitializer.cs index 05ea518e9..8bd595385 100644 --- a/Editor/DxMessagingEditorInitializer.cs +++ b/Editor/DxMessagingEditorInitializer.cs @@ -22,18 +22,6 @@ public static class DxMessagingEditorInitializer private static bool s_playModeWarningIssued; private static bool s_ensureSettingsAssetScheduled; - internal static Action StaticStateResetter { get; set; } = DxMessagingStaticState.Reset; - internal static Func PassiveSettingsLoader { get; set; } = - DxMessagingSettings.LoadSettingsPassive; - internal static Func SettingsAssetEnsurer { get; set; } = - DxMessagingSettings.GetOrCreateSettings; - internal static Action AssetDatabaseMutationScheduler { get; set; } = - DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - internal static Func DomainReloadDisabledDetector { get; set; } = - IsDomainReloadDisabled; - internal static Action DomainReloadWarningLogger { get; set; } = - message => Debug.LogWarning(message); - static DxMessagingEditorInitializer() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; @@ -64,7 +52,7 @@ private static void ApplySettingsBeforeSceneLoad() internal static void ApplyEditorSettings() { - (StaticStateResetter ?? DxMessagingStaticState.Reset)(); + DxMessagingStaticState.Reset(); /* Issue #210: ApplyEditorSettings is reachable from the [InitializeOnLoad] static @@ -75,9 +63,7 @@ AssetDatabase mutation -- the CreateAsset/SaveAssets/legacy-migration inside mutation-free passive load now; ensure the asset exists/migrates on the next editor tick, off the import window. */ - Func passiveSettingsLoader = - PassiveSettingsLoader ?? DxMessagingSettings.LoadSettingsPassive; - ApplySettingsAndDiagnostics(passiveSettingsLoader()); + ApplySettingsAndDiagnostics(DxMessagingSettings.LoadSettingsPassive()); ScheduleEnsureSettingsAsset(); } @@ -93,10 +79,9 @@ so coalesce to a single pending callback (mirrors the harvester's _rescanSchedul return; } s_ensureSettingsAssetScheduled = true; - Action scheduler = - AssetDatabaseMutationScheduler - ?? DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - scheduler(EnsureSettingsAssetThenApplyGlobals); + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation( + EnsureSettingsAssetThenApplyGlobals + ); } private static void EnsureSettingsAssetThenApplyGlobals() @@ -110,9 +95,7 @@ defensively rather than letting an exception escape this editor callback. s_ensureSettingsAssetScheduled = false; try { - Func settingsAssetEnsurer = - SettingsAssetEnsurer ?? DxMessagingSettings.GetOrCreateSettings; - ApplySettingsAndDiagnostics(settingsAssetEnsurer()); + ApplySettingsAndDiagnostics(DxMessagingSettings.GetOrCreateSettings()); } catch (Exception ex) { @@ -140,18 +123,6 @@ private static void ApplyGlobals(DxMessagingSettings settings) IMessageBus.GlobalMessageBufferSize = settings.MessageBufferSize; } - internal static void ResetTestSeams() - { - s_playModeWarningIssued = false; - s_ensureSettingsAssetScheduled = false; - StaticStateResetter = DxMessagingStaticState.Reset; - PassiveSettingsLoader = DxMessagingSettings.LoadSettingsPassive; - SettingsAssetEnsurer = DxMessagingSettings.GetOrCreateSettings; - AssetDatabaseMutationScheduler = DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - DomainReloadDisabledDetector = IsDomainReloadDisabled; - DomainReloadWarningLogger = message => Debug.LogWarning(message); - } - private static void WarnIfDomainReloadDisabled(DxMessagingSettings settings) { /* @@ -163,15 +134,14 @@ never permanently suppresses an explicitly unsuppressed settings asset. s_playModeWarningIssued || settings == null || settings.SuppressDomainReloadWarning - || !(DomainReloadDisabledDetector ?? IsDomainReloadDisabled)() + || !IsDomainReloadDisabled() ) { return; } s_playModeWarningIssued = true; - Action warningLogger = DomainReloadWarningLogger ?? Debug.LogWarning; - warningLogger(DomainReloadWarningMessage); + Debug.LogWarning(DomainReloadWarningMessage); } private static bool IsDomainReloadDisabled() diff --git a/Editor/DxMessagingEmissionCaptureNotice.cs b/Editor/DxMessagingEmissionCaptureNotice.cs index 1748671d9..9a11912b1 100644 --- a/Editor/DxMessagingEmissionCaptureNotice.cs +++ b/Editor/DxMessagingEmissionCaptureNotice.cs @@ -22,7 +22,7 @@ namespace DxMessaging.Editor /// internal static class DxMessagingEmissionCaptureNotice { - /// Name of the enable button, so tests and surfaces can find it. + /// Name of the enable button shared by the diagnostic surfaces. internal const string EnableButtonName = "dxmessaging-enable-emission-capture"; /// Short suffix for a collapsed header, e.g. "Stack trace (capture off)". @@ -43,12 +43,6 @@ internal static class DxMessagingEmissionCaptureNotice /// internal static bool CaptureEnabled => IMessageBus.GlobalDiagnosticsStackTraces; - /// - /// Test seam mirroring the inspector overlay's: lets a test run the settings write inline - /// instead of waiting on delayCall plus an editor-idle tick. - /// - internal static Action AssetDatabaseMutationScheduler { get; set; } - /// /// Turns capture on for this session immediately, then persists it to the settings asset so /// it survives a domain reload. The in-memory flip is deliberately NOT deferred: the user @@ -59,10 +53,7 @@ internal static void EnableCapture() { IMessageBus.GlobalDiagnosticsStackTraces = true; - Action schedule = - AssetDatabaseMutationScheduler - ?? DxMessagingEditorIdle.ScheduleAssetDatabaseMutation; - schedule(() => + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation(() => { try { diff --git a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs index cbd283864..c7dace558 100644 --- a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs +++ b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs @@ -33,39 +33,6 @@ public static class DxMessagingBaseCallIgnoreSync private const string FormatComment = "# One fully-qualified type name per line. Lines starting with # are comments."; - /// - /// Test seam controlling WHEN deferred regeneration runs. Production schedules the work on - /// the next editor tick via ; tests substitute a - /// capturing scheduler so they can assert no synchronous AssetDatabase import happens and - /// then drive the deferred work deterministically without touching the filesystem. - /// - internal static Action DeferralScheduler { get; set; } = - work => EditorApplication.delayCall += () => work(); - - /// - /// Test seam for Unity editor state. Production uses Unity's update/compile flags; tests - /// substitute a deterministic busy/idle predicate to prove deferred work requeues. - /// - internal static Func CanMutateAssetDatabase { get; set; } = - DxMessaging.Editor.DxMessagingEditorIdle.CanMutateAssetDatabase; - - /// - /// Test seam controlling WHAT regeneration does. Production performs the real - /// write + via ; - /// tests substitute a recorder so they can observe whether the apply happens synchronously - /// or only after the deferred tick, without writing the on-disk sidecar. - /// - internal static Action SidecarApplier { get; set; } = - RegenerateSidecarCore; - - /// - /// Test seam for the follow-up csc.rsp sync. Production schedules the - /// -additionalfile normalization after sidecar regeneration, which covers deferred - /// OnValidate writes that finish after 's startup pass. - /// - internal static Action CscRspAdditionalFileSyncScheduler { get; set; } = - ScheduleDefaultCscRspAdditionalFileSync; - /// /// Regenerates the sidecar text file from the supplied settings asset. Writes only when /// the on-disk content differs from what would be written, to avoid AssetDatabase churn @@ -74,7 +41,7 @@ public static class DxMessagingBaseCallIgnoreSync /// The settings asset. May be null -- no-op in that case. /// /// This is the entry point for explicit, user-driven edits (the "Ignore this type" button, - /// settings asset Inspector edits) and for EditMode tests: when Unity is idle the regen runs + /// settings asset Inspector edits): when Unity is idle the regen runs /// synchronously for immediate feedback, and when Unity is mid-compile or mid-asset-import /// it falls back to . It MUST NOT be called from a /// deserialization callback such as ScriptableObject.OnValidate -- those callbacks @@ -90,7 +57,7 @@ public static void RegenerateSidecar(DxMessagingSettings settings) return; } - if (!CanCurrentlyMutateAssetDatabase()) + if (!DxMessaging.Editor.DxMessagingEditorIdle.CanMutateAssetDatabase()) { RegenerateSidecarDeferred(settings); return; @@ -127,33 +94,21 @@ internal static void RegenerateSidecarDeferred(DxMessagingSettings settings) return; } - DxMessaging.Editor.DxMessagingEditorIdle.ScheduleAssetDatabaseMutation( - () => + DxMessaging.Editor.DxMessagingEditorIdle.ScheduleAssetDatabaseMutation(() => + { + if (settings == null) { - if (settings == null) - { - return; - } - ApplySidecarAndScheduleCscRspSync(settings); - }, - DeferralScheduler, - CanCurrentlyMutateAssetDatabase - ); - } - - private static bool CanCurrentlyMutateAssetDatabase() - { - Func canMutateAssetDatabase = - CanMutateAssetDatabase - ?? DxMessaging.Editor.DxMessagingEditorIdle.CanMutateAssetDatabase; - return canMutateAssetDatabase(); + return; + } + ApplySidecarAndScheduleCscRspSync(settings); + }); } private static void ApplySidecarAndScheduleCscRspSync(DxMessagingSettings settings) { try { - SidecarApplier(settings); + RegenerateSidecarCore(settings); } finally { @@ -165,9 +120,7 @@ private static void ScheduleCscRspAdditionalFileSync() { try { - Action scheduler = - CscRspAdditionalFileSyncScheduler ?? ScheduleDefaultCscRspAdditionalFileSync; - scheduler(); + ScheduleDefaultCscRspAdditionalFileSync(); } catch (Exception ex) { diff --git a/Editor/Settings/DxMessagingSettings.cs b/Editor/Settings/DxMessagingSettings.cs index 6a1d712c9..79ec54c58 100644 --- a/Editor/Settings/DxMessagingSettings.cs +++ b/Editor/Settings/DxMessagingSettings.cs @@ -33,8 +33,6 @@ public sealed class DxMessagingSettings : ScriptableObject private static string _cachedSettingsPath; internal static int SettingsRevision => Volatile.Read(ref _settingsRevision); - internal static int SettingsLookupCount { get; private set; } - internal static int SettingsSearchCount { get; private set; } internal static void InvalidateSettingsCache() { @@ -310,7 +308,6 @@ internal static DxMessagingSettings LoadSettingsPassive( Func guidToPath ) { - SettingsLookupCount++; int revision = SettingsRevision; if ( _cachedSettingsRevision == revision @@ -324,7 +321,6 @@ Func guidToPath string settingsPath = SettingsPath; if (settings == null) { - SettingsSearchCount++; string[] guids = findGuids(); if (guids != null) { diff --git a/Editor/Testing.meta b/Editor/Testing.meta deleted file mode 100644 index 4f96da3ef..000000000 --- a/Editor/Testing.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: a61780c8ba084b29bb44fdefc9b7fcf2 -timeCreated: 1761880591 diff --git a/Editor/Windows/DxMessagingEditorSourceLinks.cs b/Editor/Windows/DxMessagingEditorSourceLinks.cs index 1b59cc1e9..566fc2f0d 100644 --- a/Editor/Windows/DxMessagingEditorSourceLinks.cs +++ b/Editor/Windows/DxMessagingEditorSourceLinks.cs @@ -156,7 +156,6 @@ private static readonly Dictionary< string, MessageSourceAssemblyIndex > MessageSourceAssemblyIndexes = new(StringComparer.Ordinal); - private static Func _messageSourceFileReader = File.ReadAllLines; internal static event Action MessageSourceIndexChanged; private static readonly Regex SourceNamespaceDeclarationPattern = new( @"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*([;{]?)", @@ -461,8 +460,7 @@ UnityEditor.Compilation.Assembly compilationAssembly && !string.IsNullOrWhiteSpace(sourceFile.AssetPath) ) .ToArray(); - Func fileReader = _messageSourceFileReader; - return Task.Run(() => BuildMessageSourceAssemblyIndex(sourceFiles, fileReader)); + return Task.Run(() => BuildMessageSourceAssemblyIndex(sourceFiles)); } private static void RegisterMessageSourceIndexDrain() @@ -491,12 +489,11 @@ private static MessageSourceFile CreateMessageSourceFile(string sourceFile) } private static MessageSourceIndexBuildResult BuildMessageSourceAssemblyIndex( - IReadOnlyList sourceFiles, - Func readAllLines + IReadOnlyList sourceFiles ) { Dictionary locations = new(StringComparer.Ordinal); - if (sourceFiles == null || readAllLines == null) + if (sourceFiles == null) { return new MessageSourceIndexBuildResult(locations, isComplete: false); } @@ -507,7 +504,7 @@ Func readAllLines string[] lines; try { - lines = readAllLines(sourceFile.FullPath); + lines = File.ReadAllLines(sourceFile.FullPath); } catch (IOException) { @@ -611,52 +608,6 @@ MessageSourceAssemblyIndex index in MessageSourceAssemblyIndexes.Values.Where( } } - internal static int PendingMessageSourceIndexCount => - MessageSourceAssemblyIndexes.Values.Count(index => index.BuildTask != null); - - internal static int CompletedMessageSourceIndexCount => - MessageSourceAssemblyIndexes.Values.Count(index => - index.BuildTask != null && index.BuildTask.IsCompleted - ); - - internal static Func MessageSourceFileReader - { - get => _messageSourceFileReader; - set => _messageSourceFileReader = value ?? File.ReadAllLines; - } - - internal static void ResetMessageSourceIndexesForTests() - { - EditorApplication.update -= DrainMessageSourceAssemblyIndexes; - MessageSourceAssemblyIndexes.Clear(); - MessageSourceLocationCache.Clear(); - _messageSourceFileReader = File.ReadAllLines; - } - - internal static void CompleteMessageSourceIndexesForTests() - { - Task[] buildTasks = MessageSourceAssemblyIndexes - .Values.Where(index => index.BuildTask != null) - .Select(index => (Task)index.BuildTask) - .ToArray(); - Task.WaitAll(buildTasks); - DrainMessageSourceAssemblyIndexes(); - } - - /// - /// Raises the completion signal without needing a real index build, so a test can prove a - /// window re-renders when the background index lands. - /// - internal static void RaiseMessageSourceIndexChangedForTests() - { - MessageSourceIndexChanged?.Invoke(); - } - - internal static void DrainMessageSourceIndexesForTests() - { - DrainMessageSourceAssemblyIndexes(); - } - internal static int FindTypeDeclarationLine( IReadOnlyList lines, string targetNamespace, diff --git a/Editor/Windows/DxMessagingFlowGraphWindow.cs b/Editor/Windows/DxMessagingFlowGraphWindow.cs index 26414ebf0..5b2369f80 100644 --- a/Editor/Windows/DxMessagingFlowGraphWindow.cs +++ b/Editor/Windows/DxMessagingFlowGraphWindow.cs @@ -11,7 +11,7 @@ namespace DxMessaging.Editor.Windows using Core.Diagnostics; using Core.Messages; using DxMessaging.Editor; - using DxMessaging.Editor.Testing; + using DxMessaging.Editor.CustomEditors; using DxMessaging.Unity; using UnityEditor; using UnityEditor.SceneManagement; @@ -831,7 +831,7 @@ IEnumerable components try { MessagingComponentInspectorState state = - MessagingComponentEditorHarness.Capture( + MessagingComponentInspectorSnapshot.Capture( component, resolveSerializedProviderBus: false ); diff --git a/Editor/Windows/DxMessagingMessageMonitorLiveView.cs b/Editor/Windows/DxMessagingMessageMonitorLiveView.cs index 09ba053b8..ce244fb10 100644 --- a/Editor/Windows/DxMessagingMessageMonitorLiveView.cs +++ b/Editor/Windows/DxMessagingMessageMonitorLiveView.cs @@ -599,8 +599,7 @@ int shownCount } /// - /// Builds one log row. Exposed so tests exercise the same factory the list binds, without - /// depending on when the virtualized list realizes a row. + /// Builds one log row for the virtualized list. /// internal static VisualElement CreateRow( MessageMonitorLiveEntry row, diff --git a/Editor/Windows/DxMessagingMessageMonitorWindow.cs b/Editor/Windows/DxMessagingMessageMonitorWindow.cs index 1883b4f36..43c7e738b 100644 --- a/Editor/Windows/DxMessagingMessageMonitorWindow.cs +++ b/Editor/Windows/DxMessagingMessageMonitorWindow.cs @@ -11,7 +11,7 @@ namespace DxMessaging.Editor.Windows using Core.MessageBus; using Core.Messages; using DxMessaging.Editor; - using DxMessaging.Editor.Testing; + using DxMessaging.Editor.CustomEditors; using DxMessaging.Unity; using UnityEditor; using UnityEditor.SceneManagement; @@ -2022,7 +2022,7 @@ private static ComponentMonitorEntry CreateComponentMonitorEntry( MessagingComponent component ) { - MessagingComponentInspectorState state = MessagingComponentEditorHarness.Capture( + MessagingComponentInspectorState state = MessagingComponentInspectorSnapshot.Capture( component, resolveSerializedProviderBus: false ); diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs index 8531f237b..039f4c1f6 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -119,15 +119,14 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject internal bool IsFallbackInstance => _isFallbackInstance; /// - /// Raised when this asset is mutated in the editor or via test-only override. + /// Raised when this asset is validated in the editor. /// 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. + /// Notifies live buses after settings validation. /// internal static void RaiseSettingsChanged(DxMessagingRuntimeSettings settings) { diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs index 9fcf05206..b8af68b99 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs @@ -1,8 +1,6 @@ namespace DxMessaging.Core.Configuration { - using System; using System.Threading; - using MessageBus; #if UNITY_2021_3_OR_NEWER using UnityEngine; #endif @@ -14,24 +12,14 @@ namespace DxMessaging.Core.Configuration /// 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 nearest active prior settings, - /// including when nested tokens are disposed out of order. Each call allocates - /// one sealed reference token. Override raises - /// on push and when - /// disposing the newest active token changes the current settings. - /// public static class DxMessagingRuntimeSettingsProvider { private static DxMessagingRuntimeSettings _cached; private static readonly object _gate = new(); - private static OverrideToken _activeOverride; /// /// Returns the active settings instance. Loads the asset on first call; - /// subsequent calls return the cached reference (or a test override if - /// is active). + /// subsequent calls return the cached reference. /// /// /// This provider and its ScriptableObject settings type are available only in Unity. @@ -60,54 +48,14 @@ public static DxMessagingRuntimeSettings Current } } - /// - /// 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. Nested tokens - /// may be disposed in any order; an ended ancestor is skipped when the newest - /// token later restores state. Each call allocates one sealed reference token. - /// - public static IDisposable Override(DxMessagingRuntimeSettings settings) - { - if (settings == null) - { - throw new ArgumentNullException(nameof(settings)); - } - OverrideToken token; - lock (_gate) - { - token = new OverrideToken( - settings, - _cached, - IMessageBus.GlobalMessageBufferSize, - _activeOverride - ); - if (_activeOverride != null) - { - _activeOverride._newer = token; - } - _activeOverride = token; - _cached = settings; - } - DxMessagingRuntimeSettings.RaiseSettingsChanged(settings); - return token; - } - /// /// Clears the cached reference, forcing the next - /// access to reload from Resources. Test-only. + /// access to reload from Resources. Called when resetting static state or entering a new player session. /// - internal static void ResetForTests() + internal static void ResetCachedSettings() { lock (_gate) { - OverrideToken token = _activeOverride; - while (token != null) - { - token = token.InvalidateAndGetOlder(); - } - _activeOverride = null; _cached = null; } } @@ -115,7 +63,7 @@ internal static void ResetForTests() [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void OnSubsystemRegistration() { - ResetForTests(); + ResetCachedSettings(); } private static DxMessagingRuntimeSettings LoadOrCreate() @@ -142,97 +90,6 @@ private static DxMessagingRuntimeSettings LoadOrCreate() fallback.name = DxMessagingRuntimeSettings.ResourceName + " (Default)"; return fallback; } - - private sealed class OverrideToken : IDisposable - { - private DxMessagingRuntimeSettings _installed; - private int _previousGlobalMessageBufferSize; - private DxMessagingRuntimeSettings _previous; - private OverrideToken _older; - internal OverrideToken _newer; - private bool _disposed; - - public OverrideToken( - DxMessagingRuntimeSettings installed, - DxMessagingRuntimeSettings previous, - int previousGlobalMessageBufferSize, - OverrideToken older - ) - { - _installed = installed; - _previous = previous; - _previousGlobalMessageBufferSize = previousGlobalMessageBufferSize; - _older = older; - } - - public void Dispose() - { - DxMessagingRuntimeSettings restored = null; - bool didRestore = false; - int restoredGlobalMessageBufferSize = 0; - lock (_gate) - { - if (_disposed) - { - return; - } - _disposed = true; - - if (_newer != null) - { - /* - The immediate child used this override as its restore point. - Bypass the ended node and carry the root snapshot forward so - the newest live override eventually restores the nearest - active ancestor regardless of disposal order. - */ - _newer._older = _older; - _newer._previous = _previous; - _newer._previousGlobalMessageBufferSize = _previousGlobalMessageBufferSize; - if (_older != null) - { - _older._newer = _newer; - } - } - else - { - _activeOverride = _older; - if (_older != null) - { - _older._newer = null; - } - restored = _previous; - restoredGlobalMessageBufferSize = _previousGlobalMessageBufferSize; - _cached = restored; - didRestore = true; - } - - _installed = null; - _previous = null; - _older = null; - _newer = null; - } - if (didRestore) - { - if (restored == null || restored.IsFallbackInstance) - { - IMessageBus.GlobalMessageBufferSize = restoredGlobalMessageBufferSize; - } - DxMessagingRuntimeSettings.RaiseSettingsChanged(restored); - } - } - - internal OverrideToken InvalidateAndGetOlder() - { - OverrideToken older = _older; - _disposed = true; - _installed = null; - _previous = null; - _older = null; - _newer = null; - return older; - } - } } #endif } diff --git a/Runtime/Core/DataStructure/IntKeyMap.cs b/Runtime/Core/DataStructure/IntKeyMap.cs index 7d9369eac..93d5d0313 100644 --- a/Runtime/Core/DataStructure/IntKeyMap.cs +++ b/Runtime/Core/DataStructure/IntKeyMap.cs @@ -3,34 +3,6 @@ namespace DxMessaging.Core.DataStructure using System; using System.Runtime.CompilerServices; - internal readonly struct IntKeyMapTopologyObservation - { - internal IntKeyMapTopologyObservation( - bool found, - int slotIndex, - int lookupProbes, - int deletionScans, - int deletionMoves - ) - { - Found = found; - SlotIndex = slotIndex; - LookupProbes = lookupProbes; - DeletionScans = deletionScans; - DeletionMoves = deletionMoves; - } - - internal bool Found { get; } - - internal int SlotIndex { get; } - - internal int LookupProbes { get; } - - internal int DeletionScans { get; } - - internal int DeletionMoves { get; } - } - /// /// Compact open-addressed map specialized for integer keys and reference values. /// @@ -86,46 +58,6 @@ public bool MoveNext() internal int Count { get; private set; } - internal int Capacity => _values.Length; - - internal int LongestClusterForBenchmark - { - get - { - if (Count == 0) - { - return 0; - } - - int mask = _values.Length - 1; - int empty = 0; - while (_values[empty] != null) - { - empty++; - } - - int longest = 0; - int current = 0; - for (int offset = 1; offset <= _values.Length; ++offset) - { - int index = (empty + offset) & mask; - if (_values[index] == null) - { - current = 0; - continue; - } - - current++; - if (longest < current) - { - longest = current; - } - } - - return longest; - } - } - internal TValue this[int key] { set => Set(key, value); @@ -223,58 +155,6 @@ internal bool Remove(int key) return false; } - internal IntKeyMapTopologyObservation ObserveTopologyForBenchmark(int key) - { - TValue[] values = _values; - if (values.Length == 0) - { - return new IntKeyMapTopologyObservation(false, -1, 0, 0, 0); - } - - int mask = values.Length - 1; - int index = Bucket(key, mask); - int lookupProbes = 1; - while (values[index] != null) - { - if (_keys[index] == key) - { - int deletionScans = 0; - int deletionMoves = 0; - int gap = index; - int scan = (gap + 1) & mask; - /* - SYNC: DeleteAt below. This read-only benchmark observer predicts the - exact gaps that production deletion will fill without instrumenting it. - */ - while (values[scan] != null) - { - deletionScans++; - int home = Bucket(_keys[scan], mask); - if (((gap - home) & mask) < ((scan - home) & mask)) - { - deletionMoves++; - gap = scan; - } - - scan = (scan + 1) & mask; - } - - return new IntKeyMapTopologyObservation( - true, - index, - lookupProbes, - deletionScans, - deletionMoves - ); - } - - index = (index + 1) & mask; - lookupProbes++; - } - - return new IntKeyMapTopologyObservation(false, -1, lookupProbes, 0, 0); - } - internal void Clear() { if (Count == 0) @@ -297,7 +177,7 @@ private void DeleteAt(int gap, int mask) { int scan = (gap + 1) & mask; /* - SYNC: ObserveTopologyForBenchmark above. Keep its read-only deletion prediction + Backward-shift deletion keeps later cluster entries reachable. Keep its read-only deletion prediction aligned with this production gap-selection condition. */ while (_values[scan] != null) diff --git a/Runtime/Core/DxMessagingStaticState.cs b/Runtime/Core/DxMessagingStaticState.cs index 998983521..e4a908925 100644 --- a/Runtime/Core/DxMessagingStaticState.cs +++ b/Runtime/Core/DxMessagingStaticState.cs @@ -49,7 +49,7 @@ public static void Reset() IMessageBus.GlobalMessageBufferSize = Baseline.GlobalMessageBufferSize; IMessageBus.GlobalSequentialIndex = Baseline.GlobalSequentialIndex; #if UNITY_2021_3_OR_NEWER - DxMessagingRuntimeSettingsProvider.ResetForTests(); + DxMessagingRuntimeSettingsProvider.ResetCachedSettings(); #endif MessageRegistrationBuilder.SetSyntheticOwnerCounter(Baseline.SyntheticOwnerCounter); diff --git a/Runtime/Core/Internal/FlatDispatch.cs b/Runtime/Core/Internal/FlatDispatch.cs index fc962060b..1eb3078ac 100644 --- a/Runtime/Core/Internal/FlatDispatch.cs +++ b/Runtime/Core/Internal/FlatDispatch.cs @@ -70,21 +70,6 @@ internal abstract class FlatDispatchArray { internal abstract int Count { get; } - /// - /// Physical length of the currently rented entry array. Exposed only for internal - /// storage benchmarks; dispatch continues to use as its bound. - /// - internal abstract int Capacity { get; } - - /// - /// Number of released holders currently retained by this holder shape's pool. Released - /// holders have already returned their rented arrays and hold Array.Empty, - /// so this is empty-holder pool topology, not retained array-memory evidence. Array-pool - /// retained bytes require external memory measurement. This benchmark telemetry does not - /// participate in rent/release decisions. - /// - internal abstract int EmptyHolderPoolCount { get; } - internal abstract void Release(); } @@ -129,19 +114,6 @@ driven from different threads. internal override int Count => count; - internal override int Capacity => entries.Length; - - internal override int EmptyHolderPoolCount - { - get - { - lock (HolderPoolLock) - { - return HolderPool.Count; - } - } - } - /* True while the holder is parked in (or eligible for) the pool; false while it is owned by a live DispatchSnapshot. Guards the diff --git a/Runtime/Core/Internal/TypedSlots.cs b/Runtime/Core/Internal/TypedSlots.cs index 12f7449dc..90ee4bb0a 100644 --- a/Runtime/Core/Internal/TypedSlots.cs +++ b/Runtime/Core/Internal/TypedSlots.cs @@ -148,11 +148,6 @@ internal interface ITypedHandlerSlotSweeper /// bool HasPendingContextCleanup { get; } - /// - /// Counts retained context keys and scalar or context-keyed priority caches for diagnostics. - /// - void GetRetainedStorageCounts(out int contextKeys, out int priorityCaches); - /// /// Resets every empty typed or typed-global slot and removes it from /// the handler's slot arrays. diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs b/Runtime/Core/MessageBus/Internal/BusSlots.cs index a5b498c79..2fe6b1d6f 100644 --- a/Runtime/Core/MessageBus/Internal/BusSlots.cs +++ b/Runtime/Core/MessageBus/Internal/BusSlots.cs @@ -1,7 +1,6 @@ namespace DxMessaging.Core.MessageBus.Internal { using System.Collections.Generic; - using System.Diagnostics; using System.Runtime.CompilerServices; using DxMessaging.Core; @@ -81,8 +80,7 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// 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 - /// . + /// MessageBus.DebugAssertGlobalLiveCount. /// /// public int liveCount; @@ -184,21 +182,5 @@ invariant the eviction layer depends on. ++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/ISweepable.cs b/Runtime/Core/MessageBus/Internal/ISweepable.cs deleted file mode 100644 index a680ec786..000000000 --- a/Runtime/Core/MessageBus/Internal/ISweepable.cs +++ /dev/null @@ -1,15 +0,0 @@ -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/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 582d4ce99..cceb1c3ca 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -62,7 +62,6 @@ always equal. /// outside of dispatch, the id of the most recent emission. /// public long EmissionId => 0 < _dispatchDepth ? _scopedEmissionId : _emissionId; - internal long TickCounter => _tickCounter; internal bool IsDispatching => 0 < _dispatchDepth; private const long DefaultIdleEvictionTicks = 30; @@ -120,66 +119,28 @@ always equal. DispatchPhase.PostProcess, DispatchVariant.WithoutContext ); - internal const int ExpectedMessageCacheFieldCount = 8; - - 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) - ), - new SweepableTypeCache( - nameof(_untargetedDispatchPlans), - typeof(MessageCache), - static (bus, force) => bus.SweepStaleUntargetedDispatchPlans() - ), - new SweepableTypeCache( - nameof(_targetedDispatchPlans), - typeof(MessageCache), - static (bus, force) => bus.SweepStaleDispatchPlans(bus._targetedDispatchPlans) - ), - new SweepableTypeCache( - nameof(_broadcastDispatchPlans), - typeof(MessageCache), - static (bus, force) => bus.SweepStaleDispatchPlans(bus._broadcastDispatchPlans) - ), + private static readonly Func[] SweepableTypeCacheRegistry = + { + static (bus, force) => bus.SweepDirtyScalarTypeSlots(force), + static (bus, force) => bus.SweepDirtyTargetSlots(force), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._untargetedInterceptsByType, force), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._targetedInterceptsByType, force), + static (bus, force) => + bus.SweepDirtyInterceptorTypeSlots(bus._broadcastInterceptsByType, force), + static (bus, force) => bus.SweepStaleUntargetedDispatchPlans(), + static (bus, force) => bus.SweepStaleDispatchPlans(bus._targetedDispatchPlans), + static (bus, force) => bus.SweepStaleDispatchPlans(bus._broadcastDispatchPlans), }; - internal static IReadOnlyList SweepableTypeCaches => SweepableTypeCacheRegistry; - private static readonly ArrayPool DispatchBucketPool = ArrayPool.Shared; private static readonly ArrayPool DispatchEntryPool = ArrayPool.Shared; - private static CollectionPool ContextHandlerByTargetDictsOverride; - private static CollectionPool ContextHandlerByTargetDicts => - ContextHandlerByTargetDictsOverride ?? ContextHandlerByTargetDictPoolHolder.Instance; + ContextHandlerByTargetDictPoolHolder.Instance; private static CollectionPool CreateContextHandlerByTargetPool( int maxRetained, @@ -199,41 +160,6 @@ private static ContextHandlerMap CreateFreshContextHandlerMap() return new ContextHandlerMap(); } - internal static object CreateContextMapSeedForBenchmark() - { - return new HandlerCache(); - } - - internal static object CreatePopulatedContextMapForBenchmark(InstanceId[] keys, object seed) - { - if (keys == null) - { - throw new ArgumentNullException(nameof(keys)); - } - HandlerCache typedSeed = - seed as HandlerCache - ?? throw new ArgumentException("Unexpected context-map seed type.", nameof(seed)); - ContextHandlerMap map = CreateFreshContextHandlerMap(); - for (int index = 0; index < keys.Length; ++index) - { - map[keys[index]] = typedSeed; - } - return map; - } - - internal static void ObserveContextMapForBenchmark( - object opaqueMap, - out int count, - out int capacity - ) - { - ContextHandlerMap map = - opaqueMap as ContextHandlerMap - ?? throw new ArgumentException("Unexpected context-map type.", nameof(opaqueMap)); - count = map.Count; - capacity = map.Capacity; - } - private static class ContextHandlerByTargetDictPoolHolder { public static readonly CollectionPool Instance = @@ -736,35 +662,6 @@ public void Clear() } } - private sealed class SweepableTypeCache : ISweepable - { - private readonly Func _sweep; - - public SweepableTypeCache( - string storageFieldName, - Type storageFieldType, - Func sweep - ) - { - StorageFieldName = storageFieldName; - StorageFieldType = storageFieldType; - _sweep = sweep; - } - - public string StorageFieldName { get; } - public Type StorageFieldType { get; } - - public int Sweep(MessageBus bus, bool force) - { - if (bus == null) - { - throw new ArgumentNullException(nameof(bus)); - } - - return _sweep(bus, force); - } - } - private readonly struct DispatchLease : IDisposable { private readonly MessageBus _bus; @@ -1008,11 +905,6 @@ private void RecycleHandlerCache(HandlerCache cache) } } - internal object RecycledHandlerCacheForTesting => _recycledEmptyHandlerCache; - - internal int RecycledHandlerCacheHighWaterForTesting => - _recycledEmptyHandlerCache?.highWaterDistinctHandlers ?? 0; - public int RegisteredTargeted { get @@ -1182,88 +1074,6 @@ HandlerCache entry in _scalarSinks[ public int RegisteredGlobalAcceptAll => _globalSlots.sharedHandlers.Count; - /// - /// Counts live registration references without changing registrations. - /// - /// - /// Cold test diagnostic. Unlike the public route-container counters, this - /// includes registrations sharing a handler, priority, or context and - /// duplicate registrations held by reference count. Never read on dispatch. - /// - internal int LiveRegistrationCountForTesting - { - get - { - int count = 0; - foreach (MessageCache> sink in _scalarSinks) - { - if (sink == null) - { - continue; - } - foreach (HandlerCache handlers in sink) - { - count += CountLiveRegistrations(handlers); - } - } - foreach (MessageCache sink in _contextSinks) - { - foreach (ContextHandlerMap contexts in sink) - { - if (contexts == null) - { - continue; - } - foreach (HandlerCache handlers in contexts) - { - count += CountLiveRegistrations(handlers); - } - } - } - foreach ( - KeyValuePair registration in _globalSlots.sharedHandlers - ) - { - count += registration.Value; - } - foreach ( - KeyValuePair< - object, - Dictionary - > interceptor in _uniqueInterceptorsAndPriorities - ) - { - foreach (KeyValuePair registration in interceptor.Value) - { - count += registration.Value; - } - } - return count; - } - } - - private static int CountLiveRegistrations(HandlerCache priorities) - { - if (priorities == null) - { - return 0; - } - int count = 0; - foreach (KeyValuePair priority in priorities.handlers) - { - HandlerCache handlers = priority.Value; - if (handlers == null) - { - continue; - } - foreach (KeyValuePair registration in handlers.handlers) - { - count += registration.Value; - } - } - return count; - } - private static int SumInterceptorCache(MessageCache> cache) { int count = 0; @@ -1406,155 +1216,6 @@ so sweeps drop their cached sink references. private readonly MessageCache _targetedDispatchPlans = new(); private readonly MessageCache _broadcastDispatchPlans = new(); - internal readonly struct FlatSnapshotStorageObservation - { - internal FlatSnapshotStorageObservation( - int entryCount, - int arrayCapacity, - int emptyHolderPoolCount - ) - { - EntryCount = entryCount; - ArrayCapacity = arrayCapacity; - EmptyHolderPoolCount = emptyHolderPoolCount; - } - - internal int EntryCount { get; } - - internal int ArrayCapacity { get; } - - /// - /// Released holder objects currently pooled with empty entry arrays. This does not - /// measure arrays or retained bytes; ArrayPool memory retention requires an external - /// memory measurement. - /// - internal int EmptyHolderPoolCount { get; } - } - - internal readonly struct PriorityStorageObservation - { - internal PriorityStorageObservation(int entries, int mapCapacity, int orderCapacity) - { - Entries = entries; - MapCapacity = mapCapacity; - OrderCapacity = orderCapacity; - } - - internal int Entries { get; } - - internal int MapCapacity { get; } - - internal int OrderCapacity { get; } - } - - internal bool TryObserveUntargetedPriorityStorageForBenchmark( - out PriorityStorageObservation observation - ) - where TMessage : IUntargetedMessage - { - if ( - !_scalarSinks[BusSinkIndex.UntargetedHandleDefault] - .TryGetValue(out HandlerCache handlers) - ) - { - observation = default; - return false; - } - - observation = ObservePriorityStorage(handlers); - return true; - } - - internal static object CreatePriorityStorageOwnerForBenchmark() - { - return new HandlerCache(); - } - - internal static bool TryObservePriorityStorageOwnerForBenchmark( - object owner, - out PriorityStorageObservation observation - ) - { - if (owner is not HandlerCache handlers) - { - observation = default; - return false; - } - - observation = ObservePriorityStorage(handlers); - return true; - } - - private static PriorityStorageObservation ObservePriorityStorage( - HandlerCache handlers - ) - { - return new PriorityStorageObservation( - handlers.handlers.Count, - handlers.handlers.EnsureCapacity(0), - handlers.order.Capacity - ); - } - - /// - /// Read-only benchmark telemetry for the active untargeted flat snapshot of - /// . This method does not acquire, build, or mutate a - /// snapshot, so observing storage cannot alter the path being measured. - /// - internal bool TryObserveUntargetedFlatSnapshotStorageForBenchmark( - out FlatSnapshotStorageObservation observation - ) - where TMessage : IUntargetedMessage - { - if ( - !_scalarSinks[BusSinkIndex.UntargetedHandleDefault] - .TryGetValue(out HandlerCache handlers) - || handlers.dispatchState == null - || ReferenceEquals(handlers.dispatchState.active, DispatchSnapshot.Empty) - || handlers.dispatchState.active.flat == null - ) - { - observation = default; - return false; - } - - FlatDispatchArray flat = handlers.dispatchState.active.flat; - observation = new FlatSnapshotStorageObservation( - flat.Count, - flat.Capacity, - flat.EmptyHolderPoolCount - ); - return true; - } - - internal bool HasDispatchStateForTesting( - RegistrationMethod method, - InstanceId context - ) - where TMessage : IMessage - { - switch (method) - { - case RegistrationMethod.Targeted: - case RegistrationMethod.Broadcast: - case RegistrationMethod.TargetedPostProcessor: - case RegistrationMethod.BroadcastPostProcessor: - return ContextSinkForMethod(method) - .TryGetValue(out ContextHandlerMap handlersByContext) - && handlersByContext.TryGetValue( - context, - out HandlerCache contextHandlers - ) - && contextHandlers.dispatchState != null; - default: - return ScalarSinkForMethod(method) - .TryGetValue( - out HandlerCache scalarHandlers - ) - && scalarHandlers.dispatchState != null; - } - } - /* Bumped by every mutation that can change what a DispatchPlan caches or decides. Plans compare their stamp against this value at @@ -1575,7 +1236,7 @@ public MessageBus() { } /// - /// Internal factory used by tests and integration assemblies to construct a + /// Internal factory used by 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 @@ -1651,9 +1312,7 @@ bool applyRuntimeSettings } #if UNITY_2021_3_OR_NEWER - private static List> IdleSweepBuses = new(); - private static IdleSweepRegistryBenchmarkScope ActiveIdleSweepRegistryBenchmarkScope; - private static ContextMapPoolBenchmarkScope ActiveContextMapPoolBenchmarkScope; + private static readonly List> IdleSweepBuses = new(); private static bool RuntimeSettingsSubscribed; private static void RegisterForIdleSweeps(MessageBus bus) @@ -1727,201 +1386,6 @@ private static void ResetIdleSweepRegistry() ResetStaticPools(); } - internal static IDisposable IsolateIdleSweepRegistryForBenchmark() - { - return new IdleSweepRegistryBenchmarkScope(); - } - - internal static int IdleSweepRegistryCountForBenchmark => IdleSweepBuses.Count; - - internal static object IdleSweepRegistryIdentityForBenchmark => IdleSweepBuses; - - internal static IDisposable IsolateContextMapPoolForBenchmark() - { - return new ContextMapPoolBenchmarkScope(); - } - - internal static bool ContextMapPoolOverrideActiveForBenchmark => - ContextHandlerByTargetDictsOverride != null; - - internal static ContextMapPoolBenchmarkObservation ObserveContextMapPoolForBenchmark() - { - CollectionPool current = ContextHandlerByTargetDicts; - return new ContextMapPoolBenchmarkObservation( - current, - current.UseLru, - current.MaxRetained - ); - } - - internal static void ConfigureContextMapPoolForBenchmark(bool useLru, int maxRetained) - { - ContextHandlerByTargetDicts.UseLru = useLru; - ContextHandlerByTargetDicts.MaxRetained = maxRetained; - } - - private sealed class IdleSweepRegistryBenchmarkScope : IDisposable - { - private static readonly object SyncRoot = new object(); - private readonly List> _saved; - private readonly IdleSweepRegistryBenchmarkScope _parent; - private readonly int _ownerThreadId; - private bool _disposed; - - internal IdleSweepRegistryBenchmarkScope() - { - _ownerThreadId = Environment.CurrentManagedThreadId; - lock (SyncRoot) - { - if ( - ActiveIdleSweepRegistryBenchmarkScope != null - && ActiveIdleSweepRegistryBenchmarkScope._ownerThreadId != _ownerThreadId - ) - { - throw new InvalidOperationException( - "Nested idle-sweep benchmark scopes must share one owning thread." - ); - } - - _parent = ActiveIdleSweepRegistryBenchmarkScope; - _saved = IdleSweepBuses; - ActiveIdleSweepRegistryBenchmarkScope = this; - IdleSweepBuses = new List>(); - } - } - - public void Dispose() - { - lock (SyncRoot) - { - if (_disposed) - { - return; - } - if (Environment.CurrentManagedThreadId != _ownerThreadId) - { - throw new InvalidOperationException( - "Idle-sweep benchmark scopes must be disposed on their owning thread." - ); - } - - _disposed = true; - while ( - ActiveIdleSweepRegistryBenchmarkScope != null - && ActiveIdleSweepRegistryBenchmarkScope._disposed - ) - { - IdleSweepRegistryBenchmarkScope completed = - ActiveIdleSweepRegistryBenchmarkScope; - IdleSweepBuses = completed._saved; - ActiveIdleSweepRegistryBenchmarkScope = completed._parent; - } - } - } - } - - private sealed class ContextMapPoolBenchmarkScope : IDisposable - { - private static readonly object SyncRoot = new object(); - private readonly CollectionPool _savedOverride; - private readonly CollectionPool _savedPool; - private readonly CollectionPool _isolated; - private readonly ContextMapPoolBenchmarkScope _parent; - private readonly int _ownerThreadId; - private readonly bool _initialUseLru; - private readonly int _initialMaxRetained; - private bool _disposed; - - internal ContextMapPoolBenchmarkScope() - { - _ownerThreadId = Environment.CurrentManagedThreadId; - lock (SyncRoot) - { - if ( - ActiveContextMapPoolBenchmarkScope != null - && ActiveContextMapPoolBenchmarkScope._ownerThreadId != _ownerThreadId - ) - { - throw new InvalidOperationException( - "Nested context-map benchmark scopes must share one owning thread." - ); - } - - _parent = ActiveContextMapPoolBenchmarkScope; - _savedOverride = ContextHandlerByTargetDictsOverride; - _savedPool = ContextHandlerByTargetDicts; - _initialUseLru = _savedPool.UseLru; - _initialMaxRetained = _savedPool.MaxRetained; - _isolated = CreateContextHandlerByTargetPool( - _initialMaxRetained, - _initialUseLru - ); - ActiveContextMapPoolBenchmarkScope = this; - ContextHandlerByTargetDictsOverride = _isolated; - } - } - - public void Dispose() - { - lock (SyncRoot) - { - if (_disposed) - { - return; - } - - if (Environment.CurrentManagedThreadId != _ownerThreadId) - { - throw new InvalidOperationException( - "Context-map benchmark scopes must be disposed on their owning thread." - ); - } - if (!ReferenceEquals(ActiveContextMapPoolBenchmarkScope, this)) - { - throw new InvalidOperationException( - "Context-map benchmark scopes must be disposed in strict LIFO order." - ); - } - - bool effectiveUseLru = _isolated.UseLru; - int effectiveMaxRetained = _isolated.MaxRetained; - if (effectiveUseLru != _initialUseLru) - { - _savedPool.UseLru = effectiveUseLru; - } - if (effectiveMaxRetained != _initialMaxRetained) - { - _savedPool.MaxRetained = effectiveMaxRetained; - } - - _disposed = true; - _ = _isolated.Trim(0); - ContextHandlerByTargetDictsOverride = _savedOverride; - ActiveContextMapPoolBenchmarkScope = _parent; - } - } - } - - internal readonly struct ContextMapPoolBenchmarkObservation - { - internal ContextMapPoolBenchmarkObservation( - object identity, - bool useLru, - int maxRetained - ) - { - Identity = identity; - UseLru = useLru; - MaxRetained = maxRetained; - } - - internal object Identity { get; } - - internal bool UseLru { get; } - - internal int MaxRetained { get; } - } - /// Gets the settings-bound pool used only by nested reflexive sends. private CollectionPool GetReflexiveDispatchStatePool() { @@ -2150,35 +1614,6 @@ private readonly Dictionary< private long _reflexiveDispatchLeaseSequence; private long _activeReflexiveDispatchLeaseId; private long _reflexiveRetentionGeneration; - - /// Reports retained successful lookups across all receiver types. - internal int ReflexiveMethodCacheCount => _methodCache.Count; - - /// Reports lookup backing capacity for cold-path reclamation checks. - internal int ReflexiveMethodCacheCapacity => _methodCache.EnsureCapacity(0); - - /// Checks retention without changing the lookup's recency. - internal bool IsReflexiveMethodCached(Type componentType, MethodSignatureKey signature) => - _methodCache.ContainsKey((componentType, signature)); - - /// Reports whether this bus still owns root or active traversal scratch. - internal bool HasRetainedReflexiveDispatchState => _reflexiveDispatchState != null; - - /// Reports the backing high-water capacity of retained or active traversal scratch. - internal int ReflexiveDispatchRetainedCapacity => - _reflexiveDispatchState?.RetainedCapacity ?? 0; - - /// Diagnostics for reentrant reflexive dispatch state retained by this bus. - internal CollectionPoolDiagnostics ReflexiveDispatchPoolDiagnostics => - _reflexiveDispatchStatePool?.Snapshot() ?? default; - - /// Current retained-entry cap for the reentrant reflexive state pool. - internal int ReflexiveDispatchPoolMaxRetained => - _reflexiveDispatchStatePool?.MaxRetained ?? _handlerCacheRetentionLimit; - - /// Whether the reentrant reflexive state pool retains entries in LRU order. - internal bool ReflexiveDispatchPoolUsesLru => - _reflexiveDispatchStatePool?.UseLru ?? ContextHandlerByTargetDicts.UseLru; #endif private RegistrationLog _log; @@ -2484,13 +1919,13 @@ Any eviction below can remove a sink slot a DispatchPlan has (and the next emission of any type) see every plan as stale. */ InvalidateDispatchPlans(); - int typeSlotsEvicted = SweepableTypeCacheRegistry[0].Sweep(this, force); + int typeSlotsEvicted = SweepableTypeCacheRegistry[0](this, force); _lastContextTypeSlotsEvicted = 0; - int targetSlotsEvicted = SweepableTypeCacheRegistry[1].Sweep(this, force); + int targetSlotsEvicted = SweepableTypeCacheRegistry[1](this, force); typeSlotsEvicted += _lastContextTypeSlotsEvicted; - typeSlotsEvicted += SweepableTypeCacheRegistry[2].Sweep(this, force); - typeSlotsEvicted += SweepableTypeCacheRegistry[3].Sweep(this, force); - typeSlotsEvicted += SweepableTypeCacheRegistry[4].Sweep(this, force); + typeSlotsEvicted += SweepableTypeCacheRegistry[2](this, force); + typeSlotsEvicted += SweepableTypeCacheRegistry[3](this, force); + typeSlotsEvicted += SweepableTypeCacheRegistry[4](this, force); typeSlotsEvicted += SweepGlobalSlot(force); typeSlotsEvicted += SweepDirtyTypedHandlerSlots(force); /* @@ -2498,9 +1933,9 @@ Any eviction below can remove a sink slot a DispatchPlan has rows above so anything they evicted is dereferenced within the same sweep. Always returns 0 (derived caches, not type slots). */ - _ = SweepableTypeCacheRegistry[5].Sweep(this, force); - _ = SweepableTypeCacheRegistry[6].Sweep(this, force); - _ = SweepableTypeCacheRegistry[7].Sweep(this, force); + _ = SweepableTypeCacheRegistry[5](this, force); + _ = SweepableTypeCacheRegistry[6](this, force); + _ = SweepableTypeCacheRegistry[7](this, force); if (force) { ClearDirtySweepCandidates(); @@ -3047,124 +2482,6 @@ private static void ReturnDirtyTargetSet(HashSet targets, int highWa DxPools.InstanceIdSets.Return(targets); } - internal CollectionPoolDiagnostics GetContextDictPoolDiagnosticsForTesting() - { - return ContextHandlerByTargetDicts.Snapshot(); - } - - internal int SweepDirtyTargetSlotForBenchmark(InstanceId target) - where TMessage : IMessage - { - int typeIndex = MessageHelperIndexer.SequentialId; - int evicted = 0; - for (int sinkIndex = 0; sinkIndex < _contextSinks.Length; ++sinkIndex) - { - MessageCache sink = _contextSinks[sinkIndex]; - if ( - sink == null - || !sink.TryGetValueAtIndex(typeIndex, out ContextHandlerMap handlersByTarget) - || !handlersByTarget.TryGetValue( - target, - out HandlerCache handlers - ) - || handlers.handlers.Count != 0 - || HasActiveDispatchSnapshot(handlers.dispatchState) - ) - { - continue; - } - - handlers.Clear(); - _ = handlersByTarget.Remove(target); - evicted++; - } - - if ( - _dirtyTargets.TryGetValue(typeIndex, out List targets) - && _dirtyTargetSets.TryGetValue(typeIndex, out HashSet targetSet) - && targetSet.Remove(target) - ) - { - for (int index = targets.Count - 1; 0 <= index; --index) - { - if (targets[index] != target) - { - continue; - } - - int lastIndex = targets.Count - 1; - targets[index] = targets[lastIndex]; - targets.RemoveAt(lastIndex); - break; - } - } - - /* - Deliberately retain the empty per-type context map and dirty-candidate - collections. Returning and immediately renting them would make this row measure - shared pools in addition to the target map. ResetState returns them at teardown. - */ - return evicted; - } - - internal bool TryObserveTargetedHandleMapStorageForBenchmark( - out int entries, - out int capacity - ) - where TMessage : IMessage - { - MessageCache sink = _contextSinks[ - BusContextIndex.TargetedHandleDefault - ]; - if (!sink.TryGetValue(out ContextHandlerMap handlersByTarget)) - { - entries = 0; - capacity = 0; - return false; - } - - entries = handlersByTarget.Count; - capacity = handlersByTarget.Capacity; - return true; - } - - internal bool TryObserveTargetedHandleMapTopologyForBenchmark( - InstanceId target, - out IntKeyMapTopologyObservation observation - ) - where TMessage : IMessage - { - MessageCache sink = _contextSinks[ - BusContextIndex.TargetedHandleDefault - ]; - if (!sink.TryGetValue(out ContextHandlerMap handlersByTarget)) - { - observation = default; - return false; - } - - observation = handlersByTarget.ObserveTopologyForBenchmark(target.Id); - return true; - } - - internal bool TryObserveTargetedHandleMapLongestClusterForBenchmark( - out int longestCluster - ) - where TMessage : IMessage - { - MessageCache sink = _contextSinks[ - BusContextIndex.TargetedHandleDefault - ]; - if (!sink.TryGetValue(out ContextHandlerMap handlersByTarget)) - { - longestCluster = 0; - return false; - } - - longestCluster = handlersByTarget.LongestClusterForBenchmark; - return true; - } - private ContextHandlerMap GetOrRentContextMap(MessageCache sinks) where T : IMessage { diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index 6cfe47b9b..3c062a4c6 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -122,7 +122,7 @@ public static IMessageBus.TrimResult TrimAll(bool force = false) /// Thrown when is . /// /// - /// This is primarily intended for integration tests or dependency injection bootstrap code. Invoke + /// Use this during dependency injection bootstrap. Invoke /// when the customisation is no longer required. /// public static void SetGlobalMessageBus(MessageBus.MessageBus messageBus) @@ -652,24 +652,11 @@ IMessageBus messageBus /// Reference-identity teardown base used by token lifecycle queues. The common /// token path is implemented by the token's existing Registration object with /// value-type state returned from MessageHandler; concrete wrappers below are - /// reserved for direct handler compatibility and overlapping retry spills. + /// used when overlapping retry cleanup needs separate ownership. /// - /// - /// The implicit conversion to exists so the - /// handful of internal callers (contract tests through the - /// bus<->handler boundary) that stored the old - /// continue to compile unchanged; it allocates a single delegate ONLY - /// when a caller explicitly requests the form, and - /// is never used on the token's common storage path. - /// internal abstract class HandlerDeregistration { internal abstract void Deregister(); - - public static implicit operator Action(HandlerDeregistration deregistration) - { - return deregistration == null ? null : deregistration.Deregister; - } } /// @@ -703,12 +690,6 @@ InterceptorDeregistrationState state { return new InterceptorDeregistration(state); } - - public static implicit operator Action(InterceptorDeregistrationState state) - { - InterceptorDeregistration deregistration = new(state); - return deregistration.Deregister; - } } internal sealed class InterceptorDeregistration : HandlerDeregistration @@ -825,12 +806,6 @@ GlobalAcceptAllDeregistrationState state { return new GlobalAcceptAllDeregistration(state); } - - public static implicit operator Action(GlobalAcceptAllDeregistrationState state) - { - GlobalAcceptAllDeregistration deregistration = new(state); - return deregistration.Deregister; - } } internal sealed class GlobalAcceptAllDeregistration : HandlerDeregistration @@ -855,7 +830,7 @@ internal override void Deregister() /// MessageHandler to accept all TargetedMessages for all entities. /// MessageHandler to accept all BroadcastMessages for all entities. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( Action originalUntargetedMessageHandler, Action untargetedMessageHandler, @@ -907,7 +882,7 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( /// MessageHandler to accept all TargetedMessages for all entities. /// MessageHandler to accept all BroadcastMessages for all entities. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( FastHandler originalUntargetedMessageHandler, FastHandler untargetedMessageHandler, @@ -960,7 +935,7 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessageHandler( InstanceId target, Action originalHandler, @@ -1028,7 +1003,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessageHandler( InstanceId target, FastHandler originalHandler, @@ -1063,7 +1038,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostProcessor( InstanceId target, Action originalHandler, @@ -1095,7 +1070,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostProcessor( InstanceId target, FastHandler originalHandler, @@ -1156,7 +1131,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargetingPostProcessor( Action originalHandler, Action messageHandler, @@ -1188,7 +1163,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargetingPostProcessor( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1252,7 +1227,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargeting( Action originalHandler, Action messageHandler, @@ -1310,7 +1285,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargeting( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1339,7 +1314,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessageHandler( Action originalHandler, Action messageHandler, @@ -1400,7 +1375,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessageHandler( FastHandler originalHandler, FastHandler messageHandler, @@ -1431,7 +1406,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostProcessor( Action originalHandler, Action messageHandler, @@ -1463,7 +1438,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostProcessor( FastHandler originalHandler, FastHandler messageHandler, @@ -1496,7 +1471,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastMessageHandler( InstanceId source, Action originalHandler, @@ -1559,7 +1534,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastMessageHandler( InstanceId source, FastHandler originalHandler, @@ -1590,7 +1565,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSource( Action originalHandler, Action messageHandler, @@ -1648,7 +1623,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSource( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1678,7 +1653,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastPostProcessor( InstanceId source, Action originalHandler, @@ -1714,7 +1689,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastPostProcessor( InstanceId source, FastHandler originalHandler, @@ -1783,7 +1758,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSourcePostProcessor( Action originalHandler, Action messageHandler, @@ -1815,7 +1790,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Function that actually handles the message. /// Priority at which to run the handler, lower runs earlier than higher. /// IMessageBus override to register with, if any. Null/not provided defaults to the GlobalMessageBus. - /// The de-registration action. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSourcePostProcessor( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1879,7 +1854,7 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Interceptor to register. /// Priority to register the interceptor at (interceptors are run from low -> high priority) /// Message bus to register the interceptor on. - /// The de-registration action. + /// The deregistration state. internal InterceptorDeregistrationState RegisterUntargetedInterceptor( IMessageBus.UntargetedInterceptor interceptor, int priority = 0, @@ -1902,7 +1877,7 @@ internal InterceptorDeregistrationState RegisterUntargetedInterceptor( /// Interceptor to register. /// Priority to register the interceptor at (interceptors are run from low -> high priority) /// Message bus to register the interceptor on. - /// The de-registration action. + /// The deregistration state. internal InterceptorDeregistrationState RegisterBroadcastInterceptor( IMessageBus.BroadcastInterceptor interceptor, int priority = 0, @@ -1925,7 +1900,7 @@ internal InterceptorDeregistrationState RegisterBroadcastInterceptor( /// Interceptor to register. /// Priority to register the interceptor at (interceptors are run from low -> high priority) /// Message bus to register the interceptor on. - /// The de-registration action. + /// The deregistration state. internal InterceptorDeregistrationState RegisterTargetedInterceptor( IMessageBus.TargetedInterceptor interceptor, int priority = 0, @@ -2080,94 +2055,6 @@ out MessageCache handlersByType return false; } - internal bool TryObserveFastPriorityHandlerStorage( - IMessageBus messageBus, - int slotIndex, - int priority, - out HandlerCacheStorageObservation observation - ) - where T : IMessage - { - if (!GetHandlerForType(messageBus, out TypedHandler typedHandler)) - { - observation = default; - return false; - } - - TypedSlot slot = typedHandler._slots[slotIndex]; - if ( - slot == null - || !slot.byPriority.TryGetValue(priority, out IHandlerActionCache erased) - || erased is not HandlerActionCache> cache - ) - { - observation = default; - return false; - } - - observation = new HandlerCacheStorageObservation( - slot.byPriority.Count, - 0, - slot.byPriority.EnsureCapacity(0), - slot.orderedPriorities.Capacity, - true, - cache.entries.Count, - cache.entries.InlineCapacity, - cache.entries.MapCapacity, - cache.entries.OrderCapacity, - cache.entries.UsesSpillStorage - ); - return true; - } - - internal readonly struct HandlerCacheStorageObservation - { - internal HandlerCacheStorageObservation( - int priorityEntries, - int priorityInlineCapacity, - int priorityMapCapacity, - int priorityOrderCapacity, - bool priorityUsesSpillStorage, - int handlerEntries, - int handlerInlineCapacity, - int handlerMapCapacity, - int handlerOrderCapacity, - bool handlerUsesSpillStorage - ) - { - PriorityEntries = priorityEntries; - PriorityInlineCapacity = priorityInlineCapacity; - PriorityMapCapacity = priorityMapCapacity; - PriorityOrderCapacity = priorityOrderCapacity; - PriorityUsesSpillStorage = priorityUsesSpillStorage; - HandlerEntries = handlerEntries; - HandlerInlineCapacity = handlerInlineCapacity; - HandlerMapCapacity = handlerMapCapacity; - HandlerOrderCapacity = handlerOrderCapacity; - HandlerUsesSpillStorage = handlerUsesSpillStorage; - } - - internal int PriorityEntries { get; } - - internal int PriorityInlineCapacity { get; } - - internal int PriorityMapCapacity { get; } - - internal int PriorityOrderCapacity { get; } - - internal bool PriorityUsesSpillStorage { get; } - - internal int HandlerEntries { get; } - - internal int HandlerInlineCapacity { get; } - - internal int HandlerMapCapacity { get; } - - internal int HandlerOrderCapacity { get; } - - internal bool HandlerUsesSpillStorage { get; } - } - /// /// Resets empty typed-handler slots associated with /// . The eviction layer calls through @@ -2275,42 +2162,6 @@ out MessageCache handlersByType return count; } - /// - /// Counts retained typed-handler context keys and priority caches for one bus without creating storage. - /// - internal void GetRetainedStorageCounts( - IMessageBus messageBus, - out int contextKeys, - out int priorityCaches - ) - { - contextKeys = 0; - priorityCaches = 0; - messageBus = ResolveMessageBus(messageBus); - if ( - !_handlersByTypeByMessageBus.TryGetValue( - messageBus.RegisteredGlobalSequentialIndex, - out MessageCache handlersByType - ) - ) - { - return; - } - - foreach (object untypedHandler in handlersByType) - { - if (untypedHandler is ITypedHandlerSlotSweeper sweeper) - { - sweeper.GetRetainedStorageCounts( - out int retainedContexts, - out int retainedPriorities - ); - contextKeys += retainedContexts; - priorityCaches += retainedPriorities; - } - } - } - /// /// Reports whether a bus must retain this handler as a candidate for deferred context cleanup. /// @@ -2791,42 +2642,6 @@ public int Count get => _spillMap?.Count ?? _inlineCount; } - public int InlineCapacity - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => PhysicalInlineCapacity; - } - - public int MapCapacity - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _spillMap?.EnsureCapacity(0) ?? 0; - } - - public int OrderCapacity - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _spillOrder?.Capacity ?? 0; - } - - internal int OrderSlotCount - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _spillOrder?.Count ?? _inlineCount; - } - - internal int SkippedPrefixCount - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _spillOrderStart; - } - - public bool UsesSpillStorage - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _spillMap != null; - } - public Entry this[T key] { set @@ -3329,37 +3144,6 @@ internal TypedHandler() internal bool _markedForOuterRemoval; - void ITypedHandlerSlotSweeper.GetRetainedStorageCounts( - out int contextKeys, - out int priorityCaches - ) - { - contextKeys = 0; - priorityCaches = 0; - for (int i = 0; i < _slots.Length; ++i) - { - TypedSlot slot = _slots[i]; - if (slot == null) - { - continue; - } - - priorityCaches += slot.byPriority.Count; - if (slot.byContext == null) - { - continue; - } - - contextKeys += slot.byContext.Count; - foreach ( - Dictionary priorities in slot.byContext.Values - ) - { - priorityCaches += priorities.Count; - } - } - } - bool ITypedHandlerSlotSweeper.HasPendingContextCleanup { get @@ -3387,7 +3171,7 @@ bool ITypedHandlerSlotSweeper.HasPendingContextCleanup /// (ContainsEntry / BumpVersion / DeregisterEntry) /// so it needs no delegate-shape type argument. Tokens embed this value /// in their existing Registration object; compatibility callers can - /// convert it to an allocation-backed wrapper or . + /// convert it to a wrapper with separate ownership. /// internal readonly struct TypedHandlerDeregistrationState { @@ -3556,12 +3340,6 @@ TypedHandlerDeregistrationState state { return new TypedHandlerDeregistration(state); } - - public static implicit operator Action(TypedHandlerDeregistrationState state) - { - TypedHandlerDeregistration deregistration = new(state); - return deregistration.Deregister; - } } /// diff --git a/Runtime/Core/Pooling/StopwatchClock.cs b/Runtime/Core/Pooling/StopwatchClock.cs index 972e53e32..d463badce 100644 --- a/Runtime/Core/Pooling/StopwatchClock.cs +++ b/Runtime/Core/Pooling/StopwatchClock.cs @@ -6,7 +6,7 @@ namespace DxMessaging.Core.Pooling /// /// Default backed by a process-lifetime /// . Monotonic and Unity-agnostic so it works in - /// EditMode, PlayMode, and standalone test harnesses. + /// Unity players, editors, and non-Unity hosts. /// public sealed class StopwatchClock : IDxMessagingClock { diff --git a/Tests/Editor/Allocations/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs index c467efc9c..d76ce32b6 100644 --- a/Tests/Editor/Allocations/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -597,20 +597,23 @@ public void RegistrationDisposableIsZeroAllocAfterWarmup() [Test] [Category("Allocation")] - public void RuntimeSettingsOverrideAllocatesOneReferenceToken() + public void CachedRuntimeSettingsLookupAllocatesNothing() { DxMessagingRuntimeSettings settings = ScriptableObject.CreateInstance(); - Action overrideCycle = () => - DxMessagingRuntimeSettingsProvider.Override(settings).Dispose(); - using IDisposable isolatedRegistry = MessageBus.IsolateIdleSweepRegistryForBenchmark(); + using IDisposable isolatedRegistry = + MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); + IDisposable fixtureSettings = null; try { - overrideCycle(); + fixtureSettings = RuntimeSettingsFixture.Override(settings); + DxMessagingRuntimeSettings observed = null; + Action read = () => observed = DxMessagingRuntimeSettingsProvider.Current; + read(); long delta = AllocationProbe.MeasureMin( AllocationMeasurementAttempts, prepare: null, - operation: overrideCycle + operation: read ); if (delta == AllocationProbe.Unmeasured) { @@ -618,16 +621,17 @@ public void RuntimeSettingsOverrideAllocatesOneReferenceToken() "The GC.Alloc allocation probe is non-functional on this backend." ); } - + Assert.AreSame(settings, observed); Assert.AreEqual( - 1, + 0, delta, - "Override and Dispose must allocate only the existing sealed reference token." + "Reading the existing production settings cache must allocate nothing." ); } finally { - DxMessagingRuntimeSettingsProvider.ResetForTests(); + fixtureSettings?.Dispose(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(settings); } } diff --git a/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs index 0e7dc7d15..46235a567 100644 --- a/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs +++ b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs @@ -1154,28 +1154,38 @@ public void MarginalRegistrationLatencyUsesRepeatedFloorTrials() [Test] public void IdleSweepBenchmarkScopesRestoreAfterArbitraryDisposalOrder() { - int originalCount = MessageBus.IdleSweepRegistryCountForBenchmark; - object originalIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; - IDisposable outer = MessageBus.IsolateIdleSweepRegistryForBenchmark(); - object outerIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; + int originalCount = MessageBusInspection.IdleSweepRegistryCountForBenchmark; + object originalIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; + IDisposable outer = MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); + object outerIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; IDisposable inner = null; try { _ = new MessageBus(); - Assert.AreEqual(1, MessageBus.IdleSweepRegistryCountForBenchmark); + Assert.AreEqual(1, MessageBusInspection.IdleSweepRegistryCountForBenchmark); - inner = MessageBus.IsolateIdleSweepRegistryForBenchmark(); - object innerIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; - Assert.AreEqual(0, MessageBus.IdleSweepRegistryCountForBenchmark); - Assert.AreNotSame(outerIdentity, innerIdentity); + inner = MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); + object innerIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; + Assert.AreEqual(0, MessageBusInspection.IdleSweepRegistryCountForBenchmark); + Assert.AreSame(originalIdentity, outerIdentity); + Assert.AreSame(outerIdentity, innerIdentity); outer.Dispose(); - Assert.AreSame(innerIdentity, MessageBus.IdleSweepRegistryIdentityForBenchmark); + Assert.AreSame( + innerIdentity, + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark + ); inner.Dispose(); inner = null; - Assert.AreSame(originalIdentity, MessageBus.IdleSweepRegistryIdentityForBenchmark); - Assert.AreEqual(originalCount, MessageBus.IdleSweepRegistryCountForBenchmark); + Assert.AreSame( + originalIdentity, + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark + ); + Assert.AreEqual( + originalCount, + MessageBusInspection.IdleSweepRegistryCountForBenchmark + ); outer.Dispose(); } finally @@ -1194,9 +1204,9 @@ public void IdleSweepBenchmarkScopesRestoreAfterArbitraryDisposalOrder() [Test] public void IdleSweepBenchmarkScopeRejectsWrongThreadDisposeWithoutLosingOwnership() { - int originalCount = MessageBus.IdleSweepRegistryCountForBenchmark; - object originalIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; - IDisposable scope = MessageBus.IsolateIdleSweepRegistryForBenchmark(); + int originalCount = MessageBusInspection.IdleSweepRegistryCountForBenchmark; + object originalIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; + IDisposable scope = MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); Exception workerException = null; try { @@ -1215,34 +1225,39 @@ public void IdleSweepBenchmarkScopeRejectsWrongThreadDisposeWithoutLosingOwnersh worker.Join(); Assert.IsInstanceOf(workerException); - Assert.AreNotSame( + Assert.AreSame( originalIdentity, - MessageBus.IdleSweepRegistryIdentityForBenchmark + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark ); + Assert.AreEqual(0, MessageBusInspection.IdleSweepRegistryCountForBenchmark); } finally { scope.Dispose(); } - Assert.AreSame(originalIdentity, MessageBus.IdleSweepRegistryIdentityForBenchmark); - Assert.AreEqual(originalCount, MessageBus.IdleSweepRegistryCountForBenchmark); + Assert.AreSame( + originalIdentity, + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark + ); + Assert.AreEqual(originalCount, MessageBusInspection.IdleSweepRegistryCountForBenchmark); } [Test] public void IdleSweepBenchmarkScopeRejectsNestedCreationFromAnotherThread() { - int originalCount = MessageBus.IdleSweepRegistryCountForBenchmark; - object originalIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; - using IDisposable scope = MessageBus.IsolateIdleSweepRegistryForBenchmark(); - object isolatedIdentity = MessageBus.IdleSweepRegistryIdentityForBenchmark; + int originalCount = MessageBusInspection.IdleSweepRegistryCountForBenchmark; + object originalIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; + using IDisposable scope = MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); + object isolatedIdentity = MessageBusInspection.IdleSweepRegistryIdentityForBenchmark; Exception workerException = null; System.Threading.Thread worker = new(() => { try { - using IDisposable nested = MessageBus.IsolateIdleSweepRegistryForBenchmark(); + using IDisposable nested = + MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); } catch (Exception exception) { @@ -1253,12 +1268,18 @@ public void IdleSweepBenchmarkScopeRejectsNestedCreationFromAnotherThread() worker.Join(); Assert.IsInstanceOf(workerException); - Assert.AreSame(isolatedIdentity, MessageBus.IdleSweepRegistryIdentityForBenchmark); - Assert.AreEqual(0, MessageBus.IdleSweepRegistryCountForBenchmark); + Assert.AreSame( + isolatedIdentity, + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark + ); + Assert.AreEqual(0, MessageBusInspection.IdleSweepRegistryCountForBenchmark); scope.Dispose(); - Assert.AreSame(originalIdentity, MessageBus.IdleSweepRegistryIdentityForBenchmark); - Assert.AreEqual(originalCount, MessageBus.IdleSweepRegistryCountForBenchmark); + Assert.AreSame( + originalIdentity, + MessageBusInspection.IdleSweepRegistryIdentityForBenchmark + ); + Assert.AreEqual(originalCount, MessageBusInspection.IdleSweepRegistryCountForBenchmark); } /* @@ -1274,7 +1295,7 @@ public void WallClockScenarioResultReportsZeroThroughputAndIsWallClock( DispatchBenchmarkScenario scenario ) { - int idleRegistryCount = MessageBus.IdleSweepRegistryCountForBenchmark; + int idleRegistryCount = MessageBusInspection.IdleSweepRegistryCountForBenchmark; DispatchBenchmarkResult result = DispatchThroughputBenchmarks.RunScenario( scenario, logResult: false @@ -1288,7 +1309,7 @@ DispatchBenchmarkScenario scenario { Assert.AreEqual( idleRegistryCount, - MessageBus.IdleSweepRegistryCountForBenchmark, + MessageBusInspection.IdleSweepRegistryCountForBenchmark, $"Marginal scenario '{scenario}' must restore the process idle-sweep registry." ); } diff --git a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs index f7c389944..a807d3c1e 100644 --- a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs +++ b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs @@ -7,6 +7,10 @@ namespace DxMessaging.Tests.Editor.Contract using DxMessaging.Core.MessageBus.Internal; using DxMessaging.Core.Messages; using NUnit.Framework; +#if !UNITY_2021_3_OR_NEWER + using Assert = NUnit.Framework.Legacy.ClassicAssert; + using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert; +#endif /// /// Contract guardrails for the @@ -50,7 +54,11 @@ public void LiveCountStartsAtZero() BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(0, slot.liveCount); Assert.IsTrue(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } [Test] @@ -66,7 +74,11 @@ public void LiveCountIncrementsOnFirstRegistration() BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(1, slot.liveCount); Assert.IsFalse(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -92,7 +104,11 @@ transition advances liveCount. BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(1, slot.liveCount); Assert.IsFalse(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -115,7 +131,11 @@ public void LiveCountIncrementsForDistinctHandlers() BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(2, slot.liveCount); Assert.IsFalse(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -140,7 +160,11 @@ public void LiveCountDecrementsOnFinalDeregistration() BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(0, slot.liveCount); Assert.IsTrue(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -172,7 +196,11 @@ liveCount must stay 1 (only the final 1 -> 0 transition BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(1, slot.liveCount); Assert.IsFalse(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -196,7 +224,11 @@ public void LiveCountUnchangedOnOverDeregistration() Assert.AreEqual(0, bus.RegisteredGlobalAcceptAll); BusGlobalSlot slot = ReadGlobalSlot(bus); Assert.AreEqual(0, slot.liveCount); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); /* Second invocation is over-deregistration: the early-exit @@ -208,7 +240,11 @@ branch must NOT decrement liveCount. slot = ReadGlobalSlot(bus); Assert.AreEqual(0, slot.liveCount); Assert.IsTrue(slot.IsEmpty); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { @@ -233,7 +269,11 @@ public void IsEmptyTracksLiveCount() slot.IsEmpty, "After registration liveCount > 0, IsEmpty must be false." ); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); bus.Deregister(in dereg); slot = ReadGlobalSlot(bus); @@ -241,7 +281,11 @@ public void IsEmptyTracksLiveCount() slot.IsEmpty, "After final deregistration liveCount == 0, IsEmpty must be true." ); - slot.DebugAssertLiveCountInvariant(); + Assert.AreEqual( + slot.sharedHandlers.Count, + slot.liveCount, + "The production live counter must match the actual handler set." + ); } finally { diff --git a/Tests/Editor/Contract/CounterBasedTouchTests.cs b/Tests/Editor/Contract/CounterBasedTouchTests.cs index ddb917e33..ff6013adc 100644 --- a/Tests/Editor/Contract/CounterBasedTouchTests.cs +++ b/Tests/Editor/Contract/CounterBasedTouchTests.cs @@ -58,12 +58,14 @@ public void TypedRegisterUpdatesTouchedSlotLastTouchTicks() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedSlot( handler, @@ -113,12 +115,14 @@ public void TypedDeregisterUpdatesTouchedSlotLastTouchTicks() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedSlot( handler, bus, @@ -204,12 +208,14 @@ public void StaleTypedDeregisterAfterResetStateDoesNotTouchSlot() { currentCallCount++; }; - Action staleDeregistration = handler.RegisterUntargetedMessageHandler( - callback, - staleCallback, - priority: 17, - messageBus: bus - ); + Action staleDeregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + staleCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedSlot( handler, bus, @@ -263,13 +269,15 @@ public void StaleTargetedContextDeregisterAfterResetStateDoesNotTouchSlot() { currentCallCount++; }; - Action staleDeregistration = handler.RegisterTargetedMessageHandler( - target, - callback, - staleCallback, - priority: 17, - messageBus: bus - ); + Action staleDeregistration = ( + handler.RegisterTargetedMessageHandler( + target, + callback, + staleCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedSlot( handler, bus, @@ -325,15 +333,17 @@ public void StaleTypedGlobalDeregisterAfterResetStateDoesNotTouchSlot() }; Action targeted = (_, _) => { }; Action broadcast = (_, _) => { }; - Action staleDeregistration = handler.RegisterGlobalAcceptAll( - originalUntargeted, - staleUntargeted, - targeted, - targeted, - broadcast, - broadcast, - bus - ); + Action staleDeregistration = ( + handler.RegisterGlobalAcceptAll( + originalUntargeted, + staleUntargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); InvokeResetState(bus); @@ -377,15 +387,17 @@ public void TypedGlobalDeregisterUsesAdvancedBusTick() Action untargeted = _ => { }; Action targeted = (_, _) => { }; Action broadcast = (_, _) => { }; - Action deregistration = handler.RegisterGlobalAcceptAll( - untargeted, - untargeted, - targeted, - targeted, - broadcast, - broadcast, - bus - ); + Action deregistration = ( + handler.RegisterGlobalAcceptAll( + untargeted, + untargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ) + ).AsAction(); IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); long registerTick = ReadBusTick(bus); diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs b/Tests/Editor/Contract/EvictionSweepContractTests.cs index a6444aa20..c4fb94e52 100644 --- a/Tests/Editor/Contract/EvictionSweepContractTests.cs +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs @@ -66,12 +66,14 @@ public void LiveHandlerReclaimsTransientContexts(bool force, int retentionCapaci DxPools.TypedHandlerPriorityDicts.MaxRetained = 0; DxPools.TypedHandlerPriorityDicts.UseLru = true; DxPools.TypedHandlerPriorityDicts.MaxRetained = retentionCapacity; - keep = handler.RegisterTargetedMessageHandler( - new InstanceId(1), - callback, - callback, - messageBus: bus - ); + keep = ( + handler.RegisterTargetedMessageHandler( + new InstanceId(1), + callback, + callback, + messageBus: bus + ) + ).AsAction(); MessageHandler.TypedHandler typed = (MessageHandler.TypedHandler) ReadTypedHandler(handler, bus); @@ -122,12 +124,14 @@ void Churn(int firstContext, int count, string phase) InstanceId context = new InstanceId(firstContext + i); string diagnostic = $"force={force}, retentionCapacity={retentionCapacity}, phase={phase}, context={context.Id}"; - Action remove = handler.RegisterTargetedMessageHandler( - context, - callback, - callback, - messageBus: bus - ); + Action remove = ( + handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + messageBus: bus + ) + ).AsAction(); try { Assert.That( @@ -187,12 +191,9 @@ public void ContextPruningPreservesFrozenCallbacksAndStaleDeregistration(bool fo InstanceId transient = new InstanceId(2); int calls = 0; Action later = _ => calls++; - Action keep = handler.RegisterTargetedMessageHandler( - live, - later, - later, - messageBus: bus - ); + Action keep = ( + handler.RegisterTargetedMessageHandler(live, later, later, messageBus: bus) + ).AsAction(); Action removeFirst = null; Action removeLater = null; Action first = _ => @@ -203,19 +204,18 @@ public void ContextPruningPreservesFrozenCallbacksAndStaleDeregistration(bool fo }; try { - removeFirst = handler.RegisterTargetedMessageHandler( - transient, - first, - first, - priority: -1, - messageBus: bus - ); - removeLater = handler.RegisterTargetedMessageHandler( - transient, - later, - later, - messageBus: bus - ); + removeFirst = ( + handler.RegisterTargetedMessageHandler( + transient, + first, + first, + priority: -1, + messageBus: bus + ) + ).AsAction(); + removeLater = ( + handler.RegisterTargetedMessageHandler(transient, later, later, messageBus: bus) + ).AsAction(); MessageHandler.TypedHandler typed = (MessageHandler.TypedHandler) ReadTypedHandler(handler, bus); @@ -234,10 +234,10 @@ public void ContextPruningPreservesFrozenCallbacksAndStaleDeregistration(bool fo Is.EqualTo(2), $"force={force}: in-flight cleanup must defer context removal." ); - long deregistrationTick = bus.TickCounter; + long deregistrationTick = (long)MessageBusInspection.ReadField(bus, "_tickCounter"); bus.Trim(force); Assert.That( - bus.TickCounter, + (long)MessageBusInspection.ReadField(bus, "_tickCounter"), Is.EqualTo(deregistrationTick), $"force={force}: trim must not advance the idle-age counter." ); @@ -256,7 +256,7 @@ public void ContextPruningPreservesFrozenCallbacksAndStaleDeregistration(bool fo OtherProbeMessage tick = new OtherProbeMessage(); bus.UntargetedBroadcast(ref tick); Assert.That( - bus.TickCounter, + (long)MessageBusInspection.ReadField(bus, "_tickCounter"), Is.EqualTo(unchecked(deregistrationTick + 1)), "An unrelated emission must advance the touch age without re-registering the handler." ); @@ -267,12 +267,9 @@ public void ContextPruningPreservesFrozenCallbacksAndStaleDeregistration(bool fo Is.EqualTo(1), $"force={force}: deferred cleanup must remain scheduled after in-flight trim." ); - Action replacement = handler.RegisterTargetedMessageHandler( - transient, - later, - later, - messageBus: bus - ); + Action replacement = ( + handler.RegisterTargetedMessageHandler(transient, later, later, messageBus: bus) + ).AsAction(); try { removeLater(); @@ -320,12 +317,14 @@ public void ForceSweepResetsDirtyEmptyTypedSlotsAndTrimsPools() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); object typedHandler = ReadTypedHandler(handler, bus); Array slots = ReadArrayField(typedHandler, "_slots"); @@ -363,12 +362,14 @@ public void NonForceSweepRetainsFreshDirtyTypedSlotsUntilIdle() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); object typedHandler = ReadTypedHandler(handler, bus); Array slots = ReadArrayField(typedHandler, "_slots"); @@ -453,12 +454,14 @@ public void StaleDeregisterAfterEmptySweepDoesNotRemoveReRegisteredHandler() currentCalls++; }; - Action staleDeregistration = handler.RegisterUntargetedMessageHandler( - original, - stale, - priority: 17, - messageBus: bus - ); + Action staleDeregistration = ( + handler.RegisterUntargetedMessageHandler( + original, + stale, + priority: 17, + messageBus: bus + ) + ).AsAction(); staleDeregistration(); _ = bus.Trim(force: true); @@ -493,12 +496,14 @@ public void TrimReclaimsBusSlotAfterDispatchThenDeregister() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); ProbeMessage message = new ProbeMessage(); bus.UntargetedBroadcast(ref message); @@ -553,12 +558,14 @@ public void StaleScalarDeregisterAfterSweepDoesNotLogOverDeregistration() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); _ = bus.Trim(force: true); @@ -592,13 +599,15 @@ public void StaleContextDeregisterAfterSweepDoesNotLogOverDeregistration() { InstanceId context = new InstanceId(0x5044_4203); Action callback = _ => { }; - Action deregistration = handler.RegisterTargetedMessageHandler( - context, - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterTargetedMessageHandler( + context, + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); _ = bus.Trim(force: true); @@ -631,12 +640,14 @@ public void EmitTimeSweepReclaimsIdleDirtySlotsAndDropsBorrowedRoute() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); ProbeMessage probe = new ProbeMessage(); bus.UntargetedBroadcast(ref probe); @@ -703,12 +714,14 @@ public void EmitTimeSweepWaitsForCadenceInterval() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); OtherProbeMessage other = new OtherProbeMessage(); @@ -780,12 +793,14 @@ public void DisabledIdleEvictionPreventsEmitTimeSweep() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); OtherProbeMessage other = new OtherProbeMessage(); @@ -815,12 +830,14 @@ public void TrimReturnsDefaultAndLeavesStateWhenTrimApiIsDisabled() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); System.Collections.Generic.List pooledList = DxPools.ObjectLists.Rent(); DxPools.ObjectLists.Return(pooledList); @@ -910,20 +927,24 @@ public void EmitTimeSweepRunsForTargetedAndBroadcastTypedEntryPoints() 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 - ); + Action deregisterTargeted = ( + handler.RegisterTargetedMessageHandler( + context, + targetedCallback, + targetedCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); + Action deregisterBroadcast = ( + handler.RegisterSourcedBroadcastMessageHandler( + context, + broadcastCallback, + broadcastCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregisterTargeted(); deregisterBroadcast(); Assert.GreaterOrEqual(bus.OccupiedTargetSlots, 2); @@ -975,8 +996,8 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() Assert.IsFalse(DxPools.ObjectLists.UseLru); Assert.AreEqual(0, DxPools.DescribeAll().ObjectLists.Cached); - DxMessagingRuntimeSettingsProvider.ResetForTests(); - using (DxMessagingRuntimeSettingsProvider.Override(settings)) + RuntimeSettingsFixture.Reset(); + using (RuntimeSettingsFixture.Override(settings)) { // Entering and disposing the scope is the behavior under test. } @@ -993,7 +1014,7 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() } finally { - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); DxMessagingRuntimeSettings.RaiseSettingsChanged( DxMessagingRuntimeSettingsProvider.Current ); @@ -1003,9 +1024,7 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() [TestCase(false)] [TestCase(true)] - public void NestedRuntimeSettingsOverridesRestoreNearestActiveSettings( - bool disposeOuterFirst - ) + public void FixtureSettingsScopesRestoreNearestActiveSettings(bool disposeOuterFirst) { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1018,9 +1037,9 @@ bool disposeOuterFirst IDisposable innerToken = null; try { - originalToken = DxMessagingRuntimeSettingsProvider.Override(original); - outerToken = DxMessagingRuntimeSettingsProvider.Override(outer); - innerToken = DxMessagingRuntimeSettingsProvider.Override(inner); + originalToken = RuntimeSettingsFixture.Override(original); + outerToken = RuntimeSettingsFixture.Override(outer); + innerToken = RuntimeSettingsFixture.Override(inner); IDisposable first = disposeOuterFirst ? outerToken : innerToken; IDisposable second = disposeOuterFirst ? innerToken : outerToken; @@ -1045,7 +1064,7 @@ bool disposeOuterFirst innerToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); DxMessagingRuntimeSettings.RaiseSettingsChanged( DxMessagingRuntimeSettingsProvider.Current ); @@ -1056,7 +1075,7 @@ bool disposeOuterFirst } [Test] - public void NestedRuntimeSettingsOverridesUseTokenIdentityForSameSettingsInstance() + public void NestedFixtureSettingsScopesUseTokenIdentityForSameSettingsInstance() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1067,9 +1086,9 @@ public void NestedRuntimeSettingsOverridesUseTokenIdentityForSameSettingsInstanc IDisposable innerToken = null; try { - originalToken = DxMessagingRuntimeSettingsProvider.Override(original); - outerToken = DxMessagingRuntimeSettingsProvider.Override(shared); - innerToken = DxMessagingRuntimeSettingsProvider.Override(shared); + originalToken = RuntimeSettingsFixture.Override(original); + outerToken = RuntimeSettingsFixture.Override(shared); + innerToken = RuntimeSettingsFixture.Override(shared); outerToken.Dispose(); Assert.AreSame( @@ -1090,14 +1109,14 @@ public void NestedRuntimeSettingsOverridesUseTokenIdentityForSameSettingsInstanc innerToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(shared); UnityEngine.Object.DestroyImmediate(original); } } [Test] - public void DisposedMiddleRuntimeSettingsOverrideIsSkippedDuringUnwind() + public void DisposedMiddleFixtureSettingsScopeIsSkippedDuringUnwind() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1113,10 +1132,10 @@ public void DisposedMiddleRuntimeSettingsOverrideIsSkippedDuringUnwind() IDisposable innerToken = null; try { - originalToken = DxMessagingRuntimeSettingsProvider.Override(original); - outerToken = DxMessagingRuntimeSettingsProvider.Override(outer); - middleToken = DxMessagingRuntimeSettingsProvider.Override(middle); - innerToken = DxMessagingRuntimeSettingsProvider.Override(inner); + originalToken = RuntimeSettingsFixture.Override(original); + outerToken = RuntimeSettingsFixture.Override(outer); + middleToken = RuntimeSettingsFixture.Override(middle); + innerToken = RuntimeSettingsFixture.Override(inner); middleToken.Dispose(); Assert.AreSame( @@ -1145,7 +1164,7 @@ public void DisposedMiddleRuntimeSettingsOverrideIsSkippedDuringUnwind() middleToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(inner); UnityEngine.Object.DestroyImmediate(middle); UnityEngine.Object.DestroyImmediate(outer); @@ -1154,7 +1173,7 @@ public void DisposedMiddleRuntimeSettingsOverrideIsSkippedDuringUnwind() } [Test] - public void StaticResetInvalidatesOlderRuntimeSettingsOverrideTokens() + public void StaticResetDetachesOlderFixtureSettingsScopes() { DxMessagingRuntimeSettings staleOuter = ScriptableObject.CreateInstance(); @@ -1170,12 +1189,12 @@ public void StaticResetInvalidatesOlderRuntimeSettingsOverrideTokens() IDisposable replacementToken = null; try { - staleOuterToken = DxMessagingRuntimeSettingsProvider.Override(staleOuter); - staleInnerToken = DxMessagingRuntimeSettingsProvider.Override(staleInner); + staleOuterToken = RuntimeSettingsFixture.Override(staleOuter); + staleInnerToken = RuntimeSettingsFixture.Override(staleInner); DxMessagingStaticState.Reset(); - replacementBaseToken = DxMessagingRuntimeSettingsProvider.Override(replacementBase); - replacementToken = DxMessagingRuntimeSettingsProvider.Override(replacement); + replacementBaseToken = RuntimeSettingsFixture.Override(replacementBase); + replacementToken = RuntimeSettingsFixture.Override(replacement); staleInnerToken.Dispose(); staleOuterToken.Dispose(); @@ -1207,7 +1226,7 @@ public void StaticResetInvalidatesOlderRuntimeSettingsOverrideTokens() } [Test] - public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() + public void FixtureSettingsDisposalRaisesOnlyFinalRestoreEvent() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1227,9 +1246,9 @@ public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() }; try { - originalToken = DxMessagingRuntimeSettingsProvider.Override(original); - outerToken = DxMessagingRuntimeSettingsProvider.Override(outer); - innerToken = DxMessagingRuntimeSettingsProvider.Override(inner); + originalToken = RuntimeSettingsFixture.Override(original); + outerToken = RuntimeSettingsFixture.Override(outer); + innerToken = RuntimeSettingsFixture.Override(inner); DxMessagingRuntimeSettings.SettingsChanged += captureRestore; outerToken.Dispose(); @@ -1265,7 +1284,7 @@ public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() innerToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(inner); UnityEngine.Object.DestroyImmediate(outer); UnityEngine.Object.DestroyImmediate(original); @@ -1273,7 +1292,7 @@ public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() } [Test] - public void OutOfOrderRuntimeSettingsDisposalCarriesGlobalBufferSnapshotForward() + public void FixtureSettingsDisposalCarriesGlobalBufferSnapshotForward() { int entryBufferSize = IMessageBus.GlobalMessageBufferSize; DxMessagingRuntimeSettings originalFallback = @@ -1288,10 +1307,10 @@ public void OutOfOrderRuntimeSettingsDisposalCarriesGlobalBufferSnapshotForward( try { originalFallback.MarkAsFallbackInstance(); - originalToken = DxMessagingRuntimeSettingsProvider.Override(originalFallback); + originalToken = RuntimeSettingsFixture.Override(originalFallback); IMessageBus.GlobalMessageBufferSize = 23; - outerToken = DxMessagingRuntimeSettingsProvider.Override(outer); - innerToken = DxMessagingRuntimeSettingsProvider.Override(inner); + outerToken = RuntimeSettingsFixture.Override(outer); + innerToken = RuntimeSettingsFixture.Override(inner); outerToken.Dispose(); innerToken.Dispose(); @@ -1312,7 +1331,7 @@ public void OutOfOrderRuntimeSettingsDisposalCarriesGlobalBufferSnapshotForward( innerToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); IMessageBus.GlobalMessageBufferSize = entryBufferSize; UnityEngine.Object.DestroyImmediate(inner); UnityEngine.Object.DestroyImmediate(outer); @@ -1321,17 +1340,17 @@ public void OutOfOrderRuntimeSettingsDisposalCarriesGlobalBufferSnapshotForward( } [Test] - public void NullRuntimeSettingsOverrideLeavesCurrentSettingsUnchanged() + public void NullFixtureSettingsLeavesCurrentSettingsUnchanged() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); IDisposable originalToken = null; try { - originalToken = DxMessagingRuntimeSettingsProvider.Override(original); + originalToken = RuntimeSettingsFixture.Override(original); Assert.Throws( - () => DxMessagingRuntimeSettingsProvider.Override(null), + () => RuntimeSettingsFixture.Override(null), "A null override must be rejected." ); Assert.AreSame( @@ -1343,7 +1362,7 @@ public void NullRuntimeSettingsOverrideLeavesCurrentSettingsUnchanged() finally { originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(original); } } @@ -1381,12 +1400,14 @@ public void PlayerLoopSweepAgesIdleCandidatesWithoutEmit() try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); deregistration(); Assert.GreaterOrEqual(bus.OccupiedTypeSlots, 1); diff --git a/Tests/Editor/Contract/MessageBusInvariantTests.cs b/Tests/Editor/Contract/MessageBusInvariantTests.cs index f500bf64f..c152b0e13 100644 --- a/Tests/Editor/Contract/MessageBusInvariantTests.cs +++ b/Tests/Editor/Contract/MessageBusInvariantTests.cs @@ -10,6 +10,10 @@ namespace DxMessaging.Tests.Editor.Contract using DxMessaging.Core.MessageBus.Internal; using DxMessaging.Core.Messages; using NUnit.Framework; +#if !UNITY_2021_3_OR_NEWER + using Assert = NUnit.Framework.Legacy.ClassicAssert; + using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert; +#endif /// /// Contract guardrails for cache storage fields that @@ -23,6 +27,8 @@ public sealed class MessageBusInvariantTests private static readonly InstanceId HandlerOwnerB = new InstanceId(0x4D42_4902); private static readonly InstanceId Target = new InstanceId(0x4D42_4903); + private const int ExpectedMessageCacheFieldCount = 8; + private const BindingFlags DeclaredInstanceFields = BindingFlags.Instance | BindingFlags.Public @@ -35,10 +41,10 @@ public void MessageCacheFieldCountMatchesExpected() FieldInfo[] fields = GetMessageCacheStorageFields(); Assert.AreEqual( - MessageBus.ExpectedMessageCacheFieldCount, + ExpectedMessageCacheFieldCount, fields.Length, "MessageBus MessageCache storage field count changed. If the new cache owns " - + "per-message-type state, register it in MessageBus.SweepableTypeCaches and " + + "per-message-type state, register it in MessageBus.SweepableTypeCacheRegistry and " + "then update ExpectedMessageCacheFieldCount." ); } @@ -199,8 +205,8 @@ public void EveryMessageCacheFieldHasSweepableRegistryEntry() .Select(field => field.Name) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); - string[] registeredNames = MessageBus - .SweepableTypeCaches.Select(sweepable => sweepable.StorageFieldName) + string[] registeredNames = SweepRegistryInspection + .Rows.Select(sweepable => sweepable.StorageFieldName) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); @@ -219,7 +225,7 @@ public void SweepableRegistryDoesNotContainUnknownFields() StringComparer.Ordinal ); - foreach (ISweepable sweepable in MessageBus.SweepableTypeCaches) + foreach (ObservedSweep sweepable in SweepRegistryInspection.Rows) { Assert.IsTrue( fieldNames.Contains(sweepable.StorageFieldName), @@ -251,11 +257,8 @@ public void AotBridgeRegistrationHooksKeepGeneratedReflectionContract() [Test] public void SweepableRegistryFieldNamesAreUnique() { - string[] duplicateNames = MessageBus - .SweepableTypeCaches.GroupBy( - sweepable => sweepable.StorageFieldName, - StringComparer.Ordinal - ) + string[] duplicateNames = SweepRegistryInspection + .Rows.GroupBy(sweepable => sweepable.StorageFieldName, StringComparer.Ordinal) .Where(group => 1 < group.Count()) .Select(group => group.Key) .ToArray(); @@ -266,23 +269,20 @@ public void SweepableRegistryFieldNamesAreUnique() ); } - [Test] - public void SweepableRegistryFieldTypesMatchDeclaredFields() + [TestCase(false)] + [TestCase(true)] + public void SweepRegistryCallbacksLeaveEmptyBusEmpty(bool force) { - Dictionary fieldsByName = GetMessageCacheStorageFields() - .ToDictionary(field => field.Name, StringComparer.Ordinal); - - foreach (ISweepable sweepable in MessageBus.SweepableTypeCaches) + MessageBus bus = new(); + foreach (ObservedSweep sweepable in SweepRegistryInspection.Rows) { - 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." + 0, + sweepable.Sweep(bus, force), + $"An empty {sweepable.StorageFieldName} must reclaim no slots." ); + Assert.AreEqual(0, bus.OccupiedTypeSlots); + Assert.AreEqual(0, bus.OccupiedTargetSlots); } } @@ -335,7 +335,7 @@ ref InterceptorProbeMessage _ bus.Deregister(in deregister); Assert.AreEqual(0, bus.RegisteredInterceptors); - ISweepable sweepable = MessageBus.SweepableTypeCaches.Single(row => + ObservedSweep sweepable = SweepRegistryInspection.Rows.Single(row => row.StorageFieldName == "_untargetedInterceptsByType" ); int evicted = sweepable.Sweep(bus, force: true); @@ -355,7 +355,7 @@ public void TrimDuringActiveDispatchAfterDeregisterPreservesSnapshot() int secondCalls = 0; try { - firstDeregister = + firstDeregister = ( first.RegisterUntargetedMessageHandler( input => { @@ -371,14 +371,16 @@ public void TrimDuringActiveDispatchAfterDeregisterPreservesSnapshot() }, priority: 0, messageBus: bus - ); - secondDeregister = + ) + ).AsAction(); + secondDeregister = ( second.RegisterUntargetedMessageHandler( _ => secondCalls++, _ => secondCalls++, priority: 0, messageBus: bus - ); + ) + ).AsAction(); UntargetedDispatchProbeMessage message = new UntargetedDispatchProbeMessage(); bus.UntargetedBroadcast(ref message); @@ -541,18 +543,22 @@ public void TrimDuringInFlightDispatchAfterDeregisteringCurrentSlotDefersSnapsho calls.Add("second"); }; - firstDeregister = firstHandler.RegisterUntargetedMessageHandler( - first, - first, - priority: 0, - messageBus: bus - ); - secondDeregister = secondHandler.RegisterUntargetedMessageHandler( - second, - second, - priority: 1, - messageBus: bus - ); + firstDeregister = ( + firstHandler.RegisterUntargetedMessageHandler( + first, + first, + priority: 0, + messageBus: bus + ) + ).AsAction(); + secondDeregister = ( + secondHandler.RegisterUntargetedMessageHandler( + second, + second, + priority: 1, + messageBus: bus + ) + ).AsAction(); DispatchTrimProbeMessage message = new DispatchTrimProbeMessage(); bus.UntargetedBroadcast(ref message); diff --git a/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs new file mode 100644 index 000000000..cd0e29e1a --- /dev/null +++ b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs @@ -0,0 +1,56 @@ +#if UNITY_EDITOR +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Linq; + using System.Reflection; + using System.Runtime.CompilerServices; + using DxMessaging.Editor; + using DxMessaging.Tests.Runtime.Core; + using NUnit.Framework; + + public sealed class ProductionEditorAssemblySurfaceTests + { + [Test] + public void EditorAssemblyContainsNoTestOnlyEntryPoints() + { + Assert.That( + ProductionAssemblySurfaceTests.FindTestOnlyEntryPoints( + typeof(DxMessagingEditorInitializer).Assembly + ), + Is.Empty + ); + } + + [Test] + public void EditorAssemblyContainsNoMutableStaticDelegateInjectionPoints() + { + string[] hooks = typeof(DxMessagingEditorInitializer) + .Assembly.GetTypes() + .Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false)) + .SelectMany(type => + type.GetFields( + BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly + ) + .Where(field => + !field.IsInitOnly + && typeof(Delegate).IsAssignableFrom(field.FieldType) + && !type.GetEvents( + BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly + ) + .Any(signal => signal.Name == field.Name) + ) + ) + .Select(field => field.DeclaringType.FullName + "." + field.Name) + .ToArray(); + Assert.That(hooks, Is.Empty); + } + } +} +#endif diff --git a/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta similarity index 83% rename from Runtime/Core/MessageBus/Internal/ISweepable.cs.meta rename to Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta index 0ebc06285..424c3efaf 100644 --- a/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta +++ b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d1e4e9a7e52243f7b7d42a1ef4f54152 +guid: 77a5b4fe53024efdad7ee86411c90415 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Editor/Contract/SweepRegistryInspection.cs b/Tests/Editor/Contract/SweepRegistryInspection.cs new file mode 100644 index 000000000..19e06886e --- /dev/null +++ b/Tests/Editor/Contract/SweepRegistryInspection.cs @@ -0,0 +1,78 @@ +namespace DxMessaging.Tests.Editor.Contract +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using DxMessaging.Core.Helper; + using DxMessaging.Core.MessageBus; + + internal static class SweepRegistryInspection + { + internal static IReadOnlyList Rows + { + get + { + Func[] callbacks = (Func[]) + MessageBusInspection.ReadField( + typeof(MessageBus), + "SweepableTypeCacheRegistry" + ); + List rows = new(); + foreach (Func callback in callbacks) + { + FieldInfo[] fields = StorageFields(callback.Method).ToArray(); + if (fields.Length == 0) + { + fields = EditorProductionCalls + .From(callback.Method) + .OfType() + .Where(method => method.DeclaringType == typeof(MessageBus)) + .SelectMany(StorageFields) + .Distinct() + .ToArray(); + } + if (fields.Length != 1) + { + throw new InvalidOperationException( + $"A sweep callback must address one real message cache; observed {fields.Length} in {callback.Method}." + ); + } + rows.Add(new ObservedSweep(fields[0], callback)); + } + return rows; + } + } + + private static IEnumerable StorageFields(MethodInfo method) => + EditorProductionCalls + .Members(method) + .OfType() + .Where(field => + { + Type type = field.FieldType.IsArray + ? field.FieldType.GetElementType() + : field.FieldType; + return field.DeclaringType == typeof(MessageBus) + && type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(MessageCache<>); + }) + .Distinct(); + } + + internal sealed class ObservedSweep + { + private readonly FieldInfo _field; + private readonly Func _callback; + + internal ObservedSweep(FieldInfo field, Func callback) + { + _field = field; + _callback = callback; + } + + internal string StorageFieldName => _field.Name; + + internal int Sweep(MessageBus bus, bool force) => _callback(bus, force); + } +} diff --git a/Tests/Editor/Contract/SweepRegistryInspection.cs.meta b/Tests/Editor/Contract/SweepRegistryInspection.cs.meta new file mode 100644 index 000000000..ea1a61df5 --- /dev/null +++ b/Tests/Editor/Contract/SweepRegistryInspection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 32562d5def8c49899b768e310c90e661 +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 index afce2dcad..6a98474e0 100644 --- a/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs +++ b/Tests/Editor/Contract/TypedSlotIndexCoverageTests.cs @@ -424,12 +424,14 @@ public void ExternalSweepResetsEmptyUntargetedSlotAndInvalidatesStaleDeregistrat active = true, }; Action original = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - original, - original, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + original, + original, + priority: 17, + messageBus: bus + ) + ).AsAction(); object typedHandler = ReadTypedHandler(handler, bus); Array slots = ReadArrayField(typedHandler, "_slots"); TypedSlot slot = @@ -448,12 +450,14 @@ public void ExternalSweepResetsEmptyUntargetedSlotAndInvalidatesStaleDeregistrat + "closures observe a monotonic slot version bump." ); - Action newDeregistration = handler.RegisterUntargetedMessageHandler( - original, - original, - priority: 17, - messageBus: bus - ); + Action newDeregistration = ( + handler.RegisterUntargetedMessageHandler( + original, + original, + priority: 17, + messageBus: bus + ) + ).AsAction(); object newTypedHandler = ReadTypedHandler(handler, bus); Assert.AreNotSame( typedHandler, @@ -489,12 +493,14 @@ public void ExternalSweepPreservesActiveUntargetedSlotAndDispatch() }; int handled = 0; Action callback = _ => handled++; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); object typedHandler = ReadTypedHandler(handler, bus); Array slots = ReadArrayField(typedHandler, "_slots"); TypedSlot slot = @@ -584,13 +590,15 @@ public void ExternalSweepResetsEmptyContextSlotAndReturnsDictionariesToPools() }; InstanceId target = new InstanceId(0x5033_0104); Action original = _ => { }; - Action deregistration = handler.RegisterTargetedMessageHandler( - target, - original, - original, - priority: 17, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterTargetedMessageHandler( + target, + original, + original, + priority: 17, + messageBus: bus + ) + ).AsAction(); object typedHandler = ReadTypedHandler(handler, bus); Array slots = ReadArrayField(typedHandler, "_slots"); TypedSlot slot = @@ -733,15 +741,17 @@ public void ExternalSweepResetsEmptyGlobalSlots() Action untargeted = _ => { }; Action targeted = (_, _) => { }; Action broadcast = (_, _) => { }; - Action deregistration = handler.RegisterGlobalAcceptAll( - untargeted, - untargeted, - targeted, - targeted, - broadcast, - broadcast, - messageBus: bus - ); + Action deregistration = ( + handler.RegisterGlobalAcceptAll( + untargeted, + untargeted, + targeted, + targeted, + broadcast, + broadcast, + messageBus: bus + ) + ).AsAction(); object typedHandler = ReadTypedHandler(handler, bus); Array globalSlots = ReadArrayField(typedHandler, "_globalSlots"); TypedGlobalSlot untargetedSlot = (TypedGlobalSlot) diff --git a/Tests/Editor/Contract/TypedSlotShapeTests.cs b/Tests/Editor/Contract/TypedSlotShapeTests.cs index c3c7d2cfb..7bd1450a9 100644 --- a/Tests/Editor/Contract/TypedSlotShapeTests.cs +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -9,6 +9,11 @@ namespace DxMessaging.Tests.Editor.Contract using DxMessaging.Core.MessageBus.Internal; using DxMessaging.Core.Messages; using NUnit.Framework; +#if !UNITY_2021_3_OR_NEWER + using Assert = NUnit.Framework.Legacy.ClassicAssert; + using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert; + using StringAssert = NUnit.Framework.Legacy.StringAssert; +#endif /// /// Contract guardrails for the typed-handler-side slot grid: @@ -434,8 +439,8 @@ public void HandlerActionCacheOrderedStoragePreservesBoundaryAndSpillReuseBehavi ); } - Assert.IsFalse(cache.entries.UsesSpillStorage); - Assert.AreEqual(2, cache.entries.InlineCapacity); + Assert.IsFalse(EntryStorageInspection.UsesSpillStorage(cache.entries)); + Assert.AreEqual(2, EntryStorageInspection.InlineCapacity(cache.entries)); AssertOrderedKeys(cache, handlers[0], handlers[1]); cache.entries[handlers[0]] = @@ -449,23 +454,23 @@ public void HandlerActionCacheOrderedStoragePreservesBoundaryAndSpillReuseBehavi cache.entries[handlers[2]] = new MessageHandler.HandlerActionCache>.Entry(handlers[2], 1); - Assert.IsTrue(cache.entries.UsesSpillStorage); - Assert.AreEqual(2, cache.entries.InlineCapacity); - Assert.GreaterOrEqual(cache.entries.MapCapacity, 3); - Assert.GreaterOrEqual(cache.entries.OrderCapacity, 3); + Assert.IsTrue(EntryStorageInspection.UsesSpillStorage(cache.entries)); + Assert.AreEqual(2, EntryStorageInspection.InlineCapacity(cache.entries)); + Assert.GreaterOrEqual(EntryStorageInspection.MapCapacity(cache.entries), 3); + Assert.GreaterOrEqual(EntryStorageInspection.OrderCapacity(cache.entries), 3); AssertOrderedKeys(cache, handlers[1], handlers[0], handlers[2]); - int mapCapacity = cache.entries.MapCapacity; - int orderCapacity = cache.entries.OrderCapacity; + int mapCapacity = EntryStorageInspection.MapCapacity(cache.entries); + int orderCapacity = EntryStorageInspection.OrderCapacity(cache.entries); cache.entries.Clear(); Assert.Zero(cache.entries.Count); - Assert.IsTrue(cache.entries.UsesSpillStorage); - Assert.AreEqual(mapCapacity, cache.entries.MapCapacity); - Assert.AreEqual(orderCapacity, cache.entries.OrderCapacity); + Assert.IsTrue(EntryStorageInspection.UsesSpillStorage(cache.entries)); + Assert.AreEqual(mapCapacity, EntryStorageInspection.MapCapacity(cache.entries)); + Assert.AreEqual(orderCapacity, EntryStorageInspection.OrderCapacity(cache.entries)); cache.entries[handlers[1]] = new MessageHandler.HandlerActionCache>.Entry(handlers[1], 1); - Assert.IsTrue(cache.entries.UsesSpillStorage); + Assert.IsTrue(EntryStorageInspection.UsesSpillStorage(cache.entries)); AssertOrderedKeys(cache, handlers[1]); } @@ -483,7 +488,10 @@ public void HandlerActionCacheSpillPrefixAndFallbackPreserveSurvivorOrder() new MessageHandler.HandlerActionCache>.Entry(handler, 1); } - Assert.IsTrue(cache.entries.UsesSpillStorage, "Five handlers must use spill storage."); + Assert.IsTrue( + EntryStorageInspection.UsesSpillStorage(cache.entries), + "Five handlers must use spill storage." + ); Assert.IsTrue(cache.entries.Remove(handlers[3]), "Fallback removal must find D."); Assert.IsTrue(cache.entries.Remove(handlers[0]), "Prefix removal must find A."); Assert.IsTrue(cache.entries.Remove(handlers[1]), "Prefix removal must find B."); @@ -496,18 +504,18 @@ public void HandlerActionCacheSpillPrefixAndFallbackPreserveSurvivorOrder() Assert.AreEqual( 2, - cache.entries.SkippedPrefixCount, + EntryStorageInspection.SkippedPrefixCount(cache.entries), "A and B form the skipped prefix." ); Assert.AreEqual( 7, - cache.entries.OrderSlotCount, + EntryStorageInspection.OrderSlotCount(cache.entries), "Re-registered keys append after C/E." ); Assert.AreEqual(5, cache.entries.Count, "Exactly five delegates remain live."); Assert.AreEqual( 2, - cache.entries.SkippedPrefixCount, + EntryStorageInspection.SkippedPrefixCount(cache.entries), "Count must remain a pure O(1) read." ); @@ -519,8 +527,15 @@ public void HandlerActionCacheSpillPrefixAndFallbackPreserveSurvivorOrder() handlers[1], handlers[5] ); - Assert.Zero(cache.entries.SkippedPrefixCount, "Ordered preparation must compact."); - Assert.AreEqual(5, cache.entries.OrderSlotCount, "Compaction must leave five slots."); + Assert.Zero( + EntryStorageInspection.SkippedPrefixCount(cache.entries), + "Ordered preparation must compact." + ); + Assert.AreEqual( + 5, + EntryStorageInspection.OrderSlotCount(cache.entries), + "Compaction must leave five slots." + ); Assert.IsFalse(cache.entries.Remove(handlers[0]), "Removed A must remain absent."); } @@ -546,7 +561,7 @@ public void HandlerActionCacheSpillChurnBoundsUnobservedSkippedPrefix() ); } - int initialMapCapacity = cache.entries.MapCapacity; + int initialMapCapacity = EntryStorageInspection.MapCapacity(cache.entries); for (int index = 0; index < handlers.Length - 3; ++index) { Assert.IsTrue( @@ -567,17 +582,17 @@ public void HandlerActionCacheSpillChurnBoundsUnobservedSkippedPrefix() .OrderedEntries .CompactionPrefixThreshold - 1; Assert.LessOrEqual( - cache.entries.SkippedPrefixCount, + EntryStorageInspection.SkippedPrefixCount(cache.entries), maximumSkippedPrefix, "Skipped prefix must compact at the absolute and ratio threshold." ); Assert.LessOrEqual( - cache.entries.OrderSlotCount, + EntryStorageInspection.OrderSlotCount(cache.entries), cache.entries.Count + maximumSkippedPrefix, "Logical order slots must stay bounded by live entries plus the prefix." ); Assert.LessOrEqual( - cache.entries.OrderCapacity, + EntryStorageInspection.OrderCapacity(cache.entries), MessageHandler .HandlerActionCache> .OrderedEntries @@ -586,7 +601,7 @@ public void HandlerActionCacheSpillChurnBoundsUnobservedSkippedPrefix() ); Assert.AreEqual( initialMapCapacity, - cache.entries.MapCapacity, + EntryStorageInspection.MapCapacity(cache.entries), "Fixed live cardinality must not grow dictionary capacity." ); AssertOrderedKeys( @@ -619,8 +634,14 @@ public void HandlerActionCacheRemovingEverySpillEntryResetsOrderBeforeReuse() } Assert.Zero(cache.entries.Count, "Removing every handler must empty the map."); - Assert.Zero(cache.entries.OrderSlotCount, "Full teardown must clear order slots."); - Assert.Zero(cache.entries.SkippedPrefixCount, "Full teardown must reset the cursor."); + Assert.Zero( + EntryStorageInspection.OrderSlotCount(cache.entries), + "Full teardown must clear order slots." + ); + Assert.Zero( + EntryStorageInspection.SkippedPrefixCount(cache.entries), + "Full teardown must reset the cursor." + ); cache.entries[handlers[4]] = new MessageHandler.HandlerActionCache>.Entry(handlers[4], 1); diff --git a/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs b/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs index 3001618d9..1490096db 100644 --- a/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs +++ b/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs @@ -4,6 +4,7 @@ namespace DxMessaging.Tests.Editor using System; using System.Collections.Generic; using System.IO; + using System.Linq; using System.Reflection; using DxMessaging.Core.MessageBus; using DxMessaging.Editor; @@ -107,41 +108,76 @@ private DxMessagingSettings NewSettings() [TestFixture] public sealed class DxMessagingEditorInitializerTests { - private readonly List _scheduled = new(); private readonly List _createdSettings = new(); private readonly List _warnings = new(); - private DiagnosticsTarget _previousDiagnosticsTargets; - private bool _previousDiagnosticsStackTraces; - private int _previousMessageBufferSize; + private EditorDelayCallScope _scheduled; + private EditorStaticFieldScope _initializerState; + private EditorSettingsCacheScope _settingsCache; + private EditorStaticFieldScope _runtimeSettings; + private UnityEngine.Object _previousRuntimeSettings; + private EditorDelayCallScope _sidecarCallbacks; + private IDisposable _registryScope; + private IDisposable _poolScope; + private DiagnosticsTarget _previousTargets; + private bool _previousStacks; + private int _previousBuffer; + private int _previousBusIndex; + private IMessageBus _previousBus; + private bool _previousOptionsEnabled; + private EnterPlayModeOptions _previousOptions; + private bool _previousDebugEnabled; + private Action _previousDebugLog; + private int _previousSyntheticOwner; [SetUp] public void SetUp() { - _previousDiagnosticsTargets = IMessageBus.GlobalDiagnosticsTargets; - _previousDiagnosticsStackTraces = IMessageBus.GlobalDiagnosticsStackTraces; - _previousMessageBufferSize = IMessageBus.GlobalMessageBufferSize; - _scheduled.Clear(); + _previousTargets = IMessageBus.GlobalDiagnosticsTargets; + _previousStacks = IMessageBus.GlobalDiagnosticsStackTraces; + _previousBuffer = IMessageBus.GlobalMessageBufferSize; + _previousBusIndex = IMessageBus.GlobalSequentialIndex; + _previousBus = DxMessaging.Core.MessageHandler.MessageBus; + _previousDebugEnabled = DxMessaging.Core.MessagingDebug.enabled; + _previousDebugLog = DxMessaging.Core.MessagingDebug.LogFunction; + _previousSyntheticOwner = + DxMessaging.Core.MessageBus.MessageRegistrationBuilder.GetSyntheticOwnerCounter(); + _previousOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _previousOptions = EditorSettings.enterPlayModeOptions; + _initializerState = new( + typeof(DxMessagingEditorInitializer), + "s_playModeWarningIssued", + "s_ensureSettingsAssetScheduled" + ); + _initializerState.Set("s_playModeWarningIssued", false); + _initializerState.Set("s_ensureSettingsAssetScheduled", false); + _settingsCache = new(); + _runtimeSettings = new( + typeof(DxMessaging.Core.Configuration.DxMessagingRuntimeSettingsProvider), + "_cached" + ); + _previousRuntimeSettings = + MessageBusInspection.ReadField( + typeof(DxMessaging.Core.Configuration.DxMessagingRuntimeSettingsProvider), + "_cached" + ) as UnityEngine.Object; + _runtimeSettings.Set("_cached", null); + _sidecarCallbacks = new(typeof(DxMessagingBaseCallIgnoreSync)); + _scheduled = new(typeof(DxMessagingEditorInitializer)); + _registryScope = MessageBusInspection.IsolateIdleSweepRegistryForBenchmark(); + _poolScope = MessageBusInspection.IsolateContextMapPoolForBenchmark(); _warnings.Clear(); - - DxMessagingEditorInitializer.ResetTestSeams(); - DxMessagingEditorInitializer.StaticStateResetter = () => - { - IMessageBus.GlobalDiagnosticsTargets = DiagnosticsTarget.Off; - IMessageBus.GlobalDiagnosticsStackTraces = false; - IMessageBus.GlobalMessageBufferSize = IMessageBus.DefaultMessageBufferSize; - }; - DxMessagingEditorInitializer.AssetDatabaseMutationScheduler = work => - _scheduled.Add(work); - DxMessagingEditorInitializer.DomainReloadDisabledDetector = () => false; - DxMessagingEditorInitializer.DomainReloadWarningLogger = message => - _warnings.Add(message); + Application.logMessageReceived += ObserveWarning; + EditorSettings.enterPlayModeOptionsEnabled = false; } [TearDown] public void TearDown() { + Application.logMessageReceived -= ObserveWarning; try { + _scheduled?.Dispose(); + _sidecarCallbacks?.Dispose(); foreach (DxMessagingSettings settings in _createdSettings) { if (settings != null) @@ -153,13 +189,62 @@ public void TearDown() } finally { - DxMessagingEditorInitializer.ResetTestSeams(); - IMessageBus.GlobalDiagnosticsTargets = _previousDiagnosticsTargets; - IMessageBus.GlobalDiagnosticsStackTraces = _previousDiagnosticsStackTraces; - IMessageBus.GlobalMessageBufferSize = _previousMessageBufferSize; + EditorTestCleanup.Run( + () => + { + object currentRuntimeSettings = MessageBusInspection.ReadField( + typeof(DxMessaging.Core.Configuration.DxMessagingRuntimeSettingsProvider), + "_cached" + ); + if ( + currentRuntimeSettings + is DxMessaging.Core.Configuration.DxMessagingRuntimeSettings created + && created.IsFallbackInstance + && !ReferenceEquals(created, _previousRuntimeSettings) + ) + { + Object.DestroyImmediate(created); + } + }, + () => _runtimeSettings?.Dispose(), + () => _settingsCache?.Dispose(), + () => _initializerState?.Dispose(), + () => _poolScope?.Dispose(), + () => _registryScope?.Dispose(), + () => EditorSettings.enterPlayModeOptionsEnabled = _previousOptionsEnabled, + () => EditorSettings.enterPlayModeOptions = _previousOptions, + () => DxMessaging.Core.MessageHandler.SetGlobalMessageBus(_previousBus), + () => IMessageBus.GlobalSequentialIndex = _previousBusIndex, + () => IMessageBus.GlobalDiagnosticsTargets = _previousTargets, + () => IMessageBus.GlobalDiagnosticsStackTraces = _previousStacks, + () => IMessageBus.GlobalMessageBufferSize = _previousBuffer, + () => DxMessaging.Core.MessagingDebug.enabled = _previousDebugEnabled, + () => DxMessaging.Core.MessagingDebug.LogFunction = _previousDebugLog, + () => + DxMessaging.Core.MessageBus.MessageRegistrationBuilder.SetSyntheticOwnerCounter( + _previousSyntheticOwner + ) + ); } } + private void ObserveWarning(string message, string stack, LogType type) + { + if ( + type == LogType.Warning + && message == DxMessagingEditorInitializer.DomainReloadWarningMessage + ) + { + _warnings.Add(message); + } + } + + private void DisableDomainReload() + { + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions |= EnterPlayModeOptions.DisableDomainReload; + } + [Test] public void ApplyEditorSettingsUsesPassiveLegacyDiagnosticsValueImmediately() { @@ -168,178 +253,117 @@ public void ApplyEditorSettingsUsesPassiveLegacyDiagnosticsValueImmediately() settings._legacyEnableDiagnosticsInEditor = true; settings._diagnosticsStackTraces = true; settings._messageBufferSize = 37; - DxMessagingEditorInitializer.PassiveSettingsLoader = () => settings; - + _settingsCache.SetCurrent(settings); DxMessagingEditorInitializer.ApplyEditorSettings(); - Assert.That(IMessageBus.GlobalDiagnosticsTargets, Is.EqualTo(DiagnosticsTarget.Editor)); Assert.That(IMessageBus.GlobalDiagnosticsStackTraces, Is.True); Assert.That(IMessageBus.GlobalMessageBufferSize, Is.EqualTo(37)); Assert.That( settings._legacyEnableDiagnosticsInEditor, Is.True, - "The passive initializer path must not perform durable asset migration; it only " - + "applies effective values and schedules the idle mutation." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "ApplyEditorSettings must schedule exactly one deferred settings ensure." + "The passive path must preserve the original asset." ); + Assert.That(_scheduled.Count, Is.EqualTo(1)); } [Test] public void ApplyEditorSettingsWhenPassiveSettingsMissingDefersEnsureWithoutCreatingSynchronously() { - int ensureCount = 0; - DxMessagingEditorInitializer.PassiveSettingsLoader = () => null; - DxMessagingEditorInitializer.SettingsAssetEnsurer = () => - { - ensureCount++; - return NewSettings(); - }; + _settingsCache.SetCurrent(null); IMessageBus.GlobalDiagnosticsTargets = DiagnosticsTarget.All; IMessageBus.GlobalDiagnosticsStackTraces = true; IMessageBus.GlobalMessageBufferSize = 3; - DxMessagingEditorInitializer.ApplyEditorSettings(); - + Assert.That(_scheduled.Count, Is.EqualTo(1)); Assert.That( - ensureCount, - Is.EqualTo(0), - "The domain-load path must stay passive and defer settings asset creation." + DxMessagingSettings.LoadSettingsPassive(), + Is.Null, + "Applying globals must not replace a cached miss by creating settings synchronously." ); - Assert.That(_scheduled.Count, Is.EqualTo(1)); Assert.That(IMessageBus.GlobalDiagnosticsTargets, Is.EqualTo(DiagnosticsTarget.Off)); Assert.That(IMessageBus.GlobalDiagnosticsStackTraces, Is.False); Assert.That( IMessageBus.GlobalMessageBufferSize, Is.EqualTo(IMessageBus.DefaultMessageBufferSize) ); - Assert.That( - _warnings, - Is.Empty, - "A missing passive settings asset cannot decide the warning preference; the " - + "deferred ensure callback re-checks with the realized settings." - ); + Assert.That(_warnings, Is.Empty); } [Test] public void ApplyEditorSettingsWarnsImmediatelyWhenPassiveSettingsUnsuppressedAndDomainReloadDisabled() { - int ensureCount = 0; DxMessagingSettings settings = NewSettings(); settings._suppressDomainReloadWarning = false; - DxMessagingEditorInitializer.PassiveSettingsLoader = () => settings; - DxMessagingEditorInitializer.SettingsAssetEnsurer = () => - { - ensureCount++; - return settings; - }; - DxMessagingEditorInitializer.DomainReloadDisabledDetector = () => true; - + _settingsCache.SetCurrent(settings); + DisableDomainReload(); DxMessagingEditorInitializer.ApplyEditorSettings(); - Assert.That( _warnings, Is.EqualTo(new[] { DxMessagingEditorInitializer.DomainReloadWarningMessage }) ); Assert.That(_scheduled.Count, Is.EqualTo(1)); - _scheduled[0].Invoke(); - - Assert.That(ensureCount, Is.EqualTo(1)); Assert.That( _warnings, Is.EqualTo(new[] { DxMessagingEditorInitializer.DomainReloadWarningMessage }), - "The deferred ensure must not duplicate a warning already emitted from the " - + "domain-load passive settings read." + "The real deferred ensure must not repeat an issued warning." ); } [Test] - public void DeferredSettingsEnsureUsesSharedIdleSchedulerAndRequeuesUntilIdle() + public void DeferredSettingsEnsureUsesRealIdleQueueAndClearsDebounce() { - bool editorIdle = false; - int ensureCount = 0; - DxMessagingSettings ensuredSettings = NewSettings(); - ensuredSettings._diagnosticsTargets = DiagnosticsTarget.Runtime; - ensuredSettings._messageBufferSize = 19; - - DxMessagingEditorInitializer.PassiveSettingsLoader = () => null; - DxMessagingEditorInitializer.SettingsAssetEnsurer = () => - { - ensureCount++; - return ensuredSettings; - }; - DxMessagingEditorInitializer.AssetDatabaseMutationScheduler = work => - DxMessagingEditorIdle.ScheduleAssetDatabaseMutation( - work, - scheduledWork => _scheduled.Add(scheduledWork), - () => editorIdle - ); - + _settingsCache.SetCurrent(null); + DxMessagingEditorInitializer.ApplyEditorSettings(); DxMessagingEditorInitializer.ApplyEditorSettings(); - - Assert.That(_scheduled.Count, Is.EqualTo(1)); - _scheduled[0].Invoke(); - - Assert.That( - ensureCount, - Is.EqualTo(0), - "The deferred settings ensure must not run while the editor is compiling/updating." - ); Assert.That( _scheduled.Count, - Is.EqualTo(2), - "A busy deferred tick must requeue through DxMessagingEditorIdle." + Is.EqualTo(1), + "Repeated requests must share one real queued ensure." ); - - editorIdle = true; - _scheduled[1].Invoke(); - - Assert.That(ensureCount, Is.EqualTo(1)); + DxMessagingSettings settings = NewSettings(); + settings._diagnosticsTargets = DiagnosticsTarget.Runtime; + settings._messageBufferSize = 19; + _settingsCache.SetCurrent(settings); + _scheduled[0].Invoke(); Assert.That( IMessageBus.GlobalDiagnosticsTargets, Is.EqualTo(DiagnosticsTarget.Runtime) ); Assert.That(IMessageBus.GlobalMessageBufferSize, Is.EqualTo(19)); - Assert.That(_scheduled.Count, Is.EqualTo(2)); - DxMessagingEditorInitializer.ApplyEditorSettings(); + Assert.That(_scheduled.Count, Is.EqualTo(2)); + _scheduled[1].Invoke(); + Assert.That(IMessageBus.GlobalMessageBufferSize, Is.EqualTo(19)); + MethodInfo scheduler = typeof(DxMessagingEditorInitializer).GetMethod( + "ScheduleEnsureSettingsAsset", + BindingFlags.Static | BindingFlags.NonPublic + ); Assert.That( - _scheduled.Count, - Is.EqualTo(3), - "A completed deferred ensure must clear its debounce latch so a later settings " - + "refresh can schedule another migration/create pass." + EditorProductionCalls + .From(scheduler) + .Any(call => + call.DeclaringType == typeof(DxMessagingEditorIdle) + && call.Name == "ScheduleAssetDatabaseMutation" + ), + Is.True ); } [Test] public void DeferredSettingsEnsureWarnsOnceWhenDomainReloadIsDisabledAndWarningUnsuppressed() { - int ensureCount = 0; - DxMessagingSettings ensuredSettings = NewSettings(); - ensuredSettings._diagnosticsTargets = DiagnosticsTarget.Runtime; - ensuredSettings._messageBufferSize = 23; - ensuredSettings._suppressDomainReloadWarning = false; - - DxMessagingEditorInitializer.PassiveSettingsLoader = () => null; - DxMessagingEditorInitializer.SettingsAssetEnsurer = () => - { - ensureCount++; - return ensuredSettings; - }; - DxMessagingEditorInitializer.DomainReloadDisabledDetector = () => true; - + _settingsCache.SetCurrent(null); + DisableDomainReload(); DxMessagingEditorInitializer.ApplyEditorSettings(); - Assert.That(_warnings, Is.Empty); Assert.That(_scheduled.Count, Is.EqualTo(1)); - + DxMessagingSettings settings = NewSettings(); + settings._suppressDomainReloadWarning = false; + settings._diagnosticsTargets = DiagnosticsTarget.Runtime; + settings._messageBufferSize = 23; + _settingsCache.SetCurrent(settings); _scheduled[0].Invoke(); - - Assert.That(ensureCount, Is.EqualTo(1)); Assert.That( IMessageBus.GlobalDiagnosticsTargets, Is.EqualTo(DiagnosticsTarget.Runtime) @@ -349,36 +373,25 @@ public void DeferredSettingsEnsureWarnsOnceWhenDomainReloadIsDisabledAndWarningU _warnings, Is.EqualTo(new[] { DxMessagingEditorInitializer.DomainReloadWarningMessage }) ); - DxMessagingEditorInitializer.ApplyEditorSettings(); Assert.That(_scheduled.Count, Is.EqualTo(2)); _scheduled[1].Invoke(); - - Assert.That(ensureCount, Is.EqualTo(2)); Assert.That( _warnings, - Is.EqualTo(new[] { DxMessagingEditorInitializer.DomainReloadWarningMessage }), - "The warning must be re-evaluated after deferred settings ensure, but only emitted " - + "once per editor domain." + Is.EqualTo(new[] { DxMessagingEditorInitializer.DomainReloadWarningMessage }) ); } [Test] public void DeferredSettingsEnsureKeepsDefaultSuppressedDomainReloadWarningQuiet() { - DxMessagingEditorInitializer.PassiveSettingsLoader = () => null; - DxMessagingEditorInitializer.SettingsAssetEnsurer = NewSettings; - DxMessagingEditorInitializer.DomainReloadDisabledDetector = () => true; - + DxMessagingSettings settings = NewSettings(); + Assert.That(settings._suppressDomainReloadWarning, Is.True); + _settingsCache.SetCurrent(settings); + DisableDomainReload(); DxMessagingEditorInitializer.ApplyEditorSettings(); _scheduled[0].Invoke(); - - Assert.That( - _warnings, - Is.Empty, - "Fresh settings assets default to suppressing this advisory warning unless the " - + "user clears the suppression setting." - ); + Assert.That(_warnings, Is.Empty); } private DxMessagingSettings NewSettings() @@ -389,54 +402,24 @@ private DxMessagingSettings NewSettings() } } - /// - /// Regression coverage for issue #210: a synchronous AssetDatabase.ImportAsset issued - /// from 's OnValidate during domain-load asset - /// deserialization re-enters the asset importer and hard-crashes the native editor - /// (GuidReservations::Reserve abort on Unity 6000.4+). - /// - /// - /// The native crash cannot be reproduced from managed test code, so instead of provoking it we - /// pin the invariant that makes it impossible: the OnValidate / deserialization-context path - /// must DEFER the sidecar write to the next editor tick and never apply it synchronously. The - /// tests substitute the deferral + apply seams on so - /// they observe call timing deterministically and never write the on-disk sidecar. - /// [TestFixture] public sealed class DxMessagingBaseCallIgnoreSyncTests { - private Action _originalScheduler; - private Func _originalCanMutateAssetDatabase; - private Action _originalApplier; - private Action _originalCscRspAdditionalFileSyncScheduler; - private readonly List _scheduled = new(); private readonly List _createdSettings = new(); - private int _applyCount; - private int _cscRspSyncCount; + private EditorDelayCallScope _scheduled; + private EditorDelayCallScope _responseCallbacks; + private EditorCompilerInputScope _inputs; + private EditorSettingsCacheScope _settingsCache; + private static string SidecarPath => + Path.GetFullPath(DxMessagingBaseCallIgnoreSync.SidecarAssetPath); [SetUp] public void SetUp() { - _originalScheduler = DxMessagingBaseCallIgnoreSync.DeferralScheduler; - _originalCanMutateAssetDatabase = DxMessagingBaseCallIgnoreSync.CanMutateAssetDatabase; - _originalApplier = DxMessagingBaseCallIgnoreSync.SidecarApplier; - _originalCscRspAdditionalFileSyncScheduler = - DxMessagingBaseCallIgnoreSync.CscRspAdditionalFileSyncScheduler; - _scheduled.Clear(); - _applyCount = 0; - _cscRspSyncCount = 0; - - /* - Capture deferred work instead of routing it to EditorApplication.delayCall, and count - applies instead of touching the AssetDatabase. This makes "synchronous vs deferred" - observable and keeps the tests filesystem-free and deterministic. The csc.rsp follow-up - is captured for the same reason. - */ - DxMessagingBaseCallIgnoreSync.DeferralScheduler = work => _scheduled.Add(work); - DxMessagingBaseCallIgnoreSync.CanMutateAssetDatabase = () => true; - DxMessagingBaseCallIgnoreSync.SidecarApplier = _ => _applyCount++; - DxMessagingBaseCallIgnoreSync.CscRspAdditionalFileSyncScheduler = () => - _cscRspSyncCount++; + _scheduled = new(typeof(DxMessagingBaseCallIgnoreSync)); + _responseCallbacks = new(typeof(SetupCscRsp)); + _inputs = new(); + _settingsCache = new(); } [TearDown] @@ -444,6 +427,8 @@ public void TearDown() { try { + _scheduled?.Dispose(); + _responseCallbacks?.Dispose(); foreach (DxMessagingSettings settings in _createdSettings) { if (settings != null) @@ -455,21 +440,19 @@ public void TearDown() } finally { - /* - Restore in finally so a throw while destroying objects can never leak the - substituted seams into the rest of the editor session. - */ - DxMessagingBaseCallIgnoreSync.DeferralScheduler = _originalScheduler; - DxMessagingBaseCallIgnoreSync.CanMutateAssetDatabase = - _originalCanMutateAssetDatabase; - DxMessagingBaseCallIgnoreSync.SidecarApplier = _originalApplier; - DxMessagingBaseCallIgnoreSync.CscRspAdditionalFileSyncScheduler = - _originalCscRspAdditionalFileSyncScheduler; + try + { + _inputs?.Dispose(); + } + finally + { + _settingsCache?.Dispose(); + } } } [Test] - public void DeferredCallbacksDoNotRewritePreparedCompilerInputs() + public void PreparedCompilerInputWritersRemainIdempotent() { string project = Path.Combine(Path.GetTempPath(), $"dxm_prepared_{Guid.NewGuid():N}"); string assets = Path.Combine(project, "Assets"); @@ -491,12 +474,6 @@ ref sidecarPending ); Action writeResponse = () => SetupCscRsp.SynchronizeResponseFiles(assets, imports.Add, ref rspPending); - DxMessagingBaseCallIgnoreSync.SidecarApplier = _ => writeSidecar(); - DxMessagingBaseCallIgnoreSync.CscRspAdditionalFileSyncScheduler = () => - _scheduled.Add(writeResponse); - _scheduled.Clear(); - DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); - SetupCscRsp.PrepareCompilerInputs( true, writeSidecar, @@ -509,260 +486,157 @@ ref rspPending DateTime unchangedTime = new(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc); File.SetLastWriteTimeUtc(sidecar, unchangedTime); File.SetLastWriteTimeUtc(Path.Combine(assets, "csc.rsp"), unchangedTime); - for (int callback = 0; callback < 2; ++callback) - { - Assert.AreEqual( - 1, - _scheduled.Count, - $"Callback {callback + 1} must be the sole pending sidecar or response-file work item." - ); - Action work = _scheduled[0]; - _scheduled.RemoveAt(0); - work(); - } - - Assert.IsEmpty( - _scheduled, - "The sidecar and response-file callbacks must finish without requeuing work." - ); + writeSidecar(); + writeResponse(); CollectionAssert.AreEqual( new[] { DxMessagingBaseCallIgnoreSync.SidecarAssetPath, "Assets/csc.rsp" }, imports, - "Deferred callbacks must not import already-prepared compiler inputs again." + "Reapplying the actual prepared writers must not import unchanged inputs." ); + Assert.AreEqual(unchangedTime, File.GetLastWriteTimeUtc(sidecar)); Assert.AreEqual( unchangedTime, - File.GetLastWriteTimeUtc(sidecar), - "Deferred regeneration must leave sidecar bytes untouched." - ); - Assert.AreEqual( - unchangedTime, - File.GetLastWriteTimeUtc(Path.Combine(assets, "csc.rsp")), - "Deferred response-file synchronization must leave compiler inputs untouched." + File.GetLastWriteTimeUtc(Path.Combine(assets, "csc.rsp")) ); } finally { - _scheduled.Clear(); Directory.Delete(project, true); } } [Test] - public void OnValidateDefersSidecarRegenerationInsteadOfImportingSynchronously() + public void RealDeferredCallbacksDoNotRewritePreparedCompilerInputs() { DxMessagingSettings settings = NewSettings(); - - InvokePrivate(settings, "OnValidate"); - - Assert.That( - _applyCount, - Is.EqualTo(0), - "OnValidate must NOT write/import the sidecar synchronously: a synchronous " - + "AssetDatabase.ImportAsset from OnValidate during domain-load deserialization " - + "re-enters the asset importer and hard-crashes the native editor (#210)." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "OnValidate must schedule exactly one deferred sidecar regeneration." + _settingsCache.SetCurrent(settings); + DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); + Assert.That(_scheduled.Count, Is.EqualTo(1)); + SetupCscRsp.PrepareCompilerInputs(); + byte[] sidecar = File.ReadAllBytes(SidecarPath); + byte[] response = File.ReadAllBytes(Path.Combine(Application.dataPath, "csc.rsp")); + DateTime sidecarTime = File.GetLastWriteTimeUtc(SidecarPath); + DateTime responseTime = File.GetLastWriteTimeUtc( + Path.Combine(Application.dataPath, "csc.rsp") ); - - /* - The deferred work must be a REAL regeneration, not a dropped/no-op write: driving the - scheduled tick applies the sidecar exactly once. - */ _scheduled[0].Invoke(); - Assert.That( - _applyCount, - Is.EqualTo(1), - "OnValidate's deferred work must apply the sidecar exactly once when the editor " - + "tick runs (it must defer the real regeneration, not silently drop it)." + Assert.That(_responseCallbacks.Count, Is.EqualTo(1)); + _responseCallbacks[0].Invoke(); + CollectionAssert.AreEqual(sidecar, File.ReadAllBytes(SidecarPath)); + CollectionAssert.AreEqual( + response, + File.ReadAllBytes(Path.Combine(Application.dataPath, "csc.rsp")) ); + Assert.That(File.GetLastWriteTimeUtc(SidecarPath), Is.EqualTo(sidecarTime)); Assert.That( - _cscRspSyncCount, - Is.EqualTo(1), - "Once OnValidate's deferred sidecar write runs, csc.rsp must be resynchronized so " - + "the analyzer receives the generated sidecar during the same editor session." + File.GetLastWriteTimeUtc(Path.Combine(Application.dataPath, "csc.rsp")), + Is.EqualTo(responseTime) ); } [Test] - public void OnEnableDoesNotRegenerateSidecar() + public void OnValidateDefersSidecarRegenerationInsteadOfImportingSynchronously() { DxMessagingSettings settings = NewSettings(); + byte[] before = CurrentSidecar(); + InvokePrivate(settings, "OnValidate"); + CollectionAssert.AreEqual( + before, + CurrentSidecar(), + "OnValidate must not synchronously write or import compiler inputs." + ); + Assert.That(_scheduled.Count, Is.EqualTo(1)); + _scheduled[0].Invoke(); + AssertContent(settings); + Assert.That(_responseCallbacks.Count, Is.EqualTo(1)); + } + [Test] + public void OnEnableDoesNotRegenerateSidecar() + { + DxMessagingSettings settings = NewSettings(); + byte[] before = CurrentSidecar(); InvokePrivate(settings, "OnEnable"); - - /* - OnEnable fires on EVERY domain reload and play-mode entry. It must neither apply nor - schedule a regeneration: the on-disk sidecar is already consistent, and regenerating - here would churn the AssetDatabase on every reload (and reintroduce #210 risk). - */ - Assert.That(_applyCount, Is.EqualTo(0), "OnEnable must not apply the sidecar."); - Assert.That( - _scheduled.Count, - Is.EqualTo(0), - "OnEnable must not schedule a deferred sidecar regeneration." - ); + CollectionAssert.AreEqual(before, CurrentSidecar()); + Assert.That(_scheduled.Count, Is.Zero); + Assert.That(_responseCallbacks.Count, Is.Zero); } [Test] public void RegenerateSidecarDeferredNeverAppliesSynchronously() { DxMessagingSettings settings = NewSettings(); - + byte[] before = CurrentSidecar(); DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); - - Assert.That( - _applyCount, - Is.EqualTo(0), - "RegenerateSidecarDeferred must defer; it must never apply the sidecar synchronously." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "RegenerateSidecarDeferred must schedule exactly one deferred regeneration." - ); - - // Driving the deferred work (the next editor tick) performs the apply exactly once. + CollectionAssert.AreEqual(before, CurrentSidecar()); + Assert.That(_scheduled.Count, Is.EqualTo(1)); _scheduled[0].Invoke(); - Assert.That( - _applyCount, - Is.EqualTo(1), - "The deferred work must apply the sidecar exactly once when the editor tick runs." - ); - Assert.That( - _cscRspSyncCount, - Is.EqualTo(1), - "Deferred sidecar regeneration must schedule a csc.rsp sync after the sidecar " - + "apply runs." - ); + AssertContent(settings); + Assert.That(_responseCallbacks.Count, Is.EqualTo(1)); } [Test] - public void RegenerateSidecarDeferredRequeuesUntilEditorCanMutateAssets() + public void DeferredSidecarWorkRemainsQueuedUntilSharedIdleSchedulerAllowsIt() { - bool editorIdle = false; - DxMessagingBaseCallIgnoreSync.CanMutateAssetDatabase = () => editorIdle; DxMessagingSettings settings = NewSettings(); - + byte[] before = CurrentSidecar(); DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); - - Assert.That( - _applyCount, - Is.EqualTo(0), - "Deferred regeneration must not apply synchronously." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "Pre-condition: the first deferred tick must be scheduled." - ); - - _scheduled[0].Invoke(); - - Assert.That( - _applyCount, - Is.EqualTo(0), - "A deferred tick that fires while Unity is compiling/updating must not import the sidecar." - ); - Assert.That( - _cscRspSyncCount, - Is.EqualTo(0), - "A busy deferred tick must not resync csc.rsp before the sidecar apply runs." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(2), - "A deferred tick that fires while Unity is compiling/updating must requeue itself." - ); - - editorIdle = true; - _scheduled[1].Invoke(); - - Assert.That( - _applyCount, - Is.EqualTo(1), - "Once Unity is idle, the requeued callback must apply exactly once." - ); - Assert.That( - _cscRspSyncCount, - Is.EqualTo(1), - "Once the requeued sidecar apply succeeds, csc.rsp must be resynchronized exactly once." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(2), - "The idle apply must not keep rescheduling itself." + Assert.That(_scheduled.Count, Is.EqualTo(1)); + bool idle = false; + List ticks = new(); + DxMessagingEditorIdle.ScheduleAssetDatabaseMutation( + _scheduled[0], + ticks.Add, + () => idle ); + ticks[0](); + CollectionAssert.AreEqual(before, CurrentSidecar()); + Assert.That(ticks.Count, Is.EqualTo(2)); + idle = true; + ticks[1](); + AssertContent(settings); + Assert.That(ticks.Count, Is.EqualTo(2)); + Assert.That(_responseCallbacks.Count, Is.EqualTo(1)); } - /// - /// 2026-09-06: An ambient editor-update assumption skipped this test on Unity 2021 CI - /// even though SetUp controls the idle predicate and captures every side effect. - /// [Test] public void RegenerateSidecarAppliesSynchronouslyWhenEditorCanMutateAssets() { - /* - SetUp supplies an idle predicate and captures the apply and scheduling actions. - Explicit ignored-type edits must apply immediately under that controlled state. - */ DxMessagingSettings settings = NewSettings(); - + Assert.That(DxMessagingEditorIdle.CanMutateAssetDatabase(), Is.True); DxMessagingBaseCallIgnoreSync.RegenerateSidecar(settings); - - Assert.That( - _applyCount, - Is.EqualTo(1), - "RegenerateSidecar must apply synchronously when Unity is idle (immediate feedback " - + "for explicit Add/Remove-ignored-type actions)." - ); - Assert.That( - _scheduled.Count, - Is.EqualTo(0), - "RegenerateSidecar must not defer when Unity is idle." - ); - Assert.That( - _cscRspSyncCount, - Is.EqualTo(1), - "Synchronous sidecar regeneration must also schedule csc.rsp resynchronization." - ); + AssertContent(settings); + Assert.That(_scheduled.Count, Is.Zero); + Assert.That(_responseCallbacks.Count, Is.EqualTo(1)); } [Test] - public void RegenerateSidecarDefersWhenEditorCannotMutateAssets() + public void SynchronousSidecarEntryRetainsItsRealBusyAndDeferredGuards() { - bool editorIdle = false; - DxMessagingBaseCallIgnoreSync.CanMutateAssetDatabase = () => editorIdle; - DxMessagingSettings settings = NewSettings(); - - DxMessagingBaseCallIgnoreSync.RegenerateSidecar(settings); - - Assert.That( - _applyCount, - Is.EqualTo(0), - "RegenerateSidecar must not apply synchronously while Unity is compiling/updating." + MethodInfo entry = typeof(DxMessagingBaseCallIgnoreSync).GetMethod( + nameof(DxMessagingBaseCallIgnoreSync.RegenerateSidecar) ); + MethodBase[] calls = EditorProductionCalls.From(entry).ToArray(); Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "RegenerateSidecar must defer while Unity is compiling/updating." + calls.Any(call => + call.DeclaringType == typeof(DxMessagingEditorIdle) + && call.Name == "CanMutateAssetDatabase" + ), + Is.True ); - - editorIdle = true; - _scheduled[0].Invoke(); - Assert.That( - _applyCount, - Is.EqualTo(1), - "The deferred regeneration must apply once Unity becomes idle." + calls.Any(call => + call.DeclaringType == typeof(DxMessagingBaseCallIgnoreSync) + && call.Name == "RegenerateSidecarDeferred" + ), + Is.True ); Assert.That( - _cscRspSyncCount, - Is.EqualTo(1), - "The deferred regeneration must schedule csc.rsp resynchronization after applying." + calls.Any(call => + call.DeclaringType == typeof(DxMessagingBaseCallIgnoreSync) + && call.Name == "ApplySidecarAndScheduleCscRspSync" + ), + Is.True ); } @@ -770,61 +644,47 @@ public void RegenerateSidecarDefersWhenEditorCannotMutateAssets() public void RegenerateSidecarDeferredSkipsDestroyedSettings() { DxMessagingSettings settings = NewSettings(); + byte[] before = CurrentSidecar(); DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "Pre-condition: a deferred regeneration must have been scheduled." - ); - - // Simulate the asset being destroyed (deleted, or torn down) before the deferred tick. + Assert.That(_scheduled.Count, Is.EqualTo(1)); Object.DestroyImmediate(settings); _createdSettings.Remove(settings); - - Assert.DoesNotThrow( - () => _scheduled[0].Invoke(), - "Deferred regeneration must tolerate the captured settings asset being destroyed " - + "before the editor tick fires." - ); - Assert.That( - _applyCount, - Is.EqualTo(0), - "Deferred regeneration must skip the apply when the captured settings asset has " - + "been destroyed (Unity's lifetime-aware null check)." - ); - Assert.That( - _cscRspSyncCount, - Is.EqualTo(0), - "Destroyed settings must not trigger a csc.rsp sync because no sidecar apply ran." - ); + Assert.DoesNotThrow(() => _scheduled[0].Invoke()); + CollectionAssert.AreEqual(before, CurrentSidecar()); + Assert.That(_responseCallbacks.Count, Is.Zero); } private DxMessagingSettings NewSettings() { DxMessagingSettings settings = ScriptableObject.CreateInstance(); _createdSettings.Add(settings); - /* - CreateInstance can fire OnEnable/OnValidate; drain anything captured so each test - starts from a clean slate. - */ _scheduled.Clear(); - _applyCount = 0; - _cscRspSyncCount = 0; + _responseCallbacks.Clear(); + settings._baseCallIgnoredTypes = new List + { + "Owned.SidecarProbe" + Guid.NewGuid().ToString("N"), + }; return settings; } + private static byte[] CurrentSidecar() => + File.Exists(SidecarPath) ? File.ReadAllBytes(SidecarPath) : null; + + private static void AssertContent(DxMessagingSettings settings) => + Assert.That( + File.ReadAllText(SidecarPath), + Is.EqualTo( + DxMessagingBaseCallIgnoreSync.BuildContent(settings._baseCallIgnoredTypes) + ) + ); + private static void InvokePrivate(DxMessagingSettings settings, string methodName) { MethodInfo method = typeof(DxMessagingSettings).GetMethod( methodName, BindingFlags.Instance | BindingFlags.NonPublic ); - Assert.That( - method, - Is.Not.Null, - $"DxMessagingSettings.{methodName} was not found; if it was renamed, update this " - + "test (OnValidate/OnEnable are the #210-relevant deserialization callbacks)." - ); + Assert.That(method, Is.Not.Null); method.Invoke(settings, null); } } diff --git a/Tests/Editor/DxMessagingEmissionCaptureNoticeTests.cs b/Tests/Editor/DxMessagingEmissionCaptureNoticeTests.cs index f28913106..19dcb7b05 100644 --- a/Tests/Editor/DxMessagingEmissionCaptureNoticeTests.cs +++ b/Tests/Editor/DxMessagingEmissionCaptureNoticeTests.cs @@ -31,7 +31,6 @@ public void SetUp() [TearDown] public void TearDown() { - DxMessagingEmissionCaptureNotice.AssetDatabaseMutationScheduler = null; IMessageBus.GlobalDiagnosticsStackTraces = _captureBeforeTest; } @@ -230,7 +229,7 @@ public void FlowGraphKeepsRealCallSitesAndDropsPlaceholdersWithoutBlamingTheSett } [Test] - public void EnableCaptureTakesEffectImmediatelyAndPersistsToSettings() + public void EnableCaptureTakesEffectImmediatelyAndQueuesSettingsPersistence() { IMessageBus.GlobalDiagnosticsStackTraces = false; /* @@ -240,8 +239,7 @@ project settings asset in whichever editor runs this suite. What is testable her without that side effect is the contract that matters -- immediate in-memory effect, deferred durable write. */ - List scheduled = new(); - DxMessagingEmissionCaptureNotice.AssetDatabaseMutationScheduler = scheduled.Add; + using EditorDelayCallScope scheduled = new(typeof(DxMessagingEmissionCaptureNotice)); DxMessagingEmissionCaptureNotice.EnableCapture(); diff --git a/Tests/Editor/DxMessagingFlowGraphWindowTests.cs b/Tests/Editor/DxMessagingFlowGraphWindowTests.cs index c2484b873..bc4c8096d 100644 --- a/Tests/Editor/DxMessagingFlowGraphWindowTests.cs +++ b/Tests/Editor/DxMessagingFlowGraphWindowTests.cs @@ -11,7 +11,7 @@ namespace DxMessaging.Tests.Editor using Core.MessageBus; using Core.Messages; using DxMessaging.Editor; - using DxMessaging.Editor.Testing; + using DxMessaging.Editor.CustomEditors; using DxMessaging.Editor.Windows; using DxMessaging.Unity; using NUnit.Framework; @@ -1091,7 +1091,7 @@ public void BuildGraphUiCollapsesRouteEvidenceAndLinksMessageSource() ); if (messageSource == null) { - DxMessagingEditorSourceLinks.CompleteMessageSourceIndexesForTests(); + EditorSourceIndexInspection.Complete(); DxMessagingFlowGraphWindow.BuildGraphUi(root, snapshot, viewState); messageSource = root.Query