Bug 1437811 - Part 3 - Add a safety timeout for blockers registered by event handlers. r=Gijs
MozReview-Commit-ID: 6dfRVInzNps
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -54,16 +54,22 @@ ChromeUtils.import("resource://gre/modul
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(this, "AppConstants",
"resource://gre/modules/AppConstants.jsm");
ChromeUtils.defineModuleGetter(this, "BrowserUtils",
"resource://gre/modules/BrowserUtils.jsm");
ChromeUtils.defineModuleGetter(this, "CustomizableUI",
"resource:///modules/CustomizableUI.jsm");
+/**
+ * Safety timeout after which asynchronous events will be canceled if any of the
+ * registered blockers does not return.
+ */
+const BLOCKERS_TIMEOUT_MS = 10000;
+
const TRANSITION_PHASES = Object.freeze({
START: 1,
PREPARE: 2,
TRANSITION: 3,
END: 4
});
let gNodeToObjectMap = new WeakMap();
@@ -76,16 +82,22 @@ let gMultiLineElementsMap = new WeakMap(
* although they would lose the ability of being associated lazily.
*/
this.AssociatedToNode = class {
constructor(node) {
/**
* Node associated to this object.
*/
this.node = node;
+
+ /**
+ * This promise is resolved when the current set of blockers set by event
+ * handlers have all been processed.
+ */
+ this._blockersPromise = Promise.resolve();
}
/**
* Retrieves the instance associated with the given node, constructing a new
* one if necessary. When the last reference to the node is released, the
* object instance will be garbage collected as well.
*/
static forNode(node) {
@@ -128,16 +140,72 @@ this.AssociatedToNode = class {
let event = new this.window.CustomEvent(eventName, {
detail,
bubbles: true,
cancelable,
});
this.node.dispatchEvent(event);
return event.defaultPrevented;
}
+
+ /**
+ * Dispatches a custom event on this element and waits for any blocking
+ * promises registered using the "addBlocker" function on the details object.
+ * If this function is called again, the event is only dispatched after all
+ * the previously registered blockers have returned.
+ *
+ * The event can be canceled either by resolving any blocking promise to the
+ * boolean value "false" or by calling preventDefault on the event. Rejections
+ * and exceptions will be reported and will cancel the event.
+ *
+ * Blocking should be used sporadically because it slows down the interface.
+ * Also, non-reentrancy is not strictly guaranteed because a safety timeout of
+ * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled.
+ * This helps to prevent deadlocks if any of the event handlers does not
+ * resolve a blocker promise.
+ *
+ * @note Since there is no use case for dispatching different asynchronous
+ * events in parallel for the same element, this function will also wait
+ * for previous blockers when the event name is different.
+ *
+ * @param eventName
+ * Name of the custom event to dispatch.
+ *
+ * @resolves True if the event was canceled by a handler, false otherwise.
+ */
+ async dispatchAsyncEvent(eventName) {
+ // Wait for all the previous blockers before dispatching the event.
+ let blockersPromise = this._blockersPromise.catch(() => {});
+ return this._blockersPromise = blockersPromise.then(async () => {
+ let blockers = new Set();
+ let cancel = this.dispatchCustomEvent(eventName, {
+ addBlocker(promise) {
+ // Any exception in the blocker will cancel the operation.
+ blockers.add(promise.catch(ex => {
+ Cu.reportError(ex);
+ return true;
+ }));
+ },
+ }, true);
+ if (blockers.size) {
+ let timeoutPromise = new Promise((resolve, reject) => {
+ this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS);
+ });
+ try {
+ let results = await Promise.race([Promise.all(blockers),
+ timeoutPromise]);
+ cancel = cancel || results.some(result => result === false);
+ } catch (ex) {
+ Cu.reportError(`One of the blockers for ${eventName} timed out.`);
+ return true;
+ }
+ }
+ return cancel;
+ });
+ }
};
/**
* This is associated to <panelmultiview> elements by the panelUI.xml binding.
*/
this.PanelMultiView = class extends this.AssociatedToNode {
/**
* Tries to open the specified <panel> and displays the main view specified
@@ -565,34 +633,17 @@ this.PanelMultiView = class extends this
if (aAnchor) {
viewNode.classList.add("PanelUI-subView");
}
if (!showingSameView || !viewNode.hasAttribute("current")) {
// Emit the ViewShowing event so that the widget definition has a chance
// to lazily populate the subview with things or perhaps even cancel this
// whole operation.
- let detail = {
- blockers: new Set(),
- addBlocker(promise) {
- this.blockers.add(promise);
- }
- };
- let cancel = nextPanelView.dispatchCustomEvent("ViewShowing", detail, true);
- if (detail.blockers.size) {
- try {
- let results = await Promise.all(detail.blockers);
- cancel = cancel || results.some(val => val === false);
- } catch (e) {
- Cu.reportError(e);
- cancel = true;
- }
- }
-
- if (cancel) {
+ if (await nextPanelView.dispatchAsyncEvent("ViewShowing")) {
this._viewShowing = null;
return false;
}
}
// Now we have to transition the panel. If we've got an older transition
// still running, make sure to clean it up.
await this._cleanupTransitionPhase();