Wait for the latest license request to resolve usable keys before making another

Resolves #7796
This commit is contained in:
Rob Walch
2026-05-29 11:09:07 -07:00
parent 819d90118c
commit 4be073eb58
5 changed files with 296 additions and 21 deletions
+3 -1
View File
@@ -3229,7 +3229,7 @@ export class KeyLoader extends Logger implements ComponentAPI {
// (undocumented)
emeController: EMEController | null;
// (undocumented)
load(frag: Fragment): Promise<KeyLoadedData>;
load(frag: Fragment, initDataPromise?: Promise<void>): Promise<KeyLoadedData>;
// (undocumented)
loadClear(loadingFrag: Fragment, encryptedFragments: Fragment[], startFragRequested: boolean): Promise<void> | 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;
+16 -10
View File
@@ -930,6 +930,7 @@ export default class BaseStreamController
private loadKeyFor(
frag: MediaFragment,
details: LevelDetails,
initDataPromise?: Promise<void>,
): Promise<KeyLoadedData | void> | null {
let keyLoadingPromise: Promise<KeyLoadedData | void> | 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<PartsLoadedData | FragLoadedData | null>;
if (dataOnProgress && keyLoadingPromise) {
result = keyLoadingPromise
+87 -4
View File
@@ -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<MediaKeySessionContext>;
} = {};
// 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<MediaKeySessionContext> {
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<MediaKeySessionContext>;
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<MediaKeySessionContext> {
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;
@@ -1620,6 +1696,7 @@ class EMEController extends Logger implements ComponentAPI {
this.keyFormatPromise = null;
this.keySystemAccessPromises = {};
this.activeKeys = {};
this.sessionSetupByUri = {};
const mediaResolved = this.mediaResolved;
if (mediaResolved) {
mediaResolved();
@@ -1874,6 +1951,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);
});
+14 -6
View File
@@ -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<KeyLoadedData> {
public load(
frag: Fragment,
initDataPromise?: Promise<void>,
): Promise<KeyLoadedData> {
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<void> | undefined,
keySystemFormat?: KeySystemFormats,
): Promise<KeyLoadedData> {
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;
}
+176
View File
@@ -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<ArrayBuffer> = new Uint8Array(16)) {
this._keyStatuses.set(keyId, 'usable');
this.emit('keystatuseschange', {});
}
}
const drain = () =>
new Promise<void>((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);
});
});
});
});