Skip to content

Commit d61e920

Browse files
committed
Fix leak linked to event listeners on quality change
The #1778 and #1779 issues / PR noticed a leak that seem to arise when multiple quality switches happen. I'm still unsure of the severity (looking at it what this fixes seems very minimal, and we did not notice this yet on production at Canal+ including on low-memory devices for what seems to be a change that has been here for 2 years - but external contributors actually did notice a leak so maybe a set of conditions amplify the issue), but looking closely at the code in question, there does seem to be an improper event listener clean-up on a quality switch. The issue is rooted in the complexity behind how quality switch happen: - depending on heuristics, we may either perform an "urgent" quality switch (where we directly cancel the requests linked to the older quality) or a non-urgent one (where we will wait for the current requests to finish and only after load the new quality). - If non-urgent, we want to still do the requests for the new quality as soon as we can, thus we parallelize it with the pushing operations of the segments we just loaded from the previous quality. Thus when a "non-urgent" quality switch happen, there might be a short time where several quality-linked modules are running at the same time (the old one to push segments, the new one to load them), whereas at first glance they seemed conflicting (one loads and push one quality, the other loads and push another quality of the same thing). This lead to an awkward architecture where the clean-up process of those modules is subtly different than in other RxPlayer modules - this one has actually 2 means to terminate: - its `terminate` parameter, kind of like a SIGTERM: just finish what you're doing (e.g. finish loading segments and/or pushing them then stop). Once the `RepresentationStream` (the module in question) has finished loading segments, it sends a `terminating` event - but it might still be pushing segments. It however has no event to indicate that segments have been pushed, for now. - its `cancelSignal` parameter, more akin to a SIGKILL: terminate everything now without delay. This one is e.g. triggered when stopping the content, changing the track etc. The leaking event listener was wrongly linked to that "SIGKILL" signal, even if it was intended to be cleaned up when the module is not needed anymore. When the module was only "SIGTERMed", it was not cleaned up. --- I chose to clean it up not right when "SIGTERMed", but when the module itself anounced that it is "terminating" (it is done loading and is now pushing segments). I found it to be more appropriate for the logic in question and a corresponding `CancellationSignal` was already used for other similar logic linked to the same lifetime.
1 parent 00f9b0c commit d61e920

1 file changed

Lines changed: 40 additions & 21 deletions

File tree

src/core/stream/adaptation/adaptation_stream.ts

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,12 @@ export default function AdaptationStream(
215215
* error or on some cancellation.
216216
* @param {Object} choice - The last Representations choice that has been
217217
* made.
218-
* @param {Object} fnCancelSignal - `CancellationSignal` allowing to cancel
219-
* everything this function is doing and free all related resources.
218+
* @param {Object} repsChoiceCancelSignal - `CancellationSignal` allowing to
219+
* cancel everything this function is doing and free all related resources.
220220
*/
221221
async function onRepresentationsChoiceChange(
222222
choice: IRepresentationsChoice,
223-
fnCancelSignal: CancellationSignal,
223+
repsChoiceCancelSignal: CancellationSignal,
224224
): Promise<void> {
225225
// First check if we should perform any action regarding what was previously
226226
// in the buffer
@@ -243,7 +243,7 @@ export default function AdaptationStream(
243243
return queueMicrotask(() => {
244244
playbackObserver.listen(
245245
() => {
246-
if (fnCancelSignal.isCancelled()) {
246+
if (repsChoiceCancelSignal.isCancelled()) {
247247
return;
248248
}
249249
const { DELTA_POSITION_AFTER_RELOAD } = config.getCurrent();
@@ -255,21 +255,21 @@ export default function AdaptationStream(
255255
stayInPeriod: true,
256256
});
257257
},
258-
{ includeLastObservation: true, clearSignal: fnCancelSignal },
258+
{ includeLastObservation: true, clearSignal: repsChoiceCancelSignal },
259259
);
260260
});
261261

262262
case "flush-buffer": // Clean + flush
263263
case "clean-buffer": // Just clean
264264
for (const range of switchStrat.value) {
265265
await segmentSink.removeBuffer(range.start, range.end);
266-
if (fnCancelSignal.isCancelled()) {
266+
if (repsChoiceCancelSignal.isCancelled()) {
267267
return;
268268
}
269269
}
270270
if (switchStrat.type === "flush-buffer") {
271271
callbacks.needsBufferFlush();
272-
if (fnCancelSignal.isCancelled()) {
272+
if (repsChoiceCancelSignal.isCancelled()) {
273273
return;
274274
}
275275
}
@@ -278,7 +278,7 @@ export default function AdaptationStream(
278278
assertUnreachable(switchStrat);
279279
}
280280

281-
recursivelyCreateRepresentationStreams(fnCancelSignal);
281+
recursivelyCreateRepresentationStreams(repsChoiceCancelSignal);
282282
}
283283

284284
/**
@@ -410,30 +410,40 @@ export default function AdaptationStream(
410410
* indicating that the `RepresentationStream` should stop what it's doing.
411411
* @param {Object} representationStreamCallbacks - Callbacks to call on
412412
* various `RepresentationStream` events.
413-
* @param {Object} fnCancelSignal - `CancellationSignal` which will abort
414-
* anything this function is doing and free allocated resources.
413+
* @param {Object} globalCancelSignal - `CancellationSignal` which will
414+
* immediately clean every resources allocated by this function.
415415
*/
416416
function createRepresentationStream(
417417
representation: IRepresentation,
418418
terminateCurrentStream: IReadOnlySharedReference<ITerminationOrder | null>,
419419
representationStreamCallbacks: IRepresentationStreamCallbacks,
420-
fnCancelSignal: CancellationSignal,
420+
globalCancelSignal: CancellationSignal,
421421
): void {
422422
/** Set to `true` if we've encountered an error with this `RepresentationStream` */
423423
let hasEncounteredError = false;
424424

425-
const bufferGoalCanceller = new TaskCanceller(
426-
"AdaptationStream: BufferGoal " + adaptation.type,
425+
/**
426+
* Construct a `TaskCanceller` linked to the resources associated to the
427+
* `RepresentationStream` we will create here.
428+
*
429+
* It will be cancelled right when the `RepresentationStream` announces that
430+
* it is "terminating" - which implies that it is done loading new data and
431+
* will clean itself automatically once it has pushed all loaded segments.
432+
* We keep it distinct that `globalCancelSignal` as the latter's lifetime is
433+
* not linked to the lifetime of our `RepresentationStream`.
434+
*/
435+
const terminatingCanceller = new TaskCanceller(
436+
"RepresentationStream-linked listeners",
427437
);
428-
bufferGoalCanceller.linkToSignal(fnCancelSignal);
438+
terminatingCanceller.linkToSignal(globalCancelSignal);
429439

430440
/** Actually built buffer size, in seconds. */
431441
const bufferGoal = createMappedReference(
432442
wantedBufferAhead,
433443
(prev) => {
434444
return getBufferGoal(representation, prev);
435445
},
436-
bufferGoalCanceller.signal,
446+
terminatingCanceller.signal,
437447
);
438448

439449
const maxBufferSize =
@@ -480,20 +490,20 @@ export default function AdaptationStream(
480490

481491
// We wait 4 seconds to let the situation evolve by itself before
482492
// retrying loading segments with a lower buffer goal
483-
cancellableSleep(4000, fnCancelSignal)
493+
cancellableSleep(4000, globalCancelSignal)
484494
.then(() => {
485495
return createRepresentationStream(
486496
representation,
487497
terminateCurrentStream,
488498
representationStreamCallbacks,
489-
fnCancelSignal,
499+
globalCancelSignal,
490500
);
491501
})
492502
.catch(noop);
493503
}
494504
},
495505
terminating() {
496-
bufferGoalCanceller.cancel("Representation terminating");
506+
terminatingCanceller.cancel("Representation terminating");
497507
representationStreamCallbacks.terminating();
498508
},
499509
});
@@ -512,7 +522,16 @@ export default function AdaptationStream(
512522
},
513523
},
514524
updatedCallbacks,
515-
fnCancelSignal,
525+
// NOTE: We give the long-lived `globalCancelSignal` here (and not
526+
// `terminatingCanceller.signal`) on purpose.
527+
// `RepresentationStream` should clean-up themselves automatically based
528+
// on their `terminate` parameter.
529+
//
530+
// This `CancellationSignal` is a killswitch which if if triggered too
531+
// soon might interrupt some async operations done when this
532+
// `RepresentationStream` is terminating: e.g. pushing the segments it
533+
// just loaded.
534+
globalCancelSignal,
516535
);
517536

518537
// reload if the Representation disappears from the Manifest
@@ -525,7 +544,7 @@ export default function AdaptationStream(
525544
if (updated.adaptation === adaptation.id) {
526545
for (const rep of updated.removedRepresentations) {
527546
if (rep === representation.id) {
528-
if (fnCancelSignal.isCancelled()) {
547+
if (terminatingCanceller.isUsed()) {
529548
return;
530549
}
531550
return callbacks.waitingMediaSourceReload({
@@ -543,7 +562,7 @@ export default function AdaptationStream(
543562
}
544563
}
545564
},
546-
fnCancelSignal,
565+
terminatingCanceller.signal,
547566
);
548567
}
549568

0 commit comments

Comments
 (0)