Skip to content

A hub-owned watcher stops at the first instant of teardown, not in ShutDown (#3026) - #3072

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3026-sources-watcher-observes-teardown
Sep 2, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3026-sources-watcher-observes-teardown

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3026.

What was wrong

A per-NodeType hub's watchers — sources / IsDirty, compile, release-request, adopted-stamp — are installed through SubscribeWithReEstablish and handed to hub.RegisterForDisposal. That disposes them in the hub's ShutDown phase, the LAST one: after Quiescing has waited up to its whole 2 s budget for pending callbacks and after the hosted subtree has been joined. And a hub is part of a shutdown even earlier than its own Dispose(): an ancestor's Dispose() freezes the subtree synchronously (CloseCreation, #613) and reaches the hub only in the ancestor's DisposeHostedHubs phase.

Through that whole window the sources watcher kept working. Resolving the @@-include closure issues cross-hub GetDataRequests from the hub that is tearing down — those are exactly the pending callbacks its Quiescing then waited 2 s for, and CancelCallbacks finally errored them with ObjectDisposedException: Hub FutuRe/LocalAnalysis was disposed before the response arrived (GetDataRequest, target FutuRe/GroupAnalysis/Source/ExternalDependencies). That is the [FAULT] the issue quotes, 2 052 ms after DISPOSE_INVOKED, 22 times in one shard — one per fixture teardown, each exactly one quiesce budget late. A re-establish of the same watcher evaluates its source factory on a bare Observable.Timer tick; a factory that throws synchronously there (a hub whose scope is gone) is an unhandled exception on a scheduler thread — the exit-139 host kill.

What changed

  • IMessageHub.ShuttingDown — IsShuttingDown as a source: fires once at the FIRST instant the hub is part of a shutdown (own Dispose() or the ancestor cascade in CloseHostedHubCreation), then completes; replays to a late subscriber.
  • ActivityControlPlaneExtensions.SubscribeHubWatcher(hub, …) — the hub-owned form of SubscribeWithReEstablish (a distinct name, not an overload: MeshWeaver.Plugins carries a parameterless cref to the generic method, which a second overload would make ambiguous under its warnings-as-errors). It (1) stops at ShuttingDown — live subscription and pending re-establish — so an in-flight cross-hub read is unsubscribed (its pending callback leaves the response registry) and Quiescing drains at once; (2) gates every delivery on hub.IsShuttingDown; (3) is inert on a hub already shutting down; (4) evaluates the source factory under Observable.Defer, so a synchronous factory throw on a re-establish is a stream fault the classifier owns, not a throw on the timer thread. All seven hub-owned watchers now use it (compile, release-request, sources, adopted-stamp, control-plane, submission, build-claim arbiter).
  • Docs: HubDisposalModel.md (new section "The first instant of teardown: ShuttingDown", the rule for hub-owned watchers), ActivityControlPlane.md. What's New entry (Category: Fix).

No catch anywhere on the path, no re-run: the fix is the lifetime and the gate, as the issue directs.

Deliberately left out — the fixture verdict's blind spot

The issue also asks that the fixture's teardown clean verdict not read clean while a hosted hub timed out. The cause is real: a hosted hub removes itself from its parent's registry in its own ShutDown phase, so AnyHubQuiescingTimedOut() walks an empty registry by the time the test base asks. I implemented the fix (the collection keeps each departing child's verdict) and ran MeshWeaver.Graph.Test with it: it immediately reddened two unrelated classes on pre-existing hosted-hub timeouts — DanglingNodeTypeUpdateTest (a cache/… hub with a SubscribeRequest@TestData/dnt… pending 2002 ms: routed, "no handler was ever entered" — a genuine leaked subscribe to an error-overlay node) and EventContinuationHandlerTest (a cache/… hub whose callback drained exactly at the 2 s hand-off — the detector racing its own budget). Every other suite in the fleet would need the same sweep before that verdict can land, so it is split out rather than holding this crash fix hostage; the blind spot is documented in HubDisposalModel.md under the Quiescing phase.

Tests (each fails before the fix)

  • ShuttingDownSignalTest (Messaging.Hub.Test): the signal fires synchronously inside Dispose() on the whole subtree while the root's action block is deliberately parked (so no disposal phase can have run); a child's IsDisposing is still false and its RunLevel still Started; a late subscriber is told at once.
  • HubWatcherStopsAtTeardownStartTest (Hosting.Test): own dispose and ancestor dispose stop the watcher before any phase runs (source.HasObservers false, nothing delivered); installing on a shutting-down hub is inert; a factory throw on a re-establish is classified, not thrown.
  • SourcesWatcherStopsAtTeardownTest (Graph.Test): a real mesh, a NodeType whose one source has an @@ include pointing at an instance of a NodeType whose hub never opens its init gate — so the watcher's include read is deterministically in flight — then the NodeType hub is disposed: QuiescingTimedOut is false and no "closure could not be established" warning is logged.

Verification

  • dotnet build … -c Release -warnaserror: MeshWeaver.Messaging.Hub.Test, MeshWeaver.Hosting.Test, MeshWeaver.Graph.Test (closure covers Messaging.Hub, Mesh.Contract, Compiler.Pipeline, Graph, Hosting) — 0 warnings, 0 errors.
  • Whole-project runs (Release, DOTNET_PROCESSOR_COUNT=4, .trx): Messaging.Hub.Test 343/343, Hosting.Test 294/294, Graph.Test 1157/1159 — the 2 reds were the departed-verdict exposure described above and disappeared with its removal; the new integration test passed.
  • MeshWeaver.FutuRe.Test (the observation site, in MeshWeaver.Plugins) was NOT run locally — stopped on the maintainer's quota directive before the Plugins build; CI on that repo is the verification.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G4xPnGYEbdu8AVvR5jytyj

…utDown (#3026)

The sources / compile / release / adopted-stamp watchers of a per-NodeType hub were
disposed by RegisterForDisposal in the hub's LAST phase, after Quiescing had waited
its whole 2 s budget for the very @@-include GetDataRequests the sources watcher kept
issuing from the tearing-down hub — the 22 caught
`SourceIncludeUnavailableException ---> ObjectDisposedException` faults per
FutuRe.Test shard, one per fixture teardown, each exactly one quiesce budget after
DISPOSE_INVOKED. A re-establish evaluated the source factory on a bare timer tick,
so a factory throwing on a disposed hub was an unhandled exception on a scheduler
thread (exit 139).

- IMessageHub.ShuttingDown: IsShuttingDown as a replaying source, raised at own
  Dispose() and at the ancestor's CloseCreation cascade.
- ActivityControlPlaneExtensions.SubscribeHubWatcher(hub, …): stops at
  ShuttingDown (releasing in-flight cross-hub reads so Quiescing drains at once),
  gates every delivery on IsShuttingDown, is inert on a shutting-down hub, and
  evaluates the factory under Observable.Defer. All hub-owned watchers use it.
- Docs (HubDisposalModel, ActivityControlPlane), What's New (Fix), three tests
  that fail before the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4xPnGYEbdu8AVvR5jytyj
Copilot AI lite review requested due to automatic review settings September 2, 2026 07:51
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 2, 2026 07:52
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

234 tests  ±0   234 ✅ ±0   2m 16s ⏱️ +12s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

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.

🟡 Changes recommended

The new Hosting test uses an invalid C# call pattern (named arg followed by positional arg) that will not compile, and the new teardown gating relies on a non-thread-safe disposed flag that can race across scheduler threads.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses #3026 by ensuring hub-owned watchers stop at the first instant of teardown (including ancestor-triggered shutdown), preventing late cross-hub callbacks during disposal that were causing 2s quiesce timeouts and intermittent exit-139 host crashes.

Changes:

  • Introduces IMessageHub.ShuttingDown (a replaying one-shot IObservable<Unit>) and wires it into MessageHub teardown entry points.
  • Adds ActivityControlPlaneExtensions.SubscribeHubWatcher(...) to bind watcher lifetime to hub.ShuttingDown, gate deliveries on hub.IsShuttingDown, and evaluate watcher factories via Observable.Defer.
  • Updates key hub-owned watchers to use SubscribeHubWatcher, and adds tests + documentation/What’s New to pin the new contract.
File summaries
File Description
test/MeshWeaver.Messaging.Hub.Test/ShuttingDownSignalTest.cs Adds a deterministic test pinning ShuttingDown semantics across a hosted subtree.
test/MeshWeaver.Hosting.Test/HubWatcherStopsAtTeardownStartTest.cs Adds contract tests for watcher shutdown-at-teardown-start behavior and re-establish factory fault handling.
test/MeshWeaver.Graph.Test/SourcesWatcherStopsAtTeardownTest.cs Adds an integration repro ensuring the sources watcher releases an in-flight @@ include read on teardown.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Implements the ShuttingDown source and raises it from both Dispose() and hosted-hub creation freeze cascades.
src/MeshWeaver.Messaging.Hub/IMessageHub.cs Extends the hub contract with ShuttingDown and documents its intended watcher-lifetime usage.
src/MeshWeaver.Mesh.Contract/ActivityControlPlaneExtensions.cs Adds SubscribeHubWatcher and updates existing watchers to use the hub-owned form.
src/MeshWeaver.Graph/Configuration/BuildNodeType.cs Switches the build-claim arbiter watcher to SubscribeHubWatcher.
src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs Switches NodeType compile/sources/release/adopted-stamp watchers to SubscribeHubWatcher.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-a-background-compile-watcher-stops-when-its-host-stops.md Adds a user-facing “Fix” entry describing the watcher teardown behavior change.
src/MeshWeaver.Documentation/Data/Architecture/HubDisposalModel.md Documents the new “first instant of teardown” signal and watcher rule; notes the quiesce-verdict blind spot.
src/MeshWeaver.Documentation/Data/Architecture/ActivityControlPlane.md Updates guidance to reference SubscribeHubWatcher and the ShuttingDown lifetime contract.
Review details

Suppressed comments (2)

test/MeshWeaver.Hosting.Test/HubWatcherStopsAtTeardownStartTest.cs:124

  • This call mixes a named argument (logger: null) followed by a positional argument ("test watcher"), which does not compile in C# (positional arguments cannot follow named arguments). Name the final argument as well.
        using var watcher = ActivityControlPlaneExtensions.SubscribeHubWatcher<int>(
            child, () => source, _ => delivered++, logger: null, "test watcher");

test/MeshWeaver.Hosting.Test/HubWatcherStopsAtTeardownStartTest.cs:164

  • This call mixes a named argument (logger: null) followed by a positional argument ("test watcher"), which does not compile in C# (positional arguments cannot follow named arguments). Name the final argument as well.
            using var watcher = ActivityControlPlaneExtensions.SubscribeHubWatcher<int>(
                hub, () => { factoryCalls++; return source; }, _ => { }, logger: null, "test watcher");
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +81 to +82
using var watcher = ActivityControlPlaneExtensions.SubscribeHubWatcher<int>(
hub, () => source, _ => delivered++, logger: null, "test watcher");
Comment on lines +429 to +436
void Stop()
{
disposed = true;
// Drop the pending re-establish FIRST — that is the TimerQueue entry rooting
// this whole closure graph (#991); `serial` only holds the live subscription.
pendingReEstablish.Dispose();
serial.Dispose();
}
Comment on lines +1611 to +1620
try
{
shuttingDown.OnNext(Unit.Default);
shuttingDown.OnCompleted();
}
catch (Exception ex)
{
TryLog(LogLevel.Warning, ex,
"[SHUTTING-DOWN] {Address}: a ShuttingDown subscriber threw — teardown proceeds", Address);
}
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

612 tests  +10   612 ✅ +10   47s ⏱️ -4s
  2 suites ± 0     0 💤 ± 0 
  2 files   ± 0     0 ❌ ± 0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    3 files  ±0      3 suites  ±0   59s ⏱️ -2s
1 226 tests ±0  1 034 ✅ ±0  192 💤 ±0  0 ❌ ±0 
1 227 runs  ±0  1 035 ✅ ±0  192 💤 ±0  0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

430 tests  +8   430 ✅ +8   51s ⏱️ -8s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

557 tests  +8   555 ✅ +8   1m 37s ⏱️ -1s
  4 suites ±0     2 💤 ±0 
  4 files   ±0     0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 781 tests  +1   1 781 ✅ +1   1m 56s ⏱️ +7s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   14 files  ± 0     14 suites  ±0   8m 28s ⏱️ +6s
4 840 tests +27  4 646 ✅ +27  194 💤 ±0  0 ❌ ±0 
4 841 runs  +27  4 647 ✅ +27  194 💤 ±0  0 ❌ ±0 

Results for commit cb74e92. ± Comparison against base commit 0afb563.

♻️ This comment has been updated with latest results.

…eout-literal ratchet stays at 386

TestTimeoutLiteralRatchetGuard.TheHandWrittenTimeoutCountOnlyEverGoesDown counted the two new
`TimeSpan.FromSeconds(30)` bounds on the release polls (388 > baseline 386). The bound a parked
worker is released under is the same convergence budget every other wait in these suites uses, so
it is named, not guessed. Recount on the tree: 386. Both test projects rebuild clean under
-c Release -warnaserror --no-incremental.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants