Skip to content

fix(subscriptions): keep factory-registered handlers separate - #577

Merged
alexeyzimarev merged 3 commits into
devfrom
fix/576-handler-registration-collapse
Aug 21, 2026
Merged

fix(subscriptions): keep factory-registered handlers separate#577
alexeyzimarev merged 3 commits into
devfrom
fix/576-handler-registration-collapse

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Contributor

Closes #576.

The bug

AddEventHandler(Func<IServiceProvider, THandler>) registered the handler as a keyed singleton under (THandler, SubscriptionId) and resolved it back by type. Add several handlers to one subscription through this overload with THandler inferring to the same type each time — which happens naturally when the factories live in a collection typed Func<IServiceProvider, IEventHandler> — and every registration after the first silently no-ops on the taken key. The subscription then runs N copies of the first handler and none of the others, with nothing thrown and nothing logged.

The two AddCompositionEventHandler overloads that build the inner handler from a factory had the identical collapse.

The fix

Factory-created handlers are memoised in the registration closure instead of the container, so each registration keeps its own handler. This matches the instance overload, which never touched the container.

Handler disposal. With the container out of the picture, nothing would dispose a factory-created handler, so the subscription now owns them: ConsumePipe disposes them when the subscription is disposed, after the filters have drained the messages in flight, in reverse creation order, preferring IAsyncDisposable. Ownership is narrow:

Registration Created by Disposed by
AddEventHandler<T>() container container, as before
AddEventHandler(handler) caller caller
AddEventHandler(sp => …) us subscription
AddCompositionEventHandler(getWrappingHandler) inner: container, wrapper: us container / nobody — a wrapper decorates, it holds nothing
AddCompositionEventHandler(getInnerHandler, …) inner: us, wrapper: us subscription (inner only)

ConsumePipe.DisposeAsync also became idempotent — it wasn't, and EventSubscription compensated with an Interlocked.Exchange. Disposing a caller's handler twice is worse than disposing a filter twice.

Breaking change

Adding the same handler type twice to one subscription used to dispatch every event to the same instance twice, just as silently. Both type-based overloads now claim the container slot up front and throw ArgumentException on the second claim. Use the factory or instance overload when a subscription genuinely needs two handlers of the same type.

Factory-registered handlers are also no longer resolvable via GetRequiredKeyedService<THandler>(subscriptionId). That was never documented, and the instance overload never had it.

Tests

HandlerRegistrationTests and HandlerDisposalTests, all watched red first:

  • three factories sharing an inferred IEventHandler ran as [First, First, First]; now [First, Second, Third] — asserted on the resolved set by sending a message through the subscription's pipe, not on the registration list
  • same for both composition overloads
  • duplicate type registration, direct and via composition, now throws
  • factory handlers disposed on subscription dispose, sync and async, async preferred when both are implemented, once on repeat dispose
  • container-owned and caller-supplied handlers not disposed by the subscription
  • filters disposed before handlers

CompositionHandlerTests.ShouldResolveCompositionHandlerWithFactory asserted the inner handler was resolvable from the container, which this removes. It now captures the handler the factory built and asserts its injected dependency — same intent, no container round-trip.

Verified on net10.0: Eventuous.Tests.Subscriptions 130/130, Eventuous.Tests 29/29, Eventuous.Tests.Application 21/21. dotnet build Eventuous.slnx clean — 0 errors, and the 70 warnings match the pre-change baseline.

Docs PR to follow in Eventuous/eventuous-docs.

🤖 Generated with Claude Code

`AddEventHandler(Func<IServiceProvider, THandler>)` registered the handler as
a keyed singleton under `(THandler, SubscriptionId)` and resolved it back by
type. When several handlers were added to one subscription through this
overload and `THandler` inferred to the same type for each call - which
happens naturally when the factories are held in a collection typed
`Func<IServiceProvider, IEventHandler>` - every registration after the first
silently no-opped on the taken key, so the subscription ran N copies of the
first handler and none of the others. Nothing threw and nothing was logged.

