Bug 1220154, 1249830: Handle sendMessage replies with 0 and >1 listeners correctly. r=billm
authorKris Maglione <maglione.k@gmail.com>
Fri, 04 Mar 2016 15:40:56 -0800
changeset 287040 557e8160f3518163e4d734852f376bc17da0a9b0
parent 287038 46296f1ef6a702846fb043f84c11d77cdc12532b
child 287041 34aa49e08c1749da907373f33b6f6a2f8123cc60
push id30064
push userkwierso@gmail.com
push dateTue, 08 Mar 2016 23:00:06 +0000
treeherdermozilla-central@45382cd65651 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbillm
bugs1220154, 1249830
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
@@ -87,17 +87,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.";
@@ -212,22 +211,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.
@@ -239,57 +232,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.
@@ -298,26 +279,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,31 +732,29 @@ 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});
   }
 
   uninit() {
     this.global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId: this.windowId});
   }
 
-  get messageFilter() {
+  get messageFilterStrict() {
     return {
       innerWindowID: windowId(this.global.content),
     };
   }
 
   receiveMessage({target, messageName, recipient, data}) {
     switch (messageName) {
       case "Extension:Capture":
@@ -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;
@@ -262,16 +283,21 @@ class BaseContext {
             }
             runSafeSyncWithoutClone(reject, 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;
@@ -651,111 +677,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}`;
@@ -858,138 +790,132 @@ 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 = {
+        messageFilterPermissive: 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 = {
+        messageFilterPermissive: 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);
 }
@@ -1037,14 +963,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
@@ -6,19 +6,19 @@
 
 /**
  * This module provides wrappers around standard message managers to
  * simplify bidirectional communication. It currently allows a caller to
  * send a message to a single listener, and receive a reply. If there
  * are no matching listeners, or the message manager disconnects before
  * a reply is received, the caller is returned an error.
  *
- * Since each message must have only one recipient, the listener end may
- * specify filters for the messages it wishes to receive, and the sender
- * end likewise may specify recipient tags to match the filters.
+ * The listener end may specify filters for the messages it wishes to
+ * receive, and the sender end likewise may specify recipient tags to
+ * match the filters.
  *
  * The message handler on the listener side may return its response
  * value directly, or may return a promise, the resolution or rejection
  * of which will be returned instead. The sender end likewise receives a
  * promise which resolves or rejects to the listener's response.
  *
  *
  * A basic setup works something like this:
@@ -29,20 +29,24 @@
  *  {
  *    init(messageManager, window, extensionID) {
  *      this.window = window;
  *
  *      MessageChannel.addListener(
  *        messageManager, "ContentScript:TouchContent",
  *        this);
  *
- *      this.messageFilter = {
+ *      this.messageFilterStrict = {
  *        innerWindowID: getInnerWindowID(window),
  *        extensionID: extensionID,
  *      };
+ *
+ *      this.messageFilterPermissive = {
+ *        outerWindowID: getOuterWindowID(window),
+ *      };
  *    },
  *
  *    receiveMessage({ target, messageName, sender, recipient, data }) {
  *      if (messageName == "ContentScript:TouchContent") {
  *        return new Promise(resolve => {
  *          this.touchWindow(data.touchWith, result => {
  *            resolve({ touchResult: result });
  *          });
@@ -56,17 +60,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 +151,51 @@ 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.messageFilterStrict || {}, recipient) &&
+          MessageChannel.matchesFilter(handler.messageFilterPermissive || {}, recipient, false)) {
         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.
+   *     An opaque handler object. The object may have a
+   *     `messageFilterStrict` and/or a `messageFilterPermissive`
+   *     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,44 +290,90 @@ 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",
   },
 
   /**
-   * 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.
+   * 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.
+   *
+   * Only handlers which return a value other than `undefined` are
+   * considered to have responded. Returning a Promise which evaluates
+   * to `undefined` is interpreted as an explicit response.
+   *
+   * 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 peroperties of the `data` object match those in
+   * the `filter` object. Matching is done on a strict equality basis,
+   * and the behavior varies depending on the value of the `strict`
+   * parameter.
    *
    * @param {object} filter
    *    The filter object to match against.
    * @param {object} data
    *    The data object being matched.
+   * @param {boolean} [strict=false]
+   *    If true, all properties in the `filter` object have a
+   *    corresponding property in `data` with the same value. If
+   *    false, properties present in both objects must have the same
+   *    balue.
    * @returns {bool} True if the objects match.
    */
-  matchesFilter(filter, data) {
+  matchesFilter(filter, data, strict = true) {
+    if (strict) {
+      return Object.keys(filter).every(key => {
+        return key in data && data[key] === filter[key];
+      });
+    }
     return Object.keys(filter).every(key => {
-      return key in data && data[key] === filter[key];
+      return !(key in data) || data[key] === filter[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
@@ -358,71 +401,93 @@ this.MessageChannel = {
    *            The contents of the message, as passed to `sendMessage`.
    *
    *        The method may return any structured-clone-compatible
    *        object, which will be returned as a response to the message
    *        sender. It may also instead return a `Promise`, the
    *        resolution or rejection value of which will likewise be
    *        returned to the message sender.
    *
-   *      messageFilter:
+   *      messageFilterStrict:
    *        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`.
+   *        matches this filter, as determined by `matchesFilter` with
+   *        `strict=true`.
+   *
+   *      messageFilterPermissive:
+   *        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` with
+   *        `strict=false`.
    */
-  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]
+   *    recipient. The object must match the `messageFilterStrict` and
+   *    `messageFilterPermissive` filters defined by recipients in order
+   *    for the message to be received.
+   * @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 || this.RESPONSE_SINGLE;
+
     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);
 
     // The channel ID is used as the message name when routing responses.
     // Add a message listener to the response broker, and remove it once
     // we've gotten (or canceled) a response.
@@ -433,46 +498,89 @@ 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;
+
+    // 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}`});
+      }
+
+      // Note: We use `new Promise` rather than `Promise.resolve` here
+      // so that errors from the handler are trapped and converted into
+      // rejected promises.
+      return new Promise(resolve => {
+        resolve(handlers[0].receiveMessage(data));
+      });
+    }
+
+    let responses = handlers.map(handler => {
+      try {
+        return handler.receiveMessage(data);
+      } catch (e) {
+        return Promise.reject(e);
+      }
+    });
+    responses = responses.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 +616,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
@@ -42,16 +42,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>