diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs | 741 |
1 files changed, 741 insertions, 0 deletions
diff --git a/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs b/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs new file mode 100644 index 0000000000..2e461cbd03 --- /dev/null +++ b/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs @@ -0,0 +1,741 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + loader: "resource://devtools/shared/loader/Loader.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + SessionDataHelpers: + "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm", +}); + +ChromeUtils.defineLazyGetter(lazy, "DevToolsUtils", () => + lazy.loader.require("devtools/shared/DevToolsUtils") +); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +export class DevToolsServiceWorkerChild extends JSProcessActorChild { + constructor() { + super(); + + // The map is indexed by the Watcher Actor ID. + // The values are objects containing the following properties: + // - connection: the DevToolsServerConnection itself + // - workers: An array of object containing the following properties: + // - dbg: A WorkerDebuggerInstance + // - serviceWorkerTargetForm: The associated worker target instance form + // - workerThreadServerForwardingPrefix: The prefix used to forward events to the + // worker target on the worker thread (). + // - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate + // between content and parent processes. + // - sessionData: Data (targets, resources, …) the watcher wants to be notified about. + // See WatcherRegistry.getSessionData to see the full list of properties. + this._connections = new Map(); + + this._onConnectionChange = this._onConnectionChange.bind(this); + + EventEmitter.decorate(this); + } + + /** + * Called by nsIWorkerDebuggerManager when a worker get created. + * + * Go through all registered connections (in case we have more than one client connected) + * to eventually instantiate a target actor for this worker. + * + * @param {nsIWorkerDebugger} dbg + */ + _onWorkerRegistered(dbg) { + // Only consider service workers + if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) { + return; + } + + for (const [ + watcherActorID, + { connection, forwardingPrefix, sessionData }, + ] of this._connections) { + if (this._shouldHandleWorker(sessionData, dbg)) { + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }); + } + } + } + + /** + * Called by nsIWorkerDebuggerManager when a worker get destroyed. + * + * Go through all registered connections (in case we have more than one client connected) + * to destroy the related target which may have been created for this worker. + * + * @param {nsIWorkerDebugger} dbg + */ + _onWorkerUnregistered(dbg) { + // Only consider service workers + if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) { + return; + } + + for (const [watcherActorID, watcherConnectionData] of this._connections) { + this._destroyServiceWorkerTargetForWatcher( + watcherActorID, + watcherConnectionData, + dbg + ); + } + } + + /** + * To be called when we know a Service Worker target should be destroyed for a specific connection + * for which we pass the related "watcher connection data". + * + * @param {String} watcherActorID + * Watcher actor ID for which we should unregister this service worker. + * @param {Object} watcherConnectionData + * The metadata object for a given watcher, stored in the _connections Map. + * @param {nsIWorkerDebugger} dbg + */ + _destroyServiceWorkerTargetForWatcher( + watcherActorID, + watcherConnectionData, + dbg + ) { + const { workers, forwardingPrefix } = watcherConnectionData; + + // Check if the worker registration was handled for this watcher. + const unregisteredActorIndex = workers.findIndex(worker => { + try { + // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED). + return worker.dbg.id === dbg.id; + } catch (e) { + return false; + } + }); + + // Ignore this worker if it wasn't registered for this watcher. + if (unregisteredActorIndex === -1) { + return; + } + + const { serviceWorkerTargetForm, transport } = + workers[unregisteredActorIndex]; + + // Remove the entry from this._connection dictionnary + workers.splice(unregisteredActorIndex, 1); + + // Close the transport made against the worker thread. + transport.close(); + + // Note that we do not need to post the "disconnect" message from this destruction codepath + // as this method is only called when the worker is unregistered and so, + // we can't send any message anyway, and the worker is being destroyed anyway. + + // Also notify the parent process that this worker target got destroyed. + // As the worker thread may be already destroyed, it may not have time to send a destroy event. + try { + this.sendAsyncMessage( + "DevToolsServiceWorkerChild:serviceWorkerTargetDestroyed", + { + watcherActorID, + forwardingPrefix, + serviceWorkerTargetForm, + } + ); + } catch (e) { + // Ignore exception which may happen on content process destruction + } + } + + /** + * Function handling messages sent by DevToolsServiceWorkerParent (part of ProcessActor API). + * + * @param {Object} message + * @param {String} message.name + * @param {*} message.data + */ + receiveMessage(message) { + switch (message.name) { + case "DevToolsServiceWorkerParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, sessionData } = message.data; + return this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + case "DevToolsServiceWorkerParent:destroy": { + const { watcherActorID } = message.data; + return this._destroyTargetActors(watcherActorID); + } + case "DevToolsServiceWorkerParent:addOrSetSessionDataEntry": { + const { watcherActorID, type, entries, updateType } = message.data; + return this._addOrSetSessionDataEntry( + watcherActorID, + type, + entries, + updateType + ); + } + case "DevToolsServiceWorkerParent:removeSessionDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._removeSessionDataEntry(watcherActorID, type, entries); + } + case "DevToolsServiceWorkerParent:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsServiceWorkerParent: " + message.name + ); + } + } + + /** + * "chrome-event-target-created" event handler. Supposed to be fired very early when the process starts + */ + observe() { + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!sessionDataByWatcherActor) { + throw new Error( + "Request to instantiate the target(s) for the Service Worker, but `sharedData` is empty about watched targets" + ); + } + + // Create one Target actor for each prefix/client which listen to workers + for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { + const { targets, connectionPrefix } = sessionData; + if (targets?.includes("service_worker")) { + this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + } + } + + /** + * Instantiate targets for existing workers, watch for worker registration and listen + * for resources on those workers, for given connection and context. Targets are sent + * to the DevToolsServiceWorkerParent via the DevToolsServiceWorkerChild:serviceWorkerTargetAvailable message. + * + * @param {Object} options + * @param {String} options.watcherActorID: The ID of the WatcherActor who requested to + * observe and create these target actors. + * @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection + * of the Watcher Actor. This is used to compute a unique ID for the target actor. + * @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants + * to be notified about. See WatcherRegistry.getSessionData to see the full list + * of properties. + */ + async _watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix, + sessionData, + }) { + // We might already have been called from observe method if the process was initializing + if (this._connections.has(watcherActorID)) { + // In such case, wait for the promise in order to ensure resolving only after + // we notified about the existing targets + await this._connections.get(watcherActorID).watchPromise; + return; + } + + // Compute a unique prefix, just for this Service Worker, + // which will be used to create a JSWindowActorTransport pair between content and parent processes. + // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, + // but here, we can't have access to any DevTools connection as we are really early in the content process startup + // WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe? + // (this.manager == WindowGlobalChild interface) + const forwardingPrefix = + parentConnectionPrefix + "serviceWorkerProcess" + this.manager.childID; + + const connection = this._createConnection(forwardingPrefix); + + // This method will be concurrently called from `observe()` and `DevToolsServiceWorkerParent:instantiate-already-available` + // When the JSprocessActor initializes itself and when the watcher want to force instantiating existing targets. + // Wait for the existing promise when the second call arise. + // + // Also, _connections has to be populated *before* calling _createWorkerTargetActor, + // so create a deferred promise right away. + let resolveWatchPromise; + const watchPromise = new Promise( + resolve => (resolveWatchPromise = resolve) + ); + + this._connections.set(watcherActorID, { + connection, + watchPromise, + workers: [], + forwardingPrefix, + sessionData, + }); + + // Listen for new workers that will be spawned. + if (!this._workerDebuggerListener) { + this._workerDebuggerListener = { + onRegister: this._onWorkerRegistered.bind(this), + onUnregister: this._onWorkerUnregistered.bind(this), + }; + lazy.wdm.addListener(this._workerDebuggerListener); + } + + const promises = []; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + if (!this._shouldHandleWorker(sessionData, dbg)) { + continue; + } + promises.push( + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) + ); + } + await Promise.all(promises); + resolveWatchPromise(); + } + + /** + * Initialize a DevTools Server and return a new DevToolsServerConnection + * using this server in order to communicate to the parent process via + * the JSProcessActor message / queries. + * + * @param String forwardingPrefix + * A unique prefix used to distinguish message coming from distinct service workers. + * @return DevToolsServerConnection + * A connection to communicate with the parent process. + */ + _createConnection(forwardingPrefix) { + const { DevToolsServer } = lazy.loader.require( + "devtools/server/devtools-server" + ); + + DevToolsServer.init(); + + // We want a special server without any root actor and only target-scoped actors. + // We are going to spawn a WorkerTargetActor instance in the next few lines, + // it is going to act like a root actor without being one. + DevToolsServer.registerActors({ target: true }); + DevToolsServer.on("connectionchange", this._onConnectionChange); + + const connection = DevToolsServer.connectToParentWindowActor( + this, + forwardingPrefix + ); + + return connection; + } + + /** + * Indicates whether or not we should handle the worker debugger for a given + * watcher's session data. + * + * @param {Object} sessionData + * The session data for a given watcher, which includes metadata + * about the debugged context. + * @param {WorkerDebugger} dbg + * The worker debugger we want to check. + * + * @returns {Boolean} + */ + _shouldHandleWorker(sessionData, dbg) { + if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) { + return false; + } + // We only want to create targets for non-closed service worker + if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + return false; + } + + // Accessing `nsIPrincipal.host` may easily throw on non-http URLs. + // Ignore all non-HTTP as they most likely don't have any valid host name. + if (!dbg.principal.scheme.startsWith("http")) { + return false; + } + + const workerHost = dbg.principal.hostPort; + return workerHost == sessionData["browser-element-host"][0]; + } + + async _createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) { + // Freeze the worker execution as soon as possible in order to wait for DevTools bootstrap. + // We typically want to: + // - startup the Thread Actor, + // - pass the initial session data which includes breakpoints to the worker thread, + // - register the breakpoints, + // before release its execution. + // `connectToWorker` is going to call setDebuggerReady(true) when all of this is done. + try { + dbg.setDebuggerReady(false); + } catch (e) { + // This call will throw if the debugger is already "registered" + // (i.e. if this is called outside of the register listener) + // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66 + } + + const watcherConnectionData = this._connections.get(watcherActorID); + const { sessionData } = watcherConnectionData; + const workerThreadServerForwardingPrefix = connection.allocID( + "serviceWorkerTarget" + ); + + // Create the actual worker target actor, in the worker thread. + const { connectToWorker } = lazy.loader.require( + "devtools/server/connectors/worker-connector" + ); + + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + sessionData, + sessionContext: sessionData.sessionContext, + } + ); + + try { + await onConnectToWorker; + } catch (e) { + // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution. + // But if anything goes wrong and an exception is thrown, ensure releasing its execution, + // otherwise if devtools is broken, it will freeze the worker indefinitely. + // + // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to + // resume the debugger if it is not closed (otherwise it can cause crashes). + if (!dbg.isClosed) { + dbg.setDebuggerReady(true); + } + return; + } + + const { workerTargetForm, transport } = await onConnectToWorker; + + try { + this.sendAsyncMessage( + "DevToolsServiceWorkerChild:serviceWorkerTargetAvailable", + { + watcherActorID, + forwardingPrefix, + serviceWorkerTargetForm: workerTargetForm, + } + ); + } catch (e) { + // If there was an error while sending the message, we are not going to use this + // connection to communicate with the worker. + transport.close(); + return; + } + + // Only add data to the connection if we successfully send the + // serviceWorkerTargetAvailable message. + watcherConnectionData.workers.push({ + dbg, + transport, + serviceWorkerTargetForm: workerTargetForm, + workerThreadServerForwardingPrefix, + }); + } + + /** + * Request the service worker threads to destroy all their service worker Targets currently registered for a given Watcher actor. + * + * @param {String} watcherActorID + */ + _destroyTargetActors(watcherActorID) { + const watcherConnectionData = this._connections.get(watcherActorID); + this._connections.delete(watcherActorID); + + // This connection has already been cleaned? + if (!watcherConnectionData) { + console.error( + `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` + ); + return; + } + + for (const { + dbg, + transport, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + try { + if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + dbg.postMessage( + JSON.stringify({ + type: "disconnect", + forwardingPrefix: workerThreadServerForwardingPrefix, + }) + ); + } + } catch (e) {} + + transport.close(); + } + + watcherConnectionData.connection.close(); + } + + /** + * Destroy the server once its last connection closes. Note that multiple + * worker scripts may be running in parallel and reuse the same server. + */ + _onConnectionChange() { + const { DevToolsServer } = lazy.loader.require( + "devtools/server/devtools-server" + ); + + // Only destroy the server if there is no more connections to it. It may be + // used to debug another tab running in the same process. + if (DevToolsServer.hasConnection() || DevToolsServer.keepAlive) { + return; + } + + if (this._destroyed) { + return; + } + this._destroyed = true; + + DevToolsServer.off("connectionchange", this._onConnectionChange); + DevToolsServer.destroy(); + } + + /** + * Used by DevTools transport layer to communicate with the parent process. + * + * @param {String} packet + * @param {String prefix + */ + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsServiceWorkerChild:packet", { + packet, + prefix, + }); + } + + /** + * Go through all registered service workers for a given watcher actor + * to send them new session data entries. + * + * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments. + */ + async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) { + const watcherConnectionData = this._connections.get(watcherActorID); + if (!watcherConnectionData) { + return; + } + + lazy.SessionDataHelpers.addOrSetSessionDataEntry( + watcherConnectionData.sessionData, + type, + entries, + updateType + ); + + // This type is really specific to Service Workers and doesn't need to be transferred to the worker threads. + // We only need to instantiate and destroy the target actors based on this new host. + if (type == "browser-element-host") { + this.updateBrowserElementHost(watcherActorID, watcherConnectionData); + return; + } + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + /** + * Called whenever the debugged browser element navigates to a new page + * and the URL's host changes. + * This is used to maintain the list of active Service Worker targets + * based on that host name. + * + * @param {String} watcherActorID + * Watcher actor ID for which we should unregister this service worker. + * @param {Object} watcherConnectionData + * The metadata object for a given watcher, stored in the _connections Map. + */ + async updateBrowserElementHost(watcherActorID, watcherConnectionData) { + const { sessionData, connection, forwardingPrefix } = watcherConnectionData; + + // Create target actor matching this new host. + // Note that we may be navigating to the same host name and the target will already exist. + const dbgToInstantiate = []; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + const alreadyCreated = watcherConnectionData.workers.some( + info => info.dbg === dbg + ); + if (this._shouldHandleWorker(sessionData, dbg) && !alreadyCreated) { + dbgToInstantiate.push(dbg); + } + } + await Promise.all( + dbgToInstantiate.map(dbg => { + return this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }); + }) + ); + } + + /** + * Go through all registered service workers for a given watcher actor + * to send them request to clear some session data entries. + * + * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments. + */ + _removeSessionDataEntry(watcherActorID, type, entries) { + const watcherConnectionData = this._connections.get(watcherActorID); + + if (!watcherConnectionData) { + return; + } + + lazy.SessionDataHelpers.removeSessionDataEntry( + watcherConnectionData.sessionData, + type, + entries + ); + + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + dbg.postMessage( + JSON.stringify({ + type: "remove-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + }) + ); + } + } + } + + _removeExistingWorkerDebuggerListener() { + if (this._workerDebuggerListener) { + lazy.wdm.removeListener(this._workerDebuggerListener); + this._workerDebuggerListener = null; + } + } + + /** + * Part of JSActor API + * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52 + * + * > The didDestroy method, if present, will be called after the actor is no + * > longer able to receive any more messages. + */ + didDestroy() { + this._removeExistingWorkerDebuggerListener(); + + for (const [watcherActorID, watcherConnectionData] of this._connections) { + const { connection } = watcherConnectionData; + this._destroyTargetActors(watcherActorID); + + connection.close(); + } + + this._connections.clear(); + } +} + +/** + * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger. + * + * @param {WorkerDebugger} dbg + * @param {String} workerThreadServerForwardingPrefix + * @param {String} type + * Session data type name + * @param {Array} entries + * Session data entries to add or set. + * @param {String} updateType + * Either "add" or "set", to control if we should only add some items, + * or replace the whole data set with the new entries. + * @returns {Promise} Returns a Promise that resolves once the data entry were handled + * by the worker target. + */ +function addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, +}) { + if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + return Promise.resolve(); + } + + return new Promise(resolve => { + // Wait until we're notified by the worker that the resources are watched. + // This is important so we know existing resources were handled. + const listener = { + onMessage: message => { + message = JSON.parse(message); + if (message.type === "session-data-entry-added-or-set") { + resolve(); + dbg.removeListener(listener); + } + }, + // Resolve if the worker is being destroyed so we don't have a dangling promise. + onClose: () => resolve(), + }; + + dbg.addListener(listener); + + dbg.postMessage( + JSON.stringify({ + type: "add-or-set-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + updateType, + }) + ); + }); +} |