From e2891e8b6af7b8367c1e5e49197a2e14c94b8e04 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 27 May 2026 18:53:23 -0700 Subject: [PATCH] Wait for the latest license request to resolve usable keys before making another Resolves #7796 --- api-extractor/report/hls.js.api.md | 4 +- src/controller/base-stream-controller.ts | 26 ++-- src/controller/eme-controller.ts | 91 +++++++++++- src/loader/key-loader.ts | 20 ++- tests/unit/controller/eme-controller.ts | 176 +++++++++++++++++++++++ 5 files changed, 296 insertions(+), 21 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 7a24dd66aaa..6b2822ccd68 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -3229,7 +3229,7 @@ export class KeyLoader extends Logger implements ComponentAPI { // (undocumented) emeController: EMEController | null; // (undocumented) - load(frag: Fragment): Promise; + load(frag: Fragment, initDataPromise?: Promise): Promise; // (undocumented) loadClear(loadingFrag: Fragment, encryptedFragments: Fragment[], startFragRequested: boolean): Promise | null; } @@ -3837,6 +3837,8 @@ export interface LevelUpdatedData { // @public (undocumented) export type LicenseAndKeysRequest = EventEmitter & { status: 'initialized' | 'started' | 'generated' | MediaKeyMessageType; + resolved?: boolean; + errored?: Error; licenseXhr?: XMLHttpRequest; requestErrors: { status: number; diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 8fa5e75ebec..05ed3d10bc8 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -930,6 +930,7 @@ export default class BaseStreamController private loadKeyFor( frag: MediaFragment, details: LevelDetails, + initDataPromise?: Promise, ): Promise | null { let keyLoadingPromise: Promise | null = null; if (frag.encrypted && !frag.decryptdata?.key) { @@ -937,15 +938,17 @@ export default class BaseStreamController `Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${this.playlistLabel()} ${frag.level}`, ); this.state = State.KEY_LOADING; - keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => { - if (!this.fragContextChanged(keyLoadedData.frag)) { - this.hls.trigger(Events.KEY_LOADED, keyLoadedData); - if (this.state === State.KEY_LOADING) { - this.state = State.IDLE; + keyLoadingPromise = this.keyLoader + .load(frag, initDataPromise) + .then((keyLoadedData) => { + if (!this.fragContextChanged(keyLoadedData.frag)) { + this.hls.trigger(Events.KEY_LOADED, keyLoadedData); + if (this.state === State.KEY_LOADING) { + this.state = State.IDLE; + } + return keyLoadedData; } - return keyLoadedData; - } - }); + }); this.hls.trigger(Events.KEY_LOADING, { frag }); } else if (!frag.encrypted) { keyLoadingPromise = this.keyLoader.loadClear( @@ -1084,7 +1087,11 @@ export default class BaseStreamController return Promise.resolve(null); } - const keyLoadingPromise = this.loadKeyFor(frag, details); + // Start the init segment load before the key load so that EME key + // patching from tenc (KeyLoader.loadKeyEME) has the data it needs + // before the license request is generated (#7796). + const initDataPromise = this.loadInitSegmentIfNeeded(frag); + const keyLoadingPromise = this.loadKeyFor(frag, details, initDataPromise); if (this.fragContextChanged(frag)) { this.log( `Context changed in KEY_LOADING sn: ${frag.sn} ${frag.relurl} > ${this.fragCurrent?.relurl}`, @@ -1108,7 +1115,6 @@ export default class BaseStreamController const dataOnProgress = this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE; - const initDataPromise = this.loadInitSegmentIfNeeded(frag); let result: Promise; if (dataOnProgress && keyLoadingPromise) { result = keyLoadingPromise diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index bcff60dc65d..5e416efcc73 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -64,6 +64,8 @@ type ActiveKeys = { export type LicenseAndKeysRequest = EventEmitter & { status: 'initialized' | 'started' | 'generated' | MediaKeyMessageType; + resolved?: boolean; + errored?: Error; licenseXhr?: XMLHttpRequest; requestErrors: { status: number; message: string }[]; onmessage?: (this: MediaKeySession, ev: MediaKeyMessageEvent) => any; @@ -136,6 +138,9 @@ class EMEController extends Logger implements ComponentAPI { [keysystem: string]: KeySystemAccessPromises | undefined; } = {}; + private sessionSetupByUri: { + [uri: string]: Promise; + } = {}; // MediaKey session contexts created for each Key URI needed to make license requests that produce key status maps private mediaKeySessions: MediaKeySessionContext[] = []; @@ -345,7 +350,7 @@ class EMEController extends Logger implements ComponentAPI { reason: LicenseRequestReason, ): MediaKeySessionContext { const { mediaKeySessions } = this; - const message = ` key-system session "${keySystem}" keyId: ${arrayToHex( + const message = ` key-session "${keySystem}" keyId: ${arrayToHex( levelKey.keyId || ([] as number[]), )} keyUri: ${levelKey.uri} for "${reason}"`; for (let i = 0; i < mediaKeySessions.length; i++) { @@ -581,6 +586,57 @@ class EMEController extends Logger implements ComponentAPI { private getSessionForKey( levelKey: LevelKey, reason: LicenseRequestReason, + ): Promise { + const existing = this.findContextForKey(levelKey); + if (existing !== undefined) { + return Promise.resolve(existing); + } + // Block concurrent loadKey calls for the same Key URI on a single + // in-flight setup so we never create duplicate sessions for that URI + // (#7796). Distinct URIs proceed in parallel. Waiters re-enter + // `resolveSessionForKey` after the predecessor settles so they can + // share sessions containing their keyId's key-status, or generate + // their own license request and key session if the license server + // does not produce multiple key statuses. + const uri = levelKey.uri; + const previous = this.sessionSetupByUri[uri]; + let setup: Promise; + if (previous === undefined) { + setup = this.resolveSessionForKey(levelKey, reason); + } else { + setup = previous + .catch(() => undefined) + .then(() => { + this.throwIfDestroyed(); + return this.resolveSessionForKey(levelKey, reason); + }); + } + this.sessionSetupByUri[uri] = setup; + const cleanup = () => { + if (this.sessionSetupByUri[uri] === setup) { + delete this.sessionSetupByUri[uri]; + } + }; + setup.then(cleanup, cleanup); + return setup; + } + + private findContextForKey( + levelKey: LevelKey, + ): MediaKeySessionContext | undefined { + const { mediaKeySessions } = this; + for (let i = mediaKeySessions.length; i--; ) { + const context = mediaKeySessions[i]; + if (getKeyStatus(levelKey, context)) { + return context; + } + } + return undefined; + } + + private resolveSessionForKey( + levelKey: LevelKey, + reason: LicenseRequestReason, ): Promise { return this.getKeySystemForKeyPromise(levelKey).then( ({ keySystem, mediaKeys }) => { @@ -591,7 +647,7 @@ class EMEController extends Logger implements ComponentAPI { if (getKeyStatus(levelKey, context)) { return context; } - // If request is in progress wait for it to resolve + // If a request is in progress for this same URI, wait for it. const usablePromise = this.getUsableKeyPromise( context, levelKey, @@ -958,7 +1014,9 @@ class EMEController extends Logger implements ComponentAPI { const onmessage = (event: MediaKeyMessageEvent) => { const keySession = context.mediaKeysSession; if (!keySession as any) { - requestEmitter.emit('error', new Error('invalid state')); + const invalidStateError = new Error('invalid state'); + requestEmitter.errored = invalidStateError; + requestEmitter.emit('error', invalidStateError); return; } const { messageType, message } = event; @@ -971,6 +1029,7 @@ class EMEController extends Logger implements ComponentAPI { messageType === 'license-renewal' ) { this.renewLicense(context, levelKey, message).catch((error) => { + requestEmitter.errored = error; if (requestEmitter.eventNames().length) { requestEmitter.emit('error', error); } else { @@ -1036,6 +1095,7 @@ class EMEController extends Logger implements ComponentAPI { const handleKeyStatus = (keyStatus: MediaKeyStatus) => { let keyError: EMEKeyError | Error | undefined; if (keyStatus.startsWith('usable')) { + requestEmitter.resolved = true; requestEmitter.emit('resolved'); } else if ( keyStatus === 'internal-error' || @@ -1055,6 +1115,7 @@ class EMEController extends Logger implements ComponentAPI { ); } if (keyError) { + requestEmitter.errored = keyError; if (requestEmitter.eventNames().length) { requestEmitter.emit('error', keyError); } else { @@ -1066,7 +1127,9 @@ class EMEController extends Logger implements ComponentAPI { const onkeystatuseschange = (event: Event) => { const keySession = context.mediaKeysSession; if (!keySession as any) { - requestEmitter.emit('error', new Error('invalid state')); + const invalidStateError = new Error('invalid state'); + requestEmitter.errored = invalidStateError; + requestEmitter.emit('error', invalidStateError); return; } @@ -1166,6 +1229,19 @@ class EMEController extends Logger implements ComponentAPI { .then(() => keyUsablePromise) .catch((error) => { this.log(`mediaKeysSession.generateRequest failed: ${error}`); + const errorDetails = error.data?.details; + if ( + (errorDetails === ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED || + errorDetails === ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR) && + Object.keys(context.keyStatuses).some( + (keyId) => context.keyStatuses[keyId] === 'usable', + ) + ) { + // Emit key status error without resetting session containing usable keys + // to allow variant switching to resolve playback. + this.handleError(error); + return context; + } requestEmitter.removeAllListeners(); return this.removeSession(context).then(() => { throw error; @@ -1619,6 +1695,7 @@ class EMEController extends Logger implements ComponentAPI { this.keyFormatPromise = null; this.keySystemAccessPromises = {}; this.activeKeys = {}; + this.sessionSetupByUri = {}; const mediaResolved = this.mediaResolved; if (mediaResolved) { mediaResolved(); @@ -1873,6 +1950,12 @@ function getKeyStatusError( function getRequestToKeyUsablePromise(requestEmitter: LicenseAndKeysRequest) { return new Promise((resolve: (value?: void) => void, reject) => { + if (requestEmitter.resolved) { + return resolve(); + } + if (requestEmitter.errored) { + return reject(requestEmitter.errored); + } requestEmitter.on('error', reject); requestEmitter.on('resolved', resolve); }); diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index b08e5f85919..13d88eed21f 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -57,6 +57,7 @@ export default class KeyLoader extends Logger implements ComponentAPI { loader.destroy(); } } + this.emeController = null; this.keyLoaderInfo = {}; } @@ -75,7 +76,10 @@ export default class KeyLoader extends Logger implements ComponentAPI { return null; } - public load(frag: Fragment): Promise { + public load( + frag: Fragment, + initDataPromise?: Promise, + ): Promise { if ( !frag.decryptdata && frag.encrypted && @@ -86,15 +90,16 @@ export default class KeyLoader extends Logger implements ComponentAPI { return this.emeController .selectKeySystemFormat(frag) .then((keySystemFormat) => { - return this.loadInternal(frag, keySystemFormat); + return this.loadInternal(frag, initDataPromise, keySystemFormat); }); } - return this.loadInternal(frag); + return this.loadInternal(frag, initDataPromise); } private loadInternal( frag: Fragment, + initDataPromise: Promise | undefined, keySystemFormat?: KeySystemFormats, ): Promise { if (__USE_EME_DRM__ && keySystemFormat) { @@ -122,7 +127,9 @@ export default class KeyLoader extends Logger implements ComponentAPI { // loadKeyHTTP handles http(s) and data URLs return this.loadKeyHTTP(encryptedFrag); } - return this.loadKeyEME(encryptedFrag); + return initDataPromise + ? initDataPromise.then(() => this.loadKeyEME(encryptedFrag)) + : this.loadKeyEME(encryptedFrag); case 'AES-128': case 'AES-256': case 'AES-256-CTR': @@ -155,10 +162,11 @@ export default class KeyLoader extends Logger implements ComponentAPI { ); LevelKey.setKeyIdForUri(keyUri, keyId); } else { + const keyIdPatch = LevelKey.addKeyIdForUri(keyUri); this.log( - `Patching empty keyId with ${arrayToHex(keyId)} keyUri: ${keyUri}`, + `Patching empty keyId with ${arrayToHex(keyIdPatch)} keyUri: ${keyUri}`, ); - keyId = LevelKey.addKeyIdForUri(keyUri); + keyId = keyIdPatch; } frag.decryptdata.keyId = keyId; } diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 5d49cb667cb..fc5fb1cdccc 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -745,4 +745,180 @@ describe('EMEController', function () { expect(emeController.mediaKeySessions.length).to.equal(0); }); }); + + describe('serialized key-session setup (#7796)', function () { + class DeferredMediaKeySessionMock extends MediaKeySessionMock { + generateRequest() { + this.emit('message', { + messageType: 'license-request', + message: new Uint8Array(0), + }); + // Do not auto-publish key statuses — tests drive timing manually. + return Promise.resolve(); + } + + publishKeyStatus(keyId: Uint8Array = new Uint8Array(16)) { + this._keyStatuses.set(keyId, 'usable'); + this.emit('keystatuseschange', {}); + } + } + + const drain = () => + new Promise((resolve) => self.setTimeout(() => resolve(), 0)); + + const buildKsAccessSpy = (sessions: DeferredMediaKeySessionMock[]) => { + const createSessionSpy = sinon.spy(() => { + const session = new DeferredMediaKeySessionMock(); + sessions.push(session); + return session; + }); + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: createSessionSpy, + }), + ), + }); + }); + return { reqMediaKsAccessSpy, createSessionSpy }; + }; + + const respondToXhrEmpty = () => { + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest, + ) => { + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + }; + + it('blocks concurrent loadKey for the same URI on a single session', function () { + const sessions: DeferredMediaKeySessionMock[] = []; + const { reqMediaKsAccessSpy, createSessionSpy } = + buildKsAccessSpy(sessions); + setupEach({ + emeEnabled: true, + drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + respondToXhrEmpty(); + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + const levelKey = getParsedLevelKey(); + const pA = emeController.loadKey(getEncryptedFrag(levelKey)); + const pB = emeController.loadKey(getEncryptedFrag(levelKey)); + + return drain() + .then(() => { + expect(createSessionSpy).callCount(1); + sessions[0].publishKeyStatus(); + return Promise.all([pA, pB]); + }) + .then(() => { + expect(createSessionSpy).callCount(1); + expect(emeController.mediaKeySessions.length).to.equal(1); + }); + }); + + it('creates distinct concurrent sessions for distinct URIs', function () { + const sessions: DeferredMediaKeySessionMock[] = []; + const { reqMediaKsAccessSpy, createSessionSpy } = + buildKsAccessSpy(sessions); + setupEach({ + emeEnabled: true, + drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + respondToXhrEmpty(); + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + // Distinct keyIds mirror real HLS where different key URIs map to + // different keys. Distinct URIs do not block each other — FPS in + // particular relies on parallel session setup. + const keyA = getParsedLevelKey('data://key-uri-A'); + keyA.keyId = new Uint8Array(16).fill(0xa1); + const keyB = getParsedLevelKey('data://key-uri-B'); + keyB.keyId = new Uint8Array(16).fill(0xb2); + const fragA = getEncryptedFrag(keyA); + const fragB = getEncryptedFrag(keyB); + + const pA = emeController.loadKey(fragA); + const pB = emeController.loadKey(fragB); + + return drain() + .then(() => { + // Distinct URIs run in parallel — each gets its own session. + expect(createSessionSpy).callCount(2); + sessions[0].publishKeyStatus(keyA.keyId!); + sessions[1].publishKeyStatus(keyB.keyId!); + return Promise.all([pA, pB]); + }) + .then(() => { + expect(emeController.mediaKeySessions.length).to.equal(2); + }); + }); + + it('does not orphan blocked callers when the first session setup fails', function () { + const sessions: DeferredMediaKeySessionMock[] = []; + const createSessionSpy = sinon.spy(() => { + const session = new DeferredMediaKeySessionMock(); + if (sessions.length === 0) { + (session as any).generateRequest = () => + Promise.reject(new Error('first session failed')); + } + sessions.push(session); + return session; + }); + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: createSessionSpy, + }), + ), + }); + }); + setupEach({ + emeEnabled: true, + drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + respondToXhrEmpty(); + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + const levelKey = getParsedLevelKey(); + const pA = emeController + .loadKey(getEncryptedFrag(levelKey)) + .catch(() => undefined); + const pB = emeController.loadKey(getEncryptedFrag(levelKey)); + + return drain() + .then(() => drain()) + .then(() => { + // After A's failure, B should drive a fresh session creation. + expect(createSessionSpy.callCount).to.be.at.least(1); + if (sessions.length >= 2) { + sessions[1].publishKeyStatus(); + } + return Promise.all([pA, pB]); + }) + .then(() => { + expect(createSessionSpy.callCount).to.be.at.least(2); + expect(emeController.mediaKeySessions.length).to.equal(1); + }); + }); + }); });