Bug 215158

Summary: ResizeObserver appears to not be properly garbage collected
Product: WebKit Reporter: Clark Pan <clark.y.pan>
Component: DOMAssignee: cathiechen <cathiechen>
Status: RESOLVED FIXED    
Severity: Normal CC: ashvayka, cathiechen, cdumez, esprehn+autocc, ews-watchlist, fred.wang, kangil.han, kondapallykalyan, rniwa, simon.fraser, webkit-bug-importer
Priority: P2 Keywords: InRadar
Version: Safari 13   
Hardware: Mac   
OS: macOS 10.15   
See Also: https://bugs.webkit.org/show_bug.cgi?id=186873
https://bugs.webkit.org/show_bug.cgi?id=218225
Attachments:
Description Flags
Test file to reproduce ResizeObserver memory leak
none
Patch
none
Patch
none
Patch
none
Patch none

Description Clark Pan 2020-08-04 23:35:44 PDT
Created attachment 405989 [details]
Test file to reproduce ResizeObserver memory leak

ResizeObserver appears not to be properly garbage collected.

I'm unsure if this is a real problem, or simply a problem within the Web Inspector.

Steps to reproduce:

- Open provided example file in Safari (Tested on version Version 13.1.1 (15609.2.9.1.2))
- Open up Web Inspector -> Timelines -> Javascript Allocations
- Take a snapshot
- Wait a minute
- Take another snapshot
- Compare the two snapshots

Expected
- Only a single count of ResizeObserver and HTMLDivElement should be present

Actual
- None of the used objects seem to be garbage collected.

Notes: I've tried the same test in Chrome and Firefox, and they both behave correctly.
These test makes the assumption that garbage collection is run every time I take a heap snapshot (not true in Firefox).
Comment 1 Radar WebKit Bug Importer 2020-08-05 21:01:13 PDT
<rdar://problem/66608245>
Comment 2 Simon Fraser (smfr) 2020-08-05 21:17:38 PDT
I can reproduce.
Comment 3 Alexey Shvayka 2020-08-05 21:29:54 PDT
This might require a tweak similar to r233053.
Comment 4 cathiechen 2020-08-06 01:47:04 PDT
Thanks! I can reproduce it.
I think I can work on it in this week or next week.
Comment 5 Clark Pan 2020-10-12 17:08:30 PDT
Thanks for looking at this guys. Is there any more movement on it?
Comment 6 cathiechen 2020-10-15 06:56:55 PDT
Sorry for the delay.

It seems the snapshot do destroy JSResizeObserver. See

```
#0	0x00000004174e696c in WebCore::JSResizeObserverOwner::finalize(JSC::Handle<JSC::Unknown>, void*) at /Users/cathiechen/cc/source/WebKit/WebKitBuild/Debug/DerivedSources/WebCore/JSResizeObserver.cpp:334
#1	0x0000000435efb44a in JSC::WeakBlock::finalize(JSC::WeakImpl*) at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/WeakSetInlines.h:53
#2	0x0000000435efb4e4 in JSC::WeakBlock::sweep() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/WeakBlock.cpp:87
#3	0x0000000435efbfd2 in JSC::WeakSet::sweep() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/WeakSet.cpp:54
#4	0x0000000435ed134a in JSC::MarkedBlock::Handle::sweep(JSC::FreeList*) at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/MarkedBlock.cpp:391
#5	0x0000000435e39a04 in JSC::BlockDirectory::sweep()::$_7::operator()(unsigned long) const at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/BlockDirectory.cpp:280
#6	0x0000000435e3113c in void WTF::FastBitVectorImpl<JSC::BlockDirectoryBits::BlockDirectoryBitVectorWordView<(JSC::BlockDirectoryBits::Kind)6> >::forEachSetBit<JSC::BlockDirectory::sweep()::$_7>(JSC::BlockDirectory::sweep()::$_7 const&) const at /Users/cathiechen/cc/source/WebKit/WebKitBuild/Debug/usr/local/include/wtf/FastBitVector.h:355
#7	0x0000000435e310b6 in JSC::BlockDirectory::sweep() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/BlockDirectory.cpp:277
#8	0x0000000435ee32e9 in JSC::MarkedSpace::sweepBlocks()::$_9::operator()(JSC::BlockDirectory&) const at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/MarkedSpace.cpp:222
#9	0x0000000435ed3f0e in void JSC::MarkedSpace::forEachDirectory<JSC::MarkedSpace::sweepBlocks()::$_9>(JSC::MarkedSpace::sweepBlocks()::$_9 const&) at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/MarkedSpace.h:241
#10	0x0000000435ed3ec9 in JSC::MarkedSpace::sweepBlocks() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/MarkedSpace.cpp:220
#11	0x0000000435e52f38 in JSC::Heap::sweepSynchronously() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/Heap.cpp:1048
#12	0x0000000435e53494 in JSC::Heap::collectNow(JSC::Synchronousness, JSC::GCRequest) at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/Heap.cpp:1091
#13	0x0000000435e96fec in JSC::HeapSnapshotBuilder::buildSnapshot() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/heap/HeapSnapshotBuilder.cpp:73
#14	0x000000043600aa0f in Inspector::InspectorHeapAgent::snapshot() at /Users/cathiechen/cc/source/WebKit/Source/JavaScriptCore/inspector/agents/InspectorHeapAgent.cpp:105
```

I'll check if the Snapshot data is correct later.

@Clark do you meet other problems besides the number of resizeObserver?
Comment 7 cathiechen 2020-10-15 07:14:47 PDT
OK, snapshot does not sweep JSResizeObserver every time.
Comment 8 Clark Pan 2020-10-18 18:48:08 PDT
The number of ResizeObserver is not the issue per se; the real issue is what is leaked via the functions scopes of the passed in handler. In a single page app environment, any visual component that wants to use ResizeObserver will be leaking its entire DOM tree when the component is removed.

I've worked around this issue in our codebase by providing a proxy implement for ResizeObserver, which nulls out the handler when I remove the component in question. It would be nice to not need this hack though, since it requires developers to know to call `disconnect` when the ResizeObserver is no longer used (which is not strictly needed, since if you have matching observe/unobserve calls, it should be sufficient based on the spec).
Comment 9 cathiechen 2020-10-20 23:18:48 PDT
Created attachment 411961 [details]
Patch
Comment 10 cathiechen 2020-10-20 23:28:02 PDT
(In reply to Clark Pan from comment #8)
> The number of ResizeObserver is not the issue per se; the real issue is what
> is leaked via the functions scopes of the passed in handler. In a single
> page app environment, any visual component that wants to use ResizeObserver
> will be leaking its entire DOM tree when the component is removed.

IIUC, in the test, `handler` refers to `observer`, for `() => {}`.
Because the resize observer callback can not be garbage collected and there's always refers from callback, so observer can not be garbage collected. It will be released until the page is unloaded.
Comment 11 cathiechen 2020-10-20 23:28:28 PDT
(In reply to Clark Pan from comment #8)
> The number of ResizeObserver is not the issue per se; the real issue is what
> is leaked via the functions scopes of the passed in handler. In a single
> page app environment, any visual component that wants to use ResizeObserver
> will be leaking its entire DOM tree when the component is removed.

IIUC, in the test, `handler` refers to `observer`, for `() => {}`.
Because the resize observer callback can not be garbage collected and there's always refers from callback, so observer can not be garbage collected. It will be released until the page is unloaded.
Comment 12 Clark Pan 2020-10-20 23:43:52 PDT
(In reply to cathiechen from comment #11)
> (In reply to Clark Pan from comment #8)
> > The number of ResizeObserver is not the issue per se; the real issue is what
> > is leaked via the functions scopes of the passed in handler. In a single
> > page app environment, any visual component that wants to use ResizeObserver
> > will be leaking its entire DOM tree when the component is removed.
> 
> IIUC, in the test, `handler` refers to `observer`, for `() => {}`.
> Because the resize observer callback can not be garbage collected and
> there's always refers from callback, so observer can not be garbage
> collected. It will be released until the page is unloaded.

Your understanding is correct.

In addition, the point I was trying to make is that any other variables that are referenced by the `handler` is also not garbage collected.

In the example provided, `div` is also used by the `handler`. You can see that, within the `destructor` thats returned by the `create` function, that I'm removing the `div` from the `container`, which should mark it available for garbage collection.

However, because `handler` is not garbage collected, the `div` is still held via `handler`'s function scope. That means the `div` will be leaked as well.

In a live application, this represents a rather large memory leak.
Comment 13 cathiechen 2020-10-21 00:38:27 PDT
(In reply to Clark Pan from comment #12)
> 
> In addition, the point I was trying to make is that any other variables that
> are referenced by the `handler` is also not garbage collected.
> 
> In the example provided, `div` is also used by the `handler`. You can see
> that, within the `destructor` thats returned by the `create` function, that
> I'm removing the `div` from the `container`, which should mark it available
> for garbage collection.
> 
> However, because `handler` is not garbage collected, the `div` is still held
> via `handler`'s function scope. That means the `div` will be leaked as well.
> 
> In a live application, this represents a rather large memory leak.

Yeah, that's right.
Like Comment#3, in the patch the callback is IsWeakCallback, should be able to be gc.
Comment 14 Frédéric Wang (:fredw) 2020-10-21 02:35:23 PDT
Comment on attachment 411961 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=411961&action=review

> Source/WebCore/ChangeLog:8
> +        If ResizeObserver is referenced inside ResizeObserverCallback, they are not garbage collected properly. To fix this,

If a ResizeObserver ... it is not garbage...

or

ResizeObservers ... they are not

> LayoutTests/resize-observer/resize-observer-callback-leak.html:11
> +function test0() {

can you find a better name than "test0"?

> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
> +        }

I don't understand  this handler variable? Is it duplicating the handler below?
Comment 15 cathiechen 2020-10-21 09:37:52 PDT
Comment on attachment 411961 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=411961&action=review

Hi Fred,
Thanks for the review:)

>> Source/WebCore/ChangeLog:8
>> +        If ResizeObserver is referenced inside ResizeObserverCallback, they are not garbage collected properly. To fix this,
> 
> If a ResizeObserver ... it is not garbage...
> 
> or
> 
> ResizeObservers ... they are not

Done, thanks

>> LayoutTests/resize-observer/resize-observer-callback-leak.html:11
>> +function test0() {
> 
> can you find a better name than "test0"?

Done, changed it to accessToObserverInCallback

>> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
>> +        }
> 
> I don't understand  this handler variable? Is it duplicating the handler below?

Ah, yes, I should remove it! Thanks!
Comment 16 cathiechen 2020-10-21 09:45:51 PDT
Created attachment 411999 [details]
Patch
Comment 17 Ryosuke Niwa 2020-10-21 12:10:00 PDT
Comment on attachment 411999 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=411999&action=review

> Source/WebCore/bindings/js/JSResizeObserverCustom.cpp:36
> +    if (wrapped().callback())
> +        wrapped().callback()->visitJSFunction(visitor);

This is not safe. This function runs concurrently to the main thread.
After calling callback, the callback may be set to nullptr in the main thread before visitJSFunction is called.
Store callback to a raw pointer first.

> Source/WebCore/page/ResizeObserver.h:68
> +    ResizeObserverCallback* callback() { return m_callback.get(); }
> +

Let's call this callbackConcurrently() to signify the fact we can't mutate anything ref count, etc...

> LayoutTests/resize-observer/resize-observer-callback-leak.html:27
> +                        assert_true(observerCount < iterationCount, 'Resize observers should be collected.');

Don't assume that the number of resize observers is 0 at the beginning of the test.
There might be other resize observers created for other tests that ran before this test.
Instead, record the number of resize observes at the beginning of the test, and subtract the current number.

> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
> +                }, 1000);

What is this step_timeout about? Why is 1s sufficient?
On bots, we probably want a longer timeout.
Comment 18 cathiechen 2020-10-21 22:42:13 PDT
Comment on attachment 411999 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=411999&action=review

Hi Ryosuke,
Thanks for the review:)

>> Source/WebCore/bindings/js/JSResizeObserverCustom.cpp:36
>> +        wrapped().callback()->visitJSFunction(visitor);
> 
> This is not safe. This function runs concurrently to the main thread.
> After calling callback, the callback may be set to nullptr in the main thread before visitJSFunction is called.
> Store callback to a raw pointer first.

Done, thanks!

>> Source/WebCore/page/ResizeObserver.h:68
>> +
> 
> Let's call this callbackConcurrently() to signify the fact we can't mutate anything ref count, etc...

Done

>> LayoutTests/resize-observer/resize-observer-callback-leak.html:27
>> +                        assert_true(observerCount < iterationCount, 'Resize observers should be collected.');
> 
> Don't assume that the number of resize observers is 0 at the beginning of the test.
> There might be other resize observers created for other tests that ran before this test.
> Instead, record the number of resize observes at the beginning of the test, and subtract the current number.

Done

>> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
>> +                }, 1000);
> 
> What is this step_timeout about? Why is 1s sufficient?
> On bots, we probably want a longer timeout.

My initial thought is to call gc() in other animation frame, so that it garbage collects all the observers (except the last one).
So 1s would be sufficient.
Comment 19 cathiechen 2020-10-21 22:46:37 PDT
Created attachment 412066 [details]
Patch
Comment 20 Ryosuke Niwa 2020-10-21 22:54:31 PDT
(In reply to cathiechen from comment #18)
> Comment on attachment 411999 [details]
> Patch
>
> >> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
> >> +                }, 1000);
> > 
> > What is this step_timeout about? Why is 1s sufficient?
> > On bots, we probably want a longer timeout.
> 
> My initial thought is to call gc() in other animation frame, so that it
> garbage collects all the observers (except the last one).
> So 1s would be sufficient.

Can't we explicitly wait for that to happen instead?
Comment 21 cathiechen 2020-10-22 01:35:55 PDT
(In reply to Ryosuke Niwa from comment #20)
> (In reply to cathiechen from comment #18)
> > Comment on attachment 411999 [details]
> > Patch
> >
> > >> LayoutTests/resize-observer/resize-observer-callback-leak.html:30
> > >> +                }, 1000);
> > > 
> > > What is this step_timeout about? Why is 1s sufficient?
> > > On bots, we probably want a longer timeout.
> > 
> > My initial thought is to call gc() in other animation frame, so that it
> > garbage collects all the observers (except the last one).
> > So 1s would be sufficient.
> 
> Can't we explicitly wait for that to happen instead?

Ah, yes, we can do that! It becomes more clear. Thanks!
Comment 22 cathiechen 2020-10-22 01:38:45 PDT
Created attachment 412072 [details]
Patch
Comment 23 EWS 2020-10-22 05:41:14 PDT
Committed r268860: <https://trac.webkit.org/changeset/268860>

All reviewed patches have been landed. Closing bug and clearing flags on attachment 412072 [details].
Comment 24 Ryosuke Niwa 2020-10-22 10:55:52 PDT
Comment on attachment 412072 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=412072&action=review

> Source/WebCore/bindings/js/JSResizeObserverCustom.cpp:36
> +    ResizeObserverCallback* callback = wrapped().callbackConcurrently();
> +    if (callback)

Should have declared callback inside if.