Bug 1729395 - Handle message sender going away during message processing r=robwu
[gecko.git] / toolkit / components / extensions / ConduitsParent.jsm
blobbba819aa958a36eee154663cacbb32c035f0adb2
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4 "use strict";
6 const EXPORTED_SYMBOLS = ["BroadcastConduit", "ConduitsParent"];
8 /**
9  * This @file implements the parent side of Conduits, an abstraction over
10  * Fission IPC for extension Contexts, API managers, Ports/Messengers, and
11  * other types of "subjects" participating in implementation of extension APIs.
12  *
13  * Additionally, knowledge about conduits from all child processes is gathered
14  * here, and used together with the full CanonicalBrowsingContext tree to route
15  * IPC messages and queries directly to the right subjects.
16  *
17  * Each Conduit is tied to one subject, attached to a ConduitAddress descriptor,
18  * and exposes an API for sending/receiving via an actor, or multiple actors in
19  * case of the parent process.
20  *
21  * @typedef {number|string} ConduitID
22  *
23  * @typedef {object} ConduitAddress
24  * @prop {ConduitID} id Globally unique across all processes.
25  * @prop {string[]} [recv]
26  * @prop {string[]} [send]
27  * @prop {string[]} [query]
28  * @prop {string[]} [cast]
29  * Lists of recvX, sendX, queryX and castX methods this subject will use.
30  *
31  * @typedef {"messenger"|"port"|"tab"} BroadcastKind
32  * Kinds of broadcast targeting filters.
33  *
34  * @example:
35  *
36  *    init(actor) {
37  *      this.conduit = actor.openConduit(this, {
38  *        id: this.id,
39  *        recv: ["recvAddNumber"],
40  *        send: ["sendNumberUpdate"],
41  *      });
42  *    }
43  *
44  *    recvAddNumber({ num }, { actor, sender }) {
45  *      num += 1;
46  *      this.conduit.sendNumberUpdate(sender.id, { num });
47  *    }
48  *
49  */
51 const {
52   ExtensionUtils: { DefaultWeakMap, ExtensionError },
53 } = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
55 const { BaseConduit } = ChromeUtils.import(
56   "resource://gre/modules/ConduitsChild.jsm"
59 const { WebNavigationFrames } = ChromeUtils.import(
60   "resource://gre/modules/WebNavigationFrames.jsm"
63 const BATCH_TIMEOUT_MS = 250;
64 const ADDON_ENV = new Set(["addon_child", "devtools_child"]);
66 /**
67  * Internal, keeps track of all parent and remote (child) conduits.
68  */
69 const Hub = {
70   /** @type Map<ConduitID, ConduitAddress> Info about all child conduits. */
71   remotes: new Map(),
73   /** @type Map<ConduitID, BroadcastConduit> All open parent conduits. */
74   conduits: new Map(),
76   /** @type Map<string, BroadcastConduit> Parent conduits by recvMethod. */
77   byMethod: new Map(),
79   /** @type WeakMap<ConduitsParent, Set<ConduitAddress>> Conduits by actor. */
80   byActor: new DefaultWeakMap(() => new Set()),
82   /** @type Map<string, BroadcastConduit> */
83   reportOnClosed: new Map(),
85   /**
86    * Save info about a new parent conduit, register it as a global listener.
87    * @param {BroadcastConduit} conduit
88    */
89   openConduit(conduit) {
90     this.conduits.set(conduit.id, conduit);
91     for (let name of conduit.address.recv || []) {
92       if (this.byMethod.get(name)) {
93         // For now, we only allow one parent conduit handling each recv method.
94         throw new Error(`Duplicate BroadcastConduit method name recv${name}`);
95       }
96       this.byMethod.set(name, conduit);
97     }
98   },
100   /**
101    * Cleanup.
102    * @param {BroadcastConduit} conduit
103    */
104   closeConduit({ id, address }) {
105     this.conduits.delete(id);
106     for (let name of address.recv || []) {
107       this.byMethod.remove(name);
108     }
109   },
111   /**
112    * Confirm that a remote conduit comes from an extension page.
113    * @see ExtensionPolicyService::CheckParentFrames
114    * @param {ConduitAddress} remote
115    * @returns {boolean}
116    */
117   verifyEnv({ actor, envType, extensionId }) {
118     if (!extensionId || !ADDON_ENV.has(envType)) {
119       return false;
120     }
121     let windowGlobal = actor.manager;
123     while (windowGlobal) {
124       let { browsingContext: bc, documentPrincipal: prin } = windowGlobal;
126       if (prin.addonId !== extensionId) {
127         throw new Error(`Bad ${extensionId} principal: ${prin.URI.spec}`);
128       }
129       if (bc.currentRemoteType !== prin.addonPolicy.extension.remoteType) {
130         throw new Error(`Bad ${extensionId} process: ${bc.currentRemoteType}`);
131       }
133       if (!bc.parent) {
134         return true;
135       }
136       windowGlobal = bc.embedderWindowGlobal;
137     }
138     throw new Error(`Missing WindowGlobalParent for ${extensionId}`);
139   },
141   /**
142    * Fill in common address fields knowable from the parent process.
143    * @param {ConduitAddress} address
144    * @param {ConduitsParent} actor
145    */
146   fillInAddress(address, actor) {
147     address.actor = actor;
148     address.verified = this.verifyEnv(address);
149     address.frameId = WebNavigationFrames.getFrameId(actor.browsingContext);
150     address.url = actor.browsingContext.currentURI.spec;
151   },
153   /**
154    * Save info about a new remote conduit.
155    * @param {ConduitAddress} address
156    * @param {ConduitsParent} actor
157    */
158   recvConduitOpened(address, actor) {
159     this.fillInAddress(address, actor);
160     this.remotes.set(address.id, address);
161     this.byActor.get(actor).add(address);
162   },
164   /**
165    * Notifies listeners and cleans up after the remote conduit is closed.
166    * @param {ConduitAddress} remote
167    */
168   recvConduitClosed(remote) {
169     this.remotes.delete(remote.id);
170     this.byActor.get(remote.actor).delete(remote);
172     remote.actor = null;
173     for (let [key, conduit] of Hub.reportOnClosed.entries()) {
174       if (remote[key]) {
175         conduit.subject.recvConduitClosed(remote);
176       }
177     }
178   },
180   /**
181    * Close all remote conduits when the actor goes away.
182    * @param {ConduitsParent} actor
183    */
184   actorClosed(actor) {
185     for (let remote of this.byActor.get(actor)) {
186       // When a Port is closed, we notify the other side, but it might share
187       // an actor, so we shouldn't sendQeury() in that case (see bug 1623976).
188       this.remotes.delete(remote.id);
189     }
190     for (let remote of this.byActor.get(actor)) {
191       this.recvConduitClosed(remote);
192     }
193     this.byActor.delete(actor);
194   },
198  * Parent side conduit, registers as a global listeners for certain messages,
199  * and can target specific child conduits when sending.
200  */
201 class BroadcastConduit extends BaseConduit {
202   /**
203    * @param {object} subject
204    * @param {ConduitAddress} address
205    */
206   constructor(subject, address) {
207     super(subject, address);
209     // Create conduit.castX() bidings.
210     for (let name of address.cast || []) {
211       this[`cast${name}`] = this._cast.bind(this, name);
212     }
214     // Wants to know when conduits with a specific attribute are closed.
215     // `subject.recvConduitClosed(address)` method will be called.
216     if (address.reportOnClosed) {
217       Hub.reportOnClosed.set(address.reportOnClosed, this);
218     }
220     this.open = true;
221     Hub.openConduit(this);
222   }
224   /**
225    * Internal, sends a message to a specific conduit, used by sendX stubs.
226    * @param {string} method
227    * @param {boolean} query
228    * @param {ConduitID} target
229    * @param {object?} arg
230    * @returns {Promise<any>}
231    */
232   _send(method, query, target, arg = {}) {
233     if (!this.open) {
234       throw new Error(`send${method} on closed conduit ${this.id}`);
235     }
237     let sender = this.id;
238     let { actor } = Hub.remotes.get(target);
240     if (method === "RunListener" && arg.path.startsWith("webRequest.")) {
241       return actor.batch(method, { target, arg, query, sender });
242     }
243     return super._send(method, query, actor, { target, arg, query, sender });
244   }
246   /**
247    * Broadcasts a method call to all conduits of kind that satisfy filtering by
248    * kind-specific properties from arg, returns an array of response promises.
249    * @param {string} method
250    * @param {BroadcastKind} kind
251    * @param {object} arg
252    * @returns {Promise[]}
253    */
254   _cast(method, kind, arg) {
255     let filters = {
256       // Target Ports by portId and side (connect caller/onConnect receiver).
257       port: remote =>
258         remote.portId === arg.portId &&
259         (arg.source == null || remote.source === arg.source),
261       // Target Messengers in extension pages by extensionId and envType.
262       messenger: r =>
263         r.verified &&
264         r.id !== arg.sender.contextId &&
265         r.extensionId === arg.extensionId &&
266         r.recv.includes(method) &&
267         // TODO: Bug 1453343 - get rid of this:
268         (r.envType === "addon_child" || arg.sender.envType !== "content_child"),
270       // Target Messengers by extensionId, tabId (topBC) and frameId.
271       tab: remote =>
272         remote.extensionId === arg.extensionId &&
273         remote.actor.manager.browsingContext.top.id === arg.topBC &&
274         (arg.frameId == null || remote.frameId === arg.frameId) &&
275         remote.recv.includes(method),
276     };
278     let targets = Array.from(Hub.remotes.values()).filter(filters[kind]);
279     let promises = targets.map(c => this._send(method, true, c.id, arg));
281     return arg.firstResponse
282       ? this._raceResponses(promises)
283       : Promise.allSettled(promises);
284   }
286   /**
287    * Custom Promise.race() function that ignores certain resolutions and errors.
288    * @param {Promise<response>[]} promises
289    * @returns {Promise<response?>}
290    */
291   _raceResponses(promises) {
292     return new Promise((resolve, reject) => {
293       let result;
294       promises.map(p =>
295         p
296           .then(value => {
297             if (value.response) {
298               // We have an explicit response, resolve immediately.
299               resolve(value);
300             } else if (value.received) {
301               // Message was received, but no response.
302               // Resolve with this only if there is no other explicit response.
303               result = value;
304             }
305           })
306           .catch(err => {
307             // Forward errors that are exposed to extension, but ignore
308             // internal errors such as actor destruction and DataCloneError.
309             if (err instanceof ExtensionError || err?.mozWebExtLocation) {
310               reject(err);
311             } else {
312               Cu.reportError(err);
313             }
314           })
315       );
316       // Ensure resolving when there are no responses.
317       Promise.allSettled(promises).then(() => resolve(result));
318     });
319   }
321   async close() {
322     this.open = false;
323     Hub.closeConduit(this);
324   }
328  * Implements the parent side of the Conduits actor.
329  */
330 class ConduitsParent extends JSWindowActorParent {
331   constructor() {
332     super();
333     this.batchData = [];
334     this.batchPromise = null;
335     this.batchResolve = null;
336     this.timerActive = false;
337   }
339   /**
340    * Group webRequest events to send them as a batch, reducing IPC overhead.
341    * @param {string} name
342    * @param {MessageData} data
343    * @returns {Promise<object>}
344    */
345   batch(name, data) {
346     let pos = this.batchData.length;
347     this.batchData.push(data);
349     let sendNow = idleDispatch => {
350       if (this.batchData.length && this.manager) {
351         this.batchResolve(this.sendQuery(name, this.batchData));
352       } else {
353         this.batchResolve([]);
354       }
355       this.batchData = [];
356       this.timerActive = !idleDispatch;
357     };
359     if (!pos) {
360       this.batchPromise = new Promise(r => (this.batchResolve = r));
361       if (!this.timerActive) {
362         ChromeUtils.idleDispatch(sendNow, { timeout: BATCH_TIMEOUT_MS });
363         this.timerActive = true;
364       }
365     }
367     if (data.arg.urgentSend) {
368       // If this is an urgent blocking event, run this batch right away.
369       sendNow(false);
370     }
372     return this.batchPromise.then(results => results[pos]);
373   }
375   /**
376    * JSWindowActor method, routes the message to the target subject.
377    * @param {string} name
378    * @param {MessageData} data
379    * @returns {Promise?}
380    */
381   async receiveMessage({ name, data: { arg, query, sender } }) {
382     if (name === "ConduitOpened") {
383       return Hub.recvConduitOpened(arg, this);
384     }
386     sender = Hub.remotes.get(sender);
387     if (!sender || sender.actor !== this) {
388       throw new Error(`Unknown sender or wrong actor for recv${name}`);
389     }
391     if (name === "ConduitClosed") {
392       return Hub.recvConduitClosed(sender);
393     }
395     let conduit = Hub.byMethod.get(name);
396     if (!conduit) {
397       throw new Error(`Parent conduit for recv${name} not found`);
398     }
400     return conduit._recv(name, arg, { actor: this, query, sender });
401   }
403   /**
404    * JSWindowActor method, ensure cleanup.
405    */
406   didDestroy() {
407     Hub.actorClosed(this);
408   }