Skip to content

fix: dispose factory outputs hidden behind a non-disposable return type#33

Merged
vbreuss merged 4 commits into
mainfrom
feat/factory-disposal-tracking
Jun 29, 2026
Merged

fix: dispose factory outputs hidden behind a non-disposable return type#33
vbreuss merged 4 commits into
mainfrom
feat/factory-disposal-tracking

Conversation

@vbreuss

@vbreuss vbreuss commented Jun 29, 2026

Copy link
Copy Markdown
Member

A factory registration computes disposability from the producer method's declared return type, so a factory declared static IFoo Create() that builds a DisposableFooImpl : IFoo, IDisposable was flagged non-disposable and never tracked, leaking the instance when the owning scope or root tore down. Constructed types use the concrete info.Symbol (always truthful) and a pre-built Instance is intentionally not owned, so the lie is possible only for factory production.

For a factory whose declared return type is not itself IDisposable yet could produce one at runtime (an interface, a type parameter, or a non-sealed class), the emitted resolver now tracks the realized instance behind a runtime is IDisposable check instead of trusting the static flag. This retains only genuinely-disposable outputs (no retention cost otherwise) and closes the gap across the synchronous fresh resolver, the async fresh/caching registration, and the caching singleton/scoped disposal add. A sealed return type that is not IDisposable cannot hide one, so it emits no check (and a runtime is IDisposable against it would not even compile). The existing __gate lock and __disposed re-check semantics are preserved for factory outputs, so one built during a concurrent dispose is still disposed rather than leaked.

The static InstanceModel.IsDisposable semantics are unchanged: the AWT118 root-accumulation analyzer and strict-lifetime withholding keep their compile-time behavior. AWT118 still under-fires for factory-hidden disposables (the same declared-type gap, but compile-time) - that is left to a separate body-analysis linter PR. IAsyncDisposable remains out of scope, matching the current IDisposable-only drain.

A factory registration computes disposability from the producer method's declared return type, so a factory declared `static IFoo Create()` that builds a `DisposableFooImpl : IFoo, IDisposable` was flagged non-disposable and never tracked, leaking the instance when the owning scope or root tore down. Constructed types use the concrete `info.Symbol` (always truthful) and a pre-built `Instance` is intentionally not owned, so the lie is possible only for factory production.

For a factory whose declared return type is not itself `IDisposable` yet could produce one at runtime (an interface, a type parameter, or a non-sealed class), the emitted resolver now tracks the realized instance behind a runtime `is IDisposable` check instead of trusting the static flag. This retains only genuinely-disposable outputs (no retention cost otherwise) and closes the gap across the synchronous fresh resolver, the async fresh/caching registration, and the caching singleton/scoped disposal add. A sealed return type that is not `IDisposable` cannot hide one, so it emits no check (and a runtime `is IDisposable` against it would not even compile). The existing `__gate` lock and `__disposed` re-check semantics are preserved for factory outputs, so one built during a concurrent dispose is still disposed rather than leaked.

The static `InstanceModel.IsDisposable` semantics are unchanged: the AWT118 root-accumulation analyzer and strict-lifetime withholding keep their compile-time behavior. AWT118 still under-fires for factory-hidden disposables (the same declared-type gap, but compile-time) - that is left to a separate body-analysis linter PR. `IAsyncDisposable` remains out of scope, matching the current `IDisposable`-only drain.
@vbreuss vbreuss self-assigned this Jun 29, 2026
@vbreuss vbreuss added the bug Something isn't working label Jun 29, 2026
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Test Results

   18 files  ± 0     18 suites  ±0   1m 1s ⏱️ +2s
  213 tests + 8    212 ✅ + 8  1 💤 ±0  0 ❌ ±0 
1 091 runs  +48  1 090 ✅ +48  1 💤 ±0  0 ❌ ±0 

