Files
shaka-player/lib/routing/walker.js
T
Joey Parrish 64896d70b0 Use shorter license header
This reflects changes in Google's policy on JavaScript license
headers, which should be smaller to avoid increasing the size of the
binary unnecessarily.

This also updates the company name from "Google, Inc" to "Google LLC".

Change-Id: I3f8b9ed3700b6351f43173d50c94d35c333e82b4
2019-11-22 18:18:36 +00:00

576 lines
20 KiB
JavaScript

/** @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.routing.Walker');
goog.provide('shaka.routing.Walker.Implementation');
goog.require('goog.asserts');
goog.require('shaka.routing.Node');
goog.require('shaka.routing.Payload');
goog.require('shaka.util.Destroyer');
goog.require('shaka.util.IDestroyable');
/**
* The walker moves through a graph node-by-node executing asynchronous work
* as it enters each node.
*
* The walker accepts requests for where it should go next. Requests are queued
* and executed in FIFO order. If the current request can be interrupted, it
* will be cancelled and the next request started.
*
* A request says "I want to change where we are going". When the walker is
* ready to change destinations, it will resolve the request, allowing the
* destination to differ based on the current state and not the state when
* the request was appended.
*
* Example (from shaka.Player):
* When we unload, we need to either go to the attached or detached state based
* on whether or not we have a video element.
*
* When we are asked to unload, we don't know what other pending requests may
* be ahead of us (there could be attach requests or detach requests). We need
* to wait until its our turn to know if:
* - we should go to the attach state because we have a media element
* - we should go to the detach state because we don't have a media element
*
* The walker allows the caller to specify if a route can or cannot be
* interrupted. This is to allow potentially dependent routes to wait until
* other routes have finished.
*
* Example (from shaka.Player):
* A request to load content depends on an attach request finishing. We don't
* want load request to interrupt an attach request. By marking the attach
* request as non-interruptible we ensure that calling load before attach
* finishes will work.
*
* @implements {shaka.util.IDestroyable}
* @final
*/
shaka.routing.Walker = class {
/**
* Create a new walker that starts at |startingAt| and with |startingWith|.
* The instance of |startingWith| will be the one that the walker holds and
* uses for its life. No one else should reference it.
*
* The per-instance behaviour for the walker is provided via |implementation|
* which is used to connect this walker with the "outside world".
*
* @param {shaka.routing.Node} startingAt
* @param {shaka.routing.Payload} startingWith
* @param {shaka.routing.Walker.Implementation} implementation
*/
constructor(startingAt, startingWith, implementation) {
/** @private {?shaka.routing.Walker.Implementation} */
this.implementation_ = implementation;
/** @private {shaka.routing.Node} */
this.currentlyAt_ = startingAt;
/** @private {shaka.routing.Payload} */
this.currentlyWith_ = startingWith;
/**
* When we run out of work to do, we will set this promise so that when
* new work is added (and this is not null) it can be resolved. The only
* time when this should be non-null is when we are waiting for more work.
*
* @private {shaka.util.PublicPromise}
*/
this.waitForWork_ = null;
/** @private {!Array.<shaka.routing.Walker.Request_>} */
this.requests_ = [];
/** @private {?shaka.routing.Walker.ActiveRoute_} */
this.currentRoute_ = null;
/** @private {shaka.util.AbortableOperation} */
this.currentStep_ = null;
/**
* Hold a reference to the main loop's promise so that we know when it has
* exited. This will determine when |destroy| can resolve. Purposely make
* the main loop start next interpreter cycle so that the constructor will
* finish before it starts.
*
* @private {!Promise}
*/
this.mainLoopPromise_ = Promise.resolve().then(() => this.mainLoop_());
/** @private {!shaka.util.Destroyer} */
this.destroyer_ = new shaka.util.Destroyer(() => this.doDestroy_());
}
/** @override */
destroy() {
return this.destroyer_.destroy();
}
/** @private */
async doDestroy_() {
// If we are executing a current step, we want to interrupt it so that we
// can force the main loop to terminate.
if (this.currentStep_) {
this.currentStep_.abort();
}
// If we are waiting for more work, we want to wake-up the main loop so that
// it can exit on its own.
this.unblockMainLoop_();
// Wait for the main loop to terminate so that an async operation won't
// try and use state that we released.
await this.mainLoopPromise_;
// Any routes that we are not going to finish, we need to cancel. If we
// don't do this, those listening will be left hanging.
if (this.currentRoute_) {
this.currentRoute_.listeners.onCancel();
}
for (const request of this.requests_) {
request.listeners.onCancel();
}
// Release anything that could hold references to anything outside of this
// class.
this.currentRoute_ = null;
this.requests_ = [];
this.implementation_ = null;
}
/**
* Ask the walker to start a new route. When the walker is ready to start a
* new route, it will call |create| and |create| will provide the walker with
* a new route to execute.
*
* If any previous calls to |startNewRoute| created non-interruptible routes,
* |create| won't be called until all previous non-interruptible routes have
* finished.
*
* This method will return a collection of listeners that the caller can hook
* into. Any listener that the caller is interested should be assigned
* immediately after calling |startNewRoute| or else they could miss the event
* they want to listen for.
*
* @param {function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
* @return {shaka.routing.Walker.Listeners}
*/
startNewRoute(create) {
const listeners = {
onStart: () => {},
onEnd: () => {},
onCancel: () => {},
onError: (error) => {},
onSkip: () => {},
onEnter: () => {},
};
this.requests_.push({
create: create,
listeners: listeners,
});
// If we are in the middle of a step, try to abort it. If this is successful
// the main loop will error and the walker will enter recovery mode.
if (this.currentStep_) {
this.currentStep_.abort();
}
// Tell the main loop that new work is available. If the main loop was not
// blocked, this will be a no-op.
this.unblockMainLoop_();
return listeners;
}
/**
* @return {!Promise}
* @private
*/
async mainLoop_() {
while (!this.destroyer_.destroyed()) {
// eslint-disable-next-line no-await-in-loop
await this.doOneThing_();
}
}
/**
* Do one thing to move the walker closer to its destination. This can be:
* 1. Starting a new route.
* 2. Taking one more step/finishing a route.
* 3. Wait for a new route.
*
* @return {!Promise}
* @private
*/
doOneThing_() {
if (this.tryNewRoute_()) {
return Promise.resolve();
}
if (this.currentRoute_) {
return this.takeNextStep_();
}
goog.asserts.assert(this.waitForWork_ == null,
'We should not have a promise yet.');
// We have no more work to do. We will wait until new work has been provided
// via request route or until we are destroyed.
this.implementation_.onIdle(this.currentlyAt_);
// Wait on a new promise so that we can be resolved by |waitForWork|. This
// avoids us acting like a busy-wait.
this.waitForWork_ = new shaka.util.PublicPromise();
return this.waitForWork_;
}
/**
* Check if the walker can start a new route. There are a couple ways this can
* happen:
* 1. We have a new request but no current route
* 2. We have a new request and our current route can be interrupted
*
* @return {boolean}
* |true| when a new route was started (regardless of reason) and |false|
* when no new route was started.
*
* @private
*/
tryNewRoute_() {
goog.asserts.assert(
this.currentStep_ == null,
'We should never have a current step between taking steps.');
if (this.requests_.length == 0) {
return false;
}
// If the current route cannot be interrupted, we can't start a new route.
if (this.currentRoute_ && !this.currentRoute_.interruptible) {
return false;
}
// Stop any previously active routes. Even if we don't pick-up a new route,
// this route should stop.
if (this.currentRoute_) {
this.currentRoute_.listeners.onCancel();
this.currentRoute_ = null;
}
// Create and start the next route. We may not take any steps because it may
// be interrupted by the next request.
const request = this.requests_.shift();
const newRoute = request.create(this.currentlyWith_);
// Based on the current state of |payload|, a new route may not be
// possible. In these cases |create| will return |null| to signal that
// we should just stop the current route and move onto the next request
// (in the next main loop iteration).
if (newRoute) {
request.listeners.onStart();
// Convert the route created from the request's create method to an
// active route.
this.currentRoute_ = {
node: newRoute.node,
payload: newRoute.payload,
interruptible: newRoute.interruptible,
listeners: request.listeners,
};
} else {
request.listeners.onSkip();
}
return true;
}
/**
* Move forward one step on our current route. This assumes that we have a
* current route. A couple things can happen when moving forward:
* 1. An error - if an error occurs, it will signal an error occurred,
* attempt to recover, and drop the route.
* 2. Move - if no error occurs, we will move forward. When we arrive at
* our destination, it will signal the end and drop the route.
*
* In the event of an error or arriving at the destination, we drop the
* current route. This allows us to pick-up a new route next time the main
* loop iterates.
*
* @return {!Promise}
* @private
*/
async takeNextStep_() {
goog.asserts.assert(
this.currentRoute_,
'We need a current route to take the next step.');
// Figure out where we are supposed to go next.
this.currentlyAt_ = this.implementation_.getNext(
this.currentlyAt_,
this.currentlyWith_,
this.currentRoute_.node,
this.currentRoute_.payload);
this.currentRoute_.listeners.onEnter(this.currentlyAt_);
// Enter the new node, this is where things can go wrong since it is
// possible for "supported errors" to occur - errors that the code using
// the walker can't predict but can recover from.
try {
// TODO: This is probably a false-positive. See eslint/eslint#11687.
// eslint-disable-next-line require-atomic-updates
this.currentStep_ = this.implementation_.enterNode(
/* node= */ this.currentlyAt_,
/* has= */ this.currentlyWith_,
/* wants= */ this.currentRoute_.payload);
await this.currentStep_.promise;
this.currentStep_ = null;
// If we are at the end of the route, we need to signal it and clear the
// route so that we will pick-up a new route next iteration.
if (this.currentlyAt_ == this.currentRoute_.node) {
this.currentRoute_.listeners.onEnd();
this.currentRoute_ = null;
}
} catch (error) {
if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
goog.asserts.assert(
this.currentRoute_.interruptible,
'Do not put abortable steps in non-interruptible routes!');
this.currentRoute_.listeners.onCancel();
} else {
// There was an error with this route, so we going to abandon it and
// resolve the error. We don't reset the payload because the payload may
// still contain useful information.
this.currentRoute_.listeners.onError(error);
}
// The route and step are done. Clear them before we handle the error or
// else we may attempt to abort |currentStep_| when handling the error.
this.currentRoute_ = null;
this.currentStep_ = null;
// Still need to handle error because aborting an operation could leave us
// in an unexpected state.
this.currentlyAt_ = await this.implementation_.handleError(
this.currentlyWith_,
error);
}
}
/**
* If the main loop is blocked waiting for new work, then resolve the promise
* so that the next iteration of the main loop can execute.
*
* @private
*/
unblockMainLoop_() {
if (this.waitForWork_) {
this.waitForWork_.resolve();
this.waitForWork_ = null;
}
}
};
/**
* @typedef {{
* getNext: function(
* shaka.routing.Node,
* shaka.routing.Payload,
* shaka.routing.Node,
* shaka.routing.Payload):shaka.routing.Node,
* enterNode: function(
* shaka.routing.Node,
* shaka.routing.Payload,
* shaka.routing.Payload):!shaka.util.AbortableOperation,
* handleError: function(
* shaka.routing.Payload,
* !Error):!Promise.<shaka.routing.Node>,
* onIdle: function(shaka.routing.Node)
* }}
*
* @description
* There are some parts of the walker that will be per-instance. This type
* provides those per-instance parts.
*
* @property {function(
* shaka.routing.Node,
* shaka.routing.Payload,
* shaka.routing.Node,
* shaka.routing.Payload):shaka.routing.Node getNext
* Get the next node that the walker should move to. This method will be
* passed (in this order) the current node, current payload, destination
* node, and destination payload.
*
* @property {function(
* shaka.routing.Node,
* shaka.routing.Payload,
* shaka.routing.Payload):!Promise} enterNode
* When the walker moves into a node, it will call |enterNode| and allow the
* implementation to change the current payload. This method will be passed
* (in this order) the node the walker is entering, the current payload, and
* the destination payload. This method should NOT modify the destination
* payload.
*
* @property {function(
* shaka.routing.Payload,
* !Error):!Promise.<shaka.routing.Node> handleError
* This is the callback for when |enterNode| fails. It is passed the current
* payload and the error. If a step is aborted, the error will be
* OPERATION_ABORTED. It should reset all external dependences, modify the
* payload, and return the new current node. Calls to |handleError| should
* always resolve and the walker should always be able to continue operating.
*
* @property {function(shaka.routing.Node)} onIdle
* This is the callback for when the walker has finished processing all route
* requests and needs to wait for more work. |onIdle| will be passed the
* current node. After |onIdle| has been called, the walker will block until
* a new request is made, or the walker is destroyed.
*/
shaka.routing.Walker.Implementation;
/**
* @typedef {{
* onStart: function(),
* onEnd: function(),
* onCancel: function(),
* onError: function(!Error),
* onSkip: function(),
* onEnter: function(shaka.routing.Node)
* }}
*
* @description
* The collection of callbacks that the walker will call while executing a
* route. By setting these immediately after calling |startNewRoute|
* the user can react to route-specific events.
*
* @property {function()} onStart
* The callback for when the walker has accepted the route and will soon take
* the first step unless interrupted. Either |onStart| or |onSkip| will be
* called.
*
* @property {function()} onEnd
* The callback for when the walker has reached the end of the route. For
* every route that had |onStart| called, either |onEnd|, |onCancel|, or
* |onError| will be called.
*
* @property {function()} onCancel
* The callback for when the walker is stopping a route before getting to the
* end. This will be called either when a new route is interrupting the route,
* or the walker is being destroyed mid-route. |onCancel| will only be called
* when a route has been interrupted by another route or the walker is being
* destroyed.
*
* @property {function()} onError
* The callback for when the walker failed to execute the route because an
* unexpected error occurred. The walker will enter a recovery mode and the
* route will be abandoned.
*
* @property {function()} onSkip
* The callback for when the walker was ready to start the route, but the
* create-method returned |null|.
*
* @property {function()} onEnter
* The callback for when the walker enters a node. This will allow us to
* track the progress of the walker within a per-route scope.
*/
shaka.routing.Walker.Listeners;
/**
* @typedef {{
* node: shaka.routing.Node,
* payload: shaka.routing.Payload,
* interruptible: boolean
* }}
*
* @description
* The public description of where the walker should go. This is created
* when the callback given to |startNewRoute| is called by the walker.
*
* @property {shaka.routing.Node} node
* The node that the walker should move towards. This will be passed to
* |shaka.routing.Walker.Implementation.getNext| to help determine where to
* go next.
*
* @property {shaka.routing.Payload| payload
* The payload that the walker should have once it arrives at |node|. This
* will be passed to the |shaka.routing.Walker.Implementation.getNext| to
* help determine where to go next.
*
* @property {boolean} interruptible
* Whether or not this route can be interrupted by another request. When
* |true| this route will be interrupted so that a pending request can be
* resolved. When |false|, the route will be allowed to finished before
* resolving the next request.
*/
shaka.routing.Walker.Route;
/**
* @typedef {{
* node: shaka.routing.Node,
* payload: shaka.routing.Payload,
* interruptible: boolean,
* listeners: shaka.routing.Walker.Listeners
* }}
*
* @description
* The active route is the walker's internal representation of a route. It
* is the union of |shaka.routing.Walker.Request_| and the
* |shaka.routing.Walker.Route| created by |shaka.routing.Walker.Request_|.
*
* @property {shaka.routing.Node} node
* The node that the walker should move towards. This will be passed to
* |shaka.routing.Walker.Implementation.getNext| to help determine where to
* go next.
*
* @property {shaka.routing.Payload| payload
* The payload that the walker should have once it arrives at |node|. This
* will be passed to the |shaka.routing.Walker.Implementation.getNext| to
* help determine where to go next.
*
* @property {boolean} interruptible
* Whether or not this route can be interrupted by another request. When
* |true| this route will be interrupted so that a pending request can be
* resolved. When |false|, the route will be allowed to finished before
* resolving the next request.
*
* @property {shaka.routing.Walker.Listeners} listeners
* The listeners that the walker can used to communicate with whoever
* requested the route.
*
* @private
*/
shaka.routing.Walker.ActiveRoute_;
/**
* @typedef {{
* create: function(shaka.routing.Payload):?shaka.routing.Walker.Route,
* listeners: shaka.routing.Walker.Listeners
* }}
*
* @description
* The request is how users can talk to the walker. They can give the walker
* a request and when the walker is ready, it will resolve the request by
* calling |create|.
*
* @property {
* function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
* The function called when the walker is ready to start a new route. This can
* return |null| to say that the request was not possible and should be
* skipped.
*
* @property {shaka.routing.Walker.Listeners} listeners
* The collection of callbacks that the walker will use to talk to whoever
* provided the request.
*
* @private
*/
shaka.routing.Walker.Request_;