Skip to content

fix(subscriptions): harden the worker dispose failure path - #563

Merged
alexeyzimarev merged 2 commits into
devfrom
fix/worker-dispose-failure-path
Aug 3, 2026
Merged

fix(subscriptions): harden the worker dispose failure path#563
alexeyzimarev merged 2 commits into
devfrom
fix/worker-dispose-failure-path

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Contributor

Follow-up to #562, which merged before these review findings were addressed. Two bot reviewers flagged the dispose gate; both were right, though one's stated mechanism turned out to be wrong.

Every caller gets the shared task

The winner awaited StopWorker() directly while the TaskCompletionSource was faulted separately. A failed shutdown with no second caller therefore left a faulted task nobody observed, which resurfaces through TaskScheduler.UnobservedTaskException as exactly the kind of spurious shutdown warning #562 set out to remove. DisposeAsync now returns _disposed.Task to everyone, so the failure is observed exactly once and there is only ever one task.

Cleanup moved into a finally

A throw from the graceful stop skipped the cancel, the reader drain and _cts.Dispose(), leaking the ten-second timer ChannelExtensions.Stop arms via CancelAfter and leaving the readers holding a live token. That gap predates #562, but the dispose gate makes it permanent, since _disposing never resets. Both cleanup awaits suppress, so a throwing cancellation callback can't skip disposal either.

Worth recording that the reviewer's premise — "channel.Stop propagates reader-task faults" — is mostly wrong. Stop filters to !r.IsCompleted, and a faulted task is completed, so a reader that has already faulted is silently skipped. I confirmed this empirically: driving the commit worker's processor into a throw and then disposing produces no exception at all. The reachable window is narrower than claimed — a reader faulting while Stop awaits it, or CancelAsync throwing — but it is not empty, and the invariant "dispose always releases the CTS" is worth holding unconditionally.

Re-check the stopping token inside the resubscribe task

Unsubscribe can cancel between the check in Dropped and the task being scheduled. This does not close the race — EventSubscriptionWithCheckpoint.Resubscribe disposes the commit handler before it looks at the token, which is why the idempotent dispose in #562 is the actual fix — but it keeps the common case out of it.

Tests

No new tests. The two ChannelWorkerBase changes affect an error path whose consequences (a released timer, an unobserved task) have no seam to assert on: reaching the throw needs a reader to fault mid-Stop, and observing the fix needs either private fields or TaskScheduler.UnobservedTaskException plus forced GC — a global, GC-timed assertion that would itself be the flaky test this work is meant to eliminate. I verified the path with a throwaway probe instead, which is what turned up the IsCompleted filtering above. The token re-check is inherently racy and equally untestable; the deterministic pre-check it backs up is already covered by Drop_after_shutdown_started_does_not_resubscribe.

Verification

  • Eventuous.Tests.Subscriptions 38/38, Eventuous.Tests 26/26, Eventuous.Tests.Application 21/21 (net10.0)
  • Eventuous.Tests.KurrentDB 60/60, including the drop/resubscribe test
  • dotnet build Eventuous.slnx clean across net8.0/net9.0/net10.0; tests ran locally on net10.0 only (net8/net9 runtimes absent on the dev machine)

🤖 Generated with Claude Code

Review follow-ups on the dispose gate.

Return the shared task to every caller, including the first. The winner
used to await StopWorker() directly while the TaskCompletionSource was
faulted separately, so a failed shutdown with no second caller left a
faulted task nobody observed -- resurfacing later through
TaskScheduler.UnobservedTaskException as exactly the kind of spurious
shutdown warning this branch removes.

Cancel the CTS, drain the readers and dispose in a finally. A throw from
the graceful stop used to skip all of it, leaking the ten-second timer
Stop arms via CancelAfter and leaving the readers holding a live token.
That gap predates the dispose gate, but the gate makes it permanent since
_disposing never resets. Both cleanup awaits suppress, so disposal can't
be skipped by a cancellation callback throwing.

Also re-check the stopping token inside the resubscribe task. Unsubscribe
can cancel between the check in Dropped and the task being scheduled. It
doesn't close the race -- Resubscribe disposes the commit handler before
it looks at the token -- but it keeps the common case out of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 3, 2026 10:43
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Harden channel worker dispose: always share shutdown task and guarantee CTS cleanup

🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Return a single shared dispose task to all callers, ensuring shutdown failures are observed.
• Move cancellation/drain/CTS disposal into a finally to prevent token/timer leaks on stop failures.
• Re-check the stopping token inside the resubscribe background task to avoid post-unsubscribe
 races.
Diagram

