Bug 1220154, 1249830: Handle sendMessage replies with 0 and >1 listeners correctly. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Fri, 04 Mar 2016 15:40:56 -0800
changeset 337182 e36302f43ca11e18232d65807ab2f4e5dc76b314
parent 337165 4864c80abcb2c923cb77265d577c091aea50e20e
child 515601 e60be318be031fe9f48e6bec2f23dded20c059dc
push id12289
push usermaglione.k@gmail.com
push dateSun, 06 Mar 2016 00:39:56 +0000
reviewersbillm
bugs1220154, 1249830
milestone47.0a1
Bug 1220154, 1249830: Handle sendMessage replies with 0 and >1 listeners correctly. r?billm MozReview-Commit-ID: 7lE7RaJcl7n
browser/components/extensions/ext-tabs.js
browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/MessageChannel.jsm
toolkit/components/extensions/ext-webNavigation.js
toolkit/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/test/mochitest/test_ext_runtime_sendMessage.html
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -648,30 +648,30 @@ extensions.registerSchemaAPI("tabs", nul
 
         let message = {
           options,
           width: browser.clientWidth,
           height: browser.clientHeight,
         };
 
         return context.sendMessage(browser.messageManager, "Extension:Capture",
-                                   message, recipient);
+                                   message, {recipient});
       },
 
       detectLanguage: function(tabId) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         if (!tab) {
           return Promise.reject({message: `Invalid tab ID: ${tabId}`});
         }
 
         let browser = tab.linkedBrowser;
         let recipient = {innerWindowID: browser.innerWindowID};
 
         return context.sendMessage(browser.messageManager, "Extension:DetectLanguage",
-                                   {}, recipient);
+                                   {}, {recipient});
       },
 
       _execute: function(tabId, details, kind, method) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         let mm = tab.linkedBrowser.messageManager;
 
         let options = {
           js: [],
@@ -719,17 +719,17 @@ extensions.registerSchemaAPI("tabs", nul
           options.match_about_blank = details.matchAboutBlank;
         }
         if (details.runAt !== null) {
           options.run_at = details.runAt;
         } else {
           options.run_at = "document_idle";
         }
 
-        return context.sendMessage(mm, "Extension:Execute", {options}, recipient);
+        return context.sendMessage(mm, "Extension:Execute", {options}, {recipient});
       },
 
       executeScript: function(tabId, details) {
         return self.tabs._execute(tabId, details, "js", "executeScript");
       },
 
       insertCSS: function(tabId, details) {
         return self.tabs._execute(tabId, details, "css", "insertCSS");
--- a/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js
@@ -10,58 +10,72 @@ add_task(function* tabsSendMessageReply(
       "content_scripts": [{
         "matches": ["http://example.com/"],
         "js": ["content-script.js"],
         "run_at": "document_start",
       }],
     },
 
     background: function() {
+      let firstTab;
       let promiseResponse = new Promise(resolve => {
         browser.runtime.onMessage.addListener((msg, sender, respond) => {
           if (msg == "content-script-ready") {
             let tabId = sender.tab.id;
 
             browser.tabs.sendMessage(tabId, "respond-never", response => {
-              browser.test.fail("Got unexpected response callback");
+              browser.test.fail(`Got unexpected response callback: ${response}`);
               browser.test.notifyFail("sendMessage");
             });
 
             Promise.all([
               promiseResponse,
+
               browser.tabs.sendMessage(tabId, "respond-now"),
+              browser.tabs.sendMessage(tabId, "respond-now-2"),
               new Promise(resolve => browser.tabs.sendMessage(tabId, "respond-soon", resolve)),
               browser.tabs.sendMessage(tabId, "respond-promise"),
               browser.tabs.sendMessage(tabId, "respond-never"),
+
               browser.tabs.sendMessage(tabId, "respond-error").catch(error => Promise.resolve({error})),
               browser.tabs.sendMessage(tabId, "throw-error").catch(error => Promise.resolve({error})),
-            ]).then(([response, respondNow, respondSoon, respondPromise, respondNever, respondError, throwError]) => {
+
+              browser.tabs.sendMessage(firstTab, "no-listener").catch(error => Promise.resolve({error})),
+            ]).then(([response, respondNow, respondNow2, respondSoon, respondPromise, respondNever, respondError, throwError, noListener]) => {
               browser.test.assertEq("expected-response", response, "Content script got the expected response");
 
               browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response");
+              browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener");
               browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response");
               browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response");
               browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution");
 
               browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response");
               browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response");
 
+              browser.test.assertEq("Could not establish connection. Receiving end does not exist.",
+                                    noListener.error.message,
+                                    "Got the expected no listener response");
+
               return browser.tabs.remove(tabId);
             }).then(() => {
               browser.test.notifyPass("sendMessage");
             });
 
             return Promise.resolve("expected-response");
           } else if (msg[0] == "got-response") {
             resolve(msg[1]);
           }
         });
       });
 
-      browser.tabs.create({url: "http://example.com/"});
+      browser.tabs.query({currentWindow: true, active: true}).then(tabs => {
+        firstTab = tabs[0].id;
+        browser.tabs.create({url: "http://example.com/"});
+      });
     },
 
     files: {
       "content-script.js": function() {
         browser.runtime.onMessage.addListener((msg, sender, respond) => {
           if (msg == "respond-now") {
             respond(msg);
           } else if (msg == "respond-soon") {
@@ -72,16 +86,23 @@ add_task(function* tabsSendMessageReply(
           } else if (msg == "respond-never") {
             return;
           } else if (msg == "respond-error") {
             return Promise.reject(new Error(msg));
           } else if (msg == "throw-error") {
             throw new Error(msg);
           }
         });
+        browser.runtime.onMessage.addListener((msg, sender, respond) => {
+          if (msg == "respond-now") {
+            respond("hello");
+          } else if (msg == "respond-now-2") {
+            respond(msg);
+          }
+        });
         browser.runtime.sendMessage("content-script-ready").then(response => {
           browser.runtime.sendMessage(["got-response", response]);
         });
       },
     },
   });
 
   yield extension.startup();
@@ -102,17 +123,17 @@ add_task(function* tabsSendMessageNoExce
         let exception;
         try {
           browser.tabs.sendMessage(tab.id, "message");
           browser.tabs.sendMessage(tab.id + 100, "message");
         } catch (e) {
           exception = e;
         }
 
-        browser.test.assertEq(undefined, exception, "no exception should be raised on tabs.sendMessage to unexistent tabs");
+        browser.test.assertEq(undefined, exception, "no exception should be raised on tabs.sendMessage to nonexistent tabs");
         browser.tabs.remove(tab.id, function() {
           browser.test.notifyPass("tabs.sendMessage");
         });
       });
     },
   });
 
   yield Promise.all([
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -86,17 +86,16 @@ ExtensionManagement.registerSchema("chro
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/test.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/web_navigation.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/web_request.json");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   BaseContext,
   LocaleData,
-  MessageBroker,
   Messenger,
   injectAPI,
   instanceOf,
   extend,
   flushJarCache,
 } = ExtensionUtils;
 
 const LOGGER_ID_BASE = "addons.webextension.";
@@ -211,22 +210,16 @@ var Management = {
     this.emitter.emit(hook, ...args);
   },
 
   off(hook, callback) {
     this.emitter.off(hook, callback);
   },
 };
 
-// A MessageBroker that's used to send and receive messages for
-// extension pages (which run in the chrome process).
-var globalBroker = new MessageBroker([Services.mm, Services.ppmm]);
-
-var gContextId = 0;
-
 // An extension page is an execution context for any extension content
 // that runs in the chrome process. It's used for background pages
 // (type="background"), popups (type="popup"), and any extension
 // content loaded into browser tabs (type="tab").
 //
 // |params| is an object with the following properties:
 // |type| is one of "background", "popup", or "tab".
 // |contentWindow| is the DOM window the content runs in.
@@ -238,57 +231,45 @@ ExtensionPage = class extends BaseContex
     super();
 
     let {type, contentWindow, uri} = params;
     this.extension = extension;
     this.type = type;
     this.contentWindow = contentWindow || null;
     this.uri = uri || extension.baseURI;
     this.incognito = params.incognito || false;
-    this.contextId = gContextId++;
     this.unloaded = false;
 
     // This is the MessageSender property passed to extension.
     // It can be augmented by the "page-open" hook.
     let sender = {id: extension.uuid};
     if (uri) {
       sender.url = uri.spec;
     }
     let delegate = {
       getSender() {},
     };
     Management.emit("page-load", this, params, sender, delegate);
 
     // Properties in |filter| must match those in the |recipient|
     // parameter of sendMessage.
     let filter = {extensionId: extension.id};
-    this.messenger = new Messenger(this, globalBroker, sender, filter, delegate);
+    this.messenger = new Messenger(this, [Services.mm, Services.ppmm], sender, filter, delegate);
 
     this.extension.views.add(this);
   }
 
   get cloneScope() {
     return this.contentWindow;
   }
 
   get principal() {
     return this.contentWindow.document.nodePrincipal;
   }
 
-  // A wrapper around MessageChannel.sendMessage which adds the extension ID
-  // to the recipient object, and ensures replies are not processed after the
-  // context has been unloaded.
-  sendMessage(target, messageName, data, recipient = {}, sender = {}) {
-    recipient.extensionId = this.extension.id;
-    sender.extensionId = this.extension.id;
-    sender.contextId = this.contextId;
-
-    return MessageChannel.sendMessage(target, messageName, data, recipient, sender);
-  }
-
   // Called when the extension shuts down.
   shutdown() {
     Management.emit("page-shutdown", this);
     this.unload();
   }
 
   // This method is called when an extension page navigates away or
   // its tab is closed.
@@ -297,26 +278,21 @@ ExtensionPage = class extends BaseContex
     // multiple times for tab pages closed by the "page-unload" handlers
     // triggered below.
     if (this.unloaded) {
       return;
     }
 
     this.unloaded = true;
 
-    MessageChannel.abortResponses({
-      extensionId: this.extension.id,
-      contextId: this.contextId,
-    });
+    super.unload();
 
     Management.emit("page-unload", this);
 
     this.extension.views.delete(this);
-
-    super.unload();
   }
 };
 
 // For extensions that have called setUninstallURL(), send an event
 // so the browser can display the URL.
 let UninstallObserver = {
   init: function() {
     AddonManager.addAddonListener(this);
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -38,17 +38,16 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   runSafeSyncWithoutClone,
   BaseContext,
   LocaleData,
-  MessageBroker,
   Messenger,
   injectAPI,
   flushJarCache,
   detectLanguage,
   promiseDocumentReady,
 } = ExtensionUtils;
 
 function isWhenBeforeOrSame(when1, when2) {
@@ -320,23 +319,22 @@ class ExtensionContext extends BaseConte
 
     let delegate = {
       getSender(context, target, sender) {
         // Nothing to do here.
       },
     };
 
     let url = contentWindow.location.href;
-    let broker = ExtensionContent.getBroker(mm);
     // The |sender| parameter is passed directly to the extension.
     let sender = {id: this.extension.uuid, frameId, url};
     // Properties in |filter| must match those in the |recipient|
     // parameter of sendMessage.
     let filter = {extensionId, frameId};
-    this.messenger = new Messenger(this, broker, sender, filter, delegate);
+    this.messenger = new Messenger(this, [mm], sender, filter, delegate);
 
     this.chromeObj = Cu.createObjectIn(this.sandbox, {defineAs: "browser"});
 
     // Sandboxes don't get Xrays for some weird compatibility
     // reason. However, we waive here anyway in case that changes.
     Cu.waiveXrays(this.sandbox).chrome = this.chromeObj;
 
     injectAPI(api(this), this.chromeObj);
@@ -734,18 +732,16 @@ class ExtensionGlobal {
     this.global = global;
 
     MessageChannel.addListener(global, "Extension:Capture", this);
     MessageChannel.addListener(global, "Extension:DetectLanguage", this);
     MessageChannel.addListener(global, "Extension:Execute", this);
     MessageChannel.addListener(global, "WebNavigation:GetFrame", this);
     MessageChannel.addListener(global, "WebNavigation:GetAllFrames", this);
 
-    this.broker = new MessageBroker([global]);
-
     this.windowId = global.content
                           .QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils)
                           .outerWindowID;
 
     global.sendAsyncMessage("Extension:TopWindowID", {windowId: this.windowId});
   }
 
@@ -853,15 +849,11 @@ this.ExtensionContent = {
   init(global) {
     this.globals.set(global, new ExtensionGlobal(global));
   },
 
   uninit(global) {
     this.globals.get(global).uninit();
     this.globals.delete(global);
   },
-
-  getBroker(messageManager) {
-    return this.globals.get(messageManager).broker;
-  },
 };
 
 ExtensionManager.init();
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -15,16 +15,18 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
 
 function filterStack(error) {
   return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "<Promise Chain>\n");
 }
 
 // Run a function and report exceptions.
@@ -120,21 +122,24 @@ DefaultWeakMap.prototype = {
 
 class SpreadArgs extends Array {
   constructor(args) {
     super();
     this.push(...args);
   }
 }
 
+let gContextId = 0;
+
 class BaseContext {
   constructor() {
     this.onClose = new Set();
     this.checkedLastError = false;
     this._lastError = null;
+    this.contextId = ++gContextId;
   }
 
   get cloneScope() {
     throw new Error("Not implemented");
   }
 
   get principal() {
     throw new Error("Not implemented");
@@ -165,16 +170,32 @@ class BaseContext {
   callOnClose(obj) {
     this.onClose.add(obj);
   }
 
   forgetOnClose(obj) {
     this.onClose.delete(obj);
   }
 
+  /**
+   * A wrapper around MessageChannel.sendMessage which adds the extension ID
+   * to the recipient object, and ensures replies are not processed after the
+   * context has been unloaded.
+   */
+  sendMessage(target, messageName, data, options = {}) {
+    options.recipient = options.recipient || {};
+    options.sender = options.sender || {};
+
+    options.recipient.extensionId = this.extension.id;
+    options.sender.extensionId = this.extension.id;
+    options.sender.contextId = this.contextId;
+
+    return MessageChannel.sendMessage(target, messageName, data, options);
+  }
+
   get lastError() {
     this.checkedLastError = true;
     return this._lastError;
   }
 
   set lastError(val) {
     this.checkedLastError = false;
     this._lastError = val;
@@ -276,16 +297,21 @@ class BaseContext {
           value => {
             runSafeSyncWithoutClone(reject, this.normalizeError(value));
           });
       });
     }
   }
 
   unload() {
+    MessageChannel.abortResponses({
+      extensionId: this.extension.id,
+      contextId: this.contextId,
+    });
+
     for (let obj of this.onClose) {
       obj.close();
     }
   }
 }
 
 function LocaleData(data) {
   this.defaultLocale = data.defaultLocale;
@@ -665,111 +691,17 @@ function promiseDocumentReady(doc) {
     }, true);
   });
 }
 
 /*
  * Messaging primitives.
  */
 
-var nextBrokerId = 1;
-
-var MESSAGES = [
-  "Extension:Message",
-  "Extension:Connect",
-];
-
-// Receives messages from multiple message managers and directs them
-// to a set of listeners. On the child side: one broker per frame
-// script.  On the parent side: one broker total, covering both the
-// global MM and the ppmm. Message must be tagged with a recipient,
-// which is an object with properties. Listeners can filter for
-// messages that have a certain value for a particular property in the
-// recipient. (If a message doesn't specify the given property, it's
-// considered a match.)
-function MessageBroker(messageManagers) {
-  this.messageManagers = messageManagers;
-  for (let mm of this.messageManagers) {
-    for (let message of MESSAGES) {
-      mm.addMessageListener(message, this);
-    }
-  }
-
-  this.listeners = {message: [], connect: []};
-}
-
-MessageBroker.prototype = {
-  uninit() {
-    for (let mm of this.messageManagers) {
-      for (let message of MESSAGES) {
-        mm.removeMessageListener(message, this);
-      }
-    }
-
-    this.listeners = null;
-  },
-
-  makeId() {
-    return nextBrokerId++;
-  },
-
-  addListener(type, listener, filter) {
-    this.listeners[type].push({filter, listener});
-  },
-
-  removeListener(type, listener) {
-    for (let i = 0; i < this.listeners[type].length; i++) {
-      if (this.listeners[type][i].listener == listener) {
-        this.listeners[type].splice(i, 1);
-        return;
-      }
-    }
-  },
-
-  runListeners(type, target, data) {
-    let listeners = [];
-    for (let {listener, filter} of this.listeners[type]) {
-      let pass = true;
-      for (let prop in filter) {
-        if (prop in data.recipient && filter[prop] != data.recipient[prop]) {
-          pass = false;
-          break;
-        }
-      }
-
-      // Save up the list of listeners to call in case they modify the
-      // set of listeners.
-      if (pass) {
-        listeners.push(listener);
-      }
-    }
-
-    for (let listener of listeners) {
-      listener(type, target, data.message, data.sender, data.recipient);
-    }
-  },
-
-  receiveMessage({name, data, target}) {
-    switch (name) {
-      case "Extension:Message":
-        this.runListeners("message", target, data);
-        break;
-
-      case "Extension:Connect":
-        this.runListeners("connect", target, data);
-        break;
-    }
-  },
-
-  sendMessage(messageManager, type, message, sender, recipient) {
-    let data = {message, sender, recipient};
-    let names = {message: "Extension:Message", connect: "Extension:Connect"};
-    messageManager.sendAsyncMessage(names[type], data);
-  },
-};
+var nextPortId = 1;
 
 // Abstraction for a Port object in the extension API. Each port has a unique ID.
 function Port(context, messageManager, name, id, sender) {
   this.context = context;
   this.messageManager = messageManager;
   this.name = name;
   this.id = id;
   this.listenerName = `Extension:Port-${this.id}`;
@@ -872,138 +804,134 @@ function getMessageManager(target) {
   }
   return target;
 }
 
 // Each extension scope gets its own Messenger object. It handles the
 // basics of sendMessage, onMessage, connect, and onConnect.
 //
 // |context| is the extension scope.
-// |broker| is a MessageBroker used to receive and send messages.
+// |messageManagers| is an array of MessageManagers used to receive messages.
 // |sender| is an object describing the sender (usually giving its extension id, tabId, etc.)
 // |filter| is a recipient filter to apply to incoming messages from the broker.
 // |delegate| is an object that must implement a few methods:
 //    getSender(context, messageManagerTarget, sender): returns a MessageSender
 //      See https://developer.chrome.com/extensions/runtime#type-MessageSender.
-function Messenger(context, broker, sender, filter, delegate) {
+function Messenger(context, messageManagers, sender, filter, delegate) {
   this.context = context;
-  this.broker = broker;
+  this.messageManagers = messageManagers;
   this.sender = sender;
   this.filter = filter;
   this.delegate = delegate;
 }
 
 Messenger.prototype = {
-  sendMessage(messageManager, msg, recipient, responseCallback) {
-    let id = this.broker.makeId();
-    let replyName = `Extension:Reply-${id}`;
-    recipient.messageId = id;
-    this.broker.sendMessage(messageManager, "message", msg, this.sender, recipient);
+  _sendMessage(messageManager, message, data, recipient) {
+    let options = {
+      recipient,
+      sender: this.sender,
+      responseType: MessageChannel.RESPONSE_FIRST,
+    };
 
-    let promise = new Promise((resolve, reject) => {
-      let onClose;
-      let listener = ({data: response}) => {
-        messageManager.removeMessageListener(replyName, listener);
-        this.context.forgetOnClose(onClose);
+    return this.context.sendMessage(messageManager, message, data, options);
+  },
 
-        if (response.gotData) {
-          resolve(response.data);
-        } else if (response.error) {
-          reject(response.error);
-        } else if (!responseCallback) {
-          // As a special case, we don't call the callback variant if we
-          // receive no response, but the promise needs to resolve or
-          // reject in either case.
-          resolve();
+  sendMessage(messageManager, msg, recipient, responseCallback) {
+    let promise = this._sendMessage(messageManager, "Extension:Message", msg, recipient)
+      .catch(error => {
+        if (error.result == MessageChannel.RESULT_NO_HANDLER) {
+          return Promise.reject({message: "Could not establish connection. Receiving end does not exist."});
+        } else if (error.result == MessageChannel.RESULT_NO_RESPONSE) {
+          if (responseCallback) {
+            // As a special case, we don't call the callback variant if we
+            // receive no response. So return a promise which will never
+            // resolve.
+            return new Promise(() => {});
+          }
+        } else {
+          return Promise.reject({message: error.message});
         }
-      };
-      onClose = {
-        close() {
-          messageManager.removeMessageListener(replyName, listener);
-        },
-      };
-
-      messageManager.addMessageListener(replyName, listener);
-      this.context.callOnClose(onClose);
-    });
+      });
 
     return this.context.wrapPromise(promise, responseCallback);
   },
 
   onMessage(name) {
     return new SingletonEventManager(this.context, name, callback => {
-      let listener = (type, target, message, sender, recipient) => {
-        message = Cu.cloneInto(message, this.context.cloneScope);
-        if (this.delegate) {
-          this.delegate.getSender(this.context, target, sender);
-        }
-        sender = Cu.cloneInto(sender, this.context.cloneScope);
+      let listener = {
+        messageFilter: {},
+        listenerInfo: this.filter,
+
+        receiveMessage: ({target, data: message, sender, recipient}) => {
+          if (this.delegate) {
+            this.delegate.getSender(this.context, target, sender);
+          }
 
-        let mm = getMessageManager(target);
-        let replyName = `Extension:Reply-${recipient.messageId}`;
+          let sendResponse;
+          let response = undefined;
+          let promise = new Promise(resolve => {
+            sendResponse = value => {
+              resolve(value);
+              response = promise;
+            };
+          });
 
-        new Promise((resolve, reject) => {
-          let sendResponse = Cu.exportFunction(resolve, this.context.cloneScope);
+          message = Cu.cloneInto(message, this.context.cloneScope);
+          sender = Cu.cloneInto(sender, this.context.cloneScope);
+          sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope);
 
           // Note: We intentionally do not use runSafe here so that any
           // errors are propagated to the message sender.
           let result = callback(message, sender, sendResponse);
           if (result instanceof Promise) {
-            resolve(result);
-          } else if (result !== true) {
-            reject();
+            return result;
+          } else if (result === true) {
+            return promise;
           }
-        }).then(
-          data => {
-            mm.sendAsyncMessage(replyName, {data, gotData: true});
-          },
-          error => {
-            if (error) {
-              // The result needs to be structured-clonable, which
-              // ordinary Error objects are not.
-              try {
-                error = {message: String(error.message), stack: String(error.stack)};
-              } catch (e) {
-                error = {message: String(error)};
-              }
-            }
-            mm.sendAsyncMessage(replyName, {error, gotData: false});
-          });
+          return response;
+        },
       };
 
-      this.broker.addListener("message", listener, this.filter);
+      MessageChannel.addListener(this.messageManagers, "Extension:Message", listener);
       return () => {
-        this.broker.removeListener("message", listener);
+        MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener);
       };
     }).api();
   },
 
   connect(messageManager, name, recipient) {
-    let portId = this.broker.makeId();
+    let portId = nextPortId++;
     let port = new Port(this.context, messageManager, name, portId, null);
     let msg = {name, portId};
-    this.broker.sendMessage(messageManager, "connect", msg, this.sender, recipient);
+    // TODO: Disconnect the port if no response?
+    this._sendMessage(messageManager, "Extension:Connect", msg, recipient);
     return port.api();
   },
 
   onConnect(name) {
-    return new EventManager(this.context, name, fire => {
-      let listener = (type, target, message, sender, recipient) => {
-        let {name, portId} = message;
-        let mm = getMessageManager(target);
-        if (this.delegate) {
-          this.delegate.getSender(this.context, target, sender);
-        }
-        let port = new Port(this.context, mm, name, portId, sender);
-        fire.withoutClone(port.api());
+    return new SingletonEventManager(this.context, name, callback => {
+      let listener = {
+        messageFilter: {},
+        listenerInfo: this.filter,
+
+        receiveMessage: ({target, data: message, sender, recipient}) => {
+          let {name, portId} = message;
+          let mm = getMessageManager(target);
+          if (this.delegate) {
+            this.delegate.getSender(this.context, target, sender);
+          }
+          let port = new Port(this.context, mm, name, portId, sender);
+          runSafeSyncWithoutClone(callback, port.api());
+          return true;
+        },
       };
 
-      this.broker.addListener("connect", listener, this.filter);
+      MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener);
       return () => {
-        this.broker.removeListener("connect", listener);
+        MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener);
       };
     }).api();
   },
 };
 
 function flushJarCache(jarFile) {
   Services.obs.notifyObservers(jarFile, "flush-cache-entry", null);
 }
@@ -1051,14 +979,13 @@ this.ExtensionUtils = {
   runSafe,
   runSafeSync,
   runSafeSyncWithoutClone,
   runSafeWithoutClone,
   BaseContext,
   DefaultWeakMap,
   EventManager,
   LocaleData,
-  MessageBroker,
   Messenger,
   PlatformInfo,
   SingletonEventManager,
   SpreadArgs,
 };
--- a/toolkit/components/extensions/MessageChannel.jsm
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -56,17 +56,17 @@
  * filter, and an optional sender tag to identify itself:
  *
  *  let data = { touchWith: "pencil" };
  *  let sender = { extensionID, contextID };
  *  let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID };
  *
  *  MessageChannel.sendMessage(
  *    tab.linkedBrowser.messageManager, "ContentScript:TouchContent",
- *    data, recipient, sender
+ *    data, {recipient, sender}
  *  ).then(result => {
  *    alert(result.touchResult);
  *  });
  *
  * Since the lifetimes of message senders and receivers may not always
  * match, either side of the message channel may cancel pending
  * responses which match its sender or recipient tags.
  *
@@ -147,58 +147,50 @@ class FilteringMessageManager {
 
   /**
    * Receives a message from our message manager, maps it to a handler, and
    * passes the result to our message callback.
    */
   receiveMessage({data, target}) {
     let handlers = Array.from(this.getHandlers(data.messageName, data.recipient));
 
-    let result = {};
-    if (handlers.length == 0) {
-      result.error = {result: MessageChannel.RESULT_NO_HANDLER,
-                      message: "No matching message handler"};
-    } else if (handlers.length > 1) {
-      result.error = {result: MessageChannel.RESULT_MULTIPLE_HANDLERS,
-                      message: `Multiple matching handlers for ${data.messageName}`};
-    } else {
-      result.handler = handlers[0];
-    }
-
     data.target = target;
-    this.callback(result, data);
+    this.callback({handlers}, data);
   }
 
   /**
    * Iterates over all handlers for the given message name. If `recipient`
    * is provided, only iterates over handlers whose filters match it.
    *
    * @param {string|number} messageName
    *     The message for which to return handlers.
    * @param {object} recipient
    *     The recipient data on which to filter handlers.
    */
   * getHandlers(messageName, recipient) {
     let handlers = this.handlers.get(messageName) || new Set();
     for (let handler of handlers) {
-      if (MessageChannel.matchesFilter(handler.messageFilter, recipient)) {
+      if (MessageChannel.matchesFilter(handler.messageFilter, recipient) &&
+          MessageChannel.matchesInfo(handler.listenerInfo || {}, recipient)) {
         yield handler;
       }
     }
   }
 
   /**
    * Registers a handler for the given message.
    *
    * @param {string} messageName
    *     The internal message name for which to register the handler.
    * @param {object} handler
    *     An opaque handler object. The object must have a `messageFilter`
-   *     property on which to filter messages. Final dispatching is handled
-   *     by the message callback passed to the constructor.
+   *     property on which to filter messages.
+   *
+   *     Final dispatching is handled by the message callback passed to
+   *     the constructor.
    */
   addHandler(messageName, handler) {
     if (!this.handlers.has(messageName)) {
       this.handlers.set(messageName, new Set());
     }
 
     this.handlers.get(messageName).add(handler);
   }
@@ -293,23 +285,54 @@ this.MessageChannel = {
     this.pendingResponses = new Set();
   },
 
   RESULT_SUCCESS: 0,
   RESULT_DISCONNECTED: 1,
   RESULT_NO_HANDLER: 2,
   RESULT_MULTIPLE_HANDLERS: 3,
   RESULT_ERROR: 4,
+  RESULT_NO_RESPONSE: 5,
 
   REASON_DISCONNECTED: {
     result: this.RESULT_DISCONNECTED,
     message: "Message manager disconnected",
   },
 
   /**
+   * Specifies that only a single listener matching the specified
+   * recipient tag may be listening for the given message, at the other
+   * end of the target message manager.
+   *
+   * If no matching listeners exist, a RESULT_NO_HANDLER error will be
+   * returned. If multiple matching listeners exist, a
+   * RESULT_MULTIPLE_HANDLERS error will be returned.
+   */
+  RESPONSE_SINGLE: 0,
+
+  /**
+   * If multiple message managers matching the specified recipient tag
+   * are listening for a message, all listeners are notified, but only
+   * the first response or error is returned.
+   *
+   * If no matching listeners exist, a RESULT_NO_HANDLER error will be
+   * returned. If no listeners return a response, a RESULT_NO_RESPONSE
+   * error will be returned.
+   */
+  RESPONSE_FIRST: 1,
+
+  /**
+   * If multiple message managers matching the specified recipient tag
+   * are listening for a message, all listeners are notified, and all
+   * responses are returned as an array, once all listeners have
+   * replied.
+   */
+  RESPONSE_ALL: 2,
+
+  /**
    * Returns true if the given `data` object matches the given `filter`
    * object. The objects match if every property of `filter` is present
    * in `data`, and the values in both objects are strictly equal.
    *
    * @param {object} filter
    *    The filter object to match against.
    * @param {object} data
    *    The data object being matched.
@@ -317,20 +340,36 @@ this.MessageChannel = {
    */
   matchesFilter(filter, data) {
     return Object.keys(filter).every(key => {
       return key in data && data[key] === filter[key];
     });
   },
 
   /**
+   * Returns true if every property which is present in both the `info`
+   * and `data` object has the same value in both objects.
+   *
+   * @param {object} info
+   *    The info object to match against.
+   * @param {object} data
+   *    The data object being matched.
+   * @returns {bool} True if the objects match.
+   */
+  matchesInfo(info, data) {
+    return Object.keys(info).every(key => {
+      return !(key in data) || data[key] === info[key];
+    });
+  },
+
+  /**
    * Adds a message listener to the given message manager.
    *
-   * @param {nsIMessageSender} target
-   *    The message manager on which to listen.
+   * @param {nsIMessageSender|[nsIMessageSender]} targets
+   *    The message managers on which to listen.
    * @param {string|number} messageName
    *    The name of the message to listen for.
    * @param {MessageReceiver} handler
    *    The handler to dispatch to. Must be an object with the following
    *    properties:
    *
    *      receiveMessage:
    *        A method which is called for each message received by the
@@ -363,63 +402,84 @@ this.MessageChannel = {
    *        resolution or rejection value of which will likewise be
    *        returned to the message sender.
    *
    *      messageFilter:
    *        An object containing arbitrary properties on which to filter
    *        received messages. Messages will only be dispatched to this
    *        object if the `recipient` object passed to `sendMessage`
    *        matches this filter, as determined by `matchesFilter`.
+   *
+   *      listenerInfo:
+   *        An object containing arbitrary properties describing the
+   *        listener. This filters messages in a similar manner to
+   *        `messageFilter`, but only except that properties are only
+   *        required to match if they are present in both the
+   *        `listenerInfo` and the `recipient` object.
    */
-  addListener(target, messageName, handler) {
-    this.messageManagers.get(target).addHandler(messageName, handler);
+  addListener(targets, messageName, handler) {
+    for (let target of [].concat(targets)) {
+      this.messageManagers.get(target).addHandler(messageName, handler);
+    }
   },
 
   /**
    * Removes a message listener from the given message manager.
    *
    * @param {nsIMessageSender} target
-   *    The message manager on which to stop listening.
+   * @param {nsIMessageSender|[nsIMessageSender]} targets
+   *    The message managers on which to stop listening.
    * @param {string|number} messageName
    *    The name of the message to stop listening for.
    * @param {MessageReceiver} handler
    *    The handler to stop dispatching to.
    */
-  removeListener(target, messageName, handler) {
-    this.messageManagers.get(target).removeListener(messageName, handler);
+  removeListener(targets, messageName, handler) {
+    for (let target of [].concat(targets)) {
+      this.messageManagers.get(target).removeHandler(messageName, handler);
+    }
   },
 
   /**
    * Sends a message via the given message manager. Returns a promise which
    * resolves or rejects with the return value of the message receiver.
    *
    * The promise also rejects if there is no matching listener, or the other
    * side of the message manager disconnects before the response is received.
    *
    * @param {nsIMessageSender} target
    *    The message manager on which to send the message.
    * @param {string} messageName
    *    The name of the message to send, as passed to `addListener`.
    * @param {object} data
    *    A structured-clone-compatible object to send to the message
    *    recipient.
-   * @param {object} [recipient]
+   * @param {object} [options]
+   *    An object containing any of the following properties:
+   * @param {object} [options.recipient]
    *    A structured-clone-compatible object to identify the message
    *    recipient. The object must match the `messageFilter` defined by
    *    recipients in order for the message to be received.
-   * @param {object} [sender]
+   * @param {object} [options.sender]
    *    A structured-clone-compatible object to identify the message
    *    sender. This object may also be used as a filter to prematurely
    *    abort responses when the sender is being destroyed.
    *    @see `abortResponses`.
+   * @param {integer} [options.responseType=RESPONSE_SINGLE]
+   *    Specifies the type of response expected. See the `RESPONSE_*`
+   *    contents for details.
    * @returns Promise
    */
-  sendMessage(target, messageName, data, recipient = {}, sender = {}) {
+  sendMessage(target, messageName, data, options = {}) {
+    let sender = options.sender || {};
+    let recipient = options.recipient || {};
+    let responseType = options.responseType;
+
     let channelId = gChannelId++;
-    let message = {messageName, channelId, sender, recipient, data};
+    let message = {messageName, channelId, sender, recipient, data, responseType};
 
     let deferred = PromiseUtils.defer();
     deferred.messageFilter = {};
     deferred.sender = recipient;
     deferred.messageManager = target;
 
     this._addPendingResponse(deferred);
 
@@ -433,46 +493,80 @@ this.MessageChannel = {
       broker.removeHandler(channelId, deferred);
     };
     deferred.promise.then(cleanup, cleanup);
 
     target.sendAsyncMessage(MESSAGE_MESSAGE, message);
     return deferred.promise;
   },
 
+  _callHandlers(handlers, data) {
+    let responseType = data.responseType || this.RESPONSE_SINGLE;
+
+    // At least one handler is required for all response types but
+    // RESPONSE_ALL.
+    if (handlers.length == 0 && responseType != this.RESPONSE_ALL) {
+      return Promise.reject({result: MessageChannel.RESULT_NO_HANDLER,
+                             message: "No matching message handler"});
+    }
+
+    if (responseType == this.RESPONSE_SINGLE) {
+      if (handlers.length > 1) {
+        return Promise.reject({result: MessageChannel.RESULT_MULTIPLE_HANDLERS,
+                               message: `Multiple matching handlers for ${data.messageName}`});
+      }
+
+      return new Promise(resolve => {
+        resolve(handlers[0].receiveMessage(data));
+      });
+    }
+
+    let responses = handlers.map(handler => handler.receiveMessage(data))
+                            .filter(response => response !== undefined);
+
+    switch (responseType) {
+      case this.RESPONSE_FIRST:
+        if (responses.length == 0) {
+          return Promise.reject({result: MessageChannel.RESULT_NO_RESPONSE,
+                                 message: "No handler returned a response"});
+        }
+
+        return Promise.race(responses);
+
+      case this.RESPONSE_ALL:
+        return Promise.all(responses);
+    }
+    return Promise.reject({message: "Invalid response type"});
+  },
+
   /**
    * Handles dispatching message callbacks from the message brokers to their
    * appropriate `MessageReceivers`, and routing the responses back to the
    * original senders.
    *
    * Each handler object is a `MessageReceiver` object as passed to
    * `addListener`.
    */
-  _handleMessage({handler, error}, data) {
+  _handleMessage({handlers}, data) {
     // The target passed to `receiveMessage` is sometimes a message manager
     // owner instead of a message manager, so make sure to convert it to a
     // message manager first if necessary.
     let {target} = data;
     if (!(target instanceof Ci.nsIMessageSender)) {
       target = target.messageManager;
     }
 
     let deferred = {
       sender: data.sender,
       messageManager: target,
     };
     deferred.promise = new Promise((resolve, reject) => {
       deferred.reject = reject;
 
-      if (handler) {
-        let result = handler.receiveMessage(data);
-        resolve(result);
-      } else {
-        reject(error);
-      }
+      this._callHandlers(handlers, data).then(resolve, reject);
     }).then(
       value => {
         let response = {
           result: this.RESULT_SUCCESS,
           messageName: data.channelId,
           recipient: {},
           value,
         };
@@ -508,25 +602,27 @@ this.MessageChannel = {
   },
 
   /**
    * Handles message callbacks from the response brokers.
    *
    * Each handler object is a deferred object created by `sendMessage`, and
    * should be resolved or rejected based on the contents of the response.
    */
-  _handleResponse({handler, error}, data) {
-    if (error) {
-      // If we have an error at this point, we have handler to report it to,
-      // so just log it.
-      Cu.reportError(error.message);
+  _handleResponse({handlers}, data) {
+    // If we have an error at this point, we have handler to report it to,
+    // so just log it.
+    if (handlers.length == 0) {
+      Cu.reportError(`No matching message response handler for ${data.messageName}`);
+    } else if (handlers.length > 1) {
+      Cu.reportError(`Multiple matching response handlers for ${data.messageName}`);
     } else if (data.result === this.RESULT_SUCCESS) {
-      handler.resolve(data.value);
+      handlers[0].resolve(data.value);
     } else {
-      handler.reject(data.error);
+      handlers[0].reject(data.error);
     }
   },
 
   /**
    * Adds a pending response to the the `pendingResponses` list.
    *
    * The response object must be a deferred promise with the following
    * properties:
--- a/toolkit/components/extensions/ext-webNavigation.js
+++ b/toolkit/components/extensions/ext-webNavigation.js
@@ -85,31 +85,31 @@ extensions.registerSchemaAPI("webNavigat
         let tab = TabManager.getTab(details.tabId);
         if (!tab) {
           return Promise.reject({message: `No tab found with tabId: ${details.tabId}`});
         }
 
         let {innerWindowID, messageManager} = tab.linkedBrowser;
         let recipient = {innerWindowID};
 
-        return context.sendMessage(messageManager, "WebNavigation:GetAllFrames", {}, recipient)
+        return context.sendMessage(messageManager, "WebNavigation:GetAllFrames", {}, {recipient})
                       .then((results) => results.map(convertGetFrameResult.bind(null, details.tabId)));
       },
       getFrame(details) {
         let tab = TabManager.getTab(details.tabId);
         if (!tab) {
           return Promise.reject({message: `No tab found with tabId: ${details.tabId}`});
         }
 
         let recipient = {
           innerWindowID: tab.linkedBrowser.innerWindowID,
         };
 
         let mm = tab.linkedBrowser.messageManager;
-        return context.sendMessage(mm, "WebNavigation:GetFrame", {options: details}, recipient)
+        return context.sendMessage(mm, "WebNavigation:GetFrame", {options: details}, {recipient})
                       .then((result) => {
                         return result ?
                           convertGetFrameResult(details.tabId, result) :
                           Promise.reject({message: `No frame found with frameId: ${details.frameId}`});
                       });
       },
     },
   };
--- a/toolkit/components/extensions/test/mochitest/mochitest.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -41,16 +41,17 @@ skip-if = buildapp == 'b2g' # runat != d
 [test_ext_permission_xhr.html]
 skip-if = buildapp == 'b2g' # JavaScript error: jar:remoteopenfile:///data/local/tmp/generated-extension.xpi!/content.js, line 46: NS_ERROR_ILLEGAL_VALUE:
 [test_ext_runtime_connect.html]
 skip-if = buildapp == 'b2g' # port.sender.tab is undefined on b2g.
 [test_ext_runtime_connect2.html]
 skip-if = buildapp == 'b2g' # port.sender.tab is undefined on b2g.
 [test_ext_runtime_disconnect.html]
 [test_ext_runtime_getPlatformInfo.html]
+[test_ext_runtime_sendMessage.html]
 [test_ext_sandbox_var.html]
 [test_ext_sendmessage_reply.html]
 skip-if = buildapp == 'b2g' # sender.tab is undefined on b2g.
 [test_ext_sendmessage_reply2.html]
 skip-if = buildapp == 'b2g' # sender.tab is undefined on b2g.
 [test_ext_sendmessage_doublereply.html]
 skip-if = buildapp == 'b2g' # sender.tab is undefined on b2g.
 [test_ext_storage.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_sendMessage.html
@@ -0,0 +1,85 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(function* tabsSendMessageReply() {
+  function background() {
+    browser.runtime.onMessage.addListener((msg, sender, respond) => {
+      if (msg == "respond-now") {
+        respond(msg);
+      } else if (msg == "respond-soon") {
+        setTimeout(() => { respond(msg); }, 0);
+        return true;
+      } else if (msg == "respond-promise") {
+        return Promise.resolve(msg);
+      } else if (msg == "respond-never") {
+        return;
+      } else if (msg == "respond-error") {
+        return Promise.reject(new Error(msg));
+      } else if (msg == "throw-error") {
+        throw new Error(msg);
+      }
+    });
+
+    browser.runtime.onMessage.addListener((msg, sender, respond) => {
+      if (msg == "respond-now") {
+        respond("hello");
+      } else if (msg == "respond-now-2") {
+        respond(msg);
+      }
+    });
+
+    browser.runtime.sendMessage("respond-never", response => {
+      browser.test.fail(`Got unexpected response callback: ${response}`);
+      browser.test.notifyFail("sendMessage");
+    });
+
+    Promise.all([
+      browser.runtime.sendMessage("respond-now"),
+      browser.runtime.sendMessage("respond-now-2"),
+      new Promise(resolve => browser.runtime.sendMessage("respond-soon", resolve)),
+      browser.runtime.sendMessage("respond-promise"),
+      browser.runtime.sendMessage("respond-never"),
+
+      browser.runtime.sendMessage("respond-error").catch(error => Promise.resolve({error})),
+      browser.runtime.sendMessage("throw-error").catch(error => Promise.resolve({error})),
+    ]).then(([respondNow, respondNow2, respondSoon, respondPromise, respondNever, respondError, throwError]) => {
+      browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response");
+      browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener");
+      browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response");
+      browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response");
+      browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution");
+
+      browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response");
+      browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response");
+
+      browser.test.notifyPass("sendMessage");
+    }).catch(e => {
+      browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      browser.test.notifyFail("sendMessage");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`
+  });
+
+  yield extension.startup();
+  yield extension.awaitFinish("sendMessage");
+  yield extension.unload();
+});
+</script>
+
+</body>
+</html>