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
148 changes: 132 additions & 16 deletions src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,29 @@ void Dispatch()
/// the type on every judgement attempt. The alternative — refusing on an absence — is what
/// #4208 measured: a compile against nothing, CS0246s about correct code, and a parked type.
/// </para>
///
/// <para>🚨 <b>#4280 — the same deferral one step further: a PARTIAL live set.</b> The empty
/// witness above covers a type activated before ANY of its sources landed; the Reinsurance
/// gate (run 34804118498) measured the type activated 1.7 s into a 6.7 s install, with 8 of
/// the 14 files its bundle names on the mesh. Every declared query had matched something, so
/// the snapshot witness said "judgeable", the fingerprints disagreed (of course — six files
/// short), the build went StaleAdopted and a compile of the 8-file set was dispatched, failed
/// CS0246 on names declared in the six still landing, and parked. The affected set was a
/// different one on every run, and included types the repo does not own: an install-order
/// race, not a property of any type. So the bundle now carries the PATHS it was built from
/// (<see cref="NodeTypeDefinition.AdoptedSourcePaths"/>), and while any of them is absent
/// from the live snapshot the judgement is deferred exactly as the empty case is: the request
/// stays standing, the next publication — the one carrying the last arrival — judges it, and
/// nothing is dispatched off a set still being written.</para>
///
/// <para><b>Its residue, also named.</b> A path DELETED upstream after the bundle was baked is
/// absent from the live set forever, so from here the judgement defers for as long as that
/// bundle stands. It is bounded by the install's own release request: that fold re-judges
/// after the install completes and, on a mesh that compiles module content, an
/// <c>IsDirty</c> record compiles the live (complete) set and replaces the adopted build; on
/// a mesh that does not, the adopted build keeps serving as it would have as StaleAdopted —
/// the same bytes, minus the notification — until the bundle for the commit that deleted the
/// file arrives, which names a set the live one covers.</para>
/// </summary>
/// <param name="def">The owner's own definition.</param>
/// <param name="liveSources">The live source snapshot to judge against — the value
Expand All @@ -1194,7 +1217,28 @@ void Dispatch()
internal static bool CanJudgeAdoption(
NodeTypeDefinition def, IReadOnlyDictionary<string, long>? liveSources)
=> CanJudgeAdoption(
def.AdoptedSourceFingerprint, def.CurrentSourceFingerprint, liveSources);
def.AdoptedSourceFingerprint, def.CurrentSourceFingerprint, liveSources,
def.AdoptedSourcePaths);

/// <summary>
/// 🚨 #4280 — the source paths the adopted bytes were built from that the live snapshot does
/// NOT hold yet: the set that makes a differing fingerprint an install in flight rather than a
/// measurement. Empty when the bundle recorded no paths (a legacy producer — nothing to wait
/// for) or when every path is present. Ordinal-ignore-case on the path, the same equivalence
/// <c>NodeCompileShaping.CollectCompileSources</c> deduplicates on, so a producer and a
/// consumer that spell a path differently cannot hold each other hostage.
/// </summary>
internal static IReadOnlyList<string> SourcePathsNotYetLive(
IReadOnlyCollection<string>? adoptedSourcePaths,
IReadOnlyDictionary<string, long>? liveSources)
{
if (adoptedSourcePaths is not { Count: > 0 })
return [];
if (liveSources is not { Count: > 0 })
return adoptedSourcePaths.ToList();
var live = new HashSet<string>(liveSources.Keys, StringComparer.OrdinalIgnoreCase);
return adoptedSourcePaths.Where(p => !live.Contains(p)).ToList();
}

/// <summary>
/// <see cref="CanJudgeAdoption(NodeTypeDefinition, IReadOnlyDictionary{string, long})"/> for a
Expand All @@ -1214,13 +1258,32 @@ internal static bool CanJudgeAdoption(
string? adoptedFingerprint,
string? liveFingerprint,
IReadOnlyDictionary<string, long>? liveSources)
=> CanJudgeAdoption(adoptedFingerprint, liveFingerprint, liveSources, adoptedSourcePaths: null);

