Skip to content

fix(#574): stop fanning StopAllAsync out over disposed daemons at shutdown (marten#5055) - #575

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/marten-5055-disposed-cts-stopall
Jul 27, 2026
Merged

fix(#574): stop fanning StopAllAsync out over disposed daemons at shutdown (marten#5055)#575
jeremydmiller merged 1 commit into
mainfrom
fix/marten-5055-disposed-cts-stopall

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #574. JasperFx.Events side of JasperFx/marten#5055.

Problem

At Kubernetes pod shutdown, a second Pause/Stop pass (double AddAsyncDaemon hosted-service registration, user pause + host stop, Wolverine quiesce + host stop) fans StopAllAsync() out over daemons the first pass already disposed. StopAllAsync opened with await _semaphore.WaitAsync(_cancellation.Token), and reading .Token off the disposed CancellationTokenSource threw ObjectDisposedException — which ProjectionCoordinatorBase.PauseAsync logged as Error while trying to stop daemon agents, once per daemon, on every shutdown.

There was also a latent second bug: after StopAsync, the subclass daemon caches still handed back the disposed daemons to ResumeAsync and the daemon accessors.

Changes

  • JasperFxAsyncDaemon: Dispose() is now idempotent and sets a volatile bool _disposed; StopAllAsync() early-returns when disposed, and additionally catches ObjectDisposedException around the semaphore entry to cover a Dispose() racing an in-flight stop. A disposed daemon has nothing left to stop, so a silent no-op is the correct behavior.
  • ProjectionCoordinatorBase.PauseAsync: an ObjectDisposedException from a daemon stop now logs at Debug ("already disposed... benign during shutdown") instead of Error — same philosophy as the ProjectionCoordinatorBase.executeAsync: terminate on cancellation / disposed data source instead of re-polling (shutdown drain race) #499/#499: terminate coordinator loop on disposed data source / wrapped cancellation #500 disposed-data-source handling in executeAsync.
  • ProjectionCoordinatorBase.StopAsync: after disposing the resolved daemons, calls a new protected abstract void ClearResolvedDaemons() seam so the subclass cache is purged. A second StopAsync then has nothing to fan out over, and a later ResumeAsync/daemon accessor resolves fresh daemons instead of dead instances.

⚠️ Downstream breaking change

ClearResolvedDaemons() is abstract, so ProjectionCoordinatorBase subclasses must add it when they bump:

  • Marten: ProjectionCoordinator (and ExplicitProjectionCoordinator) should clear the _daemons ImHashMap. The Marten follow-up (cache clearing + AddAsyncDaemon idempotence) is tracked separately on marten#5055.
  • Polecat: its ProjectionCoordinator should clear its daemon dictionary the same way.

Tests

New coverage in EventTests/Daemon:

  • DisposedDaemonStopAllTests — real JasperFxAsyncDaemon: StopAllAsync after Dispose() (and after a stop→dispose cycle) no-ops instead of throwing; Dispose() is idempotent.
  • ProjectionCoordinatorBaseTestsStopAsync purges the resolved-daemon cache; a second StopAsync neither throws nor logs an Error (verified against a fake daemon that reproduces the pre-fix ObjectDisposedException); pausing over an already-disposed daemon logs Debug, not Error.

./build.sh test-events and ./build.sh test-event-store both pass locally.

🤖 Generated with Claude Code

…tdown (marten#5055)

At pod shutdown a second Pause/Stop pass (double AddAsyncDaemon
hosted-service registration, user pause + host stop, Wolverine quiesce +
host stop) hit StopAllAsync on daemons the first pass had already
disposed. _semaphore.WaitAsync(_cancellation.Token) then threw
ObjectDisposedException off the disposed CancellationTokenSource, and
ProjectionCoordinatorBase.PauseAsync logged one "Error while trying to
stop daemon agents" per daemon on every shutdown.

- JasperFxAsyncDaemon: idempotent Dispose() sets a volatile _disposed
  flag; StopAllAsync no-ops when disposed, including the window where
  Dispose races an in-flight stop between the flag check and the token
  access.
- ProjectionCoordinatorBase.PauseAsync: ObjectDisposedException from a
  daemon stop now lands at Debug, not Error — nothing is left to stop.
- ProjectionCoordinatorBase.StopAsync: new ClearResolvedDaemons() seam
  purges the subclass daemon cache after disposal, so a second StopAsync
  has nothing to fan out over and a later ResumeAsync/daemon accessor
  resolves fresh daemons instead of handing back dead instances.

Marten and Polecat must implement ClearResolvedDaemons() when they bump.

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.

ObjectDisposedException from StopAllAsync when a second Pause/Stop fans out over disposed daemons (marten#5055)

1 participant