From cf4d7da65d3feabb54ba407089dd45f822d65e4e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 31 Oct 2024 17:03:31 +0100 Subject: [PATCH] DRM: Second attempt to manage a `maxSessionCacheSize` on contents going over the limit This is a retry of #1511 (I even re-used the same git branch) because I found the work in that PR to be too complex. If you already read the previous PR, you can skip the `The issue` below. The issue ========= Conditions ---------- This work is about a specific and for now never seen issue that has chance to pop up under the following conditions: - The current content make use of per-Period key rotation (NOTE: this is also possible without key rotation, but this is made much more probable by it). - The device on which we we play that content has a very limited number of key slots available simultaneously for decryption (sometimes **VERY** limited, e.g. `6`, so we can at most rely on 6 keys simultaneously. We for now know of four different set-top boxes with that type of limitation, from two constructors. The `maxSessionCacheSize` option --------------------------------------------- Theoretically, an application can rely there on the `keySystems[].maxSessionCacheSize` option to set a maximum number of `MediaKeySession` we may keep at at the same time. _Note that we prefer here to rely on a number of `MediaKeySession` and not of keys, because the RxPlayer is not able to predict how many keys it will find inside a license (NOTE: to simplify, let's just say that 1 `MediaKeySession` == 1 license here, as it's very predominantly the case) nor is it able to not communicate some keys that are found in a given license._ _Yet an application generally has a rough idea of how many keys it's going to find in a license a most, and can set up its `maxSessionCacheSize` accordingly (e.g. if there's max `10` key slots available on the device and `5` keys per license maximum, it could just communicate to us a `maxSessionCacheSize` of `2`)._ So problem solved right? WRONG! The RxPlayer, when exploiting the `maxSessionCacheSize` property, will when that limit is reached just close the `MediaKeySession` it has least recently seen the need for (basically, when the RxPlayer loads segment for a new Period/track/Representation, it asks our decryption logic to make sure it has the right key, this is how our decryption logic know that a `MediaKeySession` or more precizely a key it can make use of, has been needed recently). Example scenario ---------------- So let's just imagine a simple scenario: 1. we're currently loading a Period `A` with encrypted content and buffering a future Period `B` with content encrypted with a different key. 2. we're asking the decryption logic to make sure the key is loaded for future Period `B` 3. The decryption logic sees that it doesn't have the key yet, and thus has to create a new `MediaKeySession`. Yet, it sees that it cannot create a new `MediaKeySession` for that new key without closing an old one to respect the `keySystems[].maxSessionCacheSize` option, and it turns out one relied on to play Period A was the least recently needed for some reason. 4. The decryption logic closes a `MediaKeySession` for Period `A` that was currently relied on. 5. ??? I don't know what happens, the `MediaKeySession` closure may fail in which case we could be left with too many key slots used on the device and some random error, or content playback may just fail directly. In any case, I wouldn't bet on something good happening. Other types of scenarios are possible, e.g. we could be closing a `MediaKeySession` needed in the future and not think to re-create it when playing that future Period, potentially leading to a future infinite rebuffering. Solution I'm proposing here =========================== In a previous PR, I tried to handle all cases but that became too complex and I know think that doing it in multiple steps may be easier to architects: we handle first the "simple" cases (which sadly are not the most frequent ones), we'll then see the harder cases. The simpler case it to just close `MediaKeySession` that are known to not be needed anymore if we go over the `maxSessionCacheSize` limit on the current content. To have a vague non-perfect idea of what is currently needed, we look at all `Period`s from the current position onward, list their key ids, compare with the keys currently handled by our DRM logic, and just close the ones that haven't been found. How I'm implementing this ========================= Detecting the issue ------------------- As we now have a difference in our `MediaKeySession`-closing algorithm depending on if the `MediaKeySession` is linked to the current content or not, I chose in our `ContentDecryptor` module that: 1. `MediaKeySession` that are not linked to the current content keep being closed like they were before: least recently needed first. 2. `MediaKeySession` that are linked to the current content are never directly closed by the `ContentDecryptor`. Instead, the `ContentDecryptor` module basically signals a `tooMuchSessions` event when only left with `MediaKeySession` for the current content yet going over the `maxSessionCacheSize` limit. It also doesn't create the `MediaKeySession` in that case. Fixing the situation -------------------- The `ContentDecryptor` now exposes a new method, `freeKeyIds`. The idea is that you communicate to it the key id you don't need anymore, then the `ContentDecryptor` will see if can consequently close some `MediaKeySession`. It is the role of the `ContentInitializer` to call this `freeKeyIds` method on key ids it doesn't seem to have the use of anymore (all key ids are in the payload of the `tooMuchSessions` event). Note: the new `ActiveSessionsStore` ----------------------------------- To allow the `ContentDecryptor` to easily know when it can restart creating `MediaKeySession` after encountering this `tooMuchSessions` situation and then having its `freeKeyIds` method called, I replaced its simple `_currentSessions` private array into a new kind of "MediaKeySession store" (a third one after the `LoadedSessionsStore` and the `PersistentSessionsStore`), called the `ActiveSessionsStore`, which also keeps a `isFull` boolean around. This new store's difference with the `LoadedSessionsStore` may be unclear at first but there's one: - The `LoadedSessionsStore` stores information on all `MediaKeySession` currently attached to a `MediaKeys` (and also creates / close them). - The `ActiveSessionsStore` is technically just an array of `MediaKeySession` information and a `isFull` flag, and its intended semantic is to represent all `MediaKeySession` that are "actively-used" by the `ContentDecryptor` (in implementation, it basically means all `MediaKeySession` linked to the current content). If you followed, note that the session information stored by the `LoadedSessionsStore` is a superset of the ones stored by the `ActiveSessionsStore` (the former contains all information from the latter) as "active" sessions are all currently "loaded". Writing that, I'm still unsure if the `isFull` flag would have more its place on the `LoadedSessionsStore` instead. After all `maxSessionCacheSize` technically applies to all "loaded" sessions, not just the "active" ones which is a concept only relied on by RxPlayer internals. We may discuss on what makes the most sense here. Remaining issues ================ This PR only fixes a fraction of the issue, actually the simpler part where we can close older `MediaKeySession` linked to the current content that we don't need anymore, like for example for a previous DASH Period. But there's also the risk of encountering that limit while preloading future contents encrypted through a different keys, or when seeking back at a previous DASH Period with different keys. In all those scenarios (which actually seems more probable), there's currently no fix, just error logs and multiple FIXME mentions in the code. Fixing this issue while keeping a readable code is very hard right now, moreover for what is only a suite of theoretical problems that has never been observed for now. So I sometimes wonder if the best compromise would not be to just let it happen, and have an heuristic somewhere else detecting the issue and fixing it by slightly reducing the experience (e.g. by reloading + disabling future Period pre-loading)... --- src/main_thread/decrypt/content_decryptor.ts | 287 ++++++++++++------ .../decrypt/create_or_load_session.ts | 39 ++- src/main_thread/decrypt/types.ts | 5 + .../decrypt/utils/active_sessions_store.ts | 139 +++++++++ .../utils/clean_old_loaded_sessions.ts | 38 ++- .../decrypt/utils/loaded_sessions_store.ts | 9 + .../init/directfile_content_initializer.ts | 3 + .../init/media_source_content_initializer.ts | 17 ++ .../handle_too_much_media_key_sessions.ts | 79 +++++ .../utils/initialize_content_decryption.ts | 8 + .../global/decrypt/content_decryptor.test.ts | 12 +- .../utils/clean_old_loaded_sessions.test.ts | 18 +- 12 files changed, 547 insertions(+), 107 deletions(-) create mode 100644 src/main_thread/decrypt/utils/active_sessions_store.ts create mode 100644 src/main_thread/init/utils/handle_too_much_media_key_sessions.ts diff --git a/src/main_thread/decrypt/content_decryptor.ts b/src/main_thread/decrypt/content_decryptor.ts index 6634097483..3b8d00e60c 100644 --- a/src/main_thread/decrypt/content_decryptor.ts +++ b/src/main_thread/decrypt/content_decryptor.ts @@ -30,11 +30,12 @@ import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal.ts" import arrayFind from "../../utils/array_find.ts"; import arrayIncludes from "../../utils/array_includes.ts"; import EventEmitter from "../../utils/event_emitter.ts"; +import flatMap from "../../utils/flat_map.ts"; import isNullOrUndefined from "../../utils/is_null_or_undefined.ts"; import { objectValues } from "../../utils/object_values.ts"; import { bytesToHex } from "../../utils/string_parsing.ts"; import TaskCanceller from "../../utils/task_canceller.ts"; -import createOrLoadSession from "./create_or_load_session.ts"; +import createOrLoadSession, { NoSessionSpaceError } from "./create_or_load_session.ts"; import type { ICodecSupportList } from "./find_key_system.ts"; import type { IMediaKeysInfos } from "./get_media_keys.ts"; import initMediaKeys from "./init_media_keys.ts"; @@ -50,6 +51,8 @@ import type { IContentDecryptorEvent, } from "./types.ts"; import { MediaKeySessionLoadingType, ContentDecryptorState } from "./types.ts"; +import type { IActiveSessionInfo } from "./utils/active_sessions_store.ts"; +import ActiveSessionsStore from "./utils/active_sessions_store.ts"; import { DecommissionedSessionError } from "./utils/check_key_statuses.ts"; import cleanOldStoredPersistentInfo from "./utils/clean_old_stored_persistent_info.ts"; import getDrmSystemId from "./utils/get_drm_system_id.ts"; @@ -112,12 +115,11 @@ export default class ContentDecryptor extends EventEmitter { this._onFatalError(err); @@ -422,6 +444,91 @@ export default class ContentDecryptor extends EventEmitter} keyIds + * @returns {boolean} + */ + public freeKeyIds(keyIds: Uint8Array[]): boolean { + if (this._stateData.isMediaKeysAttached !== MediaKeyAttachmentStatus.Attached) { + log.warn("DRM", "Invalid state when freeing key ids", { + attachmentState: this._stateData.isMediaKeysAttached, + }); + return false; + } + + const loadedSessionsStore = + this._stateData.data.mediaKeysData.stores.loadedSessionsStore; + const entries = loadedSessionsStore.getAll(); + for (const entry of entries) { + const { keySessionRecord } = entry; + const keyIdsFromSession = keySessionRecord.getAssociatedKeyIds(); + if (areAllKeyIdsContainedIn(keyIdsFromSession, keyIds)) { + loadedSessionsStore.closeSession(entry.mediaKeySession).catch((err) => { + const errorMsg = err instanceof Error ? err.message : "Unknown Error"; + log.warn("DRM", "Failed to close mediaKeySession in `freeKeyIds`", errorMsg); + }); + } + } + + const currentActiveSessions = this._activeSessionsStore.getSessions(); + for (let i = currentActiveSessions.length - 1; i >= 0; i--) { + const session = currentActiveSessions[i]; + if (!loadedSessionsStore.hasEntryForRecord(session.record)) { + this._activeSessionsStore.removeSession(session); + } + } + + return !this._activeSessionsStore.isFull(); + } + + /** + * Returns `true` if the `ContentDecryptor` is known to currently depend on + * too much `MediaKeySession` for the current configuration, and is thus + * unable to create more, limiting the possibility to handle further keys. + * + * To allow the `ContentDecryptor` to remove some of those `MediaKeySession`, + * you're advised to call the `freeKeyIds` method for keys you don't want to + * be handling anymore. + * + * @returns {boolean} + */ + public hasTooMuchSessions(): boolean { + return ( + this._initDataQueues.overflowQueue.length > 0 && this._activeSessionsStore.isFull() + ); + } + /** * Async logic run each time new initialization data has to be processed. * The promise return may reject, in which case a fatal error should be linked @@ -456,7 +563,7 @@ export default class ContentDecryptor extends EventEmitter x.source === MediaKeySessionLoadingType.Created, ); @@ -499,9 +606,9 @@ export default class ContentDecryptor extends EventEmitter x.source === MediaKeySessionLoadingType.Created, - ); + const createdSessions = this._activeSessionsStore + .getSessions() + .filter((x) => x.source === MediaKeySessionLoadingType.Created); const periodKeys = new Set(); addKeyIdsFromPeriod(periodKeys, period); for (const createdSess of createdSessions) { @@ -571,13 +678,51 @@ export default class ContentDecryptor extends EventEmitter s.record); + let sessionRes; + try { + sessionRes = await createOrLoadSession( + { + activeRecords, + initializationData, + sessionStores: stores, + sessionType: wantedSessionType, + maxSessionCacheSize, + }, + this._canceller.signal, + ); + } catch (err) { + if (!(err instanceof NoSessionSpaceError)) { + throw err; + } + if (this._isStopped()) { + return; + } + + // We have no space for further sessions for that content, trigger a + // "tooMuchSessions" event. + this._activeSessionsStore.markAsFull(); + if (this._initDataQueues.overflowQueue.indexOf(initializationData) === -1) { + this._initDataQueues.overflowQueue.push(initializationData); + } + + // We unlock the init data queue first, to avoid weird states. + this._unlockInitDataQueue(); + if (this._isStopped()) { + return; + } + if (this._activeSessionsStore.isFull()) { + this.trigger("tooMuchSessions", { + waitingKeyIds: flatMap(this._initDataQueues.overflowQueue, (k) => { + return k.keyIds ?? []; + }), + activeKeyIds: flatMap(activeRecords, (r: KeySessionRecord): Uint8Array[] => { + return r.getAssociatedKeyIds(); + }), + }); + } + return; + } if (this._isStopped()) { return; } @@ -588,7 +733,7 @@ export default class ContentDecryptor extends EventEmitter= 0) { - this._currentSessions.splice(indexOf, 1); - } + this._activeSessionsStore.removeSession(sessionInfo); if (initializationData.content !== undefined) { this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: [], @@ -724,10 +866,7 @@ export default class ContentDecryptor extends EventEmitter= 0) { - this._currentSessions.splice(indexInCurrent, 1); - } + this._activeSessionsStore.removeSession(sessionInfo); return Promise.resolve(); } throw new EncryptedMediaError( @@ -755,8 +894,9 @@ export default class ContentDecryptor extends EventEmitter - x.record.isCompatibleWith(initializationData), + const compatibleSessionInfo = arrayFind( + this._activeSessionsStore.getSessions(), + (x) => x.record.isCompatibleWith(initializationData), ); if (compatibleSessionInfo === undefined) { @@ -871,9 +1011,9 @@ export default class ContentDecryptor extends EventEmitter - x.record.isCompatibleWith(initData), + const compatibleSessionInfo = arrayFind( + this._activeSessionsStore.getSessions(), + (x) => x.record.isCompatibleWith(initData), ); if (compatibleSessionInfo === undefined) { return; } /** Remove the session from the currentSessions */ - const indexOf = this._currentSessions.indexOf(compatibleSessionInfo); - if (indexOf !== -1) { + const hasRemoved = this._activeSessionsStore.removeSession(compatibleSessionInfo); + if (hasRemoved) { log.debug( "DRM", "A session from a processed init is removed due to forceSessionRecreation policy.", ); - this._currentSessions.splice(indexOf, 1); } } @@ -946,7 +1085,8 @@ export default class ContentDecryptor extends EventEmitter 0 && + !this._activeSessionsStore.isFull() + ? this._initDataQueues.overflowQueue.shift() + : this._initDataQueues.mainQueue.shift(); if (initData === undefined) { return; } - this.onInitializationData(initData); + const { mediaKeysData } = this._stateData.data; + this._processInitializationData(initData, mediaKeysData).catch((err) => { + this._onFatalError(err); + }); } } @@ -1370,38 +1517,6 @@ type IErrorStateData = IContentDecryptorStateBase< null // data >; -/** Information linked to a session created by the `ContentDecryptor`. */ -interface IActiveSessionInfo { - /** - * Record associated to the session. - * Most notably, it allows both to identify the session as well as to - * anounce and find out which key ids are already handled. - */ - record: KeySessionRecord; - - /** Current keys' statuses linked that session. */ - keyStatuses: { - /** Key ids linked to keys that are "usable". */ - whitelisted: Uint8Array[]; - /** - * Key ids linked to keys that are not considered "usable". - * Content linked to those keys are not decipherable and may thus be - * fallbacked from. - */ - blacklisted: Uint8Array[]; - }; - - /** Source of the MediaKeySession linked to that record. */ - source: MediaKeySessionLoadingType; - - /** - * If different than `null`, all initialization data compatible with this - * processed initialization data has been blacklisted with this corresponding - * error. - */ - blacklistedSessionError: BlacklistedSessionError | null; -} - /** * Sent when the created (or already created) MediaKeys is attached to the * current HTMLMediaElement element. diff --git a/src/main_thread/decrypt/create_or_load_session.ts b/src/main_thread/decrypt/create_or_load_session.ts index 9f98c85ec8..798fe85b85 100644 --- a/src/main_thread/decrypt/create_or_load_session.ts +++ b/src/main_thread/decrypt/create_or_load_session.ts @@ -20,10 +20,14 @@ import type { CancellationSignal } from "../../utils/task_canceller.ts"; import createSession from "./create_session.ts"; import type { IProcessedProtectionData, IMediaKeySessionStores } from "./types.ts"; import { MediaKeySessionLoadingType } from "./types.ts"; -import cleanOldLoadedSessions from "./utils/clean_old_loaded_sessions.ts"; +import cleanOldLoadedSessions, { + NoSessionSpaceError, +} from "./utils/clean_old_loaded_sessions.ts"; import isSessionUsable from "./utils/is_session_usable.ts"; import type KeySessionRecord from "./utils/key_session_record.ts"; +export { NoSessionSpaceError }; + /** * Handle MediaEncryptedEvents sent by a HTMLMediaElement: * Either create a MediaKeySession, recuperate a previous MediaKeySession or @@ -34,24 +38,34 @@ import type KeySessionRecord from "./utils/key_session_record.ts"; * `EME_MAX_SIMULTANEOUS_MEDIA_KEY_SESSIONS` config property. * * You can refer to the events emitted to know about the current situation. - * @param {Object} initializationData - * @param {Object} stores - * @param {string} wantedSessionType - * @param {number} maxSessionCacheSize + * @param {Object} arg + * @param {Object} arg.initializationData + * @param {Object} arg.sessionStores + * @param {string} arg.sessionType + * @param {number} arg.maxSessionCacheSize * @param {Object} cancelSignal * @returns {Promise} */ export default async function createOrLoadSession( - initializationData: IProcessedProtectionData, - stores: IMediaKeySessionStores, - wantedSessionType: MediaKeySessionType, - maxSessionCacheSize: number, + { + initializationData, + sessionStores, + sessionType, + activeRecords, + maxSessionCacheSize, + }: { + initializationData: IProcessedProtectionData; + sessionStores: IMediaKeySessionStores; + sessionType: MediaKeySessionType; + activeRecords: KeySessionRecord[]; + maxSessionCacheSize: number; + }, cancelSignal: CancellationSignal, ): Promise { /** Store previously-loaded compatible MediaKeySession, if one. */ let previousLoadedSession: IMediaKeySession | null = null; - const { loadedSessionsStore, persistentSessionsStore } = stores; + const { loadedSessionsStore, persistentSessionsStore } = sessionStores; const entry = loadedSessionsStore.reuse(initializationData); if (entry !== null) { previousLoadedSession = entry.mediaKeySession; @@ -86,6 +100,7 @@ export default async function createOrLoadSession( await cleanOldLoadedSessions( loadedSessionsStore, + activeRecords, // Account for the next session we will be creating // Note that `maxSessionCacheSize < 0 has special semantic (no limit)` maxSessionCacheSize <= 0 ? maxSessionCacheSize : maxSessionCacheSize - 1, @@ -95,9 +110,9 @@ export default async function createOrLoadSession( } const evt = await createSession( - stores, + sessionStores, initializationData, - wantedSessionType, + sessionType, cancelSignal, ); return { diff --git a/src/main_thread/decrypt/types.ts b/src/main_thread/decrypt/types.ts index 6f3d1b5459..aa17c5bcbd 100644 --- a/src/main_thread/decrypt/types.ts +++ b/src/main_thread/decrypt/types.ts @@ -41,6 +41,11 @@ export interface IContentDecryptorEvent { */ warning: IPlayerError; + tooMuchSessions: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }; + /** * Event emitted when the `ContentDecryptor`'s state changed. * States are a central aspect of the `ContentDecryptor`, be sure to check the diff --git a/src/main_thread/decrypt/utils/active_sessions_store.ts b/src/main_thread/decrypt/utils/active_sessions_store.ts new file mode 100644 index 0000000000..69e7688d1c --- /dev/null +++ b/src/main_thread/decrypt/utils/active_sessions_store.ts @@ -0,0 +1,139 @@ +import type { BlacklistedSessionError } from "../session_events_listener.ts"; +import type { MediaKeySessionLoadingType } from "../types.ts"; +import type KeySessionRecord from "./key_session_record.ts"; + +/** + * Contains information about all key sessions loaded for the current + * content. + * This object is most notably used to check which keys are already obtained, + * thus avoiding to perform new unnecessary license requests and CDM + * interactions. + * + * It is important to create only one `ActiveSessionsStore` for a given + * `MediaKeys` to prevent conflicts. + * + * An `ActiveSessionsStore` instance can also be "marked" as full with the + * `markAsFull` method. + * "Marking as full" this way does not change your ability do add new session, + * but the `isFull` method will return `true` until at least a single session is + * removed from this `ActiveSessionsInfo`. + * This "full" flag allows to simplify the management of having too many + * simultaneous `MediaKeySession` on the current device, by storing in a single + * place whether this event has been encountered and whether it had chance to + * be resolved since. + * + * @class ActiveSessionsInfo + */ +export default class ActiveSessionsStore { + /** Metadata on each `MediaKeySession` stored here. */ + private _sessions: IActiveSessionInfo[]; + + /** + * `true` after the `markAsFull` method has been called, until `removeSession` + * is called **and** led to a `MediaKeySession` has been removed. + * + * This boolean has no impact on the creation of new `MediaKeySession`, it is + * only here as a flag to indicate that a surplus of `MediaKeySession` + * linked to this `ActiveSessionsStore` has been detected and only resets to + * `false` when it has chances to be resolved (when a `MediaKeySession` has + * since been removed). + */ + private _isFull: boolean; + + constructor() { + this._sessions = []; + this._isFull = false; + } + + /** + * Set the `isFull` flag to true meaning that the `isFull` method will from + * now on return `true` until at least one `MediaKeySession` has been removed + * from this `ActiveSessionsStore` (through the `removeSession` method). + * + * This flag allows to store the information of whether too much + * `MediaKeySession` seems to be created right now. + */ + public markAsFull(): void { + this._isFull = true; + } + + /** + * Add a new `MediaKeySession`, and its associated information, to the + * `ActiveSessionsStore`. + * @param {Object} sessionInfo + */ + public addSession(sessionInfo: IActiveSessionInfo) { + this._sessions.push(sessionInfo); + } + + /** + * Returns all information in the `ActiveSessionsStore` by order of insertion. + * @returns {Array.} + */ + public getSessions(): IActiveSessionInfo[] { + return this._sessions; + } + + /** + * Remove element with the corresponding `MediaKeySession` information from + * the `ActiveSessionsStore` if found. + * + * Returns `true` if the corresponding element has been found and removed, or + * `false` if it wasn't found. + * + * @param {Object} sessionInfo + * @returns {boolean} + */ + public removeSession(sessionInfo: IActiveSessionInfo): boolean { + const indexOf = this._sessions.indexOf(sessionInfo); + if (indexOf >= 0) { + this._sessions.splice(indexOf, 1); + this._isFull = false; + return true; + } + return false; + } + + /** + * If `true`, we know that there's too much `MediaKeySession` currently + * created. + * + * @see `markAsFull` method. + * @returns {boolean} + */ + public isFull(): boolean { + return this._isFull; + } +} + +/** Information linked to a session created by the `ContentDecryptor`. */ +export interface IActiveSessionInfo { + /** + * Record associated to the session. + * Most notably, it allows both to identify the session as well as to + * anounce and find out which key ids are already handled. + */ + record: KeySessionRecord; + + /** Current keys' statuses linked that session. */ + keyStatuses: { + /** Key ids linked to keys that are "usable". */ + whitelisted: Uint8Array[]; + /** + * Key ids linked to keys that are not considered "usable". + * Content linked to those keys are not decipherable and may thus be + * fallbacked from. + */ + blacklisted: Uint8Array[]; + }; + + /** Source of the MediaKeySession linked to that record. */ + source: MediaKeySessionLoadingType; + + /** + * If different than `null`, all initialization data compatible with this + * processed initialization data has been blacklisted with this corresponding + * error. + */ + blacklistedSessionError: BlacklistedSessionError | null; +} diff --git a/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts b/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts index 57c2c180a8..ab255a4fef 100644 --- a/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts +++ b/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts @@ -15,6 +15,8 @@ */ import log from "../../../log.ts"; +import arrayIncludes from "../../../utils/array_includes.ts"; +import type KeySessionRecord from "./key_session_record.ts"; import type LoadedSessionsStore from "./loaded_sessions_store.ts"; /** @@ -28,6 +30,7 @@ import type LoadedSessionsStore from "./loaded_sessions_store.ts"; */ export default async function cleanOldLoadedSessions( loadedSessionsStore: LoadedSessionsStore, + activeRecords: KeySessionRecord[], limit: number, ): Promise { if (limit < 0 || limit >= loadedSessionsStore.getLength()) { @@ -38,11 +41,36 @@ export default async function cleanOldLoadedSessions( length: loadedSessionsStore.getLength(), }); const proms: Array> = []; - const entries = loadedSessionsStore.getAll().slice(); // clone - const toDelete = entries.length - limit; - for (let i = 0; i < toDelete; i++) { - const entry = entries[i]; - proms.push(loadedSessionsStore.closeSession(entry.mediaKeySession)); + const sessionsMetadata = loadedSessionsStore.getAll().slice(); // clone + let toDelete = sessionsMetadata.length - limit; + for (let i = 0; toDelete > 0 && i < sessionsMetadata.length; i++) { + const metadata = sessionsMetadata[i]; + if (!arrayIncludes(activeRecords, metadata.keySessionRecord)) { + proms.push(loadedSessionsStore.closeSession(metadata.mediaKeySession)); + toDelete--; + } + } + if (toDelete > 0) { + return Promise.all(proms).then(() => { + return Promise.reject( + new NoSessionSpaceError("Could not remove all sessions: some are still active"), + ); + }); } await Promise.all(proms); } + +/** + * Error thrown when the MediaKeySession is blacklisted. + * Such MediaKeySession should not be re-used but other MediaKeySession for the + * same content can still be used. + * @class NoSessionSpaceError + * @extends Error + */ +export class NoSessionSpaceError extends Error { + constructor(message: string) { + super(message); + // @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class + Object.setPrototypeOf(this, NoSessionSpaceError.prototype); + } +} diff --git a/src/main_thread/decrypt/utils/loaded_sessions_store.ts b/src/main_thread/decrypt/utils/loaded_sessions_store.ts index e0daae32aa..d8d5fb7343 100644 --- a/src/main_thread/decrypt/utils/loaded_sessions_store.ts +++ b/src/main_thread/decrypt/utils/loaded_sessions_store.ts @@ -128,6 +128,15 @@ export default class LoadedSessionsStore { return null; } + public hasEntryForRecord(keySessionRecord: KeySessionRecord): boolean { + for (const stored of this._storage) { + if (stored.keySessionRecord === keySessionRecord) { + return true; + } + } + return false; + } + /** * Get `LoadedSessionsStore`'s entry for a given MediaKeySession. * Returns `null` if the given MediaKeySession is not stored in the diff --git a/src/main_thread/init/directfile_content_initializer.ts b/src/main_thread/init/directfile_content_initializer.ts index da2e7ecafe..aa467d5953 100644 --- a/src/main_thread/init/directfile_content_initializer.ts +++ b/src/main_thread/init/directfile_content_initializer.ts @@ -108,6 +108,9 @@ export default class DirectFileContentInitializer extends ContentInitializer { onWarning: (err: IPlayerError) => this.trigger("warning", err), onBlackListProtectionData: noop, onKeyIdsCompatibilityUpdate: noop, + onTooMuchSessions: () => { + log.error("Init", "There's currently too much MediaKeySession created"); + }, }, cancelSignal, ); diff --git a/src/main_thread/init/media_source_content_initializer.ts b/src/main_thread/init/media_source_content_initializer.ts index 44d1f42aa2..c18d8e1edc 100644 --- a/src/main_thread/init/media_source_content_initializer.ts +++ b/src/main_thread/init/media_source_content_initializer.ts @@ -75,6 +75,7 @@ import createCorePlaybackObserver from "./utils/create_core_playback_observer.ts import type { IInitialTimeOptions } from "./utils/get_initial_time.ts"; import getInitialTime from "./utils/get_initial_time.ts"; import getLoadedReference from "./utils/get_loaded_reference.ts"; +import handleTooMuchMediaKeySessions from "./utils/handle_too_much_media_key_sessions.ts"; import performInitialSeekAndPlay from "./utils/initial_seek_and_play.ts"; import RebufferingController from "./utils/rebuffering_controller.ts"; import StreamEventsEmitter from "./utils/stream_events_emitter/stream_events_emitter.ts"; @@ -404,6 +405,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const { statusRef: drmInitializationStatus, contentDecryptor } = this._initializeContentDecryption( mediaElement, + playbackObserver, lastContentProtection, mediaSourceStatus, () => notifyAndStartMediaSourceReload(0, undefined, undefined), @@ -1367,6 +1369,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { private _initializeContentDecryption( mediaElement: IMediaElement, + playbackObserver: IMediaElementPlaybackObserver, lastContentProtection: IReadOnlySharedReference, mediaSourceStatus: SharedReference, reloadMediaSource: () => void, @@ -1526,6 +1529,20 @@ export default class MediaSourceContentInitializer extends ContentInitializer { } }); + contentDecryptor.addEventListener("tooMuchSessions", (payload) => { + const manifest = this._currentContentInfo?.manifest; + if (isNullOrUndefined(manifest)) { + log.error("Init", "Received tooMuchSessions error before getting a Manifest"); + return; + } + handleTooMuchMediaKeySessions( + contentDecryptor, + manifest, + playbackObserver, + payload, + ); + }); + contentDecryptor.addEventListener("error", (error) => { this._onFatalError(error); }); diff --git a/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts b/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts new file mode 100644 index 0000000000..4ffe544daa --- /dev/null +++ b/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts @@ -0,0 +1,79 @@ +import log from "../../../log.ts"; +import type ContentDecryptor from "../../../main_thread/decrypt/index.ts"; +import type { IManifestMetadata } from "../../../manifest/index.ts"; +import { getAdaptations } from "../../../manifest/index.ts"; +import type { IMediaElementPlaybackObserver } from "../../../playback_observer/index.ts"; +import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal.ts"; +import isNullOrUndefined from "../../../utils/is_null_or_undefined.ts"; + +/** + * Logic performed when the `ContentDecryptor` tells us that there are too + * many DRM sessions created for the current content. + * + * We here try to determine which keys aren't needed anymore on the current + * content, and indicate to the `ContentDecryptor` that it can "free" them. + * + * @param {Object} contentDecryptor - The `ContentDecryptor` instance which + * has encountered the issue. + * @param {Object} manifest - Metadata on the content currently being played. + * @param {Object} playbackObserver - The PlaybackObserver linked to the same + * media element than the one handled by the `ContentDecryptor`. + * @param {Object} payload - The payload from the `tooMuchSessions` event from + * the `ContentDecryptor`. + */ +export default function handleTooMuchMediaKeySessions( + contentDecryptor: ContentDecryptor, + manifest: IManifestMetadata, + playbackObserver: IMediaElementPlaybackObserver, + payload: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }, +): void { + if (isNullOrUndefined(manifest)) { + log.error("Init", "Received tooMuchSessions error before fetching the Manifest"); + return; + } + + // We will here free all keys that aren't needed for the content buffered + // forward. + + const basePosition = Math.min( + playbackObserver.getCurrentTime(), + playbackObserver.getReference().getValue().position.getWanted(), + ); + + const keyIdsToCheck = payload.activeKeyIds.slice(); + for (const period of manifest.periods) { + if (period.end !== undefined && period.end < basePosition) { + continue; + } + for (const adaptation of getAdaptations(period)) { + for (const representation of adaptation.representations) { + const repKeyIds = representation.contentProtections?.keyIds; + if (repKeyIds === undefined) { + break; + } + for (let i = keyIdsToCheck.length - 1; i >= 0; i--) { + const kidToCheck = keyIdsToCheck[i]; + for (const repKid of repKeyIds) { + if (areArraysOfNumbersEqual(kidToCheck, repKid)) { + keyIdsToCheck.splice(i, 1); + } + } + } + } + } + } + + if (keyIdsToCheck.length === 0) { + // FIXME: + log.error("Init", "Too much MediaKeySession but found none to free"); + } else { + const hasFreedSession = contentDecryptor.freeKeyIds(keyIdsToCheck); + if (!hasFreedSession) { + // FIXME: + log.error("Init", "Too much MediaKeySession even after freeing some keys"); + } + } +} diff --git a/src/main_thread/init/utils/initialize_content_decryption.ts b/src/main_thread/init/utils/initialize_content_decryption.ts index 62e006440c..4d730dc91e 100644 --- a/src/main_thread/init/utils/initialize_content_decryption.ts +++ b/src/main_thread/init/utils/initialize_content_decryption.ts @@ -40,6 +40,10 @@ export default function initializeContentDecryption( onWarning: (err: IPlayerError) => void; onError: (err: Error) => void; onBlackListProtectionData: (val: IProcessedProtectionData) => void; + onTooMuchSessions: (arg: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }) => void; onKeyIdsCompatibilityUpdate: (updates: { whitelistedKeyIds: Uint8Array[]; blacklistedKeyIds: Uint8Array[]; @@ -141,6 +145,10 @@ export default function initializeContentDecryption( callbacks.onKeyIdsCompatibilityUpdate(x); }); + contentDecryptor.addEventListener("tooMuchSessions", (e) => { + callbacks.onTooMuchSessions(e); + }); + decryptorCanceller.signal.register((err) => { contentDecryptor.dispose(err.reason); }); diff --git a/tests/unit/global/decrypt/content_decryptor.test.ts b/tests/unit/global/decrypt/content_decryptor.test.ts index fa813bfe9c..dcf56e5634 100644 --- a/tests/unit/global/decrypt/content_decryptor.test.ts +++ b/tests/unit/global/decrypt/content_decryptor.test.ts @@ -159,7 +159,11 @@ describe("content_decryptor - session decommissioning", () => { await new Promise((res) => setTimeout(res, 120)); expect( - (contentDecryptor as unknown as { _currentSessions: unknown[] })._currentSessions, + ( + contentDecryptor as unknown as { + _activeSessionsStore: { getSessions(): unknown[] }; + } + )._activeSessionsStore.getSessions(), ).toHaveLength(3); sessionCallbacks[1].onError( @@ -174,7 +178,11 @@ describe("content_decryptor - session decommissioning", () => { await new Promise((res) => setTimeout(res, 30)); expect( - (contentDecryptor as unknown as { _currentSessions: unknown[] })._currentSessions, + ( + contentDecryptor as unknown as { + _activeSessionsStore: { getSessions(): unknown[] }; + } + )._activeSessionsStore.getSessions(), ).toHaveLength(2); contentDecryptor.dispose(undefined); diff --git a/tests/unit/src/main_thread/decrypt/utils/clean_old_loaded_sessions.test.ts b/tests/unit/src/main_thread/decrypt/utils/clean_old_loaded_sessions.test.ts index c5134821d9..3f151459e1 100644 --- a/tests/unit/src/main_thread/decrypt/utils/clean_old_loaded_sessions.test.ts +++ b/tests/unit/src/main_thread/decrypt/utils/clean_old_loaded_sessions.test.ts @@ -1,24 +1,38 @@ import { describe, it, expect, vi } from "vitest"; import cleanOldLoadedSessions from "../../../../../../src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts"; +import InitDataValuesContainer from "../../../../../../src/main_thread/decrypt/utils/init_data_values_container.ts"; +import KeySessionRecord from "../../../../../../src/main_thread/decrypt/utils/key_session_record.ts"; import type LoadedSessionsStore from "../../../../../../src/main_thread/decrypt/utils/loaded_sessions_store.ts"; const entry1 = { initializationData: { data: new Uint8Array([1, 6, 9]), type: "test" }, mediaKeySession: { sessionId: "toto" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; const entry2 = { initializationData: { data: new Uint8Array([4, 8]), type: "foo" }, mediaKeySession: { sessionId: "titi" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; const entry3 = { initializationData: { data: new Uint8Array([7, 3, 121, 87]), type: "bar" }, mediaKeySession: { sessionId: "tutu" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; function createLoadedSessionsStore(): LoadedSessionsStore { @@ -64,7 +78,7 @@ async function checkNothingHappen( limit: number, ): Promise { const mockCloseSession = vi.spyOn(loadedSessionsStore, "closeSession"); - await cleanOldLoadedSessions(loadedSessionsStore, limit); + await cleanOldLoadedSessions(loadedSessionsStore, [], limit); expect(mockCloseSession).not.toHaveBeenCalled(); mockCloseSession.mockRestore(); } @@ -85,7 +99,7 @@ async function checkEntriesCleaned( entries: Array<{ sessionId: string }>, ): Promise { const mockCloseSession = vi.spyOn(loadedSessionsStore, "closeSession"); - const prom = cleanOldLoadedSessions(loadedSessionsStore, limit).then(() => { + const prom = cleanOldLoadedSessions(loadedSessionsStore, [], limit).then(() => { expect(mockCloseSession).toHaveBeenCalledTimes(entries.length); mockCloseSession.mockRestore(); });