diff options
Diffstat (limited to 'toolkit/components/asyncshutdown')
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"] |