Skip to content

Commit 35368fb

Browse files
PhysShellclaude
andcommitted
audit(runtime): dominator tree + retained size — which ONE reference, if cut, frees the memory
`roots` reports what holds the typical instance. It cannot tell you that CUTTING that reference would free anything, because an object held by two references at once is attributed to whichever is nearer. That is not an implementation shortcoming — "who holds it" is ill-posed. The well-posed question is "which single reference, if cut, makes this object collectable, and how much memory does that free", and dominance answers it: D dominates X when EVERY path from a root to X goes through D, so D's retained size is what you get back by dropping it. This is what Eclipse MAT and dotMemory are built on. It immediately paid for itself on the SectorTS leak. `roots` names the static PropertyChanged event, and it is not wrong — but `dominators` says: >>> NO single reference holds this memory — the biggest dominator accounts for only 6.7% (24 MB of 361 MB). The objects are reachable from SEVERAL roots at once, so cutting any one of them frees nothing. Which is correct, and is exactly what the real fix turned out to be: the working fix detaches SEVERAL references at once (UnregisterEventHandlers(false)). A shortest-path walk would have named one, confidently, and the fix would not have worked. Algorithm: Cooper-Harvey-Kennedy, "A Simple, Fast Dominance Algorithm" (2001) — the iterative formulation LLVM used for years. A page of code, converges in a couple of passes, no balanced forests. The paper is public; this is an implementation of it, not a copy of anyone's code. (PerfView, MIT, is the closest .NET reference — it computes a spanning tree with inclusive sizes, which approximates this.) The graph is CSR, not List<List<int>>: ids are handed out in discovery order and BFS processes nodes in that same order, so each node's successors land contiguously in one int[]. ~150 bytes/object; 3.7M objects cost ~600 MB and 48 s attached live. Correctness is not taken on faith: * `RetentionPath selftest` (no target, no Windows, no ClrMD) checks the algorithm against graphs whose dominators are known by hand — a DIAMOND (an object reachable through both branches is dominated by NEITHER — the exact case a path walk gets wrong), a CHAIN (retained size accumulates), and a CYCLE (a gate dominates a reference cycle, which reference counting can never free). 13/13. * At run time the super-root's retained size must equal the total size of the reachable graph, or the walk REFUSES to report. A wrong dominator tree does not fail loudly — it quietly tells you to cut the wrong reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PjQnE1FDucd6vBVQbFswiE
1 parent 25ba595 commit 35368fb

4 files changed

Lines changed: 584 additions & 12 deletions

File tree

‎audit/runtime/README.md‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,62 @@ Output is the **`runtime.json` contract** (`OwnAudit/docs/runtime-contract.md`),
112112
as retained is `confirmed`; retention with **nothing static to explain it** is `runtime-only` — the
113113
analyzer's blind spot, and therefore a rule request.
114114

115+
### `dominators` — which ONE reference, if cut, frees the memory
116+
117+
`roots` tells you what the *typical* path is. It cannot tell you that **cutting** that path would free
118+
anything, because an object held by two references at once is attributed to whichever is nearer. That is
119+
not a shortcoming of the implementation; the question "who holds it" is simply ill-posed. The well-posed
120+
question is:
121+
122+
> which single reference, if cut, makes this object collectable — and how much memory does that free?
123+
124+
Dominance answers it. `D` dominates `X` when **every** path from a root to `X` goes through `D`, so `X`'s
125+
immediate dominator is its one true retainer, and `D`'s **retained size** — everything it dominates — is
126+
what you get back by dropping the reference to `D`. This is what Eclipse MAT and dotMemory are built on,
127+
and it is why they can say *"detach this and you get 1.4 GB back"* while a path walk cannot.
128+
129+
```powershell
130+
RetentionPath.exe dominators --pid 1234 --top 8
131+
132+
3 727 278 reachable objects, 361 MB retained in total
133+
134+
DOMINATORS — cut this ONE reference and the retained bytes go away:
135+
retained MB own B type
136+
24,2 74 584 System.Object[]
137+
21,5 48 BaseDict.DictionaryList
138+
9,5 16 344 System.Object[]
139+
140+
>>> NO single reference holds this memory — the biggest dominator accounts for only 6,7%
141+
(24 MB of 361 MB). The objects are reachable from SEVERAL roots at once, so cutting any
142+
one of them frees nothing. The fix must detach all of them.
143+
```
144+
145+
**That verdict is the point of the verb.** On the SectorTS leak, `roots` names the static
146+
`PropertyChanged` event and it is not wrong — but `dominators` shows that detaching it *alone* frees
147+
nothing, because the same documents are also held by a static `List<Object>`. And that is exactly what
148+
the real fix turned out to be: `UnregisterEventHandlers(false)` detaches **several** references at once.
149+
A shortest-path walk would have named one of them, confidently, and the fix would not have worked.
150+
151+
Algorithm: **Cooper–Harvey–Kennedy**, *"A Simple, Fast Dominance Algorithm"* (2001) — the iterative
152+
formulation, a page of code, converging in a couple of passes on real graphs. (PerfView, MIT, is the
153+
closest .NET reference; note it computes a *spanning tree* with inclusive sizes, which approximates
154+
this.) The graph is held as CSR — a 4M-object heap will not fit in `List<List<int>>`.
155+
156+
Correctness is not taken on faith:
157+
158+
* `RetentionPath selftest` — no target, no Windows, no ClrMD — checks the algorithm against graphs whose
159+
dominators are known by hand: a **diamond** (an object reachable through both branches is dominated by
160+
neither — the exact case a path walk gets wrong), a **chain** (retained size accumulates), and a
161+
**cycle** (a gate dominates a reference cycle, which reference counting can never free).
162+
* At run time the super-root's retained size must equal the total size of the reachable graph, or the
163+
walk **refuses to report**. A wrong dominator tree does not fail loudly — it just tells you to cut the
164+
wrong reference.
165+
166+
Cost: ~48 s and ~600 MB of analyzer memory for a 3.7 M-object heap, attached live. A 40 M-object heap
167+
will not fit; take a dump, or sample with `roots`.
168+
115169
### What it does not do (read this before trusting it)
116170

117-
* **It cannot tell you that cutting the top retainer would free the object.** This is the important
118-
one. The histogram partitions instances by their *shortest* path, so when an object is held by two
119-
references at once, it is attributed to whichever is nearer — and the run above shows exactly that:
120-
50 % of the GTDs come out under the static event and 44 % under a static `List<Object>`, which most
121-
likely means many are held by **both**. Detach the event and they still will not collect.
122-
The question *"which single reference, if cut, frees this object — and how much memory does that
123-
free"* is well-posed, and it has a standard answer this tool does not implement: a **dominator tree**
124-
with retained sizes (Lengauer–Tarjan, or the iterative Cooper–Harvey–Kennedy formulation; it is what
125-
Eclipse MAT and dotMemory are built on). That is the honest next step, and it is a feature, not a
126-
tweak.
127171
* **A `[stack]` root is not retention.** It means the object is live in a frame *right now*. The tool
128172
labels it as such precisely so it is not mistaken for a leak; the same is true of `[finalizer]`.
129173
* **It matches the TYPE, not the type's spelling.** Asking for `GTDGoody` will not match

0 commit comments

Comments
 (0)