diff --git a/.cspell.json b/.cspell.json index 6f89b730b..9e0a44ddb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,7 +16,7 @@ ".devcontainer/**", ".gitignore", ".gitattributes", - ".vale/styles/Vocab/**/reject.txt", + ".vale/styles/config/vocabularies/**/reject.txt", "*.meta", "**/*.asset", "Samples~/**", diff --git a/.docs-tests/DocsObsoleteApiReferenceTests.cs b/.docs-tests/DocsObsoleteApiReferenceTests.cs index 2cef3893b..73bf2567c 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" }; private static readonly string[] ExpectedProbeMembers = { "Probe.Removed" }; // Resolve + parse once per test-run process: both tests share the same Roslyn scan of @@ -131,6 +133,60 @@ 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("ordinary")] + [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); + } + } + } + [TestCase("ordinary")] [TestCase(".artifacts")] public void ObsoleteScanExcludesGeneratedPathsRelativeToRepository(string parentName) diff --git a/.docs-tests/DocsSnippetCompilationTests.cs b/.docs-tests/DocsSnippetCompilationTests.cs index 51c57db1d..75b4d990b 100644 --- a/.docs-tests/DocsSnippetCompilationTests.cs +++ b/.docs-tests/DocsSnippetCompilationTests.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using NUnit.Framework; namespace WallstopStudios.DxMessaging.Docs.Tests; @@ -8,6 +11,341 @@ namespace WallstopStudios.DxMessaging.Docs.Tests; [TestFixture] internal sealed class DocsSnippetCompilationTests { + private static readonly CSharpParseOptions UnityReferenceParseOptions = new( + LanguageVersion.CSharp9, + preprocessorSymbols: new[] { "UNITY_2021_3_OR_NEWER" } + ); + + /// Keep bus reference declarations aligned with the actual public interface. + /// 2026-10-22: Stub compilation accepted obsolete Action return types in API declarations. + [TestCase("RegisterUntargetedInterceptor")] + [TestCase("RegisterTargetedInterceptor")] + [TestCase("RegisterBroadcastInterceptor")] + [TestCase("RegisterGlobalAcceptAll")] + [TestCase("Deregister")] + public void BusReferenceSignaturesMatchRuntimeInterface(string methodName) + { + AssertReferenceSignatures("IMessageBus", methodName); + } + + /// Preserve the names and constraints needed to call the printed token overloads. + /// 2026-10-22: Reference prototypes omitted constraints and used different parameter names. + [TestCase("RegisterUntargeted")] + [TestCase("RegisterUntargetedPostProcessor")] + [TestCase("RegisterGameObjectTargeted")] + [TestCase("RegisterComponentTargeted")] + [TestCase("RegisterTargeted")] + [TestCase("RegisterTargetedWithoutTargeting")] + [TestCase("RegisterTargetedPostProcessor")] + [TestCase("RegisterTargetedWithoutTargetingPostProcessor")] + [TestCase("RegisterGameObjectBroadcast")] + [TestCase("RegisterComponentBroadcast")] + [TestCase("RegisterBroadcast")] + [TestCase("RegisterBroadcastWithoutSource")] + [TestCase("RegisterBroadcastPostProcessor")] + [TestCase("RegisterBroadcastWithoutSourcePostProcessor")] + public void TokenReferenceSignaturesMatchRuntimeDeclarations(string methodName) + { + AssertReferenceSignatures("MessageRegistrationToken", methodName); + } + + private static void AssertReferenceSignatures(string declaringType, string methodName) + { + string root = ResolveRepoRoot(); + string referencePath = Path.Combine(root, "docs", "reference", "reference.md"); + string runtimePath = + declaringType == "IMessageBus" + ? Path.Combine(root, "Runtime", "Core", "MessageBus", "IMessageBus.cs") + : Path.Combine(root, "Runtime", "Core", "MessageRegistrationToken.cs"); + MethodDeclarationSyntax[] runtimeMethods = CSharpSyntaxTree + .ParseText(File.ReadAllText(runtimePath), UnityReferenceParseOptions) + .GetRoot() + .DescendantNodes() + .OfType() + .Where(method => + method.Identifier.ValueText == methodName + && method.Parent is TypeDeclarationSyntax owner + && owner.Identifier.ValueText == declaringType + ) + .ToArray(); + MethodDeclarationSyntax[] documentedMethods = ExtractCodeBlocks(referencePath, "csharp") + .SelectMany(snippet => + CSharpSyntaxTree + .ParseText( + "interface ReferenceSignatures {\n" + snippet + "\n}", + UnityReferenceParseOptions + ) + .GetRoot() + .DescendantNodes() + .OfType() + ) + .Where(method => method.Identifier.ValueText == methodName) + .ToArray(); + string label = referencePath + ": " + declaringType + "." + methodName; + Assert.That(documentedMethods, Is.Not.Empty, label + " must be documented"); + if (declaringType == "IMessageBus") + { + Assert.That(documentedMethods, Has.Length.EqualTo(1), label + " must appear once"); + } + foreach (MethodDeclarationSyntax documented in documentedMethods) + { + string[] parameterTypes = documented + .ParameterList.Parameters.Select(parameter => SignatureText(parameter.Type)) + .ToArray(); + MethodDeclarationSyntax[] matches = runtimeMethods + .Where(method => + method + .ParameterList.Parameters.Select(parameter => SignatureText(parameter.Type)) + .SequenceEqual(parameterTypes) + ) + .ToArray(); + Assert.That(matches, Has.Length.EqualTo(1), label + " must match one actual overload"); + MethodDeclarationSyntax runtimeMethod = matches[0]; + Assert.Multiple(() => + { + Assert.That( + SignatureText(documented.ReturnType), + Is.EqualTo(SignatureText(runtimeMethod.ReturnType)), + label + " return type" + ); + Assert.That( + SignatureText(documented.TypeParameterList), + Is.EqualTo(SignatureText(runtimeMethod.TypeParameterList)), + label + " type parameters" + ); + Assert.That( + SignatureText(documented.ParameterList), + Is.EqualTo(SignatureText(runtimeMethod.ParameterList)), + label + " parameters" + ); + Assert.That( + documented.ConstraintClauses.Select(SignatureText), + Is.EqualTo(runtimeMethod.ConstraintClauses.Select(SignatureText)), + label + " constraints" + ); + }); + } + } + + private static string SignatureText(SyntaxNode? node) + { + // The Unity reference shortens UnityEngine types while retaining their public names. + return node?.WithoutTrivia() + .NormalizeWhitespace() + .ToFullString() + .Replace("UnityEngine.", string.Empty, StringComparison.Ordinal) + ?? string.Empty; + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void MigrationEventBridgeOwnsItsEnabledLifetime(int publisherChange) + { + string migrationPath = Path.Combine(ResolveDocsRoot(), "guides", "migration-guide.md"); + string[] samples = System + .Text.RegularExpressions.Regex.Matches( + File.ReadAllText(migrationPath), + @"```csharp\s*\r?\n(?[\s\S]*?)```" + ) + .Select(match => match.Groups["code"].Value) + .Where(code => code.Contains("public class LegacyBridge :", StringComparison.Ordinal)) + .ToArray(); + Assert.That( + samples, + Has.Length.EqualTo(1), + "Expected exactly one documented LegacyBridge sample." + ); + + /* + 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 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 : UnityEngine.MonoBehaviour + { + public event System.Action OnSomethingHappened; + public int SubscriptionCount => OnSomethingHappened?.GetInvocationList().Length ?? 0; + public void Raise(int value) => OnSomethingHappened?.Invoke(value); + } + public struct SomethingHappened + { + public int Value; + public SomethingHappened(int value) { Value = value; } + } + public static class EmissionFixture + { + public static readonly System.Collections.Generic.List Values = new(); + public static void Emit(this ref SomethingHappened message) => Values.Add(message.Value); + } + """; + string[] platformAssemblies = ( + (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")! + ).Split(Path.PathSeparator); + Microsoft.CodeAnalysis.CSharp.CSharpCompilation compilation = + Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "MigrationBridgeLifecycle", + new[] + { + Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText( + "using UnityEngine;\n" + samples[0] + "\n" + fixtures + ), + }, + platformAssemblies.Select(path => + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(path) + ), + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary + ) + ); + using MemoryStream assemblyBytes = new(); + Microsoft.CodeAnalysis.Emit.EmitResult result = compilation.Emit(assemblyBytes); + Assert.That( + result.Success, + Is.True, + "The actual bridge sample must compile without ignored errors: " + + string.Join("\n", result.Diagnostics) + ); + assemblyBytes.Position = 0; + System.Runtime.Loader.AssemblyLoadContext loadContext = new( + "MigrationBridgeLifecycle", + isCollectible: true + ); + try + { + System.Reflection.Assembly assembly = loadContext.LoadFromStream(assemblyBytes); + Type bridgeType = assembly.GetType("LegacyBridge", throwOnError: true)!; + Type publisherType = assembly.GetType("LegacySystem", throwOnError: true)!; + object bridge = Activator.CreateInstance(bridgeType)!; + object originalPublisher = Activator.CreateInstance(publisherType)!; + object replacementPublisher = Activator.CreateInstance(publisherType)!; + const System.Reflection.BindingFlags instanceMembers = + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic; + System.Reflection.FieldInfo publisherField = bridgeType.GetField( + "legacySystem", + instanceMembers + )!; + System.Reflection.MethodInfo raise = publisherType.GetMethod("Raise")!; + System.Reflection.PropertyInfo subscriptions = publisherType.GetProperty( + "SubscriptionCount" + )!; + IList values = + (IList) + assembly.GetType("EmissionFixture")!.GetField("Values")!.GetValue(null)!; + int[] firstExpected = { 11 }; + publisherField.SetValue(bridge, originalPublisher); + bridgeType.GetMethod("Awake", instanceMembers)?.Invoke(bridge, null); + bridgeType.GetMethod("OnEnable", instanceMembers)?.Invoke(bridge, null); + Assert.That( + subscriptions.GetValue(originalPublisher), + Is.EqualTo(1), + "An enabled bridge needs exactly one subscription." + ); + raise.Invoke(originalPublisher, new object[] { 11 }); + Assert.That( + values, + Is.EqualTo(firstExpected), + "An enabled bridge must forward the original event payload exactly once." + ); + + object? nextPublisher = publisherChange switch + { + 1 => replacementPublisher, + 2 => null, + _ => 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 }); + Assert.Multiple(() => + { + Assert.That( + subscriptions.GetValue(originalPublisher), + Is.EqualTo(0), + "Disable must remove the original subscription after reassignment or publisher destruction." + ); + Assert.That( + values, + Is.EqualTo(firstExpected), + "Disabled bridges must not forward either publisher's events." + ); + }); + + bridgeType.GetMethod("OnEnable", instanceMembers)?.Invoke(bridge, null); + raise.Invoke(originalPublisher, new object[] { 44 }); + raise.Invoke(replacementPublisher, new object[] { 55 }); + int[] expected = publisherChange switch + { + 1 => new[] { 11, 55 }, + 2 or 3 => new[] { 11 }, + _ => new[] { 11, 44 }, + }; + Assert.That( + values, + Is.EqualTo(expected), + "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( + subscriptions.GetValue(originalPublisher), + Is.EqualTo(0), + "Final disable must leave no original-publisher subscription." + ); + Assert.That( + subscriptions.GetValue(replacementPublisher), + Is.EqualTo(0), + "Final disable must leave no replacement-publisher subscription." + ); + raise.Invoke(originalPublisher, new object[] { 66 }); + raise.Invoke(replacementPublisher, new object[] { 77 }); + Assert.That( + values, + Is.EqualTo(expected), + "Neither publisher may retain the bridge after final disable." + ); + } + finally + { + loadContext.Unload(); + } + } + [Test] public void DocsTestSdkAcceptsAvailableNet9FeatureBands() { @@ -1108,6 +1446,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", @@ -1117,7 +1575,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) { @@ -1133,7 +1595,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) @@ -1553,8 +2015,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) ) @@ -1624,7 +2092,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) @@ -1645,8 +2113,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")) { @@ -1708,7 +2182,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); @@ -1718,8 +2192,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"); 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/.docs-tests/RuntimeHashCompilationTests.cs b/.docs-tests/RuntimeHashCompilationTests.cs new file mode 100644 index 000000000..8d21db659 --- /dev/null +++ b/.docs-tests/RuntimeHashCompilationTests.cs @@ -0,0 +1,246 @@ +using System.Collections; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.Docs.Tests; + +/// +/// 2026-10-27: valid signatures overflowed when consumer compilation enabled integer +/// overflow checks. Compile the actual shipped key under both options and exercise +/// construction, equality, hash stability, dictionary lookup, and default/null contracts. +/// 2026-10-28: registration handles had the same implicit-wraparound defect; compile +/// the whole portable Runtime and preserve reference identity for custom handle state. +/// +[TestFixture] +internal sealed class RuntimeHashCompilationTests +{ + 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 readonly Lazy UncheckedRuntime = new(() => + CompilePortableRuntime(false) + ); + private static readonly Lazy CheckedRuntime = new(() => CompilePortableRuntime(true)); + + private static IEnumerable RegistrationCases() + { + foreach (bool checkOverflow in new[] { false, true }) + { + foreach (long id in new[] { 0L, 1L, long.MinValue, long.MaxValue }) + { + foreach (bool hasState in new[] { false, true }) + { + yield return new TestCaseData(checkOverflow, id, hasState); + } + } + } + } + + [TestCaseSource(nameof(RegistrationCases))] + public void ExternalRegistrationsRemainDictionaryKeys( + bool checkOverflow, + long id, + bool hasState + ) + { + Type keyType = GetPortableRuntime(checkOverflow) + .GetType("DxMessaging.Core.MessageBus.MessageBusRegistration", true)!; + ConstructorInfo constructor = keyType.GetConstructor( + new[] { typeof(long), typeof(object) } + )!; + IdentityOnlyState? state = hasState ? new IdentityOnlyState() : null; + object key = constructor.Invoke(new object?[] { id, state }); + object equalKey = constructor.Invoke(new object?[] { id, state }); + IdentityOnlyState otherState = new(); + object otherKey = constructor.Invoke(new object?[] { id, otherState }); + string label = $"checked={checkOverflow}; id={id}; state={hasState}"; + Assert.That(key, Is.EqualTo(equalKey), label); + Assert.That(key, Is.Not.EqualTo(otherKey), label); + int firstHash = key.GetHashCode(); + Assert.That(firstHash, Is.EqualTo(equalKey.GetHashCode()), label); + Assert.That(key.GetHashCode(), Is.EqualTo(firstHash), label); + AssertDictionaryRoundTrip(keyType, key, equalKey, label); + Assert.That(state?.HashCalls ?? 0, Is.Zero, label); + Assert.That(state?.EqualityCalls ?? 0, Is.Zero, label); + Assert.That(otherState.HashCalls, Is.Zero, label); + Assert.That(otherState.EqualityCalls, Is.Zero, label); + } + + [TestCase(false)] + [TestCase(true)] + public void DefaultRegistrationRemainsADictionaryKey(bool checkOverflow) + { + Type keyType = GetPortableRuntime(checkOverflow) + .GetType("DxMessaging.Core.MessageBus.MessageBusRegistration", true)!; + object key = Activator.CreateInstance(keyType)!; + object equalKey = Activator.CreateInstance(keyType)!; + string label = $"checked={checkOverflow}"; + Assert.That(key, Is.EqualTo(equalKey), label); + Assert.That(key.GetHashCode(), Is.EqualTo(equalKey.GetHashCode()), label); + AssertDictionaryRoundTrip(keyType, key, equalKey, label); + } + + [TestCase(false, "null")] + [TestCase(true, "null")] + [TestCase(false, "empty")] + [TestCase(true, "empty")] + [TestCase(false, "runtime")] + [TestCase(true, "runtime")] + [TestCase(false, "maximum-hash")] + [TestCase(true, "maximum-hash")] + public void ValidSignaturesRemainDictionaryKeys(bool checkOverflow, string shape) + { + Type keyType = GetPortableRuntime(checkOverflow) + .GetType("DxMessaging.Core.Messages.MethodSignatureKey", true)!; + ConstructorInfo constructor = keyType.GetConstructor( + new[] { typeof(string), typeof(Type[]) } + )!; + Type[]? parameterTypes = shape switch + { + "null" => null, + "empty" => Type.EmptyTypes, + "runtime" => new[] { typeof(int), typeof(string), typeof(object) }, + "maximum-hash" => new Type[] { new MaximumHashType(), new MaximumHashType() }, + _ => throw new ArgumentOutOfRangeException( + nameof(shape), + shape, + "Unknown signature shape." + ), + }; + object key = constructor.Invoke(new object?[] { "Receive", parameterTypes }); + object equalKey = constructor.Invoke(new object?[] { "Receive", parameterTypes }); + Assert.That(key, Is.EqualTo(equalKey), $"checked={checkOverflow}; shape={shape}"); + Assert.That( + key.GetHashCode(), + Is.EqualTo(equalKey.GetHashCode()), + $"checked={checkOverflow}; shape={shape}" + ); + + AssertDictionaryRoundTrip( + keyType, + key, + equalKey, + $"checked={checkOverflow}; shape={shape}" + ); + } + + [TestCase(false)] + [TestCase(true)] + public void DefaultAndNullNameContractsSurviveCompilation(bool checkOverflow) + { + Type keyType = GetPortableRuntime(checkOverflow) + .GetType("DxMessaging.Core.Messages.MethodSignatureKey", true)!; + ConstructorInfo constructor = keyType.GetConstructor( + new[] { typeof(string), typeof(Type[]) } + )!; + object defaultKey = Activator.CreateInstance(keyType)!; + Assert.That( + defaultKey, + Is.EqualTo(Activator.CreateInstance(keyType)), + $"checked={checkOverflow}" + ); + Assert.That(defaultKey.GetHashCode(), Is.Zero, $"checked={checkOverflow}"); + object constructedKey = constructor.Invoke(new object?[] { "", Type.EmptyTypes }); + Assert.That(defaultKey, Is.Not.EqualTo(constructedKey), $"checked={checkOverflow}"); + TargetInvocationException? exception = Assert.Throws(() => + constructor.Invoke(new object?[] { null, Type.EmptyTypes }) + ); + Assert.That( + exception!.InnerException, + Is.TypeOf(), + $"checked={checkOverflow}" + ); + Assert.That( + ((ArgumentNullException)exception.InnerException!).ParamName, + Is.EqualTo("methodName"), + $"checked={checkOverflow}" + ); + } + + private static void AssertDictionaryRoundTrip( + Type keyType, + object key, + object equalKey, + string label + ) + { + IDictionary dictionary = (IDictionary) + Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(keyType, typeof(int)))!; + dictionary.Add(key, 42); + Assert.That(dictionary[equalKey], Is.EqualTo(42), label); + dictionary.Remove(equalKey); + Assert.That(dictionary.Count, Is.Zero, label); + } + + private static Assembly GetPortableRuntime(bool checkOverflow) => + (checkOverflow ? CheckedRuntime : UncheckedRuntime).Value; + + private static Assembly CompilePortableRuntime(bool checkOverflow) + { + 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."); + CSharpParseOptions parseOptions = new(LanguageVersion.CSharp9); + /* Match the maintained portable project's recursive Runtime C# inputs, without Unity symbols. */ + SyntaxTree[] trees = Directory + .EnumerateFiles( + Path.Combine(directory!.FullName, "Runtime"), + "*.cs", + SearchOption.AllDirectories + ) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => CSharpSyntaxTree.ParseText(File.ReadAllText(path), parseOptions, path)) + .ToArray(); + CSharpCompilation compilation = CSharpCompilation.Create( + "PortableRuntime_" + Guid.NewGuid().ToString("N"), + trees, + References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + optimizationLevel: OptimizationLevel.Release, + checkOverflow: checkOverflow, + allowUnsafe: true + ) + ); + using MemoryStream stream = new(); + Microsoft.CodeAnalysis.Emit.EmitResult result = compilation.Emit(stream); + Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, result.Diagnostics)); + return Assembly.Load(stream.ToArray()); + } + + private sealed class IdentityOnlyState + { + public int HashCalls { get; private set; } + public int EqualityCalls { get; private set; } + + public override int GetHashCode() + { + HashCalls++; + return 17; + } + + public override bool Equals(object? other) + { + EqualityCalls++; + return other is IdentityOnlyState; + } + } + + private sealed class MaximumHashType : TypeDelegator + { + public MaximumHashType() + : base(typeof(int)) { } + + public override int GetHashCode() => int.MaxValue; + } +} diff --git a/.docs-tests/RuntimeXmlDocumentationTests.cs b/.docs-tests/RuntimeXmlDocumentationTests.cs new file mode 100644 index 000000000..235bf5cf5 --- /dev/null +++ b/.docs-tests/RuntimeXmlDocumentationTests.cs @@ -0,0 +1,189 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.Docs.Tests; + +[TestFixture] +internal sealed class RuntimeXmlDocumentationTests +{ + [TestCase( + false, + false, + TestName = "Runtime XML grammar, attachment and parameter tags: plain .NET" + )] + [TestCase( + true, + false, + TestName = "Runtime XML grammar, attachment and parameter tags: Unity player projection" + )] + [TestCase( + true, + true, + TestName = "Runtime XML grammar, attachment and parameter tags: Unity Editor projection" + )] + public void RuntimeDocumentationHasValidXmlAndParameterDocumentation(bool unity, bool editor) + { + string root = ResolveRepoRoot(); + string[] symbols = GetSymbols(unity, editor); + SyntaxTree[] trees = Directory + .EnumerateFiles(Path.Combine(root, "Runtime"), "*.cs", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => Parse(File.ReadAllText(path), path, symbols)) + .ToArray(); + + ImmutableArray diagnostics = GetDocumentationStructureDiagnostics(trees); + + Assert.That( + diagnostics, + Is.Empty, + string.Join( + Environment.NewLine, + diagnostics.Select(diagnostic => diagnostic.ToString()) + ) + ); + } + + [TestCase( + "/// A & B.\npublic sealed class Sample { }", + "CS1570", + TestName = "Runtime XML guard rejects unescaped text" + )] + [TestCase( + "[System.Obsolete]\n/// Sample.\npublic sealed class Sample { }", + "CS1587", + TestName = "Runtime XML guard rejects documentation after an attribute" + )] + [TestCase( + "/// Sample.\n[System.Obsolete]\npublic sealed class Sample { }", + "", + TestName = "Runtime XML guard accepts documentation before attributes" + )] + [TestCase( + "/// List<string> holds A & B.\npublic sealed class Sample { }", + "", + TestName = "Runtime XML guard accepts escaped generic and text characters" + )] + [TestCase( + "/// Sample.\npublic sealed class Sample {\n/// Input.\npublic void Run(int value) { }\n}", + "CS1572", + TestName = "Runtime XML guard rejects an unknown parameter tag" + )] + [TestCase( + "/// Sample.\npublic sealed class Sample {\n/// First input.\npublic void Run(int first, int second) { }\n}", + "CS1573", + TestName = "Runtime XML guard rejects a partially documented parameter list" + )] + [TestCase( + "/// Sample.\n/// Type.\npublic sealed class Sample { }", + "CS1711", + TestName = "Runtime XML guard rejects an unknown type parameter tag" + )] + [TestCase( + "/// Sample.\n/// First type.\npublic sealed class Sample { }", + "CS1712", + TestName = "Runtime XML guard rejects a partially documented type parameter list" + )] + [TestCase( + "/// Sample.\n/// Type.\npublic sealed class Sample {\n/// Run.\n/// Method type.\n/// Input.\npublic void Run(int value) { }\n}", + "", + TestName = "Runtime XML guard accepts matching parameter and type parameter tags" + )] + [TestCase( + "public sealed class Sample { public int Value { get; } }", + "CS1591", + TestName = "Runtime XML guard rejects missing public documentation" + )] + public void GuardDistinguishesInvalidDocumentation(string source, string expectedCode) + { + SyntaxTree tree = Parse(source, "Control.cs", Array.Empty()); + ImmutableArray diagnostics = GetDocumentationStructureDiagnostics( + new[] { tree } + ); + + if (expectedCode.Length == 0) + { + Assert.That(diagnostics, Is.Empty); + } + else + { + Assert.That( + diagnostics, + Has.Some.Matches(diagnostic => + diagnostic is not null && diagnostic.Id == expectedCode + ) + ); + } + } + + private static SyntaxTree Parse(string source, string path, string[] symbols) + { + CSharpParseOptions options = new( + LanguageVersion.CSharp9, + documentationMode: DocumentationMode.Diagnose, + preprocessorSymbols: symbols + ); + return CSharpSyntaxTree.ParseText(source, options, path); + } + + private static ImmutableArray GetDocumentationStructureDiagnostics( + IEnumerable trees + ) + { + CSharpCompilation compilation = CSharpCompilation.Create( + "RuntimeXmlGrammar", + syntaxTrees: trees, + references: new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + /* + These checks cover XML grammar, attachment, tag names, partial lists and public comment presence. + Member links and supported Unity compilation require complete reference inputs. + */ + return compilation + .GetDiagnostics() + .Where(diagnostic => + diagnostic.Id + is "CS1570" + or "CS1587" + or "CS1572" + or "CS1573" + or "CS1711" + or "CS1712" + or "CS1591" + ) + .ToImmutableArray(); + } + + private static string[] GetSymbols(bool unity, bool editor) + { + if (!unity) + { + return Array.Empty(); + } + + return editor + ? new[] { "UNITY_2021_3_OR_NEWER", "UNITY_6000_4_OR_NEWER", "UNITY_EDITOR" } + : new[] { "UNITY_2021_3_OR_NEWER", "UNITY_6000_4_OR_NEWER" }; + } + + private static string ResolveRepoRoot() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "package.json"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} 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/.github/actions/redact-unity-artifacts/action.yml b/.github/actions/redact-unity-artifacts/action.yml index 30572f602..c37ccdac8 100644 --- a/.github/actions/redact-unity-artifacts/action.yml +++ b/.github/actions/redact-unity-artifacts/action.yml @@ -12,6 +12,10 @@ inputs: Newline-separated directories to scrub. A directory that does not exist is skipped, so one call can cover every test mode a job may or may not run. required: true + parallel-paths: + description: Newline-separated disjoint paths to process with up to four workers; empty uses paths sequentially. + required: false + default: "" runs: using: composite steps: @@ -22,6 +26,7 @@ runs: shell: pwsh env: DXM_REDACT_PATHS: ${{ inputs.paths }} + DXM_REDACT_PARALLEL_PATHS: ${{ inputs.parallel-paths }} run: | $ErrorActionPreference = 'Stop' $paths = @( @@ -34,9 +39,45 @@ runs: } Write-Host "::group::Redacting sensitive data from Unity artifacts" try { - node scripts/unity/redact-unity-artifacts.js @paths - if ($LASTEXITCODE -ne 0) { - throw "Sensitive-data redaction failed with exit code $LASTEXITCODE." + $parallelPaths = @( + $env:DXM_REDACT_PARALLEL_PATHS -split "`n" | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) + if ($parallelPaths.Count -ne 0) { + $roots = @($parallelPaths | ForEach-Object { + [System.IO.Path]::TrimEndingDirectorySeparator([System.IO.Path]::GetFullPath($_)) + }) + $comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + for ($i = 0; $i -lt $roots.Count; $i++) { + for ($j = 0; $j -lt $i; $j++) { + $left = $roots[$i].TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + $right = $roots[$j].TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($left.StartsWith($right, $comparison) -or $right.StartsWith($left, $comparison)) { + throw 'Parallel artifact paths must not overlap.' + } + } + } + $processor = (Resolve-Path -LiteralPath 'scripts/unity/redact-unity-artifacts.js').Path + $outcomes = @($roots | ForEach-Object -Parallel { + $ErrorActionPreference = 'Stop' + try { + node $using:processor $_ 2>&1 | ForEach-Object { Write-Host "$_" } + [pscustomobject]@{ Succeeded = ($LASTEXITCODE -eq 0) } + } catch { + Write-Host "Artifact processing worker failed: $_" + [pscustomobject]@{ Succeeded = $false } + } + } -ThrottleLimit 4) + $failed = @($outcomes | Where-Object { -not $_.Succeeded }) + if ($outcomes.Count -ne $roots.Count -or $failed.Count -ne 0) { + throw 'Sensitive-data redaction failed in one or more workers.' + } + } else { + node scripts/unity/redact-unity-artifacts.js @paths + if ($LASTEXITCODE -ne 0) { + throw "Sensitive-data redaction failed with exit code $LASTEXITCODE." + } } } finally { Write-Host '::endgroup::' diff --git a/.github/analyzers/README.md b/.github/analyzers/README.md index 38796aef1..b39d9cd2b 100644 --- a/.github/analyzers/README.md +++ b/.github/analyzers/README.md @@ -8,6 +8,11 @@ closure into the disposable test project's `Assets` tree. `csc.rsp` registers the files for the existing full-source compile, where `-warnaserror` makes every enabled diagnostic blocking. +A portable analyzer run over test sources does not analyze referenced production +assemblies. Local production checks must compile the production sources with the +pinned analyzer closure, Unity references, symbols and configuration. Full EditMode +compilation remains required to verify the actual host configuration. + `DxMessaging.StaticAnalysis.globalconfig`, the repository `.editorconfig`, and `DxMessaging.CiProject.editorconfig` form the text-only analyzer policy. The EditorConfig files keep test, Editor, and copied-sample exceptions path-scoped; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6acc82aa..4a2635f6b 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 19b2aec17..999787720 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 @@ -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 diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index c597ba725..76567dacd 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 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 }} @@ -1541,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 \ @@ -1551,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 \ @@ -1568,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/.github/workflows/release.yml b/.github/workflows/release.yml index 8d5128db8..cd23d31eb 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 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/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index d55743a6b..9ccc7edd6 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 + 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 + 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@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22.23.4" + + - 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 + 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@20077985093aac0e88f88f931ebedae78c1c9284 + 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@20077985093aac0e88f88f931ebedae78c1c9284 + 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@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 + 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@20077985093aac0e88f88f931ebedae78c1c9284 + 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + 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/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index bd7665367..239f44ce1 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -40,7 +40,7 @@ jobs: required-label-sets: '[["self-hosted","Windows","RAM-64GB"]]' benchmarks: - name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} ${{ matrix.benchmark-scope }} needs: - runner-preflight runs-on: [self-hosted, Windows, RAM-64GB] @@ -49,14 +49,26 @@ jobs: fail-fast: false max-parallel: 1 matrix: + # Complete Editor coverage first, before the longer PlayMode timing selections. + test-mode: + - editmode + - playmode unity-version: - 2021.3.45f1 - 2022.3.45f1 - 6000.3.16f1 - 6000.5.2f1 - test-mode: - - editmode - - playmode + # Complete PlayMode selections exceed the existing 600-second editor watchdog. + # Split every case by PerfBench membership, then the cardinality fixture. + benchmark-scope: + - coverage + - cardinality + - remaining + exclude: + - test-mode: editmode + benchmark-scope: cardinality + - test-mode: editmode + benchmark-scope: remaining steps: # Contract item 3: the central immutable gate validates the runner-owned # editor before any credential reference or checkout. CI never installs, @@ -166,7 +178,9 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 with: lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + require-resource-lifecycle: "true" + minimum-release-cooldown-seconds: "0" + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} runner-id: ${{ runner.name }} github-token: ${{ github.token }} timeout-minutes: "300" @@ -192,6 +206,12 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} DX_PERF_COMMIT: ${{ github.sha }} + DXM_UNITY_TEST_CATEGORY: ${{ matrix.test-mode == 'playmode' && (matrix.benchmark-scope == 'coverage' && '!PerfBench' || 'PerfBench') || '' }} + DXM_UNITY_TEST_FILTER: >- + ${{ matrix.benchmark-scope == 'cardinality' && + 'DxMessaging.Tests.Runtime.Benchmarks.HandlerCardinalityBenchmarks' || + (matrix.benchmark-scope == 'remaining' && + '!DxMessaging.Tests.Runtime.Benchmarks.HandlerCardinalityBenchmarks' || '') }} run: | # Release (not Debug) code optimization for the editmode/playmode perf # legs, mirroring the repo-wide Unity Release-mode contract so the @@ -205,7 +225,7 @@ jobs: -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` - -ArtifactsPath '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -ArtifactsPath '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }}' ` -ProjectPath $projectPath ` -CachePath $cachePath ` -LicenseReturnOwner Central ` @@ -226,8 +246,8 @@ jobs: timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: - results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} - label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} + label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} ${{ matrix.benchmark-scope }} # CLASS GUARD: Unity can fail before NUnit writes results. The shared # composite fails unless a results XML exists AND reports total > 0. @@ -249,8 +269,8 @@ jobs: timeout-minutes: 10 uses: ./.github/actions/verify-unity-results with: - results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} - label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} + label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} ${{ matrix.benchmark-scope }} expected-empty: ${{ steps.compute.outputs.is-empty }} - name: Redact sensitive data from benchmark artifacts @@ -269,8 +289,8 @@ jobs: timeout-minutes: 10 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }} - path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} + path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} if-no-files-found: warn retention-days: 90 @@ -305,7 +325,7 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 with: lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ matrix.benchmark-scope }} runner-id: ${{ runner.name }} resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 565e71531..b953fc814 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -217,6 +217,24 @@ jobs: lfs: true persist-credentials: false + - name: Select project cache from tracked input paths + id: source_cache + timeout-minutes: 2 + shell: pwsh + run: | + $sourceFiles = @(git ls-files -- Runtime Editor Tests 'Samples~') + if ($LASTEXITCODE -ne 0 -or $sourceFiles.Count -eq 0) { + throw 'Cannot select a Unity project cache without tracked package input paths.' + } + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes(($sourceFiles -join "`n")) + $sourceSetHash = [System.BitConverter]::ToString($hasher.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + } finally { + $hasher.Dispose() + } + "DXM_UNITY_SOURCE_SET_KEY=$($sourceSetHash.Substring(0, 8))" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Print runner diagnostics timeout-minutes: 2 # Composite action runs PowerShell internally; `shell: bash` on this @@ -325,6 +343,8 @@ jobs: uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@20077985093aac0e88f88f931ebedae78c1c9284 # v1.17.1 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 @@ -371,6 +391,7 @@ jobs: -LicenseReturnOwner $licenseReturnOwner } else { $projectPath = Join-Path $env:RUNNER_WORKSPACE 'dxm-u\t\${{ matrix.unity-version }}-editmode' + $projectPath = "${projectPath}-$env:DXM_UNITY_SOURCE_SET_KEY" $cachePath = Join-Path $env:RUNNER_WORKSPACE 'dxm-c\${{ matrix.unity-version }}' ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` @@ -401,6 +422,7 @@ jobs: DXM_UNITY_TEST_CATEGORY: "!Stress;!Performance;!Allocation;!MemoryReclaim;!PerfBench;!PerfGate;!PerfBaseline" run: | $projectPath = Join-Path $env:RUNNER_WORKSPACE 'dxm-u\t\${{ matrix.unity-version }}-playmode' + $projectPath = "${projectPath}-$env:DXM_UNITY_SOURCE_SET_KEY" $cachePath = Join-Path $env:RUNNER_WORKSPACE 'dxm-c\${{ matrix.unity-version }}' ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` @@ -430,6 +452,7 @@ jobs: DXM_UNITY_TEST_CATEGORY: "!Stress;!Performance;!Allocation;!MemoryReclaim;!PerfBench;!PerfGate;!PerfBaseline" run: | $projectPath = Join-Path $env:RUNNER_WORKSPACE 'dxm-u\t\${{ matrix.unity-version }}-standalone' + $projectPath = "${projectPath}-$env:DXM_UNITY_SOURCE_SET_KEY" $cachePath = Join-Path $env:RUNNER_WORKSPACE 'dxm-c\${{ matrix.unity-version }}' ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` @@ -592,6 +615,10 @@ jobs: with: paths: | .artifacts/unity + parallel-paths: | + ${{ !inputs.shipping_fidelity && !inputs.consumer_install_validation && format('.artifacts/unity/{0}-editmode', matrix.unity-version) || '' }} + ${{ !inputs.shipping_fidelity && !inputs.consumer_install_validation && format('.artifacts/unity/{0}-playmode', matrix.unity-version) || '' }} + ${{ !inputs.shipping_fidelity && !inputs.consumer_install_validation && format('.artifacts/unity/{0}-standalone', matrix.unity-version) || '' }} # Content-addressed retention for issue #508. The manifest names every evidence # file with its SHA-256 and embeds the normalized matrix result, so the uploaded @@ -650,10 +677,13 @@ jobs: retention-days: 14 - name: Upload shipping-fidelity artifacts + # A failed producer retains diagnostics under a distinct artifact name. + # Successful evidence still requires sealing and replay. if: >- ${{ steps.redact_tests.outcome == 'success' && - steps.seal_shipping.outcome == 'success' && + (steps.run_shipping.outcome == 'failure' || + (steps.run_shipping.outcome == 'success' && steps.seal_shipping.outcome == 'success')) && always() && !cancelled() && steps.acquire_lock.outputs.acquired == 'true' && @@ -664,7 +694,7 @@ jobs: timeout-minutes: 10 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: unity-${{ matrix.unity-version }}-shipping + name: unity-${{ matrix.unity-version }}-${{ steps.run_shipping.outcome == 'success' && 'shipping' || 'shipping-failure-diagnostics' }} path: .artifacts/unity/${{ matrix.unity-version }}-shipping include-hidden-files: true if-no-files-found: error diff --git a/.llm/context.md b/.llm/context.md index 943032f47..a93685b49 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -38,7 +38,7 @@ index to select and read the canonical instructions. - 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. - 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-cli2`); 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. @@ -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 @@ -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-cli2` 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/allocation-and-leak-coverage/SKILL.md b/.llm/skills/allocation-and-leak-coverage/SKILL.md index 115d67757..ece8abf2a 100644 --- a/.llm/skills/allocation-and-leak-coverage/SKILL.md +++ b/.llm/skills/allocation-and-leak-coverage/SKILL.md @@ -45,15 +45,16 @@ DxMessaging promises zero managed allocations on the steady-state dispatch path. ### LeakWatcher is the only leak-detection mechanism -- `Tests/Runtime/TestUtilities/LeakWatcher.cs` snapshots six public `IMessageBus` counters - `RegisteredUntargeted`, `RegisteredTargeted`, `RegisteredBroadcast`, `RegisteredInterceptors`, `RegisteredPostProcessors`, `RegisteredGlobalAcceptAll` - and asserts on `Dispose` that they returned to their starting values, with a counter-by-counter diff in the failure message. Do not re-implement counter math inline. +- `Tests/Runtime/TestUtilities/LeakWatcher.cs` captures six public `IMessageBus` counter pairs and requires every pair to return to baseline. It also uses the built-in bus's cold internal live-registration count, including shared-priority callbacks and duplicate reference counts. The forwarding `DelegatingMessageBus` test double exposes its accounting source; independent-storage doubles can opt out. Do not re-implement counter math inline. +- Arbitrary custom buses expose only their public counter granularity. A count check cannot prove ownership when one registration replaces another without changing any observed count. Pair it with exact callback/ownership assertions; keep retained storage and object references separate. - Default form is `using (LeakWatcher.Watch(label: scenario.DisplayName)) { ... }`. To inspect without failing, construct with `throwOnLeak: false` and read `LeakedRegistrations` before disposal. - Pass `handler: handler` to the constructor or `WatchWithSlots(bus, ...)` when testing typed-handler reclamation. This adds a cold internal query for retained context keys and scalar/context priority caches on that bus. `LeakedHandlerContexts` and `LeakedHandlerPriorityCaches` remain separate from registration and bus-slot deltas; disposal freezes the counts. Default watchers do not query handler storage. -- `Snapshot` and `LeakedRegistrations` are O(types); optional handler queries also traverse that handler's typed slots and contexts. Capture at region boundaries, not in each iteration or dispatch. Keep the watcher out of Allocation and Performance fixtures. GameObject and NativeArray leaks are out of scope. -- Adding a seventh registration kind means updating, in lock-step: `IMessageBus`, `Tests/Runtime/Core/Snapshots/public-surface.txt`, `LeakWatcher` in three places (`_initialXxx`/`_finalXxx` fields, the `Snapshot` sum, the `TotalDelta` parameter list plus its message format), a row in `LeakWatcherSelfTests`, and the Public-Counter Contract section of the reference. `PublicSurfaceContractTests.PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot` catches the drift first. +- `Snapshot` and live `LeakedRegistrations` reads traverse the built-in bus's registration stores or the custom bus's public counters; optional handler queries also traverse typed slots and contexts. Capture at region boundaries, not in each iteration or dispatch. Disposal freezes the recorded delta. Keep the watcher out of Allocation and Performance fixtures. GameObject and NativeArray leaks are out of scope. +- Adding a seventh registration kind means updating, in lock-step: `IMessageBus`, `Tests/Runtime/Core/Snapshots/public-surface.txt`, `LeakWatcher` snapshots, `Snapshot` fallback, individual-pair invariant and message format, plus the built-in live-registration query, a row in `LeakWatcherSelfTests`, and the Public-Counter Contract section of the reference. The public type-set snapshot checks types; the new registration regression must prove that counter and reference accounting include the new kind. ## References | Document | Purpose | | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [allocation-coverage-required-for-dispatch.md](./references/allocation-coverage-required-for-dispatch.md) | The allocation matrix contract: zero and bounded rows, `AllocationProbe` measurement rules, differential/structural/private-holder guards, and when a STATE or RESULT assertion replaces a count budget. | -| [leak-watcher-usage.md](./references/leak-watcher-usage.md) | `LeakWatcher` public-counter contract, usage patterns, O(types) cost, self-tests, and the procedure for adding a counter. | +| [leak-watcher-usage.md](./references/leak-watcher-usage.md) | `LeakWatcher` live-registration and public-counter contract, accounting limits, cold-query cost, self-tests, and the procedure for adding a counter. | 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 1e41d7bb2..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 @@ -1,15 +1,16 @@ # LeakWatcher: Detecting Registration Leaks in Tests > **One-line summary**: Any test that creates and tears down message -> registrations should bracket the work in a `LeakWatcher` to assert no -> registrations survive the watched region. +> registrations should bracket the work in a `LeakWatcher` to check that +> registration counts return to baseline. Pin ownership with behavior assertions. ## Overview `LeakWatcher` (`Tests/Runtime/TestUtilities/LeakWatcher.cs`) is an `IDisposable` that snapshots every public registration counter on -`IMessageBus` at construction and asserts on `Dispose` that the counters -returned to their starting values. It is the canonical leak-detection +`IMessageBus` and, for built-in buses, a cold live-registration count. `Dispose` +requires each public counter and the registration total to return to baseline. +It is the canonical leak-detection mechanism for the test suite; do not re-implement the counter math inline. @@ -24,17 +25,36 @@ used by diagnostics. ## Public-Counter Contract -Default registration accounting is read-only and uses the six public bus -counters above. Bus slot checks use the public occupancy counters. Neither -reflects hidden bus fields. Optional handler-storage accounting uses the cold -internal query described below; it does not change the public counter contract. - -If a future bus revision introduces a seventh registration kind, BOTH -`LeakWatcher.Snapshot` and `LeakWatcher.LeakedRegistrations` must be -extended in lock-step so total leak deltas remain correct. The drift is -caught by -`Tests/Runtime/Core/PublicSurfaceContractTests.cs::PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot`, -which fails when the public type set drifts from the committed snapshot. +The public counter checks retain their existing semantics and use all six pairs +above. Normal-handler and postprocessor counters count route/priority containers, +so several callbacks at one priority can leave those counters unchanged. An equal +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 +`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 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. + +Arbitrary custom buses supply only their public counters. Their granularity may +miss additions within existing containers. Equal-count replacements within one +category also escape any count-only check. Assert exact callback traces and +registration ownership alongside the watcher. The watcher does not prove that +captured objects or dispatch snapshots have been reclaimed. + +Bus slot checks use the public occupancy counters. Optional handler-storage +accounting uses the cold internal query described below. These remain separate +from live-registration accounting. + +If a future bus revision introduces another registration kind, extend the public +counter snapshots, individual-pair invariant, diagnostic message, `Snapshot` +custom-bus fallback and built-in live-registration query together. Add a regression +that leaves the new kind live above a nonempty baseline. The public type-set +snapshot detects type changes; it does not check counter properties or whether +leak accounting includes them. ## Usage Patterns @@ -130,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. @@ -146,10 +166,12 @@ watchers neither traverse nor enforce handler storage. ## Cost: Capture at Region Boundaries -Both `Snapshot` and `LeakedRegistrations` walk every per-message-type -cache backing `IMessageBus.RegisteredInterceptors` and -`IMessageBus.RegisteredPostProcessors`. Each access is O(types). Snapshot -at region boundaries; do NOT read `Snapshot` inside a tight loop. The +For a built-in bus, `Snapshot` and live `LeakedRegistrations` reads traverse stored +handler and interceptor registration entries across occupied routes and types. +Reference counts are summed; duplicate registrations need no extra traversal. +Custom-bus fallback reads all six public counters, including their per-type walks. +Disposal and diagnostics also capture the public counter pairs. Snapshot at region +boundaries; do NOT poll the watcher inside a tight loop. The suite's wall-clock budget is 60 s soft / 180 s hard (`Tests/Runtime/Core/SuiteWallClockBudgetTest.cs`). @@ -178,6 +200,11 @@ handler check detects retained storage, and that trim restores the baseline. kinds, shared priorities, multiple message types, simultaneous handler and postprocessor slots, an unused bus, and isolation between two buses. +`RegistrationLeakAccountingTests` also covers nonempty baselines on every normal +and postprocessor route, shared and distinct handlers, priorities, forwarding +buses, duplicate callback/interceptor/global reference counts, balanced cross-kind +changes, frozen disposal diagnostics and the opaque custom-bus accounting limit. + ## Adding a New Counter When the bus grows a new public registration counter: @@ -185,16 +212,26 @@ When the bus grows a new public registration counter: 1. Extend `IMessageBus` with the new property; add it to `Tests/Runtime/Core/Snapshots/public-surface.txt` (the committed snapshot consumed by `PublicSurfaceContractTests`). -1. Add the counter to `LeakWatcher` in three places: `_initialXxx` / - `_finalXxx` fields, the `Snapshot` sum, and the `TotalDelta` parameter - list. Extend the failure-message format string so leak diagnostics - include the new pair. +1. Add the counter to `LeakWatcher` snapshots, `Snapshot` custom-bus fallback, + individual-pair invariant and diagnostic message. Extend the built-in + live-registration query when the new kind uses a new registration store. 1. Add a test row in `LeakWatcherSelfTests` exercising the new counter. 1. Update this skill's "Public-Counter Contract" section. -A skipped watcher extension under-counts silently; the public-surface -snapshot test catches the drift first, and a self-test that registers -exclusively against the new counter fails loudly otherwise. +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 @@ -203,7 +240,7 @@ exclusively against the new counter fails loudly otherwise. - For unrelated resources. The watcher covers bus accounting and optional typed-handler storage; GameObject and NativeArray leaks are out of scope. - For benchmark hot paths. Allocation / Performance fixtures avoid the - watcher because the per-call O(types) cost shows up in measurements. + watcher because registration-store traversal changes the measurement. ## See Also 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/documentation-style/SKILL.md b/.llm/skills/documentation-style/SKILL.md index 3bb45600d..dca042c66 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-cli2 `. - 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..5af1a9203 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-cli2 ` ### 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-cli2` passes for changed docs ## Performance Notes 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/.llm/skills/unity-editor-ci/SKILL.md b/.llm/skills/unity-editor-ci/SKILL.md index 454b4bdf2..5dd3be2c1 100644 --- a/.llm/skills/unity-editor-ci/SKILL.md +++ b/.llm/skills/unity-editor-ci/SKILL.md @@ -14,7 +14,11 @@ job runs `editmode`, `playmode`, and `standalone` as separate invocations under one lock and cleanup window. The oldest and current endpoint jobs also run the Minimal, Low, Medium, and High shipping-fidelity cells. All player modes build `StandaloneWindows64` IL2CPP players from runner-local projects under -`$RUNNER_WORKSPACE/dxm-u/t/-/`. +`$RUNNER_WORKSPACE/dxm-u/t/--/` for ordinary correctness runs. +The workflow derives the short key from tracked Runtime, Editor, Tests, and Samples input paths. +Content edits reuse the project; added, removed, or renamed paths select a new project so cached +compiler graphs cannot retain deleted sources. Existing projects stay intact. Performance and +manual shipping project paths keep their separate cache scopes. ## When to use diff --git a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md index 8b7c01b26..c61c19709 100644 --- a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md +++ b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md @@ -39,7 +39,7 @@ proof but wrote unusable evidence is listed under `unreadableEvidenceCells`, not `failedCells`, so a malformed diagnostic file never reads as a stripping regression. Workflow cancellation or the 150-minute step timeout still aborts the group. The direct runner generates a package host project under -`$RUNNER_WORKSPACE/dxm-u/t/-/`, imports the repo package with a +`$RUNNER_WORKSPACE/dxm-u/t/--/`, imports the repo package with a `file:` dependency, and configures IL2CPP before either player build. Test hosts set `testables`; the shipping host omits test packages and `testables`. Dispatch runs use the same complete static matrix. @@ -89,13 +89,21 @@ Runner administrators manually install every exact editor and required module un `unity-benchmarks.yml` (active; manual-only, NEVER on PRs): -| Axis | Values | -| --------------- | ----------------------- | -| `unity-version` | every canonical version | -| `test-mode` | `editmode`, `playmode` | +| Axis | Values | +| ----------------- | -------------------------------------- | +| `unity-version` | every canonical version | +| `test-mode` | `editmode`, `playmode` | +| `benchmark-scope` | `coverage`, `cardinality`, `remaining` | The active `unity-benchmarks.yml` explicitly omits `pull_request` and `push` per the perf isolation rule. +Each version runs one complete EditMode job and three PlayMode jobs: non-`PerfBench` +coverage, `PerfBench` cardinality, and the remaining `PerfBench` cases. The `benchmark-scope` +axis excludes the two extra EditMode combinations. Category membership and the exact +cardinality fixture name define complementary selections. Keep the 600-second editor +watchdog, benchmark windows and all cases; partition work instead of extending the cap. +Include `benchmark-scope` in lock-holder and artifact identities. + ## compute-unity-assemblies is-empty Gate Every workflow that consumes `./.github/actions/compute-unity-assemblies` gives each Unity test invocation a stable compute id. Grouped correctness uses `compute`, `compute_playmode`, and `compute_standalone`; each test run receives only its matching assembly output through an environment variable. Test work skips when its selection is empty. The endpoint-only shipping group is not test-assembly-discovery gated. Lock acquisition remains unconditional because the static matrix is structurally non-empty and the organization analyzer must prove each acquisition. When asmdef discovery resolves no owned assemblies, verification treats the empty selection as an intentional skip while the terminal return/classify/release/gate chain still proves cleanup. 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: 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/.llm/skills/unity-test-execution/references/upm-test-harness.md b/.llm/skills/unity-test-execution/references/upm-test-harness.md index 4d14739c8..b26023a48 100644 --- a/.llm/skills/unity-test-execution/references/upm-test-harness.md +++ b/.llm/skills/unity-test-execution/references/upm-test-harness.md @@ -63,12 +63,26 @@ The generated manifest shape: } ``` +## Committed Local Project + +`.unity-test-project` is a separate local host pinned to the release Editor version. Its +`file:../..` dependency resolves from `Packages/` to the repository root. Keep its Editor revision, +package sources, and locked dependency maps consistent with that exact Editor distribution. +Use compatible registry packages for Test Framework and NUnit; newer Editors can embed versions +that the release Editor does not contain. Check package minimum Editor versions before upgrading +IDE integrations, and retain every transitive dependency declared by enabled built-in modules. + +The CI runner creates its own manifest. `-GenerateOnly` checks that generated project shape; +it does not resolve or import the committed local project. Verify a clean local import separately. + ## What to Commit vs Gitignore Committed source of truth: - `scripts/unity/run-ci-tests.ps1` - package source at `Runtime/`, `Editor/`, `Tests/` +- `.unity-test-project/Packages/manifest.json` and `packages-lock.json` +- `.unity-test-project/ProjectSettings/ProjectVersion.txt` Generated and ignored: 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/.net-tests/DxMessaging.Net.Tests.csproj b/.net-tests/DxMessaging.Net.Tests.csproj index 7395bffef..bd66d2787 100644 --- a/.net-tests/DxMessaging.Net.Tests.csproj +++ b/.net-tests/DxMessaging.Net.Tests.csproj @@ -5,6 +5,41 @@ false + + + + + + + + + + + @@ -22,14 +57,175 @@ Include="../Tests/Runtime/TestUtilities/ScenarioHarness.cs" Link="ScenarioHarness.cs" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.net-tests/RuntimeContractTests.cs b/.net-tests/RuntimeContractTests.cs index 8e26c3c54..488c75f9a 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/.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/.unity-test-project/Packages/manifest.json b/.unity-test-project/Packages/manifest.json index 9f25967a9..5364af2b5 100644 --- a/.unity-test-project/Packages/manifest.json +++ b/.unity-test-project/Packages/manifest.json @@ -2,8 +2,7 @@ "dependencies": { "com.unity.ide.rider": "3.0.40", "com.unity.ide.visualstudio": "2.0.28", - "com.unity.multiplayer.center": "1.0.1", - "com.unity.test-framework": "1.6.0", + "com.unity.test-framework": "1.4.6", "com.unity.test-framework.performance": "3.6.0", "com.wallstop-studios.dxmessaging": "file:../..", "com.cysharp.messagepipe": "1.8.2", diff --git a/.unity-test-project/Packages/packages-lock.json b/.unity-test-project/Packages/packages-lock.json index edc6e4956..855d45d82 100644 --- a/.unity-test-project/Packages/packages-lock.json +++ b/.unity-test-project/Packages/packages-lock.json @@ -3,8 +3,9 @@ "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, - "source": "builtin", - "dependencies": {} + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" }, "com.unity.ide.rider": { "version": "3.0.40", @@ -24,23 +25,16 @@ }, "url": "https://packages.unity.com" }, - "com.unity.multiplayer.center": { - "version": "1.0.1", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.uielements": "1.0.0" - } - }, "com.unity.test-framework": { - "version": "1.6.0", + "version": "1.4.6", "depth": 0, - "source": "builtin", + "source": "registry", "dependencies": { "com.unity.ext.nunit": "2.0.3", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0" - } + }, + "url": "https://packages.unity.com" }, "com.unity.test-framework.performance": { "version": "3.6.0", @@ -143,7 +137,14 @@ "version": "1.0.0", "depth": 0, "source": "builtin", - "dependencies": {} + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.unitywebrequestassetbundle": "1.0.0", + "com.unity.modules.unitywebrequestaudio": "1.0.0", + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.assetbundle": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0" + } }, "com.unity.modules.assetbundle": { "version": "1.0.0", @@ -151,12 +152,6 @@ "source": "builtin", "dependencies": {} }, - "com.unity.modules.hierarchycore": { - "version": "1.0.0", - "depth": 2, - "source": "builtin", - "dependencies": {} - }, "com.unity.modules.imgui": { "version": "1.0.0", "depth": 1, @@ -169,15 +164,9 @@ "source": "builtin", "dependencies": {} }, - "com.unity.modules.physics": { - "version": "1.0.0", - "depth": 2, - "source": "builtin", - "dependencies": {} - }, "com.unity.modules.ui": { "version": "1.0.0", - "depth": 2, + "depth": 1, "source": "builtin", "dependencies": {} }, @@ -188,10 +177,38 @@ "dependencies": { "com.unity.modules.ui": "1.0.0", "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.hierarchycore": "1.0.0", - "com.unity.modules.physics": "1.0.0" + "com.unity.modules.jsonserialize": "1.0.0" } + }, + "com.unity.modules.unitywebrequestassetbundle": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.modules.assetbundle": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + } + }, + "com.unity.modules.unitywebrequestaudio": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.audio": "1.0.0" + } + }, + "com.unity.modules.audio": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.imageconversion": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} } } } diff --git a/.unity-test-project/ProjectSettings/ProjectVersion.txt b/.unity-test-project/ProjectSettings/ProjectVersion.txt index 3b73dd930..29c4eeec4 100644 --- a/.unity-test-project/ProjectSettings/ProjectVersion.txt +++ b/.unity-test-project/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ m_EditorVersion: 2022.3.45f1 -m_EditorVersionWithRevision: 2022.3.45f1 (a4ad03d2bff8) +m_EditorVersionWithRevision: 2022.3.45f1 (a13dfa44d684) diff --git a/.vale/styles/Vocab/DxMessaging/accept.txt b/.vale/styles/config/vocabularies/DxMessaging/accept.txt similarity index 94% rename from .vale/styles/Vocab/DxMessaging/accept.txt rename to .vale/styles/config/vocabularies/DxMessaging/accept.txt index 03a78bd00..7c9aab2a2 100644 --- a/.vale/styles/Vocab/DxMessaging/accept.txt +++ b/.vale/styles/config/vocabularies/DxMessaging/accept.txt @@ -25,7 +25,7 @@ csharpier markdownlint yamllint cspell -markdownlint-cli2 +markdownlint-cli preflight OpenUPM VContainer @@ -50,9 +50,8 @@ ScriptableObject ScriptableObjects GFM yaml -markdownlint-cli2 +markdownlint-cli runtime -dxmessaging wallstop WallstopStudios SourceGenerator @@ -62,3 +61,4 @@ MkDocs ASCII ASCII-only nofilter +deregistration diff --git a/.vale/styles/Vocab/DxMessaging/reject.txt b/.vale/styles/config/vocabularies/DxMessaging/reject.txt similarity index 100% rename from .vale/styles/Vocab/DxMessaging/reject.txt rename to .vale/styles/config/vocabularies/DxMessaging/reject.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 005701b88..cc4250fd8 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 @@ -17,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Correct IntelliSense summaries and references, including cache enumeration, bus reset behavior, + global observer message and context parameters, empty-bus debug logging, and deregistration callback boundaries + ([#503](https://github.com/Ambiguous-Interactive/DxMessaging/issues/503), + [#575](https://github.com/Ambiguous-Interactive/DxMessaging/issues/575)). +- **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 @@ -46,9 +56,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Keep failed registration cleanup retryable when diagnostic formatting or a log receiver throws - or reenters the token ([#663](https://github.com/Ambiguous-Interactive/DxMessaging/issues/663)). - +- Prevent registration-handle hashing from throwing in builds with integer overflow checks + ([#691](https://github.com/Ambiguous-Interactive/DxMessaging/issues/691)). + +- Preserve component cleanup ownership when deregistration callbacks release or register work + for the same listener. Failed cleanup remains publicly retryable + ([#679](https://github.com/Ambiguous-Interactive/DxMessaging/issues/679)). +- Clean up subscriptions added by diagnostics after a failed registration build, preserving + a retryable lease when cleanup fails ([#677](https://github.com/Ambiguous-Interactive/DxMessaging/issues/677)). + +- Keep lease cleanup retryable when callbacks add subscriptions during disposal, and prevent + duplicate activation notifications ([#676](https://github.com/Ambiguous-Interactive/DxMessaging/issues/676)). + +- Clean up subscriptions when registration-builder setup callbacks throw, preserving a retryable + lease when cleanup fails ([#675](https://github.com/Ambiguous-Interactive/DxMessaging/issues/675)). + +- Preserve activation when a custom bus registration callback calls `Enable()` on the same token. + The owning replay completes without restarting its registration queue, including during retargeting + ([#671](https://github.com/Ambiguous-Interactive/DxMessaging/issues/671)). + +- Stop reflexive dispatch when a diagnostic receiver resets the bus; newly registered handlers + wait for the next emission ([#670](https://github.com/Ambiguous-Interactive/DxMessaging/issues/670)). + +- Preserve retryable cleanup across diagnostic callbacks and keep a logger's successful `Disable()` + effective during retarget recovery ([#663](https://github.com/Ambiguous-Interactive/DxMessaging/issues/663), + [#668](https://github.com/Ambiguous-Interactive/DxMessaging/issues/668)). + +- Fix `InstanceId` XML and JSON data contract serialization by storing the numeric `id` member on its backing field + ([#662](https://github.com/Ambiguous-Interactive/DxMessaging/issues/662)). + +- **BREAKING:** Make `InstanceId`, `MessageRegistrationHandle`, and `MessageHandler` comparisons + follow `IComparable`: values sort after null, and unrelated argument types throw + `ArgumentException` ([#661](https://github.com/Ambiguous-Interactive/DxMessaging/issues/661)). + +- Stop teardown from attaching pending context cleanup to a typed slot retired by a reentrant + bus reset ([#660](https://github.com/Ambiguous-Interactive/DxMessaging/issues/660)). + +- Keep reflexive signature keys stable after caller-owned type arrays change, safe for default/null arrays, + and usable in checked builds; resolve reflected overloads from the captured signature + ([#659](https://github.com/Ambiguous-Interactive/DxMessaging/issues/659)). + +- Resolve custom bus cache indices before registration so a throwing or resetting getter cannot + leave an inaccessible subscription. Avoid emission getters during registration and teardown + ([#655](https://github.com/Ambiguous-Interactive/DxMessaging/issues/655), + [#669](https://github.com/Ambiguous-Interactive/DxMessaging/issues/669)). + +- Stop diagnostic call-count storage from growing with handles removed during callbacks, while + preserving completed deliveries in bounded history + ([#674](https://github.com/Ambiguous-Interactive/DxMessaging/issues/674)). +- Preserve token diagnostic origins when callbacks rebind the token, including pending callbacks + and nested deliveries ([#654](https://github.com/Ambiguous-Interactive/DxMessaging/issues/654)). +- Release consumed typed teardown state after a token disables successfully, preserving staged + callbacks for re-enabling while allowing unrelated users of recycled maps to collect + ([#653](https://github.com/Ambiguous-Interactive/DxMessaging/issues/653)). +- Release recycled context maps from consumed keyed registration handles so retaining an old + value cannot keep a later unrelated bus and receiver alive + ([#652](https://github.com/Ambiguous-Interactive/DxMessaging/issues/652)). +- Stop targeted and broadcast messages after a keyed handler resets the bus, before any + without-context handler runs ([#651](https://github.com/Ambiguous-Interactive/DxMessaging/issues/651)). +- Preserve outer global accept-all callbacks when nested emissions change their registrations + ([#650](https://github.com/Ambiguous-Interactive/DxMessaging/issues/650)). +- Stop the current emission when a global callback resets the bus, so replacement callbacks + receive only fresh emissions ([#649](https://github.com/Ambiguous-Interactive/DxMessaging/issues/649)). +- Clear deregistered handler caches during static reset so empty storage is reclaimed and + fresh global registrations cannot replay disposed callbacks + ([#647](https://github.com/Ambiguous-Interactive/DxMessaging/issues/647)). +- Preserve bus cache and generated lease owner identities across static resets so independent + buses keep their callbacks separate and new leases on a retained custom bus receive messages + ([#645](https://github.com/Ambiguous-Interactive/DxMessaging/issues/645), [#646](https://github.com/Ambiguous-Interactive/DxMessaging/issues/646)). +- Apply runtime memory settings on an idle Editor update and clarify the counter-based idle-age + tooltip ([#644](https://github.com/Ambiguous-Interactive/DxMessaging/issues/644), + [#575](https://github.com/Ambiguous-Interactive/DxMessaging/issues/575)). +- Preserve token cleanup and retryable failures during custom-bus callbacks, including activation + that reuses a staged handle while older cleanup runs + ([#642](https://github.com/Ambiguous-Interactive/DxMessaging/issues/642), [#673](https://github.com/Ambiguous-Interactive/DxMessaging/issues/673)). + +- 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 + ([#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 + 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 + ([#623](https://github.com/Ambiguous-Interactive/DxMessaging/issues/623)). +- 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 @@ -60,9 +159,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 explicit bus and provider are assigned ([#572](https://github.com/Ambiguous-Interactive/DxMessaging/issues/572)). - Make null provider and empty provider-handle configuration replace a prior explicit component bus with the global fallback ([#572](https://github.com/Ambiguous-Interactive/DxMessaging/issues/572)). -- Fix `MessagingComponent` retaining registrations after destruction and delivering to inactive - hosts before activation ([#542](https://github.com/Ambiguous-Interactive/DxMessaging/issues/542), - [#543](https://github.com/Ambiguous-Interactive/DxMessaging/issues/543)). +- Fix `MessagingComponent` retaining registrations after destruction, delivering to inactive + hosts before activation, and losing listener configuration when callbacks release, replace, or + reconfigure listener owners ([#542](https://github.com/Ambiguous-Interactive/DxMessaging/issues/542), + [#543](https://github.com/Ambiguous-Interactive/DxMessaging/issues/543), + [#683](https://github.com/Ambiguous-Interactive/DxMessaging/issues/683)). - Fix base-call ignore compiler wiring and batch preparation while preserving consumer options ([#537](https://github.com/Ambiguous-Interactive/DxMessaging/issues/537), 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/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs index d493ff7f2..c10e76906 100644 --- a/Editor/Analyzers/DxMessagingConsoleHarvester.cs +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -189,6 +189,7 @@ does not expose the expected LogEntries shape. private static double _lastTickTime; private static int _lastSeenCount; + private static int _lastSettingsRevision = -1; /* Latch flipped on by `OnAssemblyCompilationFinished` to coalesce the burst of one-event- @@ -267,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; @@ -372,10 +373,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 +406,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 +415,9 @@ and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. return; } + int settingsRevision = DxMessagingSettings.SettingsRevision; DxMessagingSettings settings = TryLoadSettings(); + _lastSettingsRevision = settingsRevision; if (settings != null && !settings._baseCallCheckEnabled) { bool wasNonEmpty = 0 < SnapshotInternal.Count; @@ -831,30 +830,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 +864,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 +1033,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/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 0c298369d..79ec54c58 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,87 @@ 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 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 +177,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 +192,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 +278,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 +294,61 @@ 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 + ) + { + 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); + 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 +379,9 @@ internal static DxMessagingSettings GetOrCreateSettings() } AssetDatabase.CreateAsset(settings, SettingsPath); AssetDatabase.SaveAssets(); + _cachedSettings = settings; + _cachedSettingsPath = SettingsPath; + _cachedSettingsRevision = SettingsRevision; } if (settings.ApplyLegacyDiagnosticsMigration()) @@ -301,6 +430,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/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta similarity index 83% rename from Runtime/Core/MessageBus/Internal/ISweepable.cs.meta rename to Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta index 0ebc06285..deb543851 100644 --- a/Runtime/Core/MessageBus/Internal/ISweepable.cs.meta +++ b/Editor/Settings/DxMessagingSettingsAssetPostprocessor.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d1e4e9a7e52243f7b7d42a1ef4f54152 +guid: 723fec350c874576b730d9b1afe030e2 MonoImporter: externalObjects: {} serializedVersion: 2 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 68dcf9f8e..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 ); @@ -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/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 69d0af80e..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; @@ -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 ); @@ -2022,7 +2022,7 @@ private static ComponentMonitorEntry CreateComponentMonitorEntry( MessagingComponent component ) { - MessagingComponentInspectorState state = MessagingComponentEditorHarness.Capture( + MessagingComponentInspectorState state = MessagingComponentInspectorSnapshot.Capture( component, resolveSerializedProviderBus: false ); diff --git a/README.md b/README.md index 7be0c8ab0..af5eae17f 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ See the [Install Guide](docs/getting-started/install.md) for all options includi ### 2. Define a Damage Command +> **Handler version:** These examples use the 4.0 `in` signature. With a 3.x package, use +> `ref DamageRequested message` for the receiver callback parameter instead. Keep the registration +> and emission calls unchanged. See the [4.0 migration guide](docs/guides/migration-guide.md#migrating-to-400). + ```csharp using DxMessaging.Core.Attributes; @@ -544,7 +548,7 @@ invocation itself can be allocation-free. void OnDamage(in ApplyDamage msg) { // Pass by in = zero allocations health -= msg.amount; // No boxing, no GC pressure } -// Automatic cleanup prevents common leak patterns +// MessageAwareComponent owns cleanup; plain C# owners dispose their tokens. ``` **Note:** Struct messages passed by readonly reference avoid copies without allocating. @@ -715,36 +719,49 @@ Inspector diagnostics, and Project Settings together. ### Local Bus Islands for Testing -**The problem with normal events:** Global static events contaminate tests. Mock complexity. Flaky tests. +Global static subscriptions can survive a test and affect later tests. -**DxMessaging solution:** Each test gets its own isolated message bus: +Pass a local bus to each participant, enable its token, and dispose the token when the test ends: ```csharp -[Test] -public void TestAchievementSystem() { - // Create isolated bus - zero global state - var testBus = new MessageBus(); - var handler = new MessageHandler(new InstanceId(1), testBus) { active = true }; - var token = MessageRegistrationToken.Create(handler, testBus); +using DxMessaging.Core; +using DxMessaging.Core.MessageBus; +using DxMessaging.Core.Messages; +using NUnit.Framework; + +public sealed class AchievementMessagingTests +{ + private readonly struct EnemyKilled : IBroadcastMessage + { + public string Name { get; } + public EnemyKilled(string name) { Name = name; } + } - // Test in isolation - _ = token.RegisterBroadcastWithoutSource(achievements.OnKill); + [Test] + public void KillMessageReachesLocalSubscriber() + { + MessageBus testBus = new MessageBus(); + MessageHandler handler = new MessageHandler(new InstanceId(1), testBus) { active = true }; + using (MessageRegistrationToken token = MessageRegistrationToken.Create(handler, testBus)) + { + bool received = false; + _ = token.RegisterBroadcastWithoutSource( + (in InstanceId context, in EnemyKilled killed) => received = killed.Name == "Boss"); + token.Enable(); - var gameObject = new GameObject(); - var msg = new EnemyKilled("Boss"); - msg.EmitGameObjectBroadcast(gameObject, testBus); // Only this test sees it + EnemyKilled message = new EnemyKilled("Boss"); + InstanceId source = new InstanceId(2); + testBus.SourcedBroadcast(ref source, ref message); - Assert.IsTrue(achievements.Unlocked("BossSlayer")); + Assert.That(received, Is.True); + } + } } -// Bus destroyed, zero cleanup needed ``` -#### Why this matters - -- Tests don't interfere with each other -- No "arrange/act/cleanup" boilerplate -- No mocking frameworks needed -- Parallel test execution is supported +The local bus isolates message routing. The `using` block removes registrations even if the assertion +fails. This example needs no Unity object. Local buses still share runtime settings and pools: run +Unity messaging tests on the main thread and avoid concurrent access to that shared state. ## Documentation diff --git a/Runtime/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Runtime/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index efe0a104f..2bce57064 100644 Binary files a/Runtime/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll and b/Runtime/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll differ diff --git a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs index 8531f237b..802ff6836 100644 --- a/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs +++ b/Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs @@ -4,6 +4,10 @@ namespace DxMessaging.Core.Configuration using MessageBus; #if UNITY_2021_3_OR_NEWER using UnityEngine; +#if UNITY_EDITOR + using System.Collections.Concurrent; + using System.Threading; +#endif /// /// Runtime-loaded settings asset that controls memory-reclamation policy and @@ -31,7 +35,7 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject /// Default soft cap on retained entries in shared pools and the per-bus priority-leaf spare. public const int DefaultBufferMaxDistinctEntries = 512; - /// Default idle threshold in seconds before an empty slot is eligible for eviction. + /// Default counter-age budget for empty slots; the legacy setting name retains Seconds. public const float DefaultIdleEvictionSeconds = 30f; /// Default minimum interval between idle sweeps, in seconds. @@ -39,7 +43,7 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject [SerializeField] [Tooltip( - "Idle threshold in seconds. Empty per-message-type slots are evicted only after going at least this long without a register/deregister/dispatch touch. See IdleEvictionSeconds." + "Legacy-named idle-age budget in bus activity ticks, rounded up. Empty slots become eligible when their age exceeds this budget. Wall time controls sweep cadence only. See IdleEvictionSeconds." )] [Min(0f)] internal float _idleEvictionSeconds = DefaultIdleEvictionSeconds; @@ -92,7 +96,11 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject private bool _isFallbackInstance; - /// Idle threshold in seconds. See _idleEvictionSeconds. + /// Counter-age budget for empty slots, rounded up; the legacy name retains Seconds. + /// + /// Bus register, deregister and emit operations advance age. A due PlayerLoop sweep also + /// advances it once. Wall time controls sweep cadence; it does not measure this age. + /// public float IdleEvictionSeconds => _idleEvictionSeconds; /// Per-pool retained-entry cap. See _bufferMaxDistinctEntries. @@ -119,15 +127,14 @@ public sealed class DxMessagingRuntimeSettings : ScriptableObject internal bool IsFallbackInstance => _isFallbackInstance; /// - /// Raised when this asset is mutated in the editor or via test-only override. - /// Subscribers should be small and re-entrancy-safe; the event is invoked - /// synchronously from OnValidate. + /// Raised on an idle Editor update after this asset is validated. + /// Repeated validations coalesce until that update; subscribers read the latest values. + /// Subscribers should be small and re-entrancy-safe. /// 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) { @@ -146,6 +153,19 @@ private static void ClearSubscribersOnLoad() } #if UNITY_EDITOR + private static readonly ConcurrentQueue PendingValidations = + new ConcurrentQueue(); + + [NonSerialized] + private int _validationPending; + + [UnityEditor.InitializeOnLoadMethod] + private static void RegisterValidationUpdates() + { + UnityEditor.EditorApplication.update -= ProcessPendingValidations; + UnityEditor.EditorApplication.update += ProcessPendingValidations; + } + private void OnValidate() { if (_idleEvictionSeconds < 0f) @@ -168,6 +188,49 @@ private void OnValidate() { _registrationLogCapacity = 0; } + + /* + Validation can run during loading. Queue only managed state here; asset + inspection and subscribers that mutate live buses run on the Editor thread. + */ + if (Interlocked.CompareExchange(ref _validationPending, 1, 0) == 0) + { + PendingValidations.Enqueue(this); + } + } + + private static void ProcessPendingValidations() + { + if ( + UnityEditor.EditorApplication.isUpdating + || UnityEditor.EditorApplication.isCompiling + ) + { + return; + } + + /* + Bound this update to the requests already queued. A subscriber can validate + again; that new request belongs to the next update, preventing recursive drain. + */ + int count = PendingValidations.Count; + for (int i = 0; i < count; ++i) + { + if (!PendingValidations.TryDequeue(out DxMessagingRuntimeSettings settings)) + { + break; + } + + Interlocked.Exchange(ref settings._validationPending, 0); + if (settings != null && !settings.IsFallbackInstance) + { + settings.ApplyValidatedSettings(); + } + } + } + + private void ApplyValidatedSettings() + { string assetPath = UnityEditor.AssetDatabase.GetAssetPath(this); if ( !string.IsNullOrEmpty(assetPath) 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/CyclicBuffer.cs b/Runtime/Core/DataStructure/CyclicBuffer.cs index f0b847845..3b7987328 100644 --- a/Runtime/Core/DataStructure/CyclicBuffer.cs +++ b/Runtime/Core/DataStructure/CyclicBuffer.cs @@ -70,7 +70,7 @@ public void Dispose() { } /// Maximum number of elements retained in the buffer. public int Capacity { get; private set; } - /// Current number of elements stored (<= ). + /// Current number of elements stored (<= ). public int Count { get; private set; } private readonly List _buffer; 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/Diagnostics/MessageEmissionData.cs b/Runtime/Core/Diagnostics/MessageEmissionData.cs index 413f07014..bed984444 100644 --- a/Runtime/Core/Diagnostics/MessageEmissionData.cs +++ b/Runtime/Core/Diagnostics/MessageEmissionData.cs @@ -13,16 +13,16 @@ namespace DxMessaging.Core.Diagnostics /// Captures a snapshot of a message emission for diagnostics. /// /// - /// When diagnostics are enabled (see ), + /// When diagnostics are enabled (see ), /// the bus and tokens record recent emissions in ring buffers. Each record also carries a trimmed /// stack trace that excludes DxMessaging internals, but ONLY when - /// is enabled; capturing a trace + /// is enabled; capturing a trace /// costs hundreds of microseconds and tens of allocations per record, so is /// by default. /// /// The contains the relevant for targeted/broadcast messages /// (target or source respectively) and is null for untargeted messages. Runtime records emitted by a - /// also carry a that token-local delivery records can + /// also carry a that token-local delivery records can /// use to join a bus emission to the registrations that observed it. /// public readonly struct MessageEmissionData @@ -37,7 +37,7 @@ public readonly struct MessageEmissionData /// /// Trimmed stack trace captured at the emission site, or when - /// is disabled (the default). + /// is disabled (the default). /// public readonly string stackTrace; diff --git a/Runtime/Core/DxMessagingStaticState.cs b/Runtime/Core/DxMessagingStaticState.cs index 998983521..4f31fd7b2 100644 --- a/Runtime/Core/DxMessagingStaticState.cs +++ b/Runtime/Core/DxMessagingStaticState.cs @@ -14,10 +14,10 @@ namespace DxMessaging.Core /// can cause issues if stale state is not cleared. /// /// - /// Important: Message type sequential IDs (managed by MessageHelperIndexer) - /// and opaque registration handle IDs are intentionally NOT reset. Once an ID is assigned, it - /// remains consumed for the lifetime of the application domain. This prevents stale handles or - /// new message types from colliding with identities issued before a reset. + /// Important: Message type sequential IDs (managed by MessageHelperIndexer), + /// bus cache indices, generated lease owner IDs, and opaque registration handle IDs are + /// intentionally NOT reset. Once an ID is assigned, it remains consumed for the lifetime of the + /// application domain. This keeps retained buses and generated owners separate from new ones. /// /// public static class DxMessagingStaticState @@ -31,10 +31,11 @@ static DxMessagingStaticState() } /// - /// Resets all static variables in DxMessaging to their default values. + /// Resets shared configuration and clears the built-in default bus. /// /// - /// Message type and registration handle IDs are NOT reset by this method. See the class + /// Message type IDs, bus cache indices, generated owner IDs, and registration handle IDs are + /// NOT reset. See the class /// remarks for details. /// public static void Reset() @@ -47,13 +48,10 @@ public static void Reset() IMessageBus.GlobalDiagnosticsTargets = Baseline.GlobalDiagnosticsTargets; IMessageBus.GlobalDiagnosticsStackTraces = Baseline.GlobalDiagnosticsStackTraces; IMessageBus.GlobalMessageBufferSize = Baseline.GlobalMessageBufferSize; - IMessageBus.GlobalSequentialIndex = Baseline.GlobalSequentialIndex; #if UNITY_2021_3_OR_NEWER - DxMessagingRuntimeSettingsProvider.ResetForTests(); + DxMessagingRuntimeSettingsProvider.ResetCachedSettings(); #endif - MessageRegistrationBuilder.SetSyntheticOwnerCounter(Baseline.SyntheticOwnerCounter); - /* Capture the active global bus before ResetStatics swaps it back to the default instance. If a user installed a custom global bus via SetGlobalMessageBus, we @@ -86,17 +84,13 @@ private static BaselineState CaptureBaseline() DiagnosticsTarget globalDiagnosticsTargets = IMessageBus.GlobalDiagnosticsTargets; bool globalDiagnosticsStackTraces = IMessageBus.GlobalDiagnosticsStackTraces; int globalMessageBufferSize = IMessageBus.GlobalMessageBufferSize; - int globalSequentialIndex = IMessageBus.GlobalSequentialIndex; - int syntheticOwnerCounter = MessageRegistrationBuilder.GetSyntheticOwnerCounter(); return new BaselineState( messagingDebugEnabled, messagingDebugLogFunction, globalDiagnosticsTargets, globalDiagnosticsStackTraces, - globalMessageBufferSize, - globalSequentialIndex, - syntheticOwnerCounter + globalMessageBufferSize ); } @@ -107,9 +101,7 @@ internal BaselineState( Action messagingDebugLogFunction, DiagnosticsTarget globalDiagnosticsTargets, bool globalDiagnosticsStackTraces, - int globalMessageBufferSize, - int globalSequentialIndex, - int syntheticOwnerCounter + int globalMessageBufferSize ) { MessagingDebugEnabled = messagingDebugEnabled; @@ -117,8 +109,6 @@ int syntheticOwnerCounter GlobalDiagnosticsTargets = globalDiagnosticsTargets; GlobalDiagnosticsStackTraces = globalDiagnosticsStackTraces; GlobalMessageBufferSize = globalMessageBufferSize; - GlobalSequentialIndex = globalSequentialIndex; - SyntheticOwnerCounter = syntheticOwnerCounter; } internal bool MessagingDebugEnabled { get; } @@ -130,10 +120,6 @@ int syntheticOwnerCounter internal bool GlobalDiagnosticsStackTraces { get; } internal int GlobalMessageBufferSize { get; } - - internal int GlobalSequentialIndex { get; } - - internal int SyntheticOwnerCounter { get; } } } } diff --git a/Runtime/Core/Extensions/MessageExtensions.cs b/Runtime/Core/Extensions/MessageExtensions.cs index fe1d3e1f1..97b1f4e23 100644 --- a/Runtime/Core/Extensions/MessageExtensions.cs +++ b/Runtime/Core/Extensions/MessageExtensions.cs @@ -8,9 +8,10 @@ namespace DxMessaging.Core.Extensions /// Convenience extension methods for emitting messages. /// /// - /// These helpers select the correct dispatch overloads, handle by-ref messages for structs, and default to the - /// global bus when none is provided. Overloads exist for Unity and - /// to convert to implicitly. + /// These helpers select the dispatch overload and pass struct messages by reference. An explicit bus takes + /// precedence over a provider; an unavailable provider or null result falls back to the global bus. + /// Unity overloads convert UnityEngine.GameObject and UnityEngine.Component to + /// implicitly. /// /// /// @@ -42,7 +43,8 @@ public static class MessageExtensions /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitGameObjectTargeted( this TMessage message, UnityEngine.GameObject target, @@ -67,7 +69,8 @@ public static void EmitGameObjectTargeted( /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitGameObjectTargeted( this ref TMessage message, UnityEngine.GameObject target, @@ -86,7 +89,8 @@ public static void EmitGameObjectTargeted( /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitComponentTargeted( this TMessage message, UnityEngine.Component target, @@ -111,7 +115,8 @@ public static void EmitComponentTargeted( /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitComponentTargeted( this ref TMessage message, UnityEngine.Component target, @@ -131,7 +136,8 @@ public static void EmitComponentTargeted( /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitTargeted( this TMessage message, InstanceId target, @@ -155,7 +161,8 @@ public static void EmitTargeted( /// /// TargetedMessage to emit. /// Target that this message is intended for. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitTargeted( this ref TMessage message, InstanceId target, @@ -172,7 +179,8 @@ public static void EmitTargeted( /// Emits an UntargetedMessage of the given type. /// /// UntargetedMessage to emit. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitUntargeted( this TMessage message, IMessageBus messageBus = null, @@ -195,7 +203,7 @@ public static void EmitUntargeted( /// /// /// This is a convenience shorthand for EmitUntargeted that improves readability when emitting - /// messages globally. If is null, the global bus is used. + /// messages globally. A null explicit bus consults the provider first, then falls back to the global bus. /// /// /// @@ -209,7 +217,8 @@ public static void EmitUntargeted( /// /// /// Untargeted message to emit. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void Emit( this TMessage message, IMessageBus messageBus = null, @@ -231,7 +240,8 @@ public static void Emit( /// Emits an UntargetedMessage of the given type. /// /// UntargetedMessage to emit. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitUntargeted( this ref TMessage message, IMessageBus messageBus = null, @@ -248,8 +258,8 @@ public static void EmitUntargeted( /// /// /// This is a convenience shorthand for EmitUntargeted that improves readability when emitting - /// struct messages globally by reference. If is null, the global - /// bus is used. + /// struct messages globally by reference. A null explicit bus consults the provider first, then falls + /// back to the global bus. /// /// /// @@ -262,7 +272,8 @@ public static void EmitUntargeted( /// /// /// Untargeted message to emit. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void Emit( this ref TMessage message, IMessageBus messageBus = null, @@ -279,7 +290,7 @@ public static void Emit( /// /// /// Simple shorthand for targeted emission using an . In Unity, - /// is implicitly convertible from both and . + /// is implicitly convertible from both UnityEngine.GameObject and UnityEngine.Component. /// Be explicit about what you intend to target: /// - If your listeners registered via RegisterGameObjectTargeted, emit at a GameObject. /// - If your listeners registered via RegisterComponentTargeted, emit at that Component. @@ -318,7 +329,7 @@ public static void EmitAt( /// /// /// Simple shorthand for sourced emission using an . In Unity, - /// is implicitly convertible from both and . + /// is implicitly convertible from both UnityEngine.GameObject and UnityEngine.Component. /// Be explicit about what you intend to identify as the source: /// - If listeners registered via RegisterGameObjectBroadcast, emit from a GameObject. /// - If listeners registered via RegisterComponentBroadcast, emit from that Component. @@ -358,7 +369,8 @@ public static void EmitFrom( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitGameObjectBroadcast( this TMessage message, UnityEngine.GameObject source, @@ -383,7 +395,8 @@ public static void EmitGameObjectBroadcast( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitGameObjectBroadcast( this ref TMessage message, UnityEngine.GameObject source, @@ -402,7 +415,8 @@ public static void EmitGameObjectBroadcast( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitComponentBroadcast( this TMessage message, UnityEngine.Component source, @@ -427,7 +441,8 @@ public static void EmitComponentBroadcast( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitComponentBroadcast( this ref TMessage message, UnityEngine.Component source, @@ -447,7 +462,8 @@ public static void EmitComponentBroadcast( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitBroadcast( this TMessage message, InstanceId source, @@ -471,7 +487,8 @@ public static void EmitBroadcast( /// /// BroadcastMessage to emit. /// Source of this message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitBroadcast( this ref TMessage message, InstanceId source, @@ -494,7 +511,8 @@ public static void EmitBroadcast( /// /// Message to send to the target. /// Target to send the message to. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void Emit( this string message, InstanceId target, @@ -518,7 +536,8 @@ public static void Emit( /// /// String payload to send. /// Target to send the message to. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitAt( this string message, InstanceId target, @@ -539,7 +558,8 @@ public static void EmitAt( /// messages over strings for compile-time safety and discoverability. /// /// Message to send globally. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void Emit( this string message, IMessageBus messageBus = null, @@ -564,7 +584,8 @@ public static void Emit( /// /// String payload to broadcast. /// Source of the message. - /// MessageBus to emit to. If null, uses the GlobalMessageBus. + /// Explicit bus override. If null, the provider is consulted before the global bus. + /// Provider used when no explicit bus is supplied. Missing or unavailable providers and null results fall back to the global bus. public static void EmitFrom( this string message, InstanceId source, diff --git a/Runtime/Core/Helper/MessageCache.cs b/Runtime/Core/Helper/MessageCache.cs index 9c6c5407c..c6f4b95a4 100644 --- a/Runtime/Core/Helper/MessageCache.cs +++ b/Runtime/Core/Helper/MessageCache.cs @@ -33,11 +33,11 @@ internal MessageCacheEnumerator(MessageCache cache) _current = default; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Advances the enumerator to the next cached value. /// /// true if another non-null value exists; otherwise false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool MoveNext() { TValue[] values = _cache._values; @@ -55,14 +55,16 @@ public bool MoveNext() return false; } + /// Gets the value selected by the most recent successful MoveNext. + /// The current value, or the default before iteration and after it ends. public TValue Current => _current; object IEnumerator.Current => Current; - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Resets the enumerator to the position before the first element. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Reset() { _index = -1; @@ -201,8 +203,9 @@ internal void RemoveAtIndex(int index) } /// - /// Returns an enumerator iterating over non-null entries in insertion order. + /// Returns an enumerator that visits non-null entries by increasing message-type ID. /// + /// Message-type IDs are shared across cache instances. [MethodImpl(MethodImplOptions.AggressiveInlining)] public MessageCacheEnumerator GetEnumerator() { diff --git a/Runtime/Core/Helper/MessageHelperIndexer.cs b/Runtime/Core/Helper/MessageHelperIndexer.cs index 14708fb0d..307bb0af2 100644 --- a/Runtime/Core/Helper/MessageHelperIndexer.cs +++ b/Runtime/Core/Helper/MessageHelperIndexer.cs @@ -19,6 +19,8 @@ public static class MessageHelperIndexer internal static int TotalMessages = 0; } + /// Per-message-type storage for the lazily assigned cache index. + /// Message type associated with the cached index. public static class MessageHelperIndexer where TMessage : IMessage { diff --git a/Runtime/Core/IMessage.cs b/Runtime/Core/IMessage.cs index d131c6167..f70b69d52 100644 --- a/Runtime/Core/IMessage.cs +++ b/Runtime/Core/IMessage.cs @@ -34,6 +34,7 @@ namespace DxMessaging.Core /// public interface IMessage { + /// Gets the type used to identify this message to the bus. Type MessageType => GetType(); } } diff --git a/Runtime/Core/InstanceId.cs b/Runtime/Core/InstanceId.cs index 390c151fb..258095fc9 100644 --- a/Runtime/Core/InstanceId.cs +++ b/Runtime/Core/InstanceId.cs @@ -5,12 +5,14 @@ namespace DxMessaging.Core using System.Runtime.Serialization; /// - /// Lightweight wrapper around Unity instance IDs to uniquely identify objects. + /// Numeric identity for objects and message routing. /// /// - /// In Unity builds, this struct also carries a reference to the underlying . - /// It supports implicit conversions from and , - /// and provides value semantics for hashing, equality, and ordering. Outside Unity, it stores only the integer ID. + /// In Unity builds, this struct also carries a reference to the underlying UnityEngine.Object. + /// It supports implicit conversions from UnityEngine.GameObject and UnityEngine.Component, + /// and provides value semantics for hashing, equality, and ordering based only on the integer ID. + /// Constructing from an integer does not find or attach a Unity object. Caller-defined IDs share the same + /// numeric identity space as Unity IDs. Outside Unity, this struct stores only the integer ID. /// /// /// @@ -23,22 +25,26 @@ namespace DxMessaging.Core [DataContract] public readonly struct InstanceId : IComparable, IComparable, IEquatable { + /// Identifier with numeric value zero and no associated Unity object. public static readonly InstanceId EmptyId = new(0); - [DataMember(Name = "id")] + /// Gets the numeric identifier used for equality, hashing and routing. public int Id => _id; + [DataMember(Name = "id")] private readonly int _id; #if UNITY_2021_3_OR_NEWER // ReSharper disable once InconsistentNaming + /// Stored Unity object reference, when this identifier was created from one. + /// This reference does not participate in equality or hashing. public readonly UnityEngine.Object Object; #endif /// - /// Creates an identifier that wraps the provided Unity instance ID. + /// Creates an identifier from a Unity instance ID or a caller-defined numeric identity. /// - /// Unity instance ID to wrap. + /// Numeric identity to wrap. public InstanceId(int id) { _id = id; @@ -78,65 +84,65 @@ internal static int StableId(UnityEngine.Object unityObject) #endif } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// - /// Converts a reference into an . + /// Converts a UnityEngine.GameObject reference into an . /// /// GameObject to wrap. /// Instance identifier representing . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static implicit operator InstanceId(UnityEngine.GameObject gameObject) { return new InstanceId(gameObject); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// - /// Converts a reference into an . + /// Converts a UnityEngine.Component reference into an . /// /// Component to wrap. /// Instance identifier representing . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static implicit operator InstanceId(UnityEngine.Component component) { return new InstanceId(component); } #endif - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Checks for equality with another . /// /// Other instance identifier. - /// true when both identifiers refer to the same Unity instance. + /// true when both identifiers have the same numeric identity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Equals(InstanceId other) { return _id == other._id; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Checks for equality with an arbitrary object. /// /// Object to compare. /// - /// true when is an representing the same Unity instance. + /// true when is an with the same numeric identity. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public override bool Equals(object other) { return other is InstanceId id && Equals(id); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Gets a hash code suitable for dictionary or set lookups. /// - /// Hash code derived from the underlying Unity instance ID. + /// Hash code derived from the wrapped numeric identifier. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public override int GetHashCode() { return _id; } /// - /// Returns a string representation that includes the Unity instance ID and, when available, the object name. + /// Returns a string representation that includes the numeric identifier and, when available, the Unity object name. /// /// Human-readable description of the identifier. public override string ToString() @@ -150,43 +156,42 @@ public override string ToString() #endif } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// - /// Determines whether two identifiers refer to the same Unity instance. + /// Determines whether two identifiers have the same numeric identity. /// /// Left-hand identifier. /// Right-hand identifier. /// true when both identifiers represent the same value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator ==(InstanceId lhs, InstanceId rhs) { return lhs._id == rhs._id; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// - /// Determines whether two identifiers refer to different Unity instances. + /// Determines whether two identifiers have different numeric identities. /// /// Left-hand identifier. /// Right-hand identifier. /// true when the identifiers represent different values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator !=(InstanceId lhs, InstanceId rhs) { return lhs._id != rhs._id; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares two identifiers for ascending ordering. /// /// Left-hand identifier. /// Right-hand identifier. /// true when precedes . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator <(InstanceId lhs, InstanceId rhs) { return lhs._id.CompareTo(rhs._id) < 0; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares two identifiers for ascending ordering with equality. /// @@ -195,24 +200,24 @@ public override string ToString() /// /// true when precedes or both identifiers are equal. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator <=(InstanceId lhs, InstanceId rhs) { return lhs._id.CompareTo(rhs._id) <= 0; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares two identifiers for descending ordering. /// /// Left-hand identifier. /// Right-hand identifier. /// true when follows . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator >(InstanceId lhs, InstanceId rhs) { return 0 < lhs._id.CompareTo(rhs._id); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares two identifiers for descending ordering with equality. /// @@ -221,37 +226,43 @@ public override string ToString() /// /// true when follows or both identifiers are equal. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator >=(InstanceId lhs, InstanceId rhs) { return 0 <= lhs._id.CompareTo(rhs._id); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares this identifier with another identifier for ordering. /// /// Identifier to compare with. /// Relative ordering as defined by . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public int CompareTo(InstanceId other) { return _id.CompareTo(other._id); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] /// /// Compares this identifier with an arbitrary object for ordering. /// /// Object to compare with. /// - /// Relative ordering when is an ; otherwise -1. + /// Relative ordering for another ; positive for . /// + /// The argument is non-null and has another type. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public int CompareTo(object rhs) { + if (rhs is null) + { + return 1; + } if (rhs is InstanceId other) { return CompareTo(other); } - return -1; + throw new ArgumentException("Object must be an InstanceId.", nameof(rhs)); } } } 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..466cd53ca 100644 --- a/Runtime/Core/Internal/TypedSlots.cs +++ b/Runtime/Core/Internal/TypedSlots.cs @@ -35,33 +35,6 @@ namespace DxMessaging.Core.Internal /// internal interface IHandlerActionCache { - /// - /// Strictly monotonic version counter for the cache's structural - /// state. Mirrors HandlerActionCache<TDelegate>.version. - /// Read-only on this surface; bumped internally by the cache's own - /// register / deregister sites and by . - /// - long Version { get; } - - /// - /// The value observed by the most recent - /// dispatcher snapshot. Mirrors - /// HandlerActionCache<TDelegate>.lastSeenVersion and is - /// mutated by the staged dispatch path to detect when the flat cache - /// list needs to be re-materialised. - /// - long LastSeenVersion { get; set; } - - /// - /// The bus emission id of the most recent dispatch that consumed - /// this cache. Mirrors - /// HandlerActionCache<TDelegate>.lastSeenEmissionId. - /// Used by the staged dispatch staleness check. Implementations use - /// an invalid sentinel before the first dispatch so emission id 0 - /// still materializes an initial snapshot. - /// - long LastSeenEmissionId { get; set; } - /// /// True iff the cache currently retains zero entries. Cheap (single /// integer compare against entries.Count); used by the @@ -72,9 +45,8 @@ internal interface IHandlerActionCache /// /// Eviction-driven full clear. Empties the entries dictionary and - /// the flat cache list, resets / - /// , - /// and bumps as the LAST step so any captured + /// the flat cache list, resets the snapshot version and emission stamps, + /// and bumps the cache version as the LAST step so any captured /// dispatch closure that observed the prior version detects /// invalidation. Idempotent. /// @@ -97,7 +69,7 @@ internal interface IHandlerActionCache bool ContainsEntry(object originalHandler); /// - /// Bumps by one. Mirrors the explicit + /// Bumps the cache version by one. Mirrors the explicit /// version++ the legacy deregistration closure performed at the /// top of its body (before the bus deregister call) so any in-flight /// dispatch snapshot detects invalidation at the same point. @@ -109,7 +81,7 @@ internal interface IHandlerActionCache /// Mirrors the EXACT refcount logic the legacy deregistration closure /// used: if the entry's refcount is at or below one, removes it from /// both the entries map and the insertion-order list and bumps - /// a second time (reporting + /// the cache version a second time (reporting /// = true so the caller can /// decrement the owning slot's live count); otherwise decrements the /// refcount in place, preserving the first registration's handler / @@ -148,11 +120,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. @@ -200,7 +167,7 @@ internal interface ITypedHandlerSlotSweeper /// otherwise. /// /// - /// routes storage through this slot; + /// routes storage through this slot; /// the legacy named fields were deleted and the /// _slots[] array the /// storage owner. @@ -208,19 +175,19 @@ internal interface ITypedHandlerSlotSweeper /// /// /// The strongly-typed message contract this slot's parent - /// binds to. The slot itself does not + /// binds to. The slot itself does not /// reference directly today (the type-erased /// handles the per-delegate generic /// shapes) -- the parameter is carried so typed storage can add a /// concrete cache reference here without an additional /// generic re-parameterization. /// - internal sealed class TypedSlot : IEvictableSlot + internal sealed class TypedSlot where T : IMessage { /// /// Per-priority handler caches keyed by priority value. - /// routes non-context storage through + /// routes non-context storage through /// this slot. /// public readonly Dictionary byPriority = new(); @@ -235,28 +202,6 @@ internal sealed class TypedSlot : IEvictableSlot /// Monotonic version counter for the slot's structural state. public long version; - /// - /// The value observed by the most recent - /// dispatcher snapshot. Used to decide whether the cache list needs - /// to be re-materialised before the next dispatch. - /// - public long lastSeenVersion = -1; - - /// - /// The bus emission id of the most recent dispatch that consumed - /// this slot. Used by the staged dispatch staleness check. - /// - public long lastSeenEmissionId = -1; - - /// - /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Maintained by the sweep touch hook; - /// preserved across and - /// so the sweep can distinguish freshly-reset slots from - /// never-touched slots. - /// - public long lastTouchTicks; - /// /// Counts unique live (handler, priority) pairs across every entry in /// ; for context-bound slots, the SUM of @@ -290,7 +235,7 @@ internal sealed class TypedSlot : IEvictableSlot /// /// /// - /// Lifetime semantic: and + /// Lifetime semantic: /// return the outer context dictionary and every inner priority /// dictionary to before nulling the field. /// @@ -416,65 +361,13 @@ public TypedSlot(bool requiresContext) this.requiresContext = requiresContext; } - /// - public long LastTouchTicks - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lastTouchTicks; - } - - /// + /// Whether this slot owns no live registrations. public bool IsEmpty { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => liveCount == 0; } - /// - public long Version - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => version; - } - - /// - /// Full-reset semantic. Empties and - /// , nulls out - /// , and resets the staged-dispatch - /// counters. Resets to 0; this is NOT - /// monotonic and is intended only for the typed-handler analog of - /// MessageBus.ResetState() if and when that code path is - /// wired up. Use for sweep-driven slot - /// reclamation. - /// - /// - /// - /// Returns and its inner priority - /// dictionaries to . - /// - /// - /// Drops cache references without draining them. - /// is intended for the typed-handler analog of - /// MessageBus.ResetState() where the entire - /// graph is being torn down, so per-cache - /// drain would be redundant work. (eviction-driven) - /// DOES drain inner caches via - /// because outer-version invalidation alone is insufficient when the - /// slot is being re-used after sweep. - /// - /// - public void Clear() - { - byPriority.Clear(); - orderedPriorities.Clear(); - ReturnContextDictionaries(); - byContext = null; - version = 0; - lastSeenVersion = -1; - lastSeenEmissionId = -1; - liveCount = 0; - } - /// /// Eviction-driven reset. Drains every inner /// held by @@ -483,9 +376,6 @@ public void Clear() /// outer containers, then bumps as the LAST /// step so any captured dispatch closure that observed the prior /// version detects invalidation. - /// is intentionally preserved so the - /// sweep can distinguish freshly-reset slots from never-touched - /// ones. /// /// /// Drain order is load-bearing: inner caches must be reset (and their @@ -499,15 +389,9 @@ public void Clear() public void Reset() { /* - Inline the structural-clear body of Clear(); do NOT call - Clear() because that resets version=0 and would break the - monotonic invariant the eviction layer depends on: stale - deregister closures captured before reset must observe a - strictly larger version after reset and skip their work. - Per-cache drain BEFORE the structural clear: every - IHandlerActionCache.Reset() bumps its own version internally, - so closures captured against the inner cache also detect - invalidation -- not just closures captured against the slot. + Drain inner caches before releasing their containers. Captured + callbacks observe the inner version change before the owning + slot advances its version at the end of this reset. */ foreach (KeyValuePair kv in byPriority) { @@ -533,8 +417,6 @@ KeyValuePair> ctx in byContext orderedPriorities.Clear(); ReturnContextDictionaries(); byContext = null; - lastSeenVersion = -1; - lastSeenEmissionId = -1; liveCount = 0; unchecked { @@ -573,7 +455,7 @@ KeyValuePair> ctx in byContext /// /// /// - /// The specialization for + /// The specialization for /// holds an array of 6 entries. Other /// specializations share an empty array because they cannot accept global /// registrations. The per-(, @@ -583,7 +465,7 @@ KeyValuePair> ctx in byContext /// array slots and is committed in /// (sibling file in the same folder). This type defines the per-slot /// shape; the index file owns the layout decision. - /// routes global storage through this type. + /// routes global storage through this type. /// /// /// Non-generic by design: the typed handler's six legacy global @@ -607,39 +489,19 @@ KeyValuePair> ctx in byContext /// holds exactly one cache. /// /// - internal sealed class TypedGlobalSlot : IEvictableSlot + internal sealed class TypedGlobalSlot { /// /// Type-erased handler cache for this slot's /// (, ) /// coordinate. Lazy alloc on first registration; nulled by - /// and . + /// . /// public IHandlerActionCache cache; /// Monotonic version counter for the slot's structural state. public long version; - /// - /// The value observed by the most recent - /// dispatcher snapshot. - /// - public long lastSeenVersion = -1; - - /// - /// The bus emission id of the most recent dispatch that consumed - /// this slot. - /// - public long lastSeenEmissionId = -1; - - /// - /// Bus tick counter value at the most recent register / deregister / - /// emit that touched this slot. Maintained by the sweep touch hook; - /// preserved across and - /// . - /// - public long lastTouchTicks; - /// /// Reserved live-handler counter intended to mirror the entry count /// of at every stable observation point so @@ -652,51 +514,19 @@ internal sealed class TypedGlobalSlot : IEvictableSlot /// public int liveCount; - /// - public long LastTouchTicks - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lastTouchTicks; - } - - /// + /// Whether this slot owns no live registrations. public bool IsEmpty { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => liveCount == 0; } - /// - public long Version - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => version; - } - - /// - /// Full-reset semantic. Nulls out and resets - /// the staged-dispatch counters. Resets to - /// 0; this is NOT monotonic and is intended only for the - /// typed-handler analog of MessageBus.ResetState() if and - /// when that code path is wired up. Use for - /// sweep-driven slot reclamation. - /// - public void Clear() - { - cache = null; - version = 0; - lastSeenVersion = -1; - lastSeenEmissionId = -1; - liveCount = 0; - } - /// /// Eviction-driven reset. Drains through /// first, then nulls the /// reference, then bumps as the LAST step so /// any captured dispatch closure that observed the prior version /// detects invalidation. - /// is intentionally preserved. /// /// /// Drain order is load-bearing: the inner cache's own monotonic @@ -709,14 +539,12 @@ public void Clear() public void Reset() { /* - Inline the structural-clear body of Clear(); do NOT call - Clear() because that resets version=0 and would break the - monotonic invariant the eviction layer depends on. + Drain inner caches before releasing their containers. Captured + callbacks observe the inner version change before the owning + slot advances its version at the end of this reset. */ cache?.Reset(); cache = null; - lastSeenVersion = -1; - lastSeenEmissionId = -1; liveCount = 0; unchecked { diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index f3a1cd611..8028f6c7f 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -27,11 +27,12 @@ namespace DxMessaging.Core.MessageBus /// -- Emitted from a source for any listener. /// /// - /// Contract note: emitting any message on a bus with no registered handlers must - /// be a silent no-op. Implementations must NOT throw, log, or otherwise surface - /// the empty-bus state to the caller. The - /// LifecycleEdgeCasesTests.EmitOnEmptyBusIsSilentNoOp test pins this - /// contract for every message kind. + /// A missing subscription does not itself raise a dispatch error. The built-in + /// can report unmatched typed messages at + /// when is true + /// and is non-null. Message formatting + /// and log receiver exceptions propagate to the caller. Empty typed emissions + /// still advance the emission sequence without adding registrations. /// /// /// @@ -68,6 +69,8 @@ public interface IMessageBus /// static bool GlobalDiagnosticsStackTraces { get; set; } + /// Legacy toggle for whether any diagnostics target is configured. + /// Assigning true selects All; assigning false selects Off. Use GlobalDiagnosticsTargets to select environments. [Obsolete("Use GlobalDiagnosticsTargets instead.")] static bool GlobalDiagnosticsMode { @@ -75,6 +78,7 @@ static bool GlobalDiagnosticsMode set => GlobalDiagnosticsTargets = value ? DiagnosticsTarget.All : DiagnosticsTarget.Off; } + /// Gets the current emission sequence used by dispatch snapshots. long EmissionId { get; } /// @@ -116,10 +120,30 @@ static bool GlobalDiagnosticsMode /// static int GlobalMessageBufferSize { get; set; } = DefaultMessageBufferSize; - internal static int GlobalSequentialIndex = -1; + private static int GlobalSequentialIndex = -1; - protected static int GenerateNewGlobalSequentialIndex() => - Interlocked.Increment(ref GlobalSequentialIndex); + /// Reserves a bus cache index for the lifetime of the application domain. + /// All representable indices have been issued. + protected static int GenerateNewGlobalSequentialIndex() + { + while (true) + { + int current = Volatile.Read(ref GlobalSequentialIndex); + if (current == int.MaxValue) + { + throw new InvalidOperationException( + "The message bus index space is exhausted." + ); + } + int next = current + 1; + if ( + Interlocked.CompareExchange(ref GlobalSequentialIndex, next, current) == current + ) + { + return next; + } + } + } internal static bool ShouldEnableDiagnostics() { @@ -147,6 +171,12 @@ internal static bool ShouldEnableDiagnostics(DiagnosticsTarget targets, bool isE /// bool DiagnosticsMode { get; } + /// Identifies the storage used by this bus in each message handler's cache. + /// + /// Independent bus storage must reserve its own index through + /// . Never reset or reuse an issued index. + /// A forwarding wrapper uses its underlying bus's index when both share that storage. + /// int RegisteredGlobalSequentialIndex { get; } /// @@ -164,6 +194,11 @@ internal static bool ShouldEnableDiagnostics(DiagnosticsTarget targets, bool isE /// readonly struct TrimResult : IEquatable { + /// Creates a report of the storage reclaimed by a trim pass. + /// Number of type cache entries, typed handler slots and global slots reclaimed. + /// Number of bus target or source context entries removed. + /// Number of collections dropped from shared pools. + /// Number of occupied type slots remaining after the pass. public TrimResult( int typeSlotsEvicted, int targetSlotsEvicted, @@ -177,26 +212,34 @@ int liveTypeSlotsRemaining LiveTypeSlotsRemaining = liveTypeSlotsRemaining; } - /// Number of typed-handler slots reset. + /// Number of type cache entries, typed handler slots and global slots reclaimed. public int TypeSlotsEvicted { get; } /// Number of bus target/source context entries removed. public int TargetSlotsEvicted { get; } - /// Number of pooled collections dropped from shared pools. + /// Number of collections dropped from shared or bus-owned pools and spare storage. public int PooledCollectionsEvicted { get; } /// Number of occupied type slots remaining after trim. public int LiveTypeSlotsRemaining { get; } + /// Compares all four trim counts with another result. + /// Result to compare. + /// true when all four counts match; otherwise false. public bool Equals(TrimResult other) => TypeSlotsEvicted == other.TypeSlotsEvicted && TargetSlotsEvicted == other.TargetSlotsEvicted && PooledCollectionsEvicted == other.PooledCollectionsEvicted && LiveTypeSlotsRemaining == other.LiveTypeSlotsRemaining; + /// Checks whether an object contains the same trim counts. + /// Object to compare. + /// true for a TrimResult with matching counts; otherwise false. public override bool Equals(object obj) => obj is TrimResult other && Equals(other); + /// Produces a hash from all four trim counts. + /// Hash code derived from the reported counts. public override int GetHashCode() => ( TypeSlotsEvicted, @@ -205,19 +248,35 @@ public override int GetHashCode() => LiveTypeSlotsRemaining ).GetHashCode(); + /// Compares two values for equality. + /// Left value to compare. + /// Right value to compare. + /// true when all four trim counts match; otherwise false. public static bool operator ==(TrimResult left, TrimResult right) => left.Equals(right); + /// Compares two values for inequality. + /// Left value to compare. + /// Right value to compare. + /// false when all four trim counts match; otherwise true. public static bool operator !=(TrimResult left, TrimResult right) => !left.Equals(right); + /// Formats all four named trim counts. + /// Text containing the reclaimed counts and remaining type-slot count. public override string ToString() => $"TrimResult(TypeSlotsEvicted={TypeSlotsEvicted}, TargetSlotsEvicted={TargetSlotsEvicted}, PooledCollectionsEvicted={PooledCollectionsEvicted}, LiveTypeSlotsRemaining={LiveTypeSlotsRemaining})"; } + /// Gets the number of stored broadcast priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. int RegisteredBroadcast { get; } + /// Gets the number of stored targeted priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. int RegisteredTargeted { get; } + /// Gets the number of stored untargeted priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. int RegisteredUntargeted { get; } /// @@ -240,13 +299,13 @@ public override string ToString() => int RegisteredInterceptors { get; } /// - /// Total number of registered post-processors across all message kinds. + /// Number of stored post-processor priority buckets across message kinds. /// /// /// - /// Sums per-message-type post-processor handler counts across the untargeted, + /// Sums per-message-type post-processor priority buckets across the untargeted, /// targeted, targeted-without-targeting, broadcast, and broadcast-without-source - /// post-processor sinks. Test infrastructure such as LeakWatcher reads this + /// post-processor sinks. Multiple callbacks can share a bucket. LeakWatcher reads this /// counter to detect post-processor handles that escape a watched region. Same /// single-thread contract as the other counters. /// @@ -423,9 +482,8 @@ MessageBusRegistration RegisterSourcedBroadcastWithoutSource( /// And returns: true if message handling should continue, false if message handling should be stopped. /// /// - /// Interceptor delegates registered with this method are retained for the bus lifetime. - /// Calling the returned deregistration action removes the interceptor from dispatch, but - /// the delegate reference may remain until the final deregistration of that interceptor. + /// Pass the returned handle to to remove this registration + /// from dispatch. Other registrations of the same interceptor remain active. /// /// An opaque registration handle. Pass it to with the same T to stop intercepting messages. MessageBusRegistration RegisterUntargetedInterceptor( @@ -453,9 +511,8 @@ MessageBusRegistration RegisterUntargetedInterceptor( /// And returns: true if message handling should continue, false if message handling should be stopped. /// /// - /// Interceptor delegates registered with this method are retained for the bus lifetime. - /// Calling the returned deregistration action removes the interceptor from dispatch, but - /// the delegate reference may remain until the final deregistration of that interceptor. + /// Pass the returned handle to to remove this registration + /// from dispatch. Other registrations of the same interceptor remain active. /// /// An opaque registration handle. Pass it to with the same T to stop intercepting messages. MessageBusRegistration RegisterTargetedInterceptor( @@ -483,9 +540,8 @@ MessageBusRegistration RegisterTargetedInterceptor( /// And returns: true if message handling should continue, false if message handling should be stopped. /// /// - /// Interceptor delegates registered with this method are retained for the bus lifetime. - /// Calling the returned deregistration action removes the interceptor from dispatch, but - /// the delegate reference may remain until the final deregistration of that interceptor. + /// Pass the returned handle to to remove this registration + /// from dispatch. Other registrations of the same interceptor remain active. /// /// An opaque registration handle. Pass it to with the same T to stop intercepting messages. MessageBusRegistration RegisterBroadcastInterceptor( @@ -576,12 +632,13 @@ MessageBusRegistration RegisterBroadcastWithoutSourcePostProcessor( /// Pass the returned by the matching /// Register* call, using the SAME you registered with /// (for , any T : IMessage such as - /// is accepted, since the global slot is not type-keyed). + /// is accepted, since the global slot is not type-keyed). /// /// /// 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/IMessageRegistrationBuilder.cs b/Runtime/Core/MessageBus/IMessageRegistrationBuilder.cs index 0f1e8b5c9..aa9a93690 100644 --- a/Runtime/Core/MessageBus/IMessageRegistrationBuilder.cs +++ b/Runtime/Core/MessageBus/IMessageRegistrationBuilder.cs @@ -13,7 +13,7 @@ public interface IMessageRegistrationBuilder /// Build configuration describing ownership, message bus preference, and lifecycle hooks. /// Lease that exposes the constructed . /// is . - /// ActivateOnBuild activation failed and automatic cleanup remains retryable. + /// Setup or activation failed and automatic cleanup remains retryable. MessageRegistrationLease Build(MessageRegistrationBuildOptions options); } } diff --git a/Runtime/Core/MessageBus/Internal/BusSlots.cs b/Runtime/Core/MessageBus/Internal/BusSlots.cs index a5b498c79..8c7794868 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; @@ -13,7 +12,7 @@ namespace DxMessaging.Core.MessageBus.Internal /// /// /// This slot models global accept-all handlers as one shared handler set - /// ( / ) and three + /// () and three /// separate per-kind dispatch state fields /// (, /// , @@ -23,7 +22,7 @@ namespace DxMessaging.Core.MessageBus.Internal /// HandlerCache imposed. /// /// - internal sealed class BusGlobalSlot : IEvictableSlot + internal sealed class BusGlobalSlot { /// /// Live global handlers, keyed by handler with insertion order tracked @@ -32,30 +31,9 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// public readonly Dictionary sharedHandlers = new(); - /// - /// Reserved for global-slot snapshot iteration. Mirrors the legacy - /// non-generic HandlerCache.cache field, which was likewise - /// allocated for parity but never populated or read by any dispatch path. - /// Cleared by and as part of the - /// slot lifecycle. - /// - public readonly List sharedCache = new(); - /// Monotonic version counter for the slot's structural state. public long version; - /// - /// Reserved legacy snapshot-version counter. Active global snapshots - /// use the per-kind dispatch state fields instead. - /// - public long lastSeenVersion = -1; - - /// - /// Reserved legacy snapshot-emission counter. Active global snapshots - /// use the per-kind dispatch state fields instead. - /// - public long lastSeenEmissionId = -1; - /// /// Bus tick counter value at the most recent register / deregister / /// emit that touched this slot. Maintained by the sweep touch hook; @@ -81,8 +59,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; @@ -108,32 +85,18 @@ internal sealed class BusGlobalSlot : IEvictableSlot /// public MessageBus.DispatchState broadcastDispatchState; - /// - public long LastTouchTicks - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lastTouchTicks; - } - - /// + /// Whether this slot owns no live handlers. public bool IsEmpty { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => liveCount == 0; } - /// - public long Version - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => version; - } - /// /// Full-reset semantic that mirrors the legacy non-generic /// HandlerCache.Clear() body. Clears - /// and - /// and resets the dispatch-snapshot counters. Resets + /// + /// and releases the active dispatch states. Resets /// to 0; this is NOT monotonic and is /// intended only for the bus-wide MessageBus.ResetState() code /// path. Use for sweep-driven slot reclamation. @@ -141,7 +104,6 @@ public long Version public void Clear() { sharedHandlers.Clear(); - sharedCache.Clear(); untargetedDispatchState?.Reset(); untargetedDispatchState = null; targetedDispatchState?.Reset(); @@ -149,8 +111,6 @@ public void Clear() broadcastDispatchState?.Reset(); broadcastDispatchState = null; version = 0; - lastSeenVersion = -1; - lastSeenEmissionId = -1; liveCount = 0; } @@ -169,36 +129,17 @@ public void Reset() invariant the eviction layer depends on. */ sharedHandlers.Clear(); - sharedCache.Clear(); untargetedDispatchState?.Reset(); untargetedDispatchState = null; targetedDispatchState?.Reset(); targetedDispatchState = null; broadcastDispatchState?.Reset(); broadcastDispatchState = null; - lastSeenVersion = -1; - lastSeenEmissionId = -1; liveCount = 0; unchecked { ++version; } } - - /// - /// Defensive DEBUG-only assertion that - /// equals sharedHandlers.Count. Provided so contract tests can - /// pin the invariant without exposing private bus state. Stripped in - /// Release builds via . - /// - [Conditional("DEBUG")] - internal void DebugAssertLiveCountInvariant() - { - Debug.Assert( - liveCount == sharedHandlers.Count, - "BusGlobalSlot.liveCount must mirror sharedHandlers.Count at every " - + "stable observation point." - ); - } } } diff --git a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs b/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs deleted file mode 100644 index b6f87f531..000000000 --- a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace DxMessaging.Core.MessageBus.Internal -{ - /// - /// Marker interface for bus-level slot containers that the eviction layer - /// can sweep. Each slot tracks its last-touch tick so the sweep can decide - /// whether to reclaim it, and exposes a monotonic - /// so that staged dispatch closures captured before - /// eviction can detect they have been invalidated. - /// - /// - /// returns inner pooled collections to - /// DxMessaging.Core.Pooling.DxPools. The sweep policy calls - /// on idle empty slots. - /// - internal interface IEvictableSlot - { - /// - /// The bus tick counter value at the most recent register / deregister / - /// emit operation that touched this slot. Used by the sweep to decide - /// whether the slot has been idle for long enough to evict. - /// - long LastTouchTicks { get; } - - /// - /// True iff the slot currently retains zero live registrations. Cheap - /// (single integer compare); maintained at register / deregister sites. - /// Stale-but-non-empty slots are NOT eviction candidates -- only - /// idle AND empty slots are reclaimed. - /// - bool IsEmpty { get; } - - /// - /// Strictly monotonic version counter. Bumped by - /// (and by registration-time mutations on the slot). Allows captured - /// dispatch closures to detect post-eviction invalidation. - /// - long Version { get; } - - /// - /// Reclaim this slot: clear inner state, return any pooled inner - /// collections to DxPools, and bump . - /// Idempotent. is intentionally preserved - /// across so the sweep can distinguish freshly-reset - /// slots from never-touched ones. - /// - void Reset(); - } -} diff --git a/Runtime/Core/MessageBus/Internal/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 3f983d341..78e438b63 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,8 @@ private void RecycleHandlerCache(HandlerCache cache) } } - internal object RecycledHandlerCacheForTesting => _recycledEmptyHandlerCache; - - internal int RecycledHandlerCacheHighWaterForTesting => - _recycledEmptyHandlerCache?.highWaterDistinctHandlers ?? 0; - + /// Gets the number of stored targeted priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. public int RegisteredTargeted { get @@ -1032,8 +926,11 @@ HandlerCache entry in _scalarSinks[ } } + /// Gets the storage index assigned to this bus for its lifetime. public int RegisteredGlobalSequentialIndex { get; } = GenerateNewGlobalSequentialIndex(); + /// Gets the number of type-level storage entries still tracked by this bus. + /// Includes route-cache entries and pending empty handler slots. One message type can contribute multiple entries. public int OccupiedTypeSlots { get @@ -1075,6 +972,8 @@ private int OccupiedInterceptorTypeSlots } } + /// Gets the number of context entries in targeted and sourced route maps. + /// The same identifier can occupy several type or route entries. Empty entries can remain until eligible cleanup. public int OccupiedTargetSlots { get @@ -1092,6 +991,8 @@ public int OccupiedTargetSlots } } + /// Gets the number of stored broadcast priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. public int RegisteredBroadcast { get @@ -1111,6 +1012,8 @@ HandlerCache entry in _scalarSinks[ } } + /// Gets the number of stored untargeted priority buckets. + /// The built-in bus sums buckets across message types and applicable contexts. Multiple callbacks can share one bucket. public int RegisteredUntargeted { get @@ -1129,6 +1032,7 @@ HandlerCache entry in _scalarSinks[ } } + /// Gets the number of stored interceptor delegate and priority pairs across message kinds. public int RegisteredInterceptors { get @@ -1141,6 +1045,7 @@ public int RegisteredInterceptors } } + /// Gets the number of stored post-processor priority buckets across message and context routes. public int RegisteredPostProcessors { get @@ -1180,6 +1085,7 @@ HandlerCache entry in _scalarSinks[ } } + /// Gets the number of distinct MessageHandler sinks in the global accept-all slot. public int RegisteredGlobalAcceptAll => _globalSlots.sharedHandlers.Count; private static int SumInterceptorCache(MessageCache> cache) @@ -1216,6 +1122,7 @@ private static int SumTargetedSinks(MessageCache cache) return count; } + /// Controls whether this bus records diagnostic emissions. public bool DiagnosticsMode { get => _diagnosticsMode; @@ -1272,6 +1179,8 @@ private static class AotBridgeState public static bool SourcedRegistered; } + /// Gets the registration log, creating a disabled log on first access. + /// Enable the returned log to record future registration changes. public RegistrationLog Log => _log ??= new RegistrationLog(false, _registrationLogCapacity); /* @@ -1308,6 +1217,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 / @@ -1318,155 +1233,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 @@ -1487,7 +1253,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 @@ -1563,9 +1329,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) @@ -1639,201 +1403,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() { @@ -2062,35 +1631,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; @@ -2201,12 +1741,6 @@ public void BumpResetGeneration() } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static long GetCurrentTouchTick(IMessageBus messageBus) - { - return messageBus is MessageBus bus ? bus._tickCounter : messageBus?.EmissionId ?? 0; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static long GetResetGeneration(IMessageBus messageBus) { @@ -2323,8 +1857,7 @@ private DispatchLease EnterDispatch() /// ApplyRuntimeSettings - runtime settings hot reload /// (defensive; plans cache no settings today). /// - /// NOT required (no sink mutation): BumpResetGeneration (only - /// invalidates deregistration closures), handler.active toggles + /// NOT required (no sink mutation): BumpResetGeneration, handler.active toggles /// (live-checked per dispatch entry), DiagnosticsMode and /// MessagingDebug.enabled (read live every emission). /// @@ -2378,6 +1911,9 @@ private int SweepStaleUntargetedDispatchPlans() return 0; } + /// Attempts to reclaim empty route and handler storage and trim shared pools. + /// Bypass ordinary cleanup cadence while preserving live registrations and in-flight state. + /// Reclamation counts and occupied type entries remaining, or the default result when trimming is disabled. public TrimResult Trim(bool force = false) { if (!_trimApiEnabled) @@ -2396,13 +1932,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); /* @@ -2410,9 +1946,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(); @@ -2959,124 +2495,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 { @@ -3624,6 +3042,10 @@ and force every type to rebuild its plan on the next emission. private void ResetTypedSlotsForReferencedHandlers() { HashSet handlers = new HashSet(); + foreach (MessageHandler handler in _dirtyHandlers) + { + handlers.Add(handler); + } AddHandlersFromScalarSinks(handlers); AddHandlersFromContextSinks(handlers); @@ -3823,6 +3245,7 @@ liveCount mirrors sharedHandlers.Count at every stable long capturedGeneration = _resetGeneration; long capturedSweepGeneration = _globalSlotSweepGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.GlobalAcceptAll, RegistrationMethod.GlobalAcceptAll, 0, @@ -3977,6 +3400,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.UntargetedInterceptor, RegistrationMethod.Interceptor, priority, @@ -4049,6 +3473,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.TargetedInterceptor, RegistrationMethod.Interceptor, priority, @@ -4121,6 +3546,7 @@ out List interceptors long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.BroadcastInterceptor, RegistrationMethod.Interceptor, priority, @@ -4431,6 +3857,10 @@ registration. plan.scalarPost is the same reference a live { IUntargetedMessage untargetedMessage = typedMessage; BroadcastGlobalUntargeted(ref untargetedMessage, emissionId); + if (emissionResetGeneration != _resetGeneration) + { + return; + } } /* @@ -4618,6 +4048,8 @@ public void TargetedBroadcast(ref InstanceId target, ref TMessage type ReflexiveMessage always takes the featured path (the typeof check is a JIT-time constant for every other message type). */ + long emissionResetGeneration = _resetGeneration; + if (plan.fastPath && typeof(TMessage) != typeof(ReflexiveMessage)) { bool fastFound = false; @@ -4645,6 +4077,11 @@ out HandlerCache fastSorted } } + if (emissionResetGeneration != _resetGeneration) + { + return; + } + /* Without-targeting handle phase. While no mutation happened this emission the cached sink reference IS the live sink; @@ -4696,8 +4133,6 @@ otherwise fall back to the live lookup (preserving the return; } - long emissionResetGeneration = _resetGeneration; - /* Pre-freeze targeted post-processing for this emission (target-specific and without targeting). Acquiring the snapshot @@ -4764,6 +4199,10 @@ rewritten id and re-resolve its snapshot against the final target. { ITargetedMessage targetedMessage = typedMessage; BroadcastGlobalTargeted(ref target, ref targetedMessage, emissionId); + if (emissionResetGeneration != _resetGeneration) + { + return; + } } bool foundAnyHandlers = false; @@ -4779,6 +4218,10 @@ rewritten id and re-resolve its snapshot against the final target. LogLevel.Warn, "ReflexiveMessage dispatch traverses the Unity hierarchy and is significantly slower than typed messages. Prefer targeted or broadcast messages where possible." ); + if (emissionResetGeneration != _resetGeneration) + { + return; + } } } #if UNITY_2021_3_OR_NEWER @@ -4904,6 +4347,10 @@ ref typedMessage LogLevel.Error, "Reflexive messages are not supported in this build." ); + if (emissionResetGeneration != _resetGeneration) + { + return; + } #endif } @@ -4953,6 +4400,11 @@ out HandlerCache sortedHandlers } } + if (emissionResetGeneration != _resetGeneration) + { + return; + } + /* Re-compare rather than reusing planStillValid: the target-keyed handle phase above ran user code, and a handler that mutated @@ -5224,6 +4676,8 @@ public void SourcedBroadcast(ref InstanceId source, ref TMessage typed Fast lane: no interceptors, no global accept-all, no post-processors of either variant existed at emission start. */ + long emissionResetGeneration = _resetGeneration; + if (plan.fastPath) { /* @@ -5275,6 +4729,11 @@ passing counts as "found". _ = DispatchFlatSnapshot(fastSnapshot, ref typedMessage); } + if (emissionResetGeneration != _resetGeneration) + { + return; + } + /* Without-source handle phase. While no mutation happened this emission the cached sink reference IS the live sink @@ -5332,8 +4791,6 @@ exactly like the featured path. return; } - long emissionResetGeneration = _resetGeneration; - /* Pre-freeze broadcast post-processing for this emission (source-specific and without source). Acquiring the snapshot @@ -5400,6 +4857,10 @@ rewritten id and re-resolve its snapshot against the final source. { IBroadcastMessage broadcastMessage = typedMessage; BroadcastGlobalSourcedBroadcast(ref source, ref broadcastMessage, emissionId); + if (emissionResetGeneration != _resetGeneration) + { + return; + } } /* @@ -5476,6 +4937,11 @@ fire below. _ = DispatchFlatSnapshot(snapshot, ref typedMessage); } + if (emissionResetGeneration != _resetGeneration) + { + return; + } + bool bwsFound = InternalBroadcastWithoutSource( broadcastWithoutSourceHandleSnapshot, ref source, @@ -5591,11 +5057,14 @@ out InterceptorCache interceptors /* IL2CPP check elision on the frozen global bucket walk; entries and handlers are non-null by snapshot construction (see DispatchFlatSnapshot). + The entering reset generation also guards the walk: a reset invalidates + the message before a later entry can resolve fresh typed-global callbacks. */ [Il2CppSetOption(Option.NullChecks, false)] [Il2CppSetOption(Option.ArrayBoundsChecks, false)] private void BroadcastGlobalUntargeted(ref IUntargetedMessage message, long emissionId) { + long resetGeneration = _resetGeneration; DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, _globalSlots, @@ -5623,44 +5092,98 @@ private void BroadcastGlobalUntargeted(ref IUntargetedMessage message, long emis { case 1: { - InvokeGlobalUntargetedEntry(ref message, entries[0]); + if (!InvokeGlobalUntargetedEntry(ref message, entries[0], resetGeneration)) + { + return; + } continue; } case 2: { - InvokeGlobalUntargetedEntry(ref message, entries[0]); - InvokeGlobalUntargetedEntry(ref message, entries[1]); + if (!InvokeGlobalUntargetedEntry(ref message, entries[0], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[1], resetGeneration)) + { + return; + } continue; } case 3: { - InvokeGlobalUntargetedEntry(ref message, entries[0]); - InvokeGlobalUntargetedEntry(ref message, entries[1]); - InvokeGlobalUntargetedEntry(ref message, entries[2]); + if (!InvokeGlobalUntargetedEntry(ref message, entries[0], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[1], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[2], resetGeneration)) + { + return; + } continue; } case 4: { - InvokeGlobalUntargetedEntry(ref message, entries[0]); - InvokeGlobalUntargetedEntry(ref message, entries[1]); - InvokeGlobalUntargetedEntry(ref message, entries[2]); - InvokeGlobalUntargetedEntry(ref message, entries[3]); + if (!InvokeGlobalUntargetedEntry(ref message, entries[0], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[1], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[2], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[3], resetGeneration)) + { + return; + } continue; } case 5: { - InvokeGlobalUntargetedEntry(ref message, entries[0]); - InvokeGlobalUntargetedEntry(ref message, entries[1]); - InvokeGlobalUntargetedEntry(ref message, entries[2]); - InvokeGlobalUntargetedEntry(ref message, entries[3]); - InvokeGlobalUntargetedEntry(ref message, entries[4]); + if (!InvokeGlobalUntargetedEntry(ref message, entries[0], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[1], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[2], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[3], resetGeneration)) + { + return; + } + if (!InvokeGlobalUntargetedEntry(ref message, entries[4], resetGeneration)) + { + return; + } continue; } } for (int entryIndex = 0; entryIndex < entryCount; ++entryIndex) { - InvokeGlobalUntargetedEntry(ref message, entries[entryIndex]); + if ( + !InvokeGlobalUntargetedEntry( + ref message, + entries[entryIndex], + resetGeneration + ) + ) + { + return; + } } } } @@ -5674,6 +5197,7 @@ private void BroadcastGlobalTargeted( long emissionId ) { + long resetGeneration = _resetGeneration; DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, _globalSlots, @@ -5701,44 +5225,204 @@ long emissionId { case 1: { - InvokeGlobalTargetedEntry(ref target, ref message, entries[0]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } continue; } case 2: { - InvokeGlobalTargetedEntry(ref target, ref message, entries[0]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[1]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } continue; } case 3: { - InvokeGlobalTargetedEntry(ref target, ref message, entries[0]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[1]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[2]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } continue; } case 4: { - InvokeGlobalTargetedEntry(ref target, ref message, entries[0]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[1]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[2]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[3]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[3], + resetGeneration + ) + ) + { + return; + } continue; } case 5: { - InvokeGlobalTargetedEntry(ref target, ref message, entries[0]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[1]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[2]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[3]); - InvokeGlobalTargetedEntry(ref target, ref message, entries[4]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[3], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[4], + resetGeneration + ) + ) + { + return; + } continue; } } for (int entryIndex = 0; entryIndex < entryCount; ++entryIndex) { - InvokeGlobalTargetedEntry(ref target, ref message, entries[entryIndex]); + if ( + !InvokeGlobalTargetedEntry( + ref target, + ref message, + entries[entryIndex], + resetGeneration + ) + ) + { + return; + } } } } @@ -5752,6 +5436,7 @@ private void BroadcastGlobalSourcedBroadcast( long emissionId ) { + long resetGeneration = _resetGeneration; DispatchSnapshot snapshot = AcquireGlobalDispatchSnapshot( this, _globalSlots, @@ -5779,54 +5464,213 @@ long emissionId { case 1: { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[0]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } continue; } case 2: { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[0]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[1]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } continue; } case 3: { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[0]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[1]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[2]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } continue; } case 4: { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[0]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[1]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[2]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[3]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[3], + resetGeneration + ) + ) + { + return; + } continue; } case 5: { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[0]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[1]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[2]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[3]); - InvokeGlobalBroadcastEntry(ref source, ref message, entries[4]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[0], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[1], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[2], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[3], + resetGeneration + ) + ) + { + return; + } + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[4], + resetGeneration + ) + ) + { + return; + } continue; } } for (int entryIndex = 0; entryIndex < entryCount; ++entryIndex) { - InvokeGlobalBroadcastEntry(ref source, ref message, entries[entryIndex]); + if ( + !InvokeGlobalBroadcastEntry( + ref source, + ref message, + entries[entryIndex], + resetGeneration + ) + ) + { + return; + } } } } /// /// Runs the untargeted interceptor phase over the plan's frozen, - /// priority-ordered interceptor array. Callers gate on - /// , so this method is - /// entered only when at least one interceptor was registered when the - /// plan was refreshed. + /// priority-ordered interceptor array. Callers enter this phase only + /// when retains a store + /// with registered interceptors at plan refresh. /// /// /// The array is never mutated in place (see @@ -6130,6 +5974,7 @@ NOT move it. long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.Handler, registrationMethod, priority, @@ -6144,10 +5989,9 @@ NOT move it. } /// - /// Re-expression of the former closure body: - /// the scalar (untargeted / without-context) handler deregistration. Behaviour and the - /// four reentrancy invariants are unchanged; the closure's captured locals are read from - /// 's fields instead, and the sink is re-resolved from the method. + /// Removes one scalar (untargeted or without-context) handler reference using + /// captured state from . Re-resolves the live sink only + /// to classify a missing handler as stale after a sweep or over-deregistered. /// private void DeregisterScalarHandler(in MessageBusRegistration reg) where T : IMessage @@ -6184,8 +6028,9 @@ the generation guard above has passed and the captured bucket still holds this sweep-staleness / over-deregistration classification only matters when the handler is NOT found, so it is deferred to the cold fallback below. This removes the per-deregistration sink re-resolution (the measured cold-path regression source) - while preserving every guard and the throw-safe ordering (this method performs no - user callback; the throwing IMessageBus.Deregister boundary is the caller's). + while preserving every guard and the ordering of state changes. The cold fallback + below can invoke message formatting and the configured debug log receiver. The + caller still owns exception handling for its IMessageBus.Deregister boundary. */ if ( capturedHandlers.handlers.TryGetValue(priority, out HandlerCache cache) @@ -6247,8 +6092,9 @@ insertionOrder tradeoff. /* COLD FALLBACK: the handler was not found in the captured bucket, so this is a no-op deregistration (post-sweep, or a genuine over-deregistration) -- nothing is removed, - so NO version bump / snapshot invalidation is performed. Bumping here would - spuriously rebuild whichever bucket currently occupies this priority, which after a + so this branch does not bump cache versions or stage snapshots. The bus-wide + dispatch-plan stamp was already invalidated before fallback classification. + Bumping a cache version here would rebuild whichever bucket occupies this priority, which after a full deregister + re-registration at the same priority is the NEW live bucket (the stale handle must not touch it). Re-resolve the live sink only to CLASSIFY this as a silent stale-after-sweep no-op versus a genuine over-deregistration. Rare path; not @@ -6294,7 +6140,6 @@ int priority long touchTick = AdvanceTick(); InvalidateDispatchPlans(); ContextHandlerMap broadcastHandlers = GetOrRentContextMap(sinks); - ContextHandlerMap capturedBroadcastHandlers = broadcastHandlers; SlotKey slotKey = RegistrationMethodAxes.GetSlotKey(registrationMethod); if ( @@ -6359,6 +6204,7 @@ NOT move it. long capturedGeneration = _resetGeneration; return new MessageBusRegistration( + _registrationOwner, MessageBusRegistration.Kind.Handler, registrationMethod, priority, @@ -6366,17 +6212,16 @@ NOT move it. 0L, touchTick, capturedHandlers, - capturedBroadcastHandlers, + null, messageHandler, context ); } /// - /// Re-expression of the former closure body: - /// the keyed (targeted / sourced-broadcast) handler deregistration. Behaviour and the four - /// reentrancy invariants are unchanged; the closure's captured locals are read from - /// 's fields, and the sink is re-resolved from the method. + /// Removes one context-bound targeted or sourced-broadcast handler reference using + /// captured state from . Re-resolves the live sink only + /// to classify a missing handler as stale after a sweep or over-deregistered. /// private void DeregisterContextHandler(in MessageBusRegistration reg) where T : IMessage @@ -6470,9 +6315,10 @@ no user callback runs here. /* COLD FALLBACK: handler not found in the captured bucket -> a no-op deregistration - (nothing removed), so NO version bump is performed (it would spuriously rebuild the - bucket currently at this priority -- the NEW live bucket after a full deregister + - re-registration). Re-resolve only to CLASSIFY stale-after-sweep (silent) versus + (nothing removed), so this branch does not bump cache versions or stage snapshots. + The bus-wide dispatch-plan stamp was already invalidated. Bumping a cache version + here would rebuild the new live bucket after a full deregister + re-registration. + Re-resolve only to CLASSIFY stale-after-sweep (silent) versus over-deregistration (error). Rare path; not on the steady deregistration cost. */ MessageCache sinks = ContextSinkForMethod(registrationMethod); @@ -6496,6 +6342,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: @@ -7943,9 +7794,10 @@ DispatchKind kind [MethodImpl(MethodImplOptions.AggressiveInlining)] [Il2CppSetOption(Option.NullChecks, false)] - private void InvokeGlobalUntargetedEntry( + private bool InvokeGlobalUntargetedEntry( ref TMessage message, - DispatchEntry entry + DispatchEntry entry, + long resetGeneration ) where TMessage : IUntargetedMessage { @@ -7954,21 +7806,23 @@ DispatchEntry entry if (!handler.active) { - return; + return true; } ref IUntargetedMessage interfaceMessage = ref DxUnsafe.As( ref message ); handler.HandleGlobalUntargetedMessage(ref interfaceMessage, this); + return _resetGeneration == resetGeneration; } [Il2CppSetOption(Option.NullChecks, false)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void InvokeGlobalTargetedEntry( + private bool InvokeGlobalTargetedEntry( ref InstanceId target, ref TMessage message, - DispatchEntry entry + DispatchEntry entry, + long resetGeneration ) where TMessage : ITargetedMessage { @@ -7977,21 +7831,23 @@ DispatchEntry entry if (!handler.active) { - return; + return true; } ref ITargetedMessage interfaceMessage = ref DxUnsafe.As( ref message ); handler.HandleGlobalTargetedMessage(ref target, ref interfaceMessage, this); + return _resetGeneration == resetGeneration; } [Il2CppSetOption(Option.NullChecks, false)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void InvokeGlobalBroadcastEntry( + private bool InvokeGlobalBroadcastEntry( ref InstanceId source, ref TMessage message, - DispatchEntry entry + DispatchEntry entry, + long resetGeneration ) where TMessage : IBroadcastMessage { @@ -8000,13 +7856,14 @@ DispatchEntry entry if (!handler.active) { - return; + return true; } ref IBroadcastMessage interfaceMessage = ref DxUnsafe.As( ref message ); handler.HandleGlobalSourcedBroadcastMessage(ref source, ref interfaceMessage, this); + return _resetGeneration == resetGeneration; } [Conditional("ENABLE_IL2CPP")] @@ -8529,7 +8386,7 @@ ref ReflexiveMessage message message.method, ReflexiveMethodBindingFlags, null, - message.parameterTypes, + message.signatureKey.CapturedParameterTypes, null ); } @@ -8550,7 +8407,7 @@ ref ReflexiveMessage message ) || !ParameterTypesMatch( matchingMethod.GetParameters(), - message.parameterTypes + message.signatureKey.CapturedParameterTypes ) ) { diff --git a/Runtime/Core/MessageBus/MessageBusRegistration.cs b/Runtime/Core/MessageBus/MessageBusRegistration.cs index 686fd0b15..35bd8b814 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. @@ -82,8 +87,8 @@ internal enum Kind : byte internal readonly object capturedPrimary; /// - /// The keyed ( targeted/broadcast) per-type context map captured - /// by identity; null otherwise. + /// Reserved secondary identity anchor. Built-in registrations leave it null so + /// a consumed handle cannot retain a recycled context map. /// internal readonly object capturedSecondary; @@ -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, @@ -151,7 +159,9 @@ public MessageBusRegistration(long externalId, object externalState) /// The invalid/empty handle. Deregistering it is a silent no-op. public static readonly MessageBusRegistration None = default; - /// True when this handle represents a live registration (not ). + /// True when this handle is nonempty (not ). + /// This value does not query registration liveness or change after deregistration or bus reset. + /// Assign to a retained handle after successful cleanup. public bool IsValid => kind != Kind.None; /// The external id supplied to ; 0 for non-external handles. @@ -162,7 +172,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 @@ -187,22 +198,34 @@ public override int GetHashCode() MessageHandler hashes by owner, and delegates by target/method) and avoids the unnecessary collisions those overrides would introduce. (null -> 0.) */ - int hash = (int)kind; - hash = (hash * 397) ^ (int)method; - hash = (hash * 397) ^ priority; - hash = (hash * 397) ^ generation.GetHashCode(); - hash = (hash * 397) ^ sweepGeneration.GetHashCode(); - hash = (hash * 397) ^ sequence.GetHashCode(); - hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(capturedPrimary); - hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(capturedSecondary); - hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(payload); - hash = (hash * 397) ^ context.GetHashCode(); - return hash; + unchecked + { + int hash = (int)kind; + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(owner); + hash = (hash * 397) ^ (int)method; + hash = (hash * 397) ^ priority; + hash = (hash * 397) ^ generation.GetHashCode(); + hash = (hash * 397) ^ sweepGeneration.GetHashCode(); + hash = (hash * 397) ^ sequence.GetHashCode(); + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(capturedPrimary); + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(capturedSecondary); + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(payload); + hash = (hash * 397) ^ context.GetHashCode(); + return hash; + } } + /// Compares two values for equality. + /// Left value to compare. + /// Right value to compare. + /// true when both values have the same opaque registration identity; otherwise false. public static bool operator ==(MessageBusRegistration left, MessageBusRegistration right) => left.Equals(right); + /// Compares two values for inequality. + /// Left value to compare. + /// Right value to compare. + /// false when both values have the same opaque registration identity; otherwise true. public static bool operator !=(MessageBusRegistration left, MessageBusRegistration right) => !left.Equals(right); } diff --git a/Runtime/Core/MessageBus/MessageRegistrationBuilder.cs b/Runtime/Core/MessageBus/MessageRegistrationBuilder.cs index 00907fd3d..eae233001 100644 --- a/Runtime/Core/MessageBus/MessageRegistrationBuilder.cs +++ b/Runtime/Core/MessageBus/MessageRegistrationBuilder.cs @@ -6,7 +6,7 @@ namespace DxMessaging.Core.MessageBus using DxMessaging.Core; /// - /// Exception thrown when fails and + /// Exception thrown when registration setup or activation fails and /// the builder cannot fully clean up the partially constructed lease before returning control. /// /// @@ -54,7 +54,7 @@ Exception cleanupException public MessageRegistrationLease Lease { get; } /// - /// Original exception raised while activating the lease. + /// Original exception raised during setup or activation of the lease. /// public Exception ActivationException { get; } @@ -97,7 +97,8 @@ public sealed class MessageRegistrationBuildOptions { /// /// Explicit owner identifier for the underlying . - /// When omitted, a synthetic identifier is generated automatically. + /// When omitted, a negative synthetic identifier is generated automatically and remains + /// consumed until the application domain ends, including across static resets. /// public InstanceId? Owner { get; set; } @@ -180,12 +181,16 @@ Action onDispose OnDispose = onDispose; } + /// Optional callback invoked after the lease is constructed; null skips the hook. public Action OnBuild { get; } + /// Optional callback invoked when the lease becomes active; null skips the hook. public Action OnActivate { get; } + /// Optional callback invoked when the lease becomes inactive; null skips the hook. public Action OnDeactivate { get; } + /// Optional callback invoked during lease disposal; null skips the hook. public Action OnDispose { get; } } @@ -207,6 +212,8 @@ public sealed class MessageRegistrationLease : IDisposable private bool _disposed; private bool _deactivateHookInvoked; private bool _disposeHookInvoked; + private bool _activationInProgress; + private bool _disposeRequestedDuringActivation; internal MessageRegistrationLease( MessageRegistrationToken token, @@ -224,6 +231,8 @@ MessageRegistrationLifecycle lifecycle _disposed = false; _deactivateHookInvoked = false; _disposeHookInvoked = false; + _activationInProgress = false; + _disposeRequestedDuringActivation = false; } /// @@ -237,8 +246,12 @@ MessageRegistrationLifecycle lifecycle public MessageHandler Handler => _messageHandler; /// - /// Gets the message bus that registrations created through this lease will target by default. + /// Gets the bus selector captured when the lease was built. /// + /// + /// A null value selects the global bus. Later + /// calls do not update this property. + /// public IMessageBus MessageBus => _messageBus; /// @@ -247,42 +260,89 @@ MessageRegistrationLifecycle lifecycle public InstanceId Owner => _messageHandler.owner; /// - /// Gets a value indicating whether the lease is currently active (token enabled). + /// Gets the active state maintained by this lease. /// + /// + /// Lease activation and cleanup update this value. Direct calls on + /// do not automatically synchronize this property. + /// public bool IsActive => _isActive; /// /// Enables the token managed by this lease and raises the activation lifecycle callback if provided. /// + /// + /// A nested call from a registration callback returns while the outer call owns activation. + /// Disposal during registration suppresses the activation hook and attempts cleanup of accepted + /// subscriptions. Failed cleanup remains retryable through . + /// /// Thrown if the lease has already been disposed. public void Activate() { EnsureNotDisposed(); - if (_isActive) + if (_isActive || _activationInProgress) { return; } _messageHandler.active = true; _deactivateHookInvoked = false; + _activationInProgress = true; + _disposeRequestedDuringActivation = false; + Exception activationFailure = null; try { - _token.Enable(); + try + { + _token.Enable(); + } + catch (Exception exception) + { + activationFailure = exception; + } + + if (_disposeRequestedDuringActivation) + { + bool tokenCleanupFailed = false; + try + { + _token.Dispose(); + } + catch (Exception exception) + { + tokenCleanupFailed = true; + activationFailure ??= exception; + } + finally + { + _messageHandler.active = _token.Enabled; + _isActive = _token.Enabled; + _disposed = !tokenCleanupFailed && !_token.Enabled; + } + } + else + { + _isActive = _token.Enabled; + if (activationFailure != null) + { + _messageHandler.active = _token.Enabled; + } + } } - catch + finally { - _messageHandler.active = _token.Enabled; - _isActive = _token.Enabled; - throw; + _activationInProgress = false; + } + + if (activationFailure != null) + { + ExceptionDispatchInfo.Capture(activationFailure).Throw(); + } + if (_disposeRequestedDuringActivation || !_isActive) + { + return; } - /* - _isActive is set BEFORE the user callback runs so the lease's - state matches the registrations even if OnActivate throws: - Deactivate()/Dispose() can then always release the live - registrations (a post-callback assignment left them wedged). - */ - _isActive = true; if (_lifecycle.OnActivate != null) { _lifecycle.OnActivate(_token); @@ -341,9 +401,18 @@ public void Deactivate() /// /// Disposes the lease, unregistering handlers and executing lifecycle callbacks once. /// + /// + /// The disposal hook runs at most once. Cleanup accepted by a callback remains owned by this + /// lease. Retry disposal if stays true or cleanup throws. + /// Disposal also consumes cleanup accepted through the token after an earlier completed call. + /// public void Dispose() { - if (_disposed) + if (_activationInProgress) + { + _disposeRequestedDuringActivation = true; + } + if (_disposed && !_token.Enabled && _token._metadata.Count == 0) { return; } @@ -392,7 +461,7 @@ public void Dispose() { _messageHandler.active = _token.Enabled; _isActive = _token.Enabled; - _disposed = !tokenCleanupFailed; + _disposed = !tokenCleanupFailed && !_token.Enabled; } } @@ -427,16 +496,6 @@ public sealed class MessageRegistrationBuilder : IMessageRegistrationBuilder private readonly IMessageBusProvider _messageBusProvider; private static int _syntheticOwnerCounter; - internal static int GetSyntheticOwnerCounter() - { - return Volatile.Read(ref _syntheticOwnerCounter); - } - - internal static void SetSyntheticOwnerCounter(int value) - { - _ = Interlocked.Exchange(ref _syntheticOwnerCounter, value); - } - /// /// Initializes a builder that resolves buses from global state. /// @@ -457,10 +516,16 @@ public MessageRegistrationBuilder(IMessageBusProvider messageBusProvider) /// /// Creates a configured according to the supplied . /// + /// + /// Cleanup diagnostics can stage or enable registrations through the token. The builder attempts + /// their cleanup before throwing again. If cleanup remains enabled, the recovery exception exposes + /// its owning lease. Diagnostic exceptions propagate after cleanup completes. + /// /// Options controlling bus selection, diagnostics, and lifecycle behavior. /// A lease that wraps the created . /// Thrown when is . - /// Thrown when ActivateOnBuild activation fails and automatic cleanup remains retryable. + /// Thrown when the synthetic owner range is exhausted and no explicit owner is supplied. + /// Thrown when setup or activation fails and automatic cleanup remains retryable. public MessageRegistrationLease Build(MessageRegistrationBuildOptions options) { if (options == null) @@ -479,35 +544,42 @@ public MessageRegistrationLease Build(MessageRegistrationBuildOptions options) messageBus ); token.DiagnosticMode = options.EnableDiagnostics; - if (options.Configure != null) + MessageRegistrationLease lease = null; + try { - options.Configure(token); - } + if (options.Configure != null) + { + options.Configure(token); + } - MessageRegistrationLease lease = new MessageRegistrationLease( - token, - messageHandler, - messageBus, - options.Lifecycle - ); - lease.InvokeBuildHook(); + lease = new MessageRegistrationLease( + token, + messageHandler, + messageBus, + options.Lifecycle + ); + lease.InvokeBuildHook(); - if (options.ActivateOnBuild) - { - try + if (options.ActivateOnBuild) { lease.Activate(); } - catch (Exception exception) - { - HandleActivateOnBuildFailure(lease, exception); - } + } + catch (Exception exception) + { + lease ??= new MessageRegistrationLease( + token, + messageHandler, + messageBus, + options.Lifecycle + ); + HandleBuildFailure(lease, exception); } return lease; } - private static void HandleActivateOnBuildFailure( + private static void HandleBuildFailure( MessageRegistrationLease lease, Exception activationException ) @@ -522,25 +594,49 @@ Exception activationException cleanupException = exception; } + Exception diagnosticException = null; + if (!lease.Token.Enabled && cleanupException != null && MessagingDebug.enabled) + { + try + { + MessagingDebug.Log( + LogLevel.Error, + "Registration build cleanup failed after setup or activation failure: {0}", + cleanupException + ); + } + catch (Exception exception) + { + diagnosticException = exception; + } + + if (lease.Token.Enabled || lease.Token._metadata.Count != 0) + { + try + { + lease.Dispose(); + } + catch (Exception exception) + { + cleanupException ??= exception; + } + } + } + if (lease.Token.Enabled) { throw new MessageRegistrationBuildException( - "ActivateOnBuild failed and automatic lease cleanup did not complete. Dispose the Lease property after resolving the cleanup failure.", + "Registration build failed and automatic lease cleanup did not complete. Dispose the Lease property after resolving the cleanup failure.", lease, activationException, cleanupException ); } - if (cleanupException != null && MessagingDebug.enabled) + if (diagnosticException != null) { - MessagingDebug.Log( - LogLevel.Error, - "ActivateOnBuild cleanup failed after activation failure: {0}", - cleanupException - ); + ExceptionDispatchInfo.Capture(diagnosticException).Throw(); } - ExceptionDispatchInfo.Capture(activationException).Throw(); } @@ -567,10 +663,24 @@ private static InstanceId ResolveOwner(MessageRegistrationBuildOptions options) return options.Owner.Value; } - int generatedId = Interlocked.Decrement(ref _syntheticOwnerCounter); - - InstanceId syntheticOwner = new InstanceId(generatedId); - return syntheticOwner; + while (true) + { + int current = Volatile.Read(ref _syntheticOwnerCounter); + if (current == int.MinValue) + { + throw new InvalidOperationException( + "The synthetic owner identifier space is exhausted." + ); + } + int next = current - 1; + if ( + Interlocked.CompareExchange(ref _syntheticOwnerCounter, next, current) + == current + ) + { + return new InstanceId(next); + } + } } private IMessageBus ResolveMessageBus(MessageRegistrationBuildOptions options) diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index 6cfe47b9b..0544349b4 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -9,12 +9,13 @@ namespace DxMessaging.Core using MessageBus; using Messages; using Pooling; + using BusType = global::DxMessaging.Core.MessageBus.MessageBus; /// /// Per-owner handler that executes registered message callbacks. /// /// - /// A is typically created and managed by in Unity. + /// A is typically created and managed by Unity.MessagingComponent in Unity. /// Most user code interacts with the handler through , which stages /// registrations and ensures correct enable/disable lifecycles. /// @@ -91,8 +92,8 @@ in TMessage message /// Gets the baseline global instance used when no custom bus is configured. /// /// - /// The instance is recreated when runs so that domain-reload-disabled - /// environments can obtain a clean slate. + /// clears registrations on this instance and selects it as + /// the global bus. Its reference remains stable for the lifetime of the application domain. /// public static IMessageBus InitialGlobalMessageBus => _defaultGlobalMessageBus; @@ -122,7 +123,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) @@ -164,7 +165,9 @@ public static void SetGlobalMessageBus(IMessageBus messageBus) /// Restores the global to the built-in default instance. /// /// - /// The default instance is recreated by when the static state reset utility runs. + /// Selects the existing default bus without clearing its registrations and invalidates outstanding + /// global override scopes. Call to clear the default bus + /// and restore shared configuration. /// public static void ResetGlobalMessageBus() { @@ -197,7 +200,7 @@ public static GlobalMessageBusScope OverrideGlobalMessageBus(IMessageBus message } /// - /// Recreates the built-in global and assigns it as the active global bus. + /// Clears the built-in default and selects it as the active global bus. /// /// /// Invoked by to provide a clean slate when domain reloads are disabled. @@ -433,6 +436,11 @@ private struct GlobalMessageBusOverrideState /// /// Whether this MessageHandler will process messages. /// + /// + /// The built-in bus checks this before each ordinary handler or post-processor callback. + /// Global accept-all groups check it on entry; a running group can finish after it becomes false. + /// Interceptor callbacks do not check this field. Changing it leaves registrations in place. + /// public bool active; /// @@ -513,7 +521,13 @@ IMessageBus messageBus if (GetHandlerForType(messageBus, out TypedHandler handler)) { long emissionId = messageBus.EmissionId; - handler.HandleGlobalUntargeted(ref message, emissionId); + long resetGeneration = BusType.GetResetGeneration(messageBus); + handler.HandleGlobalUntargeted( + ref message, + emissionId, + messageBus, + resetGeneration + ); } } @@ -619,7 +633,14 @@ IMessageBus messageBus if (GetHandlerForType(messageBus, out TypedHandler handler)) { long emissionId = messageBus.EmissionId; - handler.HandleGlobalTargeted(ref target, ref message, emissionId); + long resetGeneration = BusType.GetResetGeneration(messageBus); + handler.HandleGlobalTargeted( + ref target, + ref message, + emissionId, + messageBus, + resetGeneration + ); } } @@ -644,7 +665,14 @@ IMessageBus messageBus if (GetHandlerForType(messageBus, out TypedHandler handler)) { long emissionId = messageBus.EmissionId; - handler.HandleGlobalBroadcast(ref source, ref message, emissionId); + long resetGeneration = BusType.GetResetGeneration(messageBus); + handler.HandleGlobalBroadcast( + ref source, + ref message, + emissionId, + messageBus, + resetGeneration + ); } } @@ -652,24 +680,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 +718,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 @@ -729,7 +738,7 @@ internal override void Deregister() /// /// Value teardown for one typed global handler slot. Captures the old closure's - /// reset/slot guards, erased cache identity, refcount key, and touch/live-count state. + /// reset/slot guards, erased cache identity, refcount key, and live-count state. /// internal readonly struct GlobalHandlerDeregistrationState { @@ -772,8 +781,7 @@ internal void Deregister() } _cache.BumpVersion(); - _slot.lastTouchTicks = - global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick(_messageBus); + _ = _cache.DeregisterEntry(_originalHandler, out bool wasLastForEntry); if (wasLastForEntry) { @@ -825,12 +833,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 @@ -851,11 +853,14 @@ internal override void Deregister() /// /// Registers this MessageHandler to Globally Accept All Messages via the MessageBus, properly handling deregistration. /// + /// Delegate identity used to count duplicate registrations and remove them. /// MessageHandler to accept all UntargetedMessages. - /// 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. + /// Delegate identity used to count duplicate registrations and remove them. + /// Handler for broadcast messages and their source. + /// Delegate identity used to count duplicate registrations and remove them. + /// Handler for targeted messages and their target. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( Action originalUntargetedMessageHandler, Action untargetedMessageHandler, @@ -867,10 +872,13 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( ) { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterGlobalAcceptAll( this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType( + messageBusIndex + ); GlobalHandlerDeregistrationState untargetedDeregistration = typedHandler.AddGlobalUntargetedHandler( @@ -903,11 +911,14 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( /// /// Registers this MessageHandler to Globally Accept All Messages via the MessageBus, properly handling deregistration. /// + /// Delegate identity used to count duplicate registrations and remove them. /// MessageHandler to accept all UntargetedMessages. - /// 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. + /// Delegate identity used to count duplicate registrations and remove them. + /// Handler for broadcast messages and their source. + /// Delegate identity used to count duplicate registrations and remove them. + /// Handler for targeted messages and their target. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( FastHandler originalUntargetedMessageHandler, FastHandler untargetedMessageHandler, @@ -919,10 +930,13 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( ) { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterGlobalAcceptAll( this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType( + messageBusIndex + ); GlobalHandlerDeregistrationState untargetedDeregistration = typedHandler.AddGlobalUntargetedHandler( @@ -957,10 +971,11 @@ internal GlobalAcceptAllDeregistrationState RegisterGlobalAcceptAll( /// /// Type of Message to be handled. /// Target Id of TargetedMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessageHandler( InstanceId target, Action originalHandler, @@ -971,12 +986,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargeted( target, this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedHandler( target, originalHandler, @@ -1004,12 +1020,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargeted( target, this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedHandler( target, originalHandler, @@ -1025,10 +1042,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage /// /// Type of Message to be handled. /// Target Id of TargetedMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessageHandler( InstanceId target, FastHandler originalHandler, @@ -1039,12 +1057,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargeted( target, this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedHandler( target, originalHandler, @@ -1060,10 +1079,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedMessage /// /// Type of Message to be handled. /// Target Id of TargetedMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostProcessor( InstanceId target, Action originalHandler, @@ -1074,9 +1094,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedPostProcessor(target, this, priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedPostProcessor( target, originalHandler, @@ -1092,10 +1113,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro /// /// Type of Message to be handled. /// Target Id of TargetedMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostProcessor( InstanceId target, FastHandler originalHandler, @@ -1106,9 +1128,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedPostProcessor(target, this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedPostProcessor( target, originalHandler, @@ -1136,9 +1159,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedPostProcessor(target, this, priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedPostProcessor( target, originalHandler, @@ -1153,10 +1177,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedPostPro /// Registers this MessageHandler to post-process TargetedMessages for all messages of the provided type via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargetingPostProcessor( Action originalHandler, Action messageHandler, @@ -1166,12 +1191,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargetingPostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingPostProcessor( originalHandler, messageHandler, @@ -1185,10 +1211,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Registers this MessageHandler to post process fast TargetedMessages for all messages of the provided type via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargetingPostProcessor( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1198,12 +1225,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargetingPostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingPostProcessor( originalHandler, messageHandler, @@ -1230,12 +1258,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargetingPostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingPostProcessor( originalHandler, flatInvoker, @@ -1249,10 +1278,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Registers this MessageHandler to accept TargetedMessages without Targeting via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargeting( Action originalHandler, Action messageHandler, @@ -1262,9 +1292,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargeting(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingHandler( originalHandler, messageHandler, @@ -1291,9 +1322,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargeting(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingHandler( originalHandler, flatInvoker, @@ -1307,10 +1339,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Registers this MessageHandler to accept fast TargetedMessages without Targeting via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithoutTargeting( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1320,9 +1353,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout where T : ITargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterTargetedWithoutTargeting(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddTargetedWithoutTargetingHandler( originalHandler, messageHandler, @@ -1336,10 +1370,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterTargetedWithout /// Registers this MessageHandler to accept UntargetedMessages via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessageHandler( Action originalHandler, Action messageHandler, @@ -1349,11 +1384,12 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa where T : IUntargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterUntargeted( this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddUntargetedHandler( originalHandler, messageHandler, @@ -1379,11 +1415,12 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa where T : IUntargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterUntargeted( this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddUntargetedHandler( originalHandler, flatInvoker, @@ -1397,10 +1434,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa /// Registers this MessageHandler to accept fast UntargetedMessages via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessageHandler( FastHandler originalHandler, FastHandler messageHandler, @@ -1410,11 +1448,12 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa where T : IUntargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterUntargeted( this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddUntargetedHandler( originalHandler, messageHandler, @@ -1428,10 +1467,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedMessa /// Registers this MessageHandler to post-process UntargetedMessages via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostProcessor( Action originalHandler, Action messageHandler, @@ -1441,12 +1481,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP where T : IUntargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterUntargetedPostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddUntargetedPostProcessor( originalHandler, messageHandler, @@ -1460,10 +1501,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP /// Registers this MessageHandler to post process fast UntargetedMessages via the MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostProcessor( FastHandler originalHandler, FastHandler messageHandler, @@ -1473,12 +1515,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP where T : IUntargetedMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterUntargetedPostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddUntargetedPostProcessor( originalHandler, messageHandler, @@ -1493,10 +1536,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterUntargetedPostP /// /// Type of Message to be handled. /// Source Id of BroadcastMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastMessageHandler( InstanceId source, Action originalHandler, @@ -1507,9 +1551,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcast(source, this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastHandler( source, @@ -1538,9 +1583,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcast(source, this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastHandler( source, originalHandler, @@ -1556,10 +1602,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// /// Type of Message to be handled. /// Source Id of BroadcastMessages to listen for. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastMessageHandler( InstanceId source, FastHandler originalHandler, @@ -1570,9 +1617,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcast(source, this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastHandler( source, originalHandler, @@ -1587,10 +1635,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Registers this MessageHandler to accept BroadcastMessage regardless of source via their MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSource( Action originalHandler, Action messageHandler, @@ -1600,9 +1649,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcastWithoutSource(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastWithoutSourceHandler( originalHandler, messageHandler, @@ -1629,9 +1679,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcastWithoutSource(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastWithoutSourceHandler( originalHandler, flatInvoker, @@ -1645,10 +1696,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Registers this MessageHandler to accept fast BroadcastMessage regardless of source via their MessageBus, properly handling deregistration. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSource( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1658,9 +1710,10 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterSourcedBroadcastWithoutSource(this, priority: priority); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddSourcedBroadcastWithoutSourceHandler( originalHandler, messageHandler, @@ -1675,10 +1728,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// /// Type of Message to be handled. /// Source object to listen for BroadcastMessages on. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastPostProcessor( InstanceId source, Action originalHandler, @@ -1689,13 +1743,14 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastPostProcessor( source, messageHandler: this, priority: priority ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastPostProcessor( source, originalHandler, @@ -1711,10 +1766,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// /// Type of Message to be handled. /// Source object to listen for BroadcastMessages on. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastPostProcessor( InstanceId source, FastHandler originalHandler, @@ -1725,13 +1781,14 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastPostProcessor( source, priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastPostProcessor( source, originalHandler, @@ -1759,13 +1816,14 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastPostProcessor( source, priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastPostProcessor( source, originalHandler, @@ -1780,10 +1838,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Registers this MessageHandler to post-processes BroadcastMessage messages for all messages of the provided type. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSourcePostProcessor( Action originalHandler, Action messageHandler, @@ -1793,12 +1852,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastWithoutSourcePostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastWithoutSourcePostProcessor( originalHandler, messageHandler, @@ -1812,10 +1872,11 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas /// Registers this MessageHandler to post processes fast BroadcastMessage messages for all messages of the provided type. /// /// Type of Message to be handled. + /// Delegate identity used to count duplicate registrations and remove them. /// 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. + /// Optional bus override. Null uses this handler's default bus. + /// The deregistration state. internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcastWithoutSourcePostProcessor( FastHandlerWithContext originalHandler, FastHandlerWithContext messageHandler, @@ -1825,12 +1886,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastWithoutSourcePostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastWithoutSourcePostProcessor( originalHandler, messageHandler, @@ -1857,12 +1919,13 @@ internal TypedHandler.TypedHandlerDeregistrationState RegisterSourcedBroadcas where T : IBroadcastMessage { messageBus = ResolveMessageBus(messageBus); + int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; MessageBusRegistration messageBusDeregistration = messageBus.RegisterBroadcastWithoutSourcePostProcessor( priority: priority, messageHandler: this ); - TypedHandler typedHandler = GetOrCreateHandlerForType(messageBus); + TypedHandler typedHandler = GetOrCreateHandlerForType(messageBusIndex); return typedHandler.AddBroadcastWithoutSourcePostProcessor( originalHandler, flatInvoker, @@ -1879,7 +1942,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 +1965,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 +1988,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, @@ -1984,12 +2047,12 @@ public override int GetHashCode() /// Compares this handler with another handler for ordering. /// /// Handler to compare. - /// Relative ordering based on . + /// Relative ordering based on ; positive for . public int CompareTo(MessageHandler other) { - if (other == null) + if (other is null) { - return -1; + return 1; } return owner.CompareTo(other.owner); @@ -2000,11 +2063,20 @@ public int CompareTo(MessageHandler other) /// /// Object to compare. /// - /// Relative ordering when is a ; otherwise -1. + /// Relative ordering for another ; positive for . /// + /// The argument is non-null and has another type. public int CompareTo(object obj) { - return CompareTo(obj as MessageHandler); + if (obj is null) + { + return 1; + } + if (obj is MessageHandler other) + { + return CompareTo(other); + } + throw new ArgumentException("Object must be a MessageHandler.", nameof(obj)); } /// @@ -2020,11 +2092,11 @@ public override string ToString() /// Retrieves an existing Handler for the specific type if it exists, or creates a new Handler if none exist. /// /// Type of Message to retrieve a Handler for. + /// Stable cache index captured before calling the bus registrar. /// Non-Null Handler for the specific type. - private TypedHandler GetOrCreateHandlerForType(IMessageBus messageBus) + private TypedHandler GetOrCreateHandlerForType(int messageBusIndex) where T : IMessage { - int messageBusIndex = messageBus.RegisteredGlobalSequentialIndex; if ( !_handlersByTypeByMessageBus.TryGetValue( messageBusIndex, @@ -2080,94 +2152,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 +2259,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 +2739,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 @@ -2989,20 +2901,6 @@ public void PrepareForOrderedIteration() } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public T KeyAt(int index) - { - if (_spillOrder != null) - { - return _spillOrder[_spillOrderStart + index]; - } - if ((uint)_inlineCount <= (uint)index) - { - throw new ArgumentOutOfRangeException(nameof(index)); - } - return GetInlineKey(index); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public Entry ValueAt(int index) { @@ -3176,7 +3074,9 @@ Both representations preserve first-registration order and make entry/order drift structurally impossible. */ public OrderedEntries entries; - public readonly List cache = new(); + public List cache = new(); + private int _globalSnapshotBorrowers; + private List> _retiredGlobalSnapshots; private System.Collections.IList _flatInvokerCache; public long version; public long lastSeenVersion = -1; @@ -3184,29 +3084,51 @@ Both representations preserve first-registration order and make public long flatInvokerLastSeenVersion = -1; public long flatInvokerLastSeenEmissionId = -1; - /// Monotonic version field, read-only on the interface surface. - long DxMessaging.Core.Internal.IHandlerActionCache.Version + internal List AcquireGlobalSnapshot(long emissionId) { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => version; + List snapshot = TypedHandler.GetOrAddNewHandlerStack(this, emissionId); + ++_globalSnapshotBorrowers; + return snapshot; } - /// Most recent dispatcher-observed version; mutable through the staged dispatch path. - long DxMessaging.Core.Internal.IHandlerActionCache.LastSeenVersion + internal void ReleaseGlobalSnapshot() { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lastSeenVersion; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set => lastSeenVersion = value; + System.Diagnostics.Debug.Assert(0 < _globalSnapshotBorrowers); + --_globalSnapshotBorrowers; + if (_globalSnapshotBorrowers != 0 || _retiredGlobalSnapshots == null) + { + return; + } + for (int index = 0; index < _retiredGlobalSnapshots.Count; ++index) + { + _retiredGlobalSnapshots[index].Clear(); + } + _retiredGlobalSnapshots.Clear(); + if (DxPools.IntSets.MaxRetained < _retiredGlobalSnapshots.Capacity) + { + _retiredGlobalSnapshots = null; + } } - /// Most recent dispatcher-observed bus emission id. - long DxMessaging.Core.Internal.IHandlerActionCache.LastSeenEmissionId + internal List PrepareHandlerSnapshotRebuild() { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lastSeenEmissionId; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set => lastSeenEmissionId = value; + if (_globalSnapshotBorrowers != 0) + { + /* + A nested emission must not overwrite a list already borrowed + by an outer callback or its prefrozen default phase. Keep + displaced lists until every global borrower has exited. + */ + List replacement = new List(entries.Count); + _retiredGlobalSnapshots ??= new List>(); + _retiredGlobalSnapshots.Add(cache); + cache = replacement; + } + else + { + cache.Clear(); + } + return cache; } /// True iff the ordered entry storage holds zero handlers. @@ -3295,7 +3217,7 @@ internal List GetOrCreateFlatInvokerCache() } /// - /// One-size-fits-all wrapper around all possible Messaging sinks for a particular MessageHandler & MessageType. + /// One-size-fits-all wrapper around all possible Messaging sinks for a particular MessageHandler and message type. /// /// Message type that this Handler exists to serve. internal sealed class TypedHandler : ITypedHandlerSlotSweeper @@ -3329,37 +3251,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 +3278,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 { @@ -3520,12 +3411,19 @@ out Dictionary sortedHandlers cache.BumpVersion(); _messageBus.Deregister(in _busRegistration); - if (_slot != null) + + /* + A foreign bus callback can reset and retire the captured slot. Check the + reset identities again before touching its former cache or pending work. + */ + if ( + !global::DxMessaging.Core.MessageBus.MessageBus.IsResetGenerationCurrent( + _messageBus, + _resetGeneration + ) || (_slot != null && _slot.version != _slotVersion) + ) { - _slot.lastTouchTicks = - global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( - _messageBus - ); + return; } _ = cache.DeregisterEntry(_originalHandler, out bool wasLastForEntry); @@ -3556,12 +3454,6 @@ TypedHandlerDeregistrationState state { return new TypedHandlerDeregistration(state); } - - public static implicit operator Action(TypedHandlerDeregistrationState state) - { - TypedHandlerDeregistration deregistration = new(state); - return deregistration.Deregister; - } } /// @@ -3864,6 +3756,7 @@ private bool HasLiveSlots() /// /// Message to emit. /// Priority at which to run the handlers. + /// Identifier used to reuse callback snapshots within one emission. public void HandleUntargeted(ref T message, int priority, long emissionId) { RunFastHandlers( @@ -3886,6 +3779,7 @@ public void HandleUntargeted(ref T message, int priority, long emissionId) /// Target the message is for. /// Message to emit. /// Priority at which to run the handlers. + /// Identifier used to reuse callback snapshots within one emission. public void HandleTargeted( ref InstanceId target, ref T message, @@ -3915,6 +3809,7 @@ long emissionId /// Target the message is for. /// Message to emit. /// Priority at which to run the handlers. + /// Identifier used to reuse callback snapshots within one emission. public void HandleTargetedWithoutTargeting( ref InstanceId target, ref T message, @@ -3944,6 +3839,7 @@ long emissionId /// Source the message is from. /// Message to emit. /// Priority at which to run the handlers. + /// Identifier used to reuse callback snapshots within one emission. public void HandleSourcedBroadcast( ref InstanceId source, ref T message, @@ -3973,6 +3869,7 @@ long emissionId /// Source the message is from. /// Message to emit. /// Priority at which to run the handlers. + /// Identifier used to reuse callback snapshots within one emission. public void HandleSourcedBroadcastWithoutSource( ref InstanceId source, ref T message, @@ -4000,42 +3897,80 @@ long emissionId /// Emits the UntargetedMessage to all global listeners. /// /// Message to emit. - public void HandleGlobalUntargeted(ref IUntargetedMessage message, long emissionId) + /// Current emission's snapshot identity. + /// Bus whose reset generation guards each callback. + /// Generation captured before the first callback. + public void HandleGlobalUntargeted( + ref IUntargetedMessage message, + long emissionId, + IMessageBus messageBus, + long resetGeneration + ) { HandlerActionCache> fastCache = GetGlobalCache< FastHandler >(TypedGlobalSlotIndex.UntargetedFast); - RunFastHandlers(fastCache, ref message, emissionId); HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.UntargetedDefault); - /* - Live-count fast path. Cross-handler in-flight snapshot - semantics do not apply to the global accept-all path: the - bus dispatch loop calls PrefreezeGlobalUntargetedForEmission - lazily per-entry inside InvokeGlobalUntargetedEntry, after - earlier-priority handlers have already run. A sibling - MessageHandler that removes this handler's entry mid-emit - drains cache.entries before the lazy prefreeze can capture - a snapshot, so cache.cache rebuilds from the now-empty - entries. Bailing on cache.entries.Count == 0 is therefore - equivalent to bailing after GetOrAddNewHandlerStack would - return an empty list, and is documented behavior for the - global path. - */ - if (cache?.entries is not { Count: > 0 }) + List> fastSnapshot = + fastCache?.AcquireGlobalSnapshot(emissionId); + bool defaultBorrowed = false; + try { - return; - } + List> handlers = cache?.AcquireGlobalSnapshot( + emissionId + ); + defaultBorrowed = cache != null; + RunFastHandlers( + fastCache, + ref message, + emissionId, + messageBus, + resetGeneration, + fastSnapshot + ); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } - List> handlers = GetOrAddNewHandlerStack( - cache, - emissionId - ); - int handlersCount = handlers.Count; - for (int i = 0; i < handlersCount && i < handlers.Count; ++i) + /* + Live-count fast path. Cross-handler in-flight snapshot + semantics do not apply to the global accept-all path: the + bus dispatch loop calls PrefreezeGlobalUntargetedForEmission + lazily per-entry inside InvokeGlobalUntargetedEntry, after + earlier-priority handlers have already run. A sibling + MessageHandler that removes this handler's entry mid-emit + drains cache.entries before the lazy prefreeze can capture + a snapshot, so cache.cache rebuilds from the now-empty + entries. Bailing on cache.entries.Count == 0 is therefore + equivalent to bailing after GetOrAddNewHandlerStack would + return an empty list, and is documented behavior for the + global path. + */ + if (cache?.entries is not { Count: > 0 }) + { + return; + } + + int handlersCount = handlers.Count; + for (int i = 0; i < handlersCount && i < handlers.Count; ++i) + { + handlers[i](message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + } + } + finally { - handlers[i](message); + if (defaultBorrowed) + { + cache.ReleaseGlobalSnapshot(); + } + fastCache?.ReleaseGlobalSnapshot(); } } @@ -4044,39 +3979,73 @@ global path. /// /// Target that this message is intended for. /// Message to emit. + /// Current emission's snapshot identity. + /// Bus whose reset generation guards each callback. + /// Generation captured before the first callback. public void HandleGlobalTargeted( ref InstanceId target, ref ITargetedMessage message, - long emissionId + long emissionId, + IMessageBus messageBus, + long resetGeneration ) { HandlerActionCache> fastCache = GetGlobalCache>( TypedGlobalSlotIndex.TargetedFast ); - RunFastHandlers(ref target, fastCache, ref message, emissionId); - HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.TargetedDefault); - /* - Live-count fast path. See comment in HandleGlobalUntargeted - for why the global accept-all path bails on - cache.entries.Count == 0 rather than reading the snapshot. - */ - if (cache?.entries is not { Count: > 0 }) - { - return; - } + List> fastSnapshot = + fastCache?.AcquireGlobalSnapshot(emissionId); + bool defaultBorrowed = false; + try + { + List> handlers = + cache?.AcquireGlobalSnapshot(emissionId); + defaultBorrowed = cache != null; + RunFastHandlers( + ref target, + fastCache, + ref message, + emissionId, + messageBus, + resetGeneration, + fastSnapshot + ); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } - List> handlers = GetOrAddNewHandlerStack( - cache, - emissionId - ); - int handlersCount = handlers.Count; - for (int i = 0; i < handlersCount && i < handlers.Count; ++i) + /* + Live-count fast path. See comment in HandleGlobalUntargeted + for why the global accept-all path bails on + cache.entries.Count == 0 rather than reading the snapshot. + */ + if (cache?.entries is not { Count: > 0 }) + { + return; + } + + int handlersCount = handlers.Count; + for (int i = 0; i < handlersCount && i < handlers.Count; ++i) + { + handlers[i](target, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + } + } + finally { - handlers[i](target, message); + if (defaultBorrowed) + { + cache.ReleaseGlobalSnapshot(); + } + fastCache?.ReleaseGlobalSnapshot(); } } @@ -4085,118 +4054,212 @@ long emissionId /// /// Source that this message is from. /// Message to emit. + /// Current emission's snapshot identity. + /// Bus whose reset generation guards each callback. + /// Generation captured before the first callback. public void HandleGlobalBroadcast( ref InstanceId source, ref IBroadcastMessage message, - long emissionId + long emissionId, + IMessageBus messageBus, + long resetGeneration ) { HandlerActionCache> fastCache = GetGlobalCache>( TypedGlobalSlotIndex.BroadcastFast ); - RunFastHandlers(ref source, fastCache, ref message, emissionId); - HandlerActionCache> cache = GetGlobalCache< Action >(TypedGlobalSlotIndex.BroadcastDefault); - /* - Live-count fast path. See comment in HandleGlobalUntargeted - for why the global accept-all path bails on - cache.entries.Count == 0 rather than reading the snapshot. - */ - if (cache?.entries is not { Count: > 0 }) - { - return; - } - - List> handlers = GetOrAddNewHandlerStack( - cache, - emissionId - ); - int handlersCount = handlers.Count; - switch (handlersCount) - { - case 1: + List> fastSnapshot = + fastCache?.AcquireGlobalSnapshot(emissionId); + bool defaultBorrowed = false; + try + { + List> handlers = + cache?.AcquireGlobalSnapshot(emissionId); + defaultBorrowed = cache != null; + RunFastHandlers( + ref source, + fastCache, + ref message, + emissionId, + messageBus, + resetGeneration, + fastSnapshot + ); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) { - handlers[0](source, message); return; } - case 2: + + /* + Live-count fast path. See comment in HandleGlobalUntargeted + for why the global accept-all path bails on + cache.entries.Count == 0 rather than reading the snapshot. + */ + if (cache?.entries is not { Count: > 0 }) { - handlers[0](source, message); - if (handlers.Count < 2) - { - return; - } - handlers[1](source, message); return; } - case 3: + + int handlersCount = handlers.Count; + switch (handlersCount) { - handlers[0](source, message); - if (handlers.Count < 2) + case 1: { + handlers[0](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } - handlers[1](source, message); - if (handlers.Count < 3) + case 2: { + handlers[0](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 2) + { + return; + } + handlers[1](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } - handlers[2](source, message); - return; - } - case 4: - { - handlers[0](source, message); - if (handlers.Count < 2) + case 3: { + handlers[0](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 2) + { + return; + } + handlers[1](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 3) + { + return; + } + handlers[2](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } - handlers[1](source, message); - if (handlers.Count < 3) + case 4: { + handlers[0](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 2) + { + return; + } + handlers[1](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 3) + { + return; + } + handlers[2](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 4) + { + return; + } + handlers[3](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } - handlers[2](source, message); - if (handlers.Count < 4) + case 5: { + handlers[0](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 2) + { + return; + } + handlers[1](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 3) + { + return; + } + handlers[2](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 4) + { + return; + } + handlers[3](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } + if (handlers.Count < 5) + { + return; + } + handlers[4](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } - handlers[3](source, message); - return; } - case 5: + + for (int i = 0; i < handlersCount && i < handlers.Count; ++i) { - handlers[0](source, message); - if (handlers.Count < 2) - { - return; - } - handlers[1](source, message); - if (handlers.Count < 3) + handlers[i](source, message); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) { return; } - handlers[2](source, message); - if (handlers.Count < 4) - { - return; - } - handlers[3](source, message); - if (handlers.Count < 5) - { - return; - } - handlers[4](source, message); - return; } } - - for (int i = 0; i < handlersCount && i < handlers.Count; ++i) + finally { - handlers[i](source, message); + if (defaultBorrowed) + { + cache.ReleaseGlobalSnapshot(); + } + fastCache?.ReleaseGlobalSnapshot(); } } @@ -4346,9 +4409,11 @@ long emissionId /// Adds a TargetedHandler to listen to Messages of the given type, returning a deregistration action. /// /// Target the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedHandler( InstanceId target, @@ -4410,9 +4475,11 @@ IMessageBus messageBus /// Adds a fast TargetedHandler to listen to Messages of the given type, returning a deregistration action. /// /// Target the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedHandler( InstanceId target, @@ -4437,9 +4504,11 @@ IMessageBus messageBus /// /// Adds a TargetedWithoutTargetingHandler to listen to Messages of the given type, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedWithoutTargetingHandler( Action originalHandler, @@ -4504,9 +4573,11 @@ IMessageBus messageBus /// /// Adds a fast TargetedWithoutTargetingHandler to listen to Messages of the given type, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedWithoutTargetingHandler( FastHandlerWithContext originalHandler, @@ -4532,9 +4603,11 @@ IMessageBus messageBus /// /// Adds a UntargetedHandler to listen to Messages of the given type, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddUntargetedHandler( Action originalHandler, @@ -4599,9 +4672,11 @@ IMessageBus messageBus /// /// Adds a fast UntargetedHandler to listen to Messages of the given type, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddUntargetedHandler( FastHandler originalHandler, @@ -4628,9 +4703,11 @@ IMessageBus messageBus /// Adds a SourcedBroadcastHandler to listen to Messages of the given type from an entity, returning a deregistration action. /// /// The Source of the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddSourcedBroadcastHandler( InstanceId source, @@ -4691,9 +4768,11 @@ IMessageBus messageBus /// Adds a fast SourcedBroadcastHandler to listen to Messages of the given type from an entity, returning a deregistration action. /// /// The Source of the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddSourcedBroadcastHandler( InstanceId source, @@ -4718,9 +4797,11 @@ IMessageBus messageBus /// /// Adds a SourcedBroadcastWithoutSourceHandler to listen to Messages of the given type from an entity, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddSourcedBroadcastWithoutSourceHandler( Action originalHandler, @@ -4787,9 +4868,11 @@ IMessageBus messageBus /// /// Adds a fast SourcedBroadcastWithoutSourceHandler to listen to Messages of the given type from an entity, returning a deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddSourcedBroadcastWithoutSourceHandler( FastHandlerWithContext originalHandler, @@ -4816,8 +4899,10 @@ IMessageBus messageBus /// /// Adds a Global UntargetedHandler to listen to all Untargeted Messages of all types, returning the deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalUntargetedHandler( Action originalHandler, Action handler, @@ -4835,8 +4920,10 @@ IMessageBus messageBus /// /// Adds a Global fast UntargetedHandler to listen to all Untargeted Messages of all types, returning the deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalUntargetedHandler( FastHandler originalHandler, FastHandler handler, @@ -4854,8 +4941,10 @@ IMessageBus messageBus /// /// Adds a Global TargetedHandler to listen to all Targeted Messages of all types for all entities, returning the deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalTargetedHandler( Action originalHandler, Action handler, @@ -4873,8 +4962,10 @@ IMessageBus messageBus /// /// Adds a Global fast TargetedHandler to listen to all Targeted Messages of all types for all entities (along with the target instance id), returning the deregistration action. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalTargetedHandler( FastHandlerWithContext originalHandler, FastHandlerWithContext handler, @@ -4890,10 +4981,12 @@ IMessageBus messageBus } /// - /// Adds a Global BroadcastHandler to listen to all Targeted Messages of all types for all entities, returning the deregistration action. + /// Adds a global handler for broadcast messages of any type with their source. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalBroadcastHandler( Action originalHandler, Action handler, @@ -4909,10 +5002,12 @@ IMessageBus messageBus } /// - /// Adds a Global fast BroadcastHandler to listen to all Targeted Messages of all types for all entities (along with the source instance id), returning the deregistration action. + /// Adds a global handler for broadcast messages of any type with their source. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. - /// De-registration action to unregister the handler. + /// Bus captured for reset validation and deregistration. + /// Cleanup state for this handler. public GlobalHandlerDeregistrationState AddGlobalBroadcastHandler( FastHandlerWithContext originalHandler, FastHandlerWithContext handler, @@ -4930,9 +5025,11 @@ IMessageBus messageBus /// /// Adds an Untargeted post-processor to be called after all other handlers have been called. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddUntargetedPostProcessor( Action originalHandler, @@ -4965,9 +5062,11 @@ IMessageBus messageBus /// /// Adds a fast Untargeted post-processor to be called after all other handlers have been called. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddUntargetedPostProcessor( FastHandler originalHandler, @@ -4994,9 +5093,11 @@ IMessageBus messageBus /// Adds a Targeted post-processor to be called after all other handlers have been called. /// /// Target the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedPostProcessor( InstanceId target, @@ -5058,9 +5159,11 @@ IMessageBus messageBus /// Adds a Targeted post-processor to be called after all other handlers have been called. /// /// Target the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedPostProcessor( InstanceId target, @@ -5085,9 +5188,11 @@ IMessageBus messageBus /// /// Adds a Targeted post-processor to be called after all other handlers have been called after every message of the given type. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedWithoutTargetingPostProcessor( Action originalHandler, @@ -5154,9 +5259,11 @@ IMessageBus messageBus /// /// Adds a Targeted post-processor to be called after all other handlers have been called after every message of the given type. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddTargetedWithoutTargetingPostProcessor( FastHandlerWithContext originalHandler, @@ -5183,9 +5290,11 @@ IMessageBus messageBus /// Adds a Broadcast post-processor to be called after all other handlers have been called. /// /// The Source the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddBroadcastPostProcessor( InstanceId source, @@ -5247,9 +5356,11 @@ IMessageBus messageBus /// Adds a fast Broadcast post-processor to be called after all other handlers have been called. /// /// The Source the handler is for. + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddBroadcastPostProcessor( InstanceId source, @@ -5274,9 +5385,11 @@ IMessageBus messageBus /// /// Adds a Broadcast post-processor to be called after all other handlers have been called for every message of the given type. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddBroadcastWithoutSourcePostProcessor( Action originalHandler, @@ -5342,9 +5455,11 @@ IMessageBus messageBus /// /// Adds a fast Broadcast post-processor to be called after all other handlers have been called. /// + /// Delegate identity used to count duplicate registrations and remove them. /// Relevant MessageHandler. /// Deregistration action for the handler. /// Priority at which to add the handler. + /// Bus captured for reset validation and deregistration. /// De-registration action to unregister the handler. public TypedHandlerDeregistrationState AddBroadcastWithoutSourcePostProcessor( FastHandlerWithContext originalHandler, @@ -5431,13 +5546,7 @@ out HandlerActionCache.Entry entry cache.entries[originalHandler] = entry; cache.version++; TypedSlot slot = FindContextSlot(handlersByContext); - if (slot != null) - { - slot.lastTouchTicks = - global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( - messageBus - ); - } + if (firstRegistration && slot != null) { slot.liveCount++; @@ -5883,27 +5992,19 @@ out HandlerActionCache> cache private static void RunFastHandlers( HandlerActionCache> cache, ref TMessage message, - long emissionId + long emissionId, + IMessageBus messageBus, + long resetGeneration, + List> globalSnapshot = null ) where TMessage : IMessage where TU : IMessage { /* - Snapshot semantics: do not bail on the live entry storage - count. A mid-emit removal can drain entries while the pinned - emission snapshot in cache.cache still holds the handlers we - must invoke. Read the snapshot first and bail only if the - snapshot itself is empty. - - Perf note: GetOrAddNewHandlerStack is now invoked on every - call (including for empty caches that the previous fast-path - would have skipped). The cost is one - emission-id/version compare and -- only when the per-emission - snapshot has not been pinned yet -- a single pass over - cache.entries to materialise an empty list. The win is - correctness across cross-handler mid-emit removals where the - pinned snapshot in cache.cache still holds handlers the live - entry storage no longer reaches. + Global dispatch supplies the list borrowed before either + callback phase. Other callers resolve the emission list here. + Check the snapshot count rather than live entries, which can + change while an outer emission still owns its callbacks. */ if (cache == null) { @@ -5911,7 +6012,8 @@ entry storage no longer reaches. } ref TU typedMessage = ref DxUnsafe.As(ref message); - List> handlers = GetOrAddNewHandlerStack(cache, emissionId); + List> handlers = + globalSnapshot ?? GetOrAddNewHandlerStack(cache, emissionId); int handlersCount = handlers.Count; if (handlersCount == 0) { @@ -5922,76 +6024,136 @@ entry storage no longer reaches. case 1: { handlers[0](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 2: { handlers[0](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 3: { handlers[0](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 4: { handlers[0](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 4) { return; } handlers[3](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 5: { handlers[0](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 4) { return; } handlers[3](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 5) { return; } handlers[4](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } } @@ -5999,20 +6161,28 @@ entry storage no longer reaches. for (int i = 0; i < handlersCount && i < handlers.Count; ++i) { handlers[i](in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } } } + // Legacy direct callers omit the bus; global dispatch supplies its reset boundary. private static void RunFastHandlers( ref InstanceId context, HandlerActionCache> cache, ref TMessage message, - long emissionId + long emissionId, + IMessageBus messageBus = null, + long resetGeneration = 0, + List> globalSnapshot = null ) where TMessage : IMessage where TU : IMessage { /* - Snapshot semantics: see comment on the FastHandler overload. + Snapshot ownership follows the FastHandler overload. The pinned emission snapshot may still hold handlers even when the live entry storage has been drained mid-emit. */ @@ -6022,10 +6192,8 @@ the live entry storage has been drained mid-emit. } ref TU typedMessage = ref DxUnsafe.As(ref message); - List> handlers = GetOrAddNewHandlerStack( - cache, - emissionId - ); + List> handlers = + globalSnapshot ?? GetOrAddNewHandlerStack(cache, emissionId); int handlersCount = handlers.Count; if (handlersCount == 0) { @@ -6036,76 +6204,136 @@ the live entry storage has been drained mid-emit. case 1: { handlers[0](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 2: { handlers[0](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 3: { handlers[0](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 4: { handlers[0](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 4) { return; } handlers[3](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } case 5: { handlers[0](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 2) { return; } handlers[1](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 3) { return; } handlers[2](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 4) { return; } handlers[3](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } if (handlers.Count < 5) { return; } handlers[4](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } return; } } @@ -6113,6 +6341,10 @@ the live entry storage has been drained mid-emit. for (int i = 0; i < handlersCount && i < handlers.Count; ++i) { handlers[i](in context, in typedMessage); + if (!BusType.IsResetGenerationCurrent(messageBus, resetGeneration)) + { + return; + } } } @@ -6782,16 +7014,12 @@ out HandlerActionCache> cache } /* - Mid-dispatch clear contract: the List returned here is the LIVE - cache.cache list, not a copy. IHandlerActionCache.Reset() (bus - reset / sweep eviction) clears it IN PLACE, so every dispatch - loop that indexes the returned list re-checks list.Count before - each invocation past the first (and the >5 fallback loops bound - on the live Count). A reset fired from inside a handler then - cleanly stops the in-flight bucket: no peer delegate runs and - nothing throws. The re-check is a single inlined List.Count - field read on data already in cache, so steady-state dispatch - cost is unchanged. + Global dispatch borrows its cached lists before running either + delegate phase. Rebuilding a borrowed cache replaces the list; + final borrower release scrubs the displaced lists. Reset still + clears the current list in place. Per-callback bus generations + stop invalidated global emissions even when their old list was + displaced. Legacy callers without global borrows retain live Count checks. */ internal static List GetOrAddNewHandlerStack( HandlerActionCache actionCache, @@ -6805,10 +7033,10 @@ long emissionId /* Rebuild in the ordered map's first-registration order. This branch only runs on registration churn, never on - steady-state dispatch, and reuses the snapshot list. + steady-state dispatch. Borrowed lists are replaced so nested + rebuilds preserve their outer readers. */ - List list = actionCache.cache; - list.Clear(); + List list = actionCache.PrepareHandlerSnapshotRebuild(); actionCache.entries.PrepareForOrderedIteration(); int orderedCount = actionCache.entries.Count; for (int i = 0; i < orderedCount; ++i) @@ -6879,8 +7107,6 @@ private static GlobalHandlerDeregistrationState AddHandler( IMessageBus messageBus ) { - slot.lastTouchTicks = - global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick(messageBus); HandlerActionCache cache = slot.cache as HandlerActionCache; if (cache == null) { @@ -7221,13 +7447,7 @@ out HandlerActionCache.Entry entry cache.entries[originalHandler] = entry; cache.version++; TypedSlot slot = FindPrioritySlot(handlers); - if (slot != null) - { - slot.lastTouchTicks = - global::DxMessaging.Core.MessageBus.MessageBus.GetCurrentTouchTick( - messageBus - ); - } + if (slot != null && !slot.orderedPriorities.Contains(priority)) { slot.orderedPriorities.Add(priority); diff --git a/Runtime/Core/MessageRegistrationHandle.cs b/Runtime/Core/MessageRegistrationHandle.cs index 64e76a902..a6d9c57c1 100644 --- a/Runtime/Core/MessageRegistrationHandle.cs +++ b/Runtime/Core/MessageRegistrationHandle.cs @@ -47,6 +47,10 @@ private MessageRegistrationHandle(long id, int slot) _slot = slot; } + /// Compares two values for equality. + /// Left value to compare. + /// Right value to compare. + /// true when both values have the same opaque registration identity; otherwise false. public static bool operator ==( MessageRegistrationHandle left, MessageRegistrationHandle right @@ -55,6 +59,10 @@ MessageRegistrationHandle right return left.Equals(right); } + /// Compares two values for inequality. + /// Left value to compare. + /// Right value to compare. + /// false when both values have the same opaque registration identity; otherwise true. public static bool operator !=( MessageRegistrationHandle left, MessageRegistrationHandle right @@ -91,6 +99,10 @@ MessageRegistrationHandle right return left.CompareTo(right) < 0; } + /// Reports whether the left handle sorts before or at the right handle. + /// Left value to compare. + /// Right value to compare. + /// true when the left handle sorts before or at the right handle. public static bool operator <=( MessageRegistrationHandle left, MessageRegistrationHandle right @@ -99,6 +111,10 @@ MessageRegistrationHandle right return left.CompareTo(right) <= 0; } + /// Reports whether the left handle sorts after or at the right handle. + /// Left value to compare. + /// Right value to compare. + /// true when the left handle sorts after or at the right handle. public static bool operator >=( MessageRegistrationHandle left, MessageRegistrationHandle right @@ -122,16 +138,21 @@ public int CompareTo(MessageRegistrationHandle other) /// /// Object to compare with. /// - /// Relative ordering when is a ; otherwise -1. + /// Relative ordering for another ; positive for . /// + /// The argument is non-null and has another type. public int CompareTo(object obj) { + if (obj is null) + { + return 1; + } if (obj is MessageRegistrationHandle handle) { return CompareTo(handle); } - return -1; + throw new ArgumentException("Object must be a MessageRegistrationHandle.", nameof(obj)); } /// diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index 25d09c13d..73958c386 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -2,6 +2,7 @@ namespace DxMessaging.Core { using System; using System.Collections.Generic; + using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using DataStructure; using Diagnostics; @@ -15,6 +16,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. /// /// /// @@ -46,15 +49,19 @@ public sealed class MessageRegistrationToken : IDisposable /// Whether new registrations activate immediately. /// /// - /// Pending cleanup after a failure can keep this true. Complete cleanup before - /// treating the token as inactive. Rollback publishes this state before diagnostic - /// formatting or log receivers run. + /// Pending cleanup after a failure, or a registration accepted during teardown, can keep + /// this true. Complete cleanup before treating the token as inactive. + /// Rollback publishes this state before diagnostic formatting or log receivers run. /// public bool Enabled => _enabled; /// /// When true, collects per-registration call counts and emission history. /// + /// + /// Counts belong to staged registrations. A completed callback can still record bounded + /// emission history after its registration has been removed. + /// public bool DiagnosticMode { get => _diagnosticMode; @@ -67,22 +74,20 @@ public bool DiagnosticMode Maps each staged registration handle to the unified per-handle Registration object the public Register* methods built. Calling Registration.Register() (re)registers the handler on the bus and returns the matching - HandlerDeregistration. The Registration object IS the collapsed staging - state: it replaces (a) the per-registration staging Func display class, (b) - the staging Func delegate itself, and (c) the nested AugmentedHandler - local-function delegate -- the captured target/source, user handler, - priority, and kind are now plain fields, and the diagnostics-augmented - invoker is an instance method bound to the object (the FastHandler handed - to MessageHandler is (FastHandler)registration.AugmentedHandlerScalar). - The registration also owns its common teardown state, eliminating the separate - HandlerDeregistration object; an allocation-backed wrapper is created only for - overlapping retry/recovery state. Net ~ -3 managed allocations per registration. + HandlerDeregistration. The Registration object stores the staged kind, + target/source, user handler and priority without a staging Func or closure. + A separate immutable binding owns the augmented invokers and their resolved + bus. Replays on the same bus reuse that binding; a different bus gets a new + binding so frozen callbacks retain their own diagnostic origin. Binding + creation is a registration cost, never a steady-state emission allocation. + The registration also owns its common teardown state; an allocation-backed + wrapper is created only for overlapping retry/recovery state. 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 @@ -100,8 +105,9 @@ free list makes insertion and removal O(1). teardown, or retarget actually needs them. */ private List _registrationReplayQueue; + private bool _isReplayingRegistrations; private List _teardownQueue; - private Dictionary _rollbackCounts; + private HashSet _rollbackBaseline; private List _activeRetargetHandles; private Dictionary _orphanDeregistrations; @@ -120,7 +126,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; @@ -141,7 +147,10 @@ internal void RecordDiagnosticEmission( InstanceId? context = null ) { - _callCounts[handle] = _callCounts.GetValueOrDefault(handle) + 1; + if (TryGetSlot(handle.Slot, handle.Id, out _)) + { + _callCounts[handle] = _callCounts.GetValueOrDefault(handle) + 1; + } long traceId = messageBus is DxMessaging.Core.MessageBus.MessageBus concreteBus && concreteBus.IsDispatching @@ -1182,7 +1191,7 @@ public MessageRegistrationHandle RegisterBroadcastWithoutSourcePostProcessor( /// Action handler functionality for UntargetedMessages. /// Action handler functionality for TargetedMessages. /// Action handler functionality for BroadcastMessages. - /// A handle that allows for registration and de-registration. + /// Handle for removing this registration from the token. public MessageRegistrationHandle RegisterGlobalAcceptAll( Action acceptAllUntargeted, Action acceptAllTargeted, @@ -1204,7 +1213,7 @@ Action acceptAllBroadcast } /// - /// Stages a registration of the provided MessageHandler to accept every message that is broadcast. + /// Stages global observers for untargeted, targeted, and broadcast messages. /// /// /// DOES NOT ACTUALLY REGISTER THE HANDLER IF NOT ENABLED. To register, a call to Enable() is needed. @@ -1212,7 +1221,7 @@ Action acceptAllBroadcast /// Handler for any untargeted message. /// Handler for any targeted message with target context. /// Handler for any broadcast message with source context. - /// A handle that allows for registration and de-registration. + /// Handle for removing this registration from the token. /// /// /// _ = token.RegisterGlobalAcceptAll( @@ -1358,8 +1367,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); } @@ -1367,8 +1388,15 @@ the Registration object is retained in _registrations to lazily } /// - /// Enables the token if not already enabled. Executes all staged registrations. + /// Activates staged registrations in their original registration order. /// + /// + /// Handles staged while disabled remain valid if replay fails. If rollback leaves + /// true, finish cleanup with before retrying + /// . If cleanup completes, retry Enable after the bus recovers. + /// A nested Enable from a registration callback returns while the outer replay owns + /// activation. The outer call determines success or failure. + /// /// /// Idempotent. /// @@ -1380,7 +1408,7 @@ the Registration object is retained in _registrations to lazily /// public void Enable() { - if (_enabled) + if (_enabled || _isReplayingRegistrations) { return; } @@ -1397,15 +1425,22 @@ after Remove/Add churn. This preserves the documented so replay tolerates re-entrant registration mutation. */ QueueRegistrationsInOrder(); - InvokeRegistrationQueueWithRollback(); + InvokeRegistrationQueueWithRollback(out _); } _enabled = true; } /// - /// Disables the token if not already disabled. Executes all staged de-registrations. + /// Runs active cleanup while retaining staged handlers for a later . /// + /// + /// Attempts the cleanup queued at the start, then rethrows the first failure. Failed + /// cleanup keeps its original handle retryable. Cleanup accepted by a callback waits + /// for a later pass, including reactivation of an existing staged handle. + /// stays true while cleanup remains. + /// Retry after the bus recovers or the callback stops adding registrations. + /// /// /// Idempotent. /// @@ -1430,8 +1465,14 @@ 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. /// + /// + /// Failed cleanup keeps its original handle retryable and rethrows the first failure + /// after other queued cleanup is attempted. Registrations accepted during teardown + /// retain cleanup for a later pass. Repeat after the bus recovers or the callback stops + /// adding registrations. stays true while cleanup remains. + /// /// /// /// var h = token.RegisterUntargeted<SceneLoaded>(OnScene); @@ -1442,7 +1483,7 @@ public void Disable() public void UnregisterAll() { Exception deregistrationException = InvokeDeregistrationQueue(); - if (deregistrationException == null) + if (deregistrationException == null && _deregistrationCount == 0) { _enabled = false; ClearRegistrationArena(); @@ -1451,13 +1492,22 @@ public void UnregisterAll() } _enabled = 0 < _deregistrationCount; - PruneRegistrationStateToFailedDeregistrations(); - ExceptionDispatchInfo.Capture(deregistrationException).Throw(); + PruneRegistrationStateToPendingDeregistrations(); + if (deregistrationException != null) + { + ExceptionDispatchInfo.Capture(deregistrationException).Throw(); + } } /// /// Retargets staged registrations to use a new message bus, re-registering active handlers if needed. /// + /// + /// Failed active replay selects the previous bus for later registrations. Pending cleanup + /// on another bus stays retryable; finish cleanup before enabling again. If diagnostic formatting + /// or a log receiver successfully finishes pending rollback cleanup through + /// or , recovery preserves that cleanup instead of replaying staging. + /// /// Bus override to apply. Pass null to resume using the handler default. /// Determines whether existing registrations should move to the supplied bus immediately. public void RetargetMessageBus(IMessageBus messageBus, MessageBusRebindMode rebindMode) @@ -1515,14 +1565,18 @@ Mirror Enable(): rebind in original registration order so the equal-priority dispatch order survives a bus retarget. */ QueueRegistrationsInOrder(); + bool cleanupCompletedDuringLogging = false; try { - InvokeRegistrationQueueWithRollback(); + InvokeRegistrationQueueWithRollback(out cleanupCompletedDuringLogging); _enabled = true; } catch (Exception exception) { - RestoreRegistrationsAfterRetargetFailure(previousMessageBus); + RestoreRegistrationsAfterRetargetFailure( + previousMessageBus, + cleanupCompletedDuringLogging + ); ExceptionDispatchInfo.Capture(exception).Throw(); throw; } @@ -1558,52 +1612,102 @@ 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); + } + + 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; - PendingDeregistration promoted = new(); - promoted.Add((MessageHandler.HandlerDeregistration)existing); - promoted.Add(deregistration); - orphans[handle] = promoted; + Exception exception = InvokeDeregistration(value, startIndex, out bool shouldRemove); + if (!shouldRemove) + { + RestoreOwnedDeregistration(handle, value); + } + return exception; } - // Logical de-registration count for a _deregistrations value (inline object == 1). - private static int DeregistrationCount(object value) => - value is PendingDeregistration pending ? pending.Count : 1; + private void RestoreOwnedDeregistration(MessageRegistrationHandle handle, object value) + { + /* + Reentrant cleanup can clear or reuse the slot and accept another cleanup. + Restore the older obligation first, or retain it as an orphan after retirement. + */ + 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); + } + } /* Invokes the de-registration tail [startIndex..) for a _deregistrations value. For the @@ -1649,10 +1753,8 @@ out bool shouldRemove } } - private Dictionary SnapshotDeregistrationCounts() + private void SnapshotDeregistrations(HashSet snapshot) { - Dictionary snapshot = _rollbackCounts ??= - new Dictionary(); snapshot.Clear(); for ( int slotIndex = _registrationHead; @@ -1661,26 +1763,75 @@ private Dictionary SnapshotDeregistrationCounts( ) { RegistrationSlot slot = _slots[slotIndex]; - if (slot.Deregistration != null) - { - snapshot[MessageRegistrationHandle.FromIdentity(slot.Id, slotIndex)] = - DeregistrationCount(slot.Deregistration); - } + AppendDeregistrationSnapshot(snapshot, slotIndex, slot.Id, slot.Deregistration); } - if (_orphanDeregistrations != null) { foreach ( KeyValuePair entry in _orphanDeregistrations ) { - snapshot[entry.Key] = DeregistrationCount(entry.Value); + AppendDeregistrationSnapshot( + snapshot, + entry.Key.Slot, + entry.Key.Id, + entry.Value + ); } } + } - return snapshot; + private static void AppendDeregistrationSnapshot( + ICollection destination, + int slot, + long id, + object value, + HashSet baseline = null + ) + { + if (value is PendingDeregistration pending) + { + pending.AppendSnapshot(destination, slot, id, baseline); + } + else if (value != null) + { + AppendDeregistrationSnapshotEntry( + destination, + slot, + id, + (MessageHandler.HandlerDeregistration)value, + 0, + baseline + ); + } } + private static void AppendDeregistrationSnapshotEntry( + ICollection destination, + int slot, + long id, + MessageHandler.HandlerDeregistration deregistration, + int order, + HashSet baseline + ) + { + TeardownIdentity identity = new TeardownIdentity( + slot, + id, + deregistration, + GetDeregistrationVersion(deregistration), + order + ); + if (baseline == null || !baseline.Contains(identity)) + { + destination.Add(identity); + } + } + + private static int GetDeregistrationVersion( + MessageHandler.HandlerDeregistration deregistration + ) => deregistration is Registration registration ? registration.DeregistrationVersion : 0; + private void RestoreMissingRegistrationsAfterRetargetDeregistrationFailure( IMessageBus previousMessageBus, List activeRetargetHandles @@ -1695,7 +1846,7 @@ List activeRetargetHandles QueueMissingRegistrationsInOrder(activeRetargetHandles); try { - InvokeRegistrationQueueWithRollback(); + InvokeRegistrationQueueWithRollback(out _); } catch (Exception exception) { @@ -1710,10 +1861,13 @@ List activeRetargetHandles } } - private void RestoreRegistrationsAfterRetargetFailure(IMessageBus previousMessageBus) + private void RestoreRegistrationsAfterRetargetFailure( + IMessageBus previousMessageBus, + bool cleanupCompletedDuringLogging + ) { _messageBus = previousMessageBus; - if (_registrationCount == 0) + if (cleanupCompletedDuringLogging || _registrationCount == 0) { _enabled = 0 < _deregistrationCount; return; @@ -1722,7 +1876,7 @@ private void RestoreRegistrationsAfterRetargetFailure(IMessageBus previousMessag QueueRegistrationsWithoutRetryableDeregistrationsInOrder(); try { - InvokeRegistrationQueueWithRollback(); + InvokeRegistrationQueueWithRollback(out _); _enabled = true; } catch (Exception restoreException) @@ -1797,14 +1951,18 @@ 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() { + bool wasReplaying = _isReplayingRegistrations; + _isReplayingRegistrations = true; try { foreach (StagedRegistration staged in GetRegistrationReplayQueue()) { Registration registration = staged.Registration; - if (registration == null) + if (registration == null || !TryGetSlot(staged.Slot, staged.Id, out _)) { continue; } @@ -1817,6 +1975,7 @@ private void InvokeRegistrationReplayQueue() finally { _registrationReplayQueue?.Clear(); + _isReplayingRegistrations = wasReplaying; } } @@ -1842,152 +2001,194 @@ public StagedRegistration(int slot, long id, Registration registration) } } - private Exception InvokeDeregistrationQueue( - Dictionary baselineCounts = null - ) + private Exception InvokeDeregistrationQueue(HashSet baseline = null) { if (_deregistrationCount == 0) { return null; } + List queue = _teardownQueue ?? new List(); + _teardownQueue = null; + queue.Clear(); + Exception firstException = null; + try + { + for ( + int slotIndex = _registrationHead; + 0 <= slotIndex; + slotIndex = _slots[slotIndex].Next + ) + { + RegistrationSlot slot = _slots[slotIndex]; + AppendDeregistrationSnapshot( + queue, + slotIndex, + slot.Id, + slot.Deregistration, + baseline + ); + } + bool hasOrphans = + _orphanDeregistrations != null && 0 < _orphanDeregistrations.Count; + if (hasOrphans) + { + foreach ( + KeyValuePair< + MessageRegistrationHandle, + object + > entry in _orphanDeregistrations + ) + { + AppendDeregistrationSnapshot( + queue, + entry.Key.Slot, + entry.Key.Id, + entry.Value, + baseline + ); + } + queue.Sort(TeardownIdentityComparer.Instance); + } + /* + Each entry owns an exact action/version, not a mutable slot's later contents. + Nested cleanup can consume it; later accepted replacements stay outside this pass. + */ + foreach (TeardownIdentity identity in queue) + { + Exception exception = InvokeSnapshotDeregistration(identity); + if (exception != null) + { + firstException ??= exception; + } + } + } + finally + { + queue.Clear(); + _teardownQueue = queue; + } + return firstException; + } - bool scopedToAddedDeregistrations = baselineCounts != null; - List teardownQueue = _teardownQueue ??= new List(); - teardownQueue.Clear(); - for ( - int slotIndex = _registrationHead; - 0 <= slotIndex; - slotIndex = _slots[slotIndex].Next + private Exception InvokeSnapshotDeregistration(TeardownIdentity identity) + { + if (GetDeregistrationVersion(identity.Deregistration) != identity.Version) + { + return null; + } + MessageRegistrationHandle handle = MessageRegistrationHandle.FromIdentity( + identity.Id, + identity.Slot + ); + object remaining; + if ( + TryGetSlot(identity.Slot, identity.Id, out RegistrationSlot slot) + && RemoveSnapshotDeregistration( + slot.Deregistration, + identity.Deregistration, + out remaining + ) ) { - RegistrationSlot slot = _slots[slotIndex]; - if (slot.Deregistration != null) + _slots[identity.Slot].Deregistration = remaining; + if (remaining == null) { - teardownQueue.Add(new TeardownIdentity(slotIndex, slot.Id, isOrphan: false)); + --_deregistrationCount; } } - - bool hasOrphanSnapshot = - _orphanDeregistrations != null && 0 < _orphanDeregistrations.Count; - if (hasOrphanSnapshot) + else if ( + _orphanDeregistrations != null + && _orphanDeregistrations.TryGetValue(handle, out object orphan) + && RemoveSnapshotDeregistration(orphan, identity.Deregistration, out remaining) + ) { - foreach (MessageRegistrationHandle handle in _orphanDeregistrations.Keys) + if (remaining == null) + { + _orphanDeregistrations.Remove(handle); + --_deregistrationCount; + } + else { - teardownQueue.Add(new TeardownIdentity(handle.Slot, handle.Id, isOrphan: true)); + _orphanDeregistrations[handle] = remaining; } } - - /* - Global handle ids are monotonic and therefore encode original registration order. - Capture and sort BEFORE invoking anything: reentrant teardown additions are deferred - to the next pass rather than joining this snapshot. - */ - if (hasOrphanSnapshot) + else { - teardownQueue.Sort(TeardownIdentityComparer.Instance); + return null; } - Exception firstException = null; try { - foreach (TeardownIdentity identity in teardownQueue) - { - MessageRegistrationHandle handle = MessageRegistrationHandle.FromIdentity( - identity.Id, - identity.Slot - ); - object value; - if (identity.IsOrphan) - { - if ( - _orphanDeregistrations == null - || !_orphanDeregistrations.TryGetValue(handle, out value) - ) - { - continue; - } - } - else - { - if (!TryGetSlot(identity.Slot, identity.Id, out RegistrationSlot slot)) - { - continue; - } - - value = slot.Deregistration; - if (value == null) - { - continue; - } - } - - int startIndex = 0; - if (scopedToAddedDeregistrations) - { - if (baselineCounts.TryGetValue(handle, out int baselineCount)) - { - startIndex = baselineCount; - } - - if (DeregistrationCount(value) <= startIndex) - { - continue; - } - } - - Exception exception = InvokeDeregistration( - value, - startIndex, - out bool shouldRemove - ); - if (exception != null) - { - firstException ??= exception; - } + identity.Deregistration?.Deregister(); + return null; + } + catch (Exception exception) + { + RestoreOwnedDeregistration(handle, identity.Deregistration); + return exception; + } + } - if (shouldRemove) - { - if (identity.IsOrphan) - { - _orphanDeregistrations.Remove(handle); - --_deregistrationCount; - } - else if (TryGetSlot(identity.Slot, identity.Id, out _)) - { - _slots[identity.Slot].Deregistration = null; - --_deregistrationCount; - } - } + private static bool RemoveSnapshotDeregistration( + object value, + MessageHandler.HandlerDeregistration selected, + out object remaining + ) + { + remaining = value; + if (value is PendingDeregistration pending) + { + if (!pending.Remove(selected)) + { + return false; } + remaining = pending.Count == 0 ? null : pending; + return true; } - finally + if (!ReferenceEquals(value, selected) || value == null) { - teardownQueue.Clear(); + return false; } - - return firstException; + remaining = null; + return true; } - private void InvokeRegistrationQueueWithRollback() + private void InvokeRegistrationQueueWithRollback(out bool cleanupCompletedDuringLogging) { - Dictionary rollbackBaseline = - SnapshotDeregistrationCounts(); + cleanupCompletedDuringLogging = false; + HashSet rollbackBaseline = + _rollbackBaseline ?? new HashSet(); + _rollbackBaseline = null; try { - InvokeRegistrationReplayQueue(); + SnapshotDeregistrations(rollbackBaseline); + try + { + InvokeRegistrationReplayQueue(); + } + catch (Exception exception) + { + RollBackDeregistrationsAfterRegistrationFailure( + rollbackBaseline, + out cleanupCompletedDuringLogging + ); + _enabled = 0 < _deregistrationCount; + ExceptionDispatchInfo.Capture(exception).Throw(); + throw; + } } - catch (Exception exception) + finally { - RollBackDeregistrationsAfterRegistrationFailure(rollbackBaseline); - _enabled = 0 < _deregistrationCount; - ExceptionDispatchInfo.Capture(exception).Throw(); - throw; + rollbackBaseline.Clear(); + _rollbackBaseline = rollbackBaseline; } } private void RollBackDeregistrationsAfterRegistrationFailure( - Dictionary rollbackBaseline + HashSet rollbackBaseline, + out bool cleanupCompletedDuringLogging ) { + cleanupCompletedDuringLogging = false; if (_deregistrationCount == 0) { return; @@ -1999,13 +2200,26 @@ Dictionary rollbackBaseline Publish pending cleanup before entering either user-code boundary. */ _enabled = 0 < _deregistrationCount; - if (rollbackException != null && MessagingDebug.enabled) + bool pendingBeforeLogging = _enabled; + try { - MessagingDebug.Log( - LogLevel.Error, - "Failed to roll back partial registration after token replay failure: {0}", - rollbackException - ); + if (rollbackException != null && MessagingDebug.enabled) + { + MessagingDebug.Log( + LogLevel.Error, + "Failed to roll back partial registration after token replay failure: {0}", + rollbackException + ); + } + } + finally + { + /* + A logger can finish pending cleanup and then throw. Preserve that completed + cleanup in outer retarget recovery without suppressing the logger exception. + Natural rollback completion occurred before logging and still restores staging. + */ + cleanupCompletedDuringLogging = pendingBeforeLogging && _deregistrationCount == 0; } } @@ -2019,7 +2233,7 @@ diagnostics collection is not allocated merely to be emptied. _emissionBufferBacking?.Clear(); } - private void PruneRegistrationStateToFailedDeregistrations() + private void PruneRegistrationStateToPendingDeregistrations() { int slotIndex = _registrationTail; while (0 <= slotIndex) @@ -2070,6 +2284,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 @@ -2092,7 +2319,11 @@ private bool RemoveRegistrationState(int slotIndex, long id) /// /// Removes a single staged registration by handle. /// - /// Handle returned from a Register* method. + /// Handle returned by a registration on this token. + /// + /// A cleanup failure is rethrown and retains the original handle for retry. Removing a + /// handle with no staged registration or pending cleanup has no effect. + /// /// /// /// var h = token.RegisterUntargeted<SceneLoaded>(OnScene); @@ -2110,19 +2341,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(); } } @@ -2145,15 +2373,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(); } } @@ -2217,9 +2441,12 @@ 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(); + _rollbackBaseline?.Clear(); _activeRetargetHandles?.Clear(); _orphanDeregistrations?.Clear(); } @@ -2260,17 +2487,63 @@ private struct RegistrationSlot public int NextFree; } - private readonly struct TeardownIdentity + private readonly struct TeardownIdentity : IEquatable { public readonly int Slot; public readonly long Id; - public readonly bool IsOrphan; - - public TeardownIdentity(int slot, long id, bool isOrphan) + public readonly MessageHandler.HandlerDeregistration Deregistration; + public readonly int Version; + public readonly int Order; + + public TeardownIdentity( + int slot, + long id, + MessageHandler.HandlerDeregistration deregistration, + int version, + int order + ) { Slot = slot; Id = id; - IsOrphan = isOrphan; + Deregistration = deregistration; + Version = version; + Order = order; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(TeardownIdentity other) => + Slot == other.Slot + && Id == other.Id + && Version == other.Version + && ReferenceEquals(Deregistration, other.Deregistration); + + public override bool Equals(object obj) => + obj is TeardownIdentity other && Equals(other); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(TeardownIdentity left, TeardownIdentity right) => + left.Equals(right); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(TeardownIdentity left, TeardownIdentity right) => + !left.Equals(right); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override int GetHashCode() + { + unchecked + { + int hash = (Slot * 397) ^ Id.GetHashCode(); + hash = (hash * 397) ^ Version; + return (hash * 397) + ^ ( + Deregistration == null + ? 0 + : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode( + Deregistration + ) + ); + } } } @@ -2280,7 +2553,8 @@ private sealed class TeardownIdentityComparer : IComparer public int Compare(TeardownIdentity left, TeardownIdentity right) { - return left.Id.CompareTo(right.Id); + int idOrder = left.Id.CompareTo(right.Id); + return idOrder == 0 ? left.Order.CompareTo(right.Order) : idOrder; } } @@ -2413,9 +2687,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)) { @@ -2427,6 +2700,84 @@ 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 void AppendSnapshot( + ICollection destination, + int slot, + long id, + HashSet baseline + ) + { + int order = 0; + if (_hasHead) + { + AppendDeregistrationSnapshotEntry( + destination, + slot, + id, + _head, + order++, + baseline + ); + } + if (_overflow != null) + { + foreach (MessageHandler.HandlerDeregistration entry in _overflow) + { + AppendDeregistrationSnapshotEntry( + destination, + slot, + id, + entry, + order++, + baseline + ); + } + } + } + + internal bool Remove(MessageHandler.HandlerDeregistration selected) + { + if (_hasHead && ReferenceEquals(_head, selected)) + { + _head = null; + _hasHead = false; + if (_overflow != null && 0 < _overflow.Count) + { + _head = _overflow[0]; + _hasHead = true; + _overflow.RemoveAt(0); + } + return true; + } + if (_overflow != null) + { + for (int index = 0; index < _overflow.Count; ++index) + { + if (ReferenceEquals(_overflow[index], selected)) + { + _overflow.RemoveAt(index); + return true; + } + } + } + return false; + } + internal Exception InvokeFrom(int startIndex) { if (startIndex < 0) @@ -2540,13 +2891,11 @@ private enum RegistrationKind } /* - The unified per-handle staging object. Replaces, per registration, the old - staging Func display class + delegate AND the - nested AugmentedHandler local-function delegate. The captured staging state - (owning token, handle, user handler delegate, target/source InstanceId, - priority, kind) lives in plain fields; the diagnostics-augmented invoker is - an instance method bound to this object (so MessageHandler still receives a - delegate on the hot path -- no virtual/interface call per dispatch). + The unified per-handle staging object holds the owning token, handle, + user delegate, target/source, priority and kind without a staging Func. + Its current immutable invocation binding owns the augmented delegates and + resolved bus. Older frozen delegates keep their own binding through replay; + MessageHandler still invokes a delegate without per-dispatch virtual calls. The base is non-generic so _registrations / the replay queue can hold every registration polymorphically. The constrained MessageHandler.Register* @@ -2562,7 +2911,19 @@ private abstract class Registration : MessageHandler.HandlerDeregistration { protected readonly MessageRegistrationToken Token; public MessageRegistrationHandle Handle; - private IMessageBus _registeredMessageBus; + + internal int DeregistrationVersion { get; private set; } + + protected bool TryClaimInlineDeregistration() + { + /* A saturated action is never reused; immutable spill wrappers remain independent. */ + if (DeregistrationVersion == int.MaxValue) + { + return false; + } + ++DeregistrationVersion; + return true; + } protected Registration(MessageRegistrationToken token) { @@ -2643,9 +3004,7 @@ protected static MessageRegistrationType GetRegistrationType(RegistrationKind ki protected IMessageBus ResolveRegistrationMessageBus() { - IMessageBus messageBus = Token._messageBus; - _registeredMessageBus = messageBus ?? Token._messageHandler?.DefaultMessageBus; - return messageBus; + return Token._messageBus; } protected static void ThrowIfNullHandler(object handler, string parameterName) @@ -2655,10 +3014,34 @@ protected static void ThrowIfNullHandler(object handler, string parameterName) throw new ArgumentNullException(parameterName); } } + } + + /* + A frozen dispatch delegate owns this binding, rather than the replayed staging + object's current bus. Rebinding creates a new target; an older callback and + its pending peers keep the old target until their snapshots are released. + The token still decides whether to record after the user callback returns. + */ + private abstract class InvocationBinding + { + protected readonly MessageRegistrationToken Token; + private readonly MessageRegistrationHandle _handle; + public readonly IMessageBus MessageBus; + + protected InvocationBinding( + MessageRegistrationToken token, + MessageRegistrationHandle handle, + IMessageBus messageBus + ) + { + Token = token; + _handle = handle; + MessageBus = messageBus; + } protected void RecordDiagnosticEmission(IMessage message, InstanceId? context = null) { - Token.RecordDiagnosticEmission(Handle, _registeredMessageBus, message, context); + Token.RecordDiagnosticEmission(_handle, MessageBus, message, context); } } @@ -2675,7 +3058,7 @@ protected MessageHandler.HandlerDeregistration StoreDeregistration( MessageHandler.TypedHandler.TypedHandlerDeregistrationState deregistration ) { - if (_hasInlineDeregistration) + if (_hasInlineDeregistration || !TryClaimInlineDeregistration()) { return deregistration; } @@ -2700,6 +3083,7 @@ registration remains the retryable inline action and any recovery registration spills into an independent compatibility wrapper. */ _hasInlineDeregistration = false; + _typedDeregistration = default; } } @@ -2711,17 +3095,6 @@ private sealed class TargetedRegistration : Registration private readonly object _userHandler; private readonly int _priority; - /* - Strongly-typed views of _userHandler, resolved ONCE at Register() time - (cold) so the per-dispatch augmented invoker calls a typed field directly - -- no per-dispatch castclass or kind-switch (which would add an O(handlers) - cost on the hot dispatch path). Exactly one is set per registration kind. - */ - private Action _scalarAction; - private MessageHandler.FastHandler _scalarFast; - private Action _contextAction; - private MessageHandler.FastHandlerWithContext _contextFast; - internal TargetedRegistration( MessageRegistrationToken token, RegistrationKind kind, @@ -2757,85 +3130,77 @@ internal override MessageHandler.HandlerDeregistration RegisterValidated() switch (_kind) { case RegistrationKind.TargetedHandlerAction: - _scalarAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedMessageHandler( _context, - _scalarAction, - AugmentedScalarAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedScalarAction, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.TargetedHandlerFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedMessageHandler( _context, - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.TargetedPostProcessorAction: - _scalarAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedPostProcessor( _context, - _scalarAction, - AugmentedScalarAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedScalarAction, _priority, messageBus ) ); case RegistrationKind.TargetedPostProcessorFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedPostProcessor( _context, - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, _priority, messageBus ) ); case RegistrationKind.TargetedWithoutTargetingAction: - _contextAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedWithoutTargeting( - _contextAction, - AugmentedContextAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedContextAction, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.TargetedWithoutTargetingFast: - _contextFast = (MessageHandler.FastHandlerWithContext)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedWithoutTargeting( - _contextFast, - AugmentedContextFast, + (MessageHandler.FastHandlerWithContext)_userHandler, + GetBinding(messageBus).AugmentedContextFast, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.TargetedWithoutTargetingPostProcessorAction: - _contextAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedWithoutTargetingPostProcessor( - _contextAction, - AugmentedContextAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedContextAction, _priority, messageBus ) ); case RegistrationKind.TargetedWithoutTargetingPostProcessorFast: - _contextFast = (MessageHandler.FastHandlerWithContext)_userHandler; return StoreDeregistration( messageHandler.RegisterTargetedWithoutTargetingPostProcessor( - _contextFast, - AugmentedContextFast, + (MessageHandler.FastHandlerWithContext)_userHandler, + GetBinding(messageBus).AugmentedContextFast, _priority, messageBus ) @@ -2872,50 +3237,76 @@ public override void Validate() ThrowIfNullHandler(_userHandler, parameterName); } - /* - Scalar invokers (targeted handler / post-processor). The user's handler is - the identity/dedup key; this flat invoker runs for the default slot. Calls - the typed field directly (no castclass) then records the (message, _context) - emission, matching the former AugmentedHandler bodies exactly. - */ - private void AugmentedScalarAction(in T message) + private Binding _binding; + + private Binding GetBinding(IMessageBus messageBus) { - _scalarAction(message); - if (Token._diagnosticMode) + IMessageBus resolvedBus = messageBus ?? Token._messageHandler.DefaultMessageBus; + if (_binding == null || !ReferenceEquals(_binding.MessageBus, resolvedBus)) { - RecordDiagnosticEmission(message, _context); + _binding = new Binding(this, resolvedBus); } + return _binding; } - private void AugmentedScalarFast(in T message) + private sealed class Binding : InvocationBinding { - _scalarFast(in message); - if (Token._diagnosticMode) + private readonly Action _scalarAction; + private readonly MessageHandler.FastHandler _scalarFast; + private readonly Action _contextAction; + private readonly MessageHandler.FastHandlerWithContext _contextFast; + private readonly InstanceId _context; + + public Binding(TargetedRegistration registration, IMessageBus messageBus) + : base(registration.Token, registration.Handle, messageBus) { - RecordDiagnosticEmission(message, _context); + _scalarAction = registration._userHandler as Action; + _scalarFast = registration._userHandler as MessageHandler.FastHandler; + _contextAction = registration._userHandler as Action; + _contextFast = + registration._userHandler as MessageHandler.FastHandlerWithContext; + _context = registration._context; } - } - /* - Context invokers (without-targeting handler / post-processor). Emission data - uses the dispatch-supplied target (the readonly parameter), not the stored _context - (default for these kinds), matching the former bodies exactly. - */ - private void AugmentedContextAction(in InstanceId target, in T message) - { - _contextAction(target, message); - if (Token._diagnosticMode) + public void AugmentedScalarAction(in T message) { - RecordDiagnosticEmission(message, target); + _scalarAction(message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, _context); + } } - } - private void AugmentedContextFast(in InstanceId target, in T message) - { - _contextFast(in target, in message); - if (Token._diagnosticMode) + public void AugmentedScalarFast(in T message) + { + _scalarFast(in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, _context); + } + } + + /* + Context invokers (without-targeting handler / post-processor). Emission data + uses the dispatch-supplied target (the readonly parameter), not the stored _context + (default for these kinds), matching the former bodies exactly. + */ + public void AugmentedContextAction(in InstanceId target, in T message) { - RecordDiagnosticEmission(message, target); + _contextAction(target, message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, target); + } + } + + public void AugmentedContextFast(in InstanceId target, in T message) + { + _contextFast(in target, in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, target); + } } } } @@ -2927,14 +3318,6 @@ private sealed class UntargetedRegistration : Registration private readonly object _userHandler; private readonly int _priority; - /* - Typed views of _userHandler, resolved once at Register() time (cold) so the - per-dispatch invoker calls a typed field directly -- no castclass/switch on - the hot path. Exactly one is set per registration kind. - */ - private Action _scalarAction; - private MessageHandler.FastHandler _scalarFast; - internal UntargetedRegistration( MessageRegistrationToken token, RegistrationKind kind, @@ -2963,31 +3346,28 @@ internal override MessageHandler.HandlerDeregistration RegisterValidated() switch (_kind) { case RegistrationKind.UntargetedHandlerAction: - _scalarAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterUntargetedMessageHandler( - _scalarAction, - AugmentedScalarAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedScalarAction, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.UntargetedHandlerFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterUntargetedMessageHandler( - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.UntargetedPostProcessorFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterUntargetedPostProcessor( - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, _priority, messageBus ) @@ -3008,25 +3388,46 @@ public override void Validate() ThrowIfNullHandler(_userHandler, parameterName); } - /* - Untargeted scalar invokers. No context: emission data carries the message - only, matching the former AugmentedHandler bodies exactly. - */ - private void AugmentedScalarAction(in T message) + private Binding _binding; + + private Binding GetBinding(IMessageBus messageBus) { - _scalarAction(message); - if (Token._diagnosticMode) + IMessageBus resolvedBus = messageBus ?? Token._messageHandler.DefaultMessageBus; + if (_binding == null || !ReferenceEquals(_binding.MessageBus, resolvedBus)) { - RecordDiagnosticEmission(message); + _binding = new Binding(this, resolvedBus); } + return _binding; } - private void AugmentedScalarFast(in T message) + private sealed class Binding : InvocationBinding { - _scalarFast(in message); - if (Token._diagnosticMode) + private readonly Action _scalarAction; + private readonly MessageHandler.FastHandler _scalarFast; + + public Binding(UntargetedRegistration registration, IMessageBus messageBus) + : base(registration.Token, registration.Handle, messageBus) { - RecordDiagnosticEmission(message); + _scalarAction = registration._userHandler as Action; + _scalarFast = registration._userHandler as MessageHandler.FastHandler; + } + + public void AugmentedScalarAction(in T message) + { + _scalarAction(message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message); + } + } + + public void AugmentedScalarFast(in T message) + { + _scalarFast(in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message); + } } } } @@ -3039,16 +3440,6 @@ private sealed class BroadcastRegistration : Registration private readonly object _userHandler; private readonly int _priority; - /* - Typed views of _userHandler, resolved once at Register() time (cold) so the - per-dispatch invoker calls a typed field directly -- no castclass/switch on - the hot path. Exactly one is set per registration kind. - */ - private Action _scalarAction; - private MessageHandler.FastHandler _scalarFast; - private Action _contextAction; - private MessageHandler.FastHandlerWithContext _contextFast; - internal BroadcastRegistration( MessageRegistrationToken token, RegistrationKind kind, @@ -3084,85 +3475,77 @@ internal override MessageHandler.HandlerDeregistration RegisterValidated() switch (_kind) { case RegistrationKind.BroadcastHandlerAction: - _scalarAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastMessageHandler( _context, - _scalarAction, - AugmentedScalarAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedScalarAction, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.BroadcastHandlerFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastMessageHandler( _context, - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.BroadcastPostProcessorAction: - _scalarAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastPostProcessor( _context, - _scalarAction, - AugmentedScalarAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedScalarAction, _priority, messageBus ) ); case RegistrationKind.BroadcastPostProcessorFast: - _scalarFast = (MessageHandler.FastHandler)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastPostProcessor( _context, - _scalarFast, - AugmentedScalarFast, + (MessageHandler.FastHandler)_userHandler, + GetBinding(messageBus).AugmentedScalarFast, _priority, messageBus ) ); case RegistrationKind.BroadcastWithoutSourceAction: - _contextAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastWithoutSource( - _contextAction, - AugmentedContextAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedContextAction, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.BroadcastWithoutSourceFast: - _contextFast = (MessageHandler.FastHandlerWithContext)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastWithoutSource( - _contextFast, - AugmentedContextFast, + (MessageHandler.FastHandlerWithContext)_userHandler, + GetBinding(messageBus).AugmentedContextFast, priority: _priority, messageBus: messageBus ) ); case RegistrationKind.BroadcastWithoutSourcePostProcessorAction: - _contextAction = (Action)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastWithoutSourcePostProcessor( - _contextAction, - AugmentedContextAction, + (Action)_userHandler, + GetBinding(messageBus).AugmentedContextAction, priority: _priority, messageBus ) ); case RegistrationKind.BroadcastWithoutSourcePostProcessorFast: - _contextFast = (MessageHandler.FastHandlerWithContext)_userHandler; return StoreDeregistration( messageHandler.RegisterSourcedBroadcastWithoutSourcePostProcessor( - _contextFast, - AugmentedContextFast, + (MessageHandler.FastHandlerWithContext)_userHandler, + GetBinding(messageBus).AugmentedContextFast, priority: _priority, messageBus ) @@ -3191,48 +3574,76 @@ public override void Validate() ThrowIfNullHandler(_userHandler, parameterName); } - /* - Broadcast scalar invokers. Emission data carries the stored source - (_context), matching the former AugmentedHandler bodies exactly. - */ - private void AugmentedScalarAction(in T message) + private Binding _binding; + + private Binding GetBinding(IMessageBus messageBus) { - _scalarAction(message); - if (Token._diagnosticMode) + IMessageBus resolvedBus = messageBus ?? Token._messageHandler.DefaultMessageBus; + if (_binding == null || !ReferenceEquals(_binding.MessageBus, resolvedBus)) { - RecordDiagnosticEmission(message, _context); + _binding = new Binding(this, resolvedBus); } + return _binding; } - private void AugmentedScalarFast(in T message) + private sealed class Binding : InvocationBinding { - _scalarFast(in message); - if (Token._diagnosticMode) + private readonly Action _scalarAction; + private readonly MessageHandler.FastHandler _scalarFast; + private readonly Action _contextAction; + private readonly MessageHandler.FastHandlerWithContext _contextFast; + private readonly InstanceId _context; + + public Binding(BroadcastRegistration registration, IMessageBus messageBus) + : base(registration.Token, registration.Handle, messageBus) { - RecordDiagnosticEmission(message, _context); + _scalarAction = registration._userHandler as Action; + _scalarFast = registration._userHandler as MessageHandler.FastHandler; + _contextAction = registration._userHandler as Action; + _contextFast = + registration._userHandler as MessageHandler.FastHandlerWithContext; + _context = registration._context; } - } - /* - Broadcast context invokers for the without-source kinds. Emission data uses - the dispatch-supplied source (the readonly parameter), not the stored _context - (default for these kinds), matching the former bodies exactly. - */ - private void AugmentedContextAction(in InstanceId source, in T message) - { - _contextAction(source, message); - if (Token._diagnosticMode) + public void AugmentedScalarAction(in T message) { - RecordDiagnosticEmission(message, source); + _scalarAction(message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, _context); + } } - } - private void AugmentedContextFast(in InstanceId source, in T message) - { - _contextFast(in source, in message); - if (Token._diagnosticMode) + public void AugmentedScalarFast(in T message) + { + _scalarFast(in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, _context); + } + } + + /* + Broadcast context invokers for the without-source kinds. Emission data uses + the dispatch-supplied source (the readonly parameter), not the stored _context + (default for these kinds), matching the former bodies exactly. + */ + public void AugmentedContextAction(in InstanceId source, in T message) + { + _contextAction(source, message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, source); + } + } + + public void AugmentedContextFast(in InstanceId source, in T message) { - RecordDiagnosticEmission(message, source); + _contextFast(in source, in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, source); + } } } } @@ -3254,7 +3665,7 @@ protected MessageHandler.HandlerDeregistration StoreDeregistration( MessageHandler.InterceptorDeregistrationState deregistration ) { - if (_hasInlineDeregistration) + if (_hasInlineDeregistration || !TryClaimInlineDeregistration()) { return deregistration; } @@ -3467,11 +3878,11 @@ internal override MessageHandler.HandlerDeregistration RegisterValidated() return StoreDeregistration( messageHandler.RegisterGlobalAcceptAll( _untargetedAction, - AugmentedUntargetedAction, + GetBinding(messageBus).AugmentedUntargetedAction, _targetedAction, - AugmentedTargetedAction, + GetBinding(messageBus).AugmentedTargetedAction, _broadcastAction, - AugmentedBroadcastAction, + GetBinding(messageBus).AugmentedBroadcastAction, messageBus ) ); @@ -3480,11 +3891,11 @@ internal override MessageHandler.HandlerDeregistration RegisterValidated() return StoreDeregistration( messageHandler.RegisterGlobalAcceptAll( _untargetedFast, - AugmentedUntargetedFast, + GetBinding(messageBus).AugmentedUntargetedFast, _targetedFast, - AugmentedTargetedFast, + GetBinding(messageBus).AugmentedTargetedFast, _broadcastFast, - AugmentedBroadcastFast, + GetBinding(messageBus).AugmentedBroadcastFast, messageBus ) ); @@ -3521,7 +3932,7 @@ private MessageHandler.HandlerDeregistration StoreDeregistration( MessageHandler.GlobalAcceptAllDeregistrationState deregistration ) { - if (_hasInlineDeregistration) + if (_hasInlineDeregistration || !TryClaimInlineDeregistration()) { return deregistration; } @@ -3531,57 +3942,93 @@ MessageHandler.GlobalAcceptAllDeregistrationState deregistration return this; } - private void AugmentedUntargetedAction(IUntargetedMessage message) + private Binding _binding; + + private Binding GetBinding(IMessageBus messageBus) { - _untargetedAction(message); - if (Token._diagnosticMode) + IMessageBus resolvedBus = messageBus ?? Token._messageHandler.DefaultMessageBus; + if (_binding == null || !ReferenceEquals(_binding.MessageBus, resolvedBus)) { - RecordDiagnosticEmission(message); + _binding = new Binding(this, resolvedBus); } + return _binding; } - private void AugmentedTargetedAction(InstanceId target, ITargetedMessage message) + private sealed class Binding : InvocationBinding { - _targetedAction(target, message); - if (Token._diagnosticMode) + private readonly Action _untargetedAction; + private readonly Action _targetedAction; + private readonly Action _broadcastAction; + private readonly MessageHandler.FastHandler _untargetedFast; + private readonly MessageHandler.FastHandlerWithContext _targetedFast; + private readonly MessageHandler.FastHandlerWithContext _broadcastFast; + + public Binding(GlobalAcceptAllRegistration registration, IMessageBus messageBus) + : base(registration.Token, registration.Handle, messageBus) { - RecordDiagnosticEmission(message, target); + _untargetedAction = registration._untargetedAction; + _targetedAction = registration._targetedAction; + _broadcastAction = registration._broadcastAction; + _untargetedFast = registration._untargetedFast; + _targetedFast = registration._targetedFast; + _broadcastFast = registration._broadcastFast; } - } - private void AugmentedBroadcastAction(InstanceId source, IBroadcastMessage message) - { - _broadcastAction(source, message); - if (Token._diagnosticMode) + public void AugmentedUntargetedAction(IUntargetedMessage message) { - RecordDiagnosticEmission(message, source); + _untargetedAction(message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message); + } } - } - private void AugmentedUntargetedFast(in IUntargetedMessage message) - { - _untargetedFast(in message); - if (Token._diagnosticMode) + public void AugmentedTargetedAction(InstanceId target, ITargetedMessage message) { - RecordDiagnosticEmission(message); + _targetedAction(target, message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, target); + } } - } - private void AugmentedTargetedFast(in InstanceId target, in ITargetedMessage message) - { - _targetedFast(in target, in message); - if (Token._diagnosticMode) + public void AugmentedBroadcastAction(InstanceId source, IBroadcastMessage message) { - RecordDiagnosticEmission(message, target); + _broadcastAction(source, message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, source); + } } - } - private void AugmentedBroadcastFast(in InstanceId source, in IBroadcastMessage message) - { - _broadcastFast(in source, in message); - if (Token._diagnosticMode) + public void AugmentedUntargetedFast(in IUntargetedMessage message) { - RecordDiagnosticEmission(message, source); + _untargetedFast(in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message); + } + } + + public void AugmentedTargetedFast(in InstanceId target, in ITargetedMessage message) + { + _targetedFast(in target, in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, target); + } + } + + public void AugmentedBroadcastFast( + in InstanceId source, + in IBroadcastMessage message + ) + { + _broadcastFast(in source, in message); + if (Token._diagnosticMode) + { + RecordDiagnosticEmission(message, source); + } } } } @@ -3645,11 +4092,14 @@ Keeping the wrapper stateless makes every copy follow that authoritative check. } /// - /// Creates a MessagingRegistrationToken that operates on the given handler. + /// Creates a disabled registration token bound to the given handler. /// /// Message handler to register handlers to. - /// MessageBus to use for this MessageRegistrationToken. Uses the GlobalMessageBus if left null. - /// MessagingRegistrationToken bound to the MessageHandler. + /// + /// Bus override. Pass to use the handler's default bus when a registration activates. + /// + /// A disabled token that stages handlers until is called. + /// is null. public static MessageRegistrationToken Create( MessageHandler messageHandler, IMessageBus messageBus = null @@ -3664,8 +4114,12 @@ public static MessageRegistrationToken Create( } /// - /// Removes all staged registrations and clears token-local diagnostic state. + /// Removes registrations and clears diagnostics when cleanup completes. /// + /// + /// Uses the same failure and reentrancy behavior as . Pending + /// cleanup remains owned by the token. Disposal does not prevent later registrations. + /// public void Dispose() { UnregisterAll(); diff --git a/Runtime/Core/Messages/GlobalStringMessage.cs b/Runtime/Core/Messages/GlobalStringMessage.cs index a97e523f1..18824684c 100644 --- a/Runtime/Core/Messages/GlobalStringMessage.cs +++ b/Runtime/Core/Messages/GlobalStringMessage.cs @@ -18,8 +18,10 @@ namespace DxMessaging.Core.Messages /// public readonly struct GlobalStringMessage : IUntargetedMessage { + /// Gets the type used to route this message through the bus. public Type MessageType => typeof(GlobalStringMessage); + /// Text supplied as the message payload. public readonly string message; /// diff --git a/Runtime/Core/Messages/ReflexiveMessage.cs b/Runtime/Core/Messages/ReflexiveMessage.cs index ce334051e..c16a656ca 100644 --- a/Runtime/Core/Messages/ReflexiveMessage.cs +++ b/Runtime/Core/Messages/ReflexiveMessage.cs @@ -7,7 +7,7 @@ namespace DxMessaging.Core.Messages /// /// /// Used by to determine which components receive the reflected call. - /// - Flat: only the immediate . + /// - Flat: only the immediate UnityEngine.GameObject. /// - Upwards: up the transform parent chain. /// - Downwards: down into children. /// - OnlyIncludeActive: exclude disabled components and components on inactive GameObjects. @@ -21,10 +21,10 @@ namespace DxMessaging.Core.Messages [Flags] public enum ReflexiveSendMode { - [Obsolete("Please use a valid Send Mode")] /// /// Legacy sentinel indicating no traversal; not supported. /// + [Obsolete("Please use a valid Send Mode")] None = 0, /// @@ -48,6 +48,8 @@ public enum ReflexiveSendMode OnlyIncludeActive = 1 << 3, } + /// Lookup identity composed of a method name and a captured ordered parameter-type sequence. + /// The default value compares equal to itself and differs from every constructed key. public readonly struct MethodSignatureKey : IEquatable { private const int HashBase = 5556137; @@ -62,25 +64,34 @@ public enum ReflexiveSendMode /// Creates a lookup key for a reflected method signature. /// /// Name of the method. - /// Ordered parameter list expected by the method. + /// Ordered parameter list captured by the key. Null and empty lists describe no parameters. + /// Nonempty type lists are copied so caller mutations cannot change equality or dictionary lookup. public MethodSignatureKey(string methodName, Type[] parameterTypes) : this() { _methodName = methodName ?? throw new ArgumentNullException(nameof(methodName)); - _parameterTypes = parameterTypes; + _parameterTypes = + parameterTypes == null || parameterTypes.Length == 0 + ? Type.EmptyTypes + : (Type[])parameterTypes.Clone(); _hashCode = CalculateHashCode(); } + internal Type[] CapturedParameterTypes => _parameterTypes ?? Type.EmptyTypes; + private int CalculateHashCode() { - int hashCode = HashBase + _methodName.GetHashCode(); - ReadOnlySpan types = _parameterTypes.AsSpan(); - for (int i = 0; i < types.Length; ++i) + unchecked { - hashCode = hashCode * HashMultiplier + types[i].GetHashCode(); - } + int hashCode = HashBase + _methodName.GetHashCode(); + ReadOnlySpan types = _parameterTypes.AsSpan(); + for (int i = 0; i < types.Length; ++i) + { + hashCode = hashCode * HashMultiplier + types[i].GetHashCode(); + } - return hashCode; + return hashCode; + } } /// @@ -109,18 +120,17 @@ public override bool Equals(object obj) /// true when both keys describe the same method. public bool Equals(MethodSignatureKey other) { - if ( - _parameterTypes.Length != other._parameterTypes.Length - || _methodName != other._methodName - ) + ReadOnlySpan types = _parameterTypes.AsSpan(); + ReadOnlySpan otherTypes = other._parameterTypes.AsSpan(); + if (types.Length != otherTypes.Length || _methodName != other._methodName) { return false; } // ReSharper disable once LoopCanBeConvertedToQuery - for (int i = 0; i < _parameterTypes.Length; ++i) + for (int i = 0; i < types.Length; ++i) { - if (_parameterTypes[i] != other._parameterTypes[i]) + if (types[i] != otherTypes[i]) { return false; } @@ -129,11 +139,19 @@ public bool Equals(MethodSignatureKey other) return true; } + /// Compares two values for equality. + /// Left value to compare. + /// Right value to compare. + /// true when the method names and ordered parameter types match; otherwise false. public static bool operator ==(MethodSignatureKey left, MethodSignatureKey right) { return left.Equals(right); } + /// Compares two values for inequality. + /// Left value to compare. + /// Right value to compare. + /// false when the method names and ordered parameter types match; otherwise true. public static bool operator !=(MethodSignatureKey left, MethodSignatureKey right) { return !(left == right); @@ -146,7 +164,7 @@ public bool Equals(MethodSignatureKey other) /// /// This message is handled specially by the bus to mimic Unity's SendMessage* patterns while still /// flowing through DxMessaging pipelines (interceptors, post-processors, diagnostics). - /// It resolves methods on components on or around the targeted based on + /// It resolves methods on components on or around the targeted UnityEngine.GameObject based on /// and invokes them with provided . /// Prefer type-safe message contracts in production; use this to integrate with legacy patterns. /// @@ -166,13 +184,23 @@ public bool Equals(MethodSignatureKey other) /// public readonly struct ReflexiveMessage : ITargetedMessage { + /// Gets the type used to route this message through the bus. public Type MessageType => typeof(ReflexiveMessage); + /// Name of the method requested by the Unity reflection bridge. public readonly string method; + + /// Traversal flags used to choose eligible Unity receivers. public readonly ReflexiveSendMode sendMode; + + /// Arguments forwarded to the reflected method. public readonly object[] parameters; + + /// Parameter type metadata supplied at construction. + /// The lookup signature is captured separately. Mutating this array does not retarget the message. public readonly Type[] parameterTypes; + /// Lookup key used by the reflection cache for this message. public readonly MethodSignatureKey signatureKey; /// diff --git a/Runtime/Core/Messages/SourcedStringMessage.cs b/Runtime/Core/Messages/SourcedStringMessage.cs index 516446ef3..474a5d3e8 100644 --- a/Runtime/Core/Messages/SourcedStringMessage.cs +++ b/Runtime/Core/Messages/SourcedStringMessage.cs @@ -18,8 +18,10 @@ namespace DxMessaging.Core.Messages /// public readonly struct SourcedStringMessage : IBroadcastMessage { + /// Gets the type used to route this message through the bus. public Type MessageType => typeof(SourcedStringMessage); + /// Text supplied as the message payload. public readonly string message; /// diff --git a/Runtime/Core/Messages/StringMessage.cs b/Runtime/Core/Messages/StringMessage.cs index 81c706c54..4f60af087 100644 --- a/Runtime/Core/Messages/StringMessage.cs +++ b/Runtime/Core/Messages/StringMessage.cs @@ -20,8 +20,10 @@ namespace DxMessaging.Core.Messages /// public readonly struct StringMessage : ITargetedMessage { + /// Gets the type used to route this message through the bus. public Type MessageType => typeof(StringMessage); + /// Text supplied as the message payload. public readonly string message; /// diff --git a/Runtime/Core/Pooling/DxPools.cs b/Runtime/Core/Pooling/DxPools.cs index 48d4bf721..b2dc91022 100644 --- a/Runtime/Core/Pooling/DxPools.cs +++ b/Runtime/Core/Pooling/DxPools.cs @@ -8,8 +8,8 @@ namespace DxMessaging.Core.Pooling /// /// Static, single-threaded pool registry shared by the bus and handler - /// subsystems. Instantiated lazily on first use; reconfigured by - /// when the runtime settings asset changes. + /// subsystems. Instantiated lazily on first use. Unity runtime settings + /// reconfigure retained-entry limits. /// /// /// Each pool returns the per-entry recycle action that clears the entry 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/Runtime/Unity/CurrentGlobalMessageBusProvider.cs b/Runtime/Unity/CurrentGlobalMessageBusProvider.cs index 5f762f007..45b255159 100644 --- a/Runtime/Unity/CurrentGlobalMessageBusProvider.cs +++ b/Runtime/Unity/CurrentGlobalMessageBusProvider.cs @@ -17,7 +17,7 @@ namespace DxMessaging.Unity /// // Configure a MessagingComponent at design time by dragging the asset into a MessagingComponentInstaller /// messagingComponentInstaller.SetProvider(CurrentGlobalMessageBusProviderHandle); /// - /// + /// [CreateAssetMenu( fileName = "CurrentGlobalMessageBusProvider", menuName = "Wallstop Studios/DxMessaging/Message Bus Providers/Current Global Message Bus" diff --git a/Runtime/Unity/InitialGlobalMessageBusProvider.cs b/Runtime/Unity/InitialGlobalMessageBusProvider.cs index cdbfd32a9..27552062d 100644 --- a/Runtime/Unity/InitialGlobalMessageBusProvider.cs +++ b/Runtime/Unity/InitialGlobalMessageBusProvider.cs @@ -16,7 +16,7 @@ namespace DxMessaging.Unity /// /// /// // Temporarily override the global bus while still keeping a reference to the startup instance. - /// var initialProvider = Resources.Load("InitialGlobalMessageBusProvider"); + /// var initialProvider = Resources.Load<InitialGlobalMessageBusProvider>("InitialGlobalMessageBusProvider"); /// IMessageBus startupBus = initialProvider.Resolve(); /// using (MessageHandler.OverrideGlobalMessageBus(customBus)) /// { @@ -24,7 +24,7 @@ namespace DxMessaging.Unity /// } /// // startupBus still references the original global bus /// - /// + /// [CreateAssetMenu( fileName = "InitialGlobalMessageBusProvider", menuName = "Wallstop Studios/DxMessaging/Message Bus Providers/Initial Global Message Bus" diff --git a/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs b/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs index 9e9d4349a..05b7c0bb1 100644 --- a/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs +++ b/Runtime/Unity/Integrations/Reflex/ReflexRegistrationInstaller.cs @@ -70,6 +70,7 @@ private IMessageBusProvider TryResolveProvider() } } + /// Adapts a Reflex container to IMessageBusProvider. public sealed class ContainerMessageBusProvider : IMessageBusProvider { private readonly Container _container; diff --git a/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs b/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs index f6d3d3758..d19f20e78 100644 --- a/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs +++ b/Runtime/Unity/Integrations/VContainer/VContainerRegistrationExtensions.cs @@ -25,7 +25,7 @@ public static class VContainerRegistrationExtensions /// pattern would still latch onto a clock-taking private overload whose /// dependency is not registered. Always prefer this /// helper. Calling this helper alongside a bare bus registration raises a - /// VContainerException at time. + /// VContainerException at time. /// /// Container builder receiving the registration. /// Lifetime to assign to the registration. Defaults to . diff --git a/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs b/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs index 833ff878b..d3ba92899 100644 --- a/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs +++ b/Runtime/Unity/Integrations/Zenject/ZenjectRegistrationInstaller.cs @@ -40,6 +40,7 @@ private static IMessageRegistrationBuilder CreateBuilder(InjectContext context) ); } + /// Adapts a Zenject container and its optional cached bus to IMessageBusProvider. public sealed class ContainerMessageBusProvider : IMessageBusProvider { private readonly DiContainer _container; diff --git a/Runtime/Unity/MessageAwareComponent.cs b/Runtime/Unity/MessageAwareComponent.cs index 953839ffc..8fa1f690f 100644 --- a/Runtime/Unity/MessageAwareComponent.cs +++ b/Runtime/Unity/MessageAwareComponent.cs @@ -56,6 +56,7 @@ public abstract class MessageAwareComponent : MonoBehaviour /// public virtual MessageRegistrationToken Token => _messageRegistrationToken; + /// Token created by the companion MessagingComponent for this listener. protected MessageRegistrationToken _messageRegistrationToken; /// @@ -89,9 +90,16 @@ public abstract class MessageAwareComponent : MonoBehaviour /// Changed in v4.0.0. The default is now false. protected virtual bool RegisterForStringMessages => false; + /// Companion component used to create and release this listener token. protected MessagingComponent _messagingComponent; + + /// Explicit bus override supplied through component configuration, if any. protected IMessageBus _configuredMessageBus; + + /// Provider configured to supply the bus when no explicit override is used. protected IMessageBusProvider _configuredMessageBusProvider; + + /// Cached availability handle used when resolving the configured provider. protected MessageBusProviderHandle _configuredMessageBusProviderHandle; #if UNITY_EDITOR || DEBUG diff --git a/Runtime/Unity/MessagingComponent.cs b/Runtime/Unity/MessagingComponent.cs index db2f7ea7d..993f27186 100644 --- a/Runtime/Unity/MessagingComponent.cs +++ b/Runtime/Unity/MessagingComponent.cs @@ -4,6 +4,7 @@ namespace DxMessaging.Unity using System; using System.Collections.Generic; using System.Linq; + using System.Runtime.ExceptionServices; using Core; using Core.MessageBus; using UnityEngine; @@ -37,6 +38,11 @@ public sealed class MessagingComponent : MonoBehaviour private MessageHandler _messageHandler; private bool _destroyed; + private bool _applyingMessageBusConfiguration; + private Queue _pendingMessageBusConfigurations; + + // Retain cleanup owners only until the outermost release frame exits. + private List _releasingTokens; [NonSerialized] private IMessageBus _messageBusOverride; @@ -140,6 +146,16 @@ out MessageRegistrationToken createdToken /// /// Message bus to prefer. Pass null to fall back to the global bus. /// Controls whether existing registrations move to the new bus immediately. + /// + /// Active migration snapshots current listener owners. A bus cleanup callback can release or + /// replace a listener; the remaining migration skips tokens no longer owned by this component. + /// Work accepted independently on a released token remains under its caller's ownership. + /// A configuration requested from a bus or provider callback queues until the current + /// migration returns from foreign code. Queued requests retain their order and mode; + /// their migrations and errors complete through the outermost configuration call. + /// One error is rethrown unchanged; multiple errors are reported by an AggregateException. + /// Fixed in v4.0.0. + /// public void Configure(IMessageBus messageBus, MessageBusRebindMode rebindMode) { _messageBusOverride = messageBus; @@ -157,6 +173,11 @@ public void Configure(IMessageBus messageBus, MessageBusRebindMode rebindMode) /// treated as unavailable. /// /// Controls whether existing listeners should migrate immediately. + /// + /// Requests made during another configuration queue until the outermost call drains them. + /// Resolution and migration errors from queued requests are reported by that outermost call. + /// Fixed in v4.0.0. + /// public void Configure( IMessageBusProvider messageBusProvider, MessageBusRebindMode rebindMode @@ -182,6 +203,11 @@ MessageBusRebindMode rebindMode /// prior explicit bus with the global fallback. /// /// Controls whether existing listeners should migrate immediately. + /// + /// Requests made during another configuration queue until the outermost call drains them. + /// Resolution and migration errors from queued requests are reported by that outermost call. + /// Fixed in v4.0.0. + /// public void Configure( MessageBusProviderHandle providerHandle, MessageBusRebindMode rebindMode @@ -207,14 +233,16 @@ MessageBusRebindMode rebindMode /// Listener whose token should be released. /// /// Invokes and removes the listener from the internal cache after - /// disposal succeeds. If token disposal throws, the listener remains cached so release can be retried. Safe to call - /// multiple times after a successful release. A destroyed Unity listener can still be released by its retained + /// disposal succeeds and leaves no accepted cleanup. If disposal throws, the listener remains cached for retry. + /// Safe to call multiple times after a successful release. A destroyed Unity listener can still be released by its retained /// reference. + /// A nested release of the same token returns false while its owning release is in progress. + /// Work accepted during cleanup remains cached and can be released by a later call. /// Fixed in v4.0.0. /// /// /// true when a retained token was disposed and removed; otherwise false for a CLR null - /// reference or a live or destroyed listener with no retained token. + /// reference, a listener with no retained token, an in-progress nested release, or accepted work that remains active. /// public bool Release(MonoBehaviour listener) { @@ -225,9 +253,34 @@ public bool Release(MonoBehaviour listener) if (_registeredListeners.TryGetValue(listener, out MessageRegistrationToken token)) { - token?.Dispose(); - _ = _registeredListeners.Remove(listener); - return true; + List releasing = _releasingTokens ??= new(); + for (int index = 0; index < releasing.Count; ++index) + { + if (ReferenceEquals(releasing[index], token)) + { + return false; + } + } + + releasing.Add(token); + try + { + token?.Dispose(); + if (token != null && token.Enabled) + { + return false; + } + _ = _registeredListeners.Remove(listener); + return true; + } + finally + { + releasing.RemoveAt(releasing.Count - 1); + if (releasing.Count == 0) + { + _releasingTokens = null; + } + } } return false; @@ -381,9 +434,84 @@ private MessageHandler CreateMessageHandler() return handler; } + /// Drains accepted requests without overlapping listener migrations. private void ApplyMessageBusConfiguration(MessageBusRebindMode rebindMode) { - IMessageBus resolvedBus = ResolveConfiguredBus(); + /* + Retain provider identity before Resolve can accept another configuration. Deferred + resolution then preserves both request inputs and acceptance order. + */ + MessageBusConfiguration configuration = new( + _messageBusOverride, + _messageBusOverride == null ? ResolveConfiguredProvider() : null, + rebindMode + ); + if (_applyingMessageBusConfiguration) + { + (_pendingMessageBusConfigurations ??= new()).Enqueue(configuration); + return; + } + + _applyingMessageBusConfiguration = true; + Exception firstFailure = null; + List failures = null; + try + { + while (true) + { + try + { + ApplyMessageBusConfiguration(in configuration); + } + catch (Exception exception) + { + if (firstFailure == null) + { + firstFailure = exception; + } + else + { + (failures ??= new() { firstFailure }).Add(exception); + } + } + + if ( + _pendingMessageBusConfigurations == null + || _pendingMessageBusConfigurations.Count == 0 + ) + { + break; + } + configuration = _pendingMessageBusConfigurations.Dequeue(); + } + } + finally + { + _pendingMessageBusConfigurations = null; + _applyingMessageBusConfiguration = false; + } + + if (failures != null) + { + throw new AggregateException("Component bus configuration failed.", failures); + } + if (firstFailure != null) + { + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + } + + /// Applies one accepted request after earlier foreign callbacks have returned. + private void ApplyMessageBusConfiguration(in MessageBusConfiguration configuration) + { + IMessageBus resolvedBus = configuration.MessageBus; + if ( + resolvedBus == null + && MessageBusProviderUtility.IsAvailable(configuration.Provider) + ) + { + resolvedBus = configuration.Provider.Resolve(); + } _messageHandler?.SetDefaultMessageBus(resolvedBus); if (_registeredListeners.Count == 0) { @@ -395,13 +523,49 @@ private void ApplyMessageBusConfiguration(MessageBusRebindMode rebindMode) Zero is the legacy Unknown value. Compare the default without naming the obsolete compatibility member so strict consumers do not inherit CS0618. */ - rebindMode == default + configuration.RebindMode == default ? MessageBusRebindMode.RebindActive - : rebindMode; + : configuration.RebindMode; + + /* + Retargeting calls foreign bus cleanup, which can release or replace another + listener. Only migrate identities that remain owned after each callback. + */ + foreach ( + KeyValuePair< + MonoBehaviour, + MessageRegistrationToken + > listener in _registeredListeners.ToArray() + ) + { + if ( + _registeredListeners.TryGetValue( + listener.Key, + out MessageRegistrationToken token + ) && ReferenceEquals(token, listener.Value) + ) + { + token.RetargetMessageBus(resolvedBus, effectiveMode); + } + } + } - foreach (MessageRegistrationToken token in _registeredListeners.Values) + /// Retains the bus/provider and mode accepted by one configuration call. + private readonly struct MessageBusConfiguration + { + internal readonly IMessageBus MessageBus; + internal readonly IMessageBusProvider Provider; + internal readonly MessageBusRebindMode RebindMode; + + internal MessageBusConfiguration( + IMessageBus messageBus, + IMessageBusProvider provider, + MessageBusRebindMode rebindMode + ) { - token.RetargetMessageBus(resolvedBus, effectiveMode); + MessageBus = messageBus; + Provider = provider; + RebindMode = rebindMode; } } 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/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs index a5b89909f..c144f213a 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs @@ -513,6 +513,142 @@ public readonly partial struct M ); } + /// + /// Compiles generated declarations and their consumers for escaped and ordinary identifiers. + /// + /// + /// 2026-10-11: Escaped identifiers previously caused invalid file hints or unescaped source. + /// Source generation must preserve semantic names while emitting valid C# and file hints. + /// + [TestCase("Sample", "Message", "", "", "value")] + [TestCase("Sample", "@class", "", "", "value")] + [TestCase("Sample", "Message", "@event", "", "value")] + [TestCase("Sample", "Message", "", "@for", "value")] + [TestCase("Sample", "Message", "Outer<@for>", "", "value")] + [TestCase("Sample", "Message", "Outer", "", "@event")] + [TestCase("Sample", "@class", "@event<@for>", "@while", "@switch")] + [TestCase("@namespace", "Message", "", "", "value")] + [TestCase("Sample", "\u03a9", "\u0394", "\u03a3", "\u03c6")] + public void GeneratedDeclarationsPreserveEscapedIdentifiers( + string namespaceName, + string typeName, + string containerName, + string typeParameter, + string fieldName + ) + { + string typeParameters = string.IsNullOrEmpty(typeParameter) ? "" : $"<{typeParameter}>"; + string containerOpen = string.IsNullOrEmpty(containerName) + ? "" + : $"public partial class {containerName} {{"; + string containerClose = string.IsNullOrEmpty(containerName) ? "" : "}"; + string source = $$""" +using DxMessaging.Core.Attributes; + +namespace {{namespaceName}}; + +{{containerOpen}} +[DxAutoConstructor] +public readonly partial struct {{typeName}}{{typeParameters}} +{ + public readonly int {{fieldName}}; + public static int ReadConstructedValue() => new {{typeName}}{{typeParameters}}(42).{{fieldName}}; +} +{{containerClose}} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Assert.That( + result.Results.SelectMany(static run => run.GeneratedSources), + Is.Not.Empty, + "A valid attributed declaration must produce source. Diagnostics:\n" + + string.Join( + "\n", + result.Diagnostics.Select(static diagnostic => diagnostic.ToString()) + ) + + string.Join("\n", result.Results.Select(static run => run.Exception?.ToString())) + ); + Diagnostic[] errors = GeneratorTestUtilities + .CompileGeneratedOutput(source, result) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + errors, + Is.Empty, + $"Generated output must compile for {containerName}/{typeName}/{typeParameter}/{fieldName}:\n" + + string.Join("\n", errors.Select(static diagnostic => diagnostic.ToString())) + ); + } + + /// + /// Distinct legal type declarations must retain both generated member sets. + /// + /// + /// 2026-10-11: Generic delimiters previously normalized to identifier underscores, + /// so a generic type and a matching nongeneric name could share one file hint. + /// + [TestCase("T", "Packet_T_", false, false)] + [TestCase("T", "Packet_T_", false, true)] + [TestCase("T_U", "Packet_T_U_", false, false)] + [TestCase("@event", "Packet_event_", false, false)] + [TestCase("T", "Outer_T_", true, false)] + [TestCase("T", "Outer_T_", true, true)] + [TestCase("T", "DifferentPacket", false, false)] + [TestCase("T", "DifferentOuter", true, true)] + public void DistinctTypeNamesKeepGeneratedFilesSeparate( + string typeParameter, + string nongenericName, + bool nested, + bool reversed + ) + { + string parameters = nested ? "" : $"<{typeParameter}>"; + string first = $$""" +[DxAutoConstructor] +public readonly partial struct Packet{{parameters}} +{ + public readonly int value; + public static int ReadGeneratedMember() => new Packet{{parameters}}(42).value; +} +"""; + string secondType = nested ? "Packet" : nongenericName; + string second = $$""" +[DxAutoConstructor] +public readonly partial struct {{secondType}} +{ + public readonly int value; + public static int ReadGeneratedMember() => new {{secondType}}(42).value; +} +"""; + if (nested) + { + first = $"public partial class Outer<{typeParameter}> {{ {first} }}"; + second = $"public partial class {nongenericName} {{ {second} }}"; + } + string declarations = reversed ? second + first : first + second; + string source = $"using DxMessaging.Core.Attributes; namespace Sample {{ {declarations} }}"; + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Assert.That( + result.Results.Select(static run => run.Exception), + Has.All.Null, + "Distinct attributed types must not abort generation. Diagnostics:\n" + + string.Join( + "\n", + result.Diagnostics.Select(static diagnostic => diagnostic.ToString()) + ) + ); + Diagnostic[] errors = GeneratorTestUtilities + .CompileGeneratedOutput(source, result) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + errors, + Is.Empty, + $"Both generated member sets must compile for {typeParameter}/{nongenericName}/{nested}/{reversed}:\n" + + string.Join("\n", errors.Select(static diagnostic => diagnostic.ToString())) + ); + } + /* ------------------------------------------------------------------------------------------ Helpers. diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs index e18ef76e0..63f9b1c89 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs @@ -625,6 +625,144 @@ namespace Sample; AssertGeneratedOutputCompilesForIl2Cpp(source); } + /// + /// Compiles generated declarations and their consumers for escaped and ordinary identifiers. + /// + /// + /// 2026-10-11: Escaped identifiers previously caused invalid file hints or unescaped source. + /// Source generation must preserve semantic names while emitting valid C# and file hints. + /// + [TestCase("Sample", "Message", "", "", "value")] + [TestCase("Sample", "@class", "", "", "value")] + [TestCase("Sample", "Message", "@event", "", "value")] + [TestCase("Sample", "Message", "", "@for", "value")] + [TestCase("Sample", "Message", "Outer<@for>", "", "value")] + [TestCase("Sample", "Message", "Outer", "", "@event")] + [TestCase("Sample", "@class", "@event<@for>", "@while", "@switch")] + [TestCase("@namespace", "Message", "", "", "value")] + [TestCase("Sample", "\u03a9", "\u0394", "\u03a3", "\u03c6")] + public void GeneratedDeclarationsPreserveEscapedIdentifiers( + string namespaceName, + string typeName, + string containerName, + string typeParameter, + string fieldName + ) + { + string typeParameters = string.IsNullOrEmpty(typeParameter) ? "" : $"<{typeParameter}>"; + string containerOpen = string.IsNullOrEmpty(containerName) + ? "" + : $"public partial class {containerName} {{"; + string containerClose = string.IsNullOrEmpty(containerName) ? "" : "}"; + string source = $$""" +using DxMessaging.Core.Attributes; + +namespace {{namespaceName}}; + +{{containerOpen}} +[DxUntargetedMessage] +public readonly partial struct {{typeName}}{{typeParameters}} +{ + public readonly int {{fieldName}}; + public static System.Type ReadMessageType() => new {{typeName}}{{typeParameters}}().MessageType; +} +{{containerClose}} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Assert.That( + result.Results.SelectMany(static run => run.GeneratedSources), + Is.Not.Empty, + "A valid attributed declaration must produce source. Diagnostics:\n" + + string.Join( + "\n", + result.Diagnostics.Select(static diagnostic => diagnostic.ToString()) + ) + + string.Join("\n", result.Results.Select(static run => run.Exception?.ToString())) + ); + Diagnostic[] errors = GeneratorTestUtilities + .CompileGeneratedOutput(source, result) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + errors, + Is.Empty, + $"Generated output must compile for {containerName}/{typeName}/{typeParameter}/{fieldName}:\n" + + string.Join("\n", errors.Select(static diagnostic => diagnostic.ToString())) + ); + AssertGeneratedOutputCompilesForIl2Cpp(source); + } + + /// + /// Distinct legal type declarations must retain both generated member sets. + /// + /// + /// 2026-10-11: Generic delimiters previously normalized to identifier underscores, + /// so a generic type and a matching nongeneric name could share one file hint. + /// + [TestCase("T", "Packet_T_", false, false)] + [TestCase("T", "Packet_T_", false, true)] + [TestCase("T_U", "Packet_T_U_", false, false)] + [TestCase("@event", "Packet_event_", false, false)] + [TestCase("T", "Outer_T_", true, false)] + [TestCase("T", "Outer_T_", true, true)] + [TestCase("T", "DifferentPacket", false, false)] + [TestCase("T", "DifferentOuter", true, true)] + public void DistinctTypeNamesKeepGeneratedFilesSeparate( + string typeParameter, + string nongenericName, + bool nested, + bool reversed + ) + { + string parameters = nested ? "" : $"<{typeParameter}>"; + string first = $$""" +[DxUntargetedMessage] +public readonly partial struct Packet{{parameters}} +{ + public readonly int value; + public static System.Type ReadGeneratedMember() => new Packet{{parameters}}().MessageType; +} +"""; + string secondType = nested ? "Packet" : nongenericName; + string second = $$""" +[DxUntargetedMessage] +public readonly partial struct {{secondType}} +{ + public readonly int value; + public static System.Type ReadGeneratedMember() => new {{secondType}}().MessageType; +} +"""; + if (nested) + { + first = $"public partial class Outer<{typeParameter}> {{ {first} }}"; + second = $"public partial class {nongenericName} {{ {second} }}"; + } + string declarations = reversed ? second + first : first + second; + string source = $"using DxMessaging.Core.Attributes; namespace Sample {{ {declarations} }}"; + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Assert.That( + result.Results.Select(static run => run.Exception), + Has.All.Null, + "Distinct attributed types must not abort generation. Diagnostics:\n" + + string.Join( + "\n", + result.Diagnostics.Select(static diagnostic => diagnostic.ToString()) + ) + ); + Diagnostic[] errors = GeneratorTestUtilities + .CompileGeneratedOutput(source, result) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + errors, + Is.Empty, + $"Both generated member sets must compile for {typeParameter}/{nongenericName}/{nested}/{reversed}:\n" + + string.Join("\n", errors.Select(static diagnostic => diagnostic.ToString())) + ); + AssertGeneratedOutputCompilesForIl2Cpp(source); + } + /* ------------------------------------------------------------------------------------------ Helpers. diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxAutoConstructorGenerator.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxAutoConstructorGenerator.cs index 2299b61dd..f875480d1 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxAutoConstructorGenerator.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxAutoConstructorGenerator.cs @@ -16,6 +16,15 @@ namespace WallstopStudios.DxMessaging.SourceGenerators [Generator] public sealed class DxAutoConstructorGenerator : ISourceGenerator { + private static readonly SymbolDisplayFormat DeclarationNameFormat = new( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + ); + + private static readonly SymbolDisplayFormat IdentifierNameFormat = + DeclarationNameFormat.WithGenericsOptions(SymbolDisplayGenericsOptions.None); + private static readonly DiagnosticDescriptor InvalidOptionalDefaultDiagnostic = new( id: "DXMSG005", title: "Invalid optional default value", @@ -252,9 +261,10 @@ GeneratorExecutionContext context string hintName = $"{typeInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.AutoGenConstructor.g.cs" .Replace("global::", "") - .Replace("<", "_") - .Replace(">", "_") - .Replace(",", "_"); // Clean hint name for file system + .Replace("@", "") + .Replace("<", "-") + .Replace(">", "-") + .Replace(",", "-"); // Hyphens cannot collide with legal C# identifier characters. context.AddSource(hintName, SourceText.From(generatedSource, Encoding.UTF8)); } @@ -312,16 +322,11 @@ GeneratorExecutionContext spc }; // Render generic parameters for the container - string containerTypeParams = - 0 < container.TypeParameters.Length - ? "<" - + string.Join(", ", container.TypeParameters.Select(static p => p.Name)) - + ">" - : string.Empty; + string containerName = container.ToDisplayString(DeclarationNameFormat); // Do not repeat modifiers like sealed/abstract/static/readonly/ref here to avoid changing semantics across parts containersOpen.AppendLine( - $"{currentIndent}{containerAccessibility} partial {containerKind} {container.Name}{containerTypeParams}" + $"{currentIndent}{containerAccessibility} partial {containerKind} {containerName}" ); containersOpen.Append(currentIndent).AppendLine("{"); currentIndent += Indent; @@ -330,13 +335,7 @@ GeneratorExecutionContext spc string innerIndent = currentIndent; // indent level for the target (innermost) type // Use simple identifier + type parameters (no containers) because we are inside container wrappers - string typeGenericParams = - 0 < typeSymbol.TypeParameters.Length - ? "<" - + string.Join(", ", typeSymbol.TypeParameters.Select(static p => p.Name)) - + ">" - : string.Empty; - string typeName = typeSymbol.Name + typeGenericParams; + string typeName = typeSymbol.ToDisplayString(DeclarationNameFormat); bool typeIsRecord = IsRecordDeclaration(typeSymbol); string typeKind = typeSymbol.TypeKind switch { @@ -387,6 +386,7 @@ GeneratorExecutionContext spc { string fieldType = field.Type.ToDisplayString(fieldTypeFormat); string fieldName = field.Name; + string parameterName = field.ToDisplayString(IdentifierNameFormat); string? defaultExpr = null; bool isOptional = false; @@ -507,8 +507,10 @@ GeneratorExecutionContext spc break; // only one DxOptionalParameterAttribute expected } - parameterDetails.Add((fieldType, fieldName, isOptional, defaultExpr)); - constructorBody.AppendLine($"{Indent}{Indent} this.{fieldName} = {fieldName};"); + parameterDetails.Add((fieldType, parameterName, isOptional, defaultExpr)); + constructorBody.AppendLine( + $"{Indent}{Indent} this.{parameterName} = {parameterName};" + ); } for (int i = 0; i < parameterDetails.Count; i++) @@ -557,7 +559,7 @@ GeneratorExecutionContext spc $"{Indent} /// ", $"{Indent} /// Auto-generated constructor by DxAutoGenConstructorGenerator.", $"{Indent} /// ", - $"{innerIndent} {constructorAccessibility} {typeSymbol.Name}({constructorParams})", + $"{innerIndent} {constructorAccessibility} {typeSymbol.ToDisplayString(IdentifierNameFormat)}({constructorParams})", $"{innerIndent} {{", $"{constructorBody}", $"{innerIndent} }}", diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxMessageIdGenerator.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxMessageIdGenerator.cs index f912e13ee..881d33360 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxMessageIdGenerator.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/DxMessageIdGenerator.cs @@ -14,6 +14,12 @@ namespace WallstopStudios.DxMessaging.SourceGenerators [Generator] public sealed class DxMessageIdGenerator : ISourceGenerator { + private static readonly SymbolDisplayFormat DeclarationNameFormat = new( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + ); + /* Base IMessage interface (used for implementation checks if needed, and property names) *** Assumes the user has defined this interface in their code *** @@ -502,9 +508,10 @@ out MessageToGenerateInfo existingInfo string implHintName = $"{messageInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.IMessage.g.cs" .Replace("global::", "") - .Replace("<", "_") - .Replace(">", "_") - .Replace(",", "_"); // Clean hint name + .Replace("@", "") + .Replace("<", "-") + .Replace(">", "-") + .Replace(",", "-"); // Hyphens cannot collide with legal C# identifier characters. context.AddSource(implHintName, SourceText.From(implSource, Encoding.UTF8)); generatedAttributedTypes.Add(messageInfo.TypeSymbol); @@ -576,16 +583,11 @@ string targetInterfaceFullName _ => "class", }; - string containerTypeParams = - 0 < container.TypeParameters.Length - ? "<" - + string.Join(", ", container.TypeParameters.Select(static p => p.Name)) - + ">" - : string.Empty; + string containerName = container.ToDisplayString(DeclarationNameFormat); // Avoid repeating sealed/abstract/static/readonly/ref to prevent conflicting semantics containersOpen.AppendLine( - $"{currentIndent}{containerAccessibility} partial {containerKind} {container.Name}{containerTypeParams}" + $"{currentIndent}{containerAccessibility} partial {containerKind} {containerName}" ); containersOpen.Append(currentIndent).AppendLine("{"); currentIndent += Indent; @@ -594,13 +596,7 @@ string targetInterfaceFullName string innerIndent = currentIndent; // Use unqualified nested identifier for declaration (containers already opened) - string typeGenericParams = - 0 < typeSymbol.TypeParameters.Length - ? "<" - + string.Join(", ", typeSymbol.TypeParameters.Select(static p => p.Name)) - + ">" - : string.Empty; - string typeNameWithGenerics = typeSymbol.Name + typeGenericParams; + string typeNameWithGenerics = typeSymbol.ToDisplayString(DeclarationNameFormat); string fullyQualifiedName = typeSymbol.ToDisplayString( SymbolDisplayFormat.FullyQualifiedFormat ); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj index da51c071d..5e4ab364e 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj @@ -15,6 +15,8 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SourceGenerators/global.json b/SourceGenerators/global.json index 90e1d2680..1df46cf91 100644 --- a/SourceGenerators/global.json +++ b/SourceGenerators/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.319", + "version": "9.0.320", "rollForward": "disable", "allowPrerelease": false } diff --git a/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs index 64798198a..f8f1e992f 100644 --- a/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs @@ -2,6 +2,7 @@ namespace DxMessaging.Tests.Editor.Allocations { using System; + using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; @@ -52,6 +53,115 @@ public void RestoreDiagnosticsState() MessagingDebug.LogFunction = _savedLogFunction; } + private static IEnumerable TypedWithoutContextAllocationCases() + { + foreach (MessageScenario scenario in MessageScenarios.AllKinds) + { + if (scenario.Kind != MessageKind.Targeted && scenario.Kind != MessageKind.Broadcast) + { + continue; + } + foreach (bool fast in new[] { false, true }) + { + foreach (bool featured in new[] { false, true }) + { + yield return new TestCaseData(scenario, fast, featured).SetName( + $"TypedWithoutContextAllocation_{scenario.Kind}_Fast{fast}_Featured{featured}" + ); + } + } + } + } + + /// Checks both handle phases after warming fast and featured dispatch plans. + [TestCaseSource(nameof(TypedWithoutContextAllocationCases))] + public void TypedAndWithoutContextHandlePhasesRemainAllocationFree( + MessageScenario scenario, + bool fast, + bool featured + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + string label = scenario + ",fast=" + fast + ",featured=" + featured; + int primaryCalls = 0; + int variantCalls = 0; + switch (scenario.Kind) + { + case MessageKind.Targeted: + { + if (fast) + { + _ = token.RegisterTargeted( + StableTarget, + (in SimpleTargetedMessage _) => ++primaryCalls + ); + _ = token.RegisterTargetedWithoutTargeting( + (in InstanceId _, in SimpleTargetedMessage __) => ++variantCalls + ); + } + else + { + _ = token.RegisterTargeted( + StableTarget, + _ => ++primaryCalls + ); + _ = token.RegisterTargetedWithoutTargeting( + (_, __) => ++variantCalls + ); + } + break; + } + case MessageKind.Broadcast: + { + if (fast) + { + _ = token.RegisterBroadcast( + StableSource, + (in SimpleBroadcastMessage _) => ++primaryCalls + ); + _ = token.RegisterBroadcastWithoutSource( + (in InstanceId _, in SimpleBroadcastMessage __) => + ++variantCalls + ); + } + else + { + _ = token.RegisterBroadcast( + StableSource, + _ => ++primaryCalls + ); + _ = token.RegisterBroadcastWithoutSource( + (_, __) => ++variantCalls + ); + } + break; + } + default: + { + throw new InvalidOperationException( + $"Unhandled MessageKind {scenario.Kind}." + ); + } + } + if (featured) + { + _ = RegisterAllowingInterceptor(scenario.WithInterceptor(true), token); + } + Action emit = BuildEmitClosure(scenario, bus); + AllocationAssertions.AssertNoAllocations("TypedWithoutContext-" + label, emit); + Assert.That(0 < primaryCalls, Is.True, label + ": keyed callbacks ran"); + Assert.That( + variantCalls, + Is.EqualTo(primaryCalls), + label + ": both handle phases must deliver once per emission" + ); + } + ); + } + /// /// Pins zero-allocation emission for class-typed (reference) messages. /// Class messages reuse the same emit path as struct messages (the @@ -266,6 +376,76 @@ copy rather than a box. See the docstring above. ); } + /// Checks steady global dispatch after nested mutation has displaced a snapshot. + [Test] + [Category("Allocation")] + public void GlobalSnapshotsRemainAllocationFreeAfterNestedMutation( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario, + [Values(0, 1, 2)] int shape, + [Values(3, 8)] int count + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + string label = scenario + ",shape=" + shape + ",count=" + count; + Action emit = BuildClassEmitClosure(scenario, bus); + RegisterClassHandler(scenario, token); + bool mutated = false; + int callbacks = 0; + MessageRegistrationHandle removed = default; + MessageRegistrationHandle RegisterSnapshotCallback(bool fast, Action callback) + { + if (fast) + { + return token.RegisterGlobalAcceptAll( + (in IUntargetedMessage _) => callback(), + (in InstanceId _, in ITargetedMessage __) => callback(), + (in InstanceId _, in IBroadcastMessage __) => callback() + ); + } + return token.RegisterGlobalAcceptAll( + _ => callback(), + (_, _) => callback(), + (_, _) => callback() + ); + } + RegisterSnapshotCallback( + shape != 0, + () => + { + if (mutated) + { + return; + } + mutated = true; + token.RemoveRegistration(removed); + RegisterSnapshotCallback(shape == 1, () => ++callbacks); + emit(); + } + ); + for (int index = 0; index < count - 1; ++index) + { + int captured = index; + removed = RegisterSnapshotCallback( + shape == 1, + () => callbacks += captured + 1 + ); + } + emit(); + int before = callbacks; + AllocationAssertions.AssertNoAllocations("GlobalSnapshots-" + label, emit); + Assert.That( + before < callbacks, + Is.True, + label + ": measured dispatch delivered peers" + ); + } + ); + } + /// /// Pins a per-emit upper bound on the struct-message global accept-all /// dispatch path. Emitting a struct message under a registered global diff --git a/Tests/Editor/Allocations/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs index c467efc9c..c5cfeeb60 100644 --- a/Tests/Editor/Allocations/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -12,6 +12,7 @@ namespace DxMessaging.Tests.Editor.Allocations using DxMessaging.Tests.Editor.Benchmarks; using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Benchmarks; + using DxMessaging.Tests.Runtime.Core; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; using UnityEngine; @@ -286,6 +287,99 @@ MessageScenario scenario ); } + /// + /// Keeps emission allocation-free after all augmented handler shapes move to + /// a different bus. Global callbacks use a class payload; typed callbacks use + /// a struct. Registration, rebinding and closure creation precede measurement. + /// + [TestCaseSource( + typeof(DiagnosticOriginAfterDisableTests), + nameof(DiagnosticOriginAfterDisableTests.RebindAllocationCases) + )] + public void ReboundTokenBindingEmitIsZeroAlloc( + MessageScenario scenario, + bool action, + bool global + ) + { + RunWithFreshHarness( + scenario, + (token, original) => + { + MessageBus replacement = MessageBus.CreateForInternalUse( + StopwatchClock.Instance, + idleEvictionTicks: 0, + evictionTickIntervalSeconds: double.PositiveInfinity, + idleEvictionEnabled: false, + trimApiEnabled: true + ); + replacement.DiagnosticsMode = false; + original.DiagnosticsMode = false; + token.DiagnosticMode = false; + int calls = 0; + Action receive = () => calls++; + Action oldEmit = DiagnosticOriginAfterDisableTests.CreateReboundAllocationEmit( + original, + scenario, + global + ); + Action emit = DiagnosticOriginAfterDisableTests.CreateReboundAllocationEmit( + replacement, + scenario, + global + ); + try + { + _ = global + ? DiagnosticOriginAfterDisableTests.RegisterGlobal( + token, + action, + receive + ) + : DiagnosticOriginAfterDisableTests.RegisterHandler( + token, + scenario, + action, + receive + ); + oldEmit(); + Assert.That( + calls, + Is.EqualTo(1), + "Original binding must actually deliver before rebinding." + ); + token.Disable(); + token.RetargetMessageBus( + replacement, + MessageBusRebindMode.PreserveRegistrations + ); + token.Enable(); + emit(); + Assert.That( + calls, + Is.EqualTo(2), + "Rebound binding must actually deliver before measurement." + ); + AllocationAssertions.AssertNoAllocations( + $"Rebound-{scenario.Kind}-Action{action}-Global{global}", + emit + ); + Assert.That( + calls, + Is.GreaterThan(2), + "Measured emissions must invoke the live rebound binding." + ); + } + finally + { + token.Dispose(); + _ = original.Trim(force: true); + _ = replacement.Trim(force: true); + } + } + ); + } + [Test] public void PreboxedStructDispatchIsZeroAlloc( [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] @@ -597,20 +691,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 +715,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/Allocations/RegistrationAllocationCountTests.cs b/Tests/Editor/Allocations/RegistrationAllocationCountTests.cs index 45356a0b4..f8383eeec 100644 --- a/Tests/Editor/Allocations/RegistrationAllocationCountTests.cs +++ b/Tests/Editor/Allocations/RegistrationAllocationCountTests.cs @@ -81,6 +81,12 @@ namespace DxMessaging.Tests.Editor.Allocations /// /// /// + /// Immutable invocation bindings separate diagnostic origin from replayed staging. + /// First activation and different-bus replay create a binding; same-bus replay reuses + /// it. The historical savings above describe earlier changes. Native counts must + /// qualify the current representation before publishing a registration-cost claim. + /// + /// /// The counting rows use (the GC.Alloc profiler /// recorder) rather than a GC.GetTotalMemory byte delta, which is vacuously /// 0 under Unity's Boehm GC. Diagnostics are forced OFF (the default diff --git a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs index f7c389944..57b2e28b0 100644 --- a/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs +++ b/Tests/Editor/Contract/BusGlobalSlotLiveCountTests.cs @@ -7,6 +7,9 @@ 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; +#endif /// /// Contract guardrails for the @@ -50,7 +53,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 +73,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 +103,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 +130,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 +159,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 +195,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 +223,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 +239,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 +268,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 +280,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..d407a39ad 100644 --- a/Tests/Editor/Contract/CounterBasedTouchTests.cs +++ b/Tests/Editor/Contract/CounterBasedTouchTests.cs @@ -3,13 +3,17 @@ namespace DxMessaging.Tests.Editor.Contract using System; using System.Reflection; using DxMessaging.Core; + using DxMessaging.Core.Internal; using DxMessaging.Core.MessageBus; using DxMessaging.Core.MessageBus.Internal; using DxMessaging.Core.Messages; using NUnit.Framework; +#if !UNITY_2021_3_OR_NEWER + using Assert = NUnit.Framework.Legacy.ClassicAssert; +#endif /// - /// Reflection-based contract tests for counter-based slot touch wiring. + /// Contract tests for bus tick progress, global slot age, and stale teardown. /// These intentionally pin likely private/internal names so implementation /// drift fails with focused messages. /// @@ -51,21 +55,23 @@ public void TickStartsAtZero() } [Test] - public void TypedRegisterUpdatesTouchedSlotLastTouchTicks() + public void TypedRegisterAdvancesBusTickAndPublishesLiveSlot() { MessageBus bus = new MessageBus(); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); - - IEvictableSlot slot = ReadTypedSlot( + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); + + TypedSlot slot = ReadTypedSlot( handler, bus, "UntargetedHandleDefault" @@ -73,7 +79,8 @@ public void TypedRegisterUpdatesTouchedSlotLastTouchTicks() long tick = ReadBusTick(bus); Assert.Greater(tick, 0); - Assert.AreEqual(tick, slot.LastTouchTicks); + Assert.IsFalse(slot.IsEmpty); + Assert.AreEqual(1, slot.liveCount); deregistration(); } @@ -91,11 +98,11 @@ public void GlobalRegisterUpdatesTouchedSlotLastTouchTicks() try { MessageBusRegistration deregistration = bus.RegisterGlobalAcceptAll(handler); - IEvictableSlot slot = ReadGlobalSlot(bus); + BusGlobalSlot slot = ReadGlobalSlot(bus); long tick = ReadBusTick(bus); Assert.Greater(tick, 0); - Assert.AreEqual(tick, slot.LastTouchTicks); + Assert.AreEqual(tick, slot.lastTouchTicks); bus.Deregister(in deregistration); } @@ -106,20 +113,22 @@ public void GlobalRegisterUpdatesTouchedSlotLastTouchTicks() } [Test] - public void TypedDeregisterUpdatesTouchedSlotLastTouchTicks() + public void TypedDeregisterAdvancesBusTickAndEmptiesSlot() { MessageBus bus = new MessageBus(); MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; try { Action callback = _ => { }; - Action deregistration = handler.RegisterUntargetedMessageHandler( - callback, - callback, - priority: 17, - messageBus: bus - ); - IEvictableSlot slot = ReadTypedSlot( + Action deregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + callback, + priority: 17, + messageBus: bus + ) + ).AsAction(); + TypedSlot slot = ReadTypedSlot( handler, bus, "UntargetedHandleDefault" @@ -130,7 +139,8 @@ public void TypedDeregisterUpdatesTouchedSlotLastTouchTicks() long deregisterTick = ReadBusTick(bus); Assert.Greater(deregisterTick, registerTick); - Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + Assert.IsTrue(slot.IsEmpty); + Assert.AreEqual(0, slot.liveCount); } finally { @@ -146,14 +156,14 @@ public void GlobalDeregisterUpdatesTouchedSlotLastTouchTicks() try { MessageBusRegistration deregistration = bus.RegisterGlobalAcceptAll(handler); - IEvictableSlot slot = ReadGlobalSlot(bus); + BusGlobalSlot slot = ReadGlobalSlot(bus); long registerTick = ReadBusTick(bus); bus.Deregister(in deregistration); long deregisterTick = ReadBusTick(bus); Assert.Greater(deregisterTick, registerTick); - Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + Assert.AreEqual(deregisterTick, slot.lastTouchTicks); } finally { @@ -169,16 +179,16 @@ public void StaleGlobalDeregisterAfterResetStateDoesNotTouchSlot() try { MessageBusRegistration staleDeregistration = bus.RegisterGlobalAcceptAll(handler); - IEvictableSlot slot = ReadGlobalSlot(bus); + BusGlobalSlot slot = ReadGlobalSlot(bus); InvokeResetState(bus); long tickAfterReset = ReadBusTick(bus); - long touchAfterReset = slot.LastTouchTicks; + long touchAfterReset = slot.lastTouchTicks; bus.Deregister(in staleDeregistration); Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); - Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); + Assert.AreEqual(touchAfterReset, slot.lastTouchTicks); } finally { @@ -204,13 +214,15 @@ public void StaleTypedDeregisterAfterResetStateDoesNotTouchSlot() { currentCallCount++; }; - Action staleDeregistration = handler.RegisterUntargetedMessageHandler( - callback, - staleCallback, - priority: 17, - messageBus: bus - ); - IEvictableSlot slot = ReadTypedSlot( + Action staleDeregistration = ( + handler.RegisterUntargetedMessageHandler( + callback, + staleCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); + TypedSlot slot = ReadTypedSlot( handler, bus, "UntargetedHandleDefault" @@ -218,12 +230,10 @@ public void StaleTypedDeregisterAfterResetStateDoesNotTouchSlot() InvokeResetState(bus); long tickAfterReset = ReadBusTick(bus); - long touchAfterReset = slot.LastTouchTicks; staleDeregistration(); Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); - Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); Assert.IsTrue(slot.IsEmpty); _ = handler.RegisterUntargetedMessageHandler( @@ -263,14 +273,16 @@ public void StaleTargetedContextDeregisterAfterResetStateDoesNotTouchSlot() { currentCallCount++; }; - Action staleDeregistration = handler.RegisterTargetedMessageHandler( - target, - callback, - staleCallback, - priority: 17, - messageBus: bus - ); - IEvictableSlot slot = ReadTypedSlot( + Action staleDeregistration = ( + handler.RegisterTargetedMessageHandler( + target, + callback, + staleCallback, + priority: 17, + messageBus: bus + ) + ).AsAction(); + TypedSlot slot = ReadTypedSlot( handler, bus, "TargetedHandleDefault" @@ -278,12 +290,10 @@ public void StaleTargetedContextDeregisterAfterResetStateDoesNotTouchSlot() InvokeResetState(bus); long tickAfterReset = ReadBusTick(bus); - long touchAfterReset = slot.LastTouchTicks; staleDeregistration(); Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); - Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); Assert.IsTrue(slot.IsEmpty); _ = handler.RegisterTargetedMessageHandler( @@ -325,25 +335,25 @@ public void StaleTypedGlobalDeregisterAfterResetStateDoesNotTouchSlot() }; Action targeted = (_, _) => { }; Action broadcast = (_, _) => { }; - Action staleDeregistration = handler.RegisterGlobalAcceptAll( - originalUntargeted, - staleUntargeted, - targeted, - targeted, - broadcast, - broadcast, - bus - ); - IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); + Action staleDeregistration = ( + handler.RegisterGlobalAcceptAll( + originalUntargeted, + staleUntargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ) + ).AsAction(); + TypedGlobalSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); InvokeResetState(bus); long tickAfterReset = ReadBusTick(bus); - long touchAfterReset = slot.LastTouchTicks; staleDeregistration(); Assert.AreEqual(tickAfterReset, ReadBusTick(bus)); - Assert.AreEqual(touchAfterReset, slot.LastTouchTicks); Assert.IsTrue(slot.IsEmpty); _ = handler.RegisterGlobalAcceptAll( @@ -377,23 +387,26 @@ public void TypedGlobalDeregisterUsesAdvancedBusTick() Action untargeted = _ => { }; Action targeted = (_, _) => { }; Action broadcast = (_, _) => { }; - Action deregistration = handler.RegisterGlobalAcceptAll( - untargeted, - untargeted, - targeted, - targeted, - broadcast, - broadcast, - bus - ); - IEvictableSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); + Action deregistration = ( + handler.RegisterGlobalAcceptAll( + untargeted, + untargeted, + targeted, + targeted, + broadcast, + broadcast, + bus + ) + ).AsAction(); + TypedGlobalSlot slot = ReadTypedGlobalSlot(handler, bus, "UntargetedDefault"); long registerTick = ReadBusTick(bus); deregistration(); long deregisterTick = ReadBusTick(bus); Assert.Greater(deregisterTick, registerTick); - Assert.AreEqual(deregisterTick, slot.LastTouchTicks); + Assert.IsTrue(slot.IsEmpty); + Assert.AreEqual(0, slot.liveCount); } finally { @@ -456,7 +469,7 @@ private static bool IsLongLike(Type type) || type == typeof(uint); } - private static IEvictableSlot ReadGlobalSlot(MessageBus bus) + private static BusGlobalSlot ReadGlobalSlot(MessageBus bus) { FieldInfo field = typeof(MessageBus).GetField( "_globalSlots", @@ -464,11 +477,11 @@ private static IEvictableSlot ReadGlobalSlot(MessageBus bus) ); Assert.IsNotNull(field, "MessageBus must retain private field '_globalSlots'."); object value = field.GetValue(bus); - Assert.IsInstanceOf(value); - return (IEvictableSlot)value; + Assert.IsInstanceOf(value); + return (BusGlobalSlot)value; } - private static IEvictableSlot ReadTypedSlot( + private static TypedSlot ReadTypedSlot( MessageHandler handler, IMessageBus bus, string typedSlotIndexName @@ -482,11 +495,11 @@ string typedSlotIndexName typedSlotIndexName ); object slot = slots.GetValue(index); - Assert.IsInstanceOf(slot); - return (IEvictableSlot)slot; + Assert.IsInstanceOf>(slot); + return (TypedSlot)slot; } - private static IEvictableSlot ReadTypedGlobalSlot( + private static TypedGlobalSlot ReadTypedGlobalSlot( MessageHandler handler, IMessageBus bus, string typedGlobalSlotIndexName @@ -499,8 +512,8 @@ string typedGlobalSlotIndexName typedGlobalSlotIndexName ); object slot = slots.GetValue(index); - Assert.IsInstanceOf(slot); - return (IEvictableSlot)slot; + Assert.IsInstanceOf(slot); + return (TypedGlobalSlot)slot; } private static object ReadTypedHandler(MessageHandler handler, IMessageBus bus) diff --git a/Tests/Editor/Contract/EvictionSweepContractTests.cs b/Tests/Editor/Contract/EvictionSweepContractTests.cs index a6444aa20..2499b9db3 100644 --- a/Tests/Editor/Contract/EvictionSweepContractTests.cs +++ b/Tests/Editor/Contract/EvictionSweepContractTests.cs @@ -9,6 +9,9 @@ namespace DxMessaging.Tests.Editor.Contract using DxMessaging.Core.Messages; using DxMessaging.Core.Pooling; using NUnit.Framework; +#if !UNITY_2021_3_OR_NEWER + using Assert = NUnit.Framework.Legacy.ClassicAssert; +#endif #if UNITY_2021_3_OR_NEWER using DxMessaging.Core.Configuration; using DxMessaging.Tests.Runtime; @@ -66,12 +69,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 +127,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 +194,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 +207,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 +237,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 +259,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 +270,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,17 +320,19 @@ 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"); int slotIndex = TypedSlotIndex.UntargetedHandleDefault; - Assert.IsInstanceOf(slots.GetValue(slotIndex)); + Assert.IsInstanceOf>(slots.GetValue(slotIndex)); System.Collections.Generic.List pooledList = DxPools.ObjectLists.Rent(); DxPools.ObjectLists.Return(pooledList); @@ -363,12 +365,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"); @@ -376,7 +380,7 @@ public void NonForceSweepRetainsFreshDirtyTypedSlotsUntilIdle() IMessageBus.TrimResult freshResult = bus.Trim(force: false); - Assert.IsInstanceOf(slots.GetValue(slotIndex)); + Assert.IsInstanceOf>(slots.GetValue(slotIndex)); Assert.AreEqual(0, freshResult.TypeSlotsEvicted); Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyTypes")); Assert.AreEqual(1, ReadCollectionCount(bus, "_dirtyHandlers")); @@ -453,12 +457,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 +499,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 +561,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 +602,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 +643,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 +717,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 +796,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 +833,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 +930,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 +999,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 +1017,7 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() } finally { - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); DxMessagingRuntimeSettings.RaiseSettingsChanged( DxMessagingRuntimeSettingsProvider.Current ); @@ -1003,9 +1027,7 @@ public void RuntimeSettingsHotReloadUpdatesSweepGatesAndPoolCaps() [TestCase(false)] [TestCase(true)] - public void NestedRuntimeSettingsOverridesRestoreNearestActiveSettings( - bool disposeOuterFirst - ) + public void FixtureSettingsScopesRestoreNearestActiveSettings(bool disposeOuterFirst) { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1018,9 +1040,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 +1067,7 @@ bool disposeOuterFirst innerToken?.Dispose(); outerToken?.Dispose(); originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); DxMessagingRuntimeSettings.RaiseSettingsChanged( DxMessagingRuntimeSettingsProvider.Current ); @@ -1056,7 +1078,7 @@ bool disposeOuterFirst } [Test] - public void NestedRuntimeSettingsOverridesUseTokenIdentityForSameSettingsInstance() + public void NestedFixtureSettingsScopesUseTokenIdentityForSameSettingsInstance() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1067,9 +1089,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 +1112,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 +1135,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 +1167,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 +1176,7 @@ public void DisposedMiddleRuntimeSettingsOverrideIsSkippedDuringUnwind() } [Test] - public void StaticResetInvalidatesOlderRuntimeSettingsOverrideTokens() + public void StaticResetDetachesOlderFixtureSettingsScopes() { DxMessagingRuntimeSettings staleOuter = ScriptableObject.CreateInstance(); @@ -1170,12 +1192,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 +1229,7 @@ public void StaticResetInvalidatesOlderRuntimeSettingsOverrideTokens() } [Test] - public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() + public void FixtureSettingsDisposalRaisesOnlyFinalRestoreEvent() { DxMessagingRuntimeSettings original = ScriptableObject.CreateInstance(); @@ -1227,9 +1249,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 +1287,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 +1295,7 @@ public void OutOfOrderRuntimeSettingsDisposalRaisesOnlyFinalRestoreEvent() } [Test] - public void OutOfOrderRuntimeSettingsDisposalCarriesGlobalBufferSnapshotForward() + public void FixtureSettingsDisposalCarriesGlobalBufferSnapshotForward() { int entryBufferSize = IMessageBus.GlobalMessageBufferSize; DxMessagingRuntimeSettings originalFallback = @@ -1288,10 +1310,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 +1334,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 +1343,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 +1365,7 @@ public void NullRuntimeSettingsOverrideLeavesCurrentSettingsUnchanged() finally { originalToken?.Dispose(); - DxMessagingRuntimeSettingsProvider.ResetForTests(); + RuntimeSettingsFixture.Reset(); UnityEngine.Object.DestroyImmediate(original); } } @@ -1381,12 +1403,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 c023cc045..05e743807 100644 --- a/Tests/Editor/Contract/MessageBusInvariantTests.cs +++ b/Tests/Editor/Contract/MessageBusInvariantTests.cs @@ -10,6 +10,9 @@ 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; +#endif /// /// Contract guardrails for cache storage fields that @@ -23,6 +26,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 +40,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 +204,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 +224,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 +256,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(); @@ -267,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); } } @@ -336,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); @@ -356,7 +355,7 @@ public void TrimDuringActiveDispatchAfterDeregisterPreservesSnapshot() int secondCalls = 0; try { - firstDeregister = + firstDeregister = ( first.RegisterUntargetedMessageHandler( input => { @@ -372,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); @@ -542,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/IEvictableSlot.cs.meta b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta similarity index 83% rename from Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta rename to Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta index b76c69089..424c3efaf 100644 --- a/Runtime/Core/MessageBus/Internal/IEvictableSlot.cs.meta +++ b/Tests/Editor/Contract/ProductionEditorAssemblySurfaceTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d0e14e71e59310d9473809487e65bd0f +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 0665c3fb3..c1cdb36a0 100644 --- a/Tests/Editor/Contract/TypedSlotShapeTests.cs +++ b/Tests/Editor/Contract/TypedSlotShapeTests.cs @@ -9,6 +9,9 @@ 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; +#endif /// /// Contract guardrails for the typed-handler-side slot grid: @@ -21,7 +24,7 @@ namespace DxMessaging.Tests.Editor.Contract /// /// /// These tests pin the eviction-driven monotonic Reset() contract - /// and the shape that typed storage relies on. + /// and the concrete slot storage that typed dispatch relies on. /// Each test below covers one structural invariant in the per-slot /// lifecycle. /// @@ -223,20 +226,6 @@ public void TypedSlotResetBumpsVersionMonotonically() } } - [Test] - public void TypedSlotResetPreservesLastTouchTicks() - { - TypedSlot slot = new TypedSlot(requiresContext: false); - slot.lastTouchTicks = 42; - slot.Reset(); - Assert.AreEqual( - 42, - slot.lastTouchTicks, - "Reset() must preserve lastTouchTicks so the sweep can distinguish " - + "freshly-reset slots from never-touched slots." - ); - } - /// /// Pins the per-cache drain: /// must invoke on every @@ -368,17 +357,16 @@ public void HandlerActionCacheImplementsIHandlerActionCache() Exercise every interface member; failure indicates a misapplied explicit-interface implementation or accidental shadowing. */ - long _ = view.Version; - view.LastSeenVersion = 7; - Assert.AreEqual(7, view.LastSeenVersion); + cache.lastSeenVersion = 7; + Assert.AreEqual(7, cache.lastSeenVersion); Assert.AreEqual( -1, - view.LastSeenEmissionId, + cache.lastSeenEmissionId, "Fresh HandlerActionCache instances must start with an invalid " + "emission sentinel so emission id 0 materializes the first snapshot." ); - view.LastSeenEmissionId = 13; - Assert.AreEqual(13, view.LastSeenEmissionId); + cache.lastSeenEmissionId = 13; + Assert.AreEqual(13, cache.lastSeenEmissionId); Assert.IsTrue( view.IsEmpty, "Freshly-constructed HandlerActionCache must report IsEmpty == true." @@ -401,13 +389,17 @@ selected storage representation. "After populating entries the cache must report IsEmpty == false." ); - long beforeReset = view.Version; + long beforeReset = cache.version; view.Reset(); - Assert.Greater(view.Version, beforeReset, "Reset() must bump version monotonically."); - Assert.AreEqual(-1, view.LastSeenVersion, "Reset() must restore lastSeenVersion = -1."); + Assert.Greater(cache.version, beforeReset, "Reset() must bump version monotonically."); Assert.AreEqual( -1, - view.LastSeenEmissionId, + cache.lastSeenVersion, + "Reset() must restore lastSeenVersion = -1." + ); + Assert.AreEqual( + -1, + cache.lastSeenEmissionId, "Reset() must restore lastSeenEmissionId to an invalid sentinel so " + "emission id 0 still materializes a fresh snapshot." ); @@ -434,8 +426,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 +441,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 +475,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 +491,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 +514,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 +548,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 +569,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 +588,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 +621,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); @@ -642,7 +650,7 @@ params System.Action[] expected { Assert.AreEqual( expected[index], - cache.entries.KeyAt(index), + EntryStorageInspection.KeyAt(cache.entries, index), "Ordered cache key at index {0} drifted.", index ); @@ -957,35 +965,6 @@ public void TypedSlotResetNullsOutByContext() ); } - [Test] - public void TypedSlotClearResetsVersionToZero() - { - TypedSlot slot = new TypedSlot(requiresContext: false); - /* - Drive version above zero via repeated Reset() so the test does - not depend on internal field write access for setup. - */ - slot.Reset(); - slot.Reset(); - slot.Reset(); - Assert.Greater(slot.version, 0); - - slot.Clear(); - - Assert.AreEqual( - 0, - slot.version, - "Clear() is the legacy 'full reset' semantic and must reset " - + "version to 0; eviction-driven monotonicity belongs to Reset()." - ); - Assert.AreEqual(-1, slot.lastSeenVersion); - Assert.AreEqual(-1, slot.lastSeenEmissionId); - Assert.AreEqual(0, slot.liveCount); - Assert.AreEqual(0, slot.byPriority.Count); - Assert.AreEqual(0, slot.orderedPriorities.Count); - Assert.IsNull(slot.byContext); - } - [Test] public void TypedGlobalSlotResetBumpsVersionMonotonically() { @@ -1029,56 +1008,10 @@ public void TypedGlobalSlotResetClearsCache() Assert.IsNull(slot.cache); } - [Test] - public void TypedGlobalSlotResetPreservesLastTouchTicks() - { - TypedGlobalSlot slot = new TypedGlobalSlot(); - slot.lastTouchTicks = 99; - slot.Reset(); - Assert.AreEqual(99, slot.lastTouchTicks); - } - - [Test] - public void TypedGlobalSlotClearResetsVersionToZero() - { - TypedGlobalSlot slot = new TypedGlobalSlot(); - slot.Reset(); - slot.Reset(); - Assert.Greater(slot.version, 0); - - slot.Clear(); - - Assert.AreEqual(0, slot.version); - Assert.AreEqual(-1, slot.lastSeenVersion); - Assert.AreEqual(-1, slot.lastSeenEmissionId); - Assert.AreEqual(0, slot.liveCount); - Assert.IsNull(slot.cache); - } - - [Test] - public void TypedSlotImplementsIEvictableSlot() - { - Assert.IsTrue( - typeof(IEvictableSlot).IsAssignableFrom(typeof(TypedSlot)), - "TypedSlot must implement IEvictableSlot so the sweep can reclaim it." - ); - } - - [Test] - public void TypedGlobalSlotImplementsIEvictableSlot() - { - Assert.IsTrue( - typeof(IEvictableSlot).IsAssignableFrom(typeof(TypedGlobalSlot)), - "TypedGlobalSlot must implement IEvictableSlot so the sweep can reclaim it." - ); - } - /// /// Reflection-based shape pin for . /// Asserts the interface declares exactly the members the staged - /// dispatch + eviction layers require: , - /// , - /// , + /// dispatch + eviction layers require: /// , /// , plus the non-generic /// per-handle teardown ops the unified deregistration object uses: @@ -1094,9 +1027,6 @@ public void IHandlerActionCacheInterfaceShape() { string[] expected = { - "Version", - "LastSeenVersion", - "LastSeenEmissionId", "IsEmpty", "Reset", "ContainsEntry", diff --git a/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs b/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs index 3001618d9..b91bfd4ef 100644 --- a/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs +++ b/Tests/Editor/DxMessagingBaseCallIgnoreSyncTests.cs @@ -2,8 +2,10 @@ namespace DxMessaging.Tests.Editor { using System; + using System.Collections; using System.Collections.Generic; using System.IO; + using System.Linq; using System.Reflection; using DxMessaging.Core.MessageBus; using DxMessaging.Editor; @@ -11,6 +13,7 @@ namespace DxMessaging.Tests.Editor using NUnit.Framework; using UnityEditor; using UnityEngine; + using UnityEngine.TestTools; using Object = UnityEngine.Object; [TestFixture] @@ -107,41 +110,100 @@ 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 IMessageBus _previousBus; + private bool _previousOptionsEnabled; + private EnterPlayModeOptions _previousOptions; + private bool _previousDebugEnabled; + private Action _previousDebugLog; + private bool _fixtureStateCaptured; + + /// + /// 2026-10-26: fresh-project tests entered while compilation was active. The real idle + /// scheduler correctly requeued settings work. Finish the incoming reload before isolating + /// fixture state so the unchanged callback assertions execute with their idle precondition. + /// Keep the reload barrier separate from synchronous setup and teardown on each NUnit repeat. + /// + [UnitySetUp] + public IEnumerator WaitForIncomingCompilation() + { + if (EditorApplication.isCompiling) + { + yield return new WaitForDomainReload(); + } + } [SetUp] public void SetUp() { - _previousDiagnosticsTargets = IMessageBus.GlobalDiagnosticsTargets; - _previousDiagnosticsStackTraces = IMessageBus.GlobalDiagnosticsStackTraces; - _previousMessageBufferSize = IMessageBus.GlobalMessageBufferSize; - _scheduled.Clear(); + _fixtureStateCaptured = false; + Assert.That( + DxMessagingEditorIdle.CanMutateAssetDatabase(), + Is.True, + "The initializer fixture requires an idle Editor before capturing state. " + + $"Compiling={EditorApplication.isCompiling}, updating={EditorApplication.isUpdating}." + ); + _previousTargets = IMessageBus.GlobalDiagnosticsTargets; + _previousStacks = IMessageBus.GlobalDiagnosticsStackTraces; + _previousBuffer = IMessageBus.GlobalMessageBufferSize; + _previousBus = DxMessaging.Core.MessageHandler.MessageBus; + _previousDebugEnabled = DxMessaging.Core.MessagingDebug.enabled; + _previousDebugLog = DxMessaging.Core.MessagingDebug.LogFunction; + _previousOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _previousOptions = EditorSettings.enterPlayModeOptions; + _fixtureStateCaptured = true; + _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() { + if (!_fixtureStateCaptured) + { + return; + } + _fixtureStateCaptured = false; + Application.logMessageReceived -= ObserveWarning; try { + _scheduled?.Dispose(); + _sidecarCallbacks?.Dispose(); foreach (DxMessagingSettings settings in _createdSettings) { if (settings != null) @@ -153,13 +215,57 @@ 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.GlobalDiagnosticsTargets = _previousTargets, + () => IMessageBus.GlobalDiagnosticsStackTraces = _previousStacks, + () => IMessageBus.GlobalMessageBufferSize = _previousBuffer, + () => DxMessaging.Core.MessagingDebug.enabled = _previousDebugEnabled, + () => DxMessaging.Core.MessagingDebug.LogFunction = _previousDebugLog + ); + } + } + + 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,217 +274,175 @@ 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); + string beforeEnsure = CaptureDeferredEnsureState(settings); + _scheduled[0].Invoke(); + string ensureTrace = + $"Before: {beforeEnsure}; after: {CaptureDeferredEnsureState(settings)}"; + TestContext.Out.WriteLine(ensureTrace); Assert.That( IMessageBus.GlobalDiagnosticsTargets, - Is.EqualTo(DiagnosticsTarget.Runtime) + Is.EqualTo(DiagnosticsTarget.Runtime), + ensureTrace ); 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); + string beforeEnsure = CaptureDeferredEnsureState(settings); _scheduled[0].Invoke(); - - Assert.That(ensureCount, Is.EqualTo(1)); + string ensureTrace = + $"Before: {beforeEnsure}; after: {CaptureDeferredEnsureState(settings)}"; + TestContext.Out.WriteLine(ensureTrace); Assert.That( IMessageBus.GlobalDiagnosticsTargets, - Is.EqualTo(DiagnosticsTarget.Runtime) + Is.EqualTo(DiagnosticsTarget.Runtime), + ensureTrace ); Assert.That(IMessageBus.GlobalMessageBufferSize, Is.EqualTo(23)); Assert.That( _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); + } - Assert.That( - _warnings, - Is.Empty, - "Fresh settings assets default to suppressing this advisory warning unless the " - + "user clears the suppression setting." + private string CaptureDeferredEnsureState(DxMessagingSettings expected) + { + object cached = MessageBusInspection.ReadField( + typeof(DxMessagingSettings), + "_cachedSettings" + ); + object cachedRevision = MessageBusInspection.ReadField( + typeof(DxMessagingSettings), + "_cachedSettingsRevision" + ); + object scheduled = MessageBusInspection.ReadField( + typeof(DxMessagingEditorInitializer), + "s_ensureSettingsAssetScheduled" ); + return $"compiling={EditorApplication.isCompiling}, updating={EditorApplication.isUpdating}, " + + $"queued={_scheduled.Count}, scheduled={scheduled}, " + + $"revision={DxMessagingSettings.SettingsRevision}, cachedRevision={cachedRevision}, " + + $"expectedCached={ReferenceEquals(cached, expected)}"; } private DxMessagingSettings NewSettings() @@ -389,61 +453,37 @@ 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() + [UnitySetUp] + public IEnumerator 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++; + if (EditorApplication.isCompiling) + { + yield return new RecompileScripts(); + } + _scheduled = new(typeof(DxMessagingBaseCallIgnoreSync)); + _responseCallbacks = new(typeof(SetupCscRsp)); + _inputs = new(); + _settingsCache = new(); } - [TearDown] - public void TearDown() + [UnityTearDown] + public IEnumerator TearDown() { try { + _scheduled?.Dispose(); + _responseCallbacks?.Dispose(); foreach (DxMessagingSettings settings in _createdSettings) { if (settings != null) @@ -455,21 +495,124 @@ 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(); + } + } + if (EditorApplication.isCompiling) + { + yield return new RecompileScripts(); } } + /// + /// 2026-10-26: startup response-file work can remain queued after a domain reload. + /// Input ownership must suspend those writers until restoration, preserve unrelated + /// callbacks, and drain writers created during the owned operation. The historical + /// Windows file-lock failure did not identify the process holding the response file. + /// + [TestCase(false)] + [TestCase(true)] + public void CompilerInputScopeSuspendsExistingWritersUntilRestoration(bool sidecar) + { + DxMessagingSettings settings = NewSettings(); + for (int cycle = 0; cycle < 10; ++cycle) + { + string context = $"sidecar={sidecar}, cycle={cycle}"; + List owned = new(); + EditorApplication.CallbackFunction unrelated = () => { }; + EditorCompilerInputScope scope = null; + try + { + Delegate[] before = CurrentDelayCallbacks(); + QueueWriter(); + Delegate[] baseline = CurrentDelayCallbacks().Except(before).ToArray(); + owned.AddRange(baseline.Cast()); + Assert.That( + baseline.Length, + Is.EqualTo(1), + $"{context} Queue one actual package writer." + ); + EditorApplication.delayCall += unrelated; + + scope = new(); + Assert.That(CurrentDelayCallbacks(), Does.Contain(unrelated), context); + foreach (Delegate callback in baseline) + { + Assert.That( + CurrentDelayCallbacks().Contains(callback), + Is.False, + $"{context} A pre-existing package writer must not mutate protected compiler inputs." + ); + } + Assert.That(CurrentDelayCallbacks(), Does.Contain(unrelated), context); + + before = CurrentDelayCallbacks(); + QueueWriter(); + Delegate[] temporary = CurrentDelayCallbacks().Except(before).ToArray(); + owned.AddRange(temporary.Cast()); + Assert.That(temporary.Length, Is.EqualTo(1), context); + scope.Dispose(); + scope = null; + + foreach (Delegate callback in baseline) + { + Assert.That( + CurrentDelayCallbacks().Count(candidate => candidate.Equals(callback)), + Is.EqualTo(1), + $"{context} Restore each pre-existing writer exactly once after restoring inputs." + ); + } + foreach (Delegate callback in temporary) + { + Assert.That( + CurrentDelayCallbacks().Contains(callback), + Is.False, + $"{context} Do not leave test-created compiler writers queued after restoration." + ); + } + Assert.That(CurrentDelayCallbacks(), Does.Contain(unrelated), context); + } + finally + { + try + { + scope?.Dispose(); + } + finally + { + foreach (EditorApplication.CallbackFunction callback in owned) + { + EditorApplication.delayCall -= callback; + } + EditorApplication.delayCall -= unrelated; + } + } + } + + void QueueWriter() + { + if (sidecar) + { + DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); + } + else + { + SetupCscRsp.ScheduleAdditionalFileForIgnoreListSync(); + } + } + } + + private static Delegate[] CurrentDelayCallbacks() => + EditorApplication.delayCall?.GetInvocationList() ?? Array.Empty(); + [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 +634,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 +646,169 @@ 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." - ); - Assert.AreEqual( - unchangedTime, - File.GetLastWriteTimeUtc(sidecar), - "Deferred regeneration must leave sidecar bytes untouched." + "Reapplying the actual prepared writers must not import unchanged inputs." ); + Assert.AreEqual(unchangedTime, File.GetLastWriteTimeUtc(sidecar)); 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); } } + /// + /// 2026-10-18: importing a temporary sidecar requested compilation while NUnit locked + /// reloads in Unity 2021 CI. The fixture batches imports until original inputs are + /// restored, then lets the framework complete pending compilation before the next + /// test constructs state. Unity setup is not rerun after that domain reload. + /// [Test] - public void OnValidateDefersSidecarRegenerationInsteadOfImportingSynchronously() + public void RealDeferredCallbacksDoNotRewritePreparedCompilerInputs() { DxMessagingSettings settings = NewSettings(); - - InvokePrivate(settings, "OnValidate"); - + _settingsCache.SetCurrent(settings); + DxMessagingBaseCallIgnoreSync.RegenerateSidecarDeferred(settings); + Assert.That(_scheduled.Count, Is.EqualTo(1)); + SetupCscRsp.PrepareCompilerInputs(); 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)." + DxMessagingEditorIdle.CanMutateAssetDatabase(), + Is.True, + "Temporary compiler inputs must remain batched until fixture restoration. " + + $"Compiling={EditorApplication.isCompiling}, updating={EditorApplication.isUpdating}." ); - Assert.That( - _scheduled.Count, - Is.EqualTo(1), - "OnValidate must schedule exactly one deferred sidecar regeneration." + 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 +816,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 76f286a16..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