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,39 @@
---
Name: Recycling a fixed NodeType now compiles the fix, not the broken source
Category: Fix
Description: Recycle stamped its recompile trigger and tore the node's hub down in the same breath, so the reactivated hub could recompile the source you had just fixed — reporting the old errors and leaving the type parked. The teardown now waits for the trigger to land.
Icon: Bug
Order: -20260830
---

# Recycling a fixed NodeType now compiles the fix, not the broken source

Fix a NodeType that failed to compile, hit **Recycle**, and the type could come back reporting the
**same errors you just fixed** — still parked, still refusing to serve. Recycling again sometimes
helped, which made it look like a slow cache rather than a bug.

## What was happening

`Recycle` does two things to a NodeType: it stamps a **release request** on the node (the trigger
that un-parks the type and asks for a fresh compile), and it **disposes the node's hub** so the next
access re-initialises it. Those two were issued back to back, and the stamp was *fire-and-forget* —
the dispose did not wait for it.

Nothing enforced the order. When the dispose won the race, the hub came back, re-ran its source
query against a half-invalidated state, matched **zero** source nodes, and recompiled the
**pre-fix** source. The type then settled at `Error` carrying the old diagnostics and stayed
parked — indistinguishable, from the outside, from "your fix did not work".

The ordering had been holding only because both calls happened to be issued inside the caller's
hub turn. That is not a guarantee, and it stopped being true as soon as an unrelated change stopped
resuming continuations inline on the signalling thread.

## What changed

The dispose is now **sequenced onto the stamp's completion** instead of racing it, so the
reactivated hub always sees the trigger — and the fixed source. If the stamp itself fails, the
recycle still proceeds (the hub bounce is what you asked for) and says so in the log, rather than
quietly degrading to the old behaviour.

The regression test that caught this deliberately does **not** wait for the fix to become visible
before recycling — that wait would hide the defect it exists to detect.
84 changes: 56 additions & 28 deletions src/MeshWeaver.Mesh.Operations/MeshOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3616,40 +3616,68 @@ private IObservable<string> RecycleCore(string resolvedPath)
// bypasses the un-park seam, so a parked (terminally failed) type swallows
// the trigger in the compile watcher's parked short-circuit and wedges at
// Pending (2026-07-16 memex Reinsurance/Layer + BusinessRules/Scope).
hub.GetWorkspace().GetMeshNodeStream(resolvedPath).Update(curr =>
//
// 🚨 THE STAMP MUST LAND BEFORE THE DISPOSE — sequence it, never race it.
// This used to be a fire-and-forget .Subscribe() followed immediately by the
// DisposeRequest below, which left the two ordered only by the fact that both
// were issued inside the caller's hub turn. That is an invariant nothing
// enforced: when the dispose won, the reactivated hub re-ran its source query
// against a half-invalidated state, matched ZERO Code nodes and recompiled the
// PRE-FIX source — the type then settled at Error with the old diagnostics and
// stayed parked, which is precisely the "matches zero Code nodes" failure the
// permission gate's Take(1) comment above describes. Chaining the dispose onto
// the write's completion makes the ordering explicit and turn-independent.
// (Found via NodeTypeCompileParkTest.ParkedNodeType_RecycleRetry_AfterFix_
// SettlesOkAndUnparks, which failed ~20-40% once #2748 stopped resuming test
// continuations inline on the signalling thread and so stopped accidentally
// holding this ordering.)
var stamped = hub.GetWorkspace().GetMeshNodeStream(resolvedPath).Update(curr =>
IsNodeTypeNode(curr)
? WithReleaseRequest(curr, DateTimeOffset.UtcNow)
: curr)
.Subscribe(
_ => { },
ex => logger.LogWarning(ex,
"Recycle: failed to stamp release request for {Path}", resolvedPath));
.Take(1)
.Timeout(TimeSpan.FromSeconds(30))
.Select(_ => true)
.Catch((Exception ex) =>
{
// A failed stamp must not swallow the recycle: the hub bounce is still
// worth doing (it is the caller's actual ask), and the compile trigger
// is recoverable by a second Recycle/Compile. Surface it in the log
// rather than silently degrading to the old racy behaviour.
logger.LogWarning(ex,
"Recycle: failed to stamp release request for {Path} — disposing the hub anyway",
resolvedPath);
return Observable.Return(false);
});

var changeFeed = hub.ServiceProvider.GetService<IMeshChangeFeed>();
if (changeFeed != null)
return stamped.Select(_ =>
{
var segments = resolvedPath.Split('/');
var id = segments.Length > 0 ? segments[^1] : resolvedPath;
var ns = segments.Length > 1 ? string.Join("/", segments[..^1]) : "";
changeFeed.Publish(new MeshChangeEvent(
Namespace: ns,
Id: id,
Path: resolvedPath,
Kind: MeshChangeKind.Updated,
NodeType: MeshNode.NodeTypePath,
Version: 0,
Timestamp: DateTimeOffset.UtcNow));
}

hub.Post(new DisposeRequest(), o => o.WithTarget(new Address(resolvedPath)));
return Observable.Return(JsonSerializer.Serialize(
new
var changeFeed = hub.ServiceProvider.GetService<IMeshChangeFeed>();
if (changeFeed != null)
{
status = "Recycled",
path = resolvedPath,
message = "DisposeRequest posted + cache invalidation broadcast via MeshChangeFeed. Wait ~100ms before the next access."
},
hub.JsonSerializerOptions));
var segments = resolvedPath.Split('/');
var id = segments.Length > 0 ? segments[^1] : resolvedPath;
var ns = segments.Length > 1 ? string.Join("/", segments[..^1]) : "";
changeFeed.Publish(new MeshChangeEvent(
Namespace: ns,
Id: id,
Path: resolvedPath,
Kind: MeshChangeKind.Updated,
NodeType: MeshNode.NodeTypePath,
Version: 0,
Timestamp: DateTimeOffset.UtcNow));
}

hub.Post(new DisposeRequest(), o => o.WithTarget(new Address(resolvedPath)));
return JsonSerializer.Serialize(
new
{
status = "Recycled",
path = resolvedPath,
message = "DisposeRequest posted + cache invalidation broadcast via MeshChangeFeed. Wait ~100ms before the next access."
},
hub.JsonSerializerOptions);
Comment on lines +3671 to +3679
});
}
catch (Exception ex)
{
Expand Down
19 changes: 18 additions & 1 deletion src/MeshWeaver.Reactive.Assertions/ReactiveWait.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Reactive.Disposables;

namespace MeshWeaver.Reactive.Assertions;

Expand Down Expand Up @@ -59,7 +60,17 @@ internal static class ReactiveWait

var completion = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);

