Closed Bug 1596992 Opened 5 years ago Closed 5 years ago

ResizeObserver Memory Leak

Categories

(Core :: Layout, defect, P2)

70 Branch
defect

Tracking

()

RESOLVED FIXED
mozilla72
Tracking Status
firefox72 --- fixed

People

(Reporter: m, Assigned: emilio)

References

()

Details

(Whiteboard: [MemShrink:P2])

Attachments

(1 file)

User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36

Steps to reproduce:

The code explains it best:

class LeakyThing {
  _memoryWaster = new ArrayBuffer(10000000);
  constructor() {
    new ResizeObserver(this._handleResize);
  }
  _handleResize = () => {
    this;
  };
}

for (let i = 0; i < 50; i++) {
  new LeakyThing();
}

https://codepen.io/matthewwithanm/pen/jOOQZRj

Actual results:

It seems like cyclical references stemming from ResizeObservers aren't cleaned up. If you force a gc (via about:memory) and then take a snapshot, you'll see the ArrayBuffers are all retained.

Expected results:

They should all be garbage collected.

I submitted the bug via Chrome (sorry 😝) but I've reproed on FF 70.0.1 ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0").

Sorry, doesn't have anything to do with cyclical references. ResizeObservers in general just aren't gc'd apparently.

for (let i = 0; i < 50; i++) {
  const ab = new ArrayBuffer(10000000);
  const ro = new ResizeObserver(() => {});
  ro.x = ab;
}
Component: Untriaged → Layout
Product: Firefox → Core
Whiteboard: [MemShrink]

I can reproduce.

Blocks: 1272409
Status: UNCONFIRMED → NEW
Ever confirmed: true
Priority: -- → P2

Well, all live ResizeObservers are kept alive for the lifetime of the document in ResizeObserverController: https://searchfox.org/mozilla-central/rev/131338e5017bc0283d86fb73844407b9a2155c98/dom/base/ResizeObserverController.cpp#85

So it's not a leak per se, but we should probably not keep them around for so long, somehow.

Well, that array is append-only, that's silly.

Taking a look...

Assignee: nobody → emilio

This is mostly per spec, see https://github.com/w3c/csswg-drafts/issues/4518. However it seems other browsers don't follow the spec, so I'll align Firefox with WebKit for now, I think.

By adding / removing to the observer list on observe() / unobserve()
respectively, rather than only adding on construction per spec.

See https://github.com/w3c/csswg-drafts/issues/4518 for the relevant spec issue.

Whiteboard: [MemShrink] → [MemShrink:P2]

Thanks for fixing this, Emilio. It would also be nice to have the memory used by the resize observer controller reported along with the document.

ResizeObservers do not have to be kept alive as long as the document.

The answer to "how long should the ResizeObserver be kept alive?" is surprisingly convoluted.

ResizeObserver must stay alive as long as either:
A) there is a js reference to observer.
B) there is an active observation. Specifically, there is an element, reachable from JS, that is being observed.

A) is easy, ResizeObserver gets kept alive by standard Javascript object lifetime
B) is bit more complex. RO is being kept alive by a reference from an Element.

In Chrome, this is implemented by Element having a pointer to all ResizeObservers observing it. These pointers keep ResizeObserver alive.

(In reply to Aleks Totic from comment #10)

ResizeObservers do not have to be kept alive as long as the document.

Well, not necessarily, but per spec you need to preserve order of construction, which means that you need an array of all the observers that are still created and alive.

That array can be an array of weak pointers of sorts, but it is still an append-only array...

Pushed by ealvarez@mozilla.com: https://hg.mozilla.org/integration/autoland/rev/4d36b54709a8 Avoid leaking ResizeObserver entries without active observations for the lifetime of the document. r=smaug

(In reply to Emilio Cobos Álvarez (:emilio) from comment #11)

(In reply to Aleks Totic from comment #10)

ResizeObservers do not have to be kept alive as long as the document.

Well, not necessarily, but per spec you need to preserve order of construction, which means that you need an array of all the observers that are still created and alive.

That array can be an array of weak pointers of sorts, but it is still an append-only array...

I do not understand the append-only requirement. Observers get removed if:

  • there are no more live Elements being observed.
  • there are no references to the observer from JS.

I do not understand the append-only requirement.

I think the point is that, if you remove them, you lose track of the order of construction.

I think the point is that, if you remove them, you lose track of the order of construction.

If observer is removed, it will never deliver any observations again.
Order of construction is no longer relevant.

If observer is removed, it will never deliver any observations again.
Order of construction is no longer relevant.

I may be misunderstanding something, but you can always call observe() on it again, causing it to deliver observations again.

(In reply to Aleks Totic from comment #13)

I do not understand the append-only requirement. Observers get removed if:

  • there are no more live Elements being observed.
  • there are no references to the observer from JS.

Yeah, Aleks is right in the sense that if you keep the list as an array of weak pointers, you can clear the entry when the element is destroyed or something.

In terms of blink code, what I mean is that there are only insert() calls into this hash set, nothing ever gets removed from there.

You could do it from the ResizeObserver destructor, I guess... But it's unclear to me how Blink guarantees any sort of sane ordering with a HashSet.

there are only insert() calls into this hash set, nothing ever gets removed from there.

Blink has garbage collection, so weak members of that set get removed automatically by the GC.

t's unclear to me how Blink guarantees any sort of sane ordering with a HashSet

Blink's collections are an undocumented zoo. I think (would check docs if there were any) that HashSet is a special type of Map whose keys are kept sorted in insertion order.

Status: NEW → RESOLVED
Closed: 5 years ago
Resolution: --- → FIXED
Target Milestone: --- → mozilla72

(In reply to Aleks Totic from comment #18)

Blink's collections are an undocumented zoo. I think (would check docs if there were any) that HashSet is a special type of Map whose keys are kept sorted in insertion order.

That doesn't match what I see:

<!doctype html>
<style>
  div {
    width: 100px;
    height: 100px;
    background: green;
  }
</style>
<div id="observeme"></div>
<script>
  let div = document.querySelector("#observeme");
  for (let i = 0; i < 100; ++i) {
    new ResizeObserver(function() {
      console.log(i);
    }).observe(div);
  }
</script>

Per spec, this test-case should enumerate the numbers from 0 to 99. In Blink the order is rather random.

Blocks: 1597958
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: