Classification
Problem — a -= that exists is not a -= that runs
The shipped rule (frontend/roslyn/OwnSharp.Extractor/Program.cs:13-14):
A subscription is "released" by a matching target -= handler in the class
Any matching -= anywhere in the class silences the finding. There is no check that the -= is
reachable, that the method holding it is ever called, or that it is not guarded away by a parameter.
The design documents specify the stricter rule, and the implementation is looser than its own spec:
|
what it says |
docs/proposals/P-004-wpf-lifetime-profile.md:33 |
source.Event += handler with no matching -= in Dispose/OnClosed/Unloaded |
docs/proposals/P-001-csharp-extractor.md:51 |
with no matching -= in a Dispose/OnClosed/… |
OwnSharp.Extractor/Program.cs:13 |
released by a matching -= in the class |
The divergence is in the unsound direction, and it is not theoretical.
Evidence — a real leak, missed, with a heap-proven retention path
In STS_new/SectorTS, GTD's constructor subscribes to a static publisher (GTD.cs:5192-5193):
AppData.Properties.GBProperty.PropertyChanged += GBProperty_PropertyChanged; // AppData.cs:232: public static readonly KernelProperty Properties
A matching -= exists (GTD.cs:5272) — so OWN001 pairs them and stays silent. But it lives inside a
method that is not a teardown, and behind a parameter that skips it:
public void UnregisterEventHandlers(bool UnregOnlyGoodys = false)
{
if (!UnregOnlyGoodys) // <-- callers pass true; the block never runs
{
AppData.Properties.GBProperty.PropertyChanged -= GBProperty_PropertyChanged;
...
}
// only the goodys are detached here
}
Who actually calls it:
| caller |
call |
released? |
MainWindow.xaml.cs (~30 sites), reports |
UnregisterEventHandlers() |
yes |
Service/GTDService.cs:139, 327, 1757, 2252, 2388 |
UnregisterEventHandlers(**true**) |
no |
BrokerDataClasses/DocCloud/** — DocCloudService_v1.cs:584, _v2.cs:710, and 8+ AutoMapper .ConstructUsing(x => new GTD(null, null)) profiles |
never calls it |
no |
The AutoMapper profiles construct a GTD per mapping operation, and every one of them pins itself
to the static event for the life of the process.
Runtime proof. Attaching to a headless process that deserializes GTDs, after 31 documents:
roots : 209 objects
on the heap : 1 685 951 objects 223 MB
REACHABLE from roots : 1 569 072 objects 148 MB
uncollected garbage : 116 879 objects 75 MB
>>> 66.3% of the heap is genuinely RETAINED
Retention path from a GC root to the document's goods list:
[PinnedHandle] System.Object[]
BrokerDataClasses.Property.KernelProperty <- AppData.Properties (static)
BrokerDataClasses.Property.GBProperty
System.ComponentModel.PropertyChangedEventHandler
System.Object[] <- the delegate's invocation list
System.ComponentModel.PropertyChangedEventHandler
BrokerDataClasses.GTD <- the whole document graph
BindingList<BrokerDataClasses.GTDGoody>
And the analyzer half-caught it. OwnAudit/sts_audit/ownsharp-sectorts.txt contains:
KDT.cs:88: [OWN001] event 'AppData.Properties.GBProperty.PropertyChanged' is subscribed
but never unsubscribed; ... may outlive and keep 'KDT' alive (possible leak)
Same static publisher, correctly flagged — because KDT has no -= at all. GTD.cs:5192 is the same
leak against the same publisher and is not flagged, purely because a -= exists somewhere in the
class.
Note the irony: Program.cs:459-460 says the delegate-unwrapping heuristic was tuned on "the dominant
SectorTS idiom". The pairing assumption was calibrated on the very codebase where it fails.
Fix direction
Primary — honour the spec. A -= releases a subscription only when it is in a teardown context:
Dispose/DisposeAsync, OnClosed/Closed, Unloaded, or a method the type's own disposal path
calls. A -= anywhere else is not a release; at most it is a mitigation candidate and should degrade
to a warning, not to silence. This restores P-004/P-001 as written.
Secondary — a guarded -= is not a -=. If the -= is inside a branch whose condition is a
parameter of the enclosing method (if (!flag) { ... -= ... }), it cannot be proven to run from the
subscription site. Treat it as unreleased, or emit OWN050 ("analysis skipped") rather than silence —
consistent with the existing doctrine of never guessing in favour of "no leak".
Optional, if the call graph is available (D5 / MOS): a -= in method M releases only if M is
reachable from a teardown of the subscribing type. Under the current modular-interprocedural model this
may be out of budget; the two rules above do not need it.
Worst case of each rule is a kept warning, never a swallowed leak — which is the doctrine #238
established.
Acceptance
GTD.cs:5192 (and PGC.cs:81) are flagged OWN001 on the SectorTS corpus; KDT.cs:88 continues to be.
- The oracle sweep shows no new FPs of the "
-= genuinely in Dispose" shape — those must stay silent.
- A minimal repro lands in
corpus/wpf/ — subscribe in ctor, -= in a non-teardown method behind a
bool parameter, caller passes true — as a bad case with a matching ok case that detaches in
Dispose.
- Re-measure the ClosedXML / 5-repo sweep: the delta should be additive (new true positives), and any
new finding of this shape should be triageable to a real un-run -=.
Notes
This is precisely the runtime-only bucket that PhysShell/OwnAudit#13 predicted — "retention with no
static finding — the analyzer's blind spot" — and it is the first real instance of it with a proven
retention path. Worth wiring back: a runtime-only finding is not just a report, it is a rule
request.
The retention path above was produced with a small ClrMD tool (attach → mark from GC roots → BFS to the
target type). If it is useful, it is also the collector OwnAudit/docs/runtime-contract.md sketches but
never implemented; happy to contribute it.
Classification
Disposeexemption), Runtime witness generation: turn supported OWN001/OWN014 evidence into red→green retention tests #270 (runtime witnesses),PhysShell/OwnAudit#13(theruntime-onlybucket, which is exactly what this is)Problem — a
-=that exists is not a-=that runsThe shipped rule (
frontend/roslyn/OwnSharp.Extractor/Program.cs:13-14):Any matching
-=anywhere in the class silences the finding. There is no check that the-=isreachable, that the method holding it is ever called, or that it is not guarded away by a parameter.
The design documents specify the stricter rule, and the implementation is looser than its own spec:
docs/proposals/P-004-wpf-lifetime-profile.md:33source.Event += handlerwith no matching-=inDispose/OnClosed/Unloadeddocs/proposals/P-001-csharp-extractor.md:51-=in aDispose/OnClosed/…OwnSharp.Extractor/Program.cs:13-=in the classThe divergence is in the unsound direction, and it is not theoretical.
Evidence — a real leak, missed, with a heap-proven retention path
In
STS_new/SectorTS,GTD's constructor subscribes to a static publisher (GTD.cs:5192-5193):A matching
-=exists (GTD.cs:5272) — so OWN001 pairs them and stays silent. But it lives inside amethod that is not a teardown, and behind a parameter that skips it:
Who actually calls it:
MainWindow.xaml.cs(~30 sites), reportsUnregisterEventHandlers()Service/GTDService.cs:139, 327, 1757, 2252, 2388UnregisterEventHandlers(**true**)BrokerDataClasses/DocCloud/**—DocCloudService_v1.cs:584,_v2.cs:710, and 8+ AutoMapper.ConstructUsing(x => new GTD(null, null))profilesThe AutoMapper profiles construct a
GTDper mapping operation, and every one of them pins itselfto the static event for the life of the process.
Runtime proof. Attaching to a headless process that deserializes GTDs, after 31 documents:
Retention path from a GC root to the document's goods list:
And the analyzer half-caught it.
OwnAudit/sts_audit/ownsharp-sectorts.txtcontains:Same static publisher, correctly flagged — because
KDThas no-=at all.GTD.cs:5192is the sameleak against the same publisher and is not flagged, purely because a
-=exists somewhere in theclass.
Note the irony:
Program.cs:459-460says the delegate-unwrapping heuristic was tuned on "the dominantSectorTS idiom". The pairing assumption was calibrated on the very codebase where it fails.
Fix direction
Primary — honour the spec. A
-=releases a subscription only when it is in a teardown context:Dispose/DisposeAsync,OnClosed/Closed,Unloaded, or a method the type's own disposal pathcalls. A
-=anywhere else is not a release; at most it is a mitigation candidate and should degradeto a warning, not to silence. This restores P-004/P-001 as written.
Secondary — a guarded
-=is not a-=. If the-=is inside a branch whose condition is aparameter of the enclosing method (
if (!flag) { ... -= ... }), it cannot be proven to run from thesubscription site. Treat it as unreleased, or emit
OWN050("analysis skipped") rather than silence —consistent with the existing doctrine of never guessing in favour of "no leak".
Optional, if the call graph is available (D5 / MOS): a
-=in methodMreleases only ifMisreachable from a teardown of the subscribing type. Under the current modular-interprocedural model this
may be out of budget; the two rules above do not need it.
Worst case of each rule is a kept warning, never a swallowed leak — which is the doctrine #238
established.
Acceptance
GTD.cs:5192(andPGC.cs:81) are flagged OWN001 on the SectorTS corpus;KDT.cs:88continues to be.-=genuinely inDispose" shape — those must stay silent.corpus/wpf/— subscribe in ctor,-=in a non-teardown method behind abool parameter, caller passes
true— as a bad case with a matching ok case that detaches inDispose.new finding of this shape should be triageable to a real un-run
-=.Notes
This is precisely the
runtime-onlybucket thatPhysShell/OwnAudit#13predicted — "retention with nostatic finding — the analyzer's blind spot" — and it is the first real instance of it with a proven
retention path. Worth wiring back: a
runtime-onlyfinding is not just a report, it is a rulerequest.
The retention path above was produced with a small ClrMD tool (attach → mark from GC roots → BFS to the
target type). If it is useful, it is also the collector
OwnAudit/docs/runtime-contract.mdsketches butnever implemented; happy to contribute it.