diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs | 571 |
1 files changed, 571 insertions, 0 deletions
diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs new file mode 100644 index 0000000000..6bbe4140c3 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs @@ -0,0 +1,571 @@ +/* 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 = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +ChromeUtils.defineLazyGetter(lazy, "Loader", () => + ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs") +); + +ChromeUtils.defineLazyGetter(lazy, "DevToolsUtils", () => + lazy.Loader.require("resource://devtools/shared/DevToolsUtils.js") +); +XPCOMUtils.defineLazyModuleGetters(lazy, { + SessionDataHelpers: + "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm", +}); +ChromeUtils.defineESModuleGetters(lazy, { + isWindowGlobalPartOfContext: + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", +}); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +export class DevToolsWorkerChild extends JSWindowActorChild { + 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 + // - workerTargetForm: 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(); + + EventEmitter.decorate(this); + } + + _onWorkerRegistered(dbg) { + if (!this._shouldHandleWorker(dbg)) { + return; + } + + for (const [watcherActorID, { connection, forwardingPrefix }] of this + ._connections) { + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }); + } + } + + _onWorkerUnregistered(dbg) { + for (const [watcherActorID, { workers, forwardingPrefix }] of this + ._connections) { + // Check if the worker registration was handled for this watcherActorID. + 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; + } + }); + if (unregisteredActorIndex === -1) { + continue; + } + + const { workerTargetForm, transport } = workers[unregisteredActorIndex]; + transport.close(); + + try { + this.sendAsyncMessage("DevToolsWorkerChild:workerTargetDestroyed", { + watcherActorID, + forwardingPrefix, + workerTargetForm, + }); + } catch (e) { + return; + } + + workers.splice(unregisteredActorIndex, 1); + } + } + + onDOMWindowCreated() { + 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 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, sessionContext } = sessionData; + if ( + targets?.includes("worker") && + lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, { + acceptInitialDocument: true, + forceAcceptTopLevelTarget: true, + acceptSameProcessIframes: true, + }) + ) { + this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + } + } + + /** + * Function handling messages sent by DevToolsWorkerParent (part of JSWindowActor API). + * + * @param {Object} message + * @param {String} message.name + * @param {*} message.data + */ + receiveMessage(message) { + // All messages pass `sessionContext` (except packet) and are expected + // to match isWindowGlobalPartOfContext result. + if (message.name != "DevToolsWorkerParent:packet") { + const { browserId } = message.data.sessionContext; + // Re-check here, just to ensure that both parent and content processes agree + // on what should or should not be watched. + if ( + this.manager.browsingContext.browserId != browserId && + !lazy.isWindowGlobalPartOfContext( + this.manager, + message.data.sessionContext, + { + acceptInitialDocument: true, + } + ) + ) { + throw new Error( + "Mismatch between DevToolsWorkerParent and DevToolsWorkerChild " + + (this.manager.browsingContext.browserId == browserId + ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)" + : `expected browsing context with ID ${browserId}, but got ${this.manager.browsingContext.browserId}`) + ); + } + } + + switch (message.name) { + case "DevToolsWorkerParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, sessionData } = message.data; + + return this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + case "DevToolsWorkerParent:destroy": { + const { watcherActorID } = message.data; + return this._destroyTargetActors(watcherActorID); + } + case "DevToolsWorkerParent:addOrSetSessionDataEntry": { + const { watcherActorID, type, entries, updateType } = message.data; + return this._addOrSetSessionDataEntry( + watcherActorID, + type, + entries, + updateType + ); + } + case "DevToolsWorkerParent:removeSessionDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._removeSessionDataEntry(watcherActorID, type, entries); + } + case "DevToolsWorkerParent:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsWorkerParent: " + message.name + ); + } + } + + /** + * 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 DevToolsWorkerParent via the DevToolsWorkerChild:workerTargetAvailable 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, + }) { + if (this._connections.has(watcherActorID)) { + throw new Error( + "DevToolsWorkerChild _watchWorkerTargets was called more than once" + + ` for the same Watcher (Actor ID: "${watcherActorID}")` + ); + } + + // 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); + } + + // Compute a unique prefix, just for this WindowGlobal, + // 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 + "workerGlobal" + this.manager.innerWindowId; + + const connection = this._createConnection(forwardingPrefix); + + this._connections.set(watcherActorID, { + connection, + workers: [], + forwardingPrefix, + sessionData, + }); + + const promises = []; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + if (!this._shouldHandleWorker(dbg)) { + continue; + } + promises.push( + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) + ); + } + await Promise.all(promises); + } + + _createConnection(forwardingPrefix) { + const { DevToolsServer } = lazy.Loader.require( + "resource://devtools/server/devtools-server.js" + ); + + 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 }); + + const connection = DevToolsServer.connectToParentWindowActor( + this, + forwardingPrefix + ); + + return connection; + } + + /** + * Indicates whether or not we should handle the worker debugger + * + * @param {WorkerDebugger} dbg: The worker debugger we want to check. + * @returns {Boolean} + */ + _shouldHandleWorker(dbg) { + // We only want to create targets for non-closed dedicated worker, in the same document + return ( + lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg) && + dbg.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED && + dbg.windowIDs.includes(this.manager.innerWindowId) + ); + } + + async _createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) { + // Prevent the debuggee from executing in this worker until the client has + // finished attaching to it. 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 + try { + dbg.setDebuggerReady(false); + } catch (e) {} + + const watcherConnectionData = this._connections.get(watcherActorID); + const { sessionData } = watcherConnectionData; + const workerThreadServerForwardingPrefix = + connection.allocID("workerTarget"); + + // Create the actual worker target actor, in the worker thread. + const { connectToWorker } = lazy.Loader.require( + "resource://devtools/server/connectors/worker-connector.js" + ); + + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + sessionData, + sessionContext: sessionData.sessionContext, + } + ); + + try { + await onConnectToWorker; + } catch (e) { + // 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("DevToolsWorkerChild:workerTargetAvailable", { + watcherActorID, + forwardingPrefix, + 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 + // workerTargetAvailable message. + watcherConnectionData.workers.push({ + dbg, + transport, + workerTargetForm, + workerThreadServerForwardingPrefix, + }); + } + + _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(); + } + + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsWorkerChild:packet", { + packet, + prefix, + }); + } + + async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) { + const watcherConnectionData = this._connections.get(watcherActorID); + if (!watcherConnectionData) { + return; + } + + lazy.SessionDataHelpers.addOrSetSessionDataEntry( + watcherConnectionData.sessionData, + type, + entries, + updateType + ); + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + _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, + }) + ); + } + } + } + + handleEvent({ type }) { + // DOMWindowCreated is registered from the WatcherRegistry via `ActorManagerParent.addJSWindowActors` + // as a DOM event to be listened to and so is fired by JSWindowActor platform code. + if (type == "DOMWindowCreated") { + this.onDOMWindowCreated(); + } + } + + _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, + }) + ); + }); +} |