Allow restarting a partially-loaded chunk instead of resuming it with a byte-range request
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 3k
- Forks
- 955
- Avg merge
- 12d 14h
- Merged PRs (30d)
- 2
Description
Use case description
When a chunk load fails part-way through, ExoPlayer resumes it with a byte-range request from nextLoadPosition and keeps the already-parsed extractor state. This is only safe if the retry returns byte-identical data to the first attempt. Nothing in the player verifies that, and a plain Range request carries no validator that would let the server refuse a mismatched continuation.
When the retry is served a different but equivalent representation of the same URL, the extractor receives bytes [0, P) from the first response and [P, …) from the second. The two halves don't line up and playback fails with a container parse error — commonly ParserException: Invalid NAL length {contentIsMalformed=true, dataType=1}, because the parser reads a NAL length field out of the middle of sample data.
Why two responses to one URL can differ. This is not a broken-server case. Common causes:
- Multi-CDN delivery. A hostname weighted across several CDNs, each pulling from its own packager/origin. A retry that re-resolves DNS can land on a different CDN.
- Packager version skew. Caches filled at different times by different builds of the same packaging software. Cache entries commonly live for months, so even a single CDN can hold objects produced by more than one build.
- Origin migration. Running two packagers side by side behind one hostname during a rollout.
In all of these the objects are semantically identical and fully interchangeable — same timeline, decode times, sample count and durations, sample payloads, encryption parameters and init segment semantics — but not byte-identical. Typical causes of the byte difference are a sidx that one packager emits and another doesn't, differing trun/tfhd default-flag encodings, or in-band SPS/PPS that one packager injects at the start of a segment and another leaves out. Any of these shifts the mdat payload offset by a few hundred bytes.
Whole-segment fetches mix across such sources without any problem. Only byte-offset resumption breaks, and it breaks silently and non-deterministically: it needs a mid-chunk network error to trigger, so it surfaces as rare, hard-to-reproduce "malformed content" reports.
Where this happens.
DASH / SmoothStreaming — ContainerMediaChunk.load(), unconditionally:
DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition);
HLS — HlsMediaChunk.feedDataToExtractor():
if (dataIsEncrypted) {
loadDataSpec = dataSpec;
skipLoadedBytes = nextLoadPosition != 0;
} else {
loadDataSpec = dataSpec.subrange(nextLoadPosition);
skipLoadedBytes = false;
}
Note the dataIsEncrypted branch does not avoid the problem. It refetches the whole segment but then calls input.skipFully(nextLoadPosition), so the extractor still gets a prefix from one response and a suffix from another. Only the transport differs, not the splice.
dataIsEncrypted is mediaSegmentKey != null, and HlsPlaylistParser sets fullSegmentEncryptionKeyUri only for KEYFORMAT=identity with METHOD=AES-128. Content using METHOD=SAMPLE-AES with DRM key formats therefore takes the subrange branch.
This may be the same underlying cause as #931, which reports the identical exception when a DASH manifest generator emitted adaptation sets in a varying order — another case of equivalent-but-not-identical responses to one URL. That issue has been open and untriaged since 2023.
Proposed solution
An opt-in mode that discards a partially-loaded chunk and restarts it from byte 0 rather than resuming it, defaulting to today's behaviour so nothing changes for existing users.
Two shapes that seem to fit the existing design:
- A new
LoadErrorAction, e.g.Loader.RETRY_FROM_START, alongsideRETRYandRETRY_RESET_ERROR_COUNT. This letsLoadErrorHandlingPolicyimplementations opt in per error, which is useful since restarting discards bytes already downloaded and is only worth it on the media-chunk path. - A flag on the media source factories (
HlsMediaSource.Factory,DashMediaSource.Factory, …), e.g.setAllowPartialChunkResume(boolean).
Implementation notes, in case they are useful:
- The rollback primitive already exists.
HlsSampleStreamWrapperdoes exactly this when discarding upstream chunks:int discardFromIndex = firstRemovedChunk.getFirstSampleIndex(i); sampleQueues[i].discardUpstreamSamples(discardFromIndex);onLoadErroralso already distinguishes partial chunks — it removes the chunk onlyif (isMediaChunk && bytesLoaded == 0). The proposal is essentially to extend that tobytesLoaded > 0by rolling the sample queues back first. - DASH looks close to free:
ContainerMediaChunk.load()re-initialises the extractor whenevernextLoadPosition == 0, so resetting that field plus discarding the chunk's emitted samples should be most of the work. - HLS needs more care:
previousExtractorreuse,initDataLoadRequired,shouldSpliceInand timestamp-adjuster continuity have to be unwound, and it must stay correct for containers like TS and ADTS where extractor state spans chunks.
Alternatives considered
- Forcing the existing
dataIsEncryptedbranch. Does not work, as above — it refetches the whole segment but still splices at the extractor. - Wrapping
DataSourceto ignoreDataSpec.positionand refetch from byte 0. Does not work either: the extractor has already consumed the prefix from the first response, so feeding it a suffix of a second response is still a splice. The decision has to be made where the extractor and sample-queue state live. - Pinning every request to one CDN, e.g. rewriting the host via
ResolvingDataSource. This does work, and is what we would fall back to. But it makes a deployment topology constraint out of a player implementation detail, and it does not help against packager version skew within a single CDN. - Making resumption validator-aware (
If-Range). The HTTP-correct fix for resuming a partial transfer is to sendIf-Rangewith theETagorLast-Modifiedfrom the first response, so the server returns206only if the representation is unchanged and200otherwise. The player would then need to detect the200and restart the chunk — so it needs the same restart machinery, but it would fix the problem automatically rather than by configuration. Intermediary caches may serve differing validators for equivalent content, which would make restarts somewhat more frequent, though only on an already-rare retry path. This seems complementary to, rather than instead of, the opt-in above.
Code quoted from the release branch as read on 2026-09-16. The code paths are long-standing rather than specific to one release.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing ContainerMediaChunk.load(), HlsMediaChunk.feedDataToExtractor(), and HlsSampleStreamWrapper.onLoadError(), then compare how DASH and HLS retain extractor and sample-queue state after partial loads. Define the restart behavior and opt-in API without changing the default; done means a failed partial chunk can be rolled back and loaded from byte 0 for both paths while preserving existing resume behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, java
- Domain
- mobile-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100