source.Subscribe(
// 🚨 DISPOSE THE SUBSCRIPTION WHEN THE WAIT SETTLES. Rx's own bridge did; dropping the
// IDisposable leaves a live subscriber on the asserted stream for the rest of the process.
// Assertions in a class share subjects, so every settled assertion keeps consuming and a
// later one starves — NodeTypeCompileParkTest.RecycleRetry, ~40% of main runs after #2748,
// reported as "Last of N emission(s), none matched" at the full timeout.
//
// The late-fault trace below therefore only covers faults arriving BEFORE disposal, which
// is the honest trade: a fault from a stream nobody is asserting on any more is noise, and
// keeping the subscription alive to catch it is what broke the suite.
var subscription = new SingleAssignmentDisposable();
subscription.Disposable = source.Subscribe(
value => completion.TrySetResult(value),
error =>
{
Expand All @@ -73,6 +84,12 @@ internal static class ReactiveWait
},
() => completion.TrySetResult(default));

_ = completion.Task.ContinueWith(
(_, state) => ((IDisposable)state!).Dispose(),
subscription,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return completion.Task;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ await workspace.GetMeshNodeStream(typePath).Update(curr =>
return curr with { Content = def with { Configuration = ValidConfiguration } };
}).Should().Within(30.Seconds()).Emit();

// 🚨 REGRESSION GUARD (do NOT add a "wait for the node to carry the fix" here).
// Recycle must itself land its release-request stamp BEFORE it disposes the hub. It
// used to fire-and-forget that write and post the DisposeRequest immediately, leaving
// the two ordered only by both being issued inside the caller's turn — so when the
// dispose won, the reactivated hub recompiled the PRE-FIX source, matched zero Code
// nodes and re-settled at Error while staying parked. Waiting here would re-hide that.
var recycleJson = await new MeshOperations(Mesh).Recycle(typePath)
.FirstAsync().Timeout(60.Seconds()).Await(ct);
Output.WriteLine($"Recycle tool returned: {recycleJson}");
Expand Down
Loading