fix(watch-together): bound self-stall recovery by remaining media and a wait deadline
A host that stalled near the end of a file kept the whole room paused for good. Recovery demanded three times the stall in buffered headroom, and with ten seconds of media left a four-second stall asked for twelve that could never arrive; the known-cache branch re-checked every half second forever. The two limits are now explicit: how much headroom to want (scaled by the stall, capped by what is left to buffer) and how long to wait for it (a deadline from the stall's end, shared with the no-cache branch). selfRecoveryMaxHoldMs was a headroom cap and is renamed to say so.
This commit is contained in:
@@ -57,11 +57,23 @@ class HostPlaybackCoordinator {
|
||||
|
||||
/// After a self stall, the room resumes only once the host has buffered
|
||||
/// this many times the stall's length (bounded by
|
||||
/// [selfRecoveryMaxHoldMs]). Resuming with a fixed second of data on a
|
||||
/// link that just starved for four is a guaranteed second stall, and every
|
||||
/// stall is a pause plus a group restart for every guest.
|
||||
/// [selfRecoveryMaxHeadroomMs], and by the media left to buffer). Resuming
|
||||
/// with a fixed second of data on a link that just starved for four is a
|
||||
/// guaranteed second stall, and every stall is a pause plus a group restart
|
||||
/// for every guest.
|
||||
static const int selfRecoveryHeadroomFactor = 3;
|
||||
static const int selfRecoveryMaxHoldMs = 15000;
|
||||
static const int selfRecoveryMaxHeadroomMs = 15000;
|
||||
|
||||
/// Longest the room waits for that headroom, measured from the stall's
|
||||
/// end. The cache is an external quantity — a link that cannot refill in
|
||||
/// this long will stall again whatever we do, and a room held paused with
|
||||
/// no feedback is the worse failure. Also the bound when the backend
|
||||
/// reports no cache position at all.
|
||||
static const int selfRecoveryMaxWaitMs = 15000;
|
||||
|
||||
/// Slack for a cache that ends a few frames shy of the reported duration
|
||||
/// once everything left has been demuxed.
|
||||
static const int selfRecoveryEofSlackMs = 1000;
|
||||
|
||||
/// mpv's own `cache-pause-wait` while hosting: how much it refills before
|
||||
/// leaving `paused-for-cache` (default 1 s). Raised so a starved host does
|
||||
@@ -652,34 +664,38 @@ class HostPlaybackCoordinator {
|
||||
|
||||
// After our own stall, require cache headroom before resuming so we
|
||||
// don't immediately drag the room back into a stall. The requirement
|
||||
// scales with the stall we just had: a link that starved for four
|
||||
// seconds needs far more than a fixed two seconds of data.
|
||||
// scales with the stall we just had, capped by the media left to buffer
|
||||
// — near the end nothing more can arrive — and the wait itself is
|
||||
// bounded, since the cache may simply never grow.
|
||||
final player = _player;
|
||||
if (_recoveringFromSelfStall && player != null) {
|
||||
if (player.buffering) return; // A new stall event will re-drive us.
|
||||
final neededMs = _selfRecoveryHeadroomMs();
|
||||
final waitedMs = _nowMs() - _selfStallEndedMs;
|
||||
final neededMs = _selfRecoveryHeadroomMs(player);
|
||||
final ahead = player.bufferAhead;
|
||||
if (ahead != null) {
|
||||
if (ahead.inMilliseconds < neededMs) {
|
||||
_scheduleAllReadyCheck(500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No cache position from this backend: hold for the time the cache
|
||||
// would take to fill instead, measured from the stall's end.
|
||||
final remainingMs = _selfStallEndedMs + neededMs - _nowMs();
|
||||
if (remainingMs > 0) {
|
||||
_scheduleAllReadyCheck(remainingMs);
|
||||
return;
|
||||
}
|
||||
// No cache position from this backend: hold for the time the cache
|
||||
// would take to fill instead.
|
||||
final satisfied = ahead != null ? ahead.inMilliseconds >= neededMs : waitedMs >= neededMs;
|
||||
if (!satisfied && waitedMs < selfRecoveryMaxWaitMs) {
|
||||
final untilDeadlineMs = selfRecoveryMaxWaitMs - waitedMs;
|
||||
_scheduleAllReadyCheck(ahead != null ? min(500, untilDeadlineMs) : min(neededMs - waitedMs, untilDeadlineMs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
_recoveringFromSelfStall = false;
|
||||
_resolveAllReady();
|
||||
}
|
||||
|
||||
int _selfRecoveryHeadroomMs() =>
|
||||
(_lastSelfStallMs * selfRecoveryHeadroomFactor).clamp(selfRecoveryMinBufferAheadMs, selfRecoveryMaxHoldMs);
|
||||
int _selfRecoveryHeadroomMs(AttachedPlayer player) {
|
||||
final scaled = (_lastSelfStallMs * selfRecoveryHeadroomFactor).clamp(
|
||||
selfRecoveryMinBufferAheadMs,
|
||||
selfRecoveryMaxHeadroomMs,
|
||||
);
|
||||
final durationMs = player.duration.inMilliseconds;
|
||||
if (durationMs <= 0) return scaled; // Live or unknown length: nothing to cap against.
|
||||
final remainingMs = durationMs - player.position.inMilliseconds - selfRecoveryEofSlackMs;
|
||||
return remainingMs < scaled ? max(0, remainingMs) : scaled;
|
||||
}
|
||||
|
||||
void _resolveAllReady() {
|
||||
_cancelSafety();
|
||||
|
||||
@@ -286,6 +286,45 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('host stall near the end: headroom is capped by the media left, not held for what cannot arrive', () {
|
||||
fakeAsync((async) {
|
||||
final h = _Harness(async, duration: const Duration(seconds: 310));
|
||||
h.coordinator.onPeerJoined('guest', compatible: true);
|
||||
h.attachForMedia(async);
|
||||
h.guestReports(async);
|
||||
h.hostBecomesReady(async);
|
||||
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||
h.player.setPosition(const Duration(seconds: 300));
|
||||
|
||||
h.player.emitBuffering(true);
|
||||
async.elapse(const Duration(seconds: 4)); // 4s stall → 12s wanted, but only 10s of media remain.
|
||||
h.player.setBuffer(const Duration(seconds: 310)); // Everything left is buffered.
|
||||
h.player.emitBuffering(false);
|
||||
async.elapse(const Duration(milliseconds: 1500));
|
||||
|
||||
expect(h.last.phase, PlaybackPhase.playing);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('host stall: a cache that never grows releases the room at the wait deadline', () {
|
||||
fakeAsync((async) {
|
||||
final h = playingRoom(async);
|
||||
h.player.setPosition(const Duration(minutes: 5));
|
||||
|
||||
h.player.emitBuffering(true);
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
h.player.setBuffer(const Duration(minutes: 5, seconds: 3)); // 3s ahead, 12s wanted, and it stays there.
|
||||
h.player.emitBuffering(false);
|
||||
async.elapse(Duration(milliseconds: HostPlaybackCoordinator.selfRecoveryMaxWaitMs - 500));
|
||||
expect(h.last.phase, PlaybackPhase.waitingForPeers);
|
||||
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(h.last.phase, PlaybackPhase.playing);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('host stall: a heartbeat during a sub-grace blip keeps extrapolating the last anchor', () {
|
||||
fakeAsync((async) {
|
||||
final h = playingRoom(async);
|
||||
|
||||
Reference in New Issue
Block a user