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
@@ -0,0 +1,24 @@
---
Name: A module type that was never baked now says so, instead of compiling in secret
Category: Fix
Description: On a mesh that requires prebuilt assemblies, opening a module type whose build never landed no longer triggers a silent on-mesh compile — the type parks with a clear message naming the missing bundle, the framework lane and the fix, and every page of that type shows it.
Icon: ShieldError
Order: -20260825
---

# A module type that was never baked now says so, instead of compiling in secret

`Modules:RequirePrebuilt` already refused to compile when a package's bundle could not be fetched
at install time. The deeper case — a type that is simply *touched* later, on first access, with no
adopted assembly — still slipped into a Roslyn compile, because that path runs through the
type's own compile watcher rather than the install lane.

It no longer does. On a require-prebuilt mesh, any trigger that would compile such a type — first
access, a release request, a self-heal, a framework-stale rebuild — parks it instead, with one
message: which type, which framework identity and architecture the assembly is missing for, which
package's bundle publishes it, and how to retry once it lands. Every page of that type renders the
reason through the usual compilation-error overlay instead of waiting on a compile that was never
going to be allowed, and the park keeps the refusal bounded — no storm, no retry loop, zero
compiles started.

Meshes that do not set the flag are unchanged: they compile exactly as before.
74 changes: 58 additions & 16 deletions src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,22 +219,16 @@ public static IDisposable InstallCompileWatcher(
// through InstallReleaseRequestWatcher, which un-parks BEFORE promoting to
// Pending — so this guard never blocks a legitimate retry, only a stray
// re-trigger (a persisted-Compiling recovery kickoff, a legacy direct flip).
if (parkRegistry?.IsParked(hubPath) == true)
// 🅿️ Wedge-close: a Pending flip we refuse to dispatch was already WRITTEN to
// the node. Without a settle write-back the type would sit at Pending forever —
// every settle-waiter (get_diagnostics' / the compile tool's Where(Ok|Error),
// WaitForLatestRelease) hangs to its timeout and the stray trigger never
// reaches a sink. Re-settle Pending → Error with the given reason so the
// trigger is answered (bounded, no Roslyn). Same fire-and-forget UpdateOwn
// shape as the kickoffs below; System scope because re-settling framework
// state is infrastructure, not a user write.
void SettleAsError(string? reason)
{
logger?.LogDebug(
"Compile watcher: {HubPath} is PARKED (terminal compile failure) — " +
"skipping recompile, serving cached error", hubPath);
// 🅿️ Wedge-close: the Pending flip we refuse to dispatch was already
// WRITTEN to the node. Without a settle write-back the type would sit
// at Pending forever — every settle-waiter (get_diagnostics' / the
// compile tool's Where(Ok|Error), WaitForLatestRelease) hangs to its
// timeout and the stray trigger never reaches a sink. Re-settle
// Pending → Error with the CACHED parked error so the trigger is
// answered (bounded, no Roslyn) while the park stays in place. Same
// fire-and-forget UpdateOwn shape as the kickoffs below; System scope
// because re-settling framework state is infrastructure, not a user
// write.
var parkedError = parkRegistry.GetParkedError(hubPath);
using (accessService?.ImpersonateAsSystem())
workspace.GetMeshNodeStream().Update(curr =>
{
Expand All @@ -250,7 +244,7 @@ public static IDisposable InstallCompileWatcher(
Content = parkedDef with
{
CompilationStatus = CompilationStatus.Error,
CompilationError = parkedError
CompilationError = reason
?? parkedDef.CompilationError
?? "Compilation is parked after a terminal failure; request a release (Compile) to retry."
}
Expand All @@ -260,6 +254,54 @@ public static IDisposable InstallCompileWatcher(
ex => logger?.LogWarning(ex,
"Compile watcher: failed to re-settle parked {HubPath} from Pending to Error",
hubPath));
}

if (parkRegistry?.IsParked(hubPath) == true)
{
logger?.LogDebug(
"Compile watcher: {HubPath} is PARKED (terminal compile failure) — " +
"skipping recompile, serving cached error", hubPath);
SettleAsError(parkRegistry.GetParkedError(hubPath));
return;
}

// 🚨 THE ADOPT-ONLY GATE (Modules:RequirePrebuilt, MeshWeaver#2193 §A). On a
// mesh that requires prebuilt assemblies, a Pending flip on a type WITHOUT a
// usable build — first access, a release request, a self-heal kick, a
// framework-stale rebuild — must never reach Roslyn. Every route into a compile
// passes through this handler (a deliberate retry un-parks and then lands
// HERE), so this is the one place that turns "compile on a miss" into a
// PARKED, named refusal: the type settles at Error with a message that says
// what is missing (the assembly for this identity/architecture), what
// publishes it (the package's bundle for this lane) and how to retry — and
// every instance page renders that reason through the compilation-error
// overlay instead of hanging on a compile that would not have been allowed.
// Parking (deterministic — the same miss reproduces until a bundle lands)
// keeps the refusal bounded and visible, exactly like a terminal source error;
// the park registry's attempt counter stays at ZERO, the observable proof no
// Roslyn pass ever started.
if (PrebuiltAssemblySeeder.RequirePrebuilt(hub.ServiceProvider))
{
var reason = PrebuiltAssemblySeeder.RequiredParkReason(hubPath);
logger?.LogError(
"Compile watcher: {HubPath} has no adopted assembly and this mesh sets {Key} — " +
"PARKING with a named refusal instead of compiling. {Reason}",
hubPath, PrebuiltAssemblySeeder.RequirePrebuiltConfigKey, reason);
// The registry is resolved from the hub's services HERE, exactly as the
// real dispatch does — the watcher's own handle is only ever probed
// null-safely and must not be the thing the park depends on. The park
// carries the type's CURRENT source snapshot so the sources watcher does
// not read "sources changed since the park" against a null baseline and
// un-park into a park/un-park ping-pong; a genuine later source edit
// still re-drives (and is refused again, named, by this gate).
var registry = hub.ServiceProvider.GetService<NodeTypeCompileParkRegistry>()
?? parkRegistry;
var pendingDef = pendingNode!.ContentAs<NodeTypeDefinition>(
hub.JsonSerializerOptions, logger);
registry?.OnCompileFailed(
hub, hubPath, reason, deterministic: true,
recipientUserId: null, sources: pendingDef?.CurrentSourceVersions, logger);
SettleAsError(reason);
return;
}

Expand Down
18 changes: 18 additions & 0 deletions src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ public static bool RequirePrebuilt(IServiceProvider? services)
}
}

/// <summary>
/// The NAMED refusal a require-prebuilt mesh parks a NodeType with when it would otherwise
/// compile on a miss (the compile watcher's adopt-only gate): what is missing, for which
/// lane, what publishes it, how to retry. One shape for the whole family — the same facts the
/// install-lane <see cref="PrebuiltRequiredException"/> names — so an operator reads the same
/// sentence whichever seam refused. The package is the type's partition root (the node-repo
/// layout: <c>{Package}/{Type}</c>). Pure.
/// </summary>
public static string RequiredParkReason(string nodeTypePath)
{
var slash = nodeTypePath.IndexOf('/');
var package = slash > 0 ? nodeTypePath[..slash] : nodeTypePath;
return $"{RequirePrebuiltConfigKey}: NodeType '{nodeTypePath}' has no adopted assembly for "
+ $"framework {LiveFrameworkMvid}/{ReleaseArchitecture.Live}, and this mesh does not "
+ $"compile module content. Publish or rebake package '{package}' for this framework "
+ "identity and architecture, then request a release to retry (MeshWeaver#2193 §A).";
}

/// <summary>
/// Why a prebuilt assembly may NOT be adopted, or null when it may.
///
Expand Down
98 changes: 98 additions & 0 deletions test/MeshWeaver.Hosting.Monolith.Test/RequirePrebuiltParkTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using MeshWeaver.Data;
using MeshWeaver.Graph.Configuration;
using MeshWeaver.Hosting.Monolith.TestBase;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

#pragma warning disable CS1591

namespace MeshWeaver.Hosting.Monolith.Test;

/// <summary>
/// 🚨 <b>The adopt-only gate on FIRST ACCESS</b> (MeshWeaver#2193 §A, the deepest seam): on a mesh
/// that sets <c>Modules:RequirePrebuilt</c>, a NodeType touched without an adopted assembly must
/// PARK with a named refusal — never enter Roslyn.
///
/// <para>The shape pinned here is the one nobody can observe from the install lane: a perfectly
/// valid type (its configuration compiles fine anywhere else) whose bundle simply never landed
/// for this identity. The first-build kickoff flips it to Pending; the compile watcher's gate
/// must then (a) settle it at <c>Error</c> with a message naming the policy key, the type, the
/// framework identity and the fix, (b) park it so no later trigger can storm, and (c) — the
/// observable proof — never start a compile: the park registry's attempt counter stays at ZERO
/// (it increments only inside the real dispatch). The flag-off contract is the whole existing
/// suite, where the same type compiles.</para>
/// </summary>
public class RequirePrebuiltParkTest(ITestOutputHelper output) : MonolithMeshTestBase(output)
{
private const string Partition = "RequirePrebuiltTest";
private const string NodeTypeId = "NeverBaked";
private const string NodeTypePath = $"{Partition}/{NodeTypeId}";

protected override MeshBuilder ConfigureMesh(MeshBuilder builder) =>
base.ConfigureMesh(builder)
.ConfigureServices(services =>
// The deployment opt-in under test — the same in-memory registration idiom the
// Content tests use.
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PrebuiltAssemblySeeder.RequirePrebuiltConfigKey] = "true",
})
.Build()));

