diff --git a/src/MeshWeaver.Data/GenericUnpartitionedDataSource.cs b/src/MeshWeaver.Data/GenericUnpartitionedDataSource.cs
index b93b4bd8fe..567a20aa28 100644
--- a/src/MeshWeaver.Data/GenericUnpartitionedDataSource.cs
+++ b/src/MeshWeaver.Data/GenericUnpartitionedDataSource.cs
@@ -309,13 +309,27 @@ This with
///
/// A task that completes once every created stream's hub has started.
+ ///
+ /// π¨ A stream that has RELEASED its hub contributes nothing to wait for (#3321 step 3):
+ /// the hub is gone, so its Started task cannot be reached, let alone settle.
+ ///
+ /// π¨ And the guard is PRESENCE (HubIfHeld), never liveness
+ /// (TryGetHub) β measured. Writing this with TryGetHub() broke
+ /// DataContextFaultedInitBeforeStreamHubBoundTest: a stream whose initial load FAULTS is
+ /// not IsUsable, so it was excluded from the WhenAll, this task completed
+ /// SUCCESSFULLY, and a hub whose data source had thrown went on answering requests as though
+ /// nothing had happened. A faulted stream's Started task is the very thing that must
+ /// still be awaited β it is how the fault reaches DataContext's gate.
///
public Task Initialized
{
get
{
lock (Streams)
- return Task.WhenAll(Streams.Values.Select(s => s.Hub.Started));
+ return Task.WhenAll(Streams.Values
+ .Select(s => s.HubIfHeld())
+ .Where(h => h is not null)
+ .Select(h => h!.Started));
}
}
///
diff --git a/src/MeshWeaver.Data/ISynchronizationStream.cs b/src/MeshWeaver.Data/ISynchronizationStream.cs
index eb4f224e0b..62d951fcbf 100644
--- a/src/MeshWeaver.Data/ISynchronizationStream.cs
+++ b/src/MeshWeaver.Data/ISynchronizationStream.cs
@@ -86,14 +86,22 @@ ISynchronizationStream Reduce(
/// The message hub associated with this stream.
///
- /// π¨ Non-nullable in the contract, but not in life. A stream whose owner has been
- /// torn down keeps answering this property, and the hub it hands back may already be winding
- /// down β dereferencing it is how a recycle window produced an NRE inside
- /// LayoutAreaHost's constructor that escaped to the subscriber as a TERMINAL
- /// DeliveryFailure (Systemorph/MeshWeaver#3321). On any path that can run concurrently
- /// with teardown, prefer , which answers
- /// null instead, or gate on
- /// .
+ /// π¨ Non-nullable in the contract, but not in life β and since #3321 step 3 it is
+ /// ABSENT on a stream that has let its hub go. A disposed stream, and a stream whose hosted
+ /// sub-hub was killed by its parent's teardown, both RELEASE this reference: that release is
+ /// what reclaims the stream β dead hub β resolved state graph that held ~580 MB of a
+ /// production replica's heap. The declaration stays non-nullable because these assemblies ship
+ /// as packages and changing it would break every downstream implementer.
+ ///
+ /// So never dereference this on a path that can run concurrently with teardown. Use
+ /// , which answers null, or gate on
+ /// . Where the
+ /// surface has no absent value to return β a patch reducer, whose signature is
+ /// β¦ β ChangeItem<T> β use
+ /// , which refuses with the TRANSIENT
+ /// HubDisposingException rather than an NRE. A raw dereference here is how a recycle
+ /// window produced an NRE inside LayoutAreaHost's constructor that escaped to the
+ /// subscriber as a TERMINAL DeliveryFailure.
///
IMessageHub Hub { get; }
/// The hub that hosts the underlying data source backing this stream.
diff --git a/src/MeshWeaver.Data/Persistence/PartitionedHubDataSource.cs b/src/MeshWeaver.Data/Persistence/PartitionedHubDataSource.cs
index bfd3f363ae..e4c92b8c1b 100644
--- a/src/MeshWeaver.Data/Persistence/PartitionedHubDataSource.cs
+++ b/src/MeshWeaver.Data/Persistence/PartitionedHubDataSource.cs
@@ -98,13 +98,27 @@ protected override ISynchronizationStream CreateStream(StreamIdenti
.Synchronize()
.Subscribe(changes =>
{
+ // π¨ #3321 step 3 β same shape as WorkspaceStreams' combined subscription: a
+ // callback in flight when the stream is torn down finds `ret.Hub` released.
+ // Dropping the frame is what `ret.OnNext` would do anyway on a released stream,
+ // so it is the answer this void callback already models. PRESENCE, not liveness
+ // (HubIfHeld, not TryGetHub): this guard must match OnNext's own
+ // `isDisposed || Hub is null` exactly, so it refuses precisely when the
+ // emission would be dropped and never a frame that would still have landed.
+ if (ret.HubIfHeld() is not { } retHub)
+ {
+ Logger.LogDebug(
+ "PartitionedHubDataSource: combined stream {StreamId} is torn down β "
+ + "dropping this frame", ret.StreamId);
+ return;
+ }
if (!isInitialized)
{
var initialStore = changes
.Where(c => c.Value != null)
.Aggregate(new EntityStore(), (acc, change) => acc.Merge(change.Value!));
- ret.OnNext(new ChangeItem(initialStore, ret.StreamId, ret.Hub.Version));
+ ret.OnNext(new ChangeItem(initialStore, ret.StreamId, retHub.Version));
foreach (var change in changes)
processedChangeItems.Add(change);
@@ -120,7 +134,7 @@ protected override ISynchronizationStream CreateStream(StreamIdenti
.Where(c => c.Value != null)
.Aggregate(new EntityStore(), (acc, change) => acc.Merge(change.Value!));
- ret.OnNext(new ChangeItem(updatedStore, ret.StreamId, ret.Hub.Version)
+ ret.OnNext(new ChangeItem(updatedStore, ret.StreamId, retHub.Version)
{
ChangedBy = string.Join(", ", newChanges.Select(c => c.ChangedBy).Where(cb => !string.IsNullOrEmpty(cb))),
Updates = newChanges.SelectMany(c => c.Updates).ToArray(),
diff --git a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs
index 6b97fcb916..9f791d0480 100644
--- a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs
+++ b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs
@@ -576,16 +576,37 @@ internal static ISynchronizationStream CreateExternalClient(
- delivery =>
- {
- reduced.DeliverMessage(delivery);
- return delivery.Forwarded();
- },
- d => reduced.StreamId.Equals(d.Message.StreamId)
- )
- );
+ // π¨ The stream's hub is resolved ONCE here and reused for both owner-protocol
+ // registrations below (#3321 step 3). `reduced` was created a few lines up, but its host
+ // can be torn down underneath this setup β and since step 3 a RELEASED stream answers
+ // `Hub` with nothing, so `reduced.Hub.Register(β¦)` would NRE while EVALUATING the
+ // argument, i.e. before RegisterForDisposal could apply its own already-dead guard. Not
+ // registering is the honest "no": a handler on a hub that is gone can never fire, and
+ // RegisterForDisposal would have disposed the registration on the spot anyway. Nothing
+ // else in this method is skipped β the keep-alive and resubscribe arms below are
+ // independently guarded and must still be wired for a stream that recovers.
+ //
+ // PRESENCE (HubIfHeld), not liveness (TryGetHub): RegisterForDisposal's own guard is
+ // `isDisposed || Hub is null`, and this must match it β refusing to wire the owner
+ // protocol on a stream that is merely winding down would drop the UnsubscribeRequest and
+ // StreamEndedEvent handling for a stream that is still going to serve.
+ var reducedHub = reduced.HubIfHeld();
+ if (reducedHub is null)
+ logger.LogDebug(
+ "Stream {StreamId}: released its hub before the owner-protocol handlers could be "
+ + "registered β nothing to wire (#3321)", reduced.StreamId);
+
+ else
+ reduced.RegisterForDisposal(
+ reducedHub.Register(
+ delivery =>
+ {
+ reduced.DeliverMessage(delivery);
+ return delivery.Forwarded();
+ },
+ d => reduced.StreamId.Equals(d.Message.StreamId)
+ )
+ );
// π¨ THE OWNER ANNOUNCING THAT OUR SERVER-SIDE HALF HAS ENDED (#2191).
//
@@ -605,27 +626,28 @@ internal static ISynchronizationStream CreateExternalClient(
- delivery =>
- {
- // Debug, not Information: Resubscribe already logs the re-ask at Information
- // WITH this reason string, so an Information line here would double the Loki
- // volume of one event.
- logger.LogDebug(
- "Stream {StreamId}: owner {Owner} ended the server-side subscription β re-asking once its teardown settles.",
- reduced.StreamId, owner);
- // No activation identity on this carrier β the owner ANNOUNCED the end rather
- // than NACKing, so there is no NACK text to read one from. A null tag is
- // treated as "a distinct activation" by the budget below, i.e. exactly the
- // pre-#2986 accounting: never guessed, never free.
- rejectedByRecycle.OnNext(new RecycleRejection(
- "ended our server-side subscription", null));
- return delivery.Processed();
- },
- d => reduced.StreamId.Equals(d.Message.StreamId)
- )
- );
+ if (reducedHub is not null)
+ reduced.RegisterForDisposal(
+ reducedHub.Register(
+ delivery =>
+ {
+ // Debug, not Information: Resubscribe already logs the re-ask at
+ // Information WITH this reason string, so an Information line here would
+ // double the Loki volume of one event.
+ logger.LogDebug(
+ "Stream {StreamId}: owner {Owner} ended the server-side subscription β re-asking once its teardown settles.",
+ reduced.StreamId, owner);
+ // No activation identity on this carrier β the owner ANNOUNCED the end
+ // rather than NACKing, so there is no NACK text to read one from. A null
+ // tag is treated as "a distinct activation" by the budget below, i.e.
+ // exactly the pre-#2986 accounting: never guessed, never free.
+ rejectedByRecycle.OnNext(new RecycleRejection(
+ "ended our server-side subscription", null));
+ return delivery.Processed();
+ },
+ d => reduced.StreamId.Equals(d.Message.StreamId)
+ )
+ );
reduced.RegisterForDisposal(
new AnonymousDisposable(
@@ -1802,7 +1824,8 @@ fromWorkspace as ISynchronizationStream
/// The current state serialized as JSON.
/// The JSON patch to apply, or null for a full update.
/// Identifier of the principal that made the change.
- /// The resulting change item, or null when no patch function is registered.
+ /// The resulting change item, or null when no patch function is registered β or when
+ /// the stream has released its hub, which is the same "nothing to derive" answer.
public static ChangeItem? ToChangeItem(
this ISynchronizationStream stream,
TReduced currentState,
@@ -1810,9 +1833,51 @@ fromWorkspace as ISynchronizationStream
JsonPatch? patch,
string changedBy)
{
- return stream.ReduceManager.PatchFunction?.Invoke(stream, currentState, currentJson, patch, changedBy);
+ try
+ {
+ return stream.ReduceManager.PatchFunction?.Invoke(stream, currentState, currentJson, patch, changedBy);
+ }
+ catch (HubDisposingException)
+ {
+ // π¨ THE ONE GATEWAY into every patch reducer, so this is the one place that has to
+ // translate their refusal (#3321 step 3). A reducer's signature is `β¦ β ChangeItem`:
+ // it must return a change, so when the stream has RELEASED its hub β step 3 drops the
+ // reference on disposal and on the hub's own death, which is what reclaims the leaked
+ // `stream β dead hub β resolved state` graph β its only channel is
+ // `RequireHub()`'s transient throw.
+ //
+ // This is NOT a swallow. `null` is this method's OWN modelled answer for "no change was
+ // derived" (it is what a stream with no PatchFunction registered already returns), and
+ // both callers handle it: SynchronizationStream.BuildChangeItem falls through to its
+ // type-registry path, and UpdateStream falls back to deserializing the patched JSON
+ // through Host.JsonSerializerOptions. Only THIS exception type is caught, by design β
+ // anything else a reducer throws is a real fault and still propagates.
+ //
+ // Debug, not Warning: the window is a teardown race on a stream that is already gone,
+ // it produces no user-visible effect (a dead stream's store is completed, so the change
+ // had nowhere to land), and it can fan out across every reduced stream of one dying hub.
+ LogPatchSkippedOnReleasedStream(stream);
+ return null;
+ }
}
+ ///
+ /// Best-effort Debug line for the skip above.
+ ///
+ /// π¨ Resolved through rather than a bare
+ /// ServiceProvider.GetService<ILoggerFactory>(), and that matters here more than
+ /// anywhere else in this file: a stream whose hub was just released is very often one whose HOST
+ /// is winding down too, and Autofac throws ObjectDisposedException from a closed scope. A
+ /// raw resolution would turn a benign "no patch derived" into a fault raised BY THE DIAGNOSTIC,
+ /// on the exact teardown path this code exists to make quiet. GetLogger already answers
+ /// that with a NullLogger β losing the line is the correct price, and
+ /// SynchronizationStream.ResolveLogger carries the same guard for the same reason.
+ ///
+ private static void LogPatchSkippedOnReleasedStream(ISynchronizationStream stream)
+ => GetLogger(stream.Host.ServiceProvider).LogDebug(
+ "[SYNC_STREAM] Patch reduction skipped for {StreamId} ({Reference}) β the stream "
+ + "released its hub before the reducer ran (#3321)", stream.StreamId, stream.Reference);
+
///
/// Converts a JSON patch over a whole entity-store projection into per-entity
diff --git a/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs
index b9c6dbe231..0c29015c9f 100644
--- a/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs
+++ b/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs
@@ -374,6 +374,30 @@ public ChangeItem? Current
/// PRE-EXISTING sub-hub, in which case the configuration lambda β and therefore
/// β never runs (and no BuildupAction runs either, so nothing can
/// observe the gap).
+ ///
+ /// π¨ RELEASED when the hub dies β this field is what leaked
+ /// (Systemorph/MeshWeaver#3321, step 3 of 3). A heap dump of a 26 h production replica found
+ /// 1 496 MessageHubs at RunLevel=Dead still reachable, and the ONLY thing holding
+ /// them was this property: 1 485 SynchronizationStream<MeshNode> + 11
+ /// <JsonElement>, one stream per corpse, exactly. Closing a DI scope does not null
+ /// the fields of an object that outlives it, so each dead hub kept its own Autofac
+ /// ILifetimeScope and TypeRegistry alive β ~390 KB apiece, ~580 MB of the 3.5 GB
+ /// live heap. is now called from BOTH ends of that lifetime:
+ /// , and a hook on the sub-hub's own disposal for the far more common case
+ /// where the PARENT tears the sub-hub down while nobody ever disposes the stream (1 485 of the
+ /// 1 496).
+ ///
+ /// π¨ So the declaration is non-nullable and the field is not, deliberately.
+ /// Making it IMessageHub? is a breaking change to a public interface these assemblies
+ /// ship as packages; what makes it SAFE instead is that every dereference reachable
+ /// concurrently with teardown goes through
+ /// (answers null) or
+ /// (throws the TRANSIENT
+ /// ), and every read inside this file resolves the field
+ /// ONCE into a local rather than re-reading it after its own guard. The predecessor of this
+ /// code nulled the field with neither of those in place and it cost a production NRE inside
+ /// LayoutAreaHost's constructor that reached the subscriber as a TERMINAL
+ /// DeliveryFailure. See Doc/Architecture/StreamLivenessAndTheHubReference.
///
public IMessageHub Hub { get; private set; } = null!;
@@ -383,6 +407,36 @@ public ChangeItem? Current
///
private void BindHub(IMessageHub syncHub) => Hub = syncHub;
+ ///
+ /// Drops the reference to the synchronization hub β the release half of #3321.
+ ///
+ /// Idempotent and callable from any thread. It does NOT dispose anything: by the time it
+ /// runs the hub is either being disposed by us () or has just finished
+ /// disposing itself (the constructor's hook), so there is nothing left to tear down β only a
+ /// reference to let go of. Everything that could still read the field is already guarded:
+ /// , the isDisposed || Hub is null arms on
+ /// / / , and
+ /// , which has always treated an absent hub as dead β so a
+ /// released stream reports itself unusable through the same predicate every cache already
+ /// consults.
+ ///
+ private void ReleaseHub()
+ {
+ var released = Interlocked.Exchange(ref hubReleased, 1) == 0;
+ if (!released)
+ return;
+ // Read the address BEFORE dropping the reference β the log line is the only trace that a
+ // corpse was let go, and it is the counter a heap re-measurement is compared against.
+ var address = Hub?.Address;
+ Hub = null!;
+ logger.LogDebug(
+ "[SYNC_STREAM] Released hub {Address} from stream {StreamId} β the stream no longer "
+ + "retains it (#3321)", address, StreamId);
+ }
+
+ /// 0 until has run; CAS'd to 1 exactly once.
+ private int hubReleased;
+
///
/// The host of the synchronization stream.
///
@@ -648,7 +702,13 @@ public void Update(Func valueUpdate, Action excep
var updated = valueUpdate(current);
if (updated is null) return null;
if (current is not null && Equals(current, updated)) return null;
- return BuildChangeItem(current, updated);
+ // Resolve the hub ONCE, here on the turn (#3321 step 3). The transform runs inside the
+ // sync hub's UpdateStreamRequest handler, which a Dispose() on another thread can
+ // overtake β and `null` is this delegate's own documented no-op, so a stream that let
+ // its hub go answers with the absence it already models instead of NRE'ing on the way
+ // into BuildChangeItem.
+ if (!TryGetActiveHub(out var hub)) return null;
+ return BuildChangeItem(hub, current, updated);
}, exceptionCallback);
///
@@ -670,7 +730,9 @@ public void SetFull(Func valueUpdate, Action exce
{
var updated = valueUpdate(current);
if (updated is null) return null;
- return BuildFullChangeItem(current, updated);
+ // Hub resolved once on the turn β see Update above (#3321 step 3).
+ if (!TryGetActiveHub(out var hub)) return null;
+ return BuildFullChangeItem(hub, current, updated);
}, exceptionCallback);
///
@@ -698,10 +760,13 @@ public void SetFull(Func valueUpdate)
/// never emitted its content (the DataChangeStreamUpdateTest count-view non-emission).
///
///
- private long OwnerVersion()
- => Owner.Equals(Host.Address) ? Hub.Version : (Current?.Version ?? 0L);
+ /// The stream's hub, ALREADY RESOLVED by the caller's guard β never
+ /// re-read from the field here, because a Dispose() on another thread can null it between the
+ /// guard and this line (#3321 step 3).
+ private long OwnerVersion(IMessageHub hub)
+ => Owner.Equals(Host.Address) ? hub.Version : (Current?.Version ?? 0L);
- private ChangeItem BuildChangeItem(TStream? current, TStream updated)
+ private ChangeItem BuildChangeItem(IMessageHub hub, TStream? current, TStream updated)
{
// π¨ ChangedBy is the stream-echo-suppression key β the identity of the STREAM that
// originated the change β and it must MATCH the value the echo-suppression filters
@@ -719,11 +784,11 @@ private ChangeItem BuildChangeItem(TStream? current, TStream updated)
// an empty ChangedBy breaks both filters. ClientId is a non-empty Guid by construction.
var changedBy = ClientId;
// π¨ ONLY the owning hub sets Version. Subscriber carries the base it read.
- var version = OwnerVersion();
+ var version = OwnerVersion(hub);
if (current is not null)
{
- var updatedJson = JsonSerializer.SerializeToElement(updated, Hub.JsonSerializerOptions);
+ var updatedJson = JsonSerializer.SerializeToElement(updated, hub.JsonSerializerOptions);
// 1. PatchFunction (e.g. PatchMeshNode) derives the per-entity Updates
// from currentβupdated. Registered on the OWNER's reduce config; a
// lightweight subscriber may not have it, so this can be null.
@@ -737,7 +802,7 @@ private ChangeItem BuildChangeItem(TStream? current, TStream updated)
// (ToDataChangeRequest) gets a well-formed Update. Without this the
// change shipped as a Full with empty Updates and the write-back's
// `Updates.Any()` filter dropped it, so the write never persisted.
- var typeRegistry = Hub.ServiceProvider.GetService();
+ var typeRegistry = hub.ServiceProvider.GetService();
if (typeRegistry is not null
&& typeRegistry.TryGetCollectionName(typeof(TStream), out var collection)
&& !string.IsNullOrEmpty(collection))
@@ -763,15 +828,15 @@ private ChangeItem BuildChangeItem(TStream? current, TStream updated)
/// clobbered by an older Full. See
/// .
///
- private ChangeItem BuildFullChangeItem(TStream? current, TStream updated)
+ private ChangeItem BuildFullChangeItem(IMessageHub hub, TStream? current, TStream updated)
{
// ChangedBy = ClientId always (never empty; matches the echo-suppression filters,
// never the per-instance StreamId). AccessContext is orthogonal. See BuildChangeItem.
var changedBy = ClientId;
// π¨ ONLY the owning hub sets Version. Subscriber carries the base it read.
- var version = OwnerVersion();
+ var version = OwnerVersion(hub);
- var typeRegistry = Hub.ServiceProvider.GetService();
+ var typeRegistry = hub.ServiceProvider.GetService();
if (typeRegistry is not null
&& typeRegistry.TryGetCollectionName(typeof(TStream), out var collection)
&& !string.IsNullOrEmpty(collection))
@@ -951,10 +1016,23 @@ public void OnError(Exception error)
// stream whose Hub was never bound β but it must be LOUD: the silent version cost
// #2625 two months of an unreproducible CI flake, because everything downstream
// simply waited forever with nothing logged.
- if (Hub is not null)
+ if (Hub is { } errorHub)
{
- Hub.FailStartup(error);
- Hub.OpenGate(SynchronizationGate);
+ errorHub.FailStartup(error);
+ errorHub.OpenGate(SynchronizationGate);
+ }
+ else if (Volatile.Read(ref hubReleased) == 1)
+ {
+ // π¨ A SECOND way to reach the branch above, introduced by #3321 step 3, and it is
+ // benign β so it must NOT borrow that branch's LogError. The hub was RELEASED
+ // because it died (this stream is undisposed, but its hosted sub-hub was torn down
+ // by its parent), which means there is no Started task left to settle: nothing is
+ // waiting on it, because the hub that owned it is already Dead. Levelling this at
+ // Error would ship a stack trace to Loki for every ordinary circuit teardown.
+ logger.LogDebug(error,
+ "[SYNC_STREAM] OnError for {StreamId} (Reference={Reference}, Owner={Owner}) "
+ + "after its hub was released β the sub-hub is already gone, so there is no "
+ + "startup left to fault.", StreamId, Reference, Owner);
}
else
{
@@ -1009,7 +1087,10 @@ private static bool IsTransientHubTimeout(Exception? error)
/// The disposable to dispose with the stream.
public void RegisterForDisposal(IDisposable disposable)
{
- if (isDisposed || Hub is null)
+ // ONE read of the field, used everywhere below β a Dispose() on another thread can null it
+ // between the guard and the use (#3321 step 3).
+ var hub = Hub;
+ if (isDisposed || hub is null)
{
// Disposed stream β no hub to register on. Dispose the registrant
// immediately so the caller doesn't leak it. The caller's intent
@@ -1023,7 +1104,7 @@ public void RegisterForDisposal(IDisposable disposable)
// without Dispose() ever being called on the stream. Everything else rides the composite,
// which Dispose() walks SYNCHRONOUSLY (see the field note).
if (Interlocked.Exchange(ref hubDisposalHooked, 1) == 0)
- Hub.RegisterForDisposal(streamDisposables);
+ hub.RegisterForDisposal(streamDisposables);
// CompositeDisposable.Add disposes the registrant immediately if the composite is already
// disposed, so a registration racing Dispose() cannot leak.
@@ -1037,12 +1118,14 @@ public void RegisterForDisposal(IDisposable disposable)
/// The processed delivery, or a failed delivery if the stream is dead/disposed.
public IMessageDelivery DeliverMessage(IMessageDelivery delivery)
{
- if (isDisposed || Hub is null)
+ // ONE read of the field β see RegisterForDisposal (#3321 step 3).
+ var hub = Hub;
+ if (isDisposed || hub is null)
{
logger.LogDebug("[SYNC_STREAM] DeliverMessage skipped for {StreamId} β stream is dead/disposed", StreamId);
return delivery.Failed("Stream is disposed");
}
- return Hub.DeliverMessage(delivery.ForwardTo(Hub.Address));
+ return hub.DeliverMessage(delivery.ForwardTo(hub.Address));
}
@@ -1055,7 +1138,9 @@ public void OnNext(ChangeItem value)
{
// A DISPOSED stream has no hub to post to; drop the value rather than
// NRE'ing on Hub.Post. (Subscribers already saw Store.OnCompleted.)
- if (isDisposed || Hub is null)
+ // ONE read of the field β see RegisterForDisposal (#3321 step 3).
+ var hub = Hub;
+ if (isDisposed || hub is null)
{
logger.LogDebug("[SYNC_STREAM] OnNext skipped for {StreamId} β stream is dead/disposed", StreamId);
return;
@@ -1074,7 +1159,7 @@ public void OnNext(ChangeItem value)
// delivery.AccessContext naturally. No ImpersonateAsHub stamping
// here β hub addresses were polluting CreatedBy on user-driven
// writes via the AsyncLocal leak (fixed 2026-05-22).
- Hub.Post(new SetCurrentRequest(value));
+ hub.Post(new SetCurrentRequest(value));
}
catch (Exception ex)
{
@@ -1217,6 +1302,29 @@ public SynchronizationStream(
// that outlives it is exactly the leaked callback the quiescing budget flags.
RegisterForDisposal(resyncSubscription);
+ // π¨ THE HOOK THAT ACTUALLY RECLAIMS THE LEAK (#3321 step 3). A sync sub-hub is a HOSTED
+ // hub: its parent disposes it during the parent's own teardown β a Blazor circuit ending, a
+ // DisposeRequest, a recycle β while this stream is owned by a workspace somewhere else and
+ // is never told. Measured on a 26 h production replica: 1 496 hubs at RunLevel=Dead, of
+ // which only 11 sat under a stream that had itself been disposed. So Dispose() clearing the
+ // field would have reclaimed 11 of 1 496; this hook is what reaches the other 1 485.
+ //
+ // Registered AFTER the line above on purpose: that call is what hooks `streamDisposables`
+ // onto the hub (RegisterForDisposal's one-shot `hubDisposalHooked`), and the hub's
+ // composite disposes in registration order β so the stream's own registrants run FIRST and
+ // still see a bound Hub, and this release runs after them.
+ syncHub.RegisterForDisposal(_ => ReleaseHub());
+
+ // A hub that was already past the point of accepting registrants disposes this one inline,
+ // so the release may ALREADY have happened. A stream with no hub is not a stream: refuse
+ // exactly as the null branch above does, rather than handing back one that can never work.
+ if (Hub is null)
+ {
+ isDisposed = true;
+ Store.OnCompleted();
+ throw new HubDisposingException(Host.Address, Reference);
+ }
+
// π¨ Capture the creating user's identity ONCE, here on the thread that constructs
// the stream β in production that is the circuit thread (cache.GetMeshNodeStream)
// or the owner's SubscribeRequest handler, where AccessService.Context is the real
@@ -1466,7 +1574,7 @@ d.Message is SetCurrentRequest or DeliveryFailure or GetDataResponse
// (far-higher) version made the monotonicity guard drop the lower-versioned
// render Fulls as stale, so a late layout-area subscriber stayed stuck on
// "Building layoutβ¦" and never emitted its content.
- SetCurrent(hub, new ChangeItem(value, StreamId, OwnerVersion()));
+ SetCurrent(hub, new ChangeItem(value, StreamId, OwnerVersion(hub)));
observer.OnNext(System.Reactive.Unit.Default);
},
observer.OnError,
@@ -1484,7 +1592,7 @@ d.Message is SetCurrentRequest or DeliveryFailure or GetDataResponse
{
// Same one-clock invariant as the observable-init path above: OwnerVersion(),
// never Host.Version, so the init frame can't outrank later owned writes.
- SetCurrent(hub, new ChangeItem(init, StreamId, OwnerVersion()));
+ SetCurrent(hub, new ChangeItem(init, StreamId, OwnerVersion(hub)));
return System.Reactive.Unit.Default;
})
// π¨ A faulted initial load must fault the STREAM, not only this hub's buildup.
@@ -1513,7 +1621,7 @@ private void UpdateStream(IMessageDelivery delivery, IMessageH
logger.LogDebug("[SYNC_STREAM] UpdateStream called for {StreamId}, ChangeType={ChangeType}, Version={Version}, MessageId={MessageId}",
StreamId, delivery.Message.ChangeType, delivery.Message.Version, delivery.Id);
- if (Hub is null || Hub.IsDisposing)
+ if (Hub is not { IsDisposing: false })
{
logger.LogDebug("[SYNC_STREAM] UpdateStream skipped for {StreamId} - hub is disposing/dead", StreamId);
return;
@@ -2246,8 +2354,16 @@ public void Dispose()
"[SYNC_STREAM] Registrant faulted while disposing stream {StreamId}", StreamId);
}
- if (Hub is not null && Hub.RunLevel <= MessageHubRunLevel.Started)
- Hub.Dispose();
+ if (Hub is { RunLevel: <= MessageHubRunLevel.Started } ownHub)
+ ownHub.Dispose();
+
+ // π¨ DROP THE REFERENCE β the whole point of #3321 step 3. Hub.Dispose() above only STARTS
+ // the hub's teardown (it posts ShutdownRequest and returns), and closing the hub's Autofac
+ // scope β which HostedHubsCollection does, correctly, several turns later β does not null
+ // the fields of an object that outlives it. Until this line the stream kept the corpse and
+ // everything it had resolved (its own ILifetimeScope, its own TypeRegistry) reachable
+ // forever. The hub-death hook registered in the constructor covers the other direction.
+ ReleaseHub();
}
private ConcurrentDictionary Properties { get; } = new();
/// Reads a property bag value by key.
diff --git a/src/MeshWeaver.Data/StandardReducers.cs b/src/MeshWeaver.Data/StandardReducers.cs
index 5cc0580d74..ed72a5d32f 100644
--- a/src/MeshWeaver.Data/StandardReducers.cs
+++ b/src/MeshWeaver.Data/StandardReducers.cs
@@ -106,21 +106,31 @@ private static ChangeItem ReduceJsonElementTo(ChangeItem`: it MUST return a change, so there is no absent value to hand back and no
+ // error channel but a throw. Since step 3 a disposed stream RELEASES its hub, so `stream.Hub`
+ // can be absent while the reducer runs (the transform executes on the sync hub's turn, which a
+ // Dispose() on another thread can overtake); reading the field four times, as the code below
+ // used to, is four chances to NRE. RequireHub() reads it once and refuses with the TRANSIENT
+ // HubDisposingException, which the single gateway (JsonSynchronizationStream.ToChangeItem)
+ // turns into the `null` its own signature already models β "no patch derived", not a fault.
private static ChangeItem PatchJsonElement(ISynchronizationStream stream, JsonElement current, JsonElement updated, JsonPatch? patch, string changedBy)
{
- var typeRegistry = stream.Hub.TypeRegistry;
- return new(updated, changedBy, stream.StreamId, ChangeType.Patch, stream.Current?.Version ?? stream.Hub.Version, current.ToEntityUpdates(updated, patch!, stream.Hub.JsonSerializerOptions, typeRegistry));
+ var hub = stream.RequireHub();
+ var typeRegistry = hub.TypeRegistry;
+ return new(updated, changedBy, stream.StreamId, ChangeType.Patch, stream.Current?.Version ?? hub.Version, current.ToEntityUpdates(updated, patch!, hub.JsonSerializerOptions, typeRegistry));
}
private static ChangeItem PatchInstanceCollectionJsonElement(ISynchronizationStream stream, InstanceCollection current, JsonElement updated, JsonPatch? patch, string changedBy)
{
- var updatedInstances = updated.Deserialize(stream.Hub.JsonSerializerOptions)!;
+ var hub = stream.RequireHub();
+ var updatedInstances = updated.Deserialize(hub.JsonSerializerOptions)!;
return new(
updatedInstances,
changedBy,
stream.StreamId,
ChangeType.Patch,
- stream.Current?.Version ?? stream.Hub.Version,
- current.ToEntityUpdates((CollectionReference)stream.Reference, updated, patch!, stream.Hub.JsonSerializerOptions));
+ stream.Current?.Version ?? hub.Version,
+ current.ToEntityUpdates((CollectionReference)stream.Reference, updated, patch!, hub.JsonSerializerOptions));
}
private static ChangeItem