Repository navigation
Playback jumps forward when a seek's request fails: ProgressiveMediaPeriod retries from a stale position #3440
Description
Version
Media3 1.11.1
More version details
Reproduced in Media3 1.11.1, in the demo app. Found in my podcast app with Media3 1.10.0. The finally block in ExtractingLoadable.load() is unchanged on main at commit 4a3e9cf. From the source, the bug is a regression introduced in ExoPlayer 2.12.0. Before that, 2.11.8 kept the seek target as the retry position after a failed open.
Devices that reproduce the issue
- Android Emulator, Pixel 10 Pro AVD running Android 17 (API 37, build CE2A.260420.019), demo app
- Pixel 11 running Android 17 (build CD1A.260905.001.B1), in my app (Media3 1.10.0)
Devices that do not reproduce the issue
No response
Reproducible in the demo app?
Yes
Reproduction steps
The failure has to clear before the player gives up, so the steps use a small local server that fails a set number of requests and logs the byte range of each one.
- Check out tag
1.11.1. Indemos/main/src/main/AndroidManifest.xml, addandroid:usesCleartextTraffic="true"to<application>, since the demo refuseshttp://URLs. That is the only change. ./gradlew :demo:installNoDecoderExtensionsDebugonto an emulator.- Save the media below as
aow64.mp3next to the server script below, and runnode server.js. adb shell am start -a androidx.media3.demo.main.action.VIEW -d http://10.0.2.2:8765/aow.mp3 -n androidx.media3.demo.main/.PlayerActivity- Let it play for about 60 seconds, so the first load has buffered ahead and paused.
curl http://127.0.0.1:8765/fail/2so the next two requests fail.- Press rewind once (
adb shell input keyevent KEYCODE_MEDIA_REWIND). The default back buffer is 0, so the 5-second seek back is outside the buffer and starts a new load. - Compare the byte range of the request that succeeds with the seek target, and where playback resumes with the seek position.
Notes:
- Two failures, not one: HttpEngine sometimes retries a dropped request on its own, so two failures make sure at least one reaches Media3.
- Press rewind only once. Further seeks cancel the failing load before it retries, and playback lands correctly.
- Airplane mode on the unmodified demo doesn't reproduce it: the default policy gives up after four failures in about 3 seconds (
ERROR_CODE_IO_NETWORK_CONNECTION_FAILED), and the emulator's Wi-Fi takes about 5 seconds to come back. Retrying after that error releases the period and prepares a new one, by which time the network is back.
server.js
// Media3 demo-app repro of the stale retry position. Serves one MP3 with range support and logs
// every request's Range header, so the log shows which byte each attempt asked for.
// GET /fail/N the next N requests for the MP3 fail at open: the socket is destroyed before
// any response is written, which the client sees as a network error.
// GET /aow.mp3 the file (LibriVox, public domain, 64 kbps CBR with a LAME Info header).
const http = require('http');
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, 'aow64.mp3');
const logFile = path.join(__dirname, 'server.log');
const port = 8765;
let failNext = 0;
let nextId = 1;
function log(line) {
const out = `${new Date().toISOString().slice(11, 23)} ${line}`;
console.log(out);
fs.appendFileSync(logFile, out + '\n');
}
http.createServer((req, res) => {
const url = req.url.split('?')[0];
const fail = /^\/fail\/(\d+)$/.exec(url);
if (fail) {
failNext = parseInt(fail[1], 10);
log(`CONTROL: the next ${failNext} request(s) fail at open`);
return res.end(`fail ${failNext}\n`);
}
if (url !== '/aow.mp3') {
res.writeHead(404);
return res.end();
}
const id = nextId++;
const range = req.headers.range || '-';
if (failNext > 0) {
failNext--;
log(`#${id} range=${range} -> FAILED (socket destroyed, no response); ${failNext} more to fail`);
return req.socket.destroy();
}
const total = fs.statSync(file).size;
const m = /bytes=(\d*)-(\d*)/.exec(req.headers.range || '');
const start = m && m[1] ? parseInt(m[1], 10) : 0;
let end = m && m[2] ? parseInt(m[2], 10) : total - 1;
if (end >= total) end = total - 1;
if (start >= total) {
log(`#${id} range=${range} -> 416`);
res.writeHead(416, { 'Content-Range': `bytes */${total}` });
return res.end();
}
const status = req.headers.range ? 206 : 200;
const headers = {
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
};
if (status === 206) headers['Content-Range'] = `bytes ${start}-${end}/${total}`;
res.writeHead(status, headers);
log(`#${id} range=${range} -> ${status} bytes ${start}-${end}/${total}`);
fs.createReadStream(file, { start, end }).on('error', () => res.destroy()).pipe(res);
}).listen(port, '127.0.0.1', () => log(`listening on ${port}`));Expected result
Media3 retries the failed request at the seek target's byte (688,985), and playback resumes at the seek position, 86.09 s.
Actual result
The request at the target fails, and Media3's first retry asks for byte 2,097,440, where the previous load had stopped. Playback resumes at 262.09 s, 176 s past the seek position, with no player error and no positionDiscontinuity.
Server log (request #2 is at the target; #3 and #4 are Media3's retries):
#2 range=bytes=688985- -> FAILED (socket destroyed, no response); 1 more to fail
#3 range=bytes=2097440- -> FAILED (socket destroyed, no response); 0 more to fail
#4 range=bytes=2097440- -> 206 bytes 2097440-5094334/5094335
EventLogger (the READY and isPlaying lines are 3 ms apart):
positionDiscontinuity [eventTime=92.56, mediaPos=86.09, window=0, period=0, reason=SEEK, PositionInfo:old [mediaItem=0, period=0, pos=91086], PositionInfo:new [mediaItem=0, period=0, pos=86086]]
internalError [eventTime=93.10, mediaPos=86.09, window=0, period=0, loadError
androidx.media3.datasource.HttpEngineDataSource$OpenException: android.net.http.NetworkExceptionWrapper: Exception in CronetUrlRequest: net::ERR_EMPTY_RESPONSE, ErrorCode=11, InternalErrorCode=-324, Retryable=false
...
at androidx.media3.exoplayer.source.ProgressiveMediaPeriod$ExtractingLoadable.load(ProgressiveMediaPeriod.java:1298)
internalError [eventTime=94.11, mediaPos=86.09, window=0, period=0, loadError
(same exception)
state [eventTime=95.18, mediaPos=86.09, window=0, period=0, READY]
isPlaying [eventTime=95.18, mediaPos=262.09, window=0, period=0, true]
An earlier run behaved the same way: a seek to 52.28 s was retried at byte 1,048,780 instead of 418,539 and resumed at 131.03 s, a 79-second jump.
Cause
In ExtractingLoadable.load(), each attempt reads its position from positionHolder and calls dataSource.open(). Only after open() returns does progressiveMediaExtractor.init() give the extractor a new input at that position. The finally block then saves the input's position for the retry whenever getCurrentInputPosition() != C.INDEX_UNSET.
The period creates one ProgressiveMediaExtractor and shares it with every ExtractingLoadable, and only init() replaces its input. So when open() throws on any load after the period's first, init() never runs, the input still belongs to the previous load, and the check passes. The finally block overwrites positionHolder.position with the previous load's stopping byte, and Loader retries the same loadable from there. On a period's first load there is no input yet, so the check fails and the retry goes to the right byte, which may be why this hasn't been noticed. In my app's logs, preparing at a nonzero position also reads from byte 0 first and opens the target in a second load, so a failed request on resume goes down the same path.
Two documented contracts break as a result. The retry passes that byte to Extractor.seek(), because pendingExtractorSeek is still set, but Extractor.seek() only allows the start of the stream or a position from the SeekMap. And the jump is never reported through onPositionDiscontinuity.
In 2.11.8, each attempt had a local ExtractorInput input = null, and finally updated the position only if (input != null), so a failed open left the target alone. 2.12.0 moved the input into the shared ProgressiveMediaExtractor and replaced that check with getCurrentInputPosition() != C.POSITION_UNSET.
Possible fix
Track whether this attempt created its input, which restores the 2.11.8 behavior. With this change, the reproduction above retries at the target byte every time (tested with two and with three failed requests), and playback resumes at the seek position. ProgressiveMediaPeriodTest and ProgressiveMediaSourceTest still pass. I haven't added a unit test for this case.
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
+ boolean inputInitialized = false;
try {
...
progressiveMediaExtractor.init(
extractorDataSource,
uri,
dataSource.getResponseHeaders(),
position,
length,
extractorOutput);
+ inputInitialized = true;
...
} finally {
if (result == Extractor.RESULT_SEEK) {
result = Extractor.RESULT_CONTINUE;
- } else if (progressiveMediaExtractor.getCurrentInputPosition() != C.INDEX_UNSET) {
+ } else if (inputInitialized) {
positionHolder.position = progressiveMediaExtractor.getCurrentInputPosition();
}I investigated and wrote this up with Claude Code, and reproduced it myself.
Media
https://archive.org/download/art_of_war_librivox/art_of_war_05-06_sun_tzu_64kb.mp3
LibriVox recording, public domain. 5,094,335 bytes, 64 kbps CBR with a LAME Info header, so the stock extractor can seek it. Any seekable progressive MP3 should behave the same way.
Bug Report
- You will email the zip file produced by
adb bugreportto android-media-github@google.com after filing this issue.
Activity
Retry failed progressive loads from the requested position
rohitjoins commentedon Oct 8, 2026
Thank you for reporting this issue and providing a detailed explanation. We have added a fix.