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:
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user