[Fact(Timeout = 120_000)]
public async Task AValidTypeWithNoAdoptedAssembly_ParksWithTheNamedRefusal_AndNeverCompiles()
{
var workspace = Mesh.GetWorkspace();
var parkRegistry = Mesh.ServiceProvider.GetRequiredService<NodeTypeCompileParkRegistry>();

// A type that WOULD compile anywhere the flag is off — the point is that it is refused
// for lack of a bundle, not for lack of correctness.
await NodeFactory.CreateNode(new MeshNode(NodeTypeId, Partition)
{
Name = "Never baked type",
NodeType = MeshNode.NodeTypePath,
Content = new NodeTypeDefinition
{
Description = "Valid configuration, no prebuilt assembly for this lane.",
Configuration = "config => config",
}
}).Should().Emit();

// (a) It settles at Error with the NAMED refusal — never sits at Pending, never hangs.
var settled = await workspace.GetMeshNodeStream(NodeTypePath)
.Should().Within(60.Seconds())
.Match(n => n.Content is NodeTypeDefinition d
&& d.CompilationStatus == CompilationStatus.Error);
var def = (NodeTypeDefinition)settled.Content!;
def.CompilationError.Should().NotBeNull();
def.CompilationError.Should().Contain(PrebuiltAssemblySeeder.RequirePrebuiltConfigKey,
"the refusal must name the policy that refused");
def.CompilationError.Should().Contain(NodeTypePath, "…and the type");
def.CompilationError.Should().Contain(PrebuiltAssemblySeeder.LiveFrameworkMvid,
"…and the lane the assembly is missing for");
def.CompilationError.Should().Contain($"'{Partition}'",
"…and the package whose bundle would fix it");

// (b) PARKED — bounded and visible, like any terminal failure.
parkRegistry.IsParked(NodeTypePath).Should().BeTrue(
"a require-prebuilt refusal must park the type so no later trigger can storm");
parkRegistry.GetParkedError(NodeTypePath).Should().Contain(
PrebuiltAssemblySeeder.RequirePrebuiltConfigKey);

// (c) NO COMPILE EVER STARTED — the counter increments only inside the real dispatch.
parkRegistry.GetCompileAttemptCount(NodeTypePath).Should().Be(0,
"the adopt-only gate refuses BEFORE Roslyn; a single attempt would mean it compiled");
}
}
Loading