Context
exchangeLicenses opens one MediaKeySession per declared key, eagerly, at entry — including renditions never played. Studio-policy content commonly declares a different key per quality tier, so a three-tier ladder fires three licence requests at startup. Providers enforce concurrent-session limits and some bill per licence issued.
The behaviour is pinned by a test (one session per distinct key, selection never consulted) and observable against a real server via the hls-drm-axinom-multikey sandbox vector, where three startup POSTs are visible.
Three independent axes
Worth separating, because the obvious framing ("eager vs on-demand") hides one:
- When — license at entry, or on encounter.
- How many sessions — one per key, or reuse a session whose
keyStatuses already covers the key.
- Which keys — every declared key, or only the selected renditions (the selected-track ids are already in state).
Axis 2 is largely independent and is the cheapest. EME does not require a session per key: MediaKeySession.keyStatuses is a map, and a Widevine or PlayReady licence routinely returns several keys. The fan-out here follows from deriving one init data per #EXT-X-KEY, not from a design decision.
Note that concatenating PSSH boxes is not the route. Per the "cenc" initialization data format, the init data is one or more concatenated pssh boxes each for a unique SystemID, and a CDM takes the first box it supports — so merging several Widevine PSSHs would have the CDM use only the first.
Prior art — hls.js already does axis 2
getSessionForKey → findContextForKey checks whether an existing session's keyStatuses already contains the key's keyId, and reuses that session when it does. Their own comment:
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.
So they do not assume the server multi-keys — they observe whether it did. They also serialise concurrent requests per key URI so two callers cannot race into duplicate sessions for one URI (video-dev/hls.js#7796).
Shaka does not do this: it suppresses duplicate init data by byte comparison (ignoreDuplicateInitData) and otherwise creates a session per distinct init data, which is where we are today.
Acceptance criteria
- Where a licence response covers several keys, subsequent keys reuse the session already holding them rather than opening a new one — and where it does not, behaviour is unchanged.
- Concurrent requests for the same key URI cannot produce duplicate sessions.
- Licensing is driven by what playback needs rather than by everything the manifest declares.
- A rendition switch onto a tier whose key was not declared at entry opens a session for that key. This is the correctness half of this issue (see "Confirmed defect" below) and should land with or before the efficiency half.
- The existing fan-out pin test is updated to encode the new contract, not deleted — it currently pins the behaviour being changed, which is the right place for the change to become visible.
Confirmed defect — per-tier keys are licensed only for the entry-time variant
Found by Cursor Bugbot on #2291 and confirmed by inspection 2026-09-17. This is not just an efficiency gap; on one asset shape it is a decode failure.
exchangeLicenses reads manifestInitData(presentation, …) once in its licensing entry. Three facts make that lossy:
resolve-track gates on a selection — it resolves the selected rendition's media playlist and patches it back into state.presentation. Other variants stay unresolved.
declaredDrmKeys counts keys on resolved tracks only (isResolvedTrack).
- The reactor cannot re-enter:
derivedStateSignal returns 'licensing' for any resolved presentation, so a presentation that later gains a resolved variant produces no state transition and no second entry.
So a studio-policy ladder with a distinct KEYID per tier declares exactly one key at entry. An ABR switch across a key boundary then decrypts against a key with no session.
#EXT-X-SESSION-KEY, the standard way to declare every key up front, is deliberately not parsed on #2291 — so nothing masks this.
Why it was not caught:
- Axinom's MultiKey vector plays because the smoke never left the initially-selected tier.
- The fan-out pin test passes because it is handed a presentation with all four variants already resolved — a state the engine does not produce at entry.
Both remain valid tests of what they pin; neither covers this. A regression test wants a presentation that gains a resolved variant after licensing begins.
Reproduction — the Axinom MultiKey vector is a live repro
Measured from its manifests 2026-09-17 (hls-drm-axinom-multikey, media.axprod.net/TestVectors/MultiKey/Cmaf_h264_1080p_cbcs):
- 5 variants, 2 distinct KEYIDs, split at the 480→720 boundary — 288/360/480 on
0xC83C4EA80F2A4523851CFBECCDC0F202, 720/1080 on 0xC868C702C71B4064AE2BC24F7CC10792
- No
EXT-X-SESSION-KEY
- Each variant playlist declares only its own key, twice — once as a Widevine PSSH, once as an
skd:// URI naming the same keyid
So: start on any SD tier, force a switch across 480→720 (or start on HD and switch down), and the new tier needs a key with no session. It plays today only because a smoke that stays on one side of the boundary never needs the other key.
This also corrects a claim previously carried in the sandbox source comment and the risk assessment — that this asset shows a license POST per ladder key at startup. It cannot: only the selected variant is resolved, so exactly one key is declared and one license fetched. Fixed in 3b9d010.
The fix is the same reactive re-scan of declaredDrmKeys that live rotation needs, which is why it belongs here rather than in its own issue.
Notes
Context
exchangeLicensesopens oneMediaKeySessionper declared key, eagerly, at entry — including renditions never played. Studio-policy content commonly declares a different key per quality tier, so a three-tier ladder fires three licence requests at startup. Providers enforce concurrent-session limits and some bill per licence issued.The behaviour is pinned by a test (one session per distinct key, selection never consulted) and observable against a real server via the
hls-drm-axinom-multikeysandbox vector, where three startup POSTs are visible.Three independent axes
Worth separating, because the obvious framing ("eager vs on-demand") hides one:
keyStatusesalready covers the key.Axis 2 is largely independent and is the cheapest. EME does not require a session per key:
MediaKeySession.keyStatusesis a map, and a Widevine or PlayReady licence routinely returns several keys. The fan-out here follows from deriving one init data per#EXT-X-KEY, not from a design decision.Note that concatenating PSSH boxes is not the route. Per the "cenc" initialization data format, the init data is one or more concatenated
psshboxes each for a unique SystemID, and a CDM takes the first box it supports — so merging several Widevine PSSHs would have the CDM use only the first.Prior art — hls.js already does axis 2
getSessionForKey→findContextForKeychecks whether an existing session'skeyStatusesalready contains the key's keyId, and reuses that session when it does. Their own comment:So they do not assume the server multi-keys — they observe whether it did. They also serialise concurrent requests per key URI so two callers cannot race into duplicate sessions for one URI (video-dev/hls.js#7796).
Shaka does not do this: it suppresses duplicate init data by byte comparison (
ignoreDuplicateInitData) and otherwise creates a session per distinct init data, which is where we are today.Acceptance criteria
Confirmed defect — per-tier keys are licensed only for the entry-time variant
Found by Cursor Bugbot on #2291 and confirmed by inspection 2026-09-17. This is not just an efficiency gap; on one asset shape it is a decode failure.
exchangeLicensesreadsmanifestInitData(presentation, …)once in itslicensingentry. Three facts make that lossy:resolve-trackgates on a selection — it resolves the selected rendition's media playlist and patches it back intostate.presentation. Other variants stay unresolved.declaredDrmKeyscounts keys on resolved tracks only (isResolvedTrack).derivedStateSignalreturns'licensing'for any resolved presentation, so a presentation that later gains a resolved variant produces no state transition and no secondentry.So a studio-policy ladder with a distinct KEYID per tier declares exactly one key at entry. An ABR switch across a key boundary then decrypts against a key with no session.
#EXT-X-SESSION-KEY, the standard way to declare every key up front, is deliberately not parsed on #2291 — so nothing masks this.Why it was not caught:
Both remain valid tests of what they pin; neither covers this. A regression test wants a presentation that gains a resolved variant after licensing begins.
Reproduction — the Axinom MultiKey vector is a live repro
Measured from its manifests 2026-09-17 (
hls-drm-axinom-multikey,media.axprod.net/TestVectors/MultiKey/Cmaf_h264_1080p_cbcs):0xC83C4EA80F2A4523851CFBECCDC0F202, 720/1080 on0xC868C702C71B4064AE2BC24F7CC10792EXT-X-SESSION-KEYskd://URI naming the same keyidSo: start on any SD tier, force a switch across 480→720 (or start on HD and switch down), and the new tier needs a key with no session. It plays today only because a smoke that stays on one side of the boundary never needs the other key.
This also corrects a claim previously carried in the sandbox source comment and the risk assessment — that this asset shows a license POST per ladder key at startup. It cannot: only the selected variant is resolved, so exactly one key is declared and one license fetched. Fixed in 3b9d010.
The fix is the same reactive re-scan of
declaredDrmKeysthat live rotation needs, which is why it belongs here rather than in its own issue.Notes
drm-support.mdand in the fan-out pin test.