fix: release per-message transformers and mappers to their factories#4254
fix: release per-message transformers and mappers to their factories#4254iancooper wants to merge 20 commits into
Conversation
…t transformer leak Root cause: ServiceProviderLifetimeScope.GetTransient<T> created one IServiceScope for the factory's entire lifetime and reused it for every transient resolution. Because IAmAMessageTransform is IDisposable, the built-in DI container tracked each resolved instance in that scope's disposables list. Release() disposed the instance directly but left the container's strong reference intact; when the factory was finally disposed the scope re-disposed every previously-released instance (double-dispose) and had retained all of them in memory since process start (unbounded growth). Fix: GetTransient now creates a fresh IServiceScope per call and stores it keyed by instance identity. Release() disposes the scope — which causes the DI container to drop its reference and dispose the instance exactly once — then returns without direct disposal. Dispose() drains any unreleased transient scopes before disposing the shared scope used for Scoped resolutions. GetOrCreateScoped is decoupled from GetTransient so scoped resolutions are unaffected. Fixes #4252 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Review — PR #4254: dispose per-message
|
Code Review — PR #4254 (transient transformer scope leak)Thanks for the thorough diagnosis in However, this change appears to trade the transformer leak for a new, equally unbounded leak on the mapper path — arguably a more common hot path than transforms. 🔴 Blocking: new unbounded leak in the mapper factories
The transformer factories call
Net effect: every message now permanently retains one Your own
The shipped fix does neither. Suggested options:
🟠 Minor / worth noting
Reviewed with Claude Code. |
…s in GetTransient<T> Root cause: ServiceProviderLifetimeScope.GetTransient<T> stored every resolved non-null instance in _transientScopes unconditionally. Because IAmAMessageMapperFactory has no Release method and MessageMapperRegistry never calls one, one IServiceScope accumulated per message processed and was only cleared at factory shutdown — a regression introduced by PR #4254. Guard changed from `instance != null` to `instance is IDisposable`: non-disposable instances (all current in-tree mappers) now have their scope disposed immediately after resolution, retaining nothing between calls. Fixes #4254 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Code Review — PR #4254: dispose per-message Thanks for the detailed write-up — the root-cause analysis in the PR body and the two Correctness / design
Test coverage
Style / conventions
Summary Solid, well-scoped fix for the reported leak with good regression coverage. Main things Id want before/after merge: a tracked follow-up for the disposable-mapper case (Part B), and ideally the stronger 🤖 Generated with Claude Code |
Root cause: IAmAMessageMapperFactory declared no Release, so ServiceProviderLifetimeScope.Release was unreachable for mappers and a transient IDisposable mapper retained one IServiceScope per message until process shutdown. Adds Release to the mapper factory and registry interfaces (breaking: netstandard2.0 rules out a default interface member; 5 in-tree implementations, none outside src/), gives the transform pipeline ownership of its mapper, and makes the five production call sites dispose the pipeline deterministically rather than relying on ~TransformPipeline(). Also fixes two defects surfaced during confirmation: HasPipeline orphaned a probe mapper per message, and ServiceProviderLifetimeScope.Release double-disposed on a repeat release and corrupted the Scoped instance cache. Addresses the "Part B" finding in the code review of #4254. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — PR #4254: dispose per-message
|
…sync-aware Three related follow-ups to bugfix 0007, recorded there as (b), (c), (d). (b) An IAsyncDisposable-only mapper or transform threw on every Create: MS DI's ServiceProviderEngineScope.Dispose() refuses to dispose a scope holding such a service. Route all three scope.Dispose() sites through a new DisposeScope helper that prefers IAsyncDisposable on the scope. netstandard2.0 keeps the synchronous path via #if, as it has no Microsoft.Bcl.AsyncInterfaces reference. (c) Revert 0006 Part A. Its `instance is IDisposable` guard disposed the DI scope of any non-disposable transient at Create, breaking anything that captured something from that scope. FluentValidationRequestHandler injects IServiceProvider and resolves from it lazily, so it got ObjectDisposedException -- 6 of 12 Validation.FluentValidation tests, bisected to 2d5a9c1. Part A's premise, that mappers have no Release, was dissolved by 0007 Part B; every transient consumer now has a release path. GetTransient is back to `instance != null`, with Release bounding the accumulation instead. 0006's regression test moves to the Create+Release contract accordingly, plus a new fact pinning that the scope must survive until Release. (d) Release the mapper in the catch of BuildWrapPipeline/BuildUnwrapPipeline in both pipeline builders. Restoring full tracking in (c) turned this previously masked leak live. Verified on net9.0 and net10.0: Core 863, Extensions 117, InMemory 144, Testing 85, AsyncAPI 46, Transforms.Adaptors 30, FluentValidation 12 (was 6/12), DataAnnotations 10. DI project builds clean on all four TFMs including netstandard2.0. Transport/backend suites needing a container runtime not run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…factories The four items 0007 had left on its deferred list, recorded there as follow-ups (e), (f), (g), (h). (e) ServiceProviderLifetimeScope.Dispose() disposed the Scoped-lifetime _scope synchronously, so a scoped IAsyncDisposable-only instance threw at factory shutdown -- the Scoped sibling of follow-up (b). Route _scope through DisposeScope too; that was the last synchronous scope-disposal site. (f) MessageMapperRegistry.Get/GetAsync cached the resolved *default* mapper type into the same dictionaries that hold explicit registrations. Two symptoms: (1) ResolveMapperInfo then reported IsDefault:false for a default mapper, so DescribeTransforms described it as custom -- an order-dependent answer; (2) Register threw "already has a mapper" if Get ran first for that request type. Split the default-resolution cache into _resolvedDefaultMappers / _resolvedDefaultAsyncMappers, which leaves ResolveMapperInfo correct unchanged and keeps the per-message MakeGenericType saving. (g) ServiceProviderHandlerFactory.Release disposed the handler directly and then disposed the lifetime scope, which disposes it again -- via _transientScopes for Transient, via _scope for Scoped. Follow-up (c) made this reliably reachable. Drop the direct dispose; the scope owns the instance. (h) GetOrCreate had no _disposed check, so a Create after Dispose tracked a scope into a dictionary nobody would drain and returned an instance whose factory was gone. _disposed becomes volatile and is set before draining; GetOrCreate throws ObjectDisposedException, and GetTransient re-checks after tracking so a Dispose that drained past it still gets the scope cleaned up. Behaviour change: create-after-dispose used to succeed silently and leak, and now throws. Verified on net9.0 and net10.0: Core 865 (was 863), Extensions 121 (was 117), InMemory 144, Testing 85, AsyncAPI 46, Transforms.Adaptors 30, FluentValidation 12, DataAnnotations 10. Paramore.Brighter, the DI extensions and ServiceActivator all build clean on all four TFMs including netstandard2.0. Transport/backend suites needing a container runtime not run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Two notes on the latest two commits ( 1. This PR fixes a regression it introduced itself
0006's guard lives in The guard's premise — "mappers have no Consequence: 0006's regression test was deliberately rewritten. It asserted 10 Full reasoning and evidence: 2. One deliberate behaviour change — worth a release noteFollow-up (h): create-after-dispose on a DI factory now throws Also folded inSeven follow-ups total, (b)–(h) in 0007's VerificationBoth net9.0 and net10.0, all green: Core 865 (was 863) · Extensions 121 (was 117) · InMemory 144 · Testing 85 · AsyncAPI 46 · Transforms.Adaptors 30 · FluentValidation 12 (was 6/12) · DataAnnotations 10.
Not run: transport/backend suites needing a container runtime. |
Code Review — Release per-message transformers and mappers to their factoriesI read through the full change (DI lifetime scope, mapper factory/registry surface, the four transform pipelines + builders, and the five production call sites) against the root-cause writeup and the new tests. Overall this is a high-quality, carefully reasoned fix. The diagnosis in the PR body matches the code, the sync/async paths are symmetric, the Strengths
Points worth considering1. Partial-failure transform leak in the builders (low). In 2. 3. Sync-over-async 4. None of these block the fix — the core leak/double-dispose defects (#4252 and the Part B mapper path) are convincingly closed and well-tested. Items 1–2 are the ones I'd most want a line of code or a comment on. Note: I could not run the build/tests in this review environment; the assessment is from reading the diff against the stated test results. 🤖 Generated with Claude Code |
…uild fails BuildTransformPipeline created transforms in a loop with no try/catch. If a later transform failed to be created (factory returns null) or failed to Initialize, the transforms already created never reached a pipeline's TransformLifetimeScope, so they were never released back to the transformer factory — a leak. The outer catch in BuildWrap/UnwrapPipeline released only the mapper. Closes all three release-gap windows (sync + async): - primary: mid-loop failure in BuildTransformPipeline now releases every already-created transform before rethrowing - window 2: a build that fails after construction disposes the constructed pipeline in the outer catch (CleanUpAfterFailedBuild) - window 3: TransformerFactory[.Async] releases a transform whose Initialize throws Verified: full Core.Tests green both TFMs (net9.0 + net10.0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
… leak one Root cause: ServiceProviderLifetimeScope.GetTransient tracked each transient IServiceScope in a dictionary keyed by the resolved instance under reference equality, storing via an indexer set. When the same reference came back twice (a type registered AddSingleton but resolved under a Transient MapperLifetime — reachable because mappers/transforms are registered TryAdd(..., Transient)), the second store overwrote the first, orphaning that scope: neither Release nor Dispose could reclaim it. Unbounded, as the mapper/transformer factories hold one process-wide lifetime scope. Fix: key _transientScopes by a ConcurrentStack<IServiceScope> per instance — GetTransient pushes, Release pops+disposes one (detaching the key when empty), Dispose drains all. Symmetric create/release, no leak on collision, and (unlike TryAdd+dispose-loser) disposal stays deferred so a shared-disposable transient is never disposed while still in use. Addresses PR #4254 review point 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
An IAsyncDisposable-only mapper or transform is drained synchronously through DisposeScope's awaited DisposeAsync — on the Proactor that runs on the message- pump thread. A DisposeAsync doing real async I/O blocks the pump for its whole duration. Document this on DisposeScope (with author guidance) and cross- reference it from Release. Addresses PR #4254 review point 3 (documentation only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
|
Thanks for the review — round 2 pushed ( 1 — Partial transform build leak ✅ fixed ( 2 — Transient scope key collision ✅ fixed ( Note on the fix: the suggested 3 — Sync-over-async DisposeScope on the pump thread ✅ documented ( 4 — GetTransient vs Dispose enumeration race — left as-is Full |
Code review — release per-message transformers and mappers to their factoriesReviewed the full diff against Correctness / robustness
Performance
Minor / nits
Test coverageStrong. The RED-proven regression tests, the Nothing here is blocking; items 1 and 2 are the ones worth a decision (fix vs. document-and-defer) before merge. Given the change is transport-agnostic but flows through 🤖 Generated with Claude Code |
…sposal cannot deadlock The async transform pipeline is disposed on the thread that releases it. On the Proactor that is the message-pump thread, running under the single-threaded BrighterSynchronizationContext. Releasing a transient IAsyncDisposable mapper/transform went through ServiceProviderLifetimeScope.DisposeScope's blocking `pending.AsTask().GetAwaiter().GetResult()`; if the user's DisposeAsync awaited without ConfigureAwait(false), its continuation was posted back to the pump context that the blocked pump thread could never drain — a hard deadlock, not just a stall. Fix (raised in review of PR #4254): - Async release path (Part A): TransformPipelineAsync<T> now implements IAsyncDisposable; DisposeAsync awaits async release of the transforms (TransformLifetimeScopeAsync.DisposeAsync) and the mapper (registry.ReleaseAsync), so an IAsyncDisposable DisposeAsync is awaited, not blocked on, and the pump thread stays free to run any continuation it posts back. Sync Dispose/finalizer remains the fallback, sharing the Interlocked release-once guard. New ValueTask ReleaseAsync on IAmAMessageMapperFactoryAsync, IAmAMessageTransformerFactoryAsync, IAmAMessageMapperRegistryAsync (its unreleased sync ReleaseAsync renamed to Release), and ServiceProviderLifetimeScope.ReleaseAsync (sharing bookkeeping with Release via CollectScopesToRelease). No-op factories return default. Proactor.TranslateMessage and OutboxProducerMediator.MapMessageAsync now `await using`. - Deadlock-safe sync fallback (Part B): DisposeScope now offloads the disposal to a pool thread when a SynchronizationContext is current (continuations resume off-pump), keeping the cheap inline path when none is (Reactor, finalizer, shutdown). Downgrades any residual deadlock to a bounded stall. Regression test drives both disposal shapes on a real BrighterAsyncContext pump with an IAsyncDisposable mapper that yields without ConfigureAwait(false); proven RED by reverting the offload (sync path deadlocks, async path still passes). Hosted on a dedicated thread with a thread-pool floor to stay non-flaky under parallel load. Green on net9.0 and net10.0: Core 871/7-skip, Extensions 124, InMemory 144, Testing 85, AsyncAPI 46, Transforms.Adaptors 30. Production builds clean including netstandard2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
Code Review — PR #4254: release per-message transformers and mappers to their factoriesReviewed the core of the change: A few things worth confirming before merge. 1. Contract change is now stricter than the leak it fixes (worth a migration note)Previously, resolving a non-disposable transient mapper and dropping it leaked nothing — MS DI does not track non-disposables, and there was a single shared scope. After this change, every transient resolution allocates an 2.
|
…nnot crash the process An exception escaping a finalizer terminates the process. Both TransformPipeline and TransformPipelineAsync released the mapper/transforms from ~Finalize with no guard, so a synchronous release that threw — MS DI's sync scope Dispose throws for an IAsyncDisposable-only mapper/transform, and a user Dispose/DisposeAsync can throw for any reason — escalated an undisposed pipeline from a throw to a hard process teardown on the finalizer thread. Wrap the finalizer's ReleaseUnmanagedResources call in try/catch and swallow. Explicit Dispose/DisposeAsync is unchanged and still surfaces the exception to the caller that owns the pipeline; only the finalizer fallback swallows. Addresses review item 2 on PR #4254. The broader "handle IAsyncDisposable-only instances on the synchronous Dispose path" change remains the tracked follow-up; this closes only the process-crash escalation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
|
Code Review — PR 4254 (per-message transformer and mapper release) Reviewed against A few observations, mostly minor — none look like blockers: 1. 2. 3. 4. Test coverage. Strong — each Part B fix pinned by a test proven RED against a reverted hunk, Nice work — the depth of the writeup made this reviewable despite the 67-file surface. 🤖 Generated with Claude Code |
… async null-object release symmetrical Review items 2 and 3 on PR #4254. Item 2 — an exception escaping a finalizer terminates the process. Like the pipeline finalizers hardened earlier, ~TransformLifetimeScope and ~TransformLifetimeScopeAsync released their tracked transforms from the finalizer with no guard, so a synchronous release that threw — MS DI's sync scope Dispose throws for an IAsyncDisposable-only transform — crashed the process. Finalization order is non-deterministic, so an orphaned lifetime scope can be finalized before its owning pipeline disposes it and take that path. Wrap the finalizer's ReleaseTrackedObjects in try/catch and swallow; explicit Dispose/DisposeAsync is unchanged and still surfaces the exception. Item 3 — EmptyMessageTransformerFactoryAsync.ReleaseAsync called sync Dispose directly. Make it await DisposeAsync when the transform offers it, falling back to sync Dispose, for symmetry with the real async transformer factory. The default EmptyMessageTransformAsync is IDisposable only, so this is behaviour-preserving. Regression test: two facts abandon an undisposed TransformLifetimeScope / TransformLifetimeScopeAsync whose factory Release throws and force finalization; proven RED by reverting each guard (test host crashes at the scope's Finalize()). Core.Tests 875 passed / 7 skipped on net9.0 and net10.0; production build clean on all four TFMs including netstandard2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
Code Review — PR #4254: release per-message transformers and mappers to their factoriesReviewed against the repository conventions in Strengths
Points worth surfacing (not blockers)
Test coverageStrong. The added tests cover: transient non-disposable non-accumulation, double-resolve-before-release, scoped async-disposable-only disposal, the pump-context deadlock (both Nice work — a model example of diagnosis-first bugfixing with the adjacent defects tracked rather than silently folded in. 🤖 Generated with Claude Code |
Code Review — PR #4254 (transient transformer/mapper release paths)Thanks for a very carefully constructed PR — the inline documentation, the concurrency reasoning in 🔴 Primary concern — scope-sharing semantics change for transient handlers (likely beyond the stated scope)
Before this PR, every transient handler resolved for one This is a behavioural change that could break the common "one unit-of-work per message" pattern, and it goes beyond the stated fix. Could you confirm it's intended? If it is, it would be worth an explicit test asserting the intended sharing (or non-sharing) semantics for a 🟠 Performance — extra
|
…lock CreateRequestFromMessage builds an async unwrap pipeline but disposes it via a synchronous `using` and blocks the unwrap on GetAwaiter().GetResult(). On the Proactor's single-threaded pump this releases a transient IAsyncDisposable mapper on the pump thread; its deadlock-safety rides on the SynchronizationContext- aware offload in ServiceProviderLifetimeScope.DisposeScope. The existing deadlock test only covers the send path (Proactor.TranslateMessage), so add a reply-path case driving CreateRequestFromMessage on a real BrighterAsyncContext pump. Proven RED by disabling the DisposeScope offload branch (test times out at 30s). Both pump-context deadlock tests are moved into a DisableParallelization collection: each blocks a dedicated pump thread on an offloaded disposal that needs a free thread-pool thread, and letting them contend with the whole parallel suite for the pool starved that offload and tripped the 30s guard spuriously. Serialising them removes the contention without weakening the assert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
Review — PR #4254: mapper/transformer release & transient scope leakThorough, well-motivated change. The root-cause write-ups (both in the PR body and the Strengths
Discussion / possible issues1. Breaking change scope (highest-impact item). Adding non-default members to five public interfaces ( 2. Mapper can leak if transform-scope disposal throws. In both Because 3. Per-resolution scope allocation on the hot path (performance). 4. 5. Test coverageExcellent — deterministic assertions ( Minor / nits
Nice work overall — the correctness reasoning is unusually rigorous for a concurrency/lifetime fix. Review generated with Claude Code. |
The pipeline released its mapper after disposing the transform scope (InstanceScope), and the release-once guard (_released) is set at the top of the disposal. So a throw from InstanceScope disposal — the synchronous-scope- throw shape this PR guards against elsewhere — skipped the mapper release, and because the guard was already set neither the finalizer nor a later dispose retried it. The mapper's DI scope was then permanently orphaned: the exact leak class this PR closes. Release the mapper in a finally in all three disposal paths (TransformPipeline.ReleaseUnmanagedResources, and TransformPipelineAsync's DisposeAsync and ReleaseUnmanagedResources). The scope-disposal exception still surfaces to an explicit Dispose/DisposeAsync caller; only the finalizer swallows. Test: When_a_transform_scope_disposal_throws_the_mapper_is_still_released (three facts, one per disposal path), each proved RED by reverting the try/finally in the hunk it pins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
Code Review — PR #4254 (transient transformer/mapper release)Reviewed against the base branch. This is a careful, high-quality fix for a genuine and subtle class of DI-scope/lifetime leaks. The diagnosis in the description is accurate, the code is unusually well-commented on the why, and the test coverage is excellent — each RED was independently proven by reverting the exact production hunk it pins, and the deterministic Below are observations, ordered by significance. None are blockers; most were already acknowledged in the description as deferred/out-of-scope, and I'm confirming they're the right calls plus adding a couple of new ones. Correctness / concurrency
Performance
API / breaking change
Things done well (worth keeping)
Overall: approve in spirit. The remaining items are the two already-tracked follow-ups (probe allocation removal; Reviewed by Claude Code. |
…ot leaked GetOrCreateScoped used a non-atomic `_scope ??= CreateScope()`. Two threads racing the first scoped resolution each create a scope; the loser's assignment is overwritten and never disposed (neither Dispose nor Release drains a scope that is not the retained `_scope`), leaking one empty scope per lifetime scope under concurrent first-use of a Scoped mapper/handler. Publish via Interlocked.CompareExchange and have the loser dispose the scope it created. Test rendezvouses the first two CreateScope() calls on a barrier so the race is forced deterministically (created == 2), then asserts created == disposed after factory disposal; RED-proven against the non-atomic assignment (disposed stayed at 1). Addresses review item 1 of PR #4254 comment 5072369263. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
|
Thanks for the review. Addressed the two actionable items in round 7 ( Item 1 — Item 2 — sync-fallback offload consuming a pool thread: confirmed inherent, left as-is. This is the unavoidable cost of draining an Items 3–5 (probe-allocation hot-path churn; the two API/breaking-change notes) remain as previously agreed: 3 is a tracked out-of-scope follow-up, 4/5 are the major-version bump + release-notes/migration-guide handling. Verified green net9.0 + net10.0: Core.Tests 878/7-skipped, Extensions.Tests 126/126 (new race test added); production build clean incl. |
|
Just a general note: the changes to interfaces here are only implemented internally, afaik. They are public to allow alternative implementations, but if you have your own DI extensions for use with Brighter, I suspect that you would not balk at us breaking you, but would want to fix the memory leak issues. We do use SemVer, but have a pragmatic approach to avoid stalling most customers for whom this is an invisible, and welcome, bug fix. For that reason: document in the release notes, but don't increment the major version. |
Code Review — PR #4254 (release per-message transformers and mappers to their factories) — part 1/2Reviewed the full This remains excellent work — the diagnosis chain, the RED-proof-per-hunk discipline, and the concurrency commentary in 1. 🔴
|
Code Review — PR #4254 — part 2/24. 🟡 The
|
…l chain
A review of the per-message release PR flagged that changing GetTransient from a
shared _scope to a per-resolution scope silently altered the default (Transient)
handler path: a scoped dependency injected into a handler and its middleware used
to be one shared instance and is now distinct per handler.
Investigation shows this is the correct, intended lifetime split, not a regression:
* the leak lived only in the mapper/transformer factories, whose
ServiceProviderLifetimeScope is app-lifetime, so transients accumulated there;
* the handler factory's scope is per-pipeline and disposed wholesale at chain
end, so it never leaked;
* Transient means "each resolution gets its own independent scope" and Scoped
means "the scope IS the call chain, shared across handlers" — both should hold.
No production code changes: GetOrCreateScoped still shares _scope (Scoped sharing
preserved, incl. the #3754 transaction-provider/unit-of-work pattern), and
GetTransient isolates per resolution. This adds the missing coverage — existing
FactoryLifetimeTests only assert handler-instance identity or resolve one handler
type twice; neither pins whether a scoped *dependency* is shared across two
*different* handlers in one call chain. The Transient-isolation fact would fail on
master (old shared _scope made the dependency shared), documenting the intended
default-path behavior.
Green on net9.0 + net10.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
…r is not an ambiguous call MessageMapperRegistry exposed two public Release overloads on unrelated marker types — Release(IAmAMessageMapper) and Release(IAmAMessageMapperAsync). Any mapper that supports both Reactor and Proactor implements both markers (e.g. JsonMessageMapper<T>, and essentially every hand-written dual mapper), so a caller holding the concrete registry — the common case — could not call Release(mapper): neither overload is a better match, giving CS0121. In-tree call sites dodged it because the registry is always interface-typed, but the concrete type is public and the PR now asks callers to "Release what you Get", so a user with a dual-interface mapper hits a compile error whose only workaround is an explicit cast. Implement the async Release explicitly (void IAmAMessageMapperRegistryAsync.Release) so only the synchronous Release(IAmAMessageMapper) is on the concrete surface; registry.Release(dualMapper) now binds unambiguously to it. Async release stays available through IAmAMessageMapperRegistryAsync or ReleaseAsync. All in-tree async callers already hold the interface, so they are unaffected. RED-proven: the added test did not compile (CS0121) before this change; green on net9.0 + net10.0 after. Production build clean incl. netstandard2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
Round 7 closed the two-threads-race-the-first-scope leak with CompareExchange.
The symmetric create-vs-Dispose race stayed open: a thread can pass GetOrCreate's
disposed-guard, create its first scoped scope, and publish it via CompareExchange
*after* Dispose has already read _scope (still null) and drained nothing. The
published scope is then neither retained-and-drained nor in _transientScopes, so
nothing ever disposes it — one IServiceScope and everything it holds leaks for the
life of the owning graph. This is the same shape GetTransient already guards with
its post-Push _disposed re-check.
Fix, mirroring GetTransient:
* after winning the publish CAS, re-check _disposed; if set, reclaim the scope we
published with CompareExchange(ref _scope, null, created) and dispose it, then
throw ObjectDisposedException — the scope this resolution needed is gone;
* Dispose claims _scope with Interlocked.Exchange(ref _scope, null) so exactly one
side disposes it whichever wins, which also collapses the check-then-use double
read that let two concurrent disposals both dispose _scope;
* the scoped Lazy factory reads _scope into a local and throws ObjectDisposedException
if a concurrent Dispose has nulled it, rather than dereferencing null.
RED-proven: with the production hunk reverted the added test leaves DisposedCount
at 0 (created 1); green on net9.0 + net10.0 after. Full Extensions.Tests 129/129
(net9.0). Production build clean incl. netstandard2.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
…not skip or double-release The round-5 follow-up moved the mapper release into a finally so a transform-scope disposal throw could not orphan it, but the transform list itself kept releasing with a foreach over the unmodified list. If Release throws on one transform — the documented IAsyncDisposable-only sync-scope-disposal throw, or a user Dispose — the remaining transforms are never released, and because Dispose throws before GC.SuppressFinalize the finalizer re-runs the same list: every transform released before the throw is released again (a user factory whose Release does transform.Dispose() gets a double dispose), and those after it still never release. Drain as we go instead: remove each transform before releasing it, in all three loops — TransformLifetimeScope.ReleaseTrackedObjects, and both the synchronous (finalizer-backing) ReleaseTrackedObjects and the asynchronous ReleaseTrackedObjectsAsync on TransformLifetimeScopeAsync. A retry then runs over the shortened list, so nothing is skipped and nothing is released twice. RED-proven: with the two production files reverted both facts fail (the retry released 'before' twice and 'after' never); green on net9.0 + net10.0 after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
…annot retain the instance CollectScopesToRelease was a lazy iterator: it popped the scope, yielded it for the caller's foreach to dispose, and only then — after control returned — reached the step that removes the now-empty stack's key. If DisposeScope throws (the documented IAsyncDisposable-only sync-disposal case), the foreach unwinds before that step runs, so the empty stack stays keyed by the instance. Because _transientScopes is keyed by the instance, that keeps a strong reference to the mapper/transform alive until factory disposal — the same retention class this PR closes, resurfacing on the failure path. Materialise the pop-and-key-removal eagerly into a list and return it, so all the bookkeeping completes before any scope is disposed; a throw while draining the returned scopes can no longer abandon it. Release/ReleaseAsync are unchanged — they still foreach the result. RED-proven: with the production hunk reverted the instance stays tracked after a throwing release; green on net9.0 + net10.0 after. Full Extensions.Tests 130/130 (net9.0). Production build clean incl. netstandard2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AghTEi1U38TWCpw4ZTJkQ3
|
Thanks for the second full-diff review — thorough and on point. Addressed in round 8 (
Verified green on net9.0 + net10.0: Core.Tests 881/7-skip, Extensions.Tests 130/130; production build clean incl. |
Code Review — PR #4254 (round 9)Reviewed the current head fresh rather than re-treading the eight prior rounds. The core design reads well: per-resolution Four items below. One is a new leak of the same class this PR exists to close; one is a test-fidelity gap meaning several scope-accounting tests don't exercise the branch production takes. Caveat: I could not build or run the suite in this environment, so these come from reading the diff against 1. 🟠
|
Code Review — PR #4254 (round 9), part 2Continued from part 1. 3. 🟡 The scope test doubles are sync-only, so the accounting tests take a branch production never takes
So all fall through to Two knock-ons from the same fact, both about comments rather than code:
4. Packaging: the new public surface rests on a transitive
|
Summary
Two related defects in how Brighter returns per-message objects to their factories.
Part A — transient transformer leak (
Fixes #4252)ServiceProviderLifetimeScope.GetTransient<T>now creates a freshIServiceScopeper call, tracked by instance identity, and disposes it inRelease— closing the unbounded-growth and double-dispose defect for transient transforms.GetOrCreateScopedis decoupled fromGetTransientso scoped resolutions continue to share the existing_scopefield, unaffected by this change.Part B — message mappers had no release path at all (raised in the code review of this PR)
IAmAMessageMapperFactoryandIAmAMessageMapperFactoryAsyncgainRelease, so a mapper can be returned to its factory exactly as a transformer is.~TransformPipeline().Releaseis added to four public interfaces:IAmAMessageMapperFactory—void Release(IAmAMessageMapper mapper)IAmAMessageMapperFactoryAsync—void Release(IAmAMessageMapperAsync mapper)IAmAMessageMapperRegistry—void Release(IAmAMessageMapper mapper)IAmAMessageMapperRegistryAsync—void ReleaseAsync(IAmAMessageMapperAsync mapper)Brighter targets
netstandard2.0, which has no runtime support for default interface members, so these cannot ship with a default body — any third-party implementation of these interfaces must add the method.In-tree blast radius is 5 factory classes and 1 registry class, all under
src/; there are no implementations intests/,samples/orbenchmarks/, no derived classes, and theFunc-basedSimpleMessageMapperFactoryconstructor signature is unchanged, so its call sites keep compiling.Root cause
Part A.
GetTransient<T>lazily created oneIServiceScopefor the factory's entire (process) lifetime and reused it for every transient resolution. The built-in DI container tracks each resolvedIDisposablein the scope's internal disposables list; that reference is only removed when the scope itself is disposed.Release()calleddisposal.Dispose()on the individual instance but never disposed the scope, so the container retained one strong reference per message indefinitely. When the factory was eventually disposed the scope re-disposed every previously-released instance (double-dispose). BecauseTransformerLifetimedefaults toTransientand transformer factories are process-lifetime singletons, this is the default code path.Part B. After Part A,
GetTransient<T>deliberately retainsinstance → scopeforIDisposableinstances on the contract that the owner callsRelease. Transformers honour that contract viaTransformLifetimeScope→IAmAMessageTransformerFactory.Release. Mappers could not: the mapper factory interfaces declared onlyCreate, no implementation had aRelease, andMessageMapperRegistrynever released whatCreatereturned.ReleaseandDisposeare the only two drains of_transientScopes, so for mappers only the second was reachable — meaning a user-definedIDisposablemapper under the defaultMapperLifetime = Transientretained oneIServiceScopeper message until process shutdown, and was never disposed.Two further defects surfaced while confirming that diagnosis, both of which had to be fixed for Part B to actually work:
HasPipelineorphaned a mapper per message. It wasreturn _mapperRegistry.Get<TRequest>() != null;— an instance created purely to answer a question, immediately unreachable, owned by nothing.OutboxProducerMediatorcalls it on every send and receive, so this was 2 mapper creations per outbound message and 3 on the sync-fallback path. With the DI defaults (DefaultMessageMapper = JsonMessageMapper<>, registeredTransient)Getessentially never returns null, so the worst case was the normal case.ServiceProviderLifetimeScope.Releasewas not idempotent, and was unsafe underScoped. It fell through to a directdisposal.Dispose()when no tracked transient scope was found, so a repeat release double-disposed. UnderMapperLifetime = Scoped— a supported configuration — that fall-through disposed an instance that remains cached in_scopedInstances, so message No API Documentation #2 would receive a disposed mapper. Introducing a per-message mapper release would have activated this.Fix
Part A.
GetTransientcreates a freshIServiceScopeper call and storesinstance → scopein aConcurrentDictionarykeyed by reference identity.Releasecallsscope.Dispose()— the DI container drops its tracked reference and disposes the instance exactly once.Disposedrains any unreleased transient scopes before disposing the shared scope used for scoped resolutions.Part B.
Releaseadded to both mapper factory interfaces and all five implementations:ServiceProviderMapperFactory/...Asyncdelegate toServiceProviderLifetimeScope.Release;SimpleMessageMapperFactory/...AsyncandControlBusMessageMapperFactoryare documented no-ops (the caller'sFuncowns what it returns and may hand back a shared instance; the control-bus mappers hold no resources). This mirrorsSimpleMessageTransformerFactory, whoseReleaseis likewise a no-op.Release/ReleaseAsyncadded to the registry interfaces andMessageMapperRegistry, forwarding to the matching factory.TransformPipeline/TransformPipelineAsynctake an optional trailingmapperRegistryparameter and release the mapper on disposal, guarded by anInterlocked.Exchangeflag so an explicitDisposefollowed by another cannot double-release. The mapper is released outsideInstanceScope, because that scope only exists when a transformer factory was supplied — releasing through it would miss the no-transformer case entirely.HasPipelinereleases its probe in afinally. Its semantics are kept byte-for-byte — the tempting swap to the existing type-onlyMessageMapperRegistry.ResolveMapperInfowas not taken, because it is not equivalent toGet<TRequest>() != null: it ignores the null-factory guard, it reports a mapper that the container cannot actually resolve, and itsIsGenericTypeDefinitionbranch turns anInvalidOperationExceptioninto afalse. That refactor is tracked separately.OutboxProducerMediator(CreateRequestFromMessage,MapMessage,MapMessageAsync) now usesusing var pipeline = ….ReactorandProactoruseusing var pipelineLifetime = pipeline as IDisposable;, which works despite their reflectiveobject?handle becauseTransformPipeline<T>implementsIDisposablenon-generically.ServiceProviderLifetimeScope.Releasenow returns early for any non-Transientlifetime and drains only viaTryRemove, making it idempotent and a no-op underScoped/Singleton.Test plan
Part A:
When_releasing_a_transient_transformer_the_factory_should_not_retain_it— sync factory, was red, now greenWhen_releasing_a_transient_transformer_async_the_factory_should_not_retain_it— async factory, was red, now greenPart B — each was proved a genuine RED by temporarily reverting the single production hunk it pins:
When_disposing_a_wrap_pipeline_should_release_the_transient_disposable_mapper— REDExpected: 1 / Actual: 0When_checking_for_a_pipeline_should_release_the_probe_mapper— REDExpected: 10 / Actual: 0When_posting_a_message_should_release_every_mapper_it_creates— drives a realCommandProcessor.PostoverOutboxProducerMediator+InMemoryOutbox, assertingCreateCount == ReleaseCountwith no forcedGC.Collect(release must be deterministic, not finalizer-driven). REDExpected: 2 / Actual: 1— precisely diagnostic: the probe mapper released, the pipeline's own mapper orphaned.Suites, all green on both net9.0 and net10.0:
Paramore.Brighter.Core.TestsParamore.Brighter.Extensions.TestsParamore.Brighter.InMemory.TestsParamore.Brighter.Testing.TestsParamore.Brighter.AsyncAPI.TestsParamore.Brighter.Transforms.Adaptors.TestsNot run locally: the transport/backend integration suites, which need a container runtime. They exercise this path through
CommandProcessor, so they are worth a CI run, though the change is transport-agnostic.Update — async release so pump disposal cannot deadlock (review item 1)
Review item 1 was a genuine deadlock, not only a stall. The async pipeline is disposed on the thread that releases it; on the Proactor that is the message-pump thread, running under the single-threaded
BrighterSynchronizationContext. Releasing a transientIAsyncDisposablemapper/transform ran throughServiceProviderLifetimeScope.DisposeScope's blockingpending.AsTask().GetAwaiter().GetResult(). A userDisposeAsyncthat awaits withoutConfigureAwait(false)posts its continuation back to the pump context — which the blocked pump thread can never drain. Hard deadlock.Fixed with a true async release path plus a defensive offload.
Part A — genuine async release through the async pipeline
TransformPipelineAsync<T>now implementsIAsyncDisposable.DisposeAsyncawaits async release of the transforms (TransformLifetimeScopeAsync.DisposeAsync) and the mapper (IAmAMessageMapperRegistryAsync.ReleaseAsync), so anIAsyncDisposabledisposal is awaited, not blocked on — the pump thread stays free to run any continuation posted back to its context. The synchronousDispose/finalizer remains the fallback, sharing theInterlockedrelease-once guard.ValueTask ReleaseAsynconIAmAMessageMapperFactoryAsync,IAmAMessageTransformerFactoryAsync, andIAmAMessageMapperRegistryAsync;ServiceProviderLifetimeScope.ReleaseAsyncshares its bookkeeping withReleasevia a newCollectScopesToRelease. No-op factories returndefault.Proactor.TranslateMessageandOutboxProducerMediator.MapMessageAsyncnowawait using. The synchronousCreateRequestFromMessagestays onusingby design (it is already sync-over-async).Part B — deadlock-safe synchronous fallback
ServiceProviderLifetimeScope.DisposeScopenow runs the disposal on a pool thread when aSynchronizationContextis current (continuations resume off-pump), and keeps the cheap inline path when none is (Reactor, finalizer, factory shutdown — unchanged, no per-release cost there). This downgrades any residual deadlock on the synchronous fallback to a bounded stall.Breaking change (extends the list above). The registry's
ReleaseAsyncadded earlier in this PR wasvoid; it is renamed toReleaseand a genuineValueTask ReleaseAsyncadded — so no additional break over released Brighter for the mapper registry/factory.IAmAMessageTransformerFactoryAsyncdoes gain a newValueTask ReleaseAsync(IAmAMessageTransformAsync). Onnetstandard2.0there are no default interface members, so a third-party implementation of these interfaces must add the method. In-tree blast radius is the four async factories, the registry, and the async transform test doubles; there are no other implementers.Test.
When_releasing_an_async_disposable_mapper_on_the_pump_context_it_should_not_deadlockdrives both disposal shapes on a realBrighterAsyncContextpump with anIAsyncDisposablemapper whoseDisposeAsyncyields withoutConfigureAwait(false). Proven RED by reverting Part B's offload — the synchronous-dispose case then deadlocks (15sJointimeout, exact diagnostic) while theawait usingcase still passes, showing A and B each prevent it independently. Hosted on a dedicated thread with a thread-pool floor so it does not flake under parallel load.Update — finalizers made non-throwing so a failed release cannot crash the process (review item 2)
Review item 2 flagged that an
IAsyncDisposable-only mapper/transform reaching~TransformPipeline()→Release→DisposeScope→ the synchronousscope.Dispose()throwsInvalidOperationExceptionon the finalizer thread (MS DI's sync scopeDisposethrows for an async-only service), and an exception escaping a finalizer terminates the process — an escalation from a throw to a hard teardown. Both pipeline finalizers released with no guard, so the same crash also applied to any userDispose/DisposeAsyncthat threw.Fix.
~TransformPipeline()and~TransformPipelineAsync()now wrap theirReleaseUnmanagedResources()call intry/catchand swallow, honouring the .NET invariant that a finalizer must never throw. ExplicitDispose/DisposeAsyncis unchanged and still surfaces the exception to the caller that owns the pipeline; only the finalizer fallback swallows. This closes the process-crash escalation; the broader "handleIAsyncDisposable-only instances on the synchronousDisposepath" change remains the tracked follow-up (see out of scope below).Test.
When_a_pipeline_finalizer_release_throws_it_should_not_escape(two facts, syncWrapPipelineand asyncWrapPipelineAsync) abandons an undisposed pipeline whose registryReleasethrows and forces finalization viaGC.Collect+WaitForPendingFinalizers. Proven RED by reverting the sync guard — the test host crashes withUnhandled exception … at TransformPipeline.Finalize()rather than failing an assertion, since an escaping finalizer exception tears down the process. Green with the guard; fullParamore.Brighter.Core.Tests873 passed / 7 skipped on net9.0 and net10.0; production build clean on all four TFMs includingnetstandard2.0.Update — round 5 review follow-ups (lifetime-scope finalizer guards, symmetrical async no-op release, reply-path deadlock test)
Answering the round-5 review (comment). The scope-churn note on the outbox hot path (removing the
HasPipeline/ResolveMapperInfoprobe allocation) is deferred with the reviewer's agreement — flagged out of scope below.Lifetime-scope finalizers made non-throwing. The prior round hardened the two pipeline finalizers; the same crash-on-finalizer hazard applied to the lifetime scopes they own.
~TransformLifetimeScope()and~TransformLifetimeScopeAsync()calledReleaseTrackedObjects()bare, so releasing anIAsyncDisposable-only transform through the synchronous path (MS DI's sync scopeDisposethrows for an async-only service) could let an exception escape the finalizer and terminate the process — and finalization order is non-deterministic, so an orphaned scope finalized before its owning pipeline could take that path. Both finalizers now wrap the release intry/catchand swallow, matching the pipeline-finalizer rationale. ExplicitDispose/DisposeAsyncstays unguarded and still surfaces to the owner. Test:When_a_transform_lifetime_scope_finalizer_release_throws_it_should_not_escape(two facts, sync + async), each proven RED by reverting its guard (host crash atTransformLifetimeScope[Async].Finalize()).Symmetrical async no-op release.
EmptyMessageTransformerFactoryAsync.ReleaseAsynccalled syncDispose(); it nowawaitsDisposeAsync()when the transform isIAsyncDisposable, else falls back to syncDispose(), matching the reader factory's shape. Behaviour-preserving (EmptyMessageTransformAsynconly implementsIDisposabletoday); covered by existing suites staying green.Reply-path sync-over-async deadlock test (test-only).
OutboxProducerMediator.CreateRequestFromMessagebuilds an async unwrap pipeline but disposes it via a synchronoususingand blocks the unwrap onGetAwaiter().GetResult(); on the Proactor pump its deadlock-safety rides on theDisposeScopeoffload. The round-3 deadlock test covered only the send path (Proactor.TranslateMessage); this adds the distinct reply-path shape, drivingCreateRequestFromMessageon a realBrighterAsyncContextpump with anIAsyncDisposablemapper. Proven RED by disabling theDisposeScopeoffload (30sJointimeout). The reply path releases the mapper twice — a throwawayHasPipeline<TRequest>probe mapper plus the pipeline's own — so the test asserts== 2, with a comment naming both sources so that whoever removes the probe allocation (the deferred hot-path change) knows why the expected count drops to 1. Both pump-context deadlock tests are placed in aDisableParallelizationxUnit collection: each blocks a dedicated pump thread on an offloaded disposal that needs a free pool thread, and letting them contend with the whole parallel suite starved that offload and tripped the guard spuriously. The sync-over-async unwrap itself is the accepted, pre-existing reply-path design and is left unchanged.Mapper released even when transform-scope disposal throws. A follow-up review note caught a residual instance of the very leak this PR closes. All three disposal paths (
TransformPipeline.ReleaseUnmanagedResources, andTransformPipelineAsync'sDisposeAsyncandReleaseUnmanagedResources) released the mapper after disposing the transform scope (InstanceScope), with the release-once guard (_released) already set at the top. So a throw from transform-scope disposal — the synchronous-scope-throw shape guarded against elsewhere — skipped the mapper release, and because the guard was set neither the finalizer nor a later dispose retried it, permanently orphaning the mapper's DI scope. Fix: release the mapper in afinallyin all three paths, so scope-disposal failure can no longer orphan it; the scope-disposal exception still surfaces to an explicitDispose/DisposeAsynccaller (only the finalizer swallows). Test:When_a_transform_scope_disposal_throws_the_mapper_is_still_released(three facts, one per disposal path), each proved RED by reverting thetry/finallyin the hunk it pins.Verified green on both net9.0 and net10.0:
Paramore.Brighter.Core.Tests(878 passed / 7 skipped) andParamore.Brighter.Extensions.Tests(125 passed, stable across repeated runs). Production build clean on all TFMs includingnetstandard2.0.Update — round 7 review follow-up (atomic first-scope publish; sync-fallback offload confirmed inherent)
Answering the latest review (comment).
First scoped resolution made race-safe (review item 1).
ServiceProviderLifetimeScope.GetOrCreateScopedpublished the shared scope with a non-atomic_scope ??= _serviceProvider.CreateScope(). Two threads racing the first scoped resolution eachCreateScope(); the loser's assignment is overwritten and never disposed, because neitherDisposenorReleasedrains a scope that is not the retained_scope— leaking one empty scope per lifetime scope under concurrent first-use of aScopedmapper/handler. Fix: publish viaInterlocked.CompareExchange(ref _scope, created, null)and have the loser dispose the scope it created. Test:When_two_threads_first_resolve_a_scoped_mapper_concurrently_it_should_not_leak_a_scoperendezvouses the first twoCreateScope()calls on a barrier so the race is forced (assertscreated == 2), then assertscreated == disposedafter factory disposal. Proven RED against the non-atomic assignment (disposed stayed at 1 while created was 2).Sync-fallback offload (review item 2) — confirmed inherent, no change. The note that the
DisposeScopesynchronous fallback (Task.Run(...).GetAwaiter().GetResult()when aSynchronizationContextis current) consumes a pool thread under load is correct, and it is the unavoidable cost of draining anIAsyncDisposable-only scope through a synchronous release contract: the choice on that path is block-with-offload or deadlock, and the offload is what avoids the deadlock. The primaryReleaseAsync/await usingpaths avoid it entirely, and the pump-deadlock tests add a thread-pool floor, so it is a bounded stall rather than a hang. Left as-is by design.Verified green on both net9.0 and net10.0:
Paramore.Brighter.Core.Tests(878 passed / 7 skipped) andParamore.Brighter.Extensions.Tests(126 passed — the new race test added). Production build clean on all TFMs includingnetstandard2.0.Update — round 8 review follow-up (second full-diff review: four fixes + handler-scope semantics pinned)
Answering the second full-diff review (part 1, part 2). Five new/decision items: four fixed, one investigated and confirmed correct (then pinned). Each fix keeps this PR's discipline — a RED test proven by reverting the pinned production hunk, then green on net9.0 + net10.0.
Ambiguous
Releaseoverload for a dual-interface mapper (part 1, item 1 🔴).MessageMapperRegistryexposed bothRelease(IAmAMessageMapper)andRelease(IAmAMessageMapperAsync)publicly. Any mapper supporting both Reactor and Proactor implements both markers (e.g.JsonMessageMapper<T>), soregistry.Release(mapper)on the concrete type failed to compile with CS0121. In-tree callers dodged it via interface-typed fields, but the concrete registry is public and the PR now asks callers to "Release what you Get". Fix: implement the asyncReleaseexplicitly (void IAmAMessageMapperRegistryAsync.Release), leaving only the synchronousReleaseon the concrete surface; async release stays available via the interface orReleaseAsync. RED-proven: the added test did not compile before the change.GetOrCreateScopedcreate-vs-Disposerace (part 1, item 2 🟠). The symmetric case to round 7: a thread can pass the disposed-guard, create its first scoped scope, and publish it viaCompareExchangeafterDisposehas already drained_scope(still null), orphaning it. Fix mirrorsGetTransient: after winning the publish CAS, re-check_disposedand reclaim the published scope with a second CAS;Disposenow claims_scopewithInterlocked.Exchangeso exactly one side disposes it (also collapsing the check-then-use double-read flagged as a nit); the scopedLazyfactory throwsObjectDisposedExceptionrather than dereferencing a nulled_scope. RED-proven: the published scope'sDisposedCountstayed at 0.Transform lifetime scope partial-release / retry double-dispose (part 1, item 3 🟠).
TransformLifetimeScope[Async].ReleaseTrackedObjects[Async]released with aforeachover the unmodified list, so a throw on one transform skipped the rest, and the finalizer retry re-released the ones that had already succeeded (a double dispose for a user factory whoseReleasedisposes). Fix: drain-as-you-go (remove each transform before releasing it) in all three loops. RED-proven: the retry released the first transform twice and the last never.Release bookkeeping abandoned on
DisposeScopethrow (part 2, item 5 🟡).CollectScopesToReleasewas a lazy iterator, so aDisposeScopethrow in the caller'sforeachunwound it before the key-removal step, leaving the now-empty stack keyed by the instance — the same instance-keyed retention this PR closes, resurfacing on the failure path. Fix: materialise the bookkeeping eagerly into a list before any scope is disposed. RED-proven: the instance stayed tracked after a throwing release.Handler-scope semantic change (part 2, still-open item) — investigated, confirmed correct, pinned. The reviewer flagged that changing
GetTransientfrom a shared_scopeto a per-resolution scope alters the default (Transient) handler path: a scoped dependency injected into a handler and its middleware used to be one shared instance and is now distinct per handler. Investigation confirms this is the correct lifetime split, not a regression. The leak lived only in the mapper/transformer factories, whoseServiceProviderLifetimeScopeis app-lifetime (so transients accumulated); the handler factory's scope is per-pipeline and disposed wholesale at chain end, so it never leaked.Transientmeans each resolution gets its own independent scope;Scopedmeans the scope is the call chain, shared across handlers — andGetOrCreateScopedstill shares_scope, so theScopedunit-of-work / transaction-provider pattern (#3754) is preserved. No production change; addedHandlerLifetimeCallChainScopeTestspinning both semantics across a two-handler chain (theTransient-isolation fact would fail onmaster, documenting the intended default-path behavior).Not changed: the
DisposeScopecontext-suppression optimization (part 2, item 4 🟡) — a throughput improvement rather than a leak fix, deferred with the other hot-path follow-ups below.Verified green on both net9.0 and net10.0:
Paramore.Brighter.Core.Tests(881 passed / 7 skipped) andParamore.Brighter.Extensions.Tests(130 passed). Production build clean on all TFMs includingnetstandard2.0.Deliberately out of scope
Adjacent defects found while confirming the diagnosis, each recorded in
bugfixes/0007-mapper-factory-release-disposable-mapper/bugfix.md:IAsyncDisposable-only instances currently throw on the synchronous scope-disposal path atServiceProviderLifetimeScope.cs. The guard added in Part A isinstance is IDisposable, so anIAsyncDisposable-only instance falls through to a synchronous scope disposal, and MS DI'sServiceProviderEngineScope.Dispose()throws when it holds such a service. This affects transforms in the current code, not just mappers. The finalizer-thread process-crash half of this is now fixed (review item 2 above); handling async-only instances correctly on the synchronousDisposepath — rather than swallowing — remains to be done and deserves its own fix.MessageMapperRegistry.ResolveMapperInfo'sIsDefaultflag goes stale after the firstGet, affectingTransformPipelineBuilder.DescribeTransforms.ServiceProviderHandlerFactory.Releasedisposes the handler directly and then disposes the lifetime scope, double-disposing transients.ResolveMapperInforefactor that would remove theHasPipelineprobe allocation entirely — needs its own behavioural analysis for the three divergences above.Fixes #4252
🤖 Generated with Claude Code