UNPKG

shaka-player

Version:
588 lines (533 loc) 20.5 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.routing.Walker'); 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_;