androidx / androidx/media

Allow restarting a partially-loaded chunk instead of resuming it with a byte-range request

Open
#3,416 0 comments 0 reactions 0 assignees View on GitHub

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:

  1. A new LoadErrorAction, e.g. Loader.RETRY_FROM_START, alongside RETRY and RETRY_RESET_ERROR_COUNT. This lets LoadErrorHandlingPolicy implementations opt in per error, which is useful since restarting discards bytes already downloaded and is only worth it on the media-chunk path.
  2. 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. HlsSampleStreamWrapper does exactly this when discarding upstream chunks:
    int discardFromIndex = firstRemovedChunk.getFirstSampleIndex(i);
    sampleQueues[i].discardUpstreamSamples(discardFromIndex);
    
    onLoadError also already distinguishes partial chunks — it removes the chunk only if (isMediaChunk && bytesLoaded == 0). The proposal is essentially to extend that to bytesLoaded > 0 by rolling the sample queues back first.
  • DASH looks close to free: ContainerMediaChunk.load() re-initialises the extractor whenever nextLoadPosition == 0, so resetting that field plus discarding the chunk's emitted samples should be most of the work.
  • HLS needs more care: previousExtractor reuse, initDataLoadRequired, shouldSpliceIn and 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 dataIsEncrypted branch. Does not work, as above — it refetches the whole segment but still splices at the extractor.
  • Wrapping DataSource to ignore DataSpec.position and 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 send If-Range with the ETag or Last-Modified from the first response, so the server returns 206 only if the representation is unchanged and 200 otherwise. The player would then need to detect the 200 and 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.