Skip to content

video-6336: media element leak causes error on chrome 92 #1530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ The Twilio Programmable Video SDKs use [Semantic Versioning](http://www.semver.o

**Version 1.x will End of Life on September 8th, 2021.** Check [this guide](https://www.twilio.com/docs/video/migrating-1x-2x) to plan your migration to the latest 2.x version. Support for the 1.x version ended on December 4th, 2020.

2.15.3 (In Progress)
====================

Bug Fixes
---------
Chrome 92 [started enforcing](https://chromium-review.googlesource.com/c/chromium/src/+/2816118) limit on number of WebMediaPlayers. This blocks creation of further WebMediaPlayers once there are already 75 (desktop) or 40 (mobile) players. Fixed a related bug where the SDK was not cleaning up an internally maintained media elements.
Please ensure that your application cleans up media elements as well after they are detached.
```js
const elements = track.detach();
elements.forEach(el => {
el.remove();
el.srcObject = null;
});
```

2.15.2 (July 15, 2021)
======================

Expand Down
7 changes: 3 additions & 4 deletions lib/media/track/mediatrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ class MediaTrack extends Track {
_end() {
this._log.debug('Ended');
if (this._dummyEl) {
this._detachElement(this._dummyEl);
this._dummyEl.remove();
this._dummyEl.srcObject = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to double check. Since we're not calling detachElement anymore, is setting srcObject to null enough, and we don't need to call removeTrack?

    const mediaStream = this._dummyEl.srcObject;
    if (mediaStream instanceof this._MediaStream) {
      mediaStream.removeTrack(this.processedTrack || this.mediaStreamTrack);
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats correct, removeTrack applies only for elements attached by application - since they may already have a mediaStream, in that case we add/remove track from it.

this._dummyEl.oncanplay = null;
this._dummyEl = null;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one @makarandp0 . So we are only detaching elements, not properly disposing them. I've learned that we also need to pause the element first, then, call load after setting the srcObject to null. Something like

this._detachElementInternal(this._dummyEl);
this._dummyEl.pause();
this._dummyEl.remove();
this._dummyEl.srcObject = null;
this._dummyEl.oncanplay = null;
this._dummyEl.load();
this._dummyEl = null;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, I will update and test per your suggestion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After experimenting a bit, I found that just setting srcObject to null is sufficient to avoid this issue. I will keep this simple and just reset srcObject as suggested as a workaround:

}

Expand All @@ -165,7 +167,6 @@ class MediaTrack extends Track {
: null;
this._elShims.set(el, shimMediaElement(el, onUnintentionallyPaused));
}

return el;
}

Expand Down Expand Up @@ -266,12 +267,10 @@ class MediaTrack extends Track {
if (!this._attachments.has(el)) {
return el;
}

const mediaStream = el.srcObject;
if (mediaStream instanceof this._MediaStream) {
mediaStream.removeTrack(this.processedTrack || this.mediaStreamTrack);
}

this._attachments.delete(el);

if (this._shouldShimAttachedElements && this._elShims.has(el)) {
Expand Down
1 change: 1 addition & 0 deletions test/integration/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require('./spec/bandwidthprofile/regressions');
require('./spec/bandwidthprofile/renderhints');
require('./spec/bandwidthprofile/video');
require('./spec/connect');
require('./spec/leaktests');
require('./spec/logger');
require('./spec/localparticipant/networkqualitylevel');
require('./spec/localparticipant/publishunpublishtrack');
Expand Down
132 changes: 132 additions & 0 deletions test/integration/spec/leaktests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-disable no-undefined */
'use strict';

const assert = require('assert');
const defaults = require('../../lib/defaults');
const { completeRoom, createRoom } = require('../../lib/rest');
const { audio: createLocalAudioTrack, video: createLocalVideoTrack } = require('../../../lib/createlocaltrack');
const connect = require('../../../lib/connect');
const getToken = require('../../lib/token');
const { isChrome } = require('../../lib/guessbrowser');

const {
smallVideoConstraints,
randomName,
participantsConnected,
tracksSubscribed,
tracksUnpublished,
waitForSometime,
waitFor,
trackStarted
} = require('../../lib/util');


function getTracksOfKind(participant, kind) {
return [...participant.tracks.values()].filter(remoteTrack => remoteTrack.kind === kind).map(({ track }) => track);
}

// eslint-disable-next-line no-warning-comments
// TODO(mpatwardhan): Fix VIDEO-6356 and enable this test for p2p rooms.
(isChrome && defaults.topology !== 'peer-to-peer' ? describe : describe.skip)('VIDEO-6336: media element leak detection', function() {
// eslint-disable-next-line no-invalid-this
this.timeout(5 * 60 * 1000);

it('tracks continue to start after 50 connect disconnects ', async () => {
const localAudioTrack = await createLocalAudioTrack({ fake: true });
const localVideoTrack = await createLocalVideoTrack(smallVideoConstraints);
const roomName = await createRoom(randomName(), defaults.topology);
const connectOptions = Object.assign({
name: roomName,
tracks: [localAudioTrack, localVideoTrack]
}, defaults);

const aliceRoom = await waitFor(connect(getToken('Alice'), connectOptions), 'Alice to connect to room');
async function joinRoomAndEnsureTracksStarted(i) {
// eslint-disable-next-line no-console
console.log(`${i}] connecting to room ${aliceRoom.sid}`);
const bobRoom = await waitFor(connect(getToken('Bob'), connectOptions), `${i}] Bob to join room: ${aliceRoom.sid}`);

// wait for Bob to see alice connected.
await waitFor(participantsConnected(bobRoom, 1), `${i}] Bob to see Alice connected: ${aliceRoom.sid}`);

const aliceRemote = bobRoom.participants.get(aliceRoom.localParticipant.sid);
await waitFor(tracksSubscribed(aliceRemote, 2), `${i}] Bob to see Alice's track: ${aliceRoom.sid}`);

const remoteVideoTracks = getTracksOfKind(aliceRemote, 'video');
const remoteAudioTracks = getTracksOfKind(aliceRemote, 'audio');
assert.strictEqual(remoteVideoTracks.length, 1);
assert.strictEqual(remoteAudioTracks.length, 1);

await waitFor(trackStarted(remoteVideoTracks[0]), `${i}] Bob to see Alice's video track started: ${aliceRoom.sid}`);
await waitFor(trackStarted(remoteAudioTracks[0]), `${i}] Bob to see Alice's audio track started: ${aliceRoom.sid}`);
bobRoom.disconnect();
await waitForSometime(1000);
}

// Alice joins room first.
for (let i = 0; i < 50; i++) {
// eslint-disable-next-line no-await-in-loop
await joinRoomAndEnsureTracksStarted(i);
}

aliceRoom.disconnect();
await completeRoom(aliceRoom.sid);
});

it('tracks continue to start after 50 publish/unpublish ', async () => {
const localAudioTrack = await createLocalAudioTrack({ fake: true });
const localVideoTrack = await createLocalVideoTrack(smallVideoConstraints);
const roomName = await createRoom(randomName(), defaults.topology);
const connectOptions = Object.assign({
name: roomName,
tracks: []
}, defaults);

const aliceRoom = await waitFor(connect(getToken('Alice'), connectOptions), 'Alice to connect to room');
const bobRoom = await waitFor(connect(getToken('Bob'), connectOptions), `Bob to join room: ${aliceRoom.sid}`);
// wait for Bob to see alice connected.
await waitFor(participantsConnected(bobRoom, 1), `Bob to see Alice connected: ${aliceRoom.sid}`);

const aliceRemote = bobRoom.participants.get(aliceRoom.localParticipant.sid);

async function publishAndVerifyStarted(i) {

// eslint-disable-next-line no-console
console.log(`${i}] Alice publishing tracks`);
// alice publishes two tracks.
const audioPublication = await waitFor(aliceRoom.localParticipant.publishTrack(localAudioTrack), `Alice to publish a audio track: ${aliceRoom.sid}`);
const videoPublication = await waitFor(aliceRoom.localParticipant.publishTrack(localVideoTrack), `Alice to publish a video track: ${aliceRoom.sid}`);

await waitFor(tracksSubscribed(aliceRemote, 2), `${i}] Bob to see Alice's track: ${aliceRoom.sid}`);

const remoteVideoTracks = getTracksOfKind(aliceRemote, 'video');
const remoteAudioTracks = getTracksOfKind(aliceRemote, 'audio');
assert.strictEqual(remoteVideoTracks.length, 1);
assert.strictEqual(remoteAudioTracks.length, 1);

// bob sees tracks published by alice started.
await waitFor(trackStarted(remoteVideoTracks[0]), `${i}] Bob to see Alice's video track started: ${aliceRoom.sid}`);
await waitFor(trackStarted(remoteAudioTracks[0]), `${i}] Bob to see Alice's audio track started: ${aliceRoom.sid}`);

const bobSeesUnpublished = tracksUnpublished(aliceRemote, 2);

// alice un-publishes tracks.
audioPublication.unpublish();
videoPublication.unpublish();

// bob sees tracks unpublished.
await waitFor(bobSeesUnpublished, `${i}] Bob to see tracks unpublished: ${aliceRoom.sid} `);

await waitForSometime(1000);
}

for (let i = 0; i < 50; i++) {
// eslint-disable-next-line no-await-in-loop
await publishAndVerifyStarted(i);
}

aliceRoom.disconnect();
await completeRoom(aliceRoom.sid);
});

});
4 changes: 4 additions & 0 deletions test/lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class HTMLElement {
return this;
}

remove() {

}

play() {
return Promise.resolve();
}
Expand Down
5 changes: 3 additions & 2 deletions test/unit/spec/media/track/localmediatrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const { defer } = require('../../../../../lib/util');
describe('#_setMediaStreamTrack', () => {
let dummyElement;
beforeEach(() => {
dummyElement = { oncanplay: 'bar' };
dummyElement = { oncanplay: 'bar', remove: sinon.spy() };
document.createElement = sinon.spy(() => {
return dummyElement;
});
Expand Down Expand Up @@ -340,7 +340,8 @@ const { defer } = require('../../../../../lib/util');
const dummyElement = {
oncanplay: null,
videoWidth: 320,
videoHeight: 240
videoHeight: 240,
remove: sinon.spy()
};

before(() => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/spec/media/track/mediatrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('MediaTrack', () => {
track._detachElement = sinon.spy();
track._attachments.delete = sinon.spy();

dummyElement = { oncanplay: 'bar' };
dummyElement = { oncanplay: 'bar', remove: sinon.spy() };
track._createElement = sinon.spy(() => {
return dummyElement;
});
Expand Down