Bug 1843851 - Add a tp6 benchmark test to raptor. r=perftest-reviewers,kshampur
[gecko.git] / devtools / server / connectors / process-actor / DevToolsServiceWorkerChild.sys.mjs
blob37bb4c49cc92482d5ba7690efed79c6bf75fc400
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/. */
5 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   loader: "resource://devtools/shared/loader/Loader.sys.mjs",
11 });
13 XPCOMUtils.defineLazyServiceGetter(
14   lazy,
15   "wdm",
16   "@mozilla.org/dom/workers/workerdebuggermanager;1",
17   "nsIWorkerDebuggerManager"
20 XPCOMUtils.defineLazyModuleGetters(lazy, {
21   SessionDataHelpers:
22     "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm",
23 });
25 XPCOMUtils.defineLazyGetter(lazy, "DevToolsUtils", () =>
26   lazy.loader.require("devtools/shared/DevToolsUtils")
29 // Name of the attribute into which we save data in `sharedData` object.
30 const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
32 export class DevToolsServiceWorkerChild extends JSProcessActorChild {
33   constructor() {
34     super();
36     // The map is indexed by the Watcher Actor ID.
37     // The values are objects containing the following properties:
38     // - connection: the DevToolsServerConnection itself
39     // - workers: An array of object containing the following properties:
40     //     - dbg: A WorkerDebuggerInstance
41     //     - serviceWorkerTargetForm: The associated worker target instance form
42     //     - workerThreadServerForwardingPrefix: The prefix used to forward events to the
43     //       worker target on the worker thread ().
44     // - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate
45     //   between content and parent processes.
46     // - sessionData: Data (targets, resources, …) the watcher wants to be notified about.
47     //   See WatcherRegistry.getSessionData to see the full list of properties.
48     this._connections = new Map();
50     this._onConnectionChange = this._onConnectionChange.bind(this);
52     EventEmitter.decorate(this);
53   }
55   /**
56    * Called by nsIWorkerDebuggerManager when a worker get created.
57    *
58    * Go through all registered connections (in case we have more than one client connected)
59    * to eventually instantiate a target actor for this worker.
60    *
61    * @param {nsIWorkerDebugger} dbg
62    */
63   _onWorkerRegistered(dbg) {
64     // Only consider service workers
65     if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
66       return;
67     }
69     for (const [
70       watcherActorID,
71       { connection, forwardingPrefix, sessionData },
72     ] of this._connections) {
73       if (this._shouldHandleWorker(sessionData, dbg)) {
74         this._createWorkerTargetActor({
75           dbg,
76           connection,
77           forwardingPrefix,
78           watcherActorID,
79         });
80       }
81     }
82   }
84   /**
85    * Called by nsIWorkerDebuggerManager when a worker get destroyed.
86    *
87    * Go through all registered connections (in case we have more than one client connected)
88    * to destroy the related target which may have been created for this worker.
89    *
90    * @param {nsIWorkerDebugger} dbg
91    */
92   _onWorkerUnregistered(dbg) {
93     // Only consider service workers
94     if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
95       return;
96     }
98     for (const [watcherActorID, watcherConnectionData] of this._connections) {
99       this._destroyServiceWorkerTargetForWatcher(
100         watcherActorID,
101         watcherConnectionData,
102         dbg
103       );
104     }
105   }
107   /**
108    * To be called when we know a Service Worker target should be destroyed for a specific connection
109    * for which we pass the related "watcher connection data".
110    *
111    * @param {String} watcherActorID
112    *        Watcher actor ID for which we should unregister this service worker.
113    * @param {Object} watcherConnectionData
114    *        The metadata object for a given watcher, stored in the _connections Map.
115    * @param {nsIWorkerDebugger} dbg
116    */
117   _destroyServiceWorkerTargetForWatcher(
118     watcherActorID,
119     watcherConnectionData,
120     dbg
121   ) {
122     const { workers, forwardingPrefix } = watcherConnectionData;
124     // Check if the worker registration was handled for this watcher.
125     const unregisteredActorIndex = workers.findIndex(worker => {
126       try {
127         // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
128         return worker.dbg.id === dbg.id;
129       } catch (e) {
130         return false;
131       }
132     });
134     // Ignore this worker if it wasn't registered for this watcher.
135     if (unregisteredActorIndex === -1) {
136       return;
137     }
139     const { serviceWorkerTargetForm, transport } =
140       workers[unregisteredActorIndex];
142     // Remove the entry from this._connection dictionnary
143     workers.splice(unregisteredActorIndex, 1);
145     // Close the transport made against the worker thread.
146     transport.close();
148     // Note that we do not need to post the "disconnect" message from this destruction codepath
149     // as this method is only called when the worker is unregistered and so,
150     // we can't send any message anyway, and the worker is being destroyed anyway.
152     // Also notify the parent process that this worker target got destroyed.
153     // As the worker thread may be already destroyed, it may not have time to send a destroy event.
154     try {
155       this.sendAsyncMessage(
156         "DevToolsServiceWorkerChild:serviceWorkerTargetDestroyed",
157         {
158           watcherActorID,
159           forwardingPrefix,
160           serviceWorkerTargetForm,
161         }
162       );
163     } catch (e) {
164       // Ignore exception which may happen on content process destruction
165     }
166   }
168   /**
169    * Function handling messages sent by DevToolsServiceWorkerParent (part of ProcessActor API).
170    *
171    * @param {Object} message
172    * @param {String} message.name
173    * @param {*} message.data
174    */
175   receiveMessage(message) {
176     switch (message.name) {
177       case "DevToolsServiceWorkerParent:instantiate-already-available": {
178         const { watcherActorID, connectionPrefix, sessionData } = message.data;
179         return this._watchWorkerTargets({
180           watcherActorID,
181           parentConnectionPrefix: connectionPrefix,
182           sessionData,
183         });
184       }
185       case "DevToolsServiceWorkerParent:destroy": {
186         const { watcherActorID } = message.data;
187         return this._destroyTargetActors(watcherActorID);
188       }
189       case "DevToolsServiceWorkerParent:addOrSetSessionDataEntry": {
190         const { watcherActorID, type, entries, updateType } = message.data;
191         return this._addOrSetSessionDataEntry(
192           watcherActorID,
193           type,
194           entries,
195           updateType
196         );
197       }
198       case "DevToolsServiceWorkerParent:removeSessionDataEntry": {
199         const { watcherActorID, type, entries } = message.data;
200         return this._removeSessionDataEntry(watcherActorID, type, entries);
201       }
202       case "DevToolsServiceWorkerParent:packet":
203         return this.emit("packet-received", message);
204       default:
205         throw new Error(
206           "Unsupported message in DevToolsServiceWorkerParent: " + message.name
207         );
208     }
209   }
211   /**
212    * "chrome-event-target-created" event handler. Supposed to be fired very early when the process starts
213    */
214   observe() {
215     const { sharedData } = Services.cpmm;
216     const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
217     if (!sessionDataByWatcherActor) {
218       throw new Error(
219         "Request to instantiate the target(s) for the Service Worker, but `sharedData` is empty about watched targets"
220       );
221     }
223     // Create one Target actor for each prefix/client which listen to workers
224     for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
225       const { targets, connectionPrefix } = sessionData;
226       if (targets?.includes("service_worker")) {
227         this._watchWorkerTargets({
228           watcherActorID,
229           parentConnectionPrefix: connectionPrefix,
230           sessionData,
231         });
232       }
233     }
234   }
236   /**
237    * Instantiate targets for existing workers, watch for worker registration and listen
238    * for resources on those workers, for given connection and context. Targets are sent
239    * to the DevToolsServiceWorkerParent via the DevToolsServiceWorkerChild:serviceWorkerTargetAvailable message.
240    *
241    * @param {Object} options
242    * @param {String} options.watcherActorID: The ID of the WatcherActor who requested to
243    *        observe and create these target actors.
244    * @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection
245    *        of the Watcher Actor. This is used to compute a unique ID for the target actor.
246    * @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants
247    *        to be notified about. See WatcherRegistry.getSessionData to see the full list
248    *        of properties.
249    */
250   async _watchWorkerTargets({
251     watcherActorID,
252     parentConnectionPrefix,
253     sessionData,
254   }) {
255     // We might already have been called from observe method if the process was initializing
256     if (this._connections.has(watcherActorID)) {
257       // In such case, wait for the promise in order to ensure resolving only after
258       // we notified about the existing targets
259       await this._connections.get(watcherActorID).watchPromise;
260       return;
261     }
263     // Compute a unique prefix, just for this Service Worker,
264     // which will be used to create a JSWindowActorTransport pair between content and parent processes.
265     // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
266     // but here, we can't have access to any DevTools connection as we are really early in the content process startup
267     // WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe?
268     // (this.manager == WindowGlobalChild interface)
269     const forwardingPrefix =
270       parentConnectionPrefix + "serviceWorkerProcess" + this.manager.childID;
272     const connection = this._createConnection(forwardingPrefix);
274     // This method will be concurrently called from `observe()` and `DevToolsServiceWorkerParent:instantiate-already-available`
275     // When the JSprocessActor initializes itself and when the watcher want to force instantiating existing targets.
276     // Wait for the existing promise when the second call arise.
277     //
278     // Also, _connections has to be populated *before* calling _createWorkerTargetActor,
279     // so create a deferred promise right away.
280     let resolveWatchPromise;
281     const watchPromise = new Promise(
282       resolve => (resolveWatchPromise = resolve)
283     );
285     this._connections.set(watcherActorID, {
286       connection,
287       watchPromise,
288       workers: [],
289       forwardingPrefix,
290       sessionData,
291     });
293     // Listen for new workers that will be spawned.
294     if (!this._workerDebuggerListener) {
295       this._workerDebuggerListener = {
296         onRegister: this._onWorkerRegistered.bind(this),
297         onUnregister: this._onWorkerUnregistered.bind(this),
298       };
299       lazy.wdm.addListener(this._workerDebuggerListener);
300     }
302     const promises = [];
303     for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
304       if (!this._shouldHandleWorker(sessionData, dbg)) {
305         continue;
306       }
307       promises.push(
308         this._createWorkerTargetActor({
309           dbg,
310           connection,
311           forwardingPrefix,
312           watcherActorID,
313         })
314       );
315     }
316     await Promise.all(promises);
317     resolveWatchPromise();
318   }
320   /**
321    * Initialize a DevTools Server and return a new DevToolsServerConnection
322    * using this server in order to communicate to the parent process via
323    * the JSProcessActor message / queries.
324    *
325    * @param String forwardingPrefix
326    *        A unique prefix used to distinguish message coming from distinct service workers.
327    * @return DevToolsServerConnection
328    *        A connection to communicate with the parent process.
329    */
330   _createConnection(forwardingPrefix) {
331     const { DevToolsServer } = lazy.loader.require(
332       "devtools/server/devtools-server"
333     );
335     DevToolsServer.init();
337     // We want a special server without any root actor and only target-scoped actors.
338     // We are going to spawn a WorkerTargetActor instance in the next few lines,
339     // it is going to act like a root actor without being one.
340     DevToolsServer.registerActors({ target: true });
341     DevToolsServer.on("connectionchange", this._onConnectionChange);
343     const connection = DevToolsServer.connectToParentWindowActor(
344       this,
345       forwardingPrefix
346     );
348     return connection;
349   }
351   /**
352    * Indicates whether or not we should handle the worker debugger for a given
353    * watcher's session data.
354    *
355    * @param {Object} sessionData
356    *        The session data for a given watcher, which includes metadata
357    *        about the debugged context.
358    * @param {WorkerDebugger} dbg
359    *        The worker debugger we want to check.
360    *
361    * @returns {Boolean}
362    */
363   _shouldHandleWorker(sessionData, dbg) {
364     if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
365       return false;
366     }
367     // We only want to create targets for non-closed service worker
368     if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
369       return false;
370     }
372     // Accessing `nsIPrincipal.host` may easily throw on non-http URLs.
373     // Ignore all non-HTTP as they most likely don't have any valid host name.
374     if (!dbg.principal.scheme.startsWith("http")) {
375       return false;
376     }
378     const workerHost = dbg.principal.host;
379     return workerHost == sessionData["browser-element-host"][0];
380   }
382   async _createWorkerTargetActor({
383     dbg,
384     connection,
385     forwardingPrefix,
386     watcherActorID,
387   }) {
388     // Freeze the worker execution as soon as possible in order to wait for DevTools bootstrap.
389     // We typically want to:
390     //  - startup the Thread Actor,
391     //  - pass the initial session data which includes breakpoints to the worker thread,
392     //  - register the breakpoints,
393     // before release its execution.
394     // `connectToWorker` is going to call setDebuggerReady(true) when all of this is done.
395     try {
396       dbg.setDebuggerReady(false);
397     } catch (e) {
398       // This call will throw if the debugger is already "registered"
399       // (i.e. if this is called outside of the register listener)
400       // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66
401     }
403     const watcherConnectionData = this._connections.get(watcherActorID);
404     const { sessionData } = watcherConnectionData;
405     const workerThreadServerForwardingPrefix = connection.allocID(
406       "serviceWorkerTarget"
407     );
409     // Create the actual worker target actor, in the worker thread.
410     const { connectToWorker } = lazy.loader.require(
411       "devtools/server/connectors/worker-connector"
412     );
414     const onConnectToWorker = connectToWorker(
415       connection,
416       dbg,
417       workerThreadServerForwardingPrefix,
418       {
419         sessionData,
420         sessionContext: sessionData.sessionContext,
421       }
422     );
424     try {
425       await onConnectToWorker;
426     } catch (e) {
427       // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution.
428       // But if anything goes wrong and an exception is thrown, ensure releasing its execution,
429       // otherwise if devtools is broken, it will freeze the worker indefinitely.
430       //
431       // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to
432       // resume the debugger if it is not closed (otherwise it can cause crashes).
433       if (!dbg.isClosed) {
434         dbg.setDebuggerReady(true);
435       }
436       return;
437     }
439     const { workerTargetForm, transport } = await onConnectToWorker;
441     try {
442       this.sendAsyncMessage(
443         "DevToolsServiceWorkerChild:serviceWorkerTargetAvailable",
444         {
445           watcherActorID,
446           forwardingPrefix,
447           serviceWorkerTargetForm: workerTargetForm,
448         }
449       );
450     } catch (e) {
451       // If there was an error while sending the message, we are not going to use this
452       // connection to communicate with the worker.
453       transport.close();
454       return;
455     }
457     // Only add data to the connection if we successfully send the
458     // serviceWorkerTargetAvailable message.
459     watcherConnectionData.workers.push({
460       dbg,
461       transport,
462       serviceWorkerTargetForm: workerTargetForm,
463       workerThreadServerForwardingPrefix,
464     });
465   }
467   /**
468    * Request the service worker threads to destroy all their service worker Targets currently registered for a given Watcher actor.
469    *
470    * @param {String} watcherActorID
471    */
472   _destroyTargetActors(watcherActorID) {
473     const watcherConnectionData = this._connections.get(watcherActorID);
474     this._connections.delete(watcherActorID);
476     // This connection has already been cleaned?
477     if (!watcherConnectionData) {
478       console.error(
479         `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
480       );
481       return;
482     }
484     for (const {
485       dbg,
486       transport,
487       workerThreadServerForwardingPrefix,
488     } of watcherConnectionData.workers) {
489       try {
490         if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
491           dbg.postMessage(
492             JSON.stringify({
493               type: "disconnect",
494               forwardingPrefix: workerThreadServerForwardingPrefix,
495             })
496           );
497         }
498       } catch (e) {}
500       transport.close();
501     }
503     watcherConnectionData.connection.close();
504   }
506   /**
507    * Destroy the server once its last connection closes. Note that multiple
508    * worker scripts may be running in parallel and reuse the same server.
509    */
510   _onConnectionChange() {
511     const { DevToolsServer } = lazy.loader.require(
512       "devtools/server/devtools-server"
513     );
515     // Only destroy the server if there is no more connections to it. It may be
516     // used to debug another tab running in the same process.
517     if (DevToolsServer.hasConnection() || DevToolsServer.keepAlive) {
518       return;
519     }
521     if (this._destroyed) {
522       return;
523     }
524     this._destroyed = true;
526     DevToolsServer.off("connectionchange", this._onConnectionChange);
527     DevToolsServer.destroy();
528   }
530   /**
531    * Used by DevTools transport layer to communicate with the parent process.
532    *
533    * @param {String} packet
534    * @param {String prefix
535    */
536   async sendPacket(packet, prefix) {
537     return this.sendAsyncMessage("DevToolsServiceWorkerChild:packet", {
538       packet,
539       prefix,
540     });
541   }
543   /**
544    * Go through all registered service workers for a given watcher actor
545    * to send them new session data entries.
546    *
547    * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
548    */
549   async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
550     const watcherConnectionData = this._connections.get(watcherActorID);
551     if (!watcherConnectionData) {
552       return;
553     }
555     lazy.SessionDataHelpers.addOrSetSessionDataEntry(
556       watcherConnectionData.sessionData,
557       type,
558       entries,
559       updateType
560     );
562     // This type is really specific to Service Workers and doesn't need to be transferred to the worker threads.
563     // We only need to instantiate and destroy the target actors based on this new host.
564     if (type == "browser-element-host") {
565       this.updateBrowserElementHost(watcherActorID, watcherConnectionData);
566       return;
567     }
569     const promises = [];
570     for (const {
571       dbg,
572       workerThreadServerForwardingPrefix,
573     } of watcherConnectionData.workers) {
574       promises.push(
575         addOrSetSessionDataEntryInWorkerTarget({
576           dbg,
577           workerThreadServerForwardingPrefix,
578           type,
579           entries,
580           updateType,
581         })
582       );
583     }
584     await Promise.all(promises);
585   }
587   /**
588    * Called whenever the debugged browser element navigates to a new page
589    * and the URL's host changes.
590    * This is used to maintain the list of active Service Worker targets
591    * based on that host name.
592    *
593    * @param {String} watcherActorID
594    *        Watcher actor ID for which we should unregister this service worker.
595    * @param {Object} watcherConnectionData
596    *        The metadata object for a given watcher, stored in the _connections Map.
597    */
598   async updateBrowserElementHost(watcherActorID, watcherConnectionData) {
599     const { sessionData, connection, forwardingPrefix } = watcherConnectionData;
601     // Create target actor matching this new host.
602     // Note that we may be navigating to the same host name and the target will already exist.
603     const dbgToInstantiate = [];
604     for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
605       const alreadyCreated = watcherConnectionData.workers.some(
606         info => info.dbg === dbg
607       );
608       if (this._shouldHandleWorker(sessionData, dbg) && !alreadyCreated) {
609         dbgToInstantiate.push(dbg);
610       }
611     }
612     await Promise.all(
613       dbgToInstantiate.map(dbg => {
614         return this._createWorkerTargetActor({
615           dbg,
616           connection,
617           forwardingPrefix,
618           watcherActorID,
619         });
620       })
621     );
622   }
624   /**
625    * Go through all registered service workers for a given watcher actor
626    * to send them request to clear some session data entries.
627    *
628    * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
629    */
630   _removeSessionDataEntry(watcherActorID, type, entries) {
631     const watcherConnectionData = this._connections.get(watcherActorID);
633     if (!watcherConnectionData) {
634       return;
635     }
637     lazy.SessionDataHelpers.removeSessionDataEntry(
638       watcherConnectionData.sessionData,
639       type,
640       entries
641     );
643     for (const {
644       dbg,
645       workerThreadServerForwardingPrefix,
646     } of watcherConnectionData.workers) {
647       if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
648         dbg.postMessage(
649           JSON.stringify({
650             type: "remove-session-data-entry",
651             forwardingPrefix: workerThreadServerForwardingPrefix,
652             dataEntryType: type,
653             entries,
654           })
655         );
656       }
657     }
658   }
660   _removeExistingWorkerDebuggerListener() {
661     if (this._workerDebuggerListener) {
662       lazy.wdm.removeListener(this._workerDebuggerListener);
663       this._workerDebuggerListener = null;
664     }
665   }
667   /**
668    * Part of JSActor API
669    * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
670    *
671    * > The didDestroy method, if present, will be called after the actor is no
672    * > longer able to receive any more messages.
673    */
674   didDestroy() {
675     this._removeExistingWorkerDebuggerListener();
677     for (const [watcherActorID, watcherConnectionData] of this._connections) {
678       const { connection } = watcherConnectionData;
679       this._destroyTargetActors(watcherActorID);
681       connection.close();
682     }
684     this._connections.clear();
685   }
689  * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger.
691  * @param {WorkerDebugger} dbg
692  * @param {String} workerThreadServerForwardingPrefix
693  * @param {String} type
694  *        Session data type name
695  * @param {Array} entries
696  *        Session data entries to add or set.
697  * @param {String} updateType
698  *        Either "add" or "set", to control if we should only add some items,
699  *        or replace the whole data set with the new entries.
700  * @returns {Promise} Returns a Promise that resolves once the data entry were handled
701  *                    by the worker target.
702  */
703 function addOrSetSessionDataEntryInWorkerTarget({
704   dbg,
705   workerThreadServerForwardingPrefix,
706   type,
707   entries,
708   updateType,
709 }) {
710   if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
711     return Promise.resolve();
712   }
714   return new Promise(resolve => {
715     // Wait until we're notified by the worker that the resources are watched.
716     // This is important so we know existing resources were handled.
717     const listener = {
718       onMessage: message => {
719         message = JSON.parse(message);
720         if (message.type === "session-data-entry-added-or-set") {
721           resolve();
722           dbg.removeListener(listener);
723         }
724       },
725       // Resolve if the worker is being destroyed so we don't have a dangling promise.
726       onClose: () => resolve(),
727     };
729     dbg.addListener(listener);
731     dbg.postMessage(
732       JSON.stringify({
733         type: "add-or-set-session-data-entry",
734         forwardingPrefix: workerThreadServerForwardingPrefix,
735         dataEntryType: type,
736         entries,
737         updateType,
738       })
739     );
740   });