Skip to content

DataContext's init watchdog is a subscription, not a Task-shaped timer race (#2528) - #2606

Merged
rbuergi merged 1 commit into
mainfrom
fix/2488-reactive-disposal-waits
Aug 28, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/2488-reactive-disposal-waits

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closes #2528.

What

DataContext.OpenInitializationGate's watchdog was the banned timer-race shape: Task.WhenAny(allInit, Task.Delay(InitializationTimeout, cts.Token)).ContinueWith(...) plus a CancellationTokenSource disarm. The wait is now a subscription — Observable.Amb over three one-shot arms, Take(1), ObserveOn(TaskPoolScheduler.Default) (the same off-thread hop the old ContinueWith(TaskScheduler.Default) gave the body), Subscribe with a fluent error arm:

  1. init settled — allInit.ToObservable().Materialize() (Task→IObservable is the sanctioned bridge direction; Materialize so a FAULTED/CANCELED init still runs the settle body instead of skipping to an error arm);
  2. the time-box — Observable.Timer(InitializationTimeout). The bound stays (deleting it re-opens the 2026-06-26 path-resolution-storm wedge); its expiry fails fast and LOUD — fail-level log, InitializationError = TimeoutException, rejection handler — never "proceed as if initialized";
  3. the disarm — an AsyncSubject<Unit> fired by Dispose(). Disarm means fire now, not unsubscribe (an Amb arm, never a TakeUntil): A gate that can never reopen, and an unbounded nested create, can strand a request forever #1270's FailGate answer still runs, Per-node hub DataContext initialization times out at 120s, taking every hub under a user partition dark (root of #3517, #2886, #1313) #1122's no-post-mortem-residue holds, and because the subject replays its terminal, a Dispose that lands before the gate arms now settles immediately (the old CTS shape armed a fresh, never-cancelled timer in that ordering).

The settle body is unchanged — the same four-way shutdown / timed-out / faulted / canceled / clean discrimination, read off allInit's actual state, ending in FailGate (shutdown) or the unconditional OpenGate.

Root cause found on the way: a FAULTED init was indistinguishable from a HUNG one

Writing #2528's requested faulted-init test exposed a pre-existing defect (verified red against main's DataContext too): a data source whose initial load throws never faulted IDataSource.Initialized. The fault stopped in the stream hub's HandleInitialize catch — hub FAILED state, but SynchronizationGate stayed shut, RunLevel never reached Started, Hub.Started never settled — so the owning DataContext saw nothing until its 120 s time-box expired, and then reported a TimeoutException instead of the real error.

Fix at the root: the Task-init arm of SynchronizationStream.Initialize routes its fault through the stream's own OnError — the path every other stream failure already takes — which classifies (teardown stays quiet), faults the store, calls Hub.FailStartup (so Initialized settles NOW with the actual exception) and opens SynchronizationGate. The error still propagates to the buildup's catch for the hub-level FAILED state. Consumed only by the data-source CreateStream path (GenericUnpartitionedDataSource:621), so the blast radius is exactly the path being fixed.

Tests (the four terminal outcomes, per the issue)

Verification

-c Release -warnaserror clean for MeshWeaver.Data, MeshWeaver.Graph and the three test projects. Full suites green locally: MeshWeaver.Data.Test 393/393, MeshWeaver.Messaging.Hub.Test 305/305, MeshWeaver.Persistence.Test 1023/1023.

What's New: Category: Fix entry included.

🤖 Generated with Claude Code

…r race (#2528)

Task.WhenAny(allInit, Task.Delay(timeout, cts.Token)) + ContinueWith becomes
Observable.Amb over three one-shot arms — init settled (Task->IObservable,
Materialize'd so a faulted/canceled init still runs the settle body), the
time-box (Observable.Timer; the bound STAYS — its expiry fails fast and loud,
never 'proceed as if initialized'), and a disarm AsyncSubject fired by
Dispose() (fire-now semantics, #1270; no post-mortem FAILED residue, #1122;
replays its terminal, so Dispose-before-arm settles immediately instead of
arming a never-cancelled timer).

Root-caused along the way: a FAULTED initial load was indistinguishable from a
HUNG one — the fault stopped in the stream hub's HandleInitialize catch,
SynchronizationGate stayed shut, Hub.Started never settled, and the DataContext
learned of it only when the 120s time-box expired, as a TimeoutException. The
Task-init arm of SynchronizationStream.Initialize now routes its fault through
the stream's own OnError (FailStartup + gate-open), so Initialized settles NOW
with the real exception and the faulted branch answers in milliseconds.

Tests: DataContextInitTimeoutTest (hung init trips the time-box, loud, fast,
gate answered), DataContextInitFaultedTest (faulted init reaches the terminal
FAILED state fast, carrying the specific error; red on main at 15s timeout).
Dispose-mid-init (#1122) and shutdown-gate-answers-deferred (#1270) stay green.

Closes #2528

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 12:04

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

Refactors DataContext’s initialization watchdog to be fully reactive (subscription-based) rather than a Task.WhenAny / Task.Delay timer race, while preserving the existing time-box and shutdown/disarm behavior. It also fixes a related startup-fault propagation gap so faulted data-source initialization is surfaced immediately (instead of being misreported as a later timeout), and pins the intended terminal outcomes with new tests.

Changes:

  • Replaced the Task-shaped watchdog with an Observable.Amb-based subscription that races init-settled vs time-box vs Dispose-disarm and always settles the initialization gate.
  • Ensured a faulted Task-based stream initialization faults the stream (via SynchronizationStream.OnError) so IDataSource.Initialized settles with the real exception.
  • Added tests covering hung-init timeout and faulted-init fast failure, plus a What’s New entry documenting the change.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
test/MeshWeaver.Data.Test/DataContextInitWatchdogTest.cs Adds regression tests pinning watchdog outcomes for hung and faulted initialization.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-28-reactive-init-watchdog.md Documents the user-visible behavior and rationale for the reactive watchdog.
src/MeshWeaver.Data/Serialization/SynchronizationStream.cs Routes Task-init faults through SynchronizationStream.OnError so startup settles with the real exception.
src/MeshWeaver.Data/DataContext.cs Rewrites the initialization watchdog to a reactive Amb subscription with a Dispose-fired disarm signal and extracted settle logic.

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

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

474 tests  ±0   470 ✅ ±0   5m 1s ⏱️ +3s
  6 suites ±0     4 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

896 tests  ±0   896 ✅ ±0   5m 15s ⏱️ -12s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 573 tests  ±0   1 572 ✅ ±0   7m 20s ⏱️ + 1m 18s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

985 tests  ±0   985 ✅ ±0   6m 25s ⏱️ -2s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

946 tests  ±0   946 ✅ ±0   5m 1s ⏱️ -16s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ±0     10 suites  ±0   5m 21s ⏱️ -37s
3 746 tests +2  3 554 ✅ +2  192 💤 ±0  0 ❌ ±0 
4 365 runs  +3  4 173 ✅ +3  192 💤 ±0  0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   42 files  ±0     42 suites  ±0   34m 25s ⏱️ +14s
8 620 tests +2  8 423 ✅ +2  197 💤 ±0  0 ❌ ±0 
9 239 runs  +3  9 042 ✅ +3  197 💤 ±0  0 ❌ ±0 

Results for commit 5204e0f. ± Comparison against base commit 8a081da.

@rbuergi
rbuergi merged commit c81b272 into main Aug 28, 2026
24 checks passed
@rbuergi
rbuergi deleted the fix/2488-reactive-disposal-waits branch September 13, 2026 10:36
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