Skip to content

Commit 423a02a

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 423a02a

1 file changed

Lines changed: 42 additions & 21 deletions

File tree

src/core/stream/adaptation/adaptation_stream.ts

Lines changed: 42 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,41 @@ 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`, triggered once the `RepresentationStream` we
427+
* will create here announces that it is "terminating" (implies that it is
428+
* done loading new data and will clean itself automatically once it has
429+
* pushed all loaded segments).
430+
*
431+
* We keep it distinct from `globalCancelSignal` as the latter's lifetime may
432+
* be much much longer than our `RepresentationStream`'s.
433+
* Thus it wouldn't be adapted as a canceller for the listeners we're
434+
* registering here.
435+
*/
436+
const terminatingCanceller = new TaskCanceller(
437+
"RepresentationStream-linked listeners in AdaptationStream",
427438
);
428-
bufferGoalCanceller.linkToSignal(fnCancelSignal);
439+
terminatingCanceller.linkToSignal(globalCancelSignal);
429440

430441
/** Actually built buffer size, in seconds. */
431442
const bufferGoal = createMappedReference(
432443
wantedBufferAhead,
433444
(prev) => {
434445
return getBufferGoal(representation, prev);
435446
},
436-
bufferGoalCanceller.signal,
447+
terminatingCanceller.signal,
437448
);
438449

439450
const maxBufferSize =
@@ -480,20 +491,21 @@ export default function AdaptationStream(
480491

481492
// We wait 4 seconds to let the situation evolve by itself before
482493
// retrying loading segments with a lower buffer goal
483-
cancellableSleep(4000, fnCancelSignal)
494+
// If the `RepresentationStream` was terminating anyway, just exits
495+
cancellableSleep(4000, terminatingCanceller.signal)
484496
.then(() => {
485497
return createRepresentationStream(
486498
representation,
487499
terminateCurrentStream,
488500
representationStreamCallbacks,
489-
fnCancelSignal,
501+
globalCancelSignal,
490502
);
491503
})
492504
.catch(noop);
493505
}
494506
},
495507
terminating() {
496-
bufferGoalCanceller.cancel("Representation terminating");
508+
terminatingCanceller.cancel("Representation terminating");
497509
representationStreamCallbacks.terminating();
498510
},
499511
});
@@ -512,7 +524,16 @@ export default function AdaptationStream(
512524
},
513525
},
514526
updatedCallbacks,
515-
fnCancelSignal,
527+
// NOTE: We give the long-lived `globalCancelSignal` here (and not
528+
// `terminatingCanceller.signal`) on purpose.
529+
// `RepresentationStream` should clean-up themselves automatically based
530+
// on their `terminate` parameter.
531+
//
532+
// This `CancellationSignal` is a killswitch which if triggered too
533+
// soon might interrupt some async operations done when this
534+
// `RepresentationStream` is terminating: e.g. stop pushing the segments
535+
// it has just loaded.
536+
globalCancelSignal,
516537
);
517538

518539
// reload if the Representation disappears from the Manifest
@@ -525,7 +546,7 @@ export default function AdaptationStream(
525546
if (updated.adaptation === adaptation.id) {
526547
for (const rep of updated.removedRepresentations) {
527548
if (rep === representation.id) {
528-
if (fnCancelSignal.isCancelled()) {
549+
if (terminatingCanceller.isUsed()) {
529550
return;
530551
}
531552
return callbacks.waitingMediaSourceReload({
@@ -543,7 +564,7 @@ export default function AdaptationStream(
543564
}
544565
}
545566
},
546-
fnCancelSignal,
567+
terminatingCanceller.signal,
547568
);
548569
}
549570

0 commit comments

Comments
 (0)