A running, curated collection of real C# idioms spotted while pointing the
cross-tool oracle (oracle.md) at open-source repos. Two reasons
to keep it:
- They're worth learning from. Mature libraries (Polly, Dapper, …) encode battle-tested ways to own, share, pool, and scope disposables and lifetimes.
- They're Own.NET's precision frontier. The recurring theme below is code that is correct but that naive leak detectors misread — exactly the cases where staying silent is the right verdict. Every entry notes how the pattern interacts with leak/lifetime analysis (and where Infer#/CodeQL over-report).
Each entry: the idiom, why it exists, a code sketch, and the analyzer angle. Sources are pinned to the file we actually read; line numbers drift, so treat them as "around here". New finds get appended — this is a notebook, not a spec.
Seen in: Polly src/Polly/Bulkhead/BulkheadSemaphoreFactory.cs →
BulkheadPolicy.cs; Dapper Dapper/SqlMapper.cs (DbWrappedReader.Create).
A factory creates a disposable and returns it; the caller becomes the owner and is responsible for disposal. The disposable is intentionally not disposed at the creation site.
// factory: creates, hands ownership out
internal static (SemaphoreSlim Parallel, SemaphoreSlim Queue)
CreateBulkheadSemaphores(int maxParallelization, int maxQueueingActions) { … }
// holder: stores in fields, disposes in its own Dispose()
private readonly SemaphoreSlim _maxParallelizationSemaphore;
private readonly SemaphoreSlim _maxQueuedActionsSemaphore;
public void Dispose()
{
_maxParallelizationSemaphore.Dispose();
_maxQueuedActionsSemaphore.Dispose();
}Why: separates construction (sizing/validation logic) from ownership (the policy lives long, the factory doesn't). Classic "owned handle".
Analyzer angle: "created but not disposed here" ≠ leak — disposal moved
with the reference. CodeQL's cs/local-not-disposed flags the factory line (it
can't follow tuple-return ownership) → false positive. Own.NET treats
return/escape as ownership transfer and stays silent. The same shape is Dapper's
DbWrappedReader (returned from ExecuteReader*, the caller disposes), where
Infer# over-reports a PULSE_RESOURCE_LEAK. Rule of thumb: follow the
reference — whoever ends up holding it owns the dispose.
Seen in: Polly src/Polly.Extensions/Registry/ConfigureBuilderContextExtensions.cs.
A disposable outlives the method that creates it, so disposal is wired to a
future event instead of a local using.
#pragma warning disable CA2000 // disposal deferred to pipeline teardown
var source = new CancellationTokenSource();
context.AddReloadToken(source.Token);
context.OnPipelineDisposed(() => source.Dispose()); // disposed later, on teardownWhy: the token must stay alive for the lifetime of the pipeline, not the
configuration call. You can't using it — you hang its disposal off the owning
object's lifecycle.
Analyzer angle: disposal exists, just not lexically — it's inside a lambda
registered with a lifecycle hook. Flow-insensitive "is there a Dispose on every
path?" checks miss it → CodeQL false positive. Note the deliberate
#pragma warning disable CA2000: the authors know and suppress the analyzer.
A suppressed analyzer warning next to a callback registration is a strong
"this is intentional deferred-dispose" signal.
Seen in: Polly src/Polly.Core/Timeout/TimeoutResilienceStrategy.cs
(_cancellationTokenSourcePool.Get(timeout) / .Return(cts)).
Hot-path disposables (here CancellationTokenSource) are rented from a pool
and returned, not newed-and-disposed each call.
var cts = _cancellationTokenSourcePool.Get(timeout);
try
{
// … use cts.Token …
}
finally
{
_cancellationTokenSourcePool.Return(cts); // recycled, not Dispose()d
}Why: CancellationTokenSource allocates timer/registration state; on a
per-call resilience path that churn matters. A pool amortizes it. (Polly's
CancellationTokenSourcePool is itself worth reading — it resets and re-arms
CTSs safely for reuse.)
Analyzer angle: the object is never "leaked" — it's recycled. Return is the
moral equivalent of dispose. A detector that only recognises Dispose()/using
sees an undisposed CTS and may flag the throw-path (CodeQL
cs/dispose-not-called-on-throw) — but the worst case is "object not returned to
the pool", which the GC reclaims anyway. Pool Get/Return is an
ownership-protocol the analyzer must learn, or it over-reports.
Seen in: Polly src/Polly/Utilities/TimedLock.cs (+ its callers in
CircuitBreaker/*).
A struct that implements IDisposable to give a zero-allocation, RAII-style
lock with a timeout (so a stuck lock throws instead of hanging forever).
using (TimedLock.Lock(_syncObject)) // acquires Monitor with a timeout
{
// critical section
} // Dispose() releases the MonitorWhy: lock(x){} can deadlock forever; TimedLock bounds the wait and can be
told to detect/raise on timeout. Being a struct means no heap allocation per
critical section — important on hot paths.
Analyzer angle: the disposable is a value type, disposed by the using.
Infer#'s Pulse engine reports each of these as PULSE_RESOURCE_LEAK "allocated
indirectly via TimedLock.Lock … not closed" — a systematic over-report on the
struct-using pattern (11 of them on Polly; the 12th Infer# resource-leak report is a
strategy-ctor allocation, see entry 7). Own.NET's silence is correct.
A value-type IDisposable used in a using is disposed deterministically;
don't treat the .Lock() factory call as an escaping allocation.
Seen in: Polly src/Polly/Bulkhead/* (the pair from entry 1).
The Bulkhead resilience pattern (isolate a dependency so its slowness can't drain
a shared pool) is implemented with two SemaphoreSlims, not one:
maxParallelization -> how many calls run at once (the bulkhead "compartment")
maxQueueingActions -> how many may wait for a slot (cap the queue itself)
Why: one semaphore bounds concurrency, but an unbounded wait queue is just a
new way to exhaust memory/threads. The second semaphore (capacity
maxQueueingActions + maxParallelization) bounds the queue and fails fast
(BulkheadRejectedException) past it. Isolation and backpressure.
Analyzer angle: not a leak case — a design pattern. Filed here because it's the canonical "bound every shared resource, including the queue you added to bound the first one" lesson. (See the chat thread for the long-form explanation.)
Seen in: Dapper Dapper/SqlMapper.IDataReader.cs / WrappedReader.cs
(WrappedBasicReader).
A wrapper holds someone else's disposable and forwards Dispose to it —
because the holder of the wrapper, not the wrapper's creator, decides lifetime.
public void Dispose() => _reader.Dispose(); // forward to the wrapped readerWhy: Dapper hands a reader back to the caller; the wrapper exists precisely so the caller disposes it (and through it, the underlying reader). Disposing it internally would close the caller's reader out from under them.
Analyzer angle: the wrapper "allocates" a reader it never disposes locally — Infer# reads that as a leak. But it's the ownership-transfer case again (entry 1) viewed through an adapter. When a type wraps a disposable it doesn't own, the correct behaviour is to forward disposal, not perform it — and an analyzer must not count the wrapped allocation against the wrapper.
Seen in: the oracle re-run of App-vNext/Polly (src/, product code only) at
commit 976983f after P-005 D5.4 step 2 shipped — the Roslyn extractor now emits
alias_join for a verified constructor adopt (var w = new W(x)). This entry is the
audit record the maintenance rule below requires: it pins no new idiom (entries 1–4
already cover Polly's), but documents that the new emission stayed precise and accounts
for every oracle-only finding.
Buckets (unchanged from the pre-step-2 run): Own.NET leak findings 0; Agree 0;
Own.NET-only 0; oracle-only 16. The new alias_join path produced zero
own-only findings on real code — the precision floor held end-to-end.
The 16 oracle-only, each accounted for (all oracle FP or by-design):
| # | site | tool | disposition → entry |
|---|---|---|---|
| 11 | TimedLock.Lock(...) across CircuitBreaker/* (AdvancedCircuitController ×3, CircuitStateController ×4, ConsecutiveCountCircuitController ×3) + TimedLock.cs:32 |
Infer# PULSE_RESOURCE_LEAK |
struct-using over-report → entry 4 |
| 1 | CircuitBreakerResiliencePipelineBuilderExtensions.cs:75 |
Infer# PULSE_RESOURCE_LEAK |
strategy ctor allocation, disposed by the pipeline → entry 1/2 |
| 2 | BulkheadSemaphoreFactory.cs:8,11 |
CodeQL cs/local-not-disposed |
factory returns, adopted into owning fields → entry 1 |
| 1 | ConfigureBuilderContextExtensions.cs:40 |
CodeQL cs/local-not-disposed |
CTS disposed in an OnPipelineDisposed callback (#pragma CA2000) → entry 2 |
| 1 | TimeoutResilienceStrategy.cs:67 |
CodeQL cs/dispose-not-called-on-throw |
pooled CTS Get/Return (catch-all around the throwing await) → entry 3 |
Analyzer angle / the recall boundary this run pins. BulkheadSemaphoreFactory
(entry 1) is the shape people expect step 2 to "fix", but it is not the construction-
site ctor-adopt step 2 models — it is factory-returns-fresh + caller-stores-in-an-
owning-field (the deferred T4b field-store-to-this shape). So step 2 correctly leaves
it untouched, and CodeQL's two FPs there stay oracle-only (we must not flag them —
the semaphores are owned by BulkheadPolicy). Both CodeQL CTS findings are also
non-leaks (callback-deferred dispose; pooled return), so there is nothing on Polly we
could newly catch without manufacturing a false positive. own-only-0 here is principled,
not luck — and the next recall lever for this family is the deferred field-store adopt, not
anything in step 2's scope. (Confidence: high — dispositions verified against the pinned
Polly source, not just the SARIF excerpts.)
Seen in: Newtonsoft.Json Src/Newtonsoft.Json/JsonSerializer.cs:717
(ApplySerializerSettings, commit 4f73e74).
A factory configures a new object and wires an event on it before handing it back;
the subscription is never -='d, but it doesn't need to be.
public static JsonSerializer Create(JsonSerializerSettings? settings)
{
JsonSerializer serializer = new JsonSerializer();
if (settings != null) ApplySerializerSettings(serializer, settings);
return serializer; // publisher escapes to the caller
}
private static void ApplySerializerSettings(JsonSerializer serializer, JsonSerializerSettings settings)
{
if (settings.Error != null)
serializer.Error += settings.Error; // <- flagged: "+= but never -="
}Why: the publisher (serializer) is the returned object. The handler lives
exactly as long as the serializer the caller now holds; when that is collected, the
subscription dies with it. No -= is needed — there is no longer-lived source
retaining a shorter-lived target (the dangerous direction our OWN001/OWN014
subscription leak targets). It's the publisher itself that is short/caller-scoped.
Analyzer angle — this is an own-only over-report (the first in this notebook
where Own.NET, not the oracle, is too strict). CodeQL/Infer# have no
event-subscription-leak query, so they stay silent (correctly). Own.NET fires
because, inside ApplySerializerSettings, serializer is an opaque parameter
— SubscriptionSourceKind can't see that the caller news it and returns it, so
it conservatively tiers it injected and emits a warning (not a hard error;
P-004 severity tiering already hedges unknown-lifetime publishers). So the default
error posture is unaffected — it only surfaces under --severity warning (the
oracle's setting). Subscription-leak analysis is the dual of ownership transfer:
a += on a publisher that escapes by return is as bounded as a returned
IDisposable — the fix is the same "follow the reference" escape rule, but
interprocedural (the construct-and-return is in the caller), which is the hard part.
The honest interim posture — advisory warning, never a hard error — is already in
place.
Status: FIXED (#146). The extractor now runs a compilation-wide provenance
pass: when a += publisher is a parameter of a private/internal method and
every visible caller passes a freshly-constructed local that escapes only into
the call / its own return (and the callee never lets the param escape), the
subscription is stamped source_provenance: "returned_fresh" and the bridge
drops it (bounded, silent). Any unprovable step — public candidate, method-group
reference, mixed callers, field-stored local, param→param forwarding, or a
local-function closure capture on either side (callee-side ProvLocalFuncFactory,
caller-side ProvCallerLocalFuncFactory — a stored local function escapes exactly
like a lambda) — denies the proof and the honest warning stands. Pinned by
frontend/roslyn/samples/ReturnedPublisherSample.cs (CI wpf-extractor) and
the source_provenance checks in tests/test_ownir.py; spec'd in
spec/OwnIR.md §4.
Seen in: Newtonsoft.Json Src/Newtonsoft.Json/Serialization/TraceJsonReader.cs:37,38
and TraceJsonWriter.cs:39 (commit 4f73e74).
A type owns a disposable field but never disposes it — and that is fine, because the
field's Dispose() frees nothing real.
internal class TraceJsonReader : JsonReader // JsonReader : IDisposable, no Dispose override
{
private readonly StringWriter _sw; // StringBuilder-backed: Dispose() is a no-op
private readonly JsonTextWriter _textWriter; // wraps _sw; at most returns a pooled char buffer
public TraceJsonReader(JsonReader inner)
{
_sw = new StringWriter(CultureInfo.InvariantCulture);
_textWriter = new JsonTextWriter(_sw);
}
}Why: these are short-lived, per-call diagnostic helpers (created only when a
TraceWriter is attached at Verbose) that capture the JSON text into an in-memory
StringWriter. A StringWriter wraps a StringBuilder — no OS handle, no
unmanaged state; Dispose() just flips a closed flag. Not disposing it leaks
nothing the GC won't reclaim.
Analyzer angle — also an own-only over-report. CodeQL/Infer# stay silent
(they model real resource handles; an undisposed StringWriter isn't one). Own.NET's
owning-field detector flags every IDisposable field equally. The lever already
exists: IsOwnedDisposableType exempts IsDisposeOptional types (Task/ValueTask/
DataTable/DataSet/DataView — "Dispose is a no-op / only a lazy wait handle"). The
fix for the two StringWriter fields is to add System.IO.StringWriter/
StringReader to that exemption — a one-liner mirroring the existing set. The
third field (JsonTextWriter, a third-party writer over the StringWriter, which on
Dispose returns a pooled char buffer) is not generically exemptable by name and
stays a low-value residual. Rule of thumb: "owns an IDisposable field" is only a
leak when the field owns a real resource — a string/in-memory writer is not one,
and the IsDisposeOptional allowlist is where that knowledge belongs.
Recorded as a deliberate non-goal of the issue #199 static-lambda precision fix
(see subscription-leaks-and-profiles.md).
static event EventHandler? Pinged; // process-lived static event
sealed class Widget {
public Widget() => Pinged += (_, _) => Log("tick"); // NON-capturing lambda
}
// new Widget(); … many times ⇒ Pinged's invocation list grows without boundA non-capturing += retains no subscriber instance, so it is not a region
escape (OWN014) and is now correctly silent. But it still appends a delegate to
the event's invocation list, and a hot path that re-subscribes (a += in the
constructor of a short-lived / transient object created repeatedly) grows that list
unbounded — a genuine memory-growth bug of a different shape: unbounded list
length, not a pinned instance.
Why the region model doesn't (and shouldn't) cover it. OWN014 answers "is this
subscriber promoted to the source's lifetime?" — a per-instance lifetime question.
Invocation-list growth is a call-count question (how often does this += run?),
which needs a different signal: a "subscribe in a hot/repeated constructor without a
matching -=" heuristic. It was never covered for the static-method exemption
either (X.Event += StaticM in a loop grows the list identically), so silencing the
non-capturing lambda is not a regression relative to that baseline — both are
outside the region model by construction.
Analyzer angle: if pursued, gate on repetition (a += reachable from a
type instantiated in a loop / registered transient) + no -=, and tier it as a
warning — never fold it into OWN014, whose subscriber-pinning premise it does not share.
Seen in: MahApps.Metro src/MahApps.Metro/Actions/CommandTriggerAction.cs:102-117;
MaterialDesignInXamlToolkit src/MaterialDesignThemes.Wpf/SmartHint.cs:189-209;
AvalonEdit ICSharpCode.AvalonEdit/Editing/AbstractMargin.cs:92-101,
LineNumberMargin.cs:105-118, Folding/FoldingMargin.cs. Found by the issue #201
oracle sweep — the single most-corroborated pattern in that run (3 repos, 6+ sites).
A DependencyProperty-changed callback (or a plain virtual On<X>Changed(old, new)
override) manages a subscription's lifetime across property changes: detach from
the old value, attach to the new one, same handler.
private static void OnCommandChanged(CommandTriggerAction action, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is ICommand oldCommand)
oldCommand.CanExecuteChanged -= action.OnCommandCanExecuteChanged; // unsub OLD
if (e.NewValue is ICommand newCommand)
newCommand.CanExecuteChanged += action.OnCommandCanExecuteChanged; // sub NEW <- flagged
}Why: the subscriber (action) needs to track whichever value the property
currently holds — as the property changes, the previous value's subscription
must go and the new value's must start. There is no leak: at most one
subscription is ever live, and it's cleaned up the moment it's superseded.
Analyzer angle: Own.NET pairs a += with a -= per source variable
(newCommand has no -= written against it) — it doesn't see that the oldCommand
unsubscribe a few lines above is the very same rotation slot, just against last
call's value. Rule of thumb: when a +=/-= pair straddles the old/new halves of
a property-changed callback (or an equivalent On<X>Changed(old, new) override),
treat them as one paired lifecycle, not two independent, unpaired subscriptions.
Shipped (issue #218). The extractor now recognises the rotation and stamps the
+= on the new half as released — so the core sees a balanced acquire/release and
stays silent (no new OwnIR fact, no OWNIR_VERSION bump; it reuses the existing
released field). A += on <newRecv>.<Event> with handler H is paired when the
same method holds a -= on <oldRecv>.<Event> with the same H, and (oldRecv, newRecv) are the old/new halves of one change: either bound from a
DependencyPropertyChangedEventArgs e.OldValue/e.NewValue (directly, or via
e.OldValue is T old / (T)e.OldValue / var old = e.OldValue), or two same-type
parameters of the enclosing method with old positioned before new (the
OnXChanged(T old, T new) override — matched by the param-pair shape, not the method
name). It deliberately does not widen: a += with a different handler than the
-=, a pair on two class fields, or on differently-typed params are not a
rotation and stay flagged. Pinned by frontend/roslyn/samples/DpRotationSample.cs
(CommandTriggerAction/AbstractMargin silent; MismatchedHandlerRotation/
UnrelatedPairRotation/TwoFieldsRotation flagged) in the wpf-extractor CI job.
Seen in: ShareX ShareX.HelpersLib/Forms/ImageViewer.cs (pbPreview,
lblStatus), Colors/ColorPicker.cs (colorBox, colorSlider),
Forms/InputBox.cs (btnOK, btnCancel, txtInputText), and ~20 more sites.
Found by the issue #201 oracle sweep.
A WinForms Control-derived field is added to this.Controls (directly or via
Controls.AddRange) inside InitializeComponent(); the class never calls
.Dispose() on the field itself.
private System.Windows.Forms.Label lblStatus;
// InitializeComponent():
lblStatus = new Label();
this.Controls.Add(this.lblStatus);
// Dispose(bool disposing):
protected override void Dispose(bool disposing) {
if (disposing) components?.Dispose();
base.Dispose(disposing); // <- disposes every child in Controls, incl. lblStatus
}Why: System.Windows.Forms.Control.Dispose(bool) recursively disposes every
child control in its own Controls collection when disposing is true — that's
the framework's own designer-generated contract, not something the class needs to
do by hand. The same holds for ToolStrip/ContextMenuStrip's
ToolStripItemCollection: disposing the strip disposes its items.
Analyzer angle: Own.NET's field-disposal scan looks for an explicit
field.Dispose() (or a recognised sink) and doesn't know about this WinForms-
specific transitive channel, so it flags every child-control field as "never
disposed" even though base.Dispose(disposing) already covers it. Rule of
thumb: a Control field added to a Controls/Items collection that itself
eventually reaches a disposed root is disposed by the framework, not by the
field owner directly — don't count it against the owner.
Seen in: ShareX ShareX.HelpersLib/Forms/TrayForm.cs:41 (TrayIcon = new NotifyIcon(components)). Found by the issue #201 oracle sweep.
protected NotifyIcon TrayIcon;
TrayIcon = new NotifyIcon(components); // registers itself with the IContainer
protected override void Dispose(bool disposing) {
if (disposing) components.Dispose(); // disposes everything registered above
base.Dispose(disposing);
}Why: several BCL/WinForms components (NotifyIcon, Timer, …) have a
constructor overload taking an IContainer — passing the designer's components
field registers the new instance with that container, so components.Dispose()
(the designer-generated one-liner every WinForms Form/UserControl already has)
disposes it. Same idea, different call shape as entry 12.
Analyzer angle: the extractor's field-disposal scan doesn't recognise
new T(components) or an explicit components.Add(x) as wiring x into the
IContainer disposal chain, so it flags the field as leaked. Rule of thumb:
construction through (or explicit registration into) the designer's IContainer
is a release, exactly like Controls.Add — the container's Dispose() is the
real sink.
Shipped (issue #219 — covers both entries 12 and 13). The field-disposal scan
now credits both channels, but only when the class reaches the framework disposal
root — a designer Dispose(bool) that calls base.Dispose(disposing)
(ClassReachesDisposalRoot). A class with no such Dispose (the ShareX
HistoryItemManager true-positive) is not rooted, so its owned controls stay
flagged. (a) A Control/ToolStripItem field added to this object's own
Controls/Items collection (this.Controls.Add/AddRange, or this.<field>. Items.Add where <field> itself reaches the root) is released transitively — a
fixpoint so a ToolStrip added to this.Controls cascades to its items. An add
into a foreign container (a parameter/local — resolved semantically, not by
name) yields no credit and stays flagged. (b) A field constructed with the
designer IContainer (new T(components)) or explicitly components.Add(x) is
released by components.Dispose(); the sink is a disposed field of type
System.ComponentModel.IContainer. A component not registered (a bare
new NotifyIcon()) stays flagged. Extractor-only, no OwnIR change. Pinned by
frontend/roslyn/samples/WinFormsDisposalSample.cs (six channel-disposed fields
silent; lblForeign/cms/item/unregisteredIcon controls flagged) in the
wpf-extractor CI job.
Seen in: ShareX ShareX.HelpersLib/Cryptographic/HashChecker.cs:59,
ShareX.HelpersLib/TaskEx.cs:55, ShareX.IndexerLib/IndexerJson.cs:49. Found by
the issue #201 oracle sweep.
private CancellationTokenSource cts;
public void Cancel() {
using (cts = new CancellationTokenSource()) { // field IS the using target
...
}
// cts.Dispose() ran at the end of the using block
}Why: a using (expr) { } disposes whatever expr evaluates to when the block
exits — it doesn't matter whether expr is a bare new T(), a pre-existing local
(the already-handled using (preExistingLocal) shape, field-dispose-via-*
fixtures), or, as here, an assignment to a field. The field is disposed exactly
once, deterministically, at the end of the block.
Analyzer angle: the flow-locals detector's using-release recognition is
scoped to a local target; a field on the left-hand side of the acquisition
expression inside using (...) isn't threaded back to the field-disposal scan, so
the field reads as permanently un-disposed. Rule of thumb: using (field = new T()) releases field just as surely as using (var local = new T()) releases
local — the target of a using acquisition can be any writable location, not
only a fresh local.
Seen in: MahApps.Metro src/MahApps.Metro/Behaviors/TiltBehavior.cs:62-70
(panel.Loaded += where panel == this.AssociatedObject); MaterialDesignInXamlToolkit
src/MainDemo.Wpf/Domain/ListsAndGridsViewModel.cs:16-17 (model.PropertyChanged +=
where model is an element of Items1, a collection the ViewModel builds itself in
its own constructor). Found by the issue #201 oracle sweep.
protected override void OnAttached() {
this.attachedElement = this.AssociatedObject; // base-class accessor
if (this.attachedElement is Panel panel)
panel.Loaded += (sl, el) => { ... }; // <- flagged
}public ListsAndGridsViewModel() {
Items1 = CreateData(); // own factory
foreach (var model in Items1)
model.PropertyChanged += (s, a) => OnPropertyChanged(...); // <- flagged
}Why: both are variations on the already-recognised self-owned-source
exemption (a class subscribing to something it owns is a collectable cycle, not a
leak) — just reached through a different path than a constructed field: a
Behavior<T>'s AssociatedObject (the base class's own accessor for the element
the behavior is attached to — necessarily co-lifetimed, since a Behavior cannot
outlive being attached), or an element of a collection the class builds itself in
its own constructor/factory (the collection and its elements share the
constructing object's lifetime).
Analyzer angle: the shipped self-owned-source exemption keys off a directly
constructed/assigned field; it doesn't follow this.AssociatedObject (a
base-class-provided reference, not a locally constructed one) or "an element
pulled from a collection this class itself populated." Rule of thumb: the
exemption's real criterion is "does this object's lifetime start and end with the
subscriber's" — a base-class accessor to the attached object, or an item of an
owned collection, satisfies that just as well as a constructed field.
Status (2026-07): all three sub-shapes (a), (b), (c) shipped — parent issue #221 closed once all three landed.
- (a) the
Behavior.AssociatedObjectself-owned source, shipped in #227 — extractorIsAssociatedObjectSource, gated on theBehaviorbase (IsBehaviorSubscriber) plus a same-class assignment-chain resolving tothis.AssociatedObject(ResolvesToAssociatedObject); pinned byfrontend/roslyn/samples/AssociatedObjectSourceSample.cs. - (b) the curated app-scoped source for an
Application-derived subscriber, shipped in #228 (PR #232) — extractor keeps the existingclsIsAppsubscriber gate byte-for-byte and only loosens the source check to a curated resolver allowlist (PaletteHelper.GetThemeManager), method-group handlers only (no lambdas); seedocs/notes/oracle-known-fps.md"What DID ship next to it (issue #228)" for why this is not the rejectedclsIsStaticbroadening. Pinned byfrontend/roslyn/samples/AppScopedSourceSample.cs. - (c) the element of a self-populated collection, shipped in #229 — extractor
IsOwnedCollectionElementSource: aforeachloop variable whose collection is a this-owned field/property populated ONLY from own construction / an own factory (MemberPopulatedByOwnFactory+IsOwnProducedValue), never a ctor parameter or an injected/service-located value; pinned byfrontend/roslyn/samples/OwnedCollectionElementSample.cs.
Seen in: MahApps.Metro src/MahApps.Metro/Controls/MetroWindow.cs:1447-1449
(if (this.GetTemplateChild(PART_Content) is MetroContentControl metroContentControl)); AvalonEdit ICSharpCode.AvalonEdit/CodeCompletion/OverloadViewer.cs:58-64
(Button upButton = (Button)this.Template.FindName("PART_UP", this);). Found by
the issue #201 oracle sweep.
public override void OnApplyTemplate() {
base.OnApplyTemplate();
Button upButton = (Button)this.Template.FindName("PART_UP", this);
upButton.Click += (sender, e) => { ... }; // <- flagged
}Why: a named template part is owned by the control's own template — its
lifetime is bound to the control instance, exactly the shape the shipped
self-owned-template-part exemption already covers (real-world-mining.md's
GetTemplateChild/FindName fix). The only difference here is where the result
is stored.
Analyzer angle: the existing exemption keys off a field assignment
(_field = GetTemplateChild(...) as T); both examples above store the template
part in a local instead (a plain local variable, or an is T x pattern
match). Same safe shape, narrower syntactic form the exemption doesn't reach.
Rule of thumb: "fetched from my own template" is what makes a template part
self-owned — whether the result lands in a field or a method-local variable
doesn't change that.
Seen in: AvalonEdit ICSharpCode.AvalonEdit/Editing/ImeSupport.cs:34-47.
Found by the issue #201 oracle sweep.
EventHandler requerySuggestedHandler; // we need to keep the event handler instance
// alive because CommandManager.RequerySuggested
// uses weak references
requerySuggestedHandler = OnRequerySuggested;
CommandManager.RequerySuggested += requerySuggestedHandler; // never -=, and that's fineWhy: System.Windows.Input.CommandManager.RequerySuggested is WPF's own
answer to the classic "static event pins every subscriber forever" trap — it's
implemented internally over weak references, so a subscriber is not kept
alive by the subscription (the in-repo comment even explains the field exists to
keep the handler itself from being collected prematurely, the opposite concern).
Never unsubscribing is the normal, intended usage.
Analyzer angle: Own.NET tiers a subscription to a provably static source as
a hard error ("possible leak" promoted to certain, since a static source
trivially outlives everything) — the systematically wrong verdict for this one
named event. Rule of thumb: a handful of BCL/WPF statics are deliberately
weak-referenced specifically to be safe to never detach from; treat
CommandManager.RequerySuggested (and any future confirmed sibling) as a named
allowlist entry, not a case for the general static-source rule.
Seen in: AvalonEdit ICSharpCode.AvalonEdit/Search/DropDownButton.cs:78-86.
Found by the issue #201 oracle sweep.
DropDownContent.Closed += DropDownContent_Closed; // <- flagged: no visible -=
void DropDownContent_Closed(object sender, EventArgs e) {
((Popup)sender).Closed -= DropDownContent_Closed; // removes itself, first firing
this.IsDropDownContentOpen = false;
}Why: the handler removes itself from the event the first time it runs — a common one-shot idiom ("do this once, then stop listening") that needs no external detach call at all. By the time the event could fire a second time, the subscription is already gone.
Analyzer angle: Own.NET's -=-search is scoped to the subscribe call's
enclosing method (or a class-level scan for a plain field-based handler); it
doesn't look inside the handler's own body for a matching self-removal.
Rule of thumb: a handler that unsubscribes itself as its first action is
bounded by construction — the search for a matching -= needs to include the
handler body, not just sibling code near the +=.
Seen in: ClosedXML ClosedXML/Excel/Cells/Slice.cs:324-416 (internal class Enumerator : IEnumerator<Point>). Found by the issue #201 oracle sweep.
internal class Enumerator : IEnumerator<Point> {
...
public void Dispose() { } // literally empty — no base call, no field release
}
// caller:
var enumerator = new Enumerator(this, area);
while (enumerator.MoveNext()) { ... } // never disposed — flaggedWhy: Enumerator implements IDisposable only because IEnumerator<T>
requires it, not because it holds a real resource — its Dispose() does
nothing at all. Never calling it cannot leak anything; there is nothing behind
the interface to release.
Analyzer angle: entry 9 already covers this idea for a handful of named
BCL types (StringWriter/StringReader, via IsNoOpDisposeWrapper), but
that allowlist doesn't reach a user-defined type, however trivially empty
its Dispose() is. Rule of thumb: rather than growing a list of known-safe
type names, a Dispose() method whose body is provably empty (no statements)
is safe to skip regardless of whose type it is — first-party or third-party,
named allowlist or not.
Shipped (issue #225). HasEmptyDisposeBody (extractor) recognises a type
whose parameterless Dispose() is declared in source with a block body of zero
statements and whose base chain carries no Dispose to cascade to (base is
object / not IDisposable), so nothing real is skipped. A LOCAL of such a
type never disposed is then dropped from both the flat (IsDisposableType) and the
--flow-locals (ImplementsIDisposable) local-disposable paths. Deliberately
scoped to locals: a FIELD keeps its own disposal contract (so the
OwnIgnoreSample Handle field stand-in and other field fixtures are untouched).
Precision guards, each pinned by EmptyDisposeSample.cs: a non-empty body
(RealResource, LeakyReader) still leaks; an empty override over a base whose
Dispose does real work (EmptyOverrideOverRealBase) still leaks; an
expression-bodied / metadata-only Dispose we cannot read as empty is not
exempted (never a guessed drop). The empty enumerator (EmptyDisposeEnumerator,
the ClosedXML shape) and an empty *Reader (ScratchReader) stay silent. Two
existing fixtures that had used an empty Dispose() as a modelling shortcut
(UnitOfWork, PixelOwner) were made faithful (a real Dispose body) so they
remain genuine leak fixtures.
Superseded by a soundness regression (issue #238) — gate narrowed to
enumerators (issue #240 / PR #240). The re-measure in
docs/notes/precision-remeasure-2026-07-11.md (PR #235) ran the shipped #225
exemption above against real ClosedXML and found it unsound:
XLWorkbook.Dispose() is empty in source only — Janitor.Fody (an IL
weaver declared in ClosedXML's FodyWeavers.xml) rewrites the body at
compile time to call real cleanup (DisposeManaged() →
Worksheets.ForEach(w => (w as XLWorksheet).Cleanup())). HasEmptyDisposeBody
read the source-level emptiness as proof of "nothing to release" and
silently exempted 263 unrelated XLWorkbook locals across
ClosedXML.Examples — a real leak class going quiet, not a false-positive
fix. This inverts the analyzer's core doctrine (an exemption's worst case
must stay "keeps today's honest warning," never "silently swallows a leak
class"), so it was treated as a bug, not a precision tweak.
The fix (#240) narrowed the gate rather than special-casing weavers:
HasEmptyDisposeBody's exemption now applies only to types that
implement the generic System.Collections.Generic.IEnumerator<T> — the
one interface that forces a (frequently no-op) Dispose() implementation
via IEnumerator<T> : IEnumerator, IDisposable, which was the entire
motivating shape for #225 in the first place. The non-generic
System.Collections.IEnumerator does not extend IDisposable, so it
does not qualify. XLWorkbook and every other weaver-augmented domain type
fall outside the gate and keep the honest warning. Defense in depth: a
FodyWeavers.xml found above the source (or above the owning project, for
linked sources) disables the exemption even for a qualifying enumerator.
Explicit-interface Dispose() is now recognized as empty too (closing the
coverage gap that left 3 of 5 Slice.cs sites unexempted) — DisposeAsync()
is the opposite of an exemption: ANY IAsyncDisposable (declared or
inherited) or bare DisposeAsync() method (declared, explicit-impl, or
inherited from a base) unconditionally disqualifies the exemption, since
async disposal means the type is no longer the "simple enumerator holding
nothing" the rule is scoped to. Two review rounds closed further soundness
holes: inherited IAsyncDisposable (via AllInterfaces), inherited bare
DisposeAsync (via base-chain walk), a sticky static-registry cache, and a
fail-open weaver-detection path (File.GetAttributes instead of
File.Exists, which silently disabled the guard when the check itself
errored). Confirmed on real ClosedXML (HEAD 4e89dce, --flow-locals):
121 of the swallowed findings restored, 0 removed. See issue #238 and PR #240
for the full record — this is a deliberate historical lesson, not
cleaned up: source-level emptiness is not a runtime no-op once a
compile-time IL weaver is in play, and a broad "any empty Dispose" gate
cannot rule that out — only a gate scoped to a specific
compiler/language-forced shape (here: IEnumerator<T>) can.
Entries 1–6 are the same lesson from different angles: disposal
responsibility travels with the reference — out of a factory (1, 6), forward in
time via a callback (2), into a pool (3), or down a using on a value type (4).
Naive "every disposable needs a lexical using/Dispose on every path" checks
misread all of them, which is why Infer#/CodeQL over-report here and a
transfer/escape-aware checker (Own.NET) correctly stays quiet. (Entry 7 is a run
ledger — an audit record of a Polly re-run, not an idiom.)
Entries 8–9 are the mirror image — the first cases where Own.NET is the one
over-reporting and the oracle is correctly silent. They map our own precision
frontier: a subscription on a publisher that escapes by return (8, the dual of
ownership transfer — bounded, but the construct-and-return is interprocedural), and
an owning field whose IDisposable holds no real resource (9, a StringWriter is
not a handle — belongs in the IsDisposeOptional allowlist). Same moral as 1–6,
pointed back at us: a leak is about the resource and the reference's
lifetime, not the mere presence of an IDisposable and a missing Dispose/-=.
Worth learning as C#; worth pinning as the precision frontier.
This log is required upkeep, not a nice-to-have (see oracle.md
§ Maintenance requirement and the README convention note). The rule:
After every oracle run, triage the
oracle-onlyfindings. Any that turn out to be an oracle false positive or our deliberate by-design skip almost always hide an idiom like the ones above — append it here, with the source file and the analyzer angle. A run that surfaces a new FP/by-design idiom and doesn't record it is an incomplete run.
Keep entries source-pinned and honest about confidence (note when a judgement rests on a decompiled/fetched excerpt rather than the full source). The point is a collection we can trust — both to learn C# ownership idioms from and to navigate Own.NET's precision frontier by.