Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
44 changes: 44 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Type>` 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/<rid>/native/` (#1728)

A module is loaded with `Assembly.LoadFrom`, which never consults the module's own `deps.json`, so
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 125 additions & 5 deletions src/MeshWeaver.Mesh.Operations/MeshOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4065,7 +4065,9 @@ public IObservable<string> GetDiagnostics(string path)
return Observable.Return(FormatDiagnostics(
CompilationStatus.Ok, nodeTypePath,
error: null, startedAt: null, lastCompiledAt: null,
hub.JsonSerializerOptions));
hub.JsonSerializerOptions,
publishedAssemblyMvid: null,
ResolveContentType(nodeTypePath)));
Comment on lines 4065 to +4070

// Slow path: subscribe to the NodeType's live stream and wait for
// the CompileWatcher to settle. Where(settled).Take(1) keeps the
Expand Down Expand Up @@ -4110,9 +4112,17 @@ public IObservable<string> 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);
});
});
Expand Down Expand Up @@ -4143,6 +4153,19 @@ or CompilationStatus.Error
// further write is coming and a waiter would hang out its whole budget.
or CompilationStatus.Unavailable;

/// <summary>
/// 🚨 <b>WHICH ASSEMBLY this NodeType's content type resolves to in THIS process</b> (#4158,
/// ask 2). Read from the live <see cref="IMeshContentTypeRegistry"/>, 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 <c>null</c> —
/// "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.
/// </summary>
private ResolvedContentType ResolveContentType(string nodeTypePath)
=> ResolvedContentType.Of(
hub.ServiceProvider.GetService<IMeshContentTypeRegistry>(), nodeTypePath);

private string FormatDiagnosticsFromDef(
Graph.Configuration.NodeTypeDefinition def, string nodeTypePath)
{
Expand All @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -4688,6 +4714,100 @@ public static string FormatDiagnostics(
DateTimeOffset? lastCompiledAt,
JsonSerializerOptions options,
string? publishedAssemblyMvid)
=> FormatDiagnostics(
status, nodeTypePath, error, startedAt, lastCompiledAt, options, publishedAssemblyMvid,
contentType: null);

/// <summary>
/// 🚨 <b>The overload that reports WHICH ASSEMBLY the content type actually resolved to</b>
/// (#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.
///
/// <para>Every other identity on the line is a claim: <paramref name="publishedAssemblyMvid"/>
/// names the bytes a BUILD produced, <c>[ModuleLoad]</c> names the assembly a module LOADED,
/// and neither is the statement the serializer and <c>/schema/&lt;Type&gt;</c> 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.</para>
///
/// <para>An OVERLOAD for the same reason the one above is: a defaulted parameter replaces the
/// signature a previously-built module calls.</para>
/// </summary>
/// <param name="contentType">
/// The reading from <see cref="ResolvedContentType.Of"/>, or <c>null</c> to omit the block
/// entirely (the pre-#4158 reply, kept for the legacy overloads).
/// </param>
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);
}

/// <summary>
/// The serialisable shape of a <see cref="ResolvedContentType"/> reading, or <c>null</c> 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.
///
/// <para>🚨 Built as an explicit <see cref="JsonObject"/>, NOT serialised from an anonymous
/// type, and both reasons were measured on the first attempt. The hub's options stamp a
/// polymorphic <c>$type</c> on a NESTED object, which put the compiler-generated name
/// <c>&lt;&gt;f__AnonymousType15`6[…]</c> into a diagnostics envelope people read; and their
/// default ignore condition DROPS null and default members, so <c>typeName</c>, <c>assembly</c>,
/// <c>mvid</c> and a <c>false</c> <c>collectible</c> all vanished — leaving a reader unable to
/// tell "not reported" from "reported as absent", which is the exact distinction
/// <see cref="ResolvedContentType.Status"/> exists to make. Every key is written here, always.
/// </para>
/// </summary>
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(),
};

/// <summary>
/// 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.
/// </summary>
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)
{
Expand Down
Loading
Loading