Skip to content

Commit

Permalink
Handle QuotaExceededError from appendBuffer().
Browse files Browse the repository at this point in the history
MediaSource may throw QuotaExceededError if it cannot append a
segment. Now, StreamingEngine will catch these errors from
MediaSourceEngine and reduce the buffering goals to avoid
encountering additional errors.

Closes #258

Change-Id: I1d957831424a4a6fb2681ee2c4f9ed7db7bf1711
  • Loading branch information
Timothy Drews committed May 12, 2016
1 parent b83a9c0 commit ddbc13d
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 17 deletions.
1 change: 1 addition & 0 deletions build/conformance.textproto
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ requirement: {
'com.google.javascript.jscomp.ConformanceRules$BanThrowOfNonErrorTypes'
error_message: 'Throwing non-Error types or Error itself is not allowed: '
'throw shaka.util.Error instead.'
whitelist_regexp: 'test/*'
}

requirement: {
Expand Down
15 changes: 11 additions & 4 deletions lib/media/media_source_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,10 +594,17 @@ shaka.media.MediaSourceEngine.prototype.enqueueOperation_ =
try {
operation.start();
} catch (exception) {
operation.p.reject(new shaka.util.Error(
shaka.util.Error.Category.MEDIA,
shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW,
exception));
if (exception.name == 'QuotaExceededError') {
operation.p.reject(new shaka.util.Error(
shaka.util.Error.Category.MEDIA,
shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR,
contentType));
} else {
operation.p.reject(new shaka.util.Error(
shaka.util.Error.Category.MEDIA,
shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW,
exception));
}
this.popFromQueue_(contentType);
}
}
Expand Down
95 changes: 85 additions & 10 deletions lib/media/streaming_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ shaka.media.StreamingEngine = function(
/** @private {?shakaExtern.StreamingConfiguration} */
this.config_ = null;

/** @private {number} */
this.bufferingGoalScale_ = 1;

/** @private {Promise} */
this.setupPeriodPromise_ = Promise.resolve();

Expand Down Expand Up @@ -181,7 +184,8 @@ shaka.media.StreamingEngine = function(
* performingUpdate: boolean,
* updateTimer: ?number,
* waitingToClearBuffer: boolean,
* clearingBuffer: boolean
* clearingBuffer: boolean,
* recovering: boolean
* }}
*
* @description
Expand Down Expand Up @@ -216,6 +220,9 @@ shaka.media.StreamingEngine = function(
* finishes.
* @property {boolean} clearingBuffer
* True indicates that the buffer is being cleared.
* @property {boolean} recovering
* True indicates that the last segment was not appended because it could not
* fit in the buffer.
*/
shaka.media.StreamingEngine.MediaState_;

Expand All @@ -233,10 +240,14 @@ shaka.media.StreamingEngine.prototype.MIN_BUFFER_LENGTH = 2;
*
* @param {shakaExtern.Manifest} manifest
* @param {shakaExtern.StreamingConfiguration} config
* @param {number} scaleFactor
*
* @return {number}
*/
shaka.media.StreamingEngine.getRebufferingGoal = function(manifest, config) {
return Math.max(manifest.minBufferTime || 0, config.rebufferingGoal);
shaka.media.StreamingEngine.getRebufferingGoal = function(
manifest, config, scaleFactor) {
return scaleFactor *
Math.max(manifest.minBufferTime || 0, config.rebufferingGoal);
};


Expand Down Expand Up @@ -278,7 +289,7 @@ shaka.media.StreamingEngine.prototype.configure = function(config) {

goog.asserts.assert(this.manifest_, 'manifest_ should not be null');
var rebufferingGoal = shaka.media.StreamingEngine.getRebufferingGoal(
this.manifest_, this.config_);
this.manifest_, this.config_, this.bufferingGoalScale_);
this.playhead_.setRebufferingGoal(rebufferingGoal);
};

Expand Down Expand Up @@ -551,7 +562,8 @@ shaka.media.StreamingEngine.prototype.initStreams_ = function(streamsByType) {
performingUpdate: false,
updateTimer: null,
waitingToClearBuffer: false,
clearingBuffer: false
clearingBuffer: false,
recovering: false
};
this.scheduleUpdate_(this.mediaStates_[type], 0);
}
Expand Down Expand Up @@ -760,15 +772,18 @@ shaka.media.StreamingEngine.prototype.update_ = function(mediaState) {
goog.asserts.assert(this.manifest_, 'manifest_ should not be null');
goog.asserts.assert(this.config_, 'config_ should not be null');
var rebufferingGoal = shaka.media.StreamingEngine.getRebufferingGoal(
this.manifest_, this.config_);
this.manifest_, this.config_, this.bufferingGoalScale_);

shaka.log.v2(logPrefix,
'update_:',
'playheadTime=' + playheadTime,
'bufferedAhead=' + bufferedAhead);

// If we've buffered to the buffering goal then schedule an update.
var bufferingGoal = Math.max(rebufferingGoal, this.config_.bufferingGoal);
var bufferingGoal = Math.max(
rebufferingGoal,
this.bufferingGoalScale_ * this.config_.bufferingGoal);

