I looked back at my run that captured every CC, and one of the leaking outer windows appears in only a single CC log. In that log, the BrowsingContext for the outer window has a refcount of 2: the window and the docshell. The three are held alive because of an extra reference to the docshell.
In the very next log, that BrowsingContext still has a refcount of 2, but now there is only one known reference: the docshell. The extra reference on the docshell has gone away. At shutdown, the three still point to each other in the ways that you would expect.
My theory is that this happens:
a) In between CCs, we decide that the outer window is definitely alive.
b) The outer window becomes garbage for some reason.
c) We run the CC. The window is not freed, because we marked it as being alive.
d) No non-garbage references to the window exist any more, so we never consider it again and we leak.
I think this is a bug in our CC optimizations: if we decide an object is definitely alive before the start of a CC, we need to add it to the purple buffer after the end of that CC, so we can guarantee that it will be considered the next time we CC. (I think it is not possible to "de-optimize" a window if it is, eg, no longer focused, because we decide other objects must also be alive once we have decided that a window or document is alive.)
I think this has not been a common problem in practice because this is not an issue for objects that have wrappers, as we always consider objects with wrappers in the CC. Boris said that he thought that an outer window would only not have a wrapper after close was called on it, and the current behavior is that we manually break the document-window cycle when close is called on it, so this won't be an issue before the patch here.
I hacked up a patch that created a kind of "uncollectable purple buffer" that is an array of strong references to anything we call MarkUncollectableForCCGeneration() on. That array gets cleared at the end of a CC, which adds them into the purple buffer. However, this did not fix the leak, so either something else is optimizing them out of the graph, or I'm wrong entirely. I'll try a broader approach that calls Root()/Unroot() on everything that we optimize out of the graph.