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
/// 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