Results for commit 959470b. ± Comparison against base commit 448c1d0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Platinum 8370C CPU 2.80GHz (Max: 2.79GHz), 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 7.021 ns 0.1950 ns 0.1728 ns 0.61 - NA
Awaiten 8 11.522 ns 0.0380 ns 0.0336 ns 1.00 - NA
MsDI 8 7.509 ns 0.0156 ns 0.0122 ns 0.65 - NA
Autofac 8 141.659 ns 3.2337 ns 3.0248 ns 12.29 656 B NA
Jab 8 3.438 ns 0.0081 ns 0.0072 ns 0.30 - NA
PureDI 8 4.212 ns 0.0131 ns 0.0123 ns 0.37 - NA
DryIoc 8 7.198 ns 0.0096 ns 0.0075 ns 0.62 - NA
SimpleInjector 8 9.829 ns 0.0262 ns 0.0204 ns 0.85 - NA
baseline* 256 168.130 ns 3.5820 ns 3.3506 ns 0.78 - NA
Awaiten 256 215.755 ns 0.2778 ns 0.2320 ns 1.00 - NA
MsDI 256 7.535 ns 0.0102 ns 0.0090 ns 0.03 - NA
Autofac 256 141.756 ns 1.9778 ns 1.8501 ns 0.66 656 B NA
Jab 256 77.509 ns 0.0698 ns 0.0653 ns 0.36 - NA
PureDI 256 8.271 ns 0.0198 ns 0.0185 ns 0.04 - NA
DryIoc 256 7.258 ns 0.0119 ns 0.0106 ns 0.03 - NA
SimpleInjector 256 13.435 ns 0.0747 ns 0.0698 ns 0.06 - NA
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 257.5 ns 1.06 ns 0.99 ns 0.91 560 B 1.00
Awaiten 281.9 ns 1.33 ns 1.24 ns 1.00 560 B 1.00
MsDI 701.9 ns 3.23 ns 2.86 ns 2.49 1104 B 1.97
Autofac 7,651.0 ns 15.82 ns 14.02 ns 27.14 13696 B 24.46
Jab 224.1 ns 0.38 ns 0.35 ns 0.79 432 B 0.77
DryIoc 438.0 ns 2.05 ns 1.92 ns 1.55 944 B 1.69
SimpleInjector 773.0 ns 1.67 ns 1.56 ns 2.74 1096 B 1.96
PureDI 199.9 ns 1.26 ns 1.18 ns 0.71 632 B 1.13
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 17.658 ns 0.5781 ns 0.5408 ns 1.07 136 B 1.00
Awaiten 8 16.49 ns 0.345 ns 0.323 ns 1.00 136 B 1.00
MsDI 8 1,517.02 ns 25.035 ns 23.418 ns 92.04 5688 B 41.82
Autofac 8 28,772.08 ns 183.958 ns 172.075 ns 1,745.66 33098 B 243.37
Jab 8 10.76 ns 0.620 ns 0.580 ns 0.65 96 B 0.71
PureDI 8 15.04 ns 0.205 ns 0.181 ns 0.91 128 B 0.94
DryIoc 8 702.49 ns 12.964 ns 12.127 ns 42.62 1472 B 10.82
SimpleInjector 8 11,310.45 ns 58.279 ns 51.663 ns 686.23 24760 B 182.06
baseline* 256 79.822 ns 0.7048 ns 0.6248 ns 0.93 2120 B 1.00
Awaiten 256 85.64 ns 0.478 ns 0.447 ns 1.00 2120 B 1.00
MsDI 256 13,919.90 ns 34.823 ns 32.573 ns 162.53 61016 B 28.78
Autofac 256 678,016.30 ns 1,509.390 ns 1,260.409 ns 7,916.82 739637 B 348.89
Jab 256 70.36 ns 0.516 ns 0.483 ns 0.82 2080 B 0.98
PureDI 256 78.71 ns 1.601 ns 1.420 ns 0.92 2112 B 1.00
DryIoc 256 43,537.70 ns 840.858 ns 745.399 ns 508.37 80128 B 37.80
SimpleInjector 256 338,098.84 ns 2,824.230 ns 2,358.360 ns 3,947.79 573171 B 270.36

baseline* rows show the corresponding Awaiten benchmark from the most recent successful main branch build with results, for regression comparison.

@github-actions

Copy link
Copy Markdown

👽 Mutation Results

Mutation testing badge

Awaiten

Details
File Score Killed Survived Timeout No Coverage Ignored Compile Errors Runtime Errors Total Detected Total Undetected Total Mutants

The final mutation score is NaN%

Coverage Thresholds: high:80 low:60 break:0

vbreuss added 2 commits June 29, 2026 13:45
…ly-once disposal

Add a runtime test for a factory whose declared return type extends IAsyncInitializable but hides a concrete IDisposable behind it, exercising the async creator path (EmitAsyncDisposableRegistration with the runtime is-IDisposable check) that the synchronous hidden-disposable tests do not reach. It asserts the instance is both initialized and disposed exactly once.

Convert the HiddenDisposable and PlainDisposable test fixtures from a bool Disposed toggle to an instance-level DisposeCount, and tighten the singleton/scoped/transient/no-regression assertions to expect exactly one disposal. A double-add to the disposal list would now fail rather than pass silently.
The CachingResolver constructor reached 8 parameters after the runtime disposal check was added (modifiers, type, method, field, construction, disposable, runtimeCheck, comment), tripping the maintainability limit. The two disposal booleans carry an invariant - a runtime check is only ever set when the static disposable flag is not - so they collapse cleanly into a single DisposalTracking value (None, Static, Runtime), bringing CachingResolver to 7 parameters and FreshResolver to 6. This also removes the repeated 'IsDisposable || TracksDisposalAtRuntime' computation at the five resolver call sites and the parallel pair of bool checks in the emitter bodies. The generated output is unchanged (the source-generator snapshot tests pass verbatim).
@vbreuss vbreuss force-pushed the feat/factory-disposal-tracking branch from 31e39af to a3e67df Compare June 29, 2026 12:01
… as a tuple switch

Convert the eleven member-level line comments in Emitter (IsWithheld, IsFuncWithheld, the DisposalTracking enum, DisposalOf, EmitsSync, the four guidance-message helpers, Withheld, and OwnedBare) to /// XML-doc summaries, matching the file's existing convention. Generic types that appear in the prose are wrapped in <c> with their angle brackets escaped (Owned&lt;T&gt;, Func&lt;…, Owned&lt;T&gt;&gt;) so the comments remain well-formed XML; in-body comments are left as line comments since they document statements rather than members.

Rewrite DisposalOf's body as a tuple switch over (RuntimeDisposalCheck, IsDisposable) instead of nested ternaries. The mapping is unchanged - (true, _) => Runtime, (_, true) => Static, (_, _) => None - so the generated output is identical and the source-generator snapshot tests pass verbatim.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss vbreuss merged commit d00d448 into main Jun 29, 2026
14 checks passed
@vbreuss vbreuss deleted the feat/factory-disposal-tracking branch June 29, 2026 13:12
github-actions Bot added a commit that referenced this pull request Jun 29, 2026
…ind a non-disposable return type (#33) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jun 29, 2026
…ind a non-disposable return type (#33) by Valentin Breuß
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant