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";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 loader: "resource://devtools/shared/loader/Loader.sys.mjs",
13 XPCOMUtils.defineLazyServiceGetter(
16 "@mozilla.org/dom/workers/workerdebuggermanager;1",
17 "nsIWorkerDebuggerManager"
20 XPCOMUtils.defineLazyModuleGetters(lazy, {
22 "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm",
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 {
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);
56 * Called by nsIWorkerDebuggerManager when a worker get created.
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.
61 * @param {nsIWorkerDebugger} dbg
63 _onWorkerRegistered(dbg) {
64 // Only consider service workers
65 if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
71 { connection, forwardingPrefix, sessionData },
72 ] of this._connections) {
73 if (this._shouldHandleWorker(sessionData, dbg)) {
74 this._createWorkerTargetActor({
85 * Called by nsIWorkerDebuggerManager when a worker get destroyed.
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.
90 * @param {nsIWorkerDebugger} dbg
92 _onWorkerUnregistered(dbg) {
93 // Only consider service workers
94 if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
98 for (const [watcherActorID, watcherConnectionData] of this._connections) {
99 this._destroyServiceWorkerTargetForWatcher(
101 watcherConnectionData,
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".
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
117 _destroyServiceWorkerTargetForWatcher(
119 watcherConnectionData,
122 const { workers, forwardingPrefix } = watcherConnectionData;
124 // Check if the worker registration was handled for this watcher.
125 const unregisteredActorIndex = workers.findIndex(worker => {
127 // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
128 return worker.dbg.id === dbg.id;
134 // Ignore this worker if it wasn't registered for this watcher.
135 if (unregisteredActorIndex === -1) {
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.
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.
155 this.sendAsyncMessage(
156 "DevToolsServiceWorkerChild:serviceWorkerTargetDestroyed",
160 serviceWorkerTargetForm,
164 // Ignore exception which may happen on content process destruction
169 * Function handling messages sent by DevToolsServiceWorkerParent (part of ProcessActor API).
171 * @param {Object} message
172 * @param {String} message.name
173 * @param {*} message.data
175 receiveMessage(message) {
176 switch (message.name) {
177 case "DevToolsServiceWorkerParent:instantiate-already-available": {
178 const { watcherActorID, connectionPrefix, sessionData } = message.data;
179 return this._watchWorkerTargets({
181 parentConnectionPrefix: connectionPrefix,
185 case "DevToolsServiceWorkerParent:destroy": {
186 const { watcherActorID } = message.data;
187 return this._destroyTargetActors(watcherActorID);
189 case "DevToolsServiceWorkerParent:addOrSetSessionDataEntry": {
190 const { watcherActorID, type, entries, updateType } = message.data;
191 return this._addOrSetSessionDataEntry(
198 case "DevToolsServiceWorkerParent:removeSessionDataEntry": {
199 const { watcherActorID, type, entries } = message.data;
200 return this._removeSessionDataEntry(watcherActorID, type, entries);
202 case "DevToolsServiceWorkerParent:packet":
203 return this.emit("packet-received", message);
206 "Unsupported message in DevToolsServiceWorkerParent: " + message.name
212 * "chrome-event-target-created" event handler. Supposed to be fired very early when the process starts
215 const { sharedData } = Services.cpmm;
216 const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
217 if (!sessionDataByWatcherActor) {
219 "Request to instantiate the target(s) for the Service Worker, but `sharedData` is empty about watched targets"
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({
229 parentConnectionPrefix: connectionPrefix,
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.
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
250 async _watchWorkerTargets({
252 parentConnectionPrefix,
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;
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.
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)
285 this._connections.set(watcherActorID, {
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),
299 lazy.wdm.addListener(this._workerDebuggerListener);
303 for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
304 if (!this._shouldHandleWorker(sessionData, dbg)) {
308 this._createWorkerTargetActor({
316 await Promise.all(promises);
317 resolveWatchPromise();
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.
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.
330 _createConnection(forwardingPrefix) {
331 const { DevToolsServer } = lazy.loader.require(
332 "devtools/server/devtools-server"
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(
352 * Indicates whether or not we should handle the worker debugger for a given
353 * watcher's session data.
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.
363 _shouldHandleWorker(sessionData, dbg) {
364 if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
367 // We only want to create targets for non-closed service worker
368 if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
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")) {
378 const workerHost = dbg.principal.host;
379 return workerHost == sessionData["browser-element-host"][0];
382 async _createWorkerTargetActor({
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.
396 dbg.setDebuggerReady(false);
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
403 const watcherConnectionData = this._connections.get(watcherActorID);
404 const { sessionData } = watcherConnectionData;
405 const workerThreadServerForwardingPrefix = connection.allocID(
406 "serviceWorkerTarget"
409 // Create the actual worker target actor, in the worker thread.
410 const { connectToWorker } = lazy.loader.require(
411 "devtools/server/connectors/worker-connector"
414 const onConnectToWorker = connectToWorker(
417 workerThreadServerForwardingPrefix,
420 sessionContext: sessionData.sessionContext,
425 await onConnectToWorker;
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.
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).
434 dbg.setDebuggerReady(true);
439 const { workerTargetForm, transport } = await onConnectToWorker;
442 this.sendAsyncMessage(
443 "DevToolsServiceWorkerChild:serviceWorkerTargetAvailable",
447 serviceWorkerTargetForm: workerTargetForm,
451 // If there was an error while sending the message, we are not going to use this
452 // connection to communicate with the worker.
457 // Only add data to the connection if we successfully send the
458 // serviceWorkerTargetAvailable message.
459 watcherConnectionData.workers.push({
462 serviceWorkerTargetForm: workerTargetForm,
463 workerThreadServerForwardingPrefix,
468 * Request the service worker threads to destroy all their service worker Targets currently registered for a given Watcher actor.
470 * @param {String} watcherActorID
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) {
479 `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
487 workerThreadServerForwardingPrefix,
488 } of watcherConnectionData.workers) {
490 if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
494 forwardingPrefix: workerThreadServerForwardingPrefix,
503 watcherConnectionData.connection.close();
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.
510 _onConnectionChange() {
511 const { DevToolsServer } = lazy.loader.require(
512 "devtools/server/devtools-server"
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) {
521 if (this._destroyed) {
524 this._destroyed = true;
526 DevToolsServer.off("connectionchange", this._onConnectionChange);
527 DevToolsServer.destroy();
531 * Used by DevTools transport layer to communicate with the parent process.
533 * @param {String} packet
534 * @param {String prefix
536 async sendPacket(packet, prefix) {
537 return this.sendAsyncMessage("DevToolsServiceWorkerChild:packet", {
544 * Go through all registered service workers for a given watcher actor
545 * to send them new session data entries.
547 * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
549 async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
550 const watcherConnectionData = this._connections.get(watcherActorID);
551 if (!watcherConnectionData) {
555 lazy.SessionDataHelpers.addOrSetSessionDataEntry(
556 watcherConnectionData.sessionData,
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);
572 workerThreadServerForwardingPrefix,
573 } of watcherConnectionData.workers) {
575 addOrSetSessionDataEntryInWorkerTarget({
577 workerThreadServerForwardingPrefix,
584 await Promise.all(promises);
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.
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.
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
608 if (this._shouldHandleWorker(sessionData, dbg) && !alreadyCreated) {
609 dbgToInstantiate.push(dbg);
613 dbgToInstantiate.map(dbg => {
614 return this._createWorkerTargetActor({
625 * Go through all registered service workers for a given watcher actor
626 * to send them request to clear some session data entries.
628 * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
630 _removeSessionDataEntry(watcherActorID, type, entries) {
631 const watcherConnectionData = this._connections.get(watcherActorID);
633 if (!watcherConnectionData) {
637 lazy.SessionDataHelpers.removeSessionDataEntry(
638 watcherConnectionData.sessionData,
645 workerThreadServerForwardingPrefix,
646 } of watcherConnectionData.workers) {
647 if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
650 type: "remove-session-data-entry",
651 forwardingPrefix: workerThreadServerForwardingPrefix,
660 _removeExistingWorkerDebuggerListener() {
661 if (this._workerDebuggerListener) {
662 lazy.wdm.removeListener(this._workerDebuggerListener);
663 this._workerDebuggerListener = null;
668 * Part of JSActor API
669 * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
671 * > The didDestroy method, if present, will be called after the actor is no
672 * > longer able to receive any more messages.
675 this._removeExistingWorkerDebuggerListener();
677 for (const [watcherActorID, watcherConnectionData] of this._connections) {
678 const { connection } = watcherConnectionData;
679 this._destroyTargetActors(watcherActorID);
684 this._connections.clear();
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.
703 function addOrSetSessionDataEntryInWorkerTarget({
705 workerThreadServerForwardingPrefix,
710 if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
711 return Promise.resolve();
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.
718 onMessage: message => {
719 message = JSON.parse(message);
720 if (message.type === "session-data-entry-added-or-set") {
722 dbg.removeListener(listener);
725 // Resolve if the worker is being destroyed so we don't have a dangling promise.
726 onClose: () => resolve(),
729 dbg.addListener(listener);
733 type: "add-or-set-session-data-entry",
734 forwardingPrefix: workerThreadServerForwardingPrefix,