fix(player): surface a persistent HTTP 503 at open instead of retrying forever

ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a
server that keeps refusing the stream at open time left a silent black screen:
ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached
the screen. A new open-phase watchdog arms on the first 503 seen before any
frame renders and, after 20s without one, synthesizes a server-http-503 error
that shows an actionable dialog. Mid-stream 503s and live TV keep their
existing ride-out paths.

close #1830
This commit is contained in:
edde746
2026-08-08 12:06:32 +02:00
parent f0debe2c32
commit e6be5f9fef
38 changed files with 321 additions and 12 deletions
+3 -2
View File
@@ -42,10 +42,11 @@ void main() {
});
group('fatalPlaybackHttpStatuses', () {
test('excludes the 503 the reconnect path deliberately retries', () {
// stream-lavf-o sets reconnect_on_http_error=503, so a 503 is expected
// mid-playback and must not latch as fatal.
// mid-playback and must not latch as fatal. At open time the player
// screen's OpenHttp503Watchdog bounds the loop instead — only its
// synthesized cause tag, never a raw latched status, ends playback.
expect(fatalPlaybackHttpStatuses.contains(503), isFalse);
expect(PlayerError.httpStatusFromLog('http: HTTP error 503 Service Unavailable'), 503);
});
@@ -0,0 +1,92 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/open_http_503_watchdog.dart';
// An open the server answers with 503 never fails on its own: ffmpeg's
// reconnect loop retries 503 forever (#1520) and mpv just reports buffering,
// so a server that keeps refusing leaves a silent black screen (#1830). The
// watchdog is the only thing that ends that open; these tests pin its arming,
// deadline, and disarm contract.
void main() {
test('fires once patience elapses after the first open-phase 503', () {
fakeAsync((async) {
var fired = 0;
final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++);
watchdog.onOpenPhase503();
expect(watchdog.isArmed, isTrue);
async.elapse(openHttp503Patience - const Duration(milliseconds: 1));
expect(fired, 0);
async.elapse(const Duration(milliseconds: 1));
expect(fired, 1);
expect(watchdog.isArmed, isFalse);
});
});
test('repeated 503s from the reconnect loop keep the original deadline', () {
fakeAsync((async) {
var fired = 0;
final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++);
watchdog.onOpenPhase503();
async.elapse(openHttp503Patience - const Duration(seconds: 1));
// ffmpeg retries with backoff; each retry logs another 503. None of
// them may push the deadline out, or a permanently refusing server
// keeps the watchdog pending forever — the exact bug it exists to end.
watchdog.onOpenPhase503();
watchdog.onOpenPhase503();
async.elapse(const Duration(seconds: 1));
expect(fired, 1);
});
});
test('disarming before the deadline prevents the failure', () {
fakeAsync((async) {
var fired = 0;
final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++);
watchdog.onOpenPhase503();
async.elapse(openHttp503Patience - const Duration(seconds: 1));
// First frame rendered: the open succeeded, later 503s are the
// reconnect path's business.
watchdog.disarm();
expect(watchdog.isArmed, isFalse);
async.elapse(openHttp503Patience * 2);
expect(fired, 0);
});
});
test('a 503 after disarm re-arms with a fresh deadline', () {
fakeAsync((async) {
var fired = 0;
final watchdog = OpenHttp503Watchdog(onPersistent: () => fired++);
watchdog.onOpenPhase503();
async.elapse(openHttp503Patience - const Duration(seconds: 1));
watchdog.disarm();
// A new open starts 503ing: the old open's elapsed time must not count
// against it.
watchdog.onOpenPhase503();
async.elapse(openHttp503Patience - const Duration(seconds: 1));
expect(fired, 0);
async.elapse(const Duration(seconds: 1));
expect(fired, 1);
});
});
test('never fires without an observed 503', () {
fakeAsync((async) {
var fired = 0;
OpenHttp503Watchdog(onPersistent: () => fired++);
async.elapse(openHttp503Patience * 3);
expect(fired, 0);
});
});
}
@@ -59,6 +59,26 @@ void main() {
});
});
group('HTTP 503', () {
test('the open-phase watchdog tag is terminal for on-demand playback', () {
// The tag only exists once the watchdog has already waited out the
// reconnect loop's chances (#1830) — no further retry recovers it.
expect(resolve(cause: PlayerError.serverHttp503), PlaybackFailureAction.serverBusyDialog);
});
test('live TV keeps its fallback ladder', () {
// The watchdog never arms for live opens, but a defensively passed tag
// must still ride the ladder rather than kill a recoverable stream.
expect(resolve(cause: PlayerError.serverHttp503, isLive: true), PlaybackFailureAction.liveRetry);
});
test('a latched fatal status outranks the watchdog tag', () {
// A 500/404 seen on the same open is the more specific diagnosis.
expect(resolve(cause: PlayerError.serverHttp503, statuses: {500}), PlaybackFailureAction.serverLimitDialog);
expect(resolve(cause: PlayerError.serverHttp503, statuses: {404}), PlaybackFailureAction.mediaUnreadableDialog);
});
});
group('live fallback ladder', () {
test('climbs every rung below the bound', () {
for (var level = 0; level < maxLiveFallbackLevel; level++) {