Skip to content

Soundness: OWN001 treats any -= in the class as a release — a -= behind a flag, or in a method nobody calls, silently swallows a real leak (heap-proven on SectorTS) #278

Description

@PhysShell

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions