I got some help understanding this from resident browser-internals guru @krisselden. I will try to relate what I learned.
Current status of leak bugs in canary
We are retaining more than necessary at the moment, but not an unbounded amount (as @jgwhite guessed) . Basically some things are left around from the previous render and not cleared away until the next render. Work is ongoing on this branch to clean up this issue. A lot of what you’ll see there is actually cleaning up places where the test suite itself was leaking things, making the hunt for real framework leaks harder.
There was a different unbounded memory leak that was fixed in 3.1.1, but it would look different from the example above. Glimmer was recompiling components unnecessarily, and that was causing the glimmer-vm’s stack to overflow.
One of the hard things about tracking these leaks down is that the heap snapshot tools are being misled into showing real but irrelevant retaining paths. destroyables
, for example, is something Kris has already looked at and confirmed is benign.
The big reason why the snapshots are pretty confusing is related to WeakMaps (which are used both by ember and by the browser’s own internals).
Why WeakMaps make the heap snapshots misleading
For the purposes of this discussion, assume we have an object leakyValue
that is stored as a value in a WeakMap named theWeakMap
, and nowhere else. Another object which I’ll call leakyKey
is the corresponding key in the WeakMap, as in theWeakMap.set(leakyKey, leakyValue)
.
leakyValue
is retained by two different retention paths. If either path was to go away, the object would be garbage collected. This is the weird thing about WeakMaps – normally any single path to an object is enough to explain why it is retained. But a value in a WeakMap is only retained if it’s reachable along both paths.
The first path is the path from a GC root to theWeakMap
itself. If theWeakMap
is dropped, leakyValue
can also be dropped, even if leakyKey
is still retained.
The second path is the path from a GC root to leakyKey
. If leakyKey
is dropped, then leakyObj
can also be dropped, regardless of whether theWeakMap
is still retained.
Now, the first difficulty is that the dev tools can’t really know which of the two path was supposed to be cut. Sometimes you have a WeakMap that is intentionally long lived, and it’s the key that’s leaking. Sometimes you have a key that’s intentionally long lived and the WeakMap is leaking. The tools can’t tell you which is relevant.
Second, the dev tools try to determine the interesting retaining paths by looking for the shortest paths. This heuristic was good until WeakMaps were introduced, but now it can be quite misleading, because of these dual paths. For example, you may have a WeakMap very close to a GC root, which will make the path through the WeakMap itself the shortest path to the value, even though it’s not really relevant because you intended to retain the WeakMap, and it’s really the longer path to the key that is the culprit. In that case, finding that longer path can be very painful, because the tools don’t surface it.
And this whole problem is compounded when there are cycles adding to the complexity.
Hopefully the heap snapshot tool will adapt soon to the new complexity of WeakMaps. For now, it is quite hard to use.