/// <summary>
/// <see cref="CanJudgeAdoption(string, string, IReadOnlyDictionary{string, long})"/> with the
/// producer's source PATHS as the third witness (#4280): a live set that is short of a path
/// the bundle was built from is still ARRIVING, and the judgement waits for it. Null paths —
/// a legacy producer — fall back to the two-witness rule unchanged.
/// </summary>
internal static bool CanJudgeAdoption(
string? adoptedFingerprint,
string? liveFingerprint,
IReadOnlyDictionary<string, long>? liveSources,
IReadOnlyCollection<string>? adoptedSourcePaths)
{
if (adoptedFingerprint is not { Length: > 0 } adopted)
return true;
if (liveFingerprint is not { Length: > 0 } live)
return false;
return string.Equals(adopted, live, StringComparison.Ordinal)
|| liveSources is { Count: > 0 };
if (string.Equals(adopted, live, StringComparison.Ordinal))
return true;
if (liveSources is not { Count: > 0 })
return false;
// #4280 — the fingerprints differ and something matched: was the live set COMPLETE when
// it was measured? Ordered after the equality check for the same reason the empty
// witness is — an honest match is an honest match whatever the paths say.
return SourcePathsNotYetLive(adoptedSourcePaths, liveSources).Count == 0;
Comment on lines +1283 to +1286

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also correct, and a narrower hole than the one above: the witness compares the manifest's sourceVersions keys (query-resolved roots, both bake paths) against CurrentSourceVersions (which also excludes includes by design — see NodeTypeSourceFingerprint's "residue" paragraph). An @@-included Code node still landing is therefore invisible to it, and because a genuinely ABSENT include is an answer in SourceFingerprintIncludeReader (contributes nothing, deliberately — a stalled read is what is inconclusive), the live fold is shorter than the producer's, the fingerprints differ, and the judgement reads "moved" and dispatches the premature compile. Taking it as its own PR after #4302: both sides have the include closure in hand (CompileInputs.ResolvedIncludes at the bake, CollectIncludeClosure in the sources watcher), so the bundle will carry it beside sourceVersions and the owner will publish the includes it resolved as present, and CanJudgeAdoption will wait on both sets. Note it must NOT be done by adding include paths to sourceVersions alone — the live snapshot never contains them, so that would defer every include-bearing type forever.

}

/// <summary>
Expand Down Expand Up @@ -1511,21 +1574,30 @@ internal static NodeTypeDefinition ApplyAdoptedSourceStampAndReport(
// says a NodeType was activated ahead of the sources it declares.
if (!CanJudgeAdoption(def, snapshot))
{
var notYetLive = SourcePathsNotYetLive(def.AdoptedSourcePaths, snapshot);
logger?.LogWarning(
"[AdoptedSourceStamp] {HubPath}: the adoption is NOT judged yet (#4208) — the "
"[AdoptedSourceStamp] {HubPath}: the adoption is NOT judged yet ({Issue}) — the "
+ "bundle records source fingerprint {Adopted}, and there is NOTHING established to "
+ "compare it against: {Reason} (live fingerprint {Live}). The stamp request is left "
+ "STANDING and the adopted build keeps serving; the sources watcher's next "
+ "publication judges it. Nothing is wrong with this type — it was activated ahead "
+ "of the sources it declares.",
hubPath, def.AdoptedSourceFingerprint,
hubPath,
notYetLive.Count > 0 && snapshot is { Count: > 0 } ? "#4280" : "#4208",
def.AdoptedSourceFingerprint,
def.CurrentSourceFingerprint is not { Length: > 0 }
? "the owner has not published a live fingerprint yet"
: "not one of its declared source queries has matched a node on this mesh"
+ (NodeTypeSourceFingerprint.EmptySourceSet.Equals(
def.CurrentSourceFingerprint, StringComparison.Ordinal)
? ", and the live fingerprint is the EMPTY fold"
: ""),
: snapshot is { Count: > 0 } && notYetLive.Count > 0
? $"the live set holds {snapshot.Count} source(s) but {notYetLive.Count} of "
+ $"the {def.AdoptedSourcePaths!.Count} the bundle was built from have not "
+ $"landed on this mesh yet — the install is still writing them: "
+ string.Join(", ", notYetLive.Take(6))
+ (notYetLive.Count > 6 ? ", …" : "")
: "not one of its declared source queries has matched a node on this mesh"
+ (NodeTypeSourceFingerprint.EmptySourceSet.Equals(
def.CurrentSourceFingerprint, StringComparison.Ordinal)
? ", and the live fingerprint is the EMPTY fold"
: ""),
def.CurrentSourceFingerprint ?? "(not computed)");
return def;
}
Expand Down Expand Up @@ -4359,13 +4431,57 @@ IObservable<Unit> StampCompileState() =>
logger?.LogWarning(outcome.Error,
"Compile failure for {HubPath}: {Error}", hubPath,
SummarizeCompileError(outcome.Result, outcome.Error));
return curr with
var modulesHashNow = hub.ServiceProvider.GetService<InstalledModulesFingerprint>()?.Hash;
var failed = ApplyCompileFailure(
def, outcome.Result, outcome.Error, resolvedActivityPath,
modulesHashNow, hubPath);

// 🚨 #4280 — A VERDICT ABOUT A SOURCE SET THAT NO LONGER EXISTS IS NOT A
// VERDICT. `def` here is the node as it is being committed against, so
// CurrentSourceVersions is the LIVE set at the instant of this write; the
// result's CompiledSources is the set Roslyn was handed. When the two differ
// the sources moved DURING the compile — a package install still writing
// its Source/ siblings (measured: a compile dispatched 1.7 s into a 6.7 s
// install, 8 of 14 files, CS0246 on names declared in the other six) — and
// the diagnostics describe code nothing is wrong with. The sources
// watcher's parked auto-retry covers this ONLY when a further arrival lands
// after this write; when the last one landed between the compile's snapshot
// and this stamp, nothing re-drives, the type rests at Error, and a gate
// that takes the first terminal status reads a red that the same process
// would have cleared. So the retry the watcher would have made is made
// HERE, in the same write: un-park first (in-memory, happens-before the
// Pending emission the compile watcher observes — the watcher's own order),
// then Pending through the one door with the live set stamped. The failure
// is still recorded in full (diagnostics, FailedBuildInputs over the
// consumed set) — it is not hidden, it is not final. An availability
// non-verdict is excluded: it never parks and has its own re-drive.
// Bounded by construction: every re-drive needs a live set that differs
// from the one just consumed, so a type whose sources have stopped moving
// parks on its next failure exactly as before.
if (!IsAvailabilityNonVerdict(outcome.Error)
&& NodeTypeCompileParkRegistry.SourcesMovedSince(
outcome.Result?.CompiledSources, def.CurrentSourceVersions))
Comment on lines +4461 to +4463

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and verified on main after the merge: the failure branch at MeshNodeCompilationService.cs:1241 destructured sources and returned the result without it, so SourcesMovedSince(null, …) could not fire on the CS0246 path — the park-lift half was inert on exactly the shape it targets; only the deferral half was live. This PR merged before the review posted (#4299), so the fix is #4302: the failure result now folds the consumed set under the same SourceVersionOf rule as the success result and the sources watcher, pinned by a real failing compile (AFailedCompileCarriesTheSetItConsumedTest, red on main, green with the fix).

{
Content = ApplyCompileFailure(
def, outcome.Result, outcome.Error, resolvedActivityPath,
hub.ServiceProvider.GetService<InstalledModulesFingerprint>()?.Hash,
hubPath)
};
var consumedCount = outcome.Result!.CompiledSources!.Count;
var liveCount = def.CurrentSourceVersions!.Count;
logger?.LogWarning(
"Compile failure for {HubPath} was formed under a source set the live one "
+ "has MOVED PAST (#4280): Roslyn was handed {Consumed} source(s), the mesh "
+ "now holds {Live} — the sources were still landing while it compiled. "
+ "The failure is recorded and NOT left standing: the type is re-driven "
+ "against the live set in this same write, as the sources watcher would "
+ "have done had one more source arrived after the verdict.",
hubPath, consumedCount, liveCount);
// Idempotent, so a re-run of this lambda on a version conflict is safe
// (the retried lambda re-derives the same decision from the same facts).
parkRegistry?.Unpark(hubPath);
return curr with
{
Content = DispatchPending(failed, modulesHashNow, def.CurrentSourceVersions),
};
}

return curr with { Content = failed };
})
.Do(saved =>
{
Expand Down
14 changes: 14 additions & 0 deletions src/MeshWeaver.Compiler.Pipeline/NodeTypeCompileParkRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ public bool ShouldRetryForSourceChange(
_parked.TryGetValue(nodeTypePath, out var p)
&& !SnapshotEquals(p.Sources, currentSources);

/// <summary>
/// 🚨 #4280 — whether a compile's CONSUMED source snapshot is one the live snapshot has since
/// MOVED PAST: the verdict it produced is about a set that no longer exists, so it is re-driven
/// at the stamp instead of resting at Error until the next source arrival happens to wake the
/// sources watcher (which, when the last arrival preceded the failure write, is never). The
/// same equality <see cref="ShouldRetryForSourceChange"/> applies, so the two triggers cannot
/// disagree about what "changed" means. A <c>null</c> consumed set — the compile never
/// resolved one — is not comparable and answers <c>false</c>: no re-drive off a set nobody
/// established (#1216).
/// </summary>
public static bool SourcesMovedSince(
IReadOnlyDictionary<string, long>? consumed, IReadOnlyDictionary<string, long>? live) =>
consumed is not null && live is not null && !SnapshotEquals(consumed, live);

/// <summary>Source-snapshot equality treating <c>null</c> and an empty map as equal.</summary>
private static bool SnapshotEquals(
IReadOnlyDictionary<string, long>? a, IReadOnlyDictionary<string, long>? b)
Expand Down
9 changes: 8 additions & 1 deletion src/MeshWeaver.Compiler.Pipeline/NodeTypeCompileState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ public record NodeTypeCompileState
/// <summary>See <see cref="NodeTypeDefinition.AdoptedSourceFingerprint"/>.</summary>
public string? AdoptedSourceFingerprint { get; init; }

/// <summary>See <see cref="NodeTypeDefinition.AdoptedSourcePaths"/> — the source paths the
/// adopted bytes were built from (#4280). Mirrored for the same reason as the fingerprint
/// beside it.</summary>
public System.Collections.Immutable.ImmutableList<string>? AdoptedSourcePaths { get; init; }

/// <summary>See <see cref="NodeTypeDefinition.CurrentModuleVersion"/> — the module version of
/// the source this mesh HOLDS. Mirrored for the same reason as the pair beside it.</summary>
public string? CurrentModuleVersion { get; init; }
Expand Down Expand Up @@ -178,6 +183,7 @@ definition is null
FailedBuildInputs = definition.FailedBuildInputs,
FailedSourceQueries = definition.FailedSourceQueries,
AdoptedSourceFingerprint = definition.AdoptedSourceFingerprint,
AdoptedSourcePaths = definition.AdoptedSourcePaths,
AdoptedModuleVersion = definition.AdoptedModuleVersion,
CurrentModuleVersion = definition.CurrentModuleVersion,
CurrentSourceFingerprint = definition.CurrentSourceFingerprint,
Expand All @@ -198,7 +204,8 @@ CompilationStatus is null && CompilationError is null && CompilationDiagnostics
&& RequestedSourceStampAt is null
&& CompiledFrameworkVersion is null && FailedBuildInputs is null
&& FailedSourceQueries is null
&& AdoptedSourceFingerprint is null && AdoptedModuleVersion is null
&& AdoptedSourceFingerprint is null && AdoptedSourcePaths is null
&& AdoptedModuleVersion is null
&& CurrentModuleVersion is null
&& CurrentSourceFingerprint is null
&& BuildProvenance is Mesh.Services.BuildProvenance.Compiled;
Expand Down
Loading
Loading