diff --git a/src/MeshWeaver.Documentation/Data/Architecture/BuildIdentityAdmission.md b/src/MeshWeaver.Documentation/Data/Architecture/BuildIdentityAdmission.md index d5d8985bb6..c21af97183 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/BuildIdentityAdmission.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/BuildIdentityAdmission.md @@ -180,6 +180,7 @@ repository on the same night. | surface | before | now | |---|---|---| | `get_diagnostics` (`FormatDiagnosticsFromDef`) | `status: "Ok"` | `status: "Foreign"`, with both identities in `error` | +| `get_diagnostics`, the `contentType` block (#4158) | nothing — every identity on the reply was a claim a build wrote | the assembly the content type RESOLVED to here: path, mvid, and whether it is collectible ([Modules](/Doc/Architecture/Modules) → *…and the RECORD is not the resolution*) | | compile-progress overlay | `Ok` ⇒ **redirect** to the page that cannot render, which bounces back | holds, and says why — localized (`ui.compileForeignFramework`) | | NodeType overview progress line | ✓ **Compiled**, printing the foreign hash beside the green tick as decoration | ⚠ built for another platform build | diff --git a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md index 4e567fda9a..f571441015 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md @@ -635,6 +635,50 @@ registry that cannot start delivering the module breaking it. The remedy the war deployment decision: delist the pack from `Modules:Assemblies` so the landed generation stops being shadowed. +### …and the RECORD is not the resolution (#4158, ask 2) + +🚨 **`get_diagnostics` used to answer entirely in claims.** `mvid` on that reply is +`NodeTypeDefinition.LatestAssemblyMvid` — the identity of the bytes a BUILD produced — and the record +beside it says in as many words that `LatestAssemblyPath` *"is an ADDRESS, not an identity"*. The +statement nobody could get was the one the serializer and `/schema/` actually act on: **which +CLR type this NodeType's content resolves to here, and which assembly that type came from.** Those are +different statements whenever two builds of one assembly NAME are in the process — a module bundle on +the shelf and a runtime-compiled collectible build ([#3732](https://github.com/Systemorph/MeshWeaver/issues/3732)) — +and that is precisely the state in which a stale ADOPTED build and a stale registry SHELF read the +same from a consumer. + +So the reply now carries a `contentType` block, read from the live `IMeshContentTypeRegistry` at the +moment of the call (`ResolvedContentType.Of`, `src/MeshWeaver.Messaging.Hub/Serialization/`): + +| field | what it answers | +|---|---| +| `status` | `resolved` · `unresolved` (asked, nothing registered) · `not-asked` (no registry in this process) | +| `typeName` | the resolved type's full name | +| `assembly` | `Assembly.Location` — **`null`, never `""`**, for an assembly loaded from a byte array | +| `mvid` | `Assembly.ManifestModule.ModuleVersionId`, first 8 hex — minted by the compiler INTO the bytes, so it cannot be stale | +| `collectible` | `true` ⇒ a runtime compile in this process; `false` ⇒ a module shipped with it | + +`collectible` + a null `assembly` is what separates the two builds of one name: an in-process compile +has no file and a collectible context, a shipped module has both the other way round. Nothing gates on +any of it — it is a reading, exactly like `built-from=`. + +Three rules, and each is held by a case that fails without it: + +- **Absence is PRINTED, never inferred.** `unresolved` and `not-asked` are two different sentences: a + missing registry MEASURED NOTHING, and reporting that as "unresolved" would report a finding nobody + made. The `unresolved` sentence also names the consequence — *its views render empty* — because that + is what an operator is actually looking at. +- **Every key is written, always.** The block is composed as an explicit `JsonObject`, not serialised + from an anonymous type. Measured on the first attempt: the hub's options stamp a polymorphic `$type` + on a NESTED object (putting `<>f__AnonymousType15`6[…]` into an envelope people read) and their + default ignore condition DROPS null and default members — so `typeName`, `assembly`, `mvid` and a + `false` `collectible` all vanished, leaving a reader unable to tell *not reported* from *reported as + absent*, which is the one distinction the block exists to make. +- **It is read from the TYPE, never from the path.** The control that holds this loads the test + assembly's own bytes into a collectible `AssemblyLoadContext`, so the same full name is present + twice; the two readings must differ. If the answer were a function of the NodeType path — or of the + node's record — they would be identical and that case goes red. + ### Native assets — `runtimes//native/` (#1728) A module is loaded with `Assembly.LoadFrom`, which never consults the module's own `deps.json`, so diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-diagnostics-say-which-assembly-a-types-content-comes-from.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-diagnostics-say-which-assembly-a-types-content-comes-from.md new file mode 100644 index 0000000000..6f0d54dccd --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-diagnostics-say-which-assembly-a-types-content-comes-from.md @@ -0,0 +1,43 @@ +--- +Name: Diagnostics say which assembly a type's content comes from +Category: Feature +Description: A NodeType's diagnostics now name the assembly its content type actually resolved to in the running installation — its file, its fingerprint, and whether it was compiled here — so "the record says this build" and "this is the build answering" stop looking like the same statement. +Icon: Search +Order: -20260913 +--- + +# Diagnostics say which assembly a type's content comes from + +Ask an installation for a type's diagnostics and it tells you whether the last compile succeeded, when, +and a fingerprint of the bytes that compile produced. Every one of those is a note somebody's build +left behind. None of them answers the question you are usually holding: **when this installation reads +a node of this type right now, which code is doing the reading?** + +Most of the time those are the same thing, which is exactly why the gap is expensive. On one occasion +an installation reported the newest copy of a module, with the newest timestamp, serving types that +predated two finished pieces of work. Everything on the report said "newest" — truthfully, about the +file. It took a night to find that the newest copy had never actually been put into service, and the +older one was still answering. From the outside, "the shelf is stale" and "the newest thing on the +shelf was never taken down" looked identical. + +So the diagnostics now carry one more thing, and it is read from the code that is loaded rather than +from any record about it: + +- the **name** of the type the installation resolves this NodeType's content to, +- the **file** that type came from — or a plain statement that it has no file, because it was compiled + here rather than shipped, +- its **fingerprint**, the one the compiler stamps into the code itself, and +- whether it was **compiled in this installation** or arrived as part of one. + +That last pair is what separates the two cases that used to look the same. A type compiled here has no +file and is marked as such; a type that shipped with a module has a file and is not. When both are +present at once — which happens whenever a piece of code is being replaced — you can now see which of +them answered. + +And when nothing answers, it says so in words rather than by leaving the field out. "No type is +registered for this NodeType here — its content stays unreadable and its views render empty" and "this +installation has no type registry at all, so nothing was measured" are two different situations, and +reading a blank as "fine" is the mistake this exists to prevent. + +Nothing decides anything differently because of it. It is a reading, and its whole value is that it is +a reading you could not take before. diff --git a/src/MeshWeaver.Mesh.Operations/MeshOperations.cs b/src/MeshWeaver.Mesh.Operations/MeshOperations.cs index 79fc6a36b1..893f0777ad 100644 --- a/src/MeshWeaver.Mesh.Operations/MeshOperations.cs +++ b/src/MeshWeaver.Mesh.Operations/MeshOperations.cs @@ -4065,7 +4065,9 @@ public IObservable GetDiagnostics(string path) return Observable.Return(FormatDiagnostics( CompilationStatus.Ok, nodeTypePath, error: null, startedAt: null, lastCompiledAt: null, - hub.JsonSerializerOptions)); + hub.JsonSerializerOptions, + publishedAssemblyMvid: null, + ResolveContentType(nodeTypePath))); // Slow path: subscribe to the NodeType's live stream and wait for // the CompileWatcher to settle. Where(settled).Take(1) keeps the @@ -4110,9 +4112,17 @@ public IObservable GetDiagnostics(string path) ? ReadCompileStartedAt(typeNode) : null, lastCompiledAt: null, - hub.JsonSerializerOptions); - return JsonSerializer.Serialize( - new { status = "Unknown", message = $"NodeType '{nodeTypePath}' has no definition" }, + hub.JsonSerializerOptions, + publishedAssemblyMvid: null, + // 🚨 This is the branch that most needs it: the node's own content + // stayed a JsonElement, so the question "which assembly would this + // type have come from" is exactly what the reader is asking. + ResolveContentType(nodeTypePath)); + return WithContentType( + JsonSerializer.Serialize( + new { status = "Unknown", message = $"NodeType '{nodeTypePath}' has no definition" }, + hub.JsonSerializerOptions), + ContentTypeBlock(ResolveContentType(nodeTypePath)), hub.JsonSerializerOptions); }); }); @@ -4143,6 +4153,19 @@ or CompilationStatus.Error // further write is coming and a waiter would hang out its whole budget. or CompilationStatus.Unavailable; + /// + /// 🚨 WHICH ASSEMBLY this NodeType's content type resolves to in THIS process (#4158, + /// ask 2). Read from the live , not from the node's + /// record: `mvid` on the reply below is the identity a BUILD produced, and when two builds of + /// one assembly name are in the process those are different statements. Never null — + /// "there is no registry here" and "the registry has no type for this NodeType" are two + /// printed answers, because reading a missing block as "fine" is the mistake this exists to + /// stop. + /// + private ResolvedContentType ResolveContentType(string nodeTypePath) + => ResolvedContentType.Of( + hub.ServiceProvider.GetService(), nodeTypePath); + private string FormatDiagnosticsFromDef( Graph.Configuration.NodeTypeDefinition def, string nodeTypePath) { @@ -4168,7 +4191,10 @@ private string FormatDiagnosticsFromDef( hub.JsonSerializerOptions, // WHICH build the Ok is about (#2471) — the identity a caller can compare against the // bytes an instance is actually serving. - def.LatestAssemblyMvid); + def.LatestAssemblyMvid, + // …and WHICH assembly the content type resolves to here (#4158) — the only coordinate + // on the reply that is a property of the loaded bytes rather than of that record. + ResolveContentType(nodeTypePath)); } /// @@ -4688,6 +4714,100 @@ public static string FormatDiagnostics( DateTimeOffset? lastCompiledAt, JsonSerializerOptions options, string? publishedAssemblyMvid) + => FormatDiagnostics( + status, nodeTypePath, error, startedAt, lastCompiledAt, options, publishedAssemblyMvid, + contentType: null); + + /// + /// 🚨 The overload that reports WHICH ASSEMBLY the content type actually resolved to + /// (#4158, ask 2) — the one coordinate on this reply that is a property of the bytes LOADED + /// HERE rather than of a record some build wrote. + /// + /// Every other identity on the line is a claim: + /// names the bytes a BUILD produced, [ModuleLoad] names the assembly a module LOADED, + /// and neither is the statement the serializer and /schema/<Type> act on. When two + /// builds of one assembly name are in the process (#3732) those diverge, and a stale adopted + /// build was indistinguishable from a stale registry shelf from the consumer — which is the + /// night of diagnosis #4158 was filed about. + /// + /// An OVERLOAD for the same reason the one above is: a defaulted parameter replaces the + /// signature a previously-built module calls. + /// + /// + /// The reading from , or null to omit the block + /// entirely (the pre-#4158 reply, kept for the legacy overloads). + /// + public static string FormatDiagnostics( + CompilationStatus status, + string nodeTypePath, + string? error, + DateTimeOffset? startedAt, + DateTimeOffset? lastCompiledAt, + JsonSerializerOptions options, + string? publishedAssemblyMvid, + ResolvedContentType? contentType) + { + return WithContentType( + FormatDiagnosticsCore( + status, nodeTypePath, error, startedAt, lastCompiledAt, options, publishedAssemblyMvid), + ContentTypeBlock(contentType), + options); + } + + /// + /// The serialisable shape of a reading, or null for + /// "do not report one". 🚨 Composed ONCE and merged into whatever branch answers, rather than + /// repeated per branch: the question "which assembly is this NodeType's content coming from" + /// has the same answer whatever the compile status is, and a block that is present for some + /// statuses and absent for others is a reading nobody can act on. + /// + /// 🚨 Built as an explicit , NOT serialised from an anonymous + /// type, and both reasons were measured on the first attempt. The hub's options stamp a + /// polymorphic $type on a NESTED object, which put the compiler-generated name + /// <>f__AnonymousType15`6[…] into a diagnostics envelope people read; and their + /// default ignore condition DROPS null and default members, so typeName, assembly, + /// mvid and a false collectible all vanished — leaving a reader unable to + /// tell "not reported" from "reported as absent", which is the exact distinction + /// exists to make. Every key is written here, always. + /// + /// + private static JsonObject? ContentTypeBlock(ResolvedContentType? contentType) + => contentType is null + ? null + : new JsonObject + { + ["status"] = contentType.Status, + ["typeName"] = contentType.TypeName, + ["assembly"] = contentType.Assembly, + ["mvid"] = contentType.Mvid, + ["collectible"] = contentType.Collectible, + ["message"] = contentType.Describe(), + }; + + /// + /// Merges the resolved-content-type block into an already-serialised diagnostics reply. Done as + /// a merge rather than by threading one more field through six anonymous objects: the branches + /// each own their own wording, and none of them is where this reading comes from. + /// + private static string WithContentType(string json, JsonObject? contentTypeBlock, JsonSerializerOptions options) + { + if (contentTypeBlock is null) + return json; + var node = JsonNode.Parse(json)?.AsObject(); + if (node is null) + return json; + node["contentType"] = contentTypeBlock; + return node.ToJsonString(options); + } + + private static string FormatDiagnosticsCore( + CompilationStatus status, + string nodeTypePath, + string? error, + DateTimeOffset? startedAt, + DateTimeOffset? lastCompiledAt, + JsonSerializerOptions options, + string? publishedAssemblyMvid) { switch (status) { diff --git a/src/MeshWeaver.Messaging.Hub/Serialization/ResolvedContentType.cs b/src/MeshWeaver.Messaging.Hub/Serialization/ResolvedContentType.cs new file mode 100644 index 0000000000..fece09e06a --- /dev/null +++ b/src/MeshWeaver.Messaging.Hub/Serialization/ResolvedContentType.cs @@ -0,0 +1,141 @@ +using System.Reflection; + +namespace MeshWeaver.Mesh.Services; + +/// +/// 🚨 WHICH ASSEMBLY a NodeType's content type actually resolved to, in THIS process (#4158, +/// ask 2) — a property of the BYTES that are loaded here, never of the record that describes them. +/// +/// Every other per-NodeType coordinate is a claim written by whoever last built the type: +/// NodeTypeDefinition.LatestAssemblyPath is "an ADDRESS, not an identity", +/// LatestAssemblyMvid is the identity of the bytes A BUILD produced, and +/// [ModuleLoad] names the assembly a module LOADED. None of them is the statement +/// /schema/<Type> and the serializer actually act on, which is: the CLR type +/// hands back right now. When two +/// builds of one assembly NAME are in the process (#3732 — a module bundle on the shelf and a +/// runtime-compiled collectible build of the same type), those two statements diverge, and until +/// this record existed nothing on the consumer side could tell them apart: a stale adopted build +/// and a stale registry shelf both read as "the newest generation, newest written, and old types". +/// +/// +/// Read from the loaded assembly, so it cannot be stale by construction. +/// is Assembly.ManifestModule.ModuleVersionId — minted by the compiler +/// into the bytes themselves — and is Assembly.Location, empty for an +/// assembly loaded from a byte array (every runtime-compiled NodeType), which is why +/// is carried beside it: a collectible assembly with no location IS the +/// in-process compile, and a non-collectible one at a path IS the shipped module. +/// +/// 🚨 Absence is PRINTED, never inferred. distinguishes +/// (asked, and no type is registered for this NodeType here — the state +/// that renders every view of it empty) from (this process has no content-type +/// registry at all, so nothing was measured). Reading a missing block as "fine" is the reading this +/// record exists to prevent. +/// +/// +/// , or . +/// +/// The resolved type's AssemblyQualifiedName-free full name, or null. +/// The resolved type's assembly Location, or null when it has +/// none (loaded from bytes) or nothing resolved. +/// The resolved assembly's module version id, or null. +/// Whether the resolved assembly is collectible — i.e. a runtime compile +/// in a NodeAssemblyLoadContext rather than a module shipped with the process. +public sealed record ResolvedContentType( + string Status, + string? TypeName, + string? Assembly, + string? Mvid, + bool Collectible) +{ + /// when a CLR type is registered for the NodeType here. + public const string Resolved = "resolved"; + + /// when the registry was asked and answered no — the state in + /// which the node's content stays an untyped element and its views render empty. + public const string Unresolved = "unresolved"; + + /// when there is no content-type registry in this process, so + /// nothing was measured. NOT the same statement as . + public const string NotAsked = "not-asked"; + + /// The reading — no registry, so no measurement. + public static readonly ResolvedContentType NoRegistry = + new(NotAsked, null, null, null, Collectible: false); + + /// + /// Asks which CLR type 's content + /// resolves to here, and describes the assembly that type came from. Pure given the registry: + /// two map lookups and reflection over already-loaded metadata, so it is safe on any thread and + /// costs nothing a health probe or a diagnostics call would notice. + /// + /// The mesh-wide content-type registry, or null. + /// The NodeType path to ask under. + /// The reading. Never null. + public static ResolvedContentType Of(IMeshContentTypeRegistry? registry, string? nodeTypePath) + { + if (registry is null) + return NoRegistry; + if (string.IsNullOrEmpty(nodeTypePath) + || !registry.TryResolveByNodeType(nodeTypePath, out var contentType)) + return new ResolvedContentType(Unresolved, null, null, null, Collectible: false); + return Describe(contentType); + } + + /// + /// The reading for a CLR type already in hand — the same description + /// produces, for callers that resolved the + /// type themselves (the schema endpoint has it at the point it serialises). + /// + /// The resolved content type. + /// The reading. Never null. + public static ResolvedContentType Describe(Type contentType) + { + ArgumentNullException.ThrowIfNull(contentType); + var assembly = contentType.Assembly; + // 🚨 Location is the EMPTY STRING — not null — for an assembly loaded from a byte array, + // which is every runtime-compiled NodeType. Normalising it to null here is what keeps + // "compiled in this process" from being reported as an assembly at path "". + var location = string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location; + return new ResolvedContentType( + Resolved, + contentType.FullName, + location, + MvidOf(assembly), + assembly.IsCollectible); + } + + /// + /// The assembly's module version id as the same short 8-hex form [ModuleLoad] prints, or + /// null when the runtime refuses it (a reflection-only or otherwise metadata-less load). + /// + private static string? MvidOf(Assembly assembly) + { + try + { + return assembly.ManifestModule.ModuleVersionId.ToString("N")[..8]; + } + catch (Exception) + { + // A manifest module that cannot be read is a "we did not measure it" answer, and saying + // so beats inventing one — the same rule Status encodes above. + return null; + } + } + + /// + /// The one sentence a human reads. Says which of the three states this is, and — when resolved + /// — names the assembly in the form that distinguishes a shipped module from an in-process + /// compile. + /// + /// The sentence. + public string Describe() => Status switch + { + Resolved => $"{TypeName} from " + + (Assembly ?? "an assembly loaded from bytes (no file)") + + $" (mvid {Mvid ?? "unreadable"}" + + (Collectible ? ", collectible — compiled in this process)" : ")"), + Unresolved => "no CLR type is registered for this NodeType in this process — its content " + + "stays an untyped element and its views render empty", + _ => "not measured: this process has no content-type registry", + }; +} diff --git a/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs b/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs new file mode 100644 index 0000000000..60e12386a8 --- /dev/null +++ b/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs @@ -0,0 +1,116 @@ +#pragma warning disable CS1591 + +using System; +using System.Reactive.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Fixture; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; // IMeshContentTypeRegistry +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +/// +/// 🚨 #4158, ask 2 — get_diagnostics names the assembly a NodeType's content type +/// actually resolved to. +/// +/// Before this, every identity on that reply was a CLAIM written by a build: mvid is +/// NodeTypeDefinition.LatestAssemblyMvid (the bytes a build produced — and the record's own +/// doc says LatestAssemblyPath "is an ADDRESS, not an identity"), and [ModuleLoad] +/// names the assembly a module LOADED. Neither is the statement the serializer and +/// /schema/<Type> act on. On 2026-09-10 that gap cost a night: a module had the newest +/// generation, the newest written= and types predating two merged PRs, and from the consumer +/// a stale ADOPTED build and a stale registry SHELF read identically. +/// +/// Both cases below FAIL on main: the reply carries no contentType member at +/// all, so TryGetProperty is false and the first assertion of each goes red. +/// +/// The second case is the negative control. Same mesh, same call, a NodeType with no +/// registered content type — it must report unresolved. If the block were a constant of the +/// reply (or derived from the node's record rather than from the live registry) both cases would +/// answer the same way and this one would fail. +/// +public class DiagnosticsNameTheContentTypeAssemblyTest(ITestOutputHelper testOutput) + : MonolithMeshTestBase(testOutput) +{ + private const string TypedNodeType = "DiagTyped"; + private const string UntypedNodeType = "DiagUntyped"; + + /// The content type the typed NodeType's diagnostics must name the assembly of. + public sealed record DiagPayload(string Value); + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => base.ConfigureMesh(builder) + .AddMeshNodes( + new MeshNode(TypedNodeType) + { + Name = TypedNodeType, + NodeType = MeshNode.NodeTypePath, + Content = new NodeTypeDefinition { DefaultNamespace = "" }, + }, + new MeshNode(UntypedNodeType) + { + Name = UntypedNodeType, + NodeType = MeshNode.NodeTypePath, + Content = new NodeTypeDefinition { DefaultNamespace = "" }, + }); + + private async Task DiagnosticsFor(string nodeTypePath) + { + var json = await new MeshOperations(Mesh).GetDiagnostics(nodeTypePath) + .Timeout(TestTimeouts.CrossSilo).FirstAsync(); + Output.WriteLine($"{nodeTypePath} → {json}"); + return JsonDocument.Parse(json).RootElement; + } + + [Fact(Timeout = 120_000)] + public async Task ARegisteredContentTypeIsNamedByItsOwnAssemblyAndMvid() + { + Mesh.ServiceProvider.GetRequiredService() + .Register(typeof(DiagPayload), TypedNodeType); + + var reply = await DiagnosticsFor(TypedNodeType); + + Assert.True(reply.TryGetProperty("contentType", out var block), + "the per-NodeType diagnostics must carry the assembly the content type resolved to — " + + "every other identity on this reply is a claim some build wrote (#4158 ask 2). " + + $"Got: {reply}"); + // 🚨 The LITERAL, not the constant: this is a control over the WIRE shape a consumer + // reads, and asserting against the constant would let a rename change the + // expectation silently. It also lets this file compile — and fail — on `main`. + Assert.Equal("resolved", block.GetProperty("status").GetString()); + Assert.Equal(typeof(DiagPayload).FullName, block.GetProperty("typeName").GetString()); + // The LOADED assembly's own path and module version id — properties of the bytes in this + // process, which is the only coordinate here that cannot be stale. + Assert.Equal(typeof(DiagPayload).Assembly.Location, + block.GetProperty("assembly").GetString()); + Assert.Equal( + typeof(DiagPayload).Assembly.ManifestModule.ModuleVersionId.ToString("N")[..8], + block.GetProperty("mvid").GetString()); + Assert.False(block.GetProperty("collectible").GetBoolean()); + } + + [Fact(Timeout = 120_000)] + public async Task ANodeTypeWithNoRegisteredContentTypeSaysSo_NotNothing() + { + var reply = await DiagnosticsFor(UntypedNodeType); + + Assert.True(reply.TryGetProperty("contentType", out var block), + $"the block must be present whatever the answer is. Got: {reply}"); + // 🚨 The negative control. If the block were a constant of the reply, or read off the + // node's record instead of the live registry, this would say "resolved" like the case + // above — and the case above would then be measuring nothing. + Assert.Equal("unresolved", block.GetProperty("status").GetString()); + Assert.Null(block.GetProperty("typeName").GetString()); + Assert.Null(block.GetProperty("assembly").GetString()); + // The consequence, spelled out: this is the state in which the node's views render blank. + Assert.Contains("render empty", block.GetProperty("message").GetString()!, + StringComparison.Ordinal); + } +} diff --git a/test/MeshWeaver.Messaging.Hub.Test/ResolvedContentTypeNamesTheLoadedAssemblyTest.cs b/test/MeshWeaver.Messaging.Hub.Test/ResolvedContentTypeNamesTheLoadedAssemblyTest.cs new file mode 100644 index 0000000000..9a549b700d --- /dev/null +++ b/test/MeshWeaver.Messaging.Hub.Test/ResolvedContentTypeNamesTheLoadedAssemblyTest.cs @@ -0,0 +1,122 @@ +#pragma warning disable CS1591 + +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; +using MeshWeaver.Mesh.Services; +using Xunit; + +namespace MeshWeaver.Messaging.Hub.Test; + +/// +/// 🚨 #4158, ask 2: the per-NodeType diagnostics must name the assembly the content type +/// actually RESOLVED to — and that reading has to be taken from the loaded assembly, because +/// every other coordinate on the reply is a claim written by whoever last built the type. +/// +/// The night this issue was filed, a module reported the newest generation, the newest +/// written= and types that predated two merged PRs; a stale adopted build and a stale +/// registry shelf were indistinguishable from the consumer. The one statement nobody could get was +/// which assembly the serializer was actually acting on. +/// +/// The third case is the negative control. The SAME type full name is present twice in +/// this process — once in the default load context, once loaded from the same file's BYTES into a +/// collectible (the #3732 shape a runtime-compiled NodeType +/// produces). If the reading were derived from the NAME — or from anything other than the resolved +/// Type's own assembly — the two readings would be identical and that case would FAIL. It is +/// what makes the other two more than restatements of their inputs. +/// +public class ResolvedContentTypeNamesTheLoadedAssemblyTest +{ + private const string NodeTypePath = "Test/ContentTypeIdentity"; + + /// A content type of this test assembly — an ordinary, file-backed, non-collectible load. + private sealed record Marker(string Value); + + [Fact] + public void NoRegistryIsNotMeasured_AndIsNotTheSameStatementAsUnresolved() + { + var notAsked = ResolvedContentType.Of(null, NodeTypePath); + // A process with no content-type registry MEASURED NOTHING, and reporting that as + // "unresolved" would report a finding nobody made. + Assert.Equal(ResolvedContentType.NotAsked, notAsked.Status); + Assert.Contains("not measured", notAsked.Describe(), StringComparison.Ordinal); + + var unresolved = ResolvedContentType.Of(new MeshContentTypeRegistry(), NodeTypePath); + Assert.Equal(ResolvedContentType.Unresolved, unresolved.Status); + // The two must stay distinguishable: an absent instrument may not read as a clean result. + Assert.NotEqual(notAsked.Status, unresolved.Status); + // The consequence is what an operator needs to hear: the views of that node render blank. + Assert.Contains("render empty", unresolved.Describe(), StringComparison.Ordinal); + } + + [Fact] + public void AResolvedTypeNamesItsOwnAssemblyFileAndMvid() + { + var registry = new MeshContentTypeRegistry(); + registry.Register(typeof(Marker), NodeTypePath); + + var reading = ResolvedContentType.Of(registry, NodeTypePath); + + Assert.Equal(ResolvedContentType.Resolved, reading.Status); + Assert.Equal(typeof(Marker).FullName, reading.TypeName); + // The path reported is the LOADED assembly's own, not a store key off a record. + Assert.Equal(typeof(Marker).Assembly.Location, reading.Assembly); + // The mvid is minted by the compiler into the bytes, so it cannot be stale. + Assert.Equal( + typeof(Marker).Assembly.ManifestModule.ModuleVersionId.ToString("N")[..8], + reading.Mvid); + Assert.False(reading.Collectible); + } + + [Fact] + public void TwoBuildsOfOneTypeNameReadDIFFERENTLY_whichIsTheWholePoint() + { + // 🚨 ASSERTED, never SKIPPED. A skip here would make the one case that can falsify this + // reading silently vacuous, which is the failure mode this whole file is about. + var file = typeof(Marker).Assembly.Location; + Assert.False(string.IsNullOrEmpty(file), + "this control re-loads the test assembly's own bytes, so it needs a file-backed load; " + + "if that ever stops being true the control must go RED, not quietly pass"); + + var defaultContext = new MeshContentTypeRegistry(); + defaultContext.Register(typeof(Marker), NodeTypePath); + var shipped = ResolvedContentType.Of(defaultContext, NodeTypePath); + + var alc = new AssemblyLoadContext("resolved-content-type-control", isCollectible: true); + try + { + Assembly copy; + using (var stream = File.OpenRead(file)) + copy = alc.LoadFromStream(stream); + var sameName = copy.GetType(typeof(Marker).FullName!, throwOnError: true)!; + // The control only means anything if BOTH types answer to the same full name. + Assert.Equal(typeof(Marker).FullName, sameName.FullName); + Assert.NotSame(typeof(Marker), sameName); + + var collectibleRegistry = new MeshContentTypeRegistry(); + collectibleRegistry.Register(sameName, NodeTypePath); + var compiled = ResolvedContentType.Of(collectibleRegistry, NodeTypePath); + + Assert.Equal(ResolvedContentType.Resolved, compiled.Status); + // Same full name — so the NAME cannot be what tells these two apart. + Assert.Equal(shipped.TypeName, compiled.TypeName); + // A type in a collectible context is an in-process compile, not a module shipped with + // the process — the distinction #3732 needed and nothing else on the reply carries. + Assert.True(compiled.Collectible); + Assert.False(shipped.Collectible); + // An assembly loaded from a byte array has an EMPTY Location; reporting it as an + // assembly at path "" would be worse than saying it has no file. + Assert.Null(compiled.Assembly); + Assert.NotNull(shipped.Assembly); + Assert.Contains("collectible", compiled.Describe(), StringComparison.Ordinal); + // If these were equal, the reading would be a function of the NodeType path rather + // than of the bytes that answered it — which is exactly what #4158 asked to fix. + Assert.NotEqual(shipped, compiled); + } + finally + { + alc.Unload(); + } + } +}