From 301af92f512eb801b4f771ece95a77a06a1fb026 Mon Sep 17 00:00:00 2001 From: John Bartos Date: Thu, 19 Jul 2018 10:32:07 -0400 Subject: [PATCH] Improve stall detection and resolution (#1808) --- karma.conf.js | 2 +- package.json | 2 +- src/controller/gap-controller.js | 167 +++++++++++++++ src/controller/stream-controller.js | 145 +------------ tests/unit/controller/check-buffer.js | 232 --------------------- tests/unit/controller/gap-controller.js | 185 ++++++++++++++++ tests/unit/controller/stream-controller.js | 70 +++++++ 7 files changed, 431 insertions(+), 372 deletions(-) create mode 100644 src/controller/gap-controller.js delete mode 100644 tests/unit/controller/check-buffer.js create mode 100644 tests/unit/controller/gap-controller.js diff --git a/karma.conf.js b/karma.conf.js index ca1f959b8a3..1ed87cd0748 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -39,7 +39,7 @@ module.exports = function(config) { }, webpack: { - devtool: 'eval', + devtool: 'inline-source-map', module: { rules: [ // instrument only testing sources with Istanbul diff --git a/package.json b/package.json index ca8ea8e26b2..d31435ff4c4 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/src/controller/gap-controller.js b/src/controller/gap-controller.js new file mode 100644 index 00000000000..8e67ede8bba --- /dev/null +++ b/src/controller/gap-controller.js @@ -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 + }); + } + } +} diff --git a/src/controller/stream-controller.js b/src/controller/stream-controller.js index 55fe9166941..cbb1dbb9340 100644 --- a/src/controller/stream-controller.js +++ b/src/controller/stream-controller.js @@ -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', @@ -57,6 +58,7 @@ class StreamController extends TaskLoop { this.audioCodecSwap = false; this._state = State.STOPPED; this.stallReported = false; + this.gapController = null; } onHandlerDestroying () { @@ -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 () { @@ -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; @@ -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); } } @@ -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 diff --git a/tests/unit/controller/check-buffer.js b/tests/unit/controller/check-buffer.js deleted file mode 100644 index 3eb5f105b77..00000000000 --- a/tests/unit/controller/check-buffer.js +++ /dev/null @@ -1,232 +0,0 @@ -import assert from 'assert'; -import sinon from 'sinon'; - -import Hls from '../../../src/hls'; - -import StreamController from '../../../src/controller/stream-controller'; -import { FragmentTracker } from '../../../src/controller/fragment-tracker'; - -import Event from '../../../src/events'; - -import { ErrorTypes, ErrorDetails } from '../../../src/errors'; - -describe('checkBuffer', function () { - let streamController; - let config; - let media; - let triggerSpy; - const sandbox = sinon.sandbox.create(); - - beforeEach(function () { - media = document.createElement('video'); - const hls = new Hls({}); - const fragmentTracker = new FragmentTracker(hls); - streamController = new StreamController(hls, fragmentTracker); - streamController.media = media; - config = hls.config; - triggerSpy = sinon.spy(hls, 'trigger'); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('_tryNudgeBuffer', function () { - it('should increment the currentTime by a multiple of nudgeRetry and the configured nudge amount', function () { - for (let i = 1; i < config.nudgeMaxRetry; i++) { - let expected = media.currentTime + (i * config.nudgeOffset); - streamController._tryNudgeBuffer(); - assert.strictEqual(expected, media.currentTime); - } - assert(triggerSpy.alwaysCalledWith(Event.ERROR, { - type: ErrorTypes.MEDIA_ERROR, - details: ErrorDetails.BUFFER_NUDGE_ON_STALL, - fatal: false - })); - }); - - it('should not increment the currentTime if the max amount of nudges has been attempted', function () { - config.nudgeMaxRetry = 0; - streamController._tryNudgeBuffer(); - assert.strictEqual(0, media.currentTime); - assert(triggerSpy.calledWith(Event.ERROR, { - type: ErrorTypes.MEDIA_ERROR, - details: ErrorDetails.BUFFER_STALLED_ERROR, - fatal: true - })); - }); - }); - - describe('_reportStall', function () { - it('should report a stall with the current buffer length if it has not already been reported', function () { - streamController._reportStall(42); - assert(triggerSpy.calledWith(Event.ERROR, { - type: ErrorTypes.MEDIA_ERROR, - details: ErrorDetails.BUFFER_STALLED_ERROR, - fatal: false, - buffer: 42 - })); - }); - - it('should not report a stall if it was already reported', function () { - streamController.stallReported = true; - streamController._reportStall(42); - assert(triggerSpy.notCalled); - }); - }); - - describe('_tryFixBufferStall', function () { - it('should nudge when stalling close to the buffer end', function () { - const mockBufferInfo = { len: 1 }; - const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; - const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); - streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); - assert(nudgeStub.calledOnce); - }); - - it('should not nudge when briefly stalling close to the buffer end', function () { - const mockBufferInfo = { len: 1 }; - const mockStallDuration = (config.highBufferWatchdogPeriod / 2) * 1000; - const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); - streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); - assert(nudgeStub.notCalled); - }); - - it('should not nudge when too far from the buffer end', function () { - const mockBufferInfo = { len: 0.25 }; - const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; - const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); - streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); - assert(nudgeStub.notCalled); - }); - - it('should try to jump partial fragments when detected', function () { - sandbox.stub(streamController.fragmentTracker, 'getPartialFragment').returns({}); - const skipHoleStub = sandbox.stub(streamController, '_trySkipBufferHole'); - streamController._tryFixBufferStall({ len: 0 }); - assert(skipHoleStub.calledOnce); - }); - - it('should not try to jump partial fragments when none are detected', function () { - sandbox.stub(streamController.fragmentTracker, 'getPartialFragment').returns(null); - const skipHoleStub = sandbox.stub(streamController, '_trySkipBufferHole'); - streamController._tryFixBufferStall({ len: 0 }); - assert(skipHoleStub.notCalled); - }); - }); - - describe('_seekToStartPos', function () { - it('should seek to startPosition when startPosition is not buffered & the media is not seeking', function () { - streamController.startPosition = 5; - streamController._seekToStartPos(); - assert.strictEqual(5, media.currentTime); - }); - - it('should not seek to startPosition when it is buffered', function () { - streamController.startPosition = 5; - media.currentTime = 5; - streamController._seekToStartPos(); - assert.strictEqual(5, media.currentTime); - }); - }); - - describe('_checkBuffer', function () { - let mockMedia; - let reportStallSpy; - beforeEach(function () { - mockMedia = { - readyState: 1, - buffered: { - length: 1 - } - }; - streamController.media = mockMedia; - reportStallSpy = sandbox.spy(streamController, '_reportStall'); - }); - - function setExpectedPlaying () { - streamController.loadedmetadata = true; - streamController.immediateSwitch = false; - mockMedia.paused = false; - mockMedia.readyState = 4; - mockMedia.currentTime = 4; - streamController.lastCurrentTime = 4; - } - - it('should not throw when media is undefined', function () { - streamController.media = null; - streamController._checkBuffer(); - }); - - it('should seek to start pos when metadata has not yet been loaded', function () { - const seekStub = sandbox.stub(streamController, '_seekToStartPos'); - streamController._checkBuffer(); - assert(seekStub.calledOnce); - assert(streamController.loadedmetadata); - }); - - it('should not seek to start pos when metadata has been loaded', function () { - const seekStub = sandbox.stub(streamController, '_seekToStartPos'); - streamController.loadedmetadata = true; - streamController._checkBuffer(); - assert(seekStub.notCalled); - assert(streamController.loadedmetadata); - }); - - it('should not seek to start pos when nothing has been buffered', function () { - const seekStub = sandbox.stub(streamController, '_seekToStartPos'); - mockMedia.buffered.length = 0; - streamController._checkBuffer(); - assert(seekStub.notCalled); - assert.strictEqual(streamController.loadedmetadata, undefined); - }); - - it('should complete the immediate switch if signalled', function () { - const levelSwitchStub = sandbox.stub(streamController, 'immediateLevelSwitchEnd'); - streamController.loadedmetadata = true; - streamController.immediateSwitch = true; - streamController._checkBuffer(); - assert(levelSwitchStub.called); - }); - - it('should try to fix a stall if expected to be playing', function () { - const fixStallStub = sandbox.stub(streamController, '_tryFixBufferStall'); - setExpectedPlaying(); - streamController._checkBuffer(); - - // The first _checkBuffer call made while stalling just sets stall flags - assert.strictEqual(typeof streamController.stalled, 'number'); - assert.equal(streamController.stallReported, false); - - streamController._checkBuffer(); - assert(fixStallStub.calledOnce); - }); - - it('should reset stall flags when no longer stalling', function () { - streamController.loadedmetadata = true; - streamController.stallReported = true; - streamController.nudgeRetry = 1; - streamController.stalled = 4200; - streamController.lastCurrentTime = 1; - const fixStallStub = sandbox.stub(streamController, '_tryFixBufferStall'); - streamController._checkBuffer(); - - assert.strictEqual(streamController.stalled, null); - assert.strictEqual(streamController.nudgeRetry, 0); - assert.strictEqual(streamController.stallReported, false); - assert(fixStallStub.notCalled); - }); - - it('should trigger reportStall when stalling for 1 second or longer', function () { - setExpectedPlaying(); - const clock = sandbox.useFakeTimers(0); - clock.tick(1000); - streamController.stalled = 1; - streamController._checkBuffer(); - assert(reportStallSpy.notCalled); - clock.tick(1001); - streamController._checkBuffer(); - assert(reportStallSpy.calledOnce); - }); - }); -}); diff --git a/tests/unit/controller/gap-controller.js b/tests/unit/controller/gap-controller.js new file mode 100644 index 00000000000..01ca8065786 --- /dev/null +++ b/tests/unit/controller/gap-controller.js @@ -0,0 +1,185 @@ +import assert from 'assert'; +import sinon from 'sinon'; + +import Hls from '../../../src/hls'; + +import GapController from '../../../src/controller/gap-controller'; +import { FragmentTracker } from '../../../src/controller/fragment-tracker'; + +import Event from '../../../src/events'; + +import { ErrorTypes, ErrorDetails } from '../../../src/errors'; + +describe('checkBuffer', function () { + let gapController; + let config; + let media; + let triggerSpy; + const sandbox = sinon.sandbox.create(); + + beforeEach(function () { + const hls = new Hls({}); + media = document.createElement('video'); + config = hls.config; + gapController = new GapController(config, media, new FragmentTracker(hls), hls); + triggerSpy = sinon.spy(hls, 'trigger'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('_tryNudgeBuffer', function () { + it('should increment the currentTime by a multiple of nudgeRetry and the configured nudge amount', function () { + for (let i = 1; i < config.nudgeMaxRetry; i++) { + let expected = media.currentTime + (i * config.nudgeOffset); + gapController._tryNudgeBuffer(); + assert.strictEqual(expected, media.currentTime); + } + assert(triggerSpy.alwaysCalledWith(Event.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.BUFFER_NUDGE_ON_STALL, + fatal: false + })); + }); + + it('should not increment the currentTime if the max amount of nudges has been attempted', function () { + config.nudgeMaxRetry = 0; + gapController._tryNudgeBuffer(); + assert.strictEqual(0, media.currentTime); + assert(triggerSpy.calledWith(Event.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.BUFFER_STALLED_ERROR, + fatal: true + })); + }); + }); + + describe('_reportStall', function () { + it('should report a stall with the current buffer length if it has not already been reported', function () { + gapController._reportStall(42); + assert(triggerSpy.calledWith(Event.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.BUFFER_STALLED_ERROR, + fatal: false, + buffer: 42 + })); + }); + + it('should not report a stall if it was already reported', function () { + gapController.stallReported = true; + gapController._reportStall(42); + assert(triggerSpy.notCalled); + }); + }); + + describe('_tryFixBufferStall', function () { + it('should nudge when stalling close to the buffer end', function () { + const mockBufferInfo = { len: 1 }; + const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; + const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); + gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + assert(nudgeStub.calledOnce); + }); + + it('should not nudge when briefly stalling close to the buffer end', function () { + const mockBufferInfo = { len: 1 }; + const mockStallDuration = (config.highBufferWatchdogPeriod / 2) * 1000; + const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); + gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + assert(nudgeStub.notCalled); + }); + + it('should not nudge when too far from the buffer end', function () { + const mockBufferInfo = { len: 0.25 }; + const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; + const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); + gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + assert(nudgeStub.notCalled); + }); + + it('should try to jump partial fragments when detected', function () { + sandbox.stub(gapController.fragmentTracker, 'getPartialFragment').returns({}); + const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole'); + gapController._tryFixBufferStall({ len: 0 }); + assert(skipHoleStub.calledOnce); + }); + + it('should not try to jump partial fragments when none are detected', function () { + sandbox.stub(gapController.fragmentTracker, 'getPartialFragment').returns(null); + const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole'); + gapController._tryFixBufferStall({ len: 0 }); + assert(skipHoleStub.notCalled); + }); + }); + + describe('poll', function () { + let mockMedia; + let reportStallSpy; + let lastCurrentTime; + let buffered; + beforeEach(function () { + mockMedia = { + buffered: { + length: 1 + } + }; + gapController.media = mockMedia; + reportStallSpy = sandbox.spy(gapController, '_reportStall'); + buffered = mockMedia.buffered; + }); + + function setStalling () { + mockMedia.paused = false; + mockMedia.readyState = 1; + mockMedia.currentTime = 4; + lastCurrentTime = 4; + } + + function setNotStalling () { + mockMedia.paused = false; + mockMedia.readyState = 4; + mockMedia.currentTime = 5; + lastCurrentTime = 4; + } + + it('should try to fix a stall if expected to be playing', function () { + const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall'); + setStalling(); + gapController.poll(lastCurrentTime, buffered); + + // The first poll call made while stalling just sets stall flags + assert.strictEqual(typeof gapController.stalled, 'number'); + assert.strictEqual(gapController.stallReported, false); + + gapController.poll(lastCurrentTime, buffered); + assert(fixStallStub.calledOnce); + }); + + it('should reset stall flags when no longer stalling', function () { + setNotStalling(); + gapController.stallReported = true; + gapController.nudgeRetry = 1; + gapController.stalled = 4200; + const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall'); + gapController.poll(lastCurrentTime, buffered); + + assert.strictEqual(gapController.stalled, null); + assert.strictEqual(gapController.nudgeRetry, 0); + assert.strictEqual(gapController.stallReported, false); + assert(fixStallStub.notCalled); + }); + + it('should trigger reportStall when stalling for 1 second or longer', function () { + setStalling(); + const clock = sandbox.useFakeTimers(0); + clock.tick(1000); + gapController.stalled = 1; + gapController.poll(lastCurrentTime, buffered); + assert(reportStallSpy.notCalled); + clock.tick(1001); + gapController.poll(lastCurrentTime, buffered); + assert(reportStallSpy.calledOnce); + }); + }); +}); diff --git a/tests/unit/controller/stream-controller.js b/tests/unit/controller/stream-controller.js index 2259a1cb415..e461305561d 100644 --- a/tests/unit/controller/stream-controller.js +++ b/tests/unit/controller/stream-controller.js @@ -207,4 +207,74 @@ describe('StreamController tests', function () { assertNotLoadingState(); }); }); + + describe('checkBuffer', function () { + let sandbox; + beforeEach(function () { + sandbox = sinon.sandbox.create(); + streamController.gapController = { + poll: () => {} + }; + streamController.media = { + buffered: { + length: 1 + } + }; + }); + afterEach(function () { + sandbox.restore(); + }); + + it('should not throw when media is undefined', function () { + streamController.media = null; + streamController._checkBuffer(); + }); + + it('should seek to start pos when metadata has not yet been loaded', function () { + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); + streamController.loadedmetadata = false; + streamController._checkBuffer(); + assert(seekStub.calledOnce); + assert(streamController.loadedmetadata); + }); + + it('should not seek to start pos when metadata has been loaded', function () { + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); + streamController.loadedmetadata = true; + streamController._checkBuffer(); + assert(seekStub.notCalled); + assert(streamController.loadedmetadata); + }); + + it('should not seek to start pos when nothing has been buffered', function () { + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); + streamController.media.buffered.length = 0; + streamController._checkBuffer(); + assert(seekStub.notCalled); + assert.strictEqual(streamController.loadedmetadata, undefined); + }); + + it('should complete the immediate switch if signalled', function () { + const levelSwitchStub = sandbox.stub(streamController, 'immediateLevelSwitchEnd'); + streamController.loadedmetadata = true; + streamController.immediateSwitch = true; + streamController._checkBuffer(); + assert(levelSwitchStub.called); + }); + + describe('_seekToStartPos', function () { + it('should seek to startPosition when startPosition is not buffered & the media is not seeking', function () { + streamController.startPosition = 5; + streamController._seekToStartPos(); + assert.strictEqual(5, streamController.media.currentTime); + }); + + it('should not seek to startPosition when it is buffered', function () { + streamController.startPosition = 5; + streamController.media.currentTime = 5; + streamController._seekToStartPos(); + assert.strictEqual(5, streamController.media.currentTime); + }); + }); + }); });