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
41 changes: 38 additions & 3 deletions src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -542,9 +542,9 @@ private IObservable<IEnumerable<MeshNode>> SnapshotSources(
// nothing stays SILENT and completes, so it can never beat the cached query with an empty
// set (compiling against no sources is worse than the stall this exists to dodge). That
// filter is also the likeliest reason #682's probe "never fired" — not the delay alone.
return DirectSourceProbe(ntDef, selfPath, TimeSpan.Zero)
.Merge(Observable.Defer(() => ResolveSources(ntDef, selfPath, null).Take(1)))
.FirstAsync()
return RaceSourceSnapshot(
DirectSourceProbe(ntDef, selfPath, TimeSpan.Zero),
Observable.Defer(() => ResolveSources(ntDef, selfPath, null).Take(1)))
.Timeout(bound)
.Catch<IEnumerable<MeshNode>, TimeoutException>(ex =>
Observable.Throw<IEnumerable<MeshNode>>(new TimeoutException(
Expand All @@ -555,6 +555,41 @@ private IObservable<IEnumerable<MeshNode>> SnapshotSources(
+ "button / a fresh RequestedReleaseAt.", ex)));
}

/// <summary>
/// The snapshot race between the authoritative direct mesh read (<paramref name="directProbe"/> —
/// emits at most one NON-EMPTY source set, stays silent and completes when it finds nothing) and
/// the cached synced query's first emission (<paramref name="cachedFirst"/>). Extracted as a pure
/// combinator so the race's correctness semantics are deterministically unit-testable
/// (CodeEditRecompileTest.SourceSnapshot_*).
///
/// <para>🚨 An EMPTY cached answer must never WIN the race (issue #612, CI run 30004790036
/// "sub-case b"). The cached synced query replays its latest set SYNCHRONOUSLY on subscribe,
/// while the probe cannot answer before its chunk quiet window — so under the old
/// <c>Merge(...).FirstAsync()</c> shape a cached query that had latched EMPTY (a missed
/// source-create update under load — the stale-synced-query class) ALWAYS beat the probe, the
/// compile consumed ZERO sources, the configuration lambda's CS0103 parked the type, and every
/// retry — including the explicit un-parking RequestedReleaseAt re-trigger — re-failed
/// identically: a permanent wedge at Status=Error with no release. The probe leg already
/// refuses to emit empty for exactly this reason ("compiling the type against NOTHING [is]
/// strictly worse than the stall"); this applies the same rule to the cached leg.</para>
///
/// <para>Semantics: the first NON-EMPTY answer from either side wins immediately (a healthy
/// cached query still settles the snapshot with zero probe latency — the #690 regression
/// guard). EMPTY settles only by CONSENSUS: both legs completed without producing a source —
/// then a source-less, configuration-only NodeType still compiles. A cached leg that never
/// emits at all leaves the race to the probe / the caller's outer <c>Timeout</c>, unchanged.</para>
/// </summary>
internal static IObservable<IEnumerable<MeshNode>> RaceSourceSnapshot(
IObservable<IEnumerable<MeshNode>> directProbe,
IObservable<IEnumerable<MeshNode>> cachedFirst)
=> directProbe
.Merge(cachedFirst
.Select(static s => (IReadOnlyCollection<MeshNode>)(s as IReadOnlyCollection<MeshNode> ?? s.ToList()))
.Where(static s => s.Count > 0)
.Select(static s => (IEnumerable<MeshNode>)s))
.Take(1)
.DefaultIfEmpty(Enumerable.Empty<MeshNode>());

/// <summary>
/// How long the cached synced source query gets before the uncached probe is even subscribed.
/// Comfortably past a warm cache hit or one cold storage read, so the probe stays dormant in
Expand Down
34 changes: 30 additions & 4 deletions src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1413,9 +1413,23 @@ curr.Content is NodeTypeDefinition def
$"Roslyn produced assembly at: {outcome.Result!.AssemblyLocation}",
LogLevel.Information));
else
{
activityMessages.Add(new LogMessage(
$"Roslyn failed: {outcome.Error?.Message ?? (outcome.Result?.Log?.Errors() is { Count: > 0 } errs ? string.Join("; ", errs.Select(m => m.Message)) : "Compilation produced no assembly")}",
LogLevel.Error));
// 🚨 Non-Roslyn abort (an infrastructure exception escaping the compile
// pipeline — NOT a CompilationException, whose message already carries the
// full diagnostics): record the exception TYPE + STACK on the activity log.
// Reducing the fault to `.Message` everywhere made the recurring CI
// first-compile "Object reference not set to an instance of an object"
// (#612, PR-884 run 31153582625) UNDIAGNOSABLE — no sink anywhere carried
// the throw site. The activity log is THE official diagnosis surface;
// faults must reach it whole (wedges-to-zero).
if (outcome.Error is not null and not CompilationException)
activityMessages.Add(new LogMessage(
$"Compile aborted by {outcome.Error.GetType().FullName}:\n{outcome.Error}",
LogLevel.Error));
}
if (newReleasePath is not null)
activityMessages.Add(new LogMessage(
$"Release created: {newReleasePath}", LogLevel.Information));
Expand Down Expand Up @@ -1488,11 +1502,23 @@ curr.Content is NodeTypeDefinition def
};
}

var errorSummary = outcome.Error?.Message
?? (outcome.Result?.Log?.Errors() is { Count: > 0 } errs
// 🚨 Name the exception TYPE for a non-Roslyn abort. A bare `.Message`
// ("Object reference not set to an instance of an object.") on the node's
// CompilationError told the CI triage NOTHING about what died (#612 /
// PR-884 occurrence); the full stack goes to the activity log above and to
// the logger below — this UI-facing summary at least names the class.
var errorSummary = outcome.Error switch
{
null => outcome.Result?.Log?.Errors() is { Count: > 0 } errs
? string.Join("; ", errs.Select(m => m.Message))
: "Compilation produced no assembly");
logger?.LogWarning("Compile failure for {HubPath}: {Error}", hubPath, errorSummary);
: "Compilation produced no assembly",
CompilationException ce => ce.Message,
{ } other => $"{other.GetType().Name}: {other.Message}",
};
// Pass the exception OBJECT so the stack reaches the log sink — message-only
// logging is what left the recurring CI compile-NRE undiagnosable.
logger?.LogWarning(outcome.Error,
"Compile failure for {HubPath}: {Error}", hubPath, errorSummary);
return curr with
{
Content = def with
Expand Down
Loading
Loading