Skip to content

Give the Azure Service Bus infrastructure factory an honest receiver lifetime (#376) - #503

Merged
brenpike merged 13 commits into
masterfrom
fix/asb-registration-factory-no-scope-376
Sep 18, 2026
Merged

brenpike merged 13 commits into
masterfrom
fix/asb-registration-factory-no-scope-376

Conversation

@brenpike

Copy link
Copy Markdown
Owner

Closes #376. #376 was the last open child of epic #307, so this closes that epic too.

The defect

The singleton IMessagingInfrastructure registration in ChatterAzureServiceBusExtensions built its MessagingInfrastructureFactory from two delegates that each did:

using var scope = scopeFactory.CreateScope();
return scope.ServiceProvider.GetRequiredService<ServiceBusReceiver>();

The using disposed the scope before the resolved instance was handed to the caller. ServiceBusReceiver was registered AddScoped, so the scope owned that instance and disposed it during teardown — every caller received an already-disposed receiver. It was benign only by accident of timing: _innerReceiver had not been lazily built yet, and nothing checked the disposed flag.

The fix

Every dependency of ServiceBusReceiver and ServiceBusMessageSender is a singleton — verified against the tree, including the options types registered through AddBuiltOptions. With nothing scoped in the graph, the honest shape is to open no scope at all:

  • Both factory delegates now resolve from the captured root IServiceProvider. The IServiceScopeFactory lookup is gone.
  • Both registrations move from AddScoped to AddTransient. Singleton would be wrong: InitializeAsync writes instance state and one receiver is created per receiver entity, so a shared instance would cross-wire entities.

This is not a new rule. The core module already landed it under #314 (epic #297) and states it as an invariant in ChatterMessageBrokerExtensions: a registration factory must never open a DI scope whose resolved graph escapes the factory delegate. ADR-0022 records the Azure Service Bus specialization of that rule so the remaining sibling sites inherit one shape rather than growing a third.

Two corrections to the issue

  • The sender was never disposed. ServiceBusMessageSender implements only IMessagingInfrastructureDispatcher, which is not IDisposable, so MS DI never tracked it and the scope never disposed it. Its only defect was per-access scope churn. The issue's "same for ServiceBusMessageSender" is wrong in kind.
  • SqlServiceBroker is not the same case. It carries a near-identical shape, but ISqlConnectionSource is registered Scoped there, so its graph genuinely holds a scoped member. The transient-plus-root answer must not be copied onto it — the component must own the scope instead. ADR-0022 says so explicitly, along with the RabbitMQ sender site tracked as Per-message DI scope creation on the publish path, and sender resolved from an already-disposed scope #371.

Teardown observation

Fixing the lifetime made the receiver's synchronous Dispose path worth looking at. It fired CloseAsync() without awaiting or observing it, so a failed close was an unobserved faulted task.

The first attempt handled the two failure shapes anyone thinks of. Review then found two more — a CloseAsync() returning null threw NullReferenceException out of Dispose, and a cancelled task never ran the OnlyOnFaulted continuation, so an aborted close read as a clean teardown. Enumerating outcomes is the defect, not the specific outcomes missed.

So the close helper no longer names outcomes at all. It is one guarded region spanning both obtaining the task and attaching the continuation, plus one unfiltered continuation that reports unless the terminal state is exactly RanToCompletion — a positive allowlist of one success state rather than a list of failures. A new TaskStatus needs no new branch. A single non-throwing reporter serves both regions, so a failing logger can no longer escape Dispose.

The guarantee is over which outcomes get reported, not over the report line landing: ExecuteSynchronously is a hint the runtime may decline, and a queued report can be lost at process exit. ADR-0022 records that as an accepted residual with its rejected alternatives rather than overstating the guarantee.

Validation

Per-project, never solution-wide.

Suite net8.0 net10.0
Chatter.MessageBrokers.AzureServiceBus.Tests 0 failed / 461 passed / 2 skipped 0 failed / 461 passed / 2 skipped
Chatter.MessageBrokers.Tests 0 failed / 1292 passed / 0 skipped 0 failed / 1292 passed / 0 skipped

Baseline at 8b58127 was 445 passed on the Azure Service Bus suite; the 16 added facts are the new tests. The 2 skips are the Docker-gated integration tests that skip at discovery, unchanged. No public surface moved — every touched type is internal.

Versioning

Chatter.MessageBrokers.AzureServiceBus 2.5.1 → 2.5.2 (patch). The only consumer-visible delta is a new Warning log line when a teardown close does not succeed.

@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: cf30837dc3

ℹ️ 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".

…in ADR-0022

The scoped receiver was itself disposed by the factory's scope; only the
injected graph and the not-yet-built inner receiver were latent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7t1aWmGQZNjZi8FQURV6G

@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: bc40d86067

ℹ️ 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".

@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: bccc28e44a

ℹ️ 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".

brenpike and others added 2 commits September 18, 2026 10:40
…instance

Sole-disposer ownership is true of the ServiceBusReceiver INSTANCE and stops
there. The container still constructs, and so still disposes, the shared
ServiceBusClient the receiver receives THROUGH, and that client is resolved
lazily during the first receiver construction — after the hosted service — so
LIFO disposal reaches it FIRST on the same provider-disposed-without-StopAsync
path the eliminated class was argued over.

Record it as an accepted residual rather than widen the fix: the path is traced
end to end and cannot reproduce the eliminated class, because no link can be
rebuilt behind a latched _disposedValue — the rebuilt adapter opens nothing in
its constructor and the SDK's own not-disposed assert throws out of
CreateReceiver. What is left is one Error-level line during a teardown that had
already skipped its stop. The three obvious remediations are rejected on the
merits: the client has no single pump-bounding owner (N receivers, one client,
shared by contract for cross-entity transactions), and a disposed-client guard
buys nothing the SDK assert does not already raise.

Scope the Closed-by-Construction claim to the instance it is true of, so the
prose stops one step short of the code instead of one step past it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7t1aWmGQZNjZi8FQURV6G
@brenpike
brenpike merged commit ce87d5f into master Sep 18, 2026
16 checks passed
@brenpike
brenpike deleted the fix/asb-registration-factory-no-scope-376 branch September 18, 2026 16:57
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.

ServiceBusReceiver and sender are resolved from a DI scope that is disposed before use

1 participant