fix(player): retry transient stream errors instead of falling back to MPV
During Android direct play, a single dropped connection or network blip mid-stream kicked an otherwise healthy ExoPlayer session over to the MPV fallback - on a Shield this showed as random backend switches minutes into an episode (log pu4ad: "ffmpeg demuxer read failed: -5" with contentIsMalformed=true, while MPV reopened the same URL fine). Three defects lined up behind it, all in the 2.17.0 ffmpeg demux path: - The AVIO read callback turned the input proxy's stored IOException (its -1 return) into a bare AVERROR(EIO) without marking javaError, so the extractor classified the failure as a malformed container. media3 never retries a ParserException, so the designed ERR_JAVA -> IOException -> load-error-retry path was unreachable. The callback now latches javaError for a negative read and fails fast on every later read in the same native call, so matroska resync cannot clobber the stored message or skip past the failed range. - A failed refill latches AVIOContext error/eof_reached and avio never drives the callback again, so even a correctly classified retry would re-fail on the stale error. nativeReadPacket and nativeSeek now clear the latch on entry; a genuine end of file (error == 0) is left alone. - FfmpegRandomAccessSource kept a handle whose read had thrown, and its position matched the retried request, so the retry was handed the same dead handle. A failed read now drops the handle and the retry reopens. A transient failure now surfaces as a retryable IOException, media3 reopens the source, and sample delivery resumes gaplessly; persistent failures still exhaust the retry policy and reach the MPV fallback as before. close #2113
This commit is contained in:
@@ -204,6 +204,13 @@ int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) {
|
||||
JNIEnv* env = envFor();
|
||||
if (env == nullptr) return AVERROR(EIO);
|
||||
if (bufSize <= 0) return 0;
|
||||
// After the byte source has failed once in this native call, fail every
|
||||
// further read immediately: libavformat's internal recovery (matroska
|
||||
// resync) would drive the dead source again, clobbering the stored
|
||||
// IOException message — and were the source half-alive, could silently
|
||||
// resync past the failed range. Failing fast makes the whole entry return
|
||||
// ERR_JAVA, and media3's retry resumes at the exact failed position.
|
||||
if (s->javaError) return AVERROR(EIO);
|
||||
|
||||
const jint want = bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes;
|
||||
// Prefer the loader's input when it already stands here: those bytes then
|
||||
@@ -216,8 +223,15 @@ int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) {
|
||||
return AVERROR(EIO);
|
||||
}
|
||||
const jint n = inputPos == s->logicalPos ? callRead(env, s, want) : callReadAt(env, s, s->logicalPos, want);
|
||||
if (s->javaError) return AVERROR(EIO);
|
||||
if (n < 0) return AVERROR(EIO);
|
||||
if (n < 0 || s->javaError) {
|
||||
// A negative count is the Input contract for a byte source that threw:
|
||||
// the Kotlin proxy caught the IOException and stored its message. Mark
|
||||
// javaError so the JNI entry point returns ERR_JAVA and media3's
|
||||
// load-error policy retries the load; a bare AVERROR would read as a
|
||||
// malformed container, which media3 never retries (#2113).
|
||||
s->javaError = true;
|
||||
return AVERROR(EIO);
|
||||
}
|
||||
if (n == 0) return AVERROR_EOF;
|
||||
|
||||
env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast<jbyte*>(buf));
|
||||
@@ -264,6 +278,21 @@ int64_t avioSeek(void* opaque, int64_t offset, int whence) {
|
||||
return target;
|
||||
}
|
||||
|
||||
// A failed byte-source read latches AVIOContext error state: fill_buffer
|
||||
// stores the AVERROR in pb->error, sets pb->eof_reached, and never drives the
|
||||
// read callback again while either is set (aviobuf.c). The Kotlin side maps
|
||||
// ERR_JAVA to an IOException that media3's load-error policy retries with the
|
||||
// source reopened, so the latch must be dropped when the retry re-enters —
|
||||
// otherwise the first retry re-fails on the stale error without ever reaching
|
||||
// the byte source (#2113). A genuine end of file latches eof_reached with
|
||||
// error == 0 and is deliberately left alone.
|
||||
void clearLatchedIoError(DemuxState* s) {
|
||||
if (s->format == nullptr || s->format->pb == nullptr) return;
|
||||
if (s->format->pb->error >= 0) return;
|
||||
s->format->pb->error = 0;
|
||||
s->format->pb->eof_reached = 0;
|
||||
}
|
||||
|
||||
// Probing (ffio_ensure_seekback) can swap the AVIO buffer for a larger
|
||||
// allocation and free the original, so the pointer handed to
|
||||
// avio_alloc_context may be stale. Free whichever buffer the context
|
||||
@@ -830,6 +859,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeR
|
||||
jint capacity = env->GetArrayLength(buffer);
|
||||
jlong result[7] = {};
|
||||
s->javaError = false;
|
||||
clearLatchedIoError(s);
|
||||
while (true) {
|
||||
if (s->packetPending) {
|
||||
// Oversized packet held across a CODE_GROW round trip: deliver it now
|
||||
@@ -931,6 +961,7 @@ Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeek(JNIEnv* env, jobjec
|
||||
DemuxState* s = gState;
|
||||
if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
|
||||
s->javaError = false;
|
||||
clearLatchedIoError(s);
|
||||
// Any packet parked for a CODE_GROW retry belongs to the pre-seek stream.
|
||||
s->packetPending = false;
|
||||
if (s->bsf != nullptr) {
|
||||
|
||||
Reference in New Issue
Block a user