/*! @license
* Shaka Player
* 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.Error');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.PublicPromise');
goog.requireType('shaka.util.AbortableOperation');
/**
* 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_());
}
/**
* Get the current routing payload.
*
* @return {shaka.routing.Payload}
*/
getCurrentPayload() {
return this.currentlyWith_;
}
/** @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_;