graph TD
  A["DisposeAsync caller"] --> B["Shared _disposed.Task"]
  A --> C["StopWorker"] --> D["channel.Stop"] --> E["finally: cancel + drain + CTS dispose"] --> B
  F["Dropped()"] --> G{"Stopping cancelled?"} --> H["Resubscribe(delay)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Store a single lazy Task field instead of TaskCompletionSource
  • ➕ Avoids manual TaskCompletionSource signaling and reduces risk of double-completion logic
  • ➕ Naturally propagates exceptions to all awaiters if the same Task instance is returned
  • ➖ Still requires careful one-time initialization to avoid multiple StopWorker invocations
  • ➖ Harder to represent “completion independent of StopWorker body” if future logic needs separate signaling
2. Serialize dispose with an async lock (SemaphoreSlim)
  • ➕ Makes disposal sequencing explicit and easier to reason about under concurrent Dispose/Resubscribe calls
  • ➖ Adds contention/overhead to a hot lifecycle path
  • ➖ Doesn’t automatically solve unobserved-task issues unless the same Task is still shared to all callers

Recommendation: Current approach is appropriate: using a shared completion Task (_disposed.Task) guarantees exactly-one observed completion/failure across concurrent callers, while the finally block enforces the critical invariant that CTS cancellation/drain/disposal always happens even when graceful stop fails. The alternatives marginally simplify implementation but add complexity (locking) or shift the same correctness burden (lazy Task initialization) without clear net benefit.

Files changed (2) +28 / -19

Bug fix (2) +28 / -19
ChannelWorkerBase.csAlways return shared dispose task; guarantee CTS cleanup via finally +23/-19

Always return shared dispose task; guarantee CTS cleanup via finally

• DisposeAsync now always returns the shared _disposed.Task, even for the first caller, preventing shutdown failures from becoming unobserved task exceptions. StopWorker wraps graceful channel stopping in a try/finally so CTS cancellation, reader draining, and CTS disposal always occur, with NoThrow used to prevent cleanup from being skipped by cancellation callback failures.

src/Core/src/Eventuous.Subscriptions/Channels/ChannelWorkerBase.cs

EventSubscription.csRe-check stopping token inside resubscribe Task.Run +5/-0

Re-check stopping token inside resubscribe Task.Run

• Adds a second stopping-token cancellation check inside the background resubscribe task to avoid scheduling resubscribe work after Unsubscribe cancels between the initial check and task execution. This reduces exposure to a common race without changing the underlying resubscribe/dispose ordering.

src/Core/src/Eventuous.Subscriptions/EventSubscription.cs

Copilot AI left a comment

Copy link
Copy Markdown

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 shutdown/dispose failure path in subscription infrastructure to prevent unobserved task exceptions during teardown and to ensure worker cancellation/cleanup always runs, even when the graceful stop path throws.

Changes:

  • Make ChannelWorkerBase.DisposeAsync() consistently return the shared _disposed.Task so shutdown failures are always observed by awaiters.
  • Move worker cleanup (cancel, reader drain, CTS dispose) into a finally block to avoid leaking timers/tokens when ChannelExtensions.Stop throws.
  • Add a second Stopping token cancellation check inside the resubscribe background task to reduce a race with Unsubscribe.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Core/src/Eventuous.Subscriptions/EventSubscription.cs Adds a second cancellation check inside the resubscribe task to reduce a scheduling race with shutdown.
src/Core/src/Eventuous.Subscriptions/Channels/ChannelWorkerBase.cs Refactors disposal to always return a shared completion task and ensures CTS/reader cleanup runs in finally.

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

Comment on lines +51 to +55
// Release the readers even when the graceful stop above failed: they hold _cts.Token,
// and Stop armed a ten-second timer on it, so both outlive the worker unless cancelled
// here. Cancelling runs their callbacks, which is why this can't be allowed to throw.
await _cts.CancelAsync().NoThrow();
await Task.WhenAll(_readerTasks).NoThrow();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

CancellationTokenSource.CancelAsync() returns Task, not ValueTask — it was added in .NET 8 as public Task CancelAsync(). Verified by reflection on the runtime this builds against:

CancelAsync returns: System.Threading.Tasks.Task
Is Task: True  Is ValueTask: False

So NoThrow(this Task) binds without a conversion, and there's nothing to overload.

The compile claim is also refuted by this PR's own checks: Build and test core (8.0), (9.0) and (10.0) are all green on the reviewed commit, and dotnet build Eventuous.slnx is clean locally across all three target frameworks. No change made.

Comment thread src/Core/src/Eventuous.Subscriptions/Channels/ChannelWorkerBase.cs Fixed
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Test Results

 46 files  + 24   46 suites  +24   12m 54s ⏱️ -50s
373 tests +  4  373 ✅ +  4  0 💤 ±0  0 ❌ ±0 
700 runs  +320  700 ✅ +320  0 💤 ±0  0 ❌ ±0 

Results for commit 6b410b6. ± Comparison against base commit 90257a1.

This pull request removes 5 and adds 9 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 10:27:59 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 10:27:59)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(8f11c2a3-70e5-49b1-a8f1-35940abe2512)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T10:29:49.5519320+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T10:29:49.5519320+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T10:29:49.5519320+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 11:42:16 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 11:42:16)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(35ff07b6-0ac2-4d65-a763-3dcad96cefab)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:38:58.5077626+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T11:38:58.5077626+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:38:58.5077626+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:04.3172720+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T11:39:04.3172720+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:04.3172720+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:10.7046348+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T11:39:10.7046348+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T11:39:10.7046348+00:00 })

♻️ This comment has been updated with latest results.

The catch transfers the outcome onto the shared completion, which is the
only thing that releases waiters. Narrowing it would strand every caller
of DisposeAsync, so the breadth is deliberate rather than sloppy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 3, 2026 11:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

@alexeyzimarev
alexeyzimarev merged commit 26993d2 into dev Aug 3, 2026
17 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/worker-dispose-failure-path branch August 3, 2026 11:46
alexeyzimarev added a commit that referenced this pull request Aug 3, 2026
Conflict in ChannelWorkerBase: kept the idempotent dispose gate from dev
(#562/#563) as-is, on top of the cleanup's collection-expression reader
initialisation. EventSubscription merged clean to dev's version, and the
checkpoint subscription keeps the cleanup's NoContext on the commit
handler dispose.

Co-Authored-By: Claude Fable 5 <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

Development

Successfully merging this pull request may close these issues.

2 participants