if (bufferedAhead >= bufferingGoal) {
shaka.log.v2(logPrefix, 'buffering goal met');
mediaState.needRebuffering = false;
Expand Down Expand Up @@ -1129,21 +1144,81 @@ shaka.media.StreamingEngine.prototype.fetchAndAppend_ = function(
reference,
results[1]);
}.bind(this)).then(function() {
if (this.destroyed_) return;

mediaState.performingUpdate = false;
mediaState.recovering = false;

// Update right away.
this.scheduleUpdate_(mediaState, 0);

// Subtlety: handleStartup_() calls onStartupComplete_() which may call
// switch() or seeked(), so we must schedule an update beforehand so
// |updateTimer| is set.
if (!this.destroyed_)
this.handleStartup_(mediaState, stream);
this.handleStartup_(mediaState, stream);

shaka.log.v1(logPrefix, 'finished fetch and append');
}.bind(this)).catch(function(error) {
if (this.destroyed_) return;
this.onError_(error);

mediaState.performingUpdate = false;

if (error.code != shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR) {
shaka.log.error(logPrefix, 'failed fetch and append: code=' + error.code);
this.onError_(error);
return;
}

// The segment cannot fit into the SourceBuffer. Ideally, MediaSource would
// have evicted old data to accommodate the segment; however, it may have
// failed to do this if the segment is very large, or if it could not find
// a suitable time range to remove.
//
// We can overcome the latter by trying to append the segment again;
// however, to avoid continuous QuotaExceededErrors we must reduce the size
// of the buffer going forward.
//
// If we've recently reduced the buffering goals, wait until the stream
// which caused the first QuotaExceededError recovers. Doing this ensures
// we don't reduce the buffering goals too quickly.

goog.asserts.assert(this.mediaStates_, 'must not be destroyed');
var mediaStates = shaka.util.MapUtils.values(this.mediaStates_);
var waitingForAnotherStreamToRecover = mediaStates.some(function(ms) {
return ms != mediaState && ms.recovering;
});

if (!waitingForAnotherStreamToRecover) {
// Reduction schedule: 80%, 60%, 40%, 20%, 16%, 12%, 8%, 4%, fail.
// Note: percentages are used for comparisons to avoid rounding errors.
var percentBefore = Math.round(100 * this.bufferingGoalScale_);
if (percentBefore > 20) {
this.bufferingGoalScale_ -= 0.2;
} else if (percentBefore > 4) {
this.bufferingGoalScale_ -= 0.04;
} else {
shaka.log.error(
logPrefix, 'MediaSource threw QuotaExceededError too many times');
this.onError_(error);
return;
}
var percentAfter = Math.round(100 * this.bufferingGoalScale_);
shaka.log.warning(
logPrefix,
'MediaSource threw QuotaExceededError:',
'reducing buffering goals by ' + (100 - percentAfter) + '%');
mediaState.recovering = true;
} else {
shaka.log.debug(
logPrefix,
'MediaSource threw QuotaExceededError:',
'waiting for another stream to recover...');
}

// If we're not rebuffering then wait to update: MediaSource may have
// failed to remove a time range because the playhead was too close to the
// range it wanted to remove.
this.scheduleUpdate_(mediaState, mediaState.needRebuffering ? 0 : 4);
}.bind(this));
};

Expand Down
2 changes: 1 addition & 1 deletion lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ shaka.Player.prototype.loadInternal = function(
shaka.Player.prototype.createPlayhead = function(opt_startTime) {
var timeline = this.manifest_.presentationTimeline;
var rebufferingGoal = shaka.media.StreamingEngine.getRebufferingGoal(
this.manifest_, this.config_.streaming);
this.manifest_, this.config_.streaming, 1 /* scaleFactor */);
return new shaka.media.Playhead(
this.video_, timeline, rebufferingGoal, opt_startTime || null,
this.onBuffering_.bind(this), this.onSeek_.bind(this));
Expand Down
8 changes: 8 additions & 0 deletions lib/util/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ shaka.util.Error.Code = {
*/
'VIDEO_ERROR': 3016,

/**
* A MediaSource operation threw QuotaExceededError and recovery failed. The
* content cannot be played correctly because the segments are too large for
* the browser/platform. This may occur when attempting to play very high
* quality, very high bitrate content on low-end devices.
* <br> error.data[0] is the type of content which caused the error.
*/
'QUOTA_EXCEEDED_ERROR': 3017,

/**
* The Player was unable to guess the manifest type based on file extension
Expand Down
21 changes: 19 additions & 2 deletions test/media_source_engine_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('MediaSourceEngine', function() {
audioSourceBuffer.updateend();
});

it('rejects the promise if this operation throws', function(done) {
it('rejects promise when operation throws', function(done) {
audioSourceBuffer.appendBuffer.and.throwError('fail!');
mockVideo.error = { code: 5 };
mediaSourceEngine.appendBuffer('audio', 1, null, null).then(function() {
Expand All @@ -196,6 +196,23 @@ describe('MediaSourceEngine', function() {
});
});

it('rejects promise when op. throws QuotaExceededError', function(done) {
var fakeDOMException = { name: 'QuotaExceededError' };
audioSourceBuffer.appendBuffer.and.callFake(function() {
throw fakeDOMException;
});
mockVideo.error = { code: 5 };
mediaSourceEngine.appendBuffer('audio', 1, null, null).then(function() {
fail('not reached');
done();
}, function(error) {
expect(error.code).toBe(shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR);
expect(error.data).toEqual(['audio']);
expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(1);
done();
});
});

it('rejects the promise if this operation fails async', function(done) {
mockVideo.error = { code: 5 };
mediaSourceEngine.appendBuffer('audio', 1, null, null).then(function() {
Expand Down Expand Up @@ -315,7 +332,7 @@ describe('MediaSourceEngine', function() {
audioSourceBuffer.updateend();
});

it('rejects the promise if this operation throws', function(done) {
it('rejects promise when operation throws', function(done) {
audioSourceBuffer.remove.and.throwError('fail!');
mockVideo.error = { code: 5 };
mediaSourceEngine.remove('audio', 1, 5).then(function() {
Expand Down
Loading

0 comments on commit ddbc13d

Please sign in to comment.