Factory-created handlers are now memoised in the registration closure instead
of the container, so each registration keeps its own handler. The same
applies to the `AddCompositionEventHandler` overloads that build the inner
handler from a factory.

Since the container no longer holds them, the subscription now owns
factory-created handlers and disposes them with the consume pipe, after the
filters have drained the messages in flight. Handlers resolved from the
container or supplied by the caller are left to their owners, and wrapping
handlers are decorators, so they aren't disposed either.

Adding the same handler type twice to one subscription used to dispatch every
event to the same instance twice, just as silently. Both type-based overloads
now claim the container slot up front and throw on the second claim.

Closes #576

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix factory handler registration collapse in subscriptions

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Memoize factory-registered handlers per registration to avoid keyed DI collisions.
• Make subscriptions own/dispose builder-created handlers via ConsumePipe with idempotent disposal.
• Add tests for handler separation, duplicate type registration errors, and disposal ordering.
Diagram

graph TD
  A["Subscription setup"] --> B["SubscriptionBuilder"] --> C[("DI container")]
  B --> D["Factory handlers"] --> E["ConsumePipe"]
  B --> F["EventSubscription"] --> E
  E --> G["Dispose owned"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep DI registration but use unique keys per registration
  • ➕ Preserves ability to resolve factory-created handlers from the container
  • ➕ Leverages container disposal semantics automatically
  • ➖ Requires key generation and tracking (index/Guid) and likely new APIs to retrieve handlers
  • ➖ Still couples subscription composition details to DI registration surface area
  • ➖ More complex and easier to misuse/extend incorrectly
2. Register factories in DI and resolve an IEnumerable at build time
  • ➕ Uses standard DI patterns for multiple registrations of the same service type
  • ➕ Avoids keyed-service collision pitfalls
  • ➖ Would likely require new service abstractions (e.g., ISubscriptionHandlerFactory) and a breaking API shift
  • ➖ Still needs explicit ownership/disposal rules for created instances
  • ➖ Harder to support current keyed-per-subscription model cleanly

Recommendation: The PR’s approach (per-registration memoization + explicit subscription ownership/disposal) is the simplest way to prevent factory registrations from collapsing while keeping existing DI behavior for type-based handlers. The added duplicate-type guard makes the prior silent behavior fail fast, reducing debugging cost, and the explicit disposal policy is well-covered by tests.

Files changed (5) +467 / -15

Bug fix (2) +89 / -9
ConsumePipe.csTrack and dispose builder-owned components; make DisposeAsync idempotent +29/-0

Track and dispose builder-owned components; make DisposeAsync idempotent

• Adds an internal ownership list to ConsumePipe for components (notably factory-created handlers) that must be disposed with the subscription. DisposeAsync is made idempotent and now disposes filters first, then owned components in reverse creation order, preferring IAsyncDisposable over IDisposable.

src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs

SubscriptionBuilder.csMemoize factory handlers per registration and enforce per-subscription handler type uniqueness +60/-9

Memoize factory handlers per registration and enforce per-subscription handler type uniqueness

• Replaces keyed DI registration for factory-based handlers with per-registration memoization in the resolve closure to prevent handler collapse when THandler infers to a common type. Tracks builder-created disposable handlers and transfers them to the pipe for disposal on subscription teardown, and throws on duplicate type-based handler registrations within the same subscription.

src/Core/src/Eventuous.Subscriptions/Registrations/SubscriptionBuilder.cs

Tests (3) +378 / -6
CompositionHandlerTests.csUpdate composition factory test to avoid container round-trip assumptions +16/-6

Update composition factory test to avoid container round-trip assumptions

• Stops asserting that the factory-created inner handler is resolvable from the keyed container. Introduces a HandlerCapture service to capture and assert the created handler’s injected dependency, matching the new ownership model.

src/Core/test/Eventuous.Tests.Subscriptions/CompositionHandlerTests.cs

HandlerDisposalTests.csAdd tests for handler ownership and disposal behavior +197/-0

Add tests for handler ownership and disposal behavior

• Introduces coverage verifying that only builder-created factory handlers are disposed by the subscription, with async disposal preferred and disposal being idempotent. Also asserts disposal ordering: filters dispose before owned handlers, and container/caller-owned handlers are not disposed.

src/Core/test/Eventuous.Tests.Subscriptions/HandlerDisposalTests.cs

HandlerRegistrationTests.csAdd regression tests for factory handler separation and duplicate type guards +165/-0

Add regression tests for factory handler separation and duplicate type guards

• Adds regression tests ensuring multiple factory registrations with an inferred common handler type execute distinct handlers, including both composition overloads. Verifies factory handlers are created once, duplicate type-based registrations throw, and the same handler type remains valid across different subscriptions.

src/Core/test/Eventuous.Tests.Subscriptions/HandlerRegistrationTests.cs

Comment thread src/Core/test/Eventuous.Tests.Subscriptions/HandlerDisposalTests.cs Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95be2a5a59

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to +56
THandler? handler = null;
AddHandlerResolve(sp => handler ??= Own(getHandler(sp)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Don't take ownership of handlers resolved from DI

When getHandler returns an existing container-owned service—for example, sp => sp.GetRequiredService<SharedHandler>()Own records that shared instance and EventSubscription.DisposeAsync now disposes it even though the container and other consumers may still use it, followed potentially by a second disposal during provider shutdown. This overload previously left the handler under container lifetime management, and the same regression exists in both composition overloads that call Own; ownership must not be inferred merely from using a provider-based factory.

Useful? React with 👍 / 👎.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Non-atomic pipe disposal ✓ Resolved 🐞 Bug ☼ Reliability
Description
ConsumePipe.DisposeAsync uses a non-atomic _disposed flag and returns immediately on subsequent
calls, so concurrent disposals can still both run (double-disposing owned handlers) or a second
caller can return before the first disposal finishes. This breaks the new “idempotent” guarantee and
is reachable from code that disposes a ConsumePipe via multiple concurrent paths (e.g., SignalR
subscription teardown).
Code

src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs[R64-67]

+        if (_disposed) return;
+
+        _disposed = true;
+
Evidence
The new idempotency guard is a non-atomic boolean check/set, which is unsafe under concurrent calls.
SubscriptionGateway can call state.Pipe.DisposeAsync() during per-subscription teardown and also
during gateway-wide disposal over a concurrent dictionary snapshot, making concurrent/duplicate
disposal attempts plausible in real usage.

src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs[63-67]
src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs[74-87]
src/SignalR/src/Eventuous.SignalR.Server/SubscriptionGateway.cs[24-28]
src/SignalR/src/Eventuous.SignalR.Server/SubscriptionGateway.cs[115-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ConsumePipe.DisposeAsync` is intended to be idempotent, but `_disposed` is a plain `bool` checked with `if (_disposed) return;`.
This is not concurrency-safe:
- Two concurrent callers can both observe `_disposed == false` and both proceed, double-disposing `_owned` components.
- A second caller can return immediately while the first caller is still disposing, so the second caller doesn't actually wait for disposal completion.
### Issue Context
There are call sites that can dispose the same `ConsumePipe` from multiple code paths (and potentially concurrently), e.g. `SubscriptionGateway.StopSubscription` and `SubscriptionGateway.DisposeAsync`.
### Fix Focus Areas
- src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs[8-88]
- src/SignalR/src/Eventuous.SignalR.Server/SubscriptionGateway.cs[115-136]
### Suggested fix
Implement a thread-safe, awaitable idempotent disposal pattern, e.g.:
- Use an `int` state with `Interlocked.Exchange` (or `CompareExchange`) to ensure only one disposal executes.
- Store the in-progress disposal `Task` (or `ValueTask`-backed `Task`) so subsequent calls return/await the same completion instead of returning immediately.
Example sketch:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Core/src/Eventuous.Subscriptions/Filters/ConsumePipe.cs Outdated
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Test Results

   44 files  + 21     44 suites  +21   12m 33s ⏱️ - 1m 43s
  547 tests +  3    547 ✅ +  5  0 💤 ±0  0 ❌  - 2 
1 098 runs  +543  1 098 ✅ +545  0 💤 ±0  0 ❌  - 2 

Results for commit f9b1629. ± Comparison against base commit 9722064.

This pull request removes 26 and adds 29 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/20/2026 16:26:07 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/20/2026 16:26:07)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(8487adcb-476a-49bf-94c5-897dfbf70562)
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncContextAwareHandler_ExistingBlob_ShouldUpdateStateAndContext
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncContextAwareHandler_NewBlob_ShouldUseContextAndStoreState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncStateHandler_ExistingBlob_ShouldUpdateState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncStateHandler_NewBlob_ShouldCreateAndStoreState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ ConcurrentAdditionOfNewBlob_ShouldReturnFailure
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ ConcurrentModificationOfExistingBlob_ShouldReturnFailure
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ CustomBlobId_ExistingBlob_ShouldUpdateWithEventId
…
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 10:49:27 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 10:49:27)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(959ee0a7-b101-48c3-9f4c-084ddfeac60f)
Eventuous.Tests.Subscriptions.ConsumePipeTests ‑ ShouldMakeSecondDisposalWaitForTheFirst
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeAsyncHandlerCreatedByFactory
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeFactoryHandlerByDefault
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeFiltersBeforeHandlers
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeHandlerCreatedByFactory
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeHandlerOnlyOnce
Eventuous.Tests.Subscriptions.HandlerDisposalTests ‑ ShouldDisposeInnerCompositionHandlerCreatedByFactory
…

♻️ This comment has been updated with latest results.

A handler factory is free to return a handler it didn't create, most often
`sp => sp.GetRequiredService<SharedHandler>()`, so inferring ownership from
the use of a provider-based factory let the subscription dispose a container
singleton other components still hold, and dispose it a second time at
provider shutdown. Ownership is now stated by the caller through new
`ownsHandler` and `ownsInnerHandler` overloads, and defaults to nobody
owning the handler. They are overloads rather than optional parameters so
assemblies compiled against the current signatures keep working.

`ConsumePipe.DisposeAsync` guarded itself with a plain bool, which two
callers can both pass, and which told the loser the teardown was finished
while it was still running. `SubscriptionGateway` produces exactly that:
`DisposeAsync` walks the subscriptions without removing them while
`RemoveConnectionAsync` removes and stops them, so a disconnect racing the
gateway shutdown disposes one pipe twice. The flag is now an interlocked
exchange, and the callers that lose the race await the single teardown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alexeyzimarev added a commit to Eventuous/eventuous-docs that referenced this pull request Aug 21, 2026
Follows the review on Eventuous/eventuous#577: a handler factory may return a
handler owned elsewhere, so the subscription only disposes handlers the
caller explicitly hands it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generic overload is the container-owned path, so a provider-based factory
that only resolves an existing service is the odd case, not the norm, and
defaulting to no ownership left the common case - a handler the factory
builds - with nobody to dispose it.

It also regressed against the behaviour before this branch. The old keyed
registration meant the container captured whatever the factory returned and
disposed it at shutdown, including a shared instance the factory merely
resolved, which the container then disposed twice. Owning by default restores
that disposal for every factory registration, and disposes a resolved shared
handler once rather than twice.

`ownsHandler` and `ownsInnerHandler` stay available to decline ownership when
the factory returns a handler owned elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit 227c010 into dev Aug 21, 2026
16 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/576-handler-registration-collapse branch August 21, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant