Bug 1220154, 1249830: Handle sendMessage replies with 0 and >1 listeners correctly. r=billm

MozReview-Commit-ID: 7lE7RaJcl7n

--HG--
extra : rebase_source : 424987c745840d56647600b8d9e1bf941afb1d95
This commit is contained in:
Kris Maglione 2016-03-04 15:40:56 -08:00
parent 5d50379e03
commit 876d0a0806
9 changed files with 384 additions and 274 deletions

View File

@ -653,7 +653,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => {
};
return context.sendMessage(browser.messageManager, "Extension:Capture",
message, recipient);
message, {recipient});
},
detectLanguage: function(tabId) {
@ -666,7 +666,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => {
let recipient = {innerWindowID: browser.innerWindowID};
return context.sendMessage(browser.messageManager, "Extension:DetectLanguage",
{}, recipient);
{}, {recipient});
},
_execute: function(tabId, details, kind, method) {
@ -724,7 +724,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => {
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) {

View File

@ -15,28 +15,35 @@ add_task(function* tabsSendMessageReply() {
},
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");
@ -44,6 +51,10 @@ add_task(function* tabsSendMessageReply() {
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");
@ -56,7 +67,10 @@ add_task(function* tabsSendMessageReply() {
});
});
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: {
@ -77,6 +91,13 @@ add_task(function* tabsSendMessageReply() {
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]);
});
@ -107,7 +128,7 @@ add_task(function* tabsSendMessageNoExceptionOnNonExistentTab() {
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");
});

View File

@ -92,7 +92,6 @@ Cu.import("resource://gre/modules/ExtensionUtils.jsm");
var {
BaseContext,
LocaleData,
MessageBroker,
Messenger,
injectAPI,
instanceOf,
@ -217,12 +216,6 @@ var Management = {
},
};
// 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
@ -244,7 +237,6 @@ ExtensionPage = class extends BaseContext {
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.
@ -261,7 +253,7 @@ ExtensionPage = class extends BaseContext {
// 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);
}
@ -274,17 +266,6 @@ ExtensionPage = class extends BaseContext {
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);
@ -303,16 +284,11 @@ ExtensionPage = class extends BaseContext {
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();
}
};

View File

@ -43,7 +43,6 @@ var {
runSafeSyncWithoutClone,
BaseContext,
LocaleData,
MessageBroker,
Messenger,
injectAPI,
flushJarCache,
@ -325,13 +324,12 @@ class ExtensionContext extends BaseContext {
};
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"});
@ -739,8 +737,6 @@ class ExtensionGlobal {
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)
@ -753,7 +749,7 @@ class ExtensionGlobal {
this.global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId: this.windowId});
}
get messageFilter() {
get messageFilterStrict() {
return {
innerWindowID: windowId(this.global.content),
};
@ -858,10 +854,6 @@ this.ExtensionContent = {
this.globals.get(global).uninit();
this.globals.delete(global);
},
getBroker(messageManager) {
return this.globals.get(messageManager).broker;
},
};
ExtensionManager.init();

View File

@ -20,6 +20,8 @@ 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");
@ -125,11 +127,14 @@ class SpreadArgs extends Array {
}
}
let gContextId = 0;
class BaseContext {
constructor() {
this.onClose = new Set();
this.checkedLastError = false;
this._lastError = null;
this.contextId = ++gContextId;
}
get cloneScope() {
@ -170,6 +175,22 @@ class BaseContext {
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;
@ -267,6 +288,11 @@ class BaseContext {
}
unload() {
MessageChannel.abortResponses({
extensionId: this.extension.id,
contextId: this.contextId,
});
for (let obj of this.onClose) {
obj.close();
}
@ -656,101 +682,7 @@ function promiseDocumentReady(doc) {
* 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) {
@ -863,128 +795,122 @@ function getMessageManager(target) {
// 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, message, data, recipient) {
let options = {
recipient,
sender: this.sender,
responseType: MessageChannel.RESPONSE_FIRST,
};
return this.context.sendMessage(messageManager, message, data, options);
},
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);
let promise = new Promise((resolve, reject) => {
let onClose;
let listener = ({data: response}) => {
messageManager.removeMessageListener(replyName, listener);
this.context.forgetOnClose(onClose);
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();
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,
let mm = getMessageManager(target);
let replyName = `Extension:Reply-${recipient.messageId}`;
receiveMessage: ({target, data: message, sender, recipient}) => {
if (this.delegate) {
this.delegate.getSender(this.context, target, sender);
}
new Promise((resolve, reject) => {
let sendResponse = Cu.exportFunction(resolve, this.context.cloneScope);
let sendResponse;
let response = undefined;
let promise = new Promise(resolve => {
sendResponse = value => {
resolve(value);
response = promise;
};
});
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();
},
@ -1042,7 +968,6 @@ this.ExtensionUtils = {
DefaultWeakMap,
EventManager,
LocaleData,
MessageBroker,
Messenger,
PlatformInfo,
SingletonEventManager,

View File

@ -11,9 +11,9 @@
* 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
@ -34,10 +34,14 @@
* messageManager, "ContentScript:TouchContent",
* this);
*
* this.messageFilter = {
* this.messageFilterStrict = {
* innerWindowID: getInnerWindowID(window),
* extensionID: extensionID,
* };
*
* this.messageFilterPermissive = {
* outerWindowID: getOuterWindowID(window),
* };
* },
*
* receiveMessage({ target, messageName, sender, recipient, data }) {
@ -61,7 +65,7 @@
*
* MessageChannel.sendMessage(
* tab.linkedBrowser.messageManager, "ContentScript:TouchContent",
* data, recipient, sender
* data, {recipient, sender}
* ).then(result => {
* alert(result.touchResult);
* });
@ -152,19 +156,8 @@ class FilteringMessageManager {
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);
}
/**
@ -179,7 +172,8 @@ class FilteringMessageManager {
* 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;
}
}
@ -191,9 +185,12 @@ class FilteringMessageManager {
* @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)) {
@ -298,6 +295,7 @@ this.MessageChannel = {
RESULT_NO_HANDLER: 2,
RESULT_MULTIPLE_HANDLERS: 3,
RESULT_ERROR: 4,
RESULT_NO_RESPONSE: 5,
REASON_DISCONNECTED: {
result: this.RESULT_DISCONNECTED,
@ -305,27 +303,72 @@ this.MessageChannel = {
},
/**
* 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
@ -363,28 +406,41 @@ this.MessageChannel = {
* 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);
}
},
/**
@ -401,23 +457,32 @@ this.MessageChannel = {
* @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;
@ -438,6 +503,54 @@ this.MessageChannel = {
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
@ -446,7 +559,7 @@ this.MessageChannel = {
* 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.
@ -462,12 +575,7 @@ this.MessageChannel = {
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 = {
@ -513,15 +621,17 @@ this.MessageChannel = {
* 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);
}
},

View File

@ -90,7 +90,7 @@ extensions.registerSchemaAPI("webNavigation", "webNavigation", (extension, conte
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) {
@ -104,7 +104,7 @@ extensions.registerSchemaAPI("webNavigation", "webNavigation", (extension, conte
};
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) :

View File

@ -47,6 +47,7 @@ skip-if = buildapp == 'b2g' # port.sender.tab is undefined on b2g.
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.

View File

@ -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>