mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
Wait for the latest license request to resolve usable keys before making another
Resolves #7796
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user