Skip to content

Commit

Permalink
Improve stall detection and resolution (video-dev#1808)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnBartos authored Jul 19, 2018
1 parent ca766a4 commit 301af92
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 372 deletions.
2 changes: 1 addition & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = function(config) {
},

webpack: {
devtool: 'eval',
devtool: 'inline-source-map',
module: {
rules: [
// instrument only testing sources with Istanbul
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"scripts": {
"build": "webpack --progress",
"build:watch": "webpack --progress --watch",
"build:watch": "webpack --env.debug --watch",
"build:analyze": "ANALYZE=true webpack --progress",
"build:release": "npm run build && npm run test && git add dist/* && git commit -m 'Update dist' && npm run docs:release",
"commit:release": "npm run build:release && git add dist/* && git commit -m 'update dist'",
Expand Down
167 changes: 167 additions & 0 deletions src/controller/gap-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { BufferHelper } from '../utils/buffer-helper';
import { ErrorTypes, ErrorDetails } from '../errors';
import Event from '../events';
import { logger } from '../utils/logger';

const stallDebounceInterval = 1000;
const jumpThreshold = 0.5; // tolerance needed as some browsers stalls playback before reaching buffered range end

export default class GapController {
constructor (config, media, fragmentTracker, hls) {
this.config = config;
this.media = media;
this.fragmentTracker = fragmentTracker;
this.hls = hls;
this.stallReported = false;
}

/**
* Checks if the playhead is stuck within a gap, and if so, attempts to free it.
* A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
* @param lastCurrentTime
* @param buffered
*/
poll (lastCurrentTime, buffered) {
const { config, media } = this;
const currentTime = media.currentTime;
const tnow = window.performance.now();

if (currentTime !== lastCurrentTime) {
// The playhead is now moving, but was previously stalled
if (this.stallReported) {
logger.warn(`playback not stuck anymore @${currentTime}, after ${Math.round(tnow - this.stalled)}ms`);
this.stallReported = false;
}
this.stalled = null;
this.nudgeRetry = 0;
return;
}

if (media.ended || !media.buffered.length || media.readyState > 2) {
return;
}

if (media.seeking && BufferHelper.isBuffered(media, currentTime)) {
return;
}

// The playhead isn't moving but it should be
// Allow some slack time to for small stalls to resolve themselves
const stalledDuration = tnow - this.stalled;
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole);
if (!this.stalled) {
this.stalled = tnow;
return;
} else if (stalledDuration >= stallDebounceInterval) {
// Report stalling after trying to fix
this._reportStall(bufferInfo.len);
}

this._tryFixBufferStall(bufferInfo, stalledDuration);
}

/**
* Detects and attempts to fix known buffer stalling issues.
* @param bufferInfo - The properties of the current buffer.
* @param stalledDuration - The amount of time Hls.js has been stalling for.
* @private
*/
_tryFixBufferStall (bufferInfo, stalledDuration) {
const { config, fragmentTracker, media } = this;
const currentTime = media.currentTime;

const partial = fragmentTracker.getPartialFragment(currentTime);
if (partial) {
// Try to skip over the buffer hole caused by a partial fragment
// This method isn't limited by the size of the gap between buffered ranges
this._trySkipBufferHole(partial);
}

if (bufferInfo.len > jumpThreshold && stalledDuration > config.highBufferWatchdogPeriod * 1000) {
// Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
// We only try to jump the hole if it's under the configured size
// Reset stalled so to rearm watchdog timer
this.stalled = null;
this._tryNudgeBuffer();
}
}

/**
* Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
* @param bufferLen - The playhead distance from the end of the current buffer segment.
* @private
*/
_reportStall (bufferLen) {
const { hls, media, stallReported } = this;
if (!stallReported) {
// Report stalled error once
this.stallReported = true;
logger.warn(`Playback stalling at @${media.currentTime} due to low buffer`);
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: false,
buffer: bufferLen
});
}
}

/**
* Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
* @param partial - The partial fragment found at the current time (where playback is stalling).
* @private
*/
_trySkipBufferHole (partial) {
const { hls, media } = this;
const currentTime = media.currentTime;
let lastEndTime = 0;
// Check if currentTime is between unbuffered regions of partial fragments
for (let i = 0; i < media.buffered.length; i++) {
let startTime = media.buffered.start(i);
if (currentTime >= lastEndTime && currentTime < startTime) {
media.currentTime = Math.max(startTime, media.currentTime + 0.1);
logger.warn(`skipping hole, adjusting currentTime from ${currentTime} to ${media.currentTime}`);
this.stalled = null;
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
reason: `fragment loaded with buffer holes, seeking from ${currentTime} to ${media.currentTime}`,
frag: partial
});
return;
}
lastEndTime = media.buffered.end(i);
}
}

/**
* Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
* @private
*/
_tryNudgeBuffer () {
const { config, hls, media } = this;
const currentTime = media.currentTime;
const nudgeRetry = (this.nudgeRetry || 0) + 1;
this.nudgeRetry = nudgeRetry;

if (nudgeRetry < config.nudgeMaxRetry) {
const targetTime = currentTime + nudgeRetry * config.nudgeOffset;
logger.log(`adjust currentTime from ${currentTime} to ${targetTime}`);
// playback stalled in buffered area ... let's nudge currentTime to try to overcome this
media.currentTime = targetTime;
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
fatal: false
});
} else {
logger.error(`still stuck in high buffer @${currentTime} after ${config.nudgeMaxRetry}, raise fatal error`);
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: true
});
}
}
}
145 changes: 7 additions & 138 deletions src/controller/stream-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import Fragment from '../loader/fragment';
import PlaylistLoader from '../loader/playlist-loader';
import * as LevelHelper from './level-helper';
import TimeRanges from '../utils/time-ranges';
import { ErrorTypes, ErrorDetails } from '../errors';
import { ErrorDetails } from '../errors';
import { logger } from '../utils/logger';
import { alignDiscontinuities } from '../utils/discontinuities';
import TaskLoop from '../task-loop';
import { calculateNextPDT, findFragmentByPDT, findFragmentBySN, fragmentWithinToleranceTest } from './fragment-finders';
import GapController from './gap-controller';

export const State = {
STOPPED: 'STOPPED',
Expand Down Expand Up @@ -57,6 +58,7 @@ class StreamController extends TaskLoop {
this.audioCodecSwap = false;
this._state = State.STOPPED;
this.stallReported = false;
this.gapController = null;
}

onHandlerDestroying () {
Expand Down Expand Up @@ -719,6 +721,8 @@ class StreamController extends TaskLoop {
if (this.levels && config.autoStartLoad) {
this.hls.startLoad(config.startPosition);
}

this.gapController = new GapController(config, media, this.fragmentTracker, this.hls);
}

onMediaDetaching () {
Expand Down Expand Up @@ -1359,14 +1363,12 @@ class StreamController extends TaskLoop {
* @private
*/
_checkBuffer () {
const { config, media } = this;
const stallDebounceInterval = 1000;
const { media } = this;
if (!media || media.readyState === 0) {
// Exit early if we don't have media or if the media hasn't bufferd anything yet (readyState 0)
return;
}

const currentTime = media.currentTime;
const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : media;
const buffered = mediaBuffer.buffered;

Expand All @@ -1376,34 +1378,7 @@ class StreamController extends TaskLoop {
} else if (this.immediateSwitch) {
this.immediateLevelSwitchEnd();
} else {
const expectedPlaying = !((media.paused && media.readyState > 1) || // not playing when media is paused and sufficiently buffered
media.ended || // not playing when media is ended
media.buffered.length === 0); // not playing if nothing buffered
const tnow = window.performance.now();

if (currentTime !== this.lastCurrentTime) {
// The playhead is now moving, but was previously stalled
if (this.stallReported) {
logger.warn(`playback not stuck anymore @${currentTime}, after ${Math.round(tnow - this.stalled)}ms`);
this.stallReported = false;
}
this.stalled = null;
this.nudgeRetry = 0;
} else if (expectedPlaying) {
// The playhead isn't moving but it should be
// Allow some slack time to for small stalls to resolve themselves
const stalledDuration = tnow - this.stalled;
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole);
if (!this.stalled) {
this.stalled = tnow;
return;
} else if (stalledDuration >= stallDebounceInterval) {
// Report stalling after trying to fix
this._reportStall(bufferInfo.len);
}

this._tryFixBufferStall(bufferInfo, stalledDuration);
}
this.gapController.poll(this.lastCurrentTime, buffered);
}
}

Expand Down Expand Up @@ -1442,112 +1417,6 @@ class StreamController extends TaskLoop {
return sliding + Math.max(0, levelDetails.totalduration - targetLatency);
}

/**
* Detects and attempts to fix known buffer stalling issues.
* @param bufferInfo - The properties of the current buffer.
* @param stalledDuration - The amount of time Hls.js has been stalling for.
* @private
*/
_tryFixBufferStall (bufferInfo, stalledDuration) {
const { config, media } = this;
const currentTime = media.currentTime;
const jumpThreshold = 0.5; // tolerance needed as some browsers stalls playback before reaching buffered range end

const partial = this.fragmentTracker.getPartialFragment(currentTime);
if (partial) {
// Try to skip over the buffer hole caused by a partial fragment
// This method isn't limited by the size of the gap between buffered ranges
this._trySkipBufferHole(partial);
}

if (bufferInfo.len > jumpThreshold && stalledDuration > config.highBufferWatchdogPeriod * 1000) {
// Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
// We only try to jump the hole if it's under the configured size
// Reset stalled so to rearm watchdog timer
this.stalled = null;
this._tryNudgeBuffer();
}
}

/**
* Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
* @param bufferLen - The playhead distance from the end of the current buffer segment.
* @private
*/
_reportStall (bufferLen) {
const { hls, media, stallReported } = this;
if (!stallReported) {
// Report stalled error once
this.stallReported = true;
logger.warn(`Playback stalling at @${media.currentTime} due to low buffer`);
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: false,
buffer: bufferLen
});
}
}

/**
* Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
* @param partial - The partial fragment found at the current time (where playback is stalling).
* @private
*/
_trySkipBufferHole (partial) {
const { hls, media } = this;
const currentTime = media.currentTime;
let lastEndTime = 0;
// Check if currentTime is between unbuffered regions of partial fragments
for (let i = 0; i < media.buffered.length; i++) {
let startTime = media.buffered.start(i);
if (currentTime >= lastEndTime && currentTime < startTime) {
media.currentTime = Math.max(startTime, media.currentTime + 0.1);
logger.warn(`skipping hole, adjusting currentTime from ${currentTime} to ${media.currentTime}`);
this.stalled = null;
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
reason: `fragment loaded with buffer holes, seeking from ${currentTime} to ${media.currentTime}`,
frag: partial
});
return;
}
lastEndTime = media.buffered.end(i);
}
}

/**
* Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
* @private
*/
_tryNudgeBuffer () {
const { config, hls, media } = this;
const currentTime = media.currentTime;
const nudgeRetry = (this.nudgeRetry || 0) + 1;
this.nudgeRetry = nudgeRetry;

if (nudgeRetry < config.nudgeMaxRetry) {
const targetTime = currentTime + nudgeRetry * config.nudgeOffset;
logger.log(`adjust currentTime from ${currentTime} to ${targetTime}`);
// playback stalled in buffered area ... let's nudge currentTime to try to overcome this
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
fatal: false
});
media.currentTime = targetTime;
} else {
logger.error(`still stuck in high buffer @${currentTime} after ${config.nudgeMaxRetry}, raise fatal error`);
hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: true
});
}
}

/**
* Seeks to the set startPosition if not equal to the mediaElement's current time.
* @private
Expand Down
Loading

0 comments on commit 301af92

Please sign in to comment.