Skip to content

Refuse to build a dead SynchronizationStream; classify the refusal as transient (page dies on recycle) - #680

Merged
rbuergi merged 1 commit into
mainfrom
fix/layoutareahost-dead-stream
Jul 27, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/layoutareahost-dead-stream

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

A page subscribing while its hub recycles died with a terminal DeliveryFailure — NullReferenceException at LayoutAreaHost..ctor. Reproduced 6 times in 26 rounds locally (a sweep saw 4/8). This is the same bug class as #672, one layer up: #672 taught the messaging layer to answer a recycle race with a retryable nack; the data/layout layer still hard-faulted.

The window is structural, not a fluke

Two gates disagree:

  • MessageHub.Dispose() freezes hosted-hub creation on its first statement (HostedHubsCollection.CloseCreation) — before the ShutdownRequest that moves RunLevel off Started is even posted — and the freeze cascades to the whole subtree, so a child is frozen while its own RunLevel still reads Started.
  • MessageService.ScheduleNotify's intake gate only rejects from DisposeHostedHubs on.

The entire Quiescing drain sits in that gap, so a disposing hub accepts requests it structurally cannot serve.

Serving a layout area means constructing a SynchronizationStream, which must own a hosted sub-hub. The constructor answered the refusal by fabricating a corpse — isDisposed, completed store, Hub = null! — documenting that consumers must use TryGetActiveHub.

No consumer can honour that contract: grep -rn TryGetActiveHub src matches only that file, ~96 sites dereference the interface's non-nullable Hub, and ISynchronizationStream exposes no liveness member to check. LayoutAreaHost..ctor:136 NRE'd, and it reached the subscriber as a terminal DeliveryFailure (ErrorType.Unknown) — which the client's sync stream OnErrors, killing the keep-alive and resubscribe latch. Page dead.

Chosen fix: make the corpse unconstructible, then classify the refusal

The constructor refuses rather than fabricating an invariant-violating object, throwing a typed transient HubDisposingException; MessageService classifies any handler exception that is or wraps one as ErrorType.ShuttingDown — the same "ask again" answer #672 gave one layer down. One decision point, covering every stream-creating path.

Alternatives rejected, for the record:

  • Guard every dereference — untenable at ~96 sites, and node-native plugins register their own reducers compiled live on the mesh, so the site set is unbounded.
  • Early-reject SubscribeRequest — racy and incomplete: can't cover the ancestor-cascade freeze (RunLevel still Started), misses in-process stream creation (Blazor circuit, WorkspaceStreams, MeshNodeStreamCache), and duplicates the decision in two places.

Two existing facts made refusal the safe shape rather than a risky change: the sibling path JsonSynchronizationStream.CreateExternalClient already threw here, and Blazor already wraps BindStream in catch (ObjectDisposedException) with the comment "old circuit's hub may already be disposing". So HubDisposingException : ObjectDisposedException — existing teardown handling keeps working, and only the classification is new. The original "don't throw" rationale was an IDE first-chance-break annoyance at a call site that already had a Catch downstream.

Logged at Debug, not Error — a recycle race is routine and paging operators for normal teardown is noise — and logging message type + id, never LogText(delivery), which was the core #608 hot-path serialization storm.

Evidence

The new SubscribeDuringRecycleTest holds the window open by parking one un-answered response callback so Quiescing cannot drain, then waits until RunLevel has demonstrably reached Quiescing before subscribing — verified state, no sleeps, no racing. Reverting only the constructor makes it fail with exactly the production signature (errorType=Unknown, TargetInvocationException → NRE).

rounds NRE at LayoutAreaHost..ctor
baseline 26 6
with fix 26 0

Also corrected: the test's own comment claimed "the wait exhausted all attempts". That is wrong — the loop catches only TimeoutException, so the DeliveryFailureException escaped on the first probe; six 15 s windows cannot fit in a 2.1–2.6 s failure.

Messaging.Hub 107/107 · Data 287/287 · Layout 338/338 · Graph 636/636 · Hosting.Monolith 402/402 (2 env-gated skips) · full solution -c Release -p:CIRun=true -warnaserror clean.

What this does not establish: that CI's rate for OverlaySelfHealInstanceRecycleTest goes to zero — see below.

Found but not fixed

  1. A second, independent failure mode in the same test, pre-existing and untouched: 2/18 baseline vs 2/26 with the fix (~10% either way). After this fix, step 3 times out because the NodeType never reaches Ok with a usable build within 90 s (the parked-Error → auto-un-park → recompile path). Different signature, no NRE, never reaches the probe loop. It will keep this test red at roughly that rate until investigated separately.
  2. Workspace.GetExternalClientSynchronizationStream's Lazy<…>(ExecutionAndPublication) caches the exception when CreateExternalClient throws — lazy.Value throws before the loop's removal code runs, so the poisoned entry never clears. Harmless today (that hub's workspace is dying anyway), pre-existing.
  3. HandleSubscribeRequest dispatches SubscribeToClient inside RunReadValidators(...).Subscribe(onNext). Every validator completes synchronously today, so a throw propagates into the handler and is caught — but if one ever completes off the hub turn, the exception escapes unobserved on a foreign thread. Pre-existing; the NRE had the same exposure.

Documentation: new "The creation window" section in HubDisposalModel.md (plus a stale test-name reference fixed).

No What's New entry — the user-visible effect is a page that no longer dies during a recycle; there is no new behaviour to describe.

🤖 Generated with Claude Code

… transient

A page subscribing while its hub recycles died with a terminal DeliveryFailure:
NullReferenceException at LayoutAreaHost..ctor. Reproduced 6 times in 26 rounds
of OverlaySelfHealInstanceRecycleTest (the sweep saw 4/8).

The window is STRUCTURAL, not a millisecond fluke, and it sits between two gates
that disagree:

  - MessageHub.Dispose() freezes hosted-hub creation on its FIRST statement
    (HostedHubsCollection.CloseCreation) — before the ShutdownRequest that moves
    RunLevel off Started is even posted — and the freeze CASCADES to the whole
    subtree, so a child is frozen while its own RunLevel still reads Started.
  - MessageService.ScheduleNotify's intake gate only rejects from
    DisposeHostedHubs on.

The entire Quiescing drain sits in that gap, so a disposing hub accepts requests
it cannot serve. Serving a layout area means constructing a
SynchronizationStream, which must own a hosted sub-hub. The ctor answered the
refusal by fabricating a corpse — isDisposed, completed store, Hub = null! —
documenting that consumers must use TryGetActiveHub. No consumer can honour
that: grep -rn TryGetActiveHub src matches only that file, ~96 sites dereference
the interface's NON-NULLABLE Hub, and ISynchronizationStream exposes no liveness
member to check. LayoutAreaHost..ctor:136 NRE'd, and it reached the subscriber
as a terminal DeliveryFailure (ErrorType.Unknown), which the client's sync
stream OnErrors — killing the keep-alive and resubscribe latch. Page dead.

Fix: the constructor REFUSES instead of fabricating an invariant-violating
object, throwing a typed transient HubDisposingException, and MessageService
classifies any handler exception that is or wraps one as ErrorType.ShuttingDown
— the same "ask again" answer #672 gave one layer down. One decision point,
covering every stream-creating path.

Rejected alternatives, for the record:
  - Guard every dereference: untenable at ~96 sites, and node-native plugins
    register their own reducers compiled live on the mesh, so the site set is
    unbounded.
  - Early-reject SubscribeRequest: racy and incomplete — it cannot cover the
    ancestor-cascade freeze (RunLevel still Started), misses in-process stream
    creation (Blazor circuit, WorkspaceStreams, MeshNodeStreamCache), and
    duplicates the decision in two places.

Two existing facts made refusal the safe shape rather than a risky change: the
sibling path JsonSynchronizationStream.CreateExternalClient ALREADY threw here,
and Blazor already wraps BindStream in catch (ObjectDisposedException) with the
comment "old circuit's hub may already be disposing". So HubDisposingException
derives from ObjectDisposedException — existing teardown handling keeps working
and only the classification is new. The original "don't throw" rationale was an
IDE first-chance-break annoyance at a call site that already had a Catch
downstream.

The failure is logged at Debug, not Error — a recycle race is routine, and
paging operators for normal teardown is noise — and logs message TYPE + ID
rather than LogText(delivery), which was the core #608 hot-path serialization
storm.

The deterministic test holds the window open by parking one un-answered response
callback so Quiescing cannot drain, then WAITS until RunLevel has demonstrably
reached Quiescing before subscribing: verified state, no sleeps, no racing.
Reverting only the ctor makes it fail with exactly the production signature
(errorType=Unknown, TargetInvocationException -> NRE).

The test's own comment claimed "the wait exhausted all attempts". That is wrong
and is corrected here: the loop catches only TimeoutException, so the
DeliveryFailureException escaped on the FIRST probe — six 15 s windows cannot
fit inside a 2.1-2.6 s failure.

Before/after, DOTNET_PROCESSOR_COUNT=2, one test process at a time:

  baseline   26 rounds -> 6 NREs at LayoutAreaHost..ctor
  with fix   26 rounds -> 0

What this does NOT establish: that CI's rate for this test goes to zero. A
SECOND, independent failure mode is untouched — 2/18 baseline vs 2/26 with the
fix (~10% either way): step 3 times out because the NodeType never reaches Ok
with a usable build within 90 s (the parked-Error -> auto-un-park -> recompile
path). Different signature, no NRE, and it will keep this test red at roughly
that rate until investigated separately.

Verified: Messaging.Hub 107/107, Data 287/287, Layout 338/338, Graph 636/636,
Hosting.Monolith 402/402 (2 env-gated skips); full solution
Release/-warnaserror clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the recycle/disposal window behavior by preventing construction of a “dead” SynchronizationStream (with Hub = null) and ensuring that hub-disposal races are reported as a transient DeliveryFailure (ErrorType.ShuttingDown) so callers can retry after reactivation instead of faulting permanently.

Changes:

  • Introduces HubDisposingException (transient, ObjectDisposedException-shaped) and teaches MessageService to classify handler failures that are/contain it as ErrorType.ShuttingDown.
  • Updates SynchronizationStream/JsonSynchronizationStream to refuse stream creation during hub disposal (throw) rather than fabricating a hub-less stream.
  • Adds/updates tests and documentation to pin the recycle-window subscribe repro and the failure-classification contract.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.Messaging.Hub.Test/HubDisposalFailureClassificationTest.cs New test that pins transient classification for direct and reflection-wrapped hub-disposal exceptions.
test/MeshWeaver.Layout.Test/SubscribeDuringRecycleTest.cs New end-to-end deterministic repro asserting recycle-window subscribe yields transient NACK, not NRE/terminal failure.
test/MeshWeaver.Hosting.Monolith.Test/OverlaySelfHealInstanceRecycleTest.cs Clarifies the retry loop semantics and why terminal DeliveryFailureException must fail loudly.
test/MeshWeaver.Data.Test/DeadStreamSafetyTest.cs Updates tests to match the new “refuse on disposing host” contract while keeping disposed-stream no-op coverage.
src/MeshWeaver.Messaging.Hub/MessageService.cs Classifies HubDisposingException (including wrapped) as transient ErrorType.ShuttingDown and logs at Debug.
src/MeshWeaver.Messaging.Contract/HubDisposingException.cs Adds the typed transient exception plus an inner-exception classifier helper.
src/MeshWeaver.Documentation/Data/Architecture/HubDisposalModel.md Documents the “creation window” and the refuse/classify contract with pointers to the new tests.
src/MeshWeaver.Data/Serialization/SynchronizationStream.cs Refuses to construct a stream when hosted-hub creation is frozen; removes “dead stream with null Hub” behavior.
src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs Switches external-client creation failure from generic ObjectDisposedException to HubDisposingException.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +63 to +76
private static bool IsHubDisposal(Exception? exception, int depth)
{
if (depth > 16)
return false;
for (var e = exception; e is not null; e = e.InnerException)
{
if (e is HubDisposingException)
return true;
if (e is AggregateException agg
&& agg.InnerExceptions.Any(inner => IsHubDisposal(inner, depth + 1)))
return true;
}
return false;
}
Comment on lines +131 to +132
for (var i = 0; i < 200 && hub.RunLevel < MessageHubRunLevel.Quiescing; i++)
await Task.Delay(10);
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   12 files  ±0     12 suites  ±0   4m 35s ⏱️ +40s
  974 tests +3    970 ✅ +4  4 💤 ±0  0 ❌  - 1 
1 037 runs  +3  1 033 ✅ +4  4 💤 ±0  0 ❌  - 1 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

959 tests  ±0   953 ✅ ±0   5m 6s ⏱️ +2s
  5 suites ±0     6 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 129 tests  ±0   1 026 ✅ ±0   5m 29s ⏱️ +39s
   10 suites ±0     103 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 986 tests  ±0   1 798 ✅ ±0   5m 14s ⏱️ +8s
   12 suites ±0     188 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 183 tests  +1   1 182 ✅ +1   7m 11s ⏱️ -2s
   10 suites ±0       1 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 139 tests  ±0   1 138 ✅ ±0   6m 21s ⏱️ -4s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

This pull request removes 5 and adds 5 tests. Note that renamed tests count towards both.
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Ctor_OnDisposingHost_DoesNotThrow_AndProducesDeadStream
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Dispose_OnDeadStream_DoesNotThrow
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ OnNext_OnDeadStream_DoesNotThrow_AndCompletesSubscribers
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ RegisterForDisposal_OnDeadStream_DisposesImmediately
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Update_OnDeadStream_SignalsDisposedToProducer
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Ctor_OnDisposingHost_Refuses_WithATransientHubDisposalError
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Dispose_IsIdempotent
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ DisposedStream_CompletesSubscribersAndDropsWrites
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ RegisterForDisposal_OnDisposedStream_DisposesImmediately
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Update_OnDisposedStream_SignalsDisposedToProducer

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   61 files  ±0     61 suites  ±0   33m 58s ⏱️ + 1m 23s
7 370 tests +4  7 067 ✅ +5  303 💤 ±0  0 ❌  - 1 
7 433 runs  +4  7 130 ✅ +5  303 💤 ±0  0 ❌  - 1 

Results for commit 54a49d6. ± Comparison against base commit 95f6ecc.

This pull request removes 5 and adds 9 tests. Note that renamed tests count towards both.
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Ctor_OnDisposingHost_DoesNotThrow_AndProducesDeadStream
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Dispose_OnDeadStream_DoesNotThrow
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ OnNext_OnDeadStream_DoesNotThrow_AndCompletesSubscribers
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ RegisterForDisposal_OnDeadStream_DisposesImmediately
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Update_OnDeadStream_SignalsDisposedToProducer
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Ctor_OnDisposingHost_Refuses_WithATransientHubDisposalError
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Dispose_IsIdempotent
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ DisposedStream_CompletesSubscribersAndDropsWrites
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ RegisterForDisposal_OnDisposedStream_DisposesImmediately
MeshWeaver.Data.Test.DeadStreamSafetyTest ‑ Update_OnDisposedStream_SignalsDisposedToProducer
MeshWeaver.Layout.Test.SubscribeDuringRecycleTest ‑ SubscribeRacingDisposal_IsRejectedAsTransient_NotWithANullReference
MeshWeaver.Messaging.Hub.Test.HubDisposalFailureClassificationTest ‑ AnOrdinaryHandlerBug_StaysTerminal
MeshWeaver.Messaging.Hub.Test.HubDisposalFailureClassificationTest ‑ HubDisposalDuringHandling_IsReportedAsTransient
MeshWeaver.Messaging.Hub.Test.HubDisposalFailureClassificationTest ‑ WrappedHubDisposal_IsStillReportedAsTransient

@rbuergi
rbuergi merged commit 7e2e1a4 into main Jul 27, 2026
16 checks passed
@rbuergi
rbuergi deleted the fix/layoutareahost-dead-stream branch July 28, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants