Skip to content

Commit 7877902

Browse files
committed
[Modern Media Controls] HTMLMediaElement is never destroyed when showing media controls
https://bugs.webkit.org/show_bug.cgi?id=270571 Reviewed by Xabier Rodriguez-Calvar. At least in GStreamer-based ports (WPE and WebKitGTK, I haven't checked on Mac ports because I don't have the proper environment easily available), the media element is leaked after explicit deinitialization and detaching from the HTML document, even after manually triggering garbage collection (GC) using the web inspector. See: #1285 After some debugging, we've detected that 2 extra references to HTMLMediaElement appear when using the controls (3 refs in total), while in a scenario where the controls are hidden on purpose only 1 reference remains, which is released as soon as the GC kicks in. Those references are held (or transitively held) by the MediaController, the shadowRoot referenced by the MediaController and by the IconService, and the <div> element that MediaController adds to the shadowRoot as a container for the controls. This commit adds code to use WeakRefs to the ShadowRoot and to the MediaJSWrapper (media) on MediaController, and to the ShadowRoot on the iconService, instead of the original objects. This allows the garbage collector to kick in when needed and have those objects freed automatically. The WeakRefs are transparently exposed as regular objects by using properties (get/set), to avoid the need to change a lot of code that expects regular objects. There's still the <div> element added to the ShadowRoot, which transitively holds a reference that prevents GC. There's no good place to remove that element, so I removed it in the "less bad place", which is HTMLMediaElement::pauseAfterDetachedTask(). A new deinitialize() JS function takes care of that. Unfortunately, the element can still be used after deinitialization, so there's also a method to reinitialize() it if needed and an extra ControlsState to mark the element as PartiallyDeinitialized in order to know when to recover from that state. * Source/WebCore/Modules/modern-media-controls/controls/icon-service.js: Store shadowRoot as a WeakRef. (const.iconService.new.IconService.prototype.get shadowRoot): Expose the shadowRootWeakRef as a regular shadowRoot object by binding it to the shadowRoot property. (const.iconService.new.IconService.prototype.set shadowRoot): Ditto, but for the setter. * Source/WebCore/Modules/modern-media-controls/media/media-controller.js: (MediaController): Store shadowRoot and media as WeakRefs. (MediaController.prototype.get media): Expose the mediaWeakRef as a regular media object by binding it to the media property. (MediaController.prototype.get shadowRoot): Expose the shadowRootWeakRef as a regular shadowRoot object by binding it to the shadowRoot property. (MediaController.prototype.get isFullscreen): Take into account the case where media can be null. (MediaController.prototype.deinitialize): Function called from HTMLMediaElement to deinitialize the MediaController. Just removes the shadowRoot child. (MediaController.prototype.reinitialize): Function called from HTMLMediaElement to reinitialize the MediaController. Readds the shadowRoot child and sets again the WeakRefs that might have become by lack of use of the main objects. * Source/WebCore/html/HTMLMediaElement.cpp: (WebCore::convertEnumerationToString): Utility function to get a printable version of ControlsState. Useful for debugging. (WebCore::HTMLMediaElement::pauseAfterDetachedTask): Deinitialize the MediaController by calling its deinitialize() JS method. (WebCore::HTMLMediaElement::ensureMediaControls): Support the case of reinitialization. Call the reinitialize() JS method in MediaController in that case. * Source/WebCore/html/HTMLMediaElement.h: Added new PartiallyDeinitialized state to ControlsState. Give friend access to convertEnumerationToString() so it can do its job. Canonical link: https://commits.webkit.org/277196@main
1 parent 8cc0b34 commit 7877902

4 files changed

Lines changed: 120 additions & 3 deletions

File tree

Source/WebCore/Modules/modern-media-controls/controls/icon-service.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ const iconService = new class IconService {
7474
}
7575

7676
// Public
77+
get shadowRoot()
78+
{
79+
return this.shadowRootWeakRef ? this.shadowRootWeakRef.deref() : null;
80+
}
81+
82+
set shadowRoot(shadowRoot)
83+
{
84+
this.shadowRootWeakRef = new WeakRef(shadowRoot);
85+
}
7786

7887
imageForIconAndLayoutTraits(icon, layoutTraits)
7988
{

Source/WebCore/Modules/modern-media-controls/media/media-controller.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@
2525

2626
class MediaController
2727
{
28-
2928
constructor(shadowRoot, media, host)
3029
{
31-
this.shadowRoot = shadowRoot;
32-
this.media = media;
30+
this.shadowRootWeakRef = new WeakRef(shadowRoot);
31+
this.mediaWeakRef = new WeakRef(media);
3332
this.host = host;
3433

3534
this.fullscreenChangeEventType = media.webkitSupportsPresentationMode ? "webkitpresentationmodechanged" : "webkitfullscreenchange";
@@ -65,6 +64,16 @@ class MediaController
6564
}
6665

6766
// Public
67+
get media()
68+
{
69+
return this.mediaWeakRef ? this.mediaWeakRef.deref() : null;
70+
}
71+
72+
get shadowRoot()
73+
{
74+
75+
return this.shadowRootWeakRef ? this.shadowRootWeakRef.deref() : null;
76+
}
6877

6978
get isAudio()
7079
{
@@ -91,6 +100,9 @@ class MediaController
91100

92101
get isFullscreen()
93102
{
103+
if (!this.media)
104+
return false;
105+
94106
return this.media.webkitSupportsPresentationMode ? this.media.webkitPresentationMode === "fullscreen" : this.media.webkitDisplayingFullscreen;
95107
}
96108

@@ -205,6 +217,24 @@ class MediaController
205217
}
206218
}
207219

220+
// HTMLMediaElement
221+
222+
deinitialize()
223+
{
224+
this.shadowRoot.removeChild(this.container);
225+
return true;
226+
}
227+
228+
reinitialize(shadowRoot, media, host)
229+
{
230+
iconService.shadowRoot = shadowRoot;
231+
this.shadowRootWeakRef = new WeakRef(shadowRoot);
232+
this.mediaWeakRef = new WeakRef(media);
233+
this.host = host;
234+
shadowRoot.appendChild(this.container);
235+
return true;
236+
}
237+
208238
// Private
209239

210240
_supportingObjectClasses()

Source/WebCore/html/HTMLMediaElement.cpp

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ String convertEnumerationToString(HTMLMediaElement::TextTrackVisibilityCheckType
294294
return values[static_cast<size_t>(enumerationValue)];
295295
}
296296

297+
static JSC::JSValue controllerJSValue(JSC::JSGlobalObject& lexicalGlobalObject, JSDOMGlobalObject&, HTMLMediaElement&);
298+
297299
class TrackDisplayUpdateScope {
298300
public:
299301
TrackDisplayUpdateScope(HTMLMediaElement& element)
@@ -444,6 +446,7 @@ HTMLMediaElement::HTMLMediaElement(const QualifiedName& tagName, Document& docum
444446
, m_parsingInProgress(createdByParser)
445447
, m_elementIsHidden(document.hidden())
446448
, m_creatingControls(false)
449+
, m_partiallyDeinitialized(false)
447450
, m_receivedLayoutSizeChanged(false)
448451
, m_hasEverNotifiedAboutPlaying(false)
449452
, m_hasEverHadAudio(false)
@@ -877,6 +880,37 @@ void HTMLMediaElement::pauseAfterDetachedTask()
877880
if (m_videoFullscreenMode == VideoFullscreenModeStandard)
878881
exitFullscreen();
879882

883+
#if ENABLE(MODERN_MEDIA_CONTROLS)
884+
if (!m_creatingControls && !m_partiallyDeinitialized && m_mediaControlsHost) {
885+
// Call MediaController.deinitialize() to get rid of circular references.
886+
m_partiallyDeinitialized = setupAndCallJS([this](JSDOMGlobalObject& globalObject, JSC::JSGlobalObject& lexicalGlobalObject, ScriptController&, DOMWrapperWorld&) {
887+
auto& vm = globalObject.vm();
888+
auto scope = DECLARE_THROW_SCOPE(vm);
889+
890+
auto controllerValue = controllerJSValue(lexicalGlobalObject, globalObject, *this);
891+
RETURN_IF_EXCEPTION(scope, false);
892+
auto* controllerObject = controllerValue.toObject(&lexicalGlobalObject);
893+
RETURN_IF_EXCEPTION(scope, false);
894+
895+
auto functionValue = controllerObject->get(&lexicalGlobalObject, JSC::Identifier::fromString(vm, "deinitialize"_s));
896+
if (UNLIKELY(scope.exception()) || functionValue.isUndefinedOrNull())
897+
return false;
898+
899+
auto* function = functionValue.toObject(&lexicalGlobalObject);
900+
RETURN_IF_EXCEPTION(scope, false);
901+
902+
auto callData = JSC::getCallData(function);
903+
if (callData.type == JSC::CallData::Type::None)
904+
return false;
905+
906+
auto resultValue = JSC::call(&lexicalGlobalObject, function, callData, controllerObject, JSC::MarkedArgumentBuffer());
907+
RETURN_IF_EXCEPTION(scope, false);
908+
909+
return resultValue.toBoolean(&lexicalGlobalObject);
910+
});
911+
}
912+
#endif // ENABLE(MODERN_MEDIA_CONTROLS)
913+
880914
if (!m_player)
881915
return;
882916

@@ -4642,6 +4676,46 @@ void HTMLMediaElement::ensureMediaControlsShadowRoot()
46424676
if (m_creatingControls)
46434677
return;
46444678

4679+
if (m_partiallyDeinitialized) {
4680+
m_partiallyDeinitialized = !setupAndCallJS([this](JSDOMGlobalObject& globalObject, JSC::JSGlobalObject& lexicalGlobalObject, ScriptController&, DOMWrapperWorld&) {
4681+
auto& vm = globalObject.vm();
4682+
auto scope = DECLARE_THROW_SCOPE(vm);
4683+
4684+
auto controllerValue = controllerJSValue(lexicalGlobalObject, globalObject, *this);
4685+
RETURN_IF_EXCEPTION(scope, false);
4686+
auto* controllerObject = controllerValue.toObject(&lexicalGlobalObject);
4687+
RETURN_IF_EXCEPTION(scope, false);
4688+
4689+
auto functionValue = controllerObject->get(&lexicalGlobalObject, JSC::Identifier::fromString(vm, "reinitialize"_s));
4690+
if (UNLIKELY(scope.exception()) || functionValue.isUndefinedOrNull())
4691+
return false;
4692+
4693+
if (!m_mediaControlsHost)
4694+
m_mediaControlsHost = MediaControlsHost::create(*this);
4695+
4696+
auto mediaJSWrapper = toJS(&lexicalGlobalObject, &globalObject, *this);
4697+
auto mediaControlsHostJSWrapper = toJS(&lexicalGlobalObject, &globalObject, *m_mediaControlsHost.copyRef());
4698+
4699+
JSC::MarkedArgumentBuffer argList;
4700+
argList.append(toJS(&lexicalGlobalObject, &globalObject, Ref { ensureUserAgentShadowRoot() }));
4701+
argList.append(mediaJSWrapper);
4702+
argList.append(mediaControlsHostJSWrapper);
4703+
ASSERT(!argList.hasOverflowed());
4704+
4705+
auto* function = functionValue.toObject(&lexicalGlobalObject);
4706+
RETURN_IF_EXCEPTION(scope, false);
4707+
4708+
auto callData = JSC::getCallData(function);
4709+
if (callData.type == JSC::CallData::Type::None)
4710+
return false;
4711+
4712+
auto resultValue = JSC::call(&lexicalGlobalObject, function, callData, controllerObject, argList);
4713+
RETURN_IF_EXCEPTION(scope, false);
4714+
4715+
return resultValue.toBoolean(&lexicalGlobalObject);
4716+
});
4717+
}
4718+
46454719
m_creatingControls = true;
46464720
ensureUserAgentShadowRoot();
46474721
m_creatingControls = false;
@@ -7668,6 +7742,9 @@ bool HTMLMediaElement::ensureMediaControlsInjectedScript()
76687742
if (mediaControlsScripts.isEmpty())
76697743
return false;
76707744

7745+
if (m_partiallyDeinitialized)
7746+
return true;
7747+
76717748
return setupAndCallJS([mediaControlsScripts = WTFMove(mediaControlsScripts)](JSDOMGlobalObject& globalObject, JSC::JSGlobalObject& lexicalGlobalObject, ScriptController& scriptController, DOMWrapperWorld& world) {
76727749
auto& vm = globalObject.vm();
76737750
auto scope = DECLARE_CATCH_SCOPE(vm);

Source/WebCore/html/HTMLMediaElement.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,7 @@ class HTMLMediaElement
11411141
bool m_elementIsHidden : 1;
11421142
bool m_elementWasRemovedFromDOM : 1;
11431143
bool m_creatingControls : 1;
1144+
bool m_partiallyDeinitialized : 1;
11441145
bool m_receivedLayoutSizeChanged : 1;
11451146
bool m_hasEverNotifiedAboutPlaying : 1;
11461147

0 commit comments

Comments
 (0)