summaryrefslogtreecommitdiffstats
path: root/services/common/async.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'services/common/async.sys.mjs')
-rw-r--r--services/common/async.sys.mjs301
1 files changed, 301 insertions, 0 deletions
diff --git a/services/common/async.sys.mjs b/services/common/async.sys.mjs
new file mode 100644
index 0000000000..564b46a071
--- /dev/null
+++ b/services/common/async.sys.mjs
@@ -0,0 +1,301 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer");
+
+/*
+ * Helpers for various async operations.
+ */
+export var Async = {
+ /**
+ * Execute an arbitrary number of asynchronous functions one after the
+ * other, passing the callback arguments on to the next one. All functions
+ * must take a callback function as their last argument. The 'this' object
+ * will be whatever chain()'s is.
+ *
+ * @usage this._chain = Async.chain;
+ * this._chain(this.foo, this.bar, this.baz)(args, for, foo)
+ *
+ * This is equivalent to:
+ *
+ * let self = this;
+ * self.foo(args, for, foo, function (bars, args) {
+ * self.bar(bars, args, function (baz, params) {
+ * self.baz(baz, params);
+ * });
+ * });
+ */
+ chain: function chain(...funcs) {
+ let thisObj = this;
+ return function callback() {
+ if (funcs.length) {
+ let args = [...arguments, callback];
+ let f = funcs.shift();
+ f.apply(thisObj, args);
+ }
+ };
+ },
+
+ /**
+ * Check if the app is still ready (not quitting). Returns true, or throws an
+ * exception if not ready.
+ */
+ checkAppReady: function checkAppReady() {
+ // Watch for app-quit notification to stop any sync calls
+ Services.obs.addObserver(function onQuitApplication() {
+ Services.obs.removeObserver(onQuitApplication, "quit-application");
+ Async.checkAppReady = Async.promiseYield = function () {
+ let exception = Components.Exception(
+ "App. Quitting",
+ Cr.NS_ERROR_ABORT
+ );
+ exception.appIsShuttingDown = true;
+ throw exception;
+ };
+ }, "quit-application");
+ // In the common case, checkAppReady just returns true
+ return (Async.checkAppReady = function () {
+ return true;
+ })();
+ },
+
+ /**
+ * Check if the app is still ready (not quitting). Returns true if the app
+ * is ready, or false if it is being shut down.
+ */
+ isAppReady() {
+ try {
+ return Async.checkAppReady();
+ } catch (ex) {
+ if (!Async.isShutdownException(ex)) {
+ throw ex;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Check if the passed exception is one raised by checkAppReady. Typically
+ * this will be used in exception handlers to allow such exceptions to
+ * make their way to the top frame and allow the app to actually terminate.
+ */
+ isShutdownException(exception) {
+ return exception && exception.appIsShuttingDown === true;
+ },
+
+ /**
+ * A "tight loop" of promises can still lock up the browser for some time.
+ * Periodically waiting for a promise returned by this function will solve
+ * that.
+ * You should probably not use this method directly and instead use jankYielder
+ * below.
+ * Some reference here:
+ * - https://gist.github.com/jesstelford/bbb30b983bddaa6e5fef2eb867d37678
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=1094248
+ */
+ promiseYield() {
+ return new Promise(resolve => {
+ Services.tm.currentThread.dispatch(resolve, Ci.nsIThread.DISPATCH_NORMAL);
+ });
+ },
+
+ /**
+ * Shared state for yielding every N calls.
+ *
+ * Can be passed to multiple Async.yieldingForEach to have them overall yield
+ * every N iterations.
+ */
+ yieldState(yieldEvery = 50) {
+ let iterations = 0;
+
+ return {
+ shouldYield() {
+ ++iterations;
+ return iterations % yieldEvery === 0;
+ },
+ };
+ },
+
+ /**
+ * Apply the given function to each element of the iterable, yielding the
+ * event loop every yieldEvery iterations.
+ *
+ * @param iterable {Iterable}
+ * The iterable or iterator to iterate through.
+ *
+ * @param fn {(*) -> void|boolean}
+ * The function to be called on each element of the iterable.
+ *
+ * Returning true from the function will stop the iteration.
+ *
+ * @param [yieldEvery = 50] {number|object}
+ * The number of iterations to complete before yielding back to the event
+ * loop.
+ *
+ * @return {boolean}
+ * Whether or not the function returned early.
+ */
+ async yieldingForEach(iterable, fn, yieldEvery = 50) {
+ const yieldState =
+ typeof yieldEvery === "number"
+ ? Async.yieldState(yieldEvery)
+ : yieldEvery;
+ let iteration = 0;
+
+ for (const item of iterable) {
+ let result = fn(item, iteration++);
+ if (typeof result !== "undefined" && typeof result.then !== "undefined") {
+ // If we await result when it is not a Promise, we create an
+ // automatically resolved promise, which is exactly the case that we
+ // are trying to avoid.
+ result = await result;
+ }
+
+ if (result === true) {
+ return true;
+ }
+
+ if (yieldState.shouldYield()) {
+ await Async.promiseYield();
+ Async.checkAppReady();
+ }
+ }
+
+ return false;
+ },
+
+ asyncQueueCaller(log) {
+ return new AsyncQueueCaller(log);
+ },
+
+ asyncObserver(log, obj) {
+ return new AsyncObserver(log, obj);
+ },
+
+ watchdog() {
+ return new Watchdog();
+ },
+};
+
+/**
+ * Allows consumers to enqueue asynchronous callbacks to be called in order.
+ * Typically this is used when providing a callback to a caller that doesn't
+ * await on promises.
+ */
+class AsyncQueueCaller {
+ constructor(log) {
+ this._log = log;
+ this._queue = Promise.resolve();
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+ }
+
+ /**
+ * /!\ Never await on another function that calls enqueueCall /!\
+ * on the same queue or we will deadlock.
+ */
+ enqueueCall(func) {
+ this._queue = (async () => {
+ await this._queue;
+ try {
+ return await func();
+ } catch (e) {
+ this._log.error(e);
+ return false;
+ }
+ })();
+ }
+
+ promiseCallsComplete() {
+ return this._queue;
+ }
+}
+
+/*
+ * Subclass of AsyncQueueCaller that can be used with Services.obs directly.
+ * When this observe() is called, it will enqueue a call to the consumers's
+ * observe().
+ */
+class AsyncObserver extends AsyncQueueCaller {
+ constructor(obj, log) {
+ super(log);
+ this.obj = obj;
+ }
+
+ observe(subject, topic, data) {
+ this.enqueueCall(() => this.obj.observe(subject, topic, data));
+ }
+
+ promiseObserversComplete() {
+ return this.promiseCallsComplete();
+ }
+}
+
+/**
+ * Woof! Signals an operation to abort, either at shutdown or after a timeout.
+ * The buffered engine uses this to abort long-running merges, so that they
+ * don't prevent Firefox from quitting, or block future syncs.
+ */
+class Watchdog {
+ constructor() {
+ this.controller = new AbortController();
+ this.timer = new Timer();
+
+ /**
+ * The reason for signaling an abort. `null` if not signaled,
+ * `"timeout"` if the watchdog timer fired, or `"shutdown"` if the app is
+ * is quitting.
+ *
+ * @type {String?}
+ */
+ this.abortReason = null;
+ }
+
+ /**
+ * Returns the abort signal for this watchdog. This can be passed to APIs
+ * that take a signal for cancellation, like `SyncedBookmarksMirror::apply`
+ * or `fetch`.
+ *
+ * @type {AbortSignal}
+ */
+ get signal() {
+ return this.controller.signal;
+ }
+
+ /**
+ * Starts the watchdog timer, and listens for the app quitting.
+ *
+ * @param {Number} delay
+ * The time to wait before signaling the operation to abort.
+ */
+ start(delay) {
+ if (!this.signal.aborted) {
+ Services.obs.addObserver(this, "quit-application");
+ this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT);
+ }
+ }
+
+ /**
+ * Stops the watchdog timer and removes any listeners. This should be called
+ * after the operation finishes.
+ */
+ stop() {
+ if (!this.signal.aborted) {
+ Services.obs.removeObserver(this, "quit-application");
+ this.timer.cancel();
+ }
+ }
+
+ observe(subject, topic, data) {
+ if (topic == "timer-callback") {
+ this.abortReason = "timeout";
+ } else if (topic == "quit-application") {
+ this.abortReason = "shutdown";
+ }
+ this.stop();
+ this.controller.abort();
+ }
+}