summaryrefslogtreecommitdiffstats
path: root/toolkit/components/asyncshutdown
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/asyncshutdown
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/asyncshutdown')
-rw-r--r--toolkit/components/asyncshutdown/AsyncShutdown.sys.mjs1122
-rw-r--r--toolkit/components/asyncshutdown/components.conf15
-rw-r--r--toolkit/components/asyncshutdown/moz.build25
-rw-r--r--toolkit/components/asyncshutdown/nsAsyncShutdown.sys.mjs251
-rw-r--r--toolkit/components/asyncshutdown/nsIAsyncShutdown.idl229
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/head.js180
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js348
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js55
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js105
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js94
-rw-r--r--toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml11
11 files changed, 2435 insertions, 0 deletions
diff --git a/toolkit/components/asyncshutdown/AsyncShutdown.sys.mjs b/toolkit/components/asyncshutdown/AsyncShutdown.sys.mjs
new file mode 100644
index 0000000000..4f0adc092a
--- /dev/null
+++ b/toolkit/components/asyncshutdown/AsyncShutdown.sys.mjs
@@ -0,0 +1,1122 @@
+/* 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/. */
+
+/**
+ * Managing safe shutdown of asynchronous services.
+ *
+ * Firefox shutdown is composed of phases that take place
+ * sequentially. Typically, each shutdown phase removes some
+ * capabilities from the application. For instance, at the end of
+ * phase profileBeforeChange, no service is permitted to write to the
+ * profile directory (with the exception of Telemetry). Consequently,
+ * if any service has requested I/O to the profile directory before or
+ * during phase profileBeforeChange, the system must be informed that
+ * these requests need to be completed before the end of phase
+ * profileBeforeChange. Failing to inform the system of this
+ * requirement can (and has been known to) cause data loss.
+ *
+ * Example: At some point during shutdown, the Add-On Manager needs to
+ * ensure that all add-ons have safely written their data to disk,
+ * before writing its own data. Since the data is saved to the
+ * profile, this must be completed during phase profileBeforeChange.
+ *
+ * AsyncShutdown.profileBeforeChange.addBlocker(
+ * "Add-on manager: shutting down",
+ * function condition() {
+ * // Do things.
+ * // Perform I/O that must take place during phase profile-before-change
+ * return promise;
+ * }
+ * });
+ *
+ * In this example, function |condition| will be called at some point
+ * during phase profileBeforeChange and phase profileBeforeChange
+ * itself is guaranteed to not terminate until |promise| is either
+ * resolved or rejected.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gDebug",
+ "@mozilla.org/xpcom/debug;1",
+ "nsIDebug2"
+);
+
+// `true` if this is a content process, `false` otherwise.
+// It would be nicer to go through `Services.appinfo`, but some tests need to be
+// able to replace that field with a custom implementation before it is first
+// called.
+const isContent =
+ // eslint-disable-next-line mozilla/use-services
+ Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).processType ==
+ Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
+
+// Display timeout warnings after 10 seconds
+const DELAY_WARNING_MS = 10 * 1000;
+
+// Crash the process if shutdown is really too long
+// (allowing for sleep).
+const PREF_DELAY_CRASH_MS = "toolkit.asyncshutdown.crash_timeout";
+var DELAY_CRASH_MS = Services.prefs.getIntPref(PREF_DELAY_CRASH_MS, 60 * 1000); // One minute
+Services.prefs.addObserver(PREF_DELAY_CRASH_MS, function () {
+ DELAY_CRASH_MS = Services.prefs.getIntPref(PREF_DELAY_CRASH_MS);
+});
+
+/**
+ * Any addBlocker calls that failed. We add this into barrier wait
+ * crash annotations to help with debugging. When we fail to add
+ * shutdown blockers that can break shutdown. We track these globally
+ * rather than per barrier because one failure mode is when the
+ * barrier has already finished by the time addBlocker is invoked -
+ * but the failure to add the blocker may result in later barriers
+ * waiting indefinitely, so the debug information is still useful
+ * for those later barriers. See bug 1801674 for more context.
+ */
+let gBrokenAddBlockers = [];
+
+/**
+ * A set of Promise that supports waiting.
+ *
+ * Promise items may be added or removed during the wait. The wait will
+ * resolve once all Promise items have been resolved or removed.
+ */
+function PromiseSet() {
+ /**
+ * key: the Promise passed pass the client of the `PromiseSet`.
+ * value: an indirection on top of `key`, as an object with
+ * the following fields:
+ * - indirection: a Promise resolved if `key` is resolved or
+ * if `resolve` is called
+ * - resolve: a function used to resolve the indirection.
+ */
+ this._indirections = new Map();
+ // Once all the tracked promises have been resolved we are done. Once Wait()
+ // resolves, it should not be possible anymore to add further promises.
+ // This covers for a possibly rare case, where something may try to add a
+ // blocker after wait() is done, that would never be awaited for.
+ this._done = false;
+}
+PromiseSet.prototype = {
+ /**
+ * Wait until all Promise have been resolved or removed.
+ *
+ * Note that calling `wait()` causes Promise to be removed from the
+ * Set once they are resolved.
+ * @param {function} onDoneCb invoked synchronously once all the entries
+ * have been handled and no new entries will be accepted.
+ * @return {Promise} Resolved once all Promise have been resolved or removed,
+ * or rejected after at least one Promise has rejected.
+ */
+ wait(onDoneCb) {
+ // Pick an arbitrary element in the map, if any exists.
+ let entry = this._indirections.entries().next();
+ if (entry.done) {
+ // No indirections left, we are done.
+ this._done = true;
+ onDoneCb();
+ return Promise.resolve();
+ }
+
+ let [, indirection] = entry.value;
+ let promise = indirection.promise;
+ promise = promise.then(() =>
+ // At this stage, the entry has been cleaned up.
+ this.wait(onDoneCb)
+ );
+ return promise;
+ },
+
+ /**
+ * Add a new Promise to the set.
+ *
+ * Calls to wait (including ongoing calls) will only return once
+ * `key` has either resolved or been removed.
+ */
+ add(key) {
+ if (this._done) {
+ throw new Error("Wait is complete, cannot add further promises.");
+ }
+ this._ensurePromise(key);
+ let indirection = Promise.withResolvers();
+ key
+ .then(
+ x => {
+ // Clean up immediately.
+ // This needs to be done before the call to `resolve`, otherwise
+ // `wait()` may loop forever.
+ this._indirections.delete(key);
+ indirection.resolve(x);
+ },
+ err => {
+ this._indirections.delete(key);
+ indirection.reject(err);
+ }
+ )
+ .finally(() => {
+ this._indirections.delete(key);
+ // Normally the promise is resolved or rejected, but if its global
+ // goes away, only finally may be invoked. In all the other cases this
+ // is a no-op since the promise has been fulfilled already.
+ indirection.reject(
+ new Error("Promise not fulfilled, did it lost its global?")
+ );
+ });
+ this._indirections.set(key, indirection);
+ },
+
+ /**
+ * Remove a Promise from the set.
+ *
+ * Calls to wait (including ongoing calls) will ignore this promise,
+ * unless it is added again.
+ */
+ delete(key) {
+ this._ensurePromise(key);
+ let value = this._indirections.get(key);
+ if (!value) {
+ return false;
+ }
+ this._indirections.delete(key);
+ value.resolve();
+ return true;
+ },
+
+ _ensurePromise(key) {
+ if (!key || typeof key != "object") {
+ throw new Error("Expected an object");
+ }
+ if (!("then" in key) || typeof key.then != "function") {
+ throw new Error("Expected a Promise");
+ }
+ },
+};
+
+/**
+ * Display a warning.
+ *
+ * As this code is generally used during shutdown, there are chances
+ * that the UX will not be available to display warnings on the
+ * console. We therefore use dump() rather than Cu.reportError().
+ */
+function log(msg, prefix = "", error = null) {
+ try {
+ dump(prefix + msg + "\n");
+ if (error) {
+ dump(prefix + error + "\n");
+ if (typeof error == "object" && "stack" in error) {
+ dump(prefix + error.stack + "\n");
+ }
+ }
+ } catch (ex) {
+ dump("INTERNAL ERROR in AsyncShutdown: cannot log message.\n");
+ }
+}
+const PREF_DEBUG_LOG = "toolkit.asyncshutdown.log";
+var DEBUG_LOG = Services.prefs.getBoolPref(PREF_DEBUG_LOG, false);
+Services.prefs.addObserver(PREF_DEBUG_LOG, function () {
+ DEBUG_LOG = Services.prefs.getBoolPref(PREF_DEBUG_LOG);
+});
+
+function debug(msg, error = null) {
+ if (DEBUG_LOG) {
+ log(msg, "DEBUG: ", error);
+ }
+}
+function warn(msg, error = null) {
+ log(msg, "WARNING: ", error);
+}
+function fatalerr(msg, error = null) {
+ log(msg, "FATAL ERROR: ", error);
+}
+
+// Utility function designed to get the current state of execution
+// of a blocker.
+// We are a little paranoid here to ensure that in case of evaluation
+// error we do not block the AsyncShutdown.
+function safeGetState(fetchState) {
+ if (!fetchState) {
+ return "(none)";
+ }
+ let data, string;
+ try {
+ // Evaluate fetchState(), normalize the result into something that we can
+ // safely stringify or upload.
+ let state = fetchState();
+ if (!state) {
+ return "(none)";
+ }
+ string = JSON.stringify(state);
+ data = JSON.parse(string);
+ // Simplify the rest of the code by ensuring that we can simply
+ // concatenate the result to a message.
+ if (data && typeof data == "object") {
+ data.toString = function () {
+ return string;
+ };
+ }
+ return data;
+ } catch (ex) {
+ // Make sure that this causes test failures
+ Promise.reject(ex);
+
+ if (string) {
+ return string;
+ }
+ try {
+ return "Error getting state: " + ex + " at " + ex.stack;
+ } catch (ex2) {
+ return "Error getting state but could not display error";
+ }
+ }
+}
+
+/**
+ * Countdown for a given duration, skipping beats if the computer is too busy,
+ * sleeping or otherwise unavailable.
+ *
+ * @param {number} delay An approximate delay to wait in milliseconds (rounded
+ * up to the closest second).
+ *
+ * @return Deferred
+ */
+function looseTimer(delay) {
+ let DELAY_BEAT = 1000;
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ let beats = Math.ceil(delay / DELAY_BEAT);
+ let deferred = Promise.withResolvers();
+ timer.initWithCallback(
+ function () {
+ if (beats <= 0) {
+ deferred.resolve();
+ }
+ --beats;
+ },
+ DELAY_BEAT,
+ Ci.nsITimer.TYPE_REPEATING_PRECISE_CAN_SKIP
+ );
+ // Ensure that the timer is both canceled once we are done with it
+ // and not garbage-collected until then.
+ deferred.promise.then(
+ () => timer.cancel(),
+ () => timer.cancel()
+ );
+ return deferred;
+}
+
+/**
+ * Given an nsIStackFrame object, find the caller filename, line number,
+ * and stack if necessary, and return them as an object.
+ *
+ * @param {nsIStackFrame} topFrame Top frame of the call stack.
+ * @param {string} filename Pre-supplied filename or null if unknown.
+ * @param {number} lineNumber Pre-supplied line number or null if unknown.
+ * @param {string} stack Pre-supplied stack or null if unknown.
+ *
+ * @return object
+ */
+function getOrigin(topFrame, filename = null, lineNumber = null, stack = null) {
+ try {
+ // Determine the filename and line number of the caller.
+ let frame = topFrame;
+
+ for (; frame && frame.filename == topFrame.filename; frame = frame.caller) {
+ // Climb up the stack
+ }
+
+ if (filename == null) {
+ filename = frame ? frame.filename : "?";
+ }
+ if (lineNumber == null) {
+ lineNumber = frame ? frame.lineNumber : 0;
+ }
+ if (stack == null) {
+ // Now build the rest of the stack as a string, using Task.jsm's rewriting
+ // to ensure that we do not lose information at each call to `Task.spawn`.
+ stack = [];
+ while (frame != null) {
+ stack.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber);
+ frame = frame.caller;
+ }
+ }
+
+ return {
+ filename,
+ lineNumber,
+ stack,
+ };
+ } catch (ex) {
+ return {
+ filename: "<internal error: could not get origin>",
+ lineNumber: -1,
+ stack: "<internal error: could not get origin>",
+ };
+ }
+}
+
+/**
+ * {string} topic -> phase
+ */
+var gPhases = new Map();
+
+export var AsyncShutdown = {
+ /**
+ * Access function getPhase. For testing purposes only.
+ */
+ get _getPhase() {
+ let accepted = Services.prefs.getBoolPref(
+ "toolkit.asyncshutdown.testing",
+ false
+ );
+ if (accepted) {
+ return getPhase;
+ }
+ return undefined;
+ },
+
+ /**
+ * This constant is used as the amount of milliseconds to allow shutdown to be
+ * blocked until we crash the process forcibly and is read from the
+ * 'toolkit.asyncshutdown.crash_timeout' pref.
+ */
+ get DELAY_CRASH_MS() {
+ return DELAY_CRASH_MS;
+ },
+};
+
+/**
+ * Register a new phase.
+ *
+ * @param {string} topic The notification topic for this Phase.
+ * @see {https://developer.mozilla.org/en-US/docs/Observer_Notifications}
+ */
+function getPhase(topic) {
+ let phase = gPhases.get(topic);
+ if (phase) {
+ return phase;
+ }
+ let spinner = new Spinner(topic);
+ phase = Object.freeze({
+ /**
+ * Register a blocker for the completion of a phase.
+ *
+ * @param {string} name The human-readable name of the blocker. Used
+ * for debugging/error reporting. Please make sure that the name
+ * respects the following model: "Some Service: some action in progress" -
+ * for instance "OS.File: flushing all pending I/O";
+ * @param {function|promise|*} condition A condition blocking the
+ * completion of the phase. Generally, this is a function
+ * returning a promise. This function is evaluated during the
+ * phase and the phase is guaranteed to not terminate until the
+ * resulting promise is either resolved or rejected. If
+ * |condition| is not a function but another value |v|, it behaves
+ * as if it were a function returning |v|.
+ * @param {object*} details Optionally, an object with details
+ * that may be useful for error reporting, as a subset of of the following
+ * fields:
+ * - fetchState (strongly recommended) A function returning
+ * information about the current state of the blocker as an
+ * object. Used for providing more details when logging errors or
+ * crashing.
+ * - stack. A string containing stack information. This module can
+ * generally infer stack information if it is not provided.
+ * - lineNumber A number containing the line number for the caller.
+ * This module can generally infer this information if it is not
+ * provided.
+ * - filename A string containing the filename for the caller. This
+ * module can generally infer the information if it is not provided.
+ *
+ * Examples:
+ * AsyncShutdown.profileBeforeChange.addBlocker("Module: just a promise",
+ * promise); // profileBeforeChange will not complete until
+ * // promise is resolved or rejected
+ *
+ * AsyncShutdown.profileBeforeChange.addBlocker("Module: a callback",
+ * function callback() {
+ * // ...
+ * // Execute this code during profileBeforeChange
+ * return promise;
+ * // profileBeforeChange will not complete until promise
+ * // is resolved or rejected
+ * });
+ *
+ * AsyncShutdown.profileBeforeChange.addBlocker("Module: trivial callback",
+ * function callback() {
+ * // ...
+ * // Execute this code during profileBeforeChange
+ * // No specific guarantee about completion of profileBeforeChange
+ * });
+ */
+ addBlocker(name, condition, details = null) {
+ spinner.addBlocker(name, condition, details);
+ },
+ /**
+ * Remove the blocker for a condition.
+ *
+ * If several blockers have been registered for the same
+ * condition, remove all these blockers. If no blocker has been
+ * registered for this condition, this is a noop.
+ *
+ * @return {boolean} true if a blocker has been removed, false
+ * otherwise. Note that a result of false may mean either that
+ * the blocker has never been installed or that the phase has
+ * completed and the blocker has already been resolved.
+ */
+ removeBlocker(condition) {
+ return spinner.removeBlocker(condition);
+ },
+
+ get name() {
+ return spinner.name;
+ },
+
+ get isClosed() {
+ return spinner.isClosed;
+ },
+
+ /**
+ * Trigger the phase without having to broadcast a
+ * notification. For testing purposes only.
+ */
+ get _trigger() {
+ let accepted = Services.prefs.getBoolPref(
+ "toolkit.asyncshutdown.testing",
+ false
+ );
+ if (accepted) {
+ return () => spinner.observe();
+ }
+ return undefined;
+ },
+ });
+ gPhases.set(topic, phase);
+ return phase;
+}
+
+/**
+ * Utility class used to spin the event loop until all blockers for a
+ * Phase are satisfied.
+ *
+ * @param {string} topic The xpcom notification for that phase.
+ */
+function Spinner(topic) {
+ this._barrier = new Barrier(topic);
+ this._topic = topic;
+ Services.obs.addObserver(this, topic);
+}
+
+Spinner.prototype = {
+ /**
+ * Register a new condition for this phase.
+ *
+ * See the documentation of `addBlocker` in property `client`
+ * of instances of `Barrier`.
+ */
+ addBlocker(name, condition, details) {
+ this._barrier.client.addBlocker(name, condition, details);
+ },
+ /**
+ * Remove the blocker for a condition.
+ *
+ * See the documentation of `removeBlocker` in rpoperty `client`
+ * of instances of `Barrier`
+ *
+ * @return {boolean} true if a blocker has been removed, false
+ * otherwise. Note that a result of false may mean either that
+ * the blocker has never been installed or that the phase has
+ * completed and the blocker has already been resolved.
+ */
+ removeBlocker(condition) {
+ return this._barrier.client.removeBlocker(condition);
+ },
+
+ get name() {
+ return this._barrier.client.name;
+ },
+
+ get isClosed() {
+ return this._barrier.client.isClosed;
+ },
+
+ // nsIObserver.observe
+ observe() {
+ let topic = this._topic;
+ debug(`Starting phase ${topic}`);
+ Services.obs.removeObserver(this, topic);
+
+ // Setup the promise that will signal our phase's end.
+ let isPhaseEnd = false;
+ try {
+ this._barrier
+ .wait({
+ warnAfterMS: DELAY_WARNING_MS,
+ crashAfterMS: DELAY_CRASH_MS,
+ })
+ .finally(() => {
+ isPhaseEnd = true;
+ });
+ } catch (ex) {
+ debug("Error waiting for notification");
+ throw ex;
+ }
+
+ // Now, spin the event loop. In case of a hang we will just crash without
+ // ever leaving this loop.
+ debug("Spinning the event loop");
+ Services.tm.spinEventLoopUntil(
+ `AsyncShutdown Spinner for ${topic}`,
+ () => isPhaseEnd
+ );
+
+ debug(`Finished phase ${topic}`);
+ },
+};
+
+/**
+ * A mechanism used to register blockers that prevent some action from
+ * happening.
+ *
+ * An instance of |Barrier| provides a capability |client| that
+ * clients can use to register blockers. The barrier is resolved once
+ * all registered blockers have been resolved. The owner of the
+ * |Barrier| may wait for the resolution of the barrier and obtain
+ * information on which blockers have not been resolved yet.
+ *
+ * @param {string} name The name of the blocker. Used mainly for error-
+ * reporting.
+ */
+function Barrier(name) {
+ if (!name) {
+ throw new TypeError("Instances of Barrier need a (non-empty) name");
+ }
+
+ /**
+ * The set of all Promise for which we need to wait before the barrier
+ * is lifted. Note that this set may be changed while we are waiting.
+ *
+ * Set to `null` once the wait is complete.
+ */
+ this._waitForMe = new PromiseSet();
+
+ /**
+ * A map from conditions, as passed by users during the call to `addBlocker`,
+ * to `promise`, as present in `this._waitForMe`.
+ *
+ * Used to let users perform cleanup through `removeBlocker`.
+ * Set to `null` once the wait is complete.
+ *
+ * Key: condition (any, as passed by user)
+ * Value: promise used as a key in `this._waitForMe`. Note that there is
+ * no guarantee that the key is still present in `this._waitForMe`.
+ */
+ this._conditionToPromise = new Map();
+
+ /**
+ * A map from Promise, as present in `this._waitForMe` or
+ * `this._conditionToPromise`, to information on blockers.
+ *
+ * Key: Promise (as present in this._waitForMe or this._conditionToPromise).
+ * Value: {
+ * trigger: function,
+ * promise,
+ * name,
+ * fetchState: function,
+ * stack,
+ * filename,
+ * lineNumber
+ * };
+ */
+ this._promiseToBlocker = new Map();
+
+ /**
+ * The name of the barrier.
+ */
+ if (typeof name != "string") {
+ throw new TypeError("The name of the barrier must be a string");
+ }
+ this._name = name;
+
+ /**
+ * A cache for the promise returned by wait().
+ */
+ this._promise = null;
+
+ /**
+ * `true` once we have started waiting.
+ */
+ this._isStarted = false;
+
+ /**
+ * `true` once we're done and won't accept any new blockers.
+ */
+ this._isClosed = false;
+
+ /**
+ * The capability of adding blockers. This object may safely be returned
+ * or passed to clients.
+ */
+ this.client = {
+ /**
+ * The name of the barrier owning this client.
+ */
+ get name() {
+ return name;
+ },
+
+ /**
+ * Register a blocker for the completion of this barrier.
+ *
+ * @param {string} name The human-readable name of the blocker. Used
+ * for debugging/error reporting. Please make sure that the name
+ * respects the following model: "Some Service: some action in progress" -
+ * for instance "OS.File: flushing all pending I/O";
+ * @param {function|promise|*} condition A condition blocking the
+ * completion of the phase. Generally, this is a function
+ * returning a promise. This function is evaluated during the
+ * phase and the phase is guaranteed to not terminate until the
+ * resulting promise is either resolved or rejected. If
+ * |condition| is not a function but another value |v|, it behaves
+ * as if it were a function returning |v|.
+ * @param {object*} details Optionally, an object with details
+ * that may be useful for error reporting, as a subset of of the following
+ * fields:
+ * - fetchState (strongly recommended) A function returning
+ * information about the current state of the blocker as an
+ * object. Used for providing more details when logging errors or
+ * crashing.
+ * - stack. A string containing stack information. This module can
+ * generally infer stack information if it is not provided.
+ * - lineNumber A number containing the line number for the caller.
+ * This module can generally infer this information if it is not
+ * provided.
+ * - filename A string containing the filename for the caller. This
+ * module can generally infer the information if it is not provided.
+ */
+ addBlocker: (name, condition, details) => {
+ if (typeof name != "string") {
+ gBrokenAddBlockers.push("No-name blocker");
+ throw new TypeError("Expected a human-readable name as first argument");
+ }
+ if (details && typeof details == "function") {
+ details = {
+ fetchState: details,
+ };
+ } else if (!details) {
+ details = {};
+ }
+ if (typeof details != "object") {
+ gBrokenAddBlockers.push(`${name} - invalid details`);
+ throw new TypeError(
+ "Expected an object as third argument to `addBlocker`, got " + details
+ );
+ }
+ if (!this._waitForMe) {
+ gBrokenAddBlockers.push(`${name} - ${this._name} finished`);
+ throw new Error(
+ `Phase "${this._name}" is finished, it is too late to register completion condition "${name}"`
+ );
+ }
+ debug(`Adding blocker ${name} for phase ${this._name}`);
+
+ try {
+ this.client._internalAddBlocker(name, condition, details);
+ } catch (ex) {
+ gBrokenAddBlockers.push(`${name} - ${ex.message}`);
+ throw ex;
+ }
+ },
+
+ _internalAddBlocker: (
+ name,
+ condition,
+ { fetchState = null, filename = null, lineNumber = null, stack = null }
+ ) => {
+ if (fetchState != null && typeof fetchState != "function") {
+ throw new TypeError("Expected a function for option `fetchState`");
+ }
+
+ // Split the condition between a trigger function and a promise.
+
+ // The function to call to notify the blocker that we have started waiting.
+ // This function returns a promise resolved/rejected once the
+ // condition is complete, and never throws.
+ let trigger;
+
+ // A promise resolved once the condition is complete.
+ let promise;
+ if (typeof condition == "function") {
+ promise = new Promise((resolve, reject) => {
+ trigger = () => {
+ try {
+ resolve(condition());
+ } catch (ex) {
+ reject(ex);
+ }
+ };
+ });
+ } else {
+ // If `condition` is not a function, `trigger` is not particularly
+ // interesting, and `condition` needs to be normalized to a promise.
+ trigger = () => {};
+ promise = Promise.resolve(condition);
+ }
+
+ // Make sure that `promise` never rejects.
+ promise = promise
+ .catch(error => {
+ let msg = `A blocker encountered an error while we were waiting.
+ Blocker: ${name}
+ Phase: ${this._name}
+ State: ${safeGetState(fetchState)}`;
+ warn(msg, error);
+
+ // The error should remain uncaught, to ensure that it
+ // still causes tests to fail.
+ Promise.reject(error);
+ })
+ // Added as a last line of defense, in case `warn`, `this._name` or
+ // `safeGetState` somehow throws an error.
+ .catch(() => {});
+
+ let topFrame = null;
+ if (filename == null || lineNumber == null || stack == null) {
+ topFrame = Components.stack;
+ }
+
+ let blocker = {
+ trigger,
+ promise,
+ name,
+ fetchState,
+ getOrigin: () => getOrigin(topFrame, filename, lineNumber, stack),
+ };
+
+ this._waitForMe.add(promise);
+ this._promiseToBlocker.set(promise, blocker);
+ this._conditionToPromise.set(condition, promise);
+
+ // As conditions may hold lots of memory, we attempt to cleanup
+ // as soon as we are done (which might be in the next tick, if
+ // we have been passed a resolved promise).
+ promise = promise.then(() => {
+ debug(`Completed blocker ${name} for phase ${this._name}`);
+ this._removeBlocker(condition);
+ });
+
+ if (this._isStarted) {
+ // The wait has already started. The blocker should be
+ // notified asap. We do it out of band as clients probably
+ // expect `addBlocker` to return immediately.
+ Promise.resolve().then(trigger);
+ }
+ },
+
+ /**
+ * Remove the blocker for a condition.
+ *
+ * If several blockers have been registered for the same
+ * condition, remove all these blockers. If no blocker has been
+ * registered for this condition, this is a noop.
+ *
+ * @return {boolean} true if at least one blocker has been
+ * removed, false otherwise.
+ */
+ removeBlocker: condition => {
+ return this._removeBlocker(condition);
+ },
+ };
+
+ /**
+ * Whether this client still accepts new blockers.
+ */
+ Object.defineProperty(this.client, "isClosed", {
+ get: () => {
+ return this._isClosed;
+ },
+ });
+}
+Barrier.prototype = Object.freeze({
+ /**
+ * The current state of the barrier, as a JSON-serializable object
+ * designed for error-reporting.
+ */
+ get state() {
+ if (!this._isStarted) {
+ return "Not started";
+ }
+ if (!this._waitForMe) {
+ return "Complete";
+ }
+ let frozen = [];
+ for (let blocker of this._promiseToBlocker.values()) {
+ let { name, fetchState } = blocker;
+ let { stack, filename, lineNumber } = blocker.getOrigin();
+ frozen.push({
+ name,
+ state: safeGetState(fetchState),
+ filename,
+ lineNumber,
+ stack,
+ });
+ }
+ return frozen;
+ },
+
+ /**
+ * Wait until all currently registered blockers are complete.
+ *
+ * Once this method has been called, any attempt to register a new blocker
+ * for this barrier will cause an error.
+ *
+ * Successive calls to this method always return the same value.
+ *
+ * @param {object=} options Optionally, an object that may contain
+ * the following fields:
+ * {number} warnAfterMS If provided and > 0, print a warning if the barrier
+ * has not been resolved after the given number of milliseconds.
+ * {number} crashAfterMS If provided and > 0, crash the process if the barrier
+ * has not been resolved after the give number of milliseconds (rounded up
+ * to the next second). To avoid crashing simply because the computer is busy
+ * or going to sleep, we actually wait for ceil(crashAfterMS/1000) successive
+ * periods of at least one second. Upon crashing, if a crash reporter is present,
+ * prepare a crash report with the state of this barrier.
+ *
+ *
+ * @return {Promise} A promise satisfied once all blockers are complete.
+ */
+ wait(options = {}) {
+ // This method only implements caching on top of _wait()
+ if (this._promise) {
+ return this._promise;
+ }
+ return (this._promise = this._wait(options));
+ },
+ _wait(options) {
+ // Sanity checks
+ if (this._isStarted) {
+ throw new TypeError("Internal error: already started " + this._name);
+ }
+ if (
+ !this._waitForMe ||
+ !this._conditionToPromise ||
+ !this._promiseToBlocker
+ ) {
+ throw new TypeError("Internal error: already finished " + this._name);
+ }
+
+ let topic = this._name;
+
+ // Notify blockers
+ for (let blocker of this._promiseToBlocker.values()) {
+ blocker.trigger(); // We have guarantees that this method will never throw
+ }
+
+ this._isStarted = true;
+
+ // Now, wait
+ let promise = this._waitForMe.wait(() => {
+ this._isClosed = true;
+ });
+
+ promise = promise.catch(function onError(error) {
+ // I don't think that this can happen.
+ // However, let's be overcautious with async/shutdown error reporting.
+ let msg =
+ "An uncaught error appeared while completing the phase." +
+ " Phase: " +
+ topic;
+ warn(msg, error);
+ });
+
+ promise = promise.then(() => {
+ // Cleanup memory
+ this._waitForMe = null;
+ this._promiseToBlocker = null;
+ this._conditionToPromise = null;
+ });
+
+ // Now handle warnings and crashes
+ let warnAfterMS = DELAY_WARNING_MS;
+ if (options && "warnAfterMS" in options) {
+ if (
+ typeof options.warnAfterMS == "number" ||
+ options.warnAfterMS == null
+ ) {
+ // Change the delay or deactivate warnAfterMS
+ warnAfterMS = options.warnAfterMS;
+ } else {
+ throw new TypeError("Wrong option value for warnAfterMS");
+ }
+ }
+
+ if (warnAfterMS && warnAfterMS > 0) {
+ // If the promise takes too long to be resolved/rejected,
+ // we need to notify the user.
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ () => {
+ let msg =
+ "At least one completion condition is taking too long to complete." +
+ " Conditions: " +
+ JSON.stringify(this.state) +
+ " Barrier: " +
+ topic;
+ warn(msg);
+ },
+ warnAfterMS,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ promise = promise.then(function onSuccess() {
+ timer.cancel();
+ // As a side-effect, this prevents |timer| from
+ // being garbage-collected too early.
+ });
+ }
+
+ let crashAfterMS = DELAY_CRASH_MS;
+ if (options && "crashAfterMS" in options) {
+ if (
+ typeof options.crashAfterMS == "number" ||
+ options.crashAfterMS == null
+ ) {
+ // Change the delay or deactivate crashAfterMS
+ crashAfterMS = options.crashAfterMS;
+ } else {
+ throw new TypeError("Wrong option value for crashAfterMS");
+ }
+ }
+
+ if (crashAfterMS > 0) {
+ let timeToCrash = null;
+
+ // If after |crashAfterMS| milliseconds (adjusted to take into
+ // account sleep and otherwise busy computer) we have not finished
+ // this shutdown phase, we assume that the shutdown is somehow
+ // frozen, presumably deadlocked. At this stage, the only thing we
+ // can do to avoid leaving the user's computer in an unstable (and
+ // battery-sucking) situation is report the issue and crash.
+ timeToCrash = looseTimer(crashAfterMS);
+ timeToCrash.promise.then(
+ () => {
+ // Report the problem as best as we can, then crash.
+ let state = this.state;
+
+ // If you change the following message, please make sure
+ // that any information on the topic and state appears
+ // within the first 200 characters of the message. This
+ // helps automatically sort oranges.
+ let msg =
+ "AsyncShutdown timeout in " +
+ topic +
+ " Conditions: " +
+ JSON.stringify(state) +
+ " At least one completion condition failed to complete" +
+ " within a reasonable amount of time. Causing a crash to" +
+ " ensure that we do not leave the user with an unresponsive" +
+ " process draining resources.";
+ fatalerr(msg);
+ if (gBrokenAddBlockers.length) {
+ fatalerr(
+ "Broken addBlocker calls: " + JSON.stringify(gBrokenAddBlockers)
+ );
+ }
+ if (Services.appinfo.crashReporterEnabled) {
+ Services.appinfo.annotateCrashReport(
+ "AsyncShutdownTimeout",
+ JSON.stringify(this._gatherCrashReportTimeoutData(topic, state))
+ );
+ } else {
+ warn("No crash reporter available");
+ }
+
+ // To help sorting out bugs, we want to make sure that the
+ // call to nsIDebug2.abort points to a guilty client, rather
+ // than to AsyncShutdown itself. We pick a client that is
+ // still blocking and use its filename/lineNumber,
+ // which have been determined during the call to `addBlocker`.
+ let filename = "?";
+ let lineNumber = -1;
+ for (let blocker of this._promiseToBlocker.values()) {
+ ({ filename, lineNumber } = blocker.getOrigin());
+ break;
+ }
+ lazy.gDebug.abort(filename, lineNumber);
+ },
+ function onSatisfied() {
+ // The promise has been rejected, which means that we have satisfied
+ // all completion conditions.
+ }
+ );
+
+ promise = promise.then(
+ function () {
+ timeToCrash.reject();
+ } /* No error is possible here*/
+ );
+ }
+
+ return promise;
+ },
+
+ _gatherCrashReportTimeoutData(phase, conditions) {
+ let data = { phase, conditions };
+ if (gBrokenAddBlockers.length) {
+ data.brokenAddBlockers = gBrokenAddBlockers;
+ }
+ return data;
+ },
+
+ _removeBlocker(condition) {
+ if (
+ !this._waitForMe ||
+ !this._promiseToBlocker ||
+ !this._conditionToPromise
+ ) {
+ // We have already cleaned up everything.
+ return false;
+ }
+
+ let promise = this._conditionToPromise.get(condition);
+ if (!promise) {
+ // The blocker has already been removed
+ return false;
+ }
+ this._conditionToPromise.delete(condition);
+ this._promiseToBlocker.delete(promise);
+ return this._waitForMe.delete(promise);
+ },
+});
+
+// List of well-known phases
+// Ideally, phases should be registered from the component that decides
+// when they start/stop. For compatibility with existing startup/shutdown
+// mechanisms, we register a few phases here.
+
+// Parent process
+if (!isContent) {
+ AsyncShutdown.profileChangeTeardown = getPhase("profile-change-teardown");
+ AsyncShutdown.profileBeforeChange = getPhase("profile-before-change");
+ AsyncShutdown.sendTelemetry = getPhase("profile-before-change-telemetry");
+}
+
+// Notifications that fire in the parent and content process, but should
+// only have phases in the parent process.
+if (!isContent) {
+ AsyncShutdown.quitApplicationGranted = getPhase("quit-application-granted");
+}
+
+// Don't add a barrier for content-child-shutdown because this
+// makes it easier to cause shutdown hangs.
+
+// All processes
+AsyncShutdown.webWorkersShutdown = getPhase("web-workers-shutdown");
+AsyncShutdown.xpcomWillShutdown = getPhase("xpcom-will-shutdown");
+
+AsyncShutdown.Barrier = Barrier;
+
+Object.freeze(AsyncShutdown);
diff --git a/toolkit/components/asyncshutdown/components.conf b/toolkit/components/asyncshutdown/components.conf
new file mode 100644
index 0000000000..d897553458
--- /dev/null
+++ b/toolkit/components/asyncshutdown/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'name': 'AsyncShutdown',
+ 'cid': '{35c496de-a115-475d-93b5-ffa3f3ae6fe3}',
+ 'contract_ids': ['@mozilla.org/async-shutdown-service;1'],
+ 'esModule': 'resource://gre/modules/nsAsyncShutdown.sys.mjs',
+ 'constructor': 'nsAsyncShutdownService',
+ },
+]
diff --git a/toolkit/components/asyncshutdown/moz.build b/toolkit/components/asyncshutdown/moz.build
new file mode 100644
index 0000000000..fafa0c0566
--- /dev/null
+++ b/toolkit/components/asyncshutdown/moz.build
@@ -0,0 +1,25 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+
+XPIDL_MODULE = "toolkit_asyncshutdown"
+
+XPIDL_SOURCES += [
+ "nsIAsyncShutdown.idl",
+]
+
+EXTRA_JS_MODULES += [
+ "AsyncShutdown.sys.mjs",
+ "nsAsyncShutdown.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Async Tooling")
diff --git a/toolkit/components/asyncshutdown/nsAsyncShutdown.sys.mjs b/toolkit/components/asyncshutdown/nsAsyncShutdown.sys.mjs
new file mode 100644
index 0000000000..23336a40bf
--- /dev/null
+++ b/toolkit/components/asyncshutdown/nsAsyncShutdown.sys.mjs
@@ -0,0 +1,251 @@
+/* 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/. */
+
+/**
+ * An implementation of nsIAsyncShutdown* based on AsyncShutdown.sys.mjs
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+
+/**
+ * Conversion between nsIPropertyBags and JS values.
+ * This uses a conservative approach to avoid losing data and doesn't throw.
+ * Don't use this if you need perfect serialization and deserialization.
+ */
+class PropertyBagConverter {
+ /**
+ * When the js value to convert is a primitive, it is stored in the property
+ * bag under a key with this name.
+ */
+ get primitiveProperty() {
+ return "PropertyBagConverter_primitive";
+ }
+
+ /**
+ * Converts from a PropertyBag to a JS value.
+ * @param {nsIPropertyBag} bag The PropertyBag to convert.
+ * @returns {jsval} A JS value.
+ */
+ propertyBagToJsValue(bag) {
+ if (!(bag instanceof Ci.nsIPropertyBag)) {
+ return null;
+ }
+ let result = {};
+ for (let { name, value: property } of bag.enumerator) {
+ let value = this.#toValue(property);
+ if (name == this.primitiveProperty) {
+ return value;
+ }
+ result[name] = value;
+ }
+ return result;
+ }
+
+ #toValue(property) {
+ if (property instanceof Ci.nsIPropertyBag) {
+ return this.propertyBagToJsValue(property);
+ }
+ if (["number", "boolean"].includes(typeof property)) {
+ return property;
+ }
+ try {
+ return JSON.parse(property);
+ } catch (ex) {
+ // Not JSON.
+ }
+ return property;
+ }
+
+ /**
+ * Converts from a JS value to a PropertyBag.
+ * @param {jsval} val JS value to convert.
+ * @returns {nsIPropertyBag} A PropertyBag.
+ * @note function is converted to "(function)" and undefined to null.
+ */
+ jsValueToPropertyBag(val) {
+ let bag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ if (val && typeof val == "object") {
+ for (let k of Object.keys(val)) {
+ bag.setProperty(k, this.#fromValue(val[k]));
+ }
+ } else {
+ bag.setProperty(this.primitiveProperty, this.#fromValue(val));
+ }
+ return bag;
+ }
+
+ #fromValue(value) {
+ if (typeof value == "function") {
+ return "(function)";
+ }
+ if (value === undefined) {
+ value = null;
+ }
+ if (["number", "boolean", "string"].includes(typeof value)) {
+ return value;
+ }
+ return JSON.stringify(value);
+ }
+}
+
+/**
+ * Construct an instance of nsIAsyncShutdownClient from a
+ * AsyncShutdown.Barrier client.
+ *
+ * @param {object} moduleClient A client, as returned from the `client`
+ * property of an instance of `AsyncShutdown.Barrier`. This client will
+ * serve as back-end for methods `addBlocker` and `removeBlocker`.
+ * @constructor
+ */
+function nsAsyncShutdownClient(moduleClient) {
+ if (!moduleClient) {
+ throw new TypeError("nsAsyncShutdownClient expects one argument");
+ }
+ this._moduleClient = moduleClient;
+ this._byXpcomBlocker = new Map();
+}
+nsAsyncShutdownClient.prototype = {
+ get jsclient() {
+ return this._moduleClient;
+ },
+ get name() {
+ return this._moduleClient.name;
+ },
+ get isClosed() {
+ return this._moduleClient.isClosed;
+ },
+ addBlocker(
+ /* nsIAsyncShutdownBlocker*/ xpcomBlocker,
+ fileName,
+ lineNumber,
+ stack
+ ) {
+ // We need a Promise-based function with the same behavior as
+ // `xpcomBlocker`. Furthermore, to support `removeBlocker`, we
+ // need to ensure that we always get the same Promise-based
+ // function if we call several `addBlocker`/`removeBlocker` several
+ // times with the same `xpcomBlocker`.
+
+ if (this._byXpcomBlocker.has(xpcomBlocker)) {
+ throw new Error(
+ `We have already registered the blocker (${xpcomBlocker.name})`
+ );
+ }
+
+ // Ideally, this should be done with a WeakMap() with xpcomBlocker
+ // as a key, but XPCWrappedNative objects cannot serve as WeakMap keys, see
+ // bug 1834365.
+ const moduleBlocker = () =>
+ new Promise(
+ // This promise is never resolved. By opposition to AsyncShutdown
+ // blockers, `nsIAsyncShutdownBlocker`s are always lifted by calling
+ // `removeBlocker`.
+ () => xpcomBlocker.blockShutdown(this)
+ );
+
+ this._byXpcomBlocker.set(xpcomBlocker, moduleBlocker);
+ this._moduleClient.addBlocker(xpcomBlocker.name, moduleBlocker, {
+ fetchState: () =>
+ new PropertyBagConverter().propertyBagToJsValue(xpcomBlocker.state),
+ filename: fileName,
+ lineNumber,
+ stack,
+ });
+ },
+
+ removeBlocker(xpcomBlocker) {
+ let moduleBlocker = this._byXpcomBlocker.get(xpcomBlocker);
+ if (!moduleBlocker) {
+ return false;
+ }
+ this._byXpcomBlocker.delete(xpcomBlocker);
+ return this._moduleClient.removeBlocker(moduleBlocker);
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAsyncShutdownClient"]),
+};
+
+/**
+ * Construct an instance of nsIAsyncShutdownBarrier from an instance
+ * of AsyncShutdown.Barrier.
+ *
+ * @param {object} moduleBarrier an instance if
+ * `AsyncShutdown.Barrier`. This instance will serve as back-end for
+ * all methods.
+ * @constructor
+ */
+function nsAsyncShutdownBarrier(moduleBarrier) {
+ this._client = new nsAsyncShutdownClient(moduleBarrier.client);
+ this._moduleBarrier = moduleBarrier;
+}
+nsAsyncShutdownBarrier.prototype = {
+ get state() {
+ return new PropertyBagConverter().jsValueToPropertyBag(
+ this._moduleBarrier.state
+ );
+ },
+ get client() {
+ return this._client;
+ },
+ wait(onReady) {
+ this._moduleBarrier.wait().then(() => {
+ onReady.done();
+ });
+ // By specification, _moduleBarrier.wait() cannot reject.
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAsyncShutdownBarrier"]),
+};
+
+export function nsAsyncShutdownService() {
+ // Cache for the getters
+
+ for (let _k of [
+ // Parent process
+ "profileBeforeChange",
+ "profileChangeTeardown",
+ "quitApplicationGranted",
+ "sendTelemetry",
+
+ // Child processes
+ "contentChildShutdown",
+
+ // All processes
+ "webWorkersShutdown",
+ "xpcomWillShutdown",
+ ]) {
+ let k = _k;
+ Object.defineProperty(this, k, {
+ configurable: true,
+ get() {
+ delete this[k];
+ let wrapped = lazy.AsyncShutdown[k]; // May be undefined, if we're on the wrong process.
+ let result = wrapped ? new nsAsyncShutdownClient(wrapped) : undefined;
+ Object.defineProperty(this, k, {
+ value: result,
+ });
+ return result;
+ },
+ });
+ }
+
+ // Hooks for testing purpose
+ this.wrappedJSObject = {
+ _propertyBagConverter: PropertyBagConverter,
+ };
+}
+
+nsAsyncShutdownService.prototype = {
+ makeBarrier(name) {
+ return new nsAsyncShutdownBarrier(new lazy.AsyncShutdown.Barrier(name));
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAsyncShutdownService"]),
+};
diff --git a/toolkit/components/asyncshutdown/nsIAsyncShutdown.idl b/toolkit/components/asyncshutdown/nsIAsyncShutdown.idl
new file mode 100644
index 0000000000..945772bd51
--- /dev/null
+++ b/toolkit/components/asyncshutdown/nsIAsyncShutdown.idl
@@ -0,0 +1,229 @@
+/* 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/. */
+
+/**
+ * A mechanism for specifying shutdown dependencies between
+ * asynchronous services.
+ *
+ * Note that this XPCOM component is designed primarily for C++
+ * clients. JavaScript clients should rather use AsyncShutdown.sys.mjs,
+ * which provides a better API and better error reporting for them.
+ */
+
+
+#include "nsISupports.idl"
+#include "nsIPropertyBag.idl"
+#include "nsIVariant.idl"
+
+interface nsIAsyncShutdownClient;
+
+/**
+ * A blocker installed by a client to be informed during some stage of
+ * shutdown and block shutdown asynchronously until some condition is
+ * complete.
+ *
+ * If you wish to use AsyncShutdown, you will need to implement this
+ * interface (and only this interface).
+ */
+[scriptable, uuid(4ef43f29-6715-4b57-a750-2ff83695ddce)]
+interface nsIAsyncShutdownBlocker: nsISupports {
+ /**
+ * The *unique* name of the blocker.
+ *
+ * By convention, it should respect the following format:
+ * "MyModuleName: Doing something while it's time"
+ * e.g.
+ * "OS.File: Flushing before profile-before-change"
+ *
+ * This attribute is uploaded as part of crash reports.
+ */
+ readonly attribute AString name;
+
+ /**
+ * Inform the blocker that the stage of shutdown has started.
+ * Shutdown will NOT proceed until `aBarrierClient.removeBlocker(this)`
+ * has been called.
+ */
+ void blockShutdown(in nsIAsyncShutdownClient aBarrierClient);
+
+ /**
+ * The current state of the blocker.
+ *
+ * In case of crash, this is converted to JSON and attached to
+ * the crash report.
+ *
+ * This field may be used to provide JSON-style data structures.
+ * For this purpose, use
+ * - nsIPropertyBag to represent objects;
+ * - nsIVariant to represent field values (which may hold nsIPropertyBag
+ * themselves).
+ */
+ readonly attribute nsIPropertyBag state;
+};
+
+/**
+ * A client for a nsIAsyncShutdownBarrier.
+ */
+[scriptable, uuid(d2031049-b990-43a2-95be-59f8a3ca5954)]
+interface nsIAsyncShutdownClient: nsISupports {
+ /**
+ * The name of the barrier.
+ */
+ readonly attribute AString name;
+
+ /**
+ * Whether the client is still open for new blockers.
+ * When this is true it is too late to add new blockers and addBlocker will
+ * throw an exception.
+ */
+ readonly attribute boolean isClosed;
+
+ /**
+ * Add a blocker.
+ *
+ * After a `blocker` has been added with `addBlocker`, if it is not
+ * removed with `removeBlocker`, this will, by design, eventually
+ * CAUSE A CRASH.
+ *
+ * Calling `addBlocker` once nsIAsyncShutdownBarrier::wait() has been
+ * called on the owning barrier returns an error.
+ *
+ * @param aBlocker The blocker to add. Once
+ * nsIAsyncShutdownBarrier::wait() has been called, it will not
+ * call its `aOnReady` callback until all blockers have been
+ * removed, each by a call to `removeBlocker`.
+ * @param aFileName The filename of the callsite, as given by `__FILE__`.
+ * @param aLineNumber The linenumber of the callsite, as given by `__LINE__`.
+ * @param aStack Information on the stack that lead to this call. Generally
+ * empty when called from C++.
+ * @throws If it's too late to add a blocker.
+ * @see isClosed.
+ */
+ void addBlocker(in nsIAsyncShutdownBlocker aBlocker,
+ in AString aFileName,
+ in long aLineNumber,
+ in AString aStack);
+
+ /**
+ * Remove a blocker.
+ *
+ * @param aBlocker A blocker previously added to this client through
+ * `addBlocker`. Noop if the blocker has never been added or has been
+ * removed already.
+ */
+ void removeBlocker(in nsIAsyncShutdownBlocker aBlocker);
+
+ /**
+ * The JS implementation of the client.
+ *
+ * It is strongly recommended that JS clients of this API use
+ * `jsclient` instead of the `nsIAsyncShutdownClient`. See
+ * AsyncShutdown.sys.mjs for more information on the JS version of
+ * this API.
+ */
+ readonly attribute jsval jsclient;
+};
+
+/**
+ * Callback invoked once all blockers of a barrier have been removed.
+ */
+[scriptable, function, uuid(910c9309-1da0-4dd0-8bdb-a325a38c604e)]
+interface nsIAsyncShutdownCompletionCallback: nsISupports {
+ /**
+ * The operation has been completed.
+ */
+ void done();
+};
+
+/**
+ * A stage of shutdown that supports blocker registration.
+ */
+[scriptable, uuid(50fa8a86-9c91-4256-8389-17d310adec90)]
+interface nsIAsyncShutdownBarrier: nsISupports {
+
+ /**
+ * The blocker registration capability. Most services may wish to
+ * publish this capability to let services that depend on it register
+ * blockers.
+ */
+ readonly attribute nsIAsyncShutdownClient client;
+
+ /**
+ * The state of all the blockers of the barrier.
+ *
+ * See the documentation of `nsIAsyncShutdownBlocker` for the
+ * format.
+ */
+ readonly attribute nsIPropertyBag state;
+
+ /**
+ * Wait for all blockers to complete.
+ *
+ * Method `aOnReady` will be called once all blockers have finished.
+ * The callback always receives NS_OK.
+ */
+ void wait(in nsIAsyncShutdownCompletionCallback aOnReady);
+};
+
+/**
+ * A service that allows registering shutdown-time dependencies.
+ */
+[scriptable, uuid(db365c78-c860-4e64-9a63-25b73f89a016)]
+interface nsIAsyncShutdownService: nsISupports {
+ /**
+ * Create a new barrier.
+ *
+ * By convention, the name should respect the following format:
+ * "MyModuleName: Doing something while it's time"
+ * e.g.
+ * "OS.File: Waiting for clients to flush before shutting down"
+ *
+ * This attribute is uploaded as part of crash reports.
+ */
+ nsIAsyncShutdownBarrier makeBarrier(in AString aName);
+
+
+ // Barriers for global shutdown stages in the parent process.
+
+ /**
+ * Barrier for notification profile-before-change.
+ */
+ readonly attribute nsIAsyncShutdownClient profileBeforeChange;
+
+ /**
+ * Barrier for notification profile-change-teardown.
+ */
+ readonly attribute nsIAsyncShutdownClient profileChangeTeardown;
+
+ /**
+ * Barrier for notification quit-application-granted.
+ */
+ readonly attribute nsIAsyncShutdownClient quitApplicationGranted;
+
+ /**
+ * Barrier for notification profile-before-change-telemetry.
+ */
+ readonly attribute nsIAsyncShutdownClient sendTelemetry;
+
+
+ // Barriers for global shutdown stages in all processes.
+
+ /**
+ * Barrier for notification web-workers-shutdown.
+ */
+ readonly attribute nsIAsyncShutdownClient webWorkersShutdown;
+
+ /**
+ * Barrier for notification xpcom-will-shutdown.
+ */
+ readonly attribute nsIAsyncShutdownClient xpcomWillShutdown;
+
+ // Don't add a barrier for content-child-shutdown because this
+ // makes it easier to cause shutdown hangs.
+
+};
+
+%{C++
+#define NS_ASYNCSHUTDOWNSERVICE_CONTRACTID "@mozilla.org/async-shutdown-service;1"
+%}
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/head.js b/toolkit/components/asyncshutdown/tests/xpcshell/head.js
new file mode 100644
index 0000000000..6b8c756122
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/head.js
@@ -0,0 +1,180 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+var { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+
+var asyncShutdownService = Cc[
+ "@mozilla.org/async-shutdown-service;1"
+].getService(Ci.nsIAsyncShutdownService);
+
+Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+
+/**
+ * Utility function used to provide the same API for various sources
+ * of async shutdown barriers.
+ *
+ * @param {string} kind One of
+ * - "phase" to test an AsyncShutdown phase;
+ * - "barrier" to test an instance of AsyncShutdown.Barrier;
+ * - "xpcom-barrier" to test an instance of nsIAsyncShutdownBarrier;
+ * - "xpcom-barrier-unwrapped" to test the field `jsclient` of a nsIAsyncShutdownClient.
+ *
+ * @return An object with the following methods:
+ * - addBlocker() - the same method as AsyncShutdown phases and barrier clients
+ * - wait() - trigger the resolution of the lock
+ */
+function makeLock(kind) {
+ if (kind == "phase") {
+ let topic = "test-Phase-" + ++makeLock.counter;
+ let phase = AsyncShutdown._getPhase(topic);
+ return {
+ addBlocker(...args) {
+ return phase.addBlocker(...args);
+ },
+ removeBlocker(blocker) {
+ return phase.removeBlocker(blocker);
+ },
+ wait() {
+ Services.obs.notifyObservers(null, topic);
+ return Promise.resolve();
+ },
+ get isClosed() {
+ return phase.isClosed;
+ },
+ };
+ } else if (kind == "barrier") {
+ let name = "test-Barrier-" + ++makeLock.counter;
+ let barrier = new AsyncShutdown.Barrier(name);
+ return {
+ addBlocker: barrier.client.addBlocker,
+ removeBlocker: barrier.client.removeBlocker,
+ wait() {
+ return barrier.wait();
+ },
+ get isClosed() {
+ return barrier.client.isClosed;
+ },
+ };
+ } else if (kind == "xpcom-barrier") {
+ let name = "test-xpcom-Barrier-" + ++makeLock.counter;
+ let barrier = asyncShutdownService.makeBarrier(name);
+ return {
+ addBlocker(blockerName, condition, state) {
+ if (condition == null) {
+ // Slight trick as `null` or `undefined` cannot be used as keys
+ // for `xpcomMap`. Note that this has no incidence on the result
+ // of the test as the XPCOM interface imposes that the condition
+ // is a method, so it cannot be `null`/`undefined`.
+ condition = "<this case can't happen with the xpcom interface>";
+ }
+ let blocker = makeLock.xpcomMap.get(condition);
+ if (!blocker) {
+ blocker = {
+ name: blockerName,
+ state,
+ blockShutdown(aBarrierClient) {
+ return (async function () {
+ try {
+ if (typeof condition == "function") {
+ await Promise.resolve(condition());
+ } else {
+ await Promise.resolve(condition);
+ }
+ } finally {
+ aBarrierClient.removeBlocker(blocker);
+ }
+ })();
+ },
+ };
+ makeLock.xpcomMap.set(condition, blocker);
+ }
+ let { fileName, lineNumber, stack } = new Error();
+ return barrier.client.addBlocker(blocker, fileName, lineNumber, stack);
+ },
+ removeBlocker(condition) {
+ let blocker = makeLock.xpcomMap.get(condition);
+ if (!blocker) {
+ return;
+ }
+ barrier.client.removeBlocker(blocker);
+ },
+ wait() {
+ return new Promise(resolve => {
+ barrier.wait(resolve);
+ });
+ },
+ get isClosed() {
+ return barrier.client.isClosed;
+ },
+ };
+ } else if ("unwrapped-xpcom-barrier") {
+ let name = "unwrapped-xpcom-barrier-" + ++makeLock.counter;
+ let barrier = asyncShutdownService.makeBarrier(name);
+ let client = barrier.client.jsclient;
+ return {
+ addBlocker: client.addBlocker,
+ removeBlocker: client.removeBlocker,
+ wait() {
+ return new Promise(resolve => {
+ barrier.wait(resolve);
+ });
+ },
+ get isClosed() {
+ return client.isClosed;
+ },
+ };
+ }
+ throw new TypeError("Unknown kind " + kind);
+}
+makeLock.counter = 0;
+makeLock.xpcomMap = new Map(); // Note: Not a WeakMap as we wish to handle non-gc-able keys (e.g. strings)
+
+/**
+ * An asynchronous task that takes several ticks to complete.
+ *
+ * @param {*=} resolution The value with which the resulting promise will be
+ * resolved once the task is complete. This may be a rejected promise,
+ * in which case the resulting promise will itself be rejected.
+ * @param {object=} outResult An object modified by side-effect during the task.
+ * Initially, its field |isFinished| is set to |false|. Once the task is
+ * complete, its field |isFinished| is set to |true|.
+ *
+ * @return {promise} A promise fulfilled once the task is complete
+ */
+function longRunningAsyncTask(resolution = undefined, outResult = {}) {
+ outResult.isFinished = false;
+ if (!("countFinished" in outResult)) {
+ outResult.countFinished = 0;
+ }
+ return new Promise(resolve => {
+ do_timeout(100, function () {
+ ++outResult.countFinished;
+ outResult.isFinished = true;
+ resolve(resolution);
+ });
+ });
+}
+
+function get_exn(f) {
+ try {
+ f();
+ return null;
+ } catch (ex) {
+ return ex;
+ }
+}
+
+function do_check_exn(exn, constructor) {
+ Assert.notEqual(exn, null);
+ if (exn.name == constructor) {
+ Assert.equal(exn.constructor.name, constructor);
+ return;
+ }
+ info("Wrong error constructor");
+ info(exn.constructor.name);
+ info(exn.stack);
+ Assert.ok(false);
+}
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js
new file mode 100644
index 0000000000..27c3a26985
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js
@@ -0,0 +1,348 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_no_condition() {
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ info("Testing a barrier with no condition (" + kind + ")");
+ let lock = makeLock(kind);
+ await lock.wait();
+ info("Barrier with no condition didn't lock");
+ }
+});
+
+add_task(async function test_phase_various_failures() {
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ info("Kind: " + kind);
+ // Testing with wrong arguments
+ let lock = makeLock(kind);
+
+ Assert.throws(
+ () => lock.addBlocker(),
+ /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/
+ );
+ Assert.throws(
+ () => lock.addBlocker(null, true),
+ /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/
+ );
+
+ if (kind != "xpcom-barrier") {
+ // xpcom-barrier actually expects a string in that position
+ Assert.throws(
+ () => lock.addBlocker("Test 2", () => true, "not a function"),
+ /TypeError/
+ );
+ }
+
+ if (kind == "xpcom-barrier") {
+ const blocker = () => true;
+ lock.addBlocker("Test 3", blocker);
+ Assert.throws(
+ () => lock.addBlocker("Test 3", blocker),
+ /We have already registered the blocker \(Test 3\)/
+ );
+ }
+
+ // Attempting to add a blocker after we are done waiting
+ Assert.ok(!lock.isClosed, "Barrier is open");
+ await lock.wait();
+ Assert.throws(() => lock.addBlocker("Test 4", () => true), /is finished/);
+ Assert.ok(lock.isClosed, "Barrier is closed");
+ }
+});
+
+add_task(async function test_reentrant() {
+ info("Ensure that we can call addBlocker from within a blocker");
+
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ info("Kind: " + kind);
+ let lock = makeLock(kind);
+
+ let deferredOuter = Promise.withResolvers();
+ let deferredInner = Promise.withResolvers();
+ let deferredBlockInner = Promise.withResolvers();
+
+ lock.addBlocker("Outer blocker", () => {
+ info("Entering outer blocker");
+ deferredOuter.resolve();
+ lock.addBlocker("Inner blocker", () => {
+ info("Entering inner blocker");
+ deferredInner.resolve();
+ return deferredBlockInner.promise;
+ });
+ });
+
+ // Note that phase-style locks spin the event loop and do not return from
+ // `lock.wait()` until after all blockers have been resolved. Therefore,
+ // to be able to test them, we need to dispatch the following steps to the
+ // event loop before calling `lock.wait()`, which we do by forcing
+ // a Promise.resolve().
+ //
+ let promiseSteps = (async function () {
+ await Promise.resolve();
+
+ info("Waiting until we have entered the outer blocker");
+ await deferredOuter.promise;
+
+ info("Waiting until we have entered the inner blocker");
+ await deferredInner.promise;
+
+ info("Allowing the lock to resolve");
+ deferredBlockInner.resolve();
+ })();
+
+ info("Starting wait");
+ await lock.wait();
+
+ info("Waiting until all steps have been walked");
+ await promiseSteps;
+ }
+});
+
+add_task(async function test_phase_removeBlocker() {
+ info(
+ "Testing that we can call removeBlocker before, during and after the call to wait()"
+ );
+
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ info("Switching to kind " + kind);
+ info("Attempt to add then remove a blocker before wait()");
+ let lock = makeLock(kind);
+ let blocker = () => {
+ info("This promise will never be resolved");
+ return Promise.withResolvers().promise;
+ };
+
+ lock.addBlocker("Wait forever", blocker);
+ let do_remove_blocker = function (aLock, aBlocker, aShouldRemove) {
+ info(
+ "Attempting to remove blocker " +
+ aBlocker +
+ ", expecting result " +
+ aShouldRemove
+ );
+ if (kind == "xpcom-barrier") {
+ // The xpcom variant always returns `undefined`, so we can't
+ // check its result.
+ aLock.removeBlocker(aBlocker);
+ return;
+ }
+ Assert.equal(aLock.removeBlocker(aBlocker), aShouldRemove);
+ };
+ do_remove_blocker(lock, blocker, true);
+ do_remove_blocker(lock, blocker, false);
+ info("Attempt to remove non-registered blockers before wait()");
+ do_remove_blocker(lock, "foo", false);
+ do_remove_blocker(lock, null, false);
+ info("Waiting (should lift immediately)");
+ await lock.wait();
+
+ info("Attempt to add a blocker then remove it during wait()");
+ lock = makeLock(kind);
+ let blockers = [
+ () => {
+ info("This blocker will self-destruct");
+ do_remove_blocker(lock, blockers[0], true);
+ return Promise.withResolvers().promise;
+ },
+ () => {
+ info("This blocker will self-destruct twice");
+ do_remove_blocker(lock, blockers[1], true);
+ do_remove_blocker(lock, blockers[1], false);
+ return Promise.withResolvers().promise;
+ },
+ () => {
+ info("Attempt to remove non-registered blockers during wait()");
+ do_remove_blocker(lock, "foo", false);
+ do_remove_blocker(lock, null, false);
+ },
+ ];
+ for (let i in blockers) {
+ lock.addBlocker("Wait forever again: " + i, blockers[i]);
+ }
+ info("Waiting (should lift very quickly)");
+ await lock.wait();
+ do_remove_blocker(lock, blockers[0], false);
+
+ info("Attempt to remove a blocker after wait");
+ lock = makeLock(kind);
+ blocker = Promise.resolve.bind(Promise);
+ await lock.wait();
+ do_remove_blocker(lock, blocker, false);
+
+ info("Attempt to remove non-registered blocker after wait()");
+ do_remove_blocker(lock, "foo", false);
+ do_remove_blocker(lock, null, false);
+ }
+});
+
+add_task(async function test_addBlocker_noDistinctNamesConstraint() {
+ info("Testing that we can add two distinct blockers with identical names");
+
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ info("Switching to kind " + kind);
+ let lock = makeLock(kind);
+ let deferred1 = Promise.withResolvers();
+ let resolved1 = false;
+ let deferred2 = Promise.withResolvers();
+ let resolved2 = false;
+ let blocker1 = () => {
+ info("Entering blocker1");
+ return deferred1.promise;
+ };
+ let blocker2 = () => {
+ info("Entering blocker2");
+ return deferred2.promise;
+ };
+
+ info("Attempt to add two distinct blockers with identical names");
+ lock.addBlocker("Blocker", blocker1);
+ lock.addBlocker("Blocker", blocker2);
+
+ // Note that phase-style locks spin the event loop and do not return from
+ // `lock.wait()` until after all blockers have been resolved. Therefore,
+ // to be able to test them, we need to dispatch the following steps to the
+ // event loop before calling `lock.wait()`, which we do by forcing
+ // a Promise.resolve().
+ //
+ let promiseSteps = (async () => {
+ info("Waiting for an event-loop spin");
+ await Promise.resolve();
+
+ info("Resolving blocker1");
+ deferred1.resolve();
+ resolved1 = true;
+
+ info("Waiting for an event-loop spin");
+ await Promise.resolve();
+
+ info("Resolving blocker2");
+ deferred2.resolve();
+ resolved2 = true;
+ })();
+
+ info("Waiting for lock");
+ await lock.wait();
+
+ Assert.ok(resolved1);
+ Assert.ok(resolved2);
+ await promiseSteps;
+ }
+});
+
+add_task(async function test_state() {
+ info("Testing information contained in `state`");
+
+ let BLOCKER_NAME = "test_state blocker " + Math.random();
+
+ // Set up the barrier. Note that we cannot test `barrier.state`
+ // immediately, as it initially contains "Not started"
+ let barrier = new AsyncShutdown.Barrier("test_filename");
+ let deferred = Promise.withResolvers();
+ let { filename, lineNumber } = Components.stack;
+ barrier.client.addBlocker(BLOCKER_NAME, function () {
+ return deferred.promise;
+ });
+
+ let promiseDone = barrier.wait();
+
+ // Now that we have called `wait()`, the state contains interesting things
+ info("State: " + JSON.stringify(barrier.state, null, "\t"));
+ let state = barrier.state[0];
+ Assert.equal(state.filename, filename);
+ Assert.equal(state.lineNumber, lineNumber + 1);
+ Assert.equal(state.name, BLOCKER_NAME);
+ Assert.ok(
+ state.stack.some(x => x.includes("test_state")),
+ "The stack contains the caller function's name"
+ );
+ Assert.ok(
+ state.stack.some(x => x.includes(filename)),
+ "The stack contains the calling file's name"
+ );
+
+ deferred.resolve();
+ await promiseDone;
+});
+
+add_task(async function test_multistate() {
+ info("Testing information contained in multiple `state`");
+
+ let BLOCKER_NAMES = [
+ "test_state blocker " + Math.random(),
+ "test_state blocker " + Math.random(),
+ ];
+
+ // Set up the barrier. Note that we cannot test `barrier.state`
+ // immediately, as it initially contains "Not started"
+ let barrier = asyncShutdownService.makeBarrier("test_filename");
+ let deferred = Promise.withResolvers();
+ let { filename, lineNumber } = Components.stack;
+ for (let name of BLOCKER_NAMES) {
+ barrier.client.jsclient.addBlocker(name, () => deferred.promise, {
+ fetchState: () => ({ progress: name }),
+ });
+ }
+
+ let promiseDone = new Promise(r => barrier.wait(r));
+
+ // Now that we have called `wait()`, the state contains interesting things.
+ Assert.ok(
+ barrier.state instanceof Ci.nsIPropertyBag,
+ "State is a PropertyBag"
+ );
+ for (let i = 0; i < BLOCKER_NAMES.length; ++i) {
+ let state = barrier.state.getProperty(i.toString());
+ Assert.equal(typeof state, "string", "state is a string");
+ info("State: " + state + "\t");
+ state = JSON.parse(state);
+ Assert.equal(state.filename, filename);
+ Assert.equal(state.lineNumber, lineNumber + 2);
+ Assert.equal(state.name, BLOCKER_NAMES[i]);
+ Assert.ok(
+ state.stack.some(x => x.includes("test_multistate")),
+ "The stack contains the caller function's name"
+ );
+ Assert.ok(
+ state.stack.some(x => x.includes(filename)),
+ "The stack contains the calling file's name"
+ );
+ Assert.equal(
+ state.state.progress,
+ BLOCKER_NAMES[i],
+ "The state contains the fetchState provided value"
+ );
+ }
+
+ deferred.resolve();
+ await promiseDone;
+});
+
+add_task(async function () {
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+});
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js
new file mode 100644
index 0000000000..669164f68e
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that when addBlocker fails, we store that failure internally
+ * and include its information in crash report annotation information.
+ */
+add_task(async function test_addBlockerFailureState() {
+ info("Testing addBlocker information reported to crash reporter");
+
+ let BLOCKER_NAME = "test_addBlocker_state blocker " + Math.random();
+
+ // Set up the barrier. Note that we cannot test `barrier.state`
+ // immediately, as it initially contains "Not started"
+ let barrier = new AsyncShutdown.Barrier("test_addBlocker_failure");
+ let deferred = Promise.withResolvers();
+ barrier.client.addBlocker(BLOCKER_NAME, function () {
+ return deferred.promise;
+ });
+
+ // Add a blocker and confirm that throws.
+ const THROWING_BLOCKER_NAME = "test_addBlocker_throws blocker";
+ Assert.throws(() => {
+ barrier.client.addBlocker(THROWING_BLOCKER_NAME, Promise.resolve(), 5);
+ }, /object as third argument/);
+
+ let promiseDone = barrier.wait();
+
+ // Now that we have called `wait()`, the state should match crash
+ // reporting info
+ let crashInfo = barrier._gatherCrashReportTimeoutData(
+ barrier._name,
+ barrier.state
+ );
+ Assert.deepEqual(
+ crashInfo.conditions,
+ barrier.state,
+ "Barrier state should match crash info."
+ );
+ Assert.equal(
+ crashInfo.brokenAddBlockers.length,
+ 1,
+ "Should have registered the broken addblocker call."
+ );
+ Assert.stringMatches(
+ crashInfo.brokenAddBlockers?.[0] || "undefined",
+ THROWING_BLOCKER_NAME,
+ "Throwing call's blocker name should be listed in message."
+ );
+
+ deferred.resolve();
+ await promiseDone;
+});
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js
new file mode 100644
index 0000000000..b37416d7a7
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+//
+// This file contains tests that need to leave uncaught asynchronous
+// errors. If your test catches all its asynchronous errors, please
+// put it in another file.
+//
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.thisTestLeaksUncaughtRejectionsAndShouldBeFixed();
+
+add_task(async function test_phase_simple_async() {
+ info("Testing various combinations of a phase with a single condition");
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ for (let arg of [undefined, null, "foo", 100, new Error("BOOM")]) {
+ for (let resolution of [arg, Promise.reject(arg)]) {
+ for (let success of [false, true]) {
+ for (let state of [
+ [null],
+ [],
+ [() => "some state"],
+ [
+ function () {
+ throw new Error("State BOOM");
+ },
+ ],
+ [
+ function () {
+ return {
+ toJSON() {
+ throw new Error("State.toJSON BOOM");
+ },
+ };
+ },
+ ],
+ ]) {
+ // Asynchronous phase
+ info(
+ "Asynchronous test with " + arg + ", " + resolution + ", " + kind
+ );
+ let lock = makeLock(kind);
+ let outParam = { isFinished: false };
+ lock.addBlocker(
+ "Async test",
+ function () {
+ if (success) {
+ return longRunningAsyncTask(resolution, outParam);
+ }
+ throw resolution;
+ },
+ ...state
+ );
+ Assert.ok(!outParam.isFinished);
+ await lock.wait();
+ Assert.equal(outParam.isFinished, success);
+ }
+ }
+
+ // Synchronous phase - just test that we don't throw/freeze
+ info("Synchronous test with " + arg + ", " + resolution + ", " + kind);
+ let lock = makeLock(kind);
+ lock.addBlocker("Sync test", resolution);
+ await lock.wait();
+ }
+ }
+ }
+});
+
+add_task(async function test_phase_many() {
+ info("Testing various combinations of a phase with many conditions");
+ for (let kind of [
+ "phase",
+ "barrier",
+ "xpcom-barrier",
+ "xpcom-barrier-unwrapped",
+ ]) {
+ let lock = makeLock(kind);
+ let outParams = [];
+ for (let arg of [undefined, null, "foo", 100, new Error("BOOM")]) {
+ for (let resolve of [true, false]) {
+ info("Testing with " + kind + ", " + arg + ", " + resolve);
+ let resolution = resolve ? arg : Promise.reject(arg);
+ let outParam = { isFinished: false };
+ lock.addBlocker("Test " + Math.random(), () =>
+ longRunningAsyncTask(resolution, outParam)
+ );
+ }
+ }
+ Assert.ok(outParams.every(x => !x.isFinished));
+ await lock.wait();
+ Assert.ok(outParams.every(x => x.isFinished));
+ }
+});
+
+add_task(async function () {
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+});
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js
new file mode 100644
index 0000000000..1e4740ddc1
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+/**
+ * Test conversion between nsIPropertyBag and JS values.
+ */
+
+var PropertyBagConverter =
+ new asyncShutdownService.wrappedJSObject._propertyBagConverter();
+
+function run_test() {
+ test_conversions();
+}
+
+function normalize(obj) {
+ if (obj === undefined) {
+ return null;
+ }
+ if (obj == null || typeof obj != "object") {
+ return obj;
+ }
+ if (Array.isArray(obj)) {
+ return obj.map(normalize);
+ }
+ let result = {};
+ for (let k of Object.keys(obj).sort()) {
+ result[k] = normalize(obj[k]);
+ }
+ return result;
+}
+
+function test_conversions() {
+ const SAMPLES = [
+ // Simple values
+ 1,
+ true,
+ "string",
+ null,
+ undefined,
+ // Object
+ {
+ a: 1,
+ b: true,
+ c: "string",
+ d: 0.5,
+ e: [2, false, "another string", 0.3],
+ f: [],
+ g: {
+ a2: 1,
+ b2: true,
+ c2: "string",
+ d2: 0.5,
+ e2: [2, false, "another string", 0.3],
+ f2: [],
+ g2: [
+ {
+ a3: 1,
+ b3: true,
+ c3: "string",
+ d3: 0.5,
+ e3: [2, false, "another string", 0.3],
+ f3: [],
+ g3: {},
+ },
+ ],
+ h2: null,
+ },
+ h: null,
+ },
+ // Array
+ [1, 2, 3],
+ // Array of objects
+ [[1, 2], { a: 1, b: "string" }, null],
+ ];
+
+ for (let sample of SAMPLES) {
+ let stringified = JSON.stringify(normalize(sample), null, "\t");
+ info("Testing conversions of " + stringified);
+ let rewrites = [sample];
+ for (let i = 1; i < 3; ++i) {
+ let source = rewrites[i - 1];
+ let bag = PropertyBagConverter.jsValueToPropertyBag(source);
+ Assert.ok(bag instanceof Ci.nsIPropertyBag, "The bag is a property bag");
+ let dest = PropertyBagConverter.propertyBagToJsValue(bag);
+ let restringified = JSON.stringify(normalize(dest), null, "\t");
+ info("Comparing");
+ info(stringified);
+ info(restringified);
+ Assert.deepEqual(sample, dest, "Testing after " + i + " conversions");
+ rewrites.push(dest);
+ }
+ }
+}
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml b/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..365af63451
--- /dev/null
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,11 @@
+[DEFAULT]
+head = "head.js"
+skip-if = ["os == 'android'"]
+
+["test_AsyncShutdown.js"]
+
+["test_AsyncShutdown_blocker_error_annotations.js"]
+
+["test_AsyncShutdown_leave_uncaught.js"]
+
+["test_converters.js"]