Source: lib/routing/walker.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.routing.Walker');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.routing.Node');
  9. goog.require('shaka.routing.Payload');
  10. goog.require('shaka.util.Destroyer');
  11. goog.require('shaka.util.Error');
  12. goog.require('shaka.util.IDestroyable');
  13. goog.require('shaka.util.PublicPromise');
  14. goog.requireType('shaka.util.AbortableOperation');
  15. /**
  16. * The walker moves through a graph node-by-node executing asynchronous work
  17. * as it enters each node.
  18. *
  19. * The walker accepts requests for where it should go next. Requests are queued
  20. * and executed in FIFO order. If the current request can be interrupted, it
  21. * will be cancelled and the next request started.
  22. *
  23. * A request says "I want to change where we are going". When the walker is
  24. * ready to change destinations, it will resolve the request, allowing the
  25. * destination to differ based on the current state and not the state when
  26. * the request was appended.
  27. *
  28. * Example (from shaka.Player):
  29. * When we unload, we need to either go to the attached or detached state based
  30. * on whether or not we have a video element.
  31. *
  32. * When we are asked to unload, we don't know what other pending requests may
  33. * be ahead of us (there could be attach requests or detach requests). We need
  34. * to wait until its our turn to know if:
  35. * - we should go to the attach state because we have a media element
  36. * - we should go to the detach state because we don't have a media element
  37. *
  38. * The walker allows the caller to specify if a route can or cannot be
  39. * interrupted. This is to allow potentially dependent routes to wait until
  40. * other routes have finished.
  41. *
  42. * Example (from shaka.Player):
  43. * A request to load content depends on an attach request finishing. We don't
  44. * want load request to interrupt an attach request. By marking the attach
  45. * request as non-interruptible we ensure that calling load before attach
  46. * finishes will work.
  47. *
  48. * @implements {shaka.util.IDestroyable}
  49. * @final
  50. */
  51. shaka.routing.Walker = class {
  52. /**
  53. * Create a new walker that starts at |startingAt| and with |startingWith|.
  54. * The instance of |startingWith| will be the one that the walker holds and
  55. * uses for its life. No one else should reference it.
  56. *
  57. * The per-instance behaviour for the walker is provided via |implementation|
  58. * which is used to connect this walker with the "outside world".
  59. *
  60. * @param {shaka.routing.Node} startingAt
  61. * @param {shaka.routing.Payload} startingWith
  62. * @param {shaka.routing.Walker.Implementation} implementation
  63. */
  64. constructor(startingAt, startingWith, implementation) {
  65. /** @private {?shaka.routing.Walker.Implementation} */
  66. this.implementation_ = implementation;
  67. /** @private {shaka.routing.Node} */
  68. this.currentlyAt_ = startingAt;
  69. /** @private {shaka.routing.Payload} */
  70. this.currentlyWith_ = startingWith;
  71. /**
  72. * When we run out of work to do, we will set this promise so that when
  73. * new work is added (and this is not null) it can be resolved. The only
  74. * time when this should be non-null is when we are waiting for more work.
  75. *
  76. * @private {?shaka.util.PublicPromise}
  77. */
  78. this.waitForWork_ = null;
  79. /** @private {!Array.<shaka.routing.Walker.Request_>} */
  80. this.requests_ = [];
  81. /** @private {?shaka.routing.Walker.ActiveRoute_} */
  82. this.currentRoute_ = null;
  83. /** @private {?shaka.util.AbortableOperation} */
  84. this.currentStep_ = null;
  85. /**
  86. * Hold a reference to the main loop's promise so that we know when it has
  87. * exited. This will determine when |destroy| can resolve. Purposely make
  88. * the main loop start next interpreter cycle so that the constructor will
  89. * finish before it starts.
  90. *
  91. * @private {!Promise}
  92. */
  93. this.mainLoopPromise_ = Promise.resolve().then(() => this.mainLoop_());
  94. /** @private {!shaka.util.Destroyer} */
  95. this.destroyer_ = new shaka.util.Destroyer(() => this.doDestroy_());
  96. }
  97. /**
  98. * Get the current routing payload.
  99. *
  100. * @return {shaka.routing.Payload}
  101. */
  102. getCurrentPayload() {
  103. return this.currentlyWith_;
  104. }
  105. /** @override */
  106. destroy() {
  107. return this.destroyer_.destroy();
  108. }
  109. /** @private */
  110. async doDestroy_() {
  111. // If we are executing a current step, we want to interrupt it so that we
  112. // can force the main loop to terminate.
  113. if (this.currentStep_) {
  114. this.currentStep_.abort();
  115. }
  116. // If we are waiting for more work, we want to wake-up the main loop so that
  117. // it can exit on its own.
  118. this.unblockMainLoop_();
  119. // Wait for the main loop to terminate so that an async operation won't
  120. // try and use state that we released.
  121. await this.mainLoopPromise_;
  122. // Any routes that we are not going to finish, we need to cancel. If we
  123. // don't do this, those listening will be left hanging.
  124. if (this.currentRoute_) {
  125. this.currentRoute_.listeners.onCancel();
  126. }
  127. for (const request of this.requests_) {
  128. request.listeners.onCancel();
  129. }
  130. // Release anything that could hold references to anything outside of this
  131. // class.
  132. this.currentRoute_ = null;
  133. this.requests_ = [];
  134. this.implementation_ = null;
  135. }
  136. /**
  137. * Ask the walker to start a new route. When the walker is ready to start a
  138. * new route, it will call |create| and |create| will provide the walker with
  139. * a new route to execute.
  140. *
  141. * If any previous calls to |startNewRoute| created non-interruptible routes,
  142. * |create| won't be called until all previous non-interruptible routes have
  143. * finished.
  144. *
  145. * This method will return a collection of listeners that the caller can hook
  146. * into. Any listener that the caller is interested should be assigned
  147. * immediately after calling |startNewRoute| or else they could miss the event
  148. * they want to listen for.
  149. *
  150. * @param {function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
  151. * @return {shaka.routing.Walker.Listeners}
  152. */
  153. startNewRoute(create) {
  154. const listeners = {
  155. onStart: () => {},
  156. onEnd: () => {},
  157. onCancel: () => {},
  158. onError: (error) => {},
  159. onSkip: () => {},
  160. onEnter: () => {},
  161. };
  162. this.requests_.push({
  163. create: create,
  164. listeners: listeners,
  165. });
  166. // If we are in the middle of a step, try to abort it. If this is successful
  167. // the main loop will error and the walker will enter recovery mode.
  168. if (this.currentStep_) {
  169. this.currentStep_.abort();
  170. }
  171. // Tell the main loop that new work is available. If the main loop was not
  172. // blocked, this will be a no-op.
  173. this.unblockMainLoop_();
  174. return listeners;
  175. }
  176. /**
  177. * @return {!Promise}
  178. * @private
  179. */
  180. async mainLoop_() {
  181. while (!this.destroyer_.destroyed()) {
  182. // eslint-disable-next-line no-await-in-loop
  183. await this.doOneThing_();
  184. }
  185. }
  186. /**
  187. * Do one thing to move the walker closer to its destination. This can be:
  188. * 1. Starting a new route.
  189. * 2. Taking one more step/finishing a route.
  190. * 3. Wait for a new route.
  191. *
  192. * @return {!Promise}
  193. * @private
  194. */
  195. doOneThing_() {
  196. if (this.tryNewRoute_()) {
  197. return Promise.resolve();
  198. }
  199. if (this.currentRoute_) {
  200. return this.takeNextStep_();
  201. }
  202. goog.asserts.assert(this.waitForWork_ == null,
  203. 'We should not have a promise yet.');
  204. // We have no more work to do. We will wait until new work has been provided
  205. // via request route or until we are destroyed.
  206. this.implementation_.onIdle(this.currentlyAt_);
  207. // Wait on a new promise so that we can be resolved by |waitForWork|. This
  208. // avoids us acting like a busy-wait.
  209. this.waitForWork_ = new shaka.util.PublicPromise();
  210. return this.waitForWork_;
  211. }
  212. /**
  213. * Check if the walker can start a new route. There are a couple ways this can
  214. * happen:
  215. * 1. We have a new request but no current route
  216. * 2. We have a new request and our current route can be interrupted
  217. *
  218. * @return {boolean}
  219. * |true| when a new route was started (regardless of reason) and |false|
  220. * when no new route was started.
  221. *
  222. * @private
  223. */
  224. tryNewRoute_() {
  225. goog.asserts.assert(
  226. this.currentStep_ == null,
  227. 'We should never have a current step between taking steps.');
  228. if (this.requests_.length == 0) {
  229. return false;
  230. }
  231. // If the current route cannot be interrupted, we can't start a new route.
  232. if (this.currentRoute_ && !this.currentRoute_.interruptible) {
  233. return false;
  234. }
  235. // Stop any previously active routes. Even if we don't pick-up a new route,
  236. // this route should stop.
  237. if (this.currentRoute_) {
  238. this.currentRoute_.listeners.onCancel();
  239. this.currentRoute_ = null;
  240. }
  241. // Create and start the next route. We may not take any steps because it may
  242. // be interrupted by the next request.
  243. const request = this.requests_.shift();
  244. const newRoute = request.create(this.currentlyWith_);
  245. // Based on the current state of |payload|, a new route may not be
  246. // possible. In these cases |create| will return |null| to signal that
  247. // we should just stop the current route and move onto the next request
  248. // (in the next main loop iteration).
  249. if (newRoute) {
  250. request.listeners.onStart();
  251. // Convert the route created from the request's create method to an
  252. // active route.
  253. this.currentRoute_ = {
  254. node: newRoute.node,
  255. payload: newRoute.payload,
  256. interruptible: newRoute.interruptible,
  257. listeners: request.listeners,
  258. };
  259. } else {
  260. request.listeners.onSkip();
  261. }
  262. return true;
  263. }
  264. /**
  265. * Move forward one step on our current route. This assumes that we have a
  266. * current route. A couple things can happen when moving forward:
  267. * 1. An error - if an error occurs, it will signal an error occurred,
  268. * attempt to recover, and drop the route.
  269. * 2. Move - if no error occurs, we will move forward. When we arrive at
  270. * our destination, it will signal the end and drop the route.
  271. *
  272. * In the event of an error or arriving at the destination, we drop the
  273. * current route. This allows us to pick-up a new route next time the main
  274. * loop iterates.
  275. *
  276. * @return {!Promise}
  277. * @private
  278. */
  279. async takeNextStep_() {
  280. goog.asserts.assert(
  281. this.currentRoute_,
  282. 'We need a current route to take the next step.');
  283. // Figure out where we are supposed to go next.
  284. this.currentlyAt_ = this.implementation_.getNext(
  285. this.currentlyAt_,
  286. this.currentlyWith_,
  287. this.currentRoute_.node,
  288. this.currentRoute_.payload);
  289. this.currentRoute_.listeners.onEnter(this.currentlyAt_);
  290. // Enter the new node, this is where things can go wrong since it is
  291. // possible for "supported errors" to occur - errors that the code using
  292. // the walker can't predict but can recover from.
  293. try {
  294. // TODO: This is probably a false-positive. See eslint/eslint#11687.
  295. // eslint-disable-next-line require-atomic-updates
  296. this.currentStep_ = this.implementation_.enterNode(
  297. /* node= */ this.currentlyAt_,
  298. /* has= */ this.currentlyWith_,
  299. /* wants= */ this.currentRoute_.payload);
  300. await this.currentStep_.promise;
  301. this.currentStep_ = null;
  302. // If we are at the end of the route, we need to signal it and clear the
  303. // route so that we will pick-up a new route next iteration.
  304. if (this.currentlyAt_ == this.currentRoute_.node) {
  305. this.currentRoute_.listeners.onEnd();
  306. this.currentRoute_ = null;
  307. }
  308. } catch (error) {
  309. if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
  310. goog.asserts.assert(
  311. this.currentRoute_.interruptible,
  312. 'Do not put abortable steps in non-interruptible routes!');
  313. this.currentRoute_.listeners.onCancel();
  314. } else {
  315. // There was an error with this route, so we going to abandon it and
  316. // resolve the error. We don't reset the payload because the payload may
  317. // still contain useful information.
  318. this.currentRoute_.listeners.onError(error);
  319. }
  320. // The route and step are done. Clear them before we handle the error or
  321. // else we may attempt to abort |currentStep_| when handling the error.
  322. this.currentRoute_ = null;
  323. this.currentStep_ = null;
  324. // Still need to handle error because aborting an operation could leave us
  325. // in an unexpected state.
  326. this.currentlyAt_ = await this.implementation_.handleError(
  327. this.currentlyWith_,
  328. error);
  329. }
  330. }
  331. /**
  332. * If the main loop is blocked waiting for new work, then resolve the promise
  333. * so that the next iteration of the main loop can execute.
  334. *
  335. * @private
  336. */
  337. unblockMainLoop_() {
  338. if (this.waitForWork_) {
  339. this.waitForWork_.resolve();
  340. this.waitForWork_ = null;
  341. }
  342. }
  343. };
  344. /**
  345. * @typedef {{
  346. * getNext: function(
  347. * shaka.routing.Node,
  348. * shaka.routing.Payload,
  349. * shaka.routing.Node,
  350. * shaka.routing.Payload):shaka.routing.Node,
  351. * enterNode: function(
  352. * shaka.routing.Node,
  353. * shaka.routing.Payload,
  354. * shaka.routing.Payload):!shaka.util.AbortableOperation,
  355. * handleError: function(
  356. * shaka.routing.Payload,
  357. * !Error):!Promise.<shaka.routing.Node>,
  358. * onIdle: function(shaka.routing.Node)
  359. * }}
  360. *
  361. * @description
  362. * There are some parts of the walker that will be per-instance. This type
  363. * provides those per-instance parts.
  364. *
  365. * @property {function(
  366. * shaka.routing.Node,
  367. * shaka.routing.Payload,
  368. * shaka.routing.Node,
  369. * shaka.routing.Payload):shaka.routing.Node getNext
  370. * Get the next node that the walker should move to. This method will be
  371. * passed (in this order) the current node, current payload, destination
  372. * node, and destination payload.
  373. *
  374. * @property {function(
  375. * shaka.routing.Node,
  376. * shaka.routing.Payload,
  377. * shaka.routing.Payload):!Promise} enterNode
  378. * When the walker moves into a node, it will call |enterNode| and allow the
  379. * implementation to change the current payload. This method will be passed
  380. * (in this order) the node the walker is entering, the current payload, and
  381. * the destination payload. This method should NOT modify the destination
  382. * payload.
  383. *
  384. * @property {function(
  385. * shaka.routing.Payload,
  386. * !Error):!Promise.<shaka.routing.Node> handleError
  387. * This is the callback for when |enterNode| fails. It is passed the current
  388. * payload and the error. If a step is aborted, the error will be
  389. * OPERATION_ABORTED. It should reset all external dependences, modify the
  390. * payload, and return the new current node. Calls to |handleError| should
  391. * always resolve and the walker should always be able to continue operating.
  392. *
  393. * @property {function(shaka.routing.Node)} onIdle
  394. * This is the callback for when the walker has finished processing all route
  395. * requests and needs to wait for more work. |onIdle| will be passed the
  396. * current node. After |onIdle| has been called, the walker will block until
  397. * a new request is made, or the walker is destroyed.
  398. */
  399. shaka.routing.Walker.Implementation;
  400. /**
  401. * @typedef {{
  402. * onStart: function(),
  403. * onEnd: function(),
  404. * onCancel: function(),
  405. * onError: function(!Error),
  406. * onSkip: function(),
  407. * onEnter: function(shaka.routing.Node)
  408. * }}
  409. *
  410. * @description
  411. * The collection of callbacks that the walker will call while executing a
  412. * route. By setting these immediately after calling |startNewRoute|
  413. * the user can react to route-specific events.
  414. *
  415. * @property {function()} onStart
  416. * The callback for when the walker has accepted the route and will soon take
  417. * the first step unless interrupted. Either |onStart| or |onSkip| will be
  418. * called.
  419. *
  420. * @property {function()} onEnd
  421. * The callback for when the walker has reached the end of the route. For
  422. * every route that had |onStart| called, either |onEnd|, |onCancel|, or
  423. * |onError| will be called.
  424. *
  425. * @property {function()} onCancel
  426. * The callback for when the walker is stopping a route before getting to the
  427. * end. This will be called either when a new route is interrupting the route,
  428. * or the walker is being destroyed mid-route. |onCancel| will only be called
  429. * when a route has been interrupted by another route or the walker is being
  430. * destroyed.
  431. *
  432. * @property {function()} onError
  433. * The callback for when the walker failed to execute the route because an
  434. * unexpected error occurred. The walker will enter a recovery mode and the
  435. * route will be abandoned.
  436. *
  437. * @property {function()} onSkip
  438. * The callback for when the walker was ready to start the route, but the
  439. * create-method returned |null|.
  440. *
  441. * @property {function()} onEnter
  442. * The callback for when the walker enters a node. This will allow us to
  443. * track the progress of the walker within a per-route scope.
  444. */
  445. shaka.routing.Walker.Listeners;
  446. /**
  447. * @typedef {{
  448. * node: shaka.routing.Node,
  449. * payload: shaka.routing.Payload,
  450. * interruptible: boolean
  451. * }}
  452. *
  453. * @description
  454. * The public description of where the walker should go. This is created
  455. * when the callback given to |startNewRoute| is called by the walker.
  456. *
  457. * @property {shaka.routing.Node} node
  458. * The node that the walker should move towards. This will be passed to
  459. * |shaka.routing.Walker.Implementation.getNext| to help determine where to
  460. * go next.
  461. *
  462. * @property {shaka.routing.Payload| payload
  463. * The payload that the walker should have once it arrives at |node|. This
  464. * will be passed to the |shaka.routing.Walker.Implementation.getNext| to
  465. * help determine where to go next.
  466. *
  467. * @property {boolean} interruptible
  468. * Whether or not this route can be interrupted by another request. When
  469. * |true| this route will be interrupted so that a pending request can be
  470. * resolved. When |false|, the route will be allowed to finished before
  471. * resolving the next request.
  472. */
  473. shaka.routing.Walker.Route;
  474. /**
  475. * @typedef {{
  476. * node: shaka.routing.Node,
  477. * payload: shaka.routing.Payload,
  478. * interruptible: boolean,
  479. * listeners: shaka.routing.Walker.Listeners
  480. * }}
  481. *
  482. * @description
  483. * The active route is the walker's internal representation of a route. It
  484. * is the union of |shaka.routing.Walker.Request_| and the
  485. * |shaka.routing.Walker.Route| created by |shaka.routing.Walker.Request_|.
  486. *
  487. * @property {shaka.routing.Node} node
  488. * The node that the walker should move towards. This will be passed to
  489. * |shaka.routing.Walker.Implementation.getNext| to help determine where to
  490. * go next.
  491. *
  492. * @property {shaka.routing.Payload| payload
  493. * The payload that the walker should have once it arrives at |node|. This
  494. * will be passed to the |shaka.routing.Walker.Implementation.getNext| to
  495. * help determine where to go next.
  496. *
  497. * @property {boolean} interruptible
  498. * Whether or not this route can be interrupted by another request. When
  499. * |true| this route will be interrupted so that a pending request can be
  500. * resolved. When |false|, the route will be allowed to finished before
  501. * resolving the next request.
  502. *
  503. * @property {shaka.routing.Walker.Listeners} listeners
  504. * The listeners that the walker can used to communicate with whoever
  505. * requested the route.
  506. *
  507. * @private
  508. */
  509. shaka.routing.Walker.ActiveRoute_;
  510. /**
  511. * @typedef {{
  512. * create: function(shaka.routing.Payload):?shaka.routing.Walker.Route,
  513. * listeners: shaka.routing.Walker.Listeners
  514. * }}
  515. *
  516. * @description
  517. * The request is how users can talk to the walker. They can give the walker
  518. * a request and when the walker is ready, it will resolve the request by
  519. * calling |create|.
  520. *
  521. * @property {
  522. * function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
  523. * The function called when the walker is ready to start a new route. This can
  524. * return |null| to say that the request was not possible and should be
  525. * skipped.
  526. *
  527. * @property {shaka.routing.Walker.Listeners} listeners
  528. * The collection of callbacks that the walker will use to talk to whoever
  529. * provided the request.
  530. *
  531. * @private
  532. */
  533. shaka.routing.Walker.Request_;