diff options
Diffstat (limited to 'devtools/server/connectors')
10 files changed, 2258 insertions, 0 deletions
diff --git a/devtools/server/connectors/content-process-connector.js b/devtools/server/connectors/content-process-connector.js new file mode 100644 index 0000000000..7c5c0de0e1 --- /dev/null +++ b/devtools/server/connectors/content-process-connector.js @@ -0,0 +1,118 @@ +/* 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/. */ + +"use strict"; + +var Services = require("Services"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { dumpn } = DevToolsUtils; + +loader.lazyRequireGetter( + this, + "ChildDebuggerTransport", + "devtools/shared/transport/child-transport", + true +); + +const CONTENT_PROCESS_SERVER_STARTUP_SCRIPT = + "resource://devtools/server/startup/content-process.js"; + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +/** + * Start a DevTools server in a content process (representing the entire process, not + * just a single frame) and add it as a child server for an active connection. + */ +function connectToContentProcess(connection, mm, onDestroy) { + return new Promise(resolve => { + const prefix = connection.allocID("content-process"); + let actor, childTransport; + + mm.addMessageListener("debug:content-process-actor", function listener( + msg + ) { + // Ignore actors being created by a Watcher actor, + // they will be handled by devtools/server/watcher/target-helpers/process.js + if (msg.watcherActorID) { + return; + } + mm.removeMessageListener("debug:content-process-actor", listener); + + // Pipe Debugger message from/to parent/child via the message manager + childTransport = new ChildDebuggerTransport(mm, prefix); + childTransport.hooks = { + onPacket: connection.send.bind(connection), + }; + childTransport.ready(); + + connection.setForwarding(prefix, childTransport); + + dumpn(`Start forwarding for process with prefix ${prefix}`); + + actor = msg.json.actor; + + resolve(actor); + }); + + // Load the content process server startup script only once. + const isContentProcessServerStartupScripLoaded = Services.ppmm + .getDelayedProcessScripts() + .some(([uri]) => uri === CONTENT_PROCESS_SERVER_STARTUP_SCRIPT); + if (!isContentProcessServerStartupScripLoaded) { + // Load the process script that will receive the debug:init-content-server message + Services.ppmm.loadProcessScript( + CONTENT_PROCESS_SERVER_STARTUP_SCRIPT, + true + ); + } + + // Send a message to the content process server startup script to forward it the + // prefix. + mm.sendAsyncMessage("debug:init-content-server", { + prefix: prefix, + }); + + function onClose() { + Services.obs.removeObserver( + onMessageManagerClose, + "message-manager-close" + ); + EventEmitter.off(connection, "closed", onClose); + if (childTransport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + childTransport.close(); + childTransport = null; + connection.cancelForwarding(prefix); + + // ... and notify the child process to clean the target-scoped actors. + try { + mm.sendAsyncMessage("debug:content-process-disconnect", { prefix }); + } catch (e) { + // Nothing to do + } + } + + if (onDestroy) { + onDestroy(mm); + } + } + + const onMessageManagerClose = DevToolsUtils.makeInfallible( + (subject, topic, data) => { + if (subject == mm) { + // Send the "tabDetached" event before closing the connection which + // will destroy fronts on the client. + connection.send({ from: actor.actor, type: "tabDetached" }); + onClose(); + } + } + ); + Services.obs.addObserver(onMessageManagerClose, "message-manager-close"); + + EventEmitter.on(connection, "closed", onClose); + }); +} + +exports.connectToContentProcess = connectToContentProcess; diff --git a/devtools/server/connectors/frame-connector.js b/devtools/server/connectors/frame-connector.js new file mode 100644 index 0000000000..6b88845fe0 --- /dev/null +++ b/devtools/server/connectors/frame-connector.js @@ -0,0 +1,276 @@ +/* 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/. */ + +"use strict"; + +var Services = require("Services"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { dumpn } = DevToolsUtils; +const { Pool } = require("devtools/shared/protocol/Pool"); + +loader.lazyRequireGetter( + this, + "DevToolsServer", + "devtools/server/devtools-server", + true +); +loader.lazyRequireGetter( + this, + "ChildDebuggerTransport", + "devtools/shared/transport/child-transport", + true +); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +/** + * Start a DevTools server in a remote frame's process and add it as a child server for + * an active connection. + * + * @param object connection + * The devtools server connection to use. + * @param Element frame + * The frame element with remote content to connect to. + * @param function [onDestroy] + * Optional function to invoke when the child process closes or the connection + * shuts down. (Need to forget about the related target actor.) + * @return object + * A promise object that is resolved once the connection is established. + */ +function connectToFrame(connection, frame, onDestroy, { addonId } = {}) { + return new Promise(resolve => { + // Get messageManager from XUL browser (which might be a specialized tunnel for RDM) + // or else fallback to asking the frameLoader itself. + const mm = frame.messageManager || frame.frameLoader.messageManager; + mm.loadFrameScript("resource://devtools/server/startup/frame.js", false); + + const spawnInParentActorPool = new Pool( + connection, + "connectToFrame-spawnInParent" + ); + connection.addActor(spawnInParentActorPool); + + const trackMessageManager = () => { + mm.addMessageListener("debug:setup-in-parent", onSetupInParent); + mm.addMessageListener( + "debug:spawn-actor-in-parent", + onSpawnActorInParent + ); + if (!actor) { + mm.addMessageListener("debug:actor", onActorCreated); + } + }; + + const untrackMessageManager = () => { + mm.removeMessageListener("debug:setup-in-parent", onSetupInParent); + mm.removeMessageListener( + "debug:spawn-actor-in-parent", + onSpawnActorInParent + ); + if (!actor) { + mm.removeMessageListener("debug:actor", onActorCreated); + } + }; + + let actor, childTransport; + const prefix = connection.allocID("child"); + // Compute the same prefix that's used by DevToolsServerConnection + const connPrefix = prefix + "/"; + + // provides hook to actor modules that need to exchange messages + // between e10s parent and child processes + const parentModules = []; + const onSetupInParent = function(msg) { + // We may have multiple connectToFrame instance running for the same frame and + // need to filter the messages. + if (msg.json.prefix != connPrefix) { + return false; + } + + const { module, setupParent } = msg.json; + let m; + + try { + m = require(module); + + if (!(setupParent in m)) { + dumpn(`ERROR: module '${module}' does not export '${setupParent}'`); + return false; + } + + parentModules.push(m[setupParent]({ mm, prefix: connPrefix })); + + return true; + } catch (e) { + const errorMessage = + "Exception during actor module setup running in the parent process: "; + DevToolsUtils.reportException(errorMessage + e); + dumpn( + `ERROR: ${errorMessage}\n\t module: '${module}'\n\t ` + + `setupParent: '${setupParent}'\n${DevToolsUtils.safeErrorString(e)}` + ); + return false; + } + }; + + const parentActors = []; + const onSpawnActorInParent = function(msg) { + // We may have multiple connectToFrame instance running for the same tab + // and need to filter the messages. + if (msg.json.prefix != connPrefix) { + return; + } + + const { module, constructor, args, spawnedByActorID } = msg.json; + let m; + + try { + m = require(module); + + if (!(constructor in m)) { + dump(`ERROR: module '${module}' does not export '${constructor}'`); + return; + } + + const Constructor = m[constructor]; + // Bind the actor to parent process connection so that these actors + // directly communicates with the client as regular actors instanciated from + // parent process + const instance = new Constructor(connection, ...args, mm); + instance.conn = connection; + instance.parentID = spawnedByActorID; + + // Manually set the actor ID in order to insert parent actorID as prefix + // in order to help identifying actor hierarchy via actor IDs. + // Remove `/` as it may confuse message forwarding between processes. + const contentPrefix = spawnedByActorID + .replace(connection.prefix, "") + .replace("/", "-"); + instance.actorID = connection.allocID( + contentPrefix + "/" + instance.typeName + ); + spawnInParentActorPool.manage(instance); + + mm.sendAsyncMessage("debug:spawn-actor-in-parent:actor", { + prefix: connPrefix, + actorID: instance.actorID, + }); + + parentActors.push(instance); + } catch (e) { + const errorMessage = + "Exception during actor module setup running in the parent process: "; + DevToolsUtils.reportException(errorMessage + e + "\n" + e.stack); + dumpn( + `ERROR: ${errorMessage}\n\t module: '${module}'\n\t ` + + `constructor: '${constructor}'\n${DevToolsUtils.safeErrorString(e)}` + ); + } + }; + + const onActorCreated = DevToolsUtils.makeInfallible(function(msg) { + if (msg.json.prefix != prefix) { + return; + } + mm.removeMessageListener("debug:actor", onActorCreated); + + // Pipe Debugger message from/to parent/child via the message manager + childTransport = new ChildDebuggerTransport(mm, prefix); + childTransport.hooks = { + // Pipe all the messages from content process actors back to the client + // through the parent process connection. + onPacket: connection.send.bind(connection), + }; + childTransport.ready(); + + connection.setForwarding(prefix, childTransport); + + dumpn(`Start forwarding for frame with prefix ${prefix}`); + + actor = msg.json.actor; + resolve(actor); + }); + + const destroy = DevToolsUtils.makeInfallible(function() { + EventEmitter.off(connection, "closed", destroy); + Services.obs.removeObserver( + onMessageManagerClose, + "message-manager-close" + ); + + // provides hook to actor modules that need to exchange messages + // between e10s parent and child processes + parentModules.forEach(mod => { + if (mod.onDisconnected) { + mod.onDisconnected(); + } + }); + // TODO: Remove this deprecated path once it's no longer needed by add-ons. + DevToolsServer.emit("disconnected-from-child:" + connPrefix, { + mm, + prefix: connPrefix, + }); + + // Destroy all actors created via spawnActorInParentProcess + // in case content wasn't able to destroy them via a message + spawnInParentActorPool.destroy(); + + if (actor) { + // The FrameTargetActor within the child process doesn't necessary + // have time to uninitialize itself when the frame is closed/killed. + // So ensure telling the client that the related actor is detached. + connection.send({ from: actor.actor, type: "tabDetached" }); + actor = null; + } + + if (childTransport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + childTransport.close(); + childTransport = null; + connection.cancelForwarding(prefix); + + // ... and notify the child process to clean the target-scoped actors. + try { + // Bug 1169643: Ignore any exception as the child process + // may already be destroyed by now. + mm.sendAsyncMessage("debug:disconnect", { prefix }); + } catch (e) { + // Nothing to do + } + } else { + // Otherwise, the frame has been closed before the actor + // had a chance to be created, so we are not able to create + // the actor. + resolve(null); + } + + if (onDestroy) { + onDestroy(mm); + } + + // Cleanup all listeners + untrackMessageManager(); + }); + + // Listen for various messages and frame events + trackMessageManager(); + + // Listen for app process exit + const onMessageManagerClose = function(subject, topic, data) { + if (subject == mm) { + destroy(); + } + }; + Services.obs.addObserver(onMessageManagerClose, "message-manager-close"); + + // Listen for connection close to cleanup things + // when user unplug the device or we lose the connection somehow. + EventEmitter.on(connection, "closed", destroy); + + mm.sendAsyncMessage("debug:connect", { prefix, addonId }); + }); +} + +exports.connectToFrame = connectToFrame; diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameChild.jsm b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.jsm new file mode 100644 index 0000000000..cb285484c2 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.jsm @@ -0,0 +1,443 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DevToolsFrameChild"]; + +const { EventEmitter } = ChromeUtils.import( + "resource://gre/modules/EventEmitter.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const Loader = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + TargetActorRegistry: + "resource://devtools/server/actors/targets/target-actor-registry.jsm", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + WindowGlobalLogger: + "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.jsm", +}); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +// If true, log info about WindowGlobal's being created. +const DEBUG = false; + +/** + * Helper function to know if a given WindowGlobal should be exposed via watchTargets("frame") API + */ +function shouldNotifyWindowGlobal(windowGlobal, watchedBrowserId) { + const browsingContext = windowGlobal.browsingContext; + + // Ignore about:blank loads, which spawn a document that never finishes loading + // and would require somewhat useless Target and all its related overload. + const window = Services.wm.getCurrentInnerWindowWithId( + windowGlobal.innerWindowId + ); + + // For some unknown reason, the print preview of PDFs generates an about:blank + // document, which, on the parent process, has windowGlobal.documentURI.spec + // set to the pdf's URL. So that Frame target helper accepts this WindowGlobal + // and instantiates a target for it. + // Which is great as this is a valuable document to debug. + // But in the content process, this ends up being an about:blank document, and + // hasLoadedNonBlankURI is always false. Nonetheless, this isn't a real empty + // about:blank. We end up loading resource://pdf.js/web/viewer.html. + // But `window.location` is set to about:blank, while `document.documentURI` + // is set to the pretty printed PDF... + // So we end up checking the documentURI in order to see if that's a special + // not-really-blank about:blank document! + if ( + !window.docShell.hasLoadedNonBlankURI && + window.document?.documentURI === "about:blank" + ) { + return false; + } + + // If we are focusing only on a sub-tree of Browsing Element, + // Ignore the out of the sub tree elements. + if (watchedBrowserId && browsingContext.browserId != watchedBrowserId) { + return false; + } + + // For now, we only mention the "remote frames". + // i.e. the frames which are in a distinct process compared to their parent document + // If there is no parent, this is most likely the top level document. + // Ignore it only if this is the top level target we are watching. + // For now we don't expect a target to be created, but we will as TabDescriptors arise. + if ( + !browsingContext.parent && + browsingContext.browserId == watchedBrowserId + ) { + return false; + } + + // `isInProcess` is always false, even if the window runs in the same process. + // `osPid` attribute is not set on WindowGlobalChild + // so it is hard to guess if the given WindowGlobal runs in this process or not, + // which is what we want to know here. Here is a workaround way to know it :/ + // --- + // Also. It might be a bit surprising to have a DevToolsFrameChild/JSWindowActorChild + // to be instantiated for WindowGlobals that aren't from this process... Is that expected? + if (Cu.isRemoteProxy(windowGlobal.window)) { + return false; + } + + // When Fission is turned off, we still process here the iframes that are running in the + // same process. + // As we can't use isInProcess, nor osPid (see previous block), we have + // to fallback to other checks. Here we check if we are able to access the parent document's window. + // If we can, it means that it runs in the same process as the current iframe we are processing. + if ( + browsingContext.parent && + browsingContext.parent.window && + !Cu.isRemoteProxy(browsingContext.parent.window) + ) { + return false; + } + + return true; +} + +function logWindowGlobal(windowGlobal, message) { + if (!DEBUG) { + return; + } + WindowGlobalLogger.logWindowGlobal(windowGlobal, message); +} + +class DevToolsFrameChild 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 + // - actor: the FrameTargetActor instance + this._connections = new Map(); + + this._onConnectionChange = this._onConnectionChange.bind(this); + EventEmitter.decorate(this); + } + + instantiate() { + const { sharedData } = Services.cpmm; + const watchedDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!watchedDataByWatcherActor) { + throw new Error( + "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets" + ); + } + + // Create one Target actor for each prefix/client which listen to frames + for (const [watcherActorID, watchedData] of watchedDataByWatcherActor) { + const { connectionPrefix, browserId } = watchedData; + if ( + watchedData.targets.includes("frame") && + shouldNotifyWindowGlobal(this.manager, browserId) + ) { + this._createTargetActor(watcherActorID, connectionPrefix, watchedData); + } + } + } + + /** + * Instantiate a new WindowGlobalTarget for the given connection. + * + * @param String watcherActorID + * The ID of the WatcherActor who requested to observe and create these target actors. + * @param String parentConnectionPrefix + * The prefix of the DevToolsServerConnection of the Watcher Actor. + * This is used to compute a unique ID for the target actor. + * @param Object initialData + * All data managed by the Watcher Actor and WatcherRegistry.jsm, containing + * target types, resources types to be listened as well as breakpoints and any + * other data meant to be shared across processes and threads. + */ + _createTargetActor(watcherActorID, parentConnectionPrefix, initialData) { + if (this._connections.get(watcherActorID)) { + throw new Error( + "DevToolsFrameChild _createTargetActor was called more than once" + + ` for the same Watcher (Actor ID: "${watcherActorID}")` + ); + } + + // 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 + // XXX: WindowGlobal's innerWindowId should be unique across processes, I think. So that should be safe? + // (this.manager == WindowGlobalChild interface) + const forwardingPrefix = + parentConnectionPrefix + "windowGlobal" + this.manager.innerWindowId; + + logWindowGlobal( + this.manager, + "Instantiate WindowGlobalTarget with prefix: " + forwardingPrefix + ); + + const { connection, targetActor } = this._createConnectionAndActor( + forwardingPrefix + ); + this._connections.set(watcherActorID, { + connection, + actor: targetActor, + }); + + // Pass initialization data to the target actor + for (const type in initialData) { + targetActor.addWatcherDataEntry(type, initialData[type]); + } + + // Immediately queue a message for the parent process, + // in order to ensure that the JSWindowActorTransport is instantiated + // before any packet is sent from the content process. + // As the order of messages is quaranteed to be delivered in the order they + // were queued, we don't have to wait for anything around this sendAsyncMessage call. + // In theory, the FrameTargetActor may emit events in its constructor. + // If it does, such RDP packets may be lost. But in practice, no events + // are emitted during its construction. Instead the frontend will start + // the communication first. + this.sendAsyncMessage("DevToolsFrameChild:connectFromContent", { + watcherActorID, + forwardingPrefix, + actor: targetActor.form(), + }); + } + + _destroyTargetActor(watcherActorID) { + const connectionInfo = this._connections.get(watcherActorID); + // This connection has already been cleaned? + if (!connectionInfo) { + throw new Error( + `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` + ); + } + connectionInfo.connection.close(); + this._connections.delete(watcherActorID); + if (this._connections.size == 0) { + this.didDestroy(); + } + } + + _createConnectionAndActor(forwardingPrefix) { + this.useCustomLoader = this.document.nodePrincipal.isSystemPrincipal; + + // When debugging chrome pages, use a new dedicated loader, using a distinct chrome compartment. + if (!this.loader) { + this.loader = this.useCustomLoader + ? new Loader.DevToolsLoader({ + invisibleToDebugger: true, + }) + : Loader; + } + const { DevToolsServer } = this.loader.require( + "devtools/server/devtools-server" + ); + + const { FrameTargetActor } = this.loader.require( + "devtools/server/actors/targets/frame" + ); + + DevToolsServer.init(); + + // We want a special server without any root actor and only target-scoped actors. + // We are going to spawn a FrameTargetActor 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 + ); + + // Create the actual target actor. + const targetActor = new FrameTargetActor(connection, this.docShell, { + followWindowGlobalLifeCycle: true, + doNotFireFrameUpdates: true, + }); + targetActor.manage(targetActor); + + return { connection, targetActor }; + } + + /** + * Destroy the server once its last connection closes. Note that multiple + * frame scripts may be running in parallel and reuse the same server. + */ + _onConnectionChange() { + const { DevToolsServer } = this.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(); + } + + /** + * Supported Queries + */ + + sendPacket(packet, prefix) { + this.sendAsyncMessage("DevToolsFrameChild:packet", { packet, prefix }); + } + + /** + * JsWindowActor API + */ + + async sendQuery(msg, args) { + try { + const res = await super.sendQuery(msg, args); + return res; + } catch (e) { + console.error("Failed to sendQuery in DevToolsFrameChild", msg); + console.error(e.toString()); + throw e; + } + } + + receiveMessage(message) { + // All messages but "packet" one pass `browserId` and are expected + // to match shouldNotifyWindowGlobal result. + if (message.name != "DevToolsFrameParent:packet") { + const { browserId } = message.data; + // 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 && + !shouldNotifyWindowGlobal(this.manager, browserId) + ) { + throw new Error( + "Mismatch between DevToolsFrameParent and DevToolsFrameChild " + + (this.manager.browsingContext.browserId == browserId + ? "window global shouldn't be notified (shouldNotifyWindowGlobal mismatch)" + : `expected browsing context with browserId ${browserId}, but got ${this.manager.browsingContext.browserId}`) + ); + } + } + switch (message.name) { + case "DevToolsFrameParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, watchedData } = message.data; + return this._createTargetActor( + watcherActorID, + connectionPrefix, + watchedData + ); + } + case "DevToolsFrameParent:destroy": { + const { watcherActorID } = message.data; + return this._destroyTargetActor(watcherActorID); + } + case "DevToolsFrameParent:addWatcherDataEntry": { + const { watcherActorID, browserId, type, entries } = message.data; + return this._addWatcherDataEntry( + watcherActorID, + browserId, + type, + entries + ); + } + case "DevToolsFrameParent:removeWatcherDataEntry": { + const { watcherActorID, browserId, type, entries } = message.data; + return this._removeWatcherDataEntry( + watcherActorID, + browserId, + type, + entries + ); + } + case "DevToolsFrameParent:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsFrameParent: " + message.name + ); + } + } + + _getTargetActorForWatcherActorID(watcherActorID, browserId) { + const connectionInfo = this._connections.get(watcherActorID); + let targetActor = connectionInfo ? connectionInfo.actor : null; + // We might not get the target actor created by DevToolsFrameChild. + // For the Tab top-level target for content toolbox, + // we are still using the "message manager connector", + // so that they keep working across navigation. + // We will surely remove all of this. "Message manager connector", and + // this special codepath once we are ready to make the top level target to + // be destroyed on navigations. See bug 1602748 for more context. + if (!targetActor && this.manager.browsingContext.browserId == browserId) { + // Ensure retrieving the one target actor related to this connection. + // This allows to distinguish actors created for various toolboxes. + // For ex, regular toolbox versus browser console versus browser toolbox + const connectionPrefix = watcherActorID.replace(/watcher\d+$/, ""); + targetActor = TargetActorRegistry.getTargetActor( + browserId, + connectionPrefix + ); + } + return targetActor; + } + + _addWatcherDataEntry(watcherActorID, browserId, type, entries) { + const targetActor = this._getTargetActorForWatcherActorID( + watcherActorID, + browserId + ); + if (!targetActor) { + throw new Error( + `No target actor for this Watcher Actor ID:"${watcherActorID}" / BrowserId:${browserId}` + ); + } + return targetActor.addWatcherDataEntry(type, entries); + } + + _removeWatcherDataEntry(watcherActorID, browserId, type, entries) { + const targetActor = this._getTargetActorForWatcherActorID( + watcherActorID, + browserId + ); + // By the time we are calling this, the target may already have been destroyed. + if (targetActor) { + return targetActor.removeWatcherDataEntry(type, entries); + } + return null; + } + + handleEvent({ type }) { + // DOMWindowCreated is registered from FrameWatcher via `ActorManagerParent.addJSWindowActors` + // as a DOM event to be listened to and so is fired by JS Window Actor code platform code. + if (type == "DOMWindowCreated") { + this.instantiate(); + } + } + + didDestroy() { + for (const [, connectionInfo] of this._connections) { + connectionInfo.connection.close(); + } + this._connections.clear(); + if (this.useCustomLoader) { + this.loader.destroy(); + } + } +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameParent.jsm b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.jsm new file mode 100644 index 0000000000..a89c7c4f6d --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.jsm @@ -0,0 +1,234 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DevToolsFrameParent"]; +const { loader } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +const { EventEmitter } = ChromeUtils.import( + "resource://gre/modules/EventEmitter.jsm" +); +const { WatcherRegistry } = ChromeUtils.import( + "resource://devtools/server/actors/watcher/WatcherRegistry.jsm" +); + +loader.lazyRequireGetter( + this, + "JsWindowActorTransport", + "devtools/shared/transport/js-window-actor-transport", + true +); + +class DevToolsFrameParent extends JSWindowActorParent { + constructor() { + super(); + + this._destroyed = false; + + // Map of DevToolsServerConnection's used to forward the messages from/to + // the client. The connections run in the parent process, as this code. We + // may have more than one when there is more than one client debugging the + // same frame. For example, a content toolbox and the browser toolbox. + // + // The map is indexed by the connection prefix. + // The values are objects containing the following properties: + // - actor: the frame target actor(as a form) + // - connection: the DevToolsServerConnection used to communicate with the + // frame target actor + // - prefix: the forwarding prefix used by the connection to know + // how to forward packets to the frame target + // - transport: the JsWindowActorTransport + // + // Reminder about prefixes: all DevToolsServerConnections have a `prefix` + // which can be considered as a kind of id. On top of this, parent process + // DevToolsServerConnections also have forwarding prefixes because they are + // responsible for forwarding messages to content process connections. + this._connections = new Map(); + + this._onConnectionClosed = this._onConnectionClosed.bind(this); + EventEmitter.decorate(this); + } + + /** + * Request the content process to create the Frame Target if there is one + * already available that matches the Browsing Context ID + */ + async instantiateTarget({ + watcherActorID, + connectionPrefix, + browserId, + watchedData, + }) { + try { + await this.sendQuery( + "DevToolsFrameParent:instantiate-already-available", + { + watcherActorID, + connectionPrefix, + browserId, + watchedData, + } + ); + } catch (e) { + console.warn( + "Failed to create DevTools Frame target for browsingContext", + this.browsingContext.id + ); + console.warn(e); + } + } + + destroyTarget({ watcherActorID, browserId }) { + this.sendAsyncMessage("DevToolsFrameParent:destroy", { + watcherActorID, + browserId, + }); + } + + /** + * Communicate to the content process that some data have been added. + */ + async addWatcherDataEntry({ watcherActorID, browserId, type, entries }) { + try { + await this.sendQuery("DevToolsFrameParent:addWatcherDataEntry", { + watcherActorID, + browserId, + type, + entries, + }); + } catch (e) { + console.warn( + "Failed to add watcher data entry for frame targets in browsing context", + this.browsingContext.id + ); + console.warn(e); + } + } + + /** + * Communicate to the content process that some data have been removed. + */ + removeWatcherDataEntry({ watcherActorID, browserId, type, entries }) { + this.sendAsyncMessage("DevToolsFrameParent:removeWatcherDataEntry", { + watcherActorID, + browserId, + type, + entries, + }); + } + + connectFromContent({ watcherActorID, forwardingPrefix, actor }) { + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + const connection = watcher.conn; + + connection.on("closed", this._onConnectionClosed); + + // Create a js-window-actor based transport. + const transport = new JsWindowActorTransport(this, forwardingPrefix); + transport.hooks = { + onPacket: connection.send.bind(connection), + onClosed() {}, + }; + transport.ready(); + + connection.setForwarding(forwardingPrefix, transport); + + this._connections.set(watcher.conn.prefix, { + watcher, + connection, + // This prefix is the prefix of the DevToolsServerConnection, running + // in the content process, for which we should forward packets to, based on its prefix. + // While `watcher.connection` is also a DevToolsServerConnection, but from this process, + // the parent process. It is the one receiving Client packets and the one, from which + // we should forward packets from. + forwardingPrefix, + transport, + actor, + }); + + watcher.notifyTargetAvailable(actor); + } + + _onConnectionClosed(status, prefix) { + if (this._connections.has(prefix)) { + const { connection } = this._connections.get(prefix); + this._cleanupConnection(connection); + } + } + + async _cleanupConnection(connection) { + const { forwardingPrefix, transport } = this._connections.get( + connection.prefix + ); + + connection.off("closed", this._onConnectionClosed); + if (transport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this transport. + transport.close(); + } + + connection.cancelForwarding(forwardingPrefix); + this._connections.delete(connection.prefix); + if (!this._connections.size) { + this._destroy(); + } + } + + _destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + for (const { actor, connection, watcher } of this._connections.values()) { + watcher.notifyTargetDestroyed(actor); + + // XXX: we should probably get rid of this + if (actor && connection.transport) { + // The FrameTargetActor within the child process doesn't necessary + // have time to uninitialize itself when the frame is closed/killed. + // So ensure telling the client that the related actor is detached. + connection.send({ from: actor.actor, type: "tabDetached" }); + } + + this._cleanupConnection(connection); + } + } + + /** + * Supported Queries + */ + + sendPacket(packet, prefix) { + this.sendAsyncMessage("DevToolsFrameParent:packet", { packet, prefix }); + } + + /** + * JsWindowActor API + */ + + receiveMessage(message) { + switch (message.name) { + case "DevToolsFrameChild:connectFromContent": + return this.connectFromContent(message.data); + case "DevToolsFrameChild:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsFrameParent: " + message.name + ); + } + } + + didDestroy() { + this._destroy(); + } +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.jsm b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.jsm new file mode 100644 index 0000000000..becb77a3bc --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.jsm @@ -0,0 +1,608 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DevToolsWorkerChild"]; + +const { EventEmitter } = ChromeUtils.import( + "resource://gre/modules/EventEmitter.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +XPCOMUtils.defineLazyGetter(this, "Loader", () => + ChromeUtils.import("resource://devtools/shared/Loader.jsm") +); + +XPCOMUtils.defineLazyGetter(this, "DevToolsUtils", () => + Loader.require("devtools/shared/DevToolsUtils") +); +XPCOMUtils.defineLazyModuleGetters(this, { + WatchedDataHelpers: + "resource://devtools/server/actors/watcher/WatchedDataHelpers.jsm", +}); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +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. + // - watchedData: Data (targets, resources, …) the watcher wants to be notified about. + // See WatcherRegistry.getWatchedData to see the full list of properties. + this._connections = new Map(); + + this._onConnectionChange = this._onConnectionChange.bind(this); + + 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 watchedDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!watchedDataByWatcherActor) { + 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, watchedData] of watchedDataByWatcherActor) { + const { targets, connectionPrefix, browserId } = watchedData; + if ( + targets.includes("worker") && + shouldNotifyWindowGlobal(this.manager, browserId) + ) { + this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + watchedData, + // When navigating, this code is triggered _before_ the workers living on the page + // we navigate from are terminated, which would create worker targets for them again. + // Since at this point the new document can't have any workers yet, we are going to + // ignore existing targets (i.e. the workers that belong to the previous document). + ignoreExistingTargets: true, + }); + } + } + } + + /** + * 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 `browserId` (except packet) and are expected + // to match shouldNotifyWindowGlobal result. + if (message.name != "DevToolsWorkerParent:packet") { + const { browserId } = message.data; + // 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 && + !shouldNotifyWindowGlobal(this.manager, browserId) + ) { + throw new Error( + "Mismatch between DevToolsWorkerParent and DevToolsWorkerChild " + + (this.manager.browsingContext.browserId == browserId + ? "window global shouldn't be notified (shouldNotifyWindowGlobal mismatch)" + : `expected browsing context with ID ${browserId}, but got ${this.manager.browsingContext.browserId}`) + ); + } + } + + switch (message.name) { + case "DevToolsWorkerParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, watchedData } = message.data; + + return this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + watchedData, + }); + } + case "DevToolsWorkerParent:destroy": { + const { watcherActorID } = message.data; + return this._destroyTargetActors(watcherActorID); + } + case "DevToolsWorkerParent:addWatcherDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._addWatcherDataEntry(watcherActorID, type, entries); + } + case "DevToolsWorkerParent:removeWatcherDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._removeWatcherDataEntry(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 browserId. Targets are sent + * to the DevToolsWorkerParent via the DevToolsWorkerChild:workerTargetAvailable message. + * + * @param {Object} options + * @param {Integer} 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.watchedData: Data (targets, resources, …) the watcher wants + * to be notified about. See WatcherRegistry.getWatchedData to see the full list + * of properties. + * @param {Boolean} options.ignoreExistingTargets: Set to true to not loop on existing + * workers. This is useful when this function is called at the very early stage + * of the life of a document, since workers of the previous document are still + * alive, and there's no way to filter them out. + */ + async _watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix, + ignoreExistingTargets, + watchedData, + }) { + 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), + }; + 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, + watchedData, + }); + + if (ignoreExistingTargets !== true) { + await Promise.all( + Array.from(wdm.getWorkerDebuggerEnumerator()) + .filter(dbg => this._shouldHandleWorker(dbg)) + .map(dbg => + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) + ) + ); + } + } + + _createConnection(forwardingPrefix) { + const { DevToolsServer } = 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 + * + * @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 ( + DevToolsUtils.isWorkerDebuggerAlive(dbg) && + dbg.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED && + dbg.window?.windowGlobalChild?.innerWindowId === + 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 { watchedData } = watcherConnectionData; + const workerThreadServerForwardingPrefix = connection.allocID( + "workerTarget" + ); + + // Create the actual worker target actor, in the worker thread. + const { connectToWorker } = Loader.require( + "devtools/server/connectors/worker-connector" + ); + + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + watchedData, + } + ); + + 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 (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 } = 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(); + } + + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsWorkerChild:packet", { + packet, + prefix, + }); + } + + async _addWatcherDataEntry(watcherActorID, type, entries) { + const watcherConnectionData = this._connections.get(watcherActorID); + if (!watcherConnectionData) { + return; + } + + WatchedDataHelpers.addWatchedDataEntry( + watcherConnectionData.watchedData, + type, + entries + ); + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + promises.push( + addWatcherDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + }) + ); + } + await Promise.all(promises); + } + + _removeWatcherDataEntry(watcherActorID, type, entries) { + const watcherConnectionData = this._connections.get(watcherActorID); + + if (!watcherConnectionData) { + return; + } + + WatchedDataHelpers.removeWatchedDataEntry( + watcherConnectionData.watchedData, + type, + entries + ); + + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + if (DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + dbg.postMessage( + JSON.stringify({ + type: "remove-watcher-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) { + 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(); + } +} + +/** + * Helper function to know if we should watch for workers on a given windowGlobal + */ +function shouldNotifyWindowGlobal(windowGlobal, watchedBrowserId) { + const browsingContext = windowGlobal.browsingContext; + + // If we are focusing only on a sub-tree of Browsing Element, ignore elements that are + // not part of it. + if (watchedBrowserId && browsingContext.browserId != watchedBrowserId) { + return false; + } + + // `isInProcess` is always false, even if the window runs in the same process. + // `osPid` attribute is not set on WindowGlobalChild + // so it is hard to guess if the given WindowGlobal runs in this process or not, + // which is what we want to know here. Here is a workaround way to know it :/ + // --- + // Also. It might be a bit surprising to have a DevToolsWorkerChild/JSWindowActorChild + // to be instantiated for WindowGlobals that aren't from this process... Is that expected? + if (Cu.isRemoteProxy(windowGlobal.window)) { + return false; + } + + return true; +} + +/** + * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger. + * + * @returns {Promise} Returns a Promise that resolves once the data entry were handled + * by the worker target. + */ +function addWatcherDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, +}) { + if (!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 === "watcher-data-entry-added") { + 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-watcher-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + }) + ); + }); +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.jsm b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.jsm new file mode 100644 index 0000000000..616bd6e24c --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.jsm @@ -0,0 +1,288 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DevToolsWorkerParent"]; +const { loader } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +const { EventEmitter } = ChromeUtils.import( + "resource://gre/modules/EventEmitter.jsm" +); +const { WatcherRegistry } = ChromeUtils.import( + "resource://devtools/server/actors/watcher/WatcherRegistry.jsm" +); + +loader.lazyRequireGetter( + this, + "JsWindowActorTransport", + "devtools/shared/transport/js-window-actor-transport", + true +); + +class DevToolsWorkerParent extends JSWindowActorParent { + constructor() { + super(); + + this._destroyed = false; + + // Map of DevToolsServerConnection's used to forward the messages from/to + // the client. The connections run in the parent process, as this code. We + // may have more than one when there is more than one client debugging the + // same worker. For example, a content toolbox and the browser toolbox. + // + // The map is indexed by the connection prefix, and the values are object with the + // following properties: + // - watcher: The WatcherActor + // - actors: A Map of the worker target actors form, indexed by WorkerTarget actorID + // - transport: the JsWindowActorTransport + // + // Reminder about prefixes: all DevToolsServerConnections have a `prefix` + // which can be considered as a kind of id. On top of this, parent process + // DevToolsServerConnections also have forwarding prefixes because they are + // responsible for forwarding messages to content process connections. + this._connections = new Map(); + + this._onConnectionClosed = this._onConnectionClosed.bind(this); + EventEmitter.decorate(this); + } + + /** + * Request the content process to create Worker Targets if workers matching the browserId + * are already available. + */ + async instantiateWorkerTargets({ + watcherActorID, + connectionPrefix, + browserId, + watchedData, + }) { + try { + await this.sendQuery( + "DevToolsWorkerParent:instantiate-already-available", + { + watcherActorID, + connectionPrefix, + browserId, + watchedData, + } + ); + } catch (e) { + console.warn( + "Failed to create DevTools Worker target for browsingContext", + this.browsingContext.id, + "and watcher actor id", + watcherActorID + ); + console.warn(e); + } + } + + destroyWorkerTargets({ watcher, browserId }) { + return this.sendAsyncMessage("DevToolsWorkerParent:destroy", { + watcherActorID: watcher.actorID, + browserId, + }); + } + + /** + * Communicate to the content process that some data have been added. + */ + async addWatcherDataEntry({ watcherActorID, type, entries }) { + try { + await this.sendQuery("DevToolsWorkerParent:addWatcherDataEntry", { + watcherActorID, + type, + entries, + }); + } catch (e) { + console.warn( + "Failed to add watcher data entry for worker targets in browsing context", + this.browsingContext.id, + "and watcher actor id", + watcherActorID + ); + console.warn(e); + } + } + + /** + * Communicate to the content process that some data have been removed. + */ + removeWatcherDataEntry({ watcherActorID, type, entries }) { + this.sendAsyncMessage("DevToolsWorkerParent:removeWatcherDataEntry", { + watcherActorID, + type, + entries, + }); + } + + workerTargetAvailable({ + watcherActorID, + forwardingPrefix, + workerTargetForm, + }) { + if (this._destroyed) { + return; + } + + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + + const connection = watcher.conn; + const { prefix } = connection; + if (!this._connections.has(prefix)) { + connection.on("closed", this._onConnectionClosed); + + // Create a js-window-actor based transport. + const transport = new JsWindowActorTransport(this, forwardingPrefix); + transport.hooks = { + onPacket: connection.send.bind(connection), + onClosed() {}, + }; + transport.ready(); + + connection.setForwarding(forwardingPrefix, transport); + + this._connections.set(prefix, { + watcher, + transport, + actors: new Map(), + }); + } + + const workerTargetActorId = workerTargetForm.actor; + this._connections + .get(prefix) + .actors.set(workerTargetActorId, workerTargetForm); + watcher.notifyTargetAvailable(workerTargetForm); + } + + workerTargetDestroyed({ + watcherActorID, + forwardingPrefix, + workerTargetForm, + }) { + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + + const connection = watcher.conn; + const { prefix } = connection; + if (!this._connections.has(prefix)) { + return; + } + + const workerTargetActorId = workerTargetForm.actor; + const { actors } = this._connections.get(prefix); + if (!actors.has(workerTargetActorId)) { + return; + } + + actors.delete(workerTargetActorId); + watcher.notifyTargetDestroyed(workerTargetForm); + } + + _onConnectionClosed(status, prefix) { + if (this._connections.has(prefix)) { + const { watcher } = this._connections.get(prefix); + this._cleanupConnection(watcher.conn); + } + } + + async _cleanupConnection(connection) { + if (!this._connections || !this._connections.has(connection.prefix)) { + return; + } + + const { transport } = this._connections.get(connection.prefix); + + connection.off("closed", this._onConnectionClosed); + if (transport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this transport. + connection.cancelForwarding(transport._prefix); + transport.close(); + } + + this._connections.delete(connection.prefix); + if (!this._connections.size) { + this._destroy(); + } + } + + _destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + for (const { actors, watcher } of this._connections.values()) { + for (const actor of actors.values()) { + watcher.notifyTargetDestroyed(actor); + } + + this._cleanupConnection(watcher.conn); + } + } + + /** + * 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 (JSWindow)actor is no + * > longer able to receive any more messages. + */ + didDestroy() { + this._destroy(); + } + + /** + * Supported Queries + */ + + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsWorkerParent:packet", { + packet, + prefix, + }); + } + + /** + * JsWindowActor API + */ + + async sendQuery(msg, args) { + try { + const res = await super.sendQuery(msg, args); + return res; + } catch (e) { + console.error("Failed to sendQuery in DevToolsWorkerParent", msg, e); + throw e; + } + } + + receiveMessage(message) { + switch (message.name) { + case "DevToolsWorkerChild:workerTargetAvailable": + return this.workerTargetAvailable(message.data); + case "DevToolsWorkerChild:workerTargetDestroyed": + return this.workerTargetDestroyed(message.data); + case "DevToolsWorkerChild:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsWorkerParent: " + message.name + ); + } + } +} diff --git a/devtools/server/connectors/js-window-actor/WindowGlobalLogger.jsm b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.jsm new file mode 100644 index 0000000000..a81c300a1b --- /dev/null +++ b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.jsm @@ -0,0 +1,67 @@ +/* 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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["WindowGlobalLogger"]; + +function getWindowGlobalUri(windowGlobal) { + let windowGlobalUri = ""; + + if (windowGlobal.documentURI) { + // If windowGlobal is a WindowGlobalParent documentURI should be available. + windowGlobalUri = windowGlobal.documentURI.spec; + } else if (windowGlobal.browsingContext?.window) { + // If windowGlobal is a WindowGlobalChild, this code runs in the same + // process as the document and we can directly access the window.location + // object. + windowGlobalUri = windowGlobal.browsingContext.window.location.href; + } + + return windowGlobalUri; +} + +const WindowGlobalLogger = { + /** + * This logger can run from the content or parent process, and windowGlobal + * will either be of type `WindowGlobalParent` or `WindowGlobalChild`. + * + * The interface for each type can be found in WindowGlobalActors.webidl + * (https://searchfox.org/mozilla-central/source/dom/chrome-webidl/WindowGlobalActors.webidl) + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The window global to log. See WindowGlobalActors.webidl for details + * about the types. + * @param {String} message + * A custom message that will be displayed at the beginning of the log. + */ + logWindowGlobal: function(windowGlobal, message) { + const { browsingContext } = windowGlobal; + const { parent } = browsingContext; + const windowGlobalUri = getWindowGlobalUri(windowGlobal); + + const details = []; + details.push( + "BrowsingContext.browserId: " + browsingContext.browserId, + "BrowsingContext.id: " + browsingContext.id, + "innerWindowId: " + windowGlobal.innerWindowId, + "pid: " + windowGlobal.osPid, + "isClosed: " + windowGlobal.isClosed, + "isInProcess: " + windowGlobal.isInProcess, + "isCurrentGlobal: " + windowGlobal.isCurrentGlobal, + "currentRemoteType: " + browsingContext.currentRemoteType, + "hasParent: " + (parent ? parent.id : "no"), + "uri: " + (windowGlobalUri ? windowGlobalUri : "no uri") + ); + + const header = "[WindowGlobalLogger] " + message; + + // Use a padding for multiline display. + const padding = " "; + const formattedDetails = details.map(s => padding + s); + const detailsString = formattedDetails.join("\n"); + + dump(header + "\n" + detailsString + "\n"); + }, +}; diff --git a/devtools/server/connectors/js-window-actor/moz.build b/devtools/server/connectors/js-window-actor/moz.build new file mode 100644 index 0000000000..9aeb1507cd --- /dev/null +++ b/devtools/server/connectors/js-window-actor/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "DevToolsFrameChild.jsm", + "DevToolsFrameParent.jsm", + "DevToolsWorkerChild.jsm", + "DevToolsWorkerParent.jsm", + "WindowGlobalLogger.jsm", +) diff --git a/devtools/server/connectors/moz.build b/devtools/server/connectors/moz.build new file mode 100644 index 0000000000..060f022131 --- /dev/null +++ b/devtools/server/connectors/moz.build @@ -0,0 +1,15 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "js-window-actor", +] + +DevToolsModules( + "content-process-connector.js", + "frame-connector.js", + "worker-connector.js", +) diff --git a/devtools/server/connectors/worker-connector.js b/devtools/server/connectors/worker-connector.js new file mode 100644 index 0000000000..a1ef2ffac0 --- /dev/null +++ b/devtools/server/connectors/worker-connector.js @@ -0,0 +1,196 @@ +/* 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/. */ + +"use strict"; + +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +loader.lazyRequireGetter( + this, + "MainThreadWorkerDebuggerTransport", + "devtools/shared/transport/worker-transport", + true +); + +/** + * Start a DevTools server in a worker and add it as a child server for a given active connection. + * + * @params {DevToolsConnection} connection + * @params {WorkerDebugger} dbg: The WorkerDebugger we want to create a target actor for. + * @params {String} forwardingPrefix: The prefix that will be used to forward messages + * to the DevToolsServer on the worker thread. + * @params {Object} options: An option object that will be passed with the "connect" packet. + * @params {Object} options.watchedData: The watchedData object that will be passed to the + * worker target actor. + */ +function connectToWorker(connection, dbg, forwardingPrefix, options) { + return new Promise((resolve, reject) => { + if (!DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + reject("closed"); + return; + } + + // Step 1: Ensure the worker debugger is initialized. + if (!dbg.isInitialized) { + dbg.initialize("resource://devtools/server/startup/worker.js"); + + // Create a listener for rpc requests from the worker debugger. Only do + // this once, when the worker debugger is first initialized, rather than + // for each connection. + const listener = { + onClose: () => { + dbg.removeListener(listener); + }, + + onMessage: message => { + message = JSON.parse(message); + if (message.type !== "rpc") { + if (message.type == "worker-thread-attached") { + // The thread actor has finished attaching and can hit installed + // breakpoints. Allow content to begin executing in the worker. + dbg.setDebuggerReady(true); + } + return; + } + + Promise.resolve() + .then(() => { + const method = { + fetch: DevToolsUtils.fetch, + }[message.method]; + if (!method) { + throw Error("Unknown method: " + message.method); + } + + return method.apply(undefined, message.params); + }) + .then( + value => { + dbg.postMessage( + JSON.stringify({ + type: "rpc", + result: value, + error: null, + id: message.id, + }) + ); + }, + reason => { + dbg.postMessage( + JSON.stringify({ + type: "rpc", + result: null, + error: reason, + id: message.id, + }) + ); + } + ); + }, + }; + + dbg.addListener(listener); + } + + if (!DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + reject("closed"); + return; + } + + // Step 2: Send a connect request to the worker debugger. + dbg.postMessage( + JSON.stringify({ + type: "connect", + forwardingPrefix, + options, + workerDebuggerData: { + id: dbg.id, + type: dbg.type, + url: dbg.url, + }, + }) + ); + + // Steps 3-5 are performed on the worker thread (see worker.js). + + // Step 6: Wait for a connection response from the worker debugger. + const listener = { + onClose: () => { + dbg.removeListener(listener); + + reject("closed"); + }, + + onMessage: message => { + message = JSON.parse(message); + if ( + message.type !== "connected" || + message.forwardingPrefix !== forwardingPrefix + ) { + return; + } + + // The initial connection message has been received, don't + // need to listen any longer + dbg.removeListener(listener); + + // Step 7: Create a transport for the connection to the worker. + const transport = new MainThreadWorkerDebuggerTransport( + dbg, + forwardingPrefix + ); + transport.ready(); + transport.hooks = { + onClosed: () => { + if (DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + // If the worker happens to be shutting down while we are trying + // to close the connection, there is a small interval during + // which no more runnables can be dispatched to the worker, but + // the worker debugger has not yet been closed. In that case, + // the call to postMessage below will fail. The onClosed hook on + // DebuggerTransport is not supposed to throw exceptions, so we + // need to make sure to catch these early. + try { + dbg.postMessage( + JSON.stringify({ + type: "disconnect", + forwardingPrefix, + }) + ); + } catch (e) { + // We can safely ignore these exceptions. The only time the + // call to postMessage can fail is if the worker is either + // shutting down, or has finished shutting down. In both + // cases, there is nothing to clean up, so we don't care + // whether this message arrives or not. + } + } + + connection.cancelForwarding(forwardingPrefix); + }, + + onPacket: packet => { + // Ensure that any packets received from the server on the worker + // thread are forwarded to the client on the main thread, as if + // they had been sent by the server on the main thread. + connection.send(packet); + }, + }; + + // Ensure that any packets received from the client on the main thread + // to actors on the worker thread are forwarded to the server on the + // worker thread. + connection.setForwarding(forwardingPrefix, transport); + + resolve({ + workerTargetForm: message.workerTargetForm, + transport: transport, + }); + }, + }; + dbg.addListener(listener); + }); +} + +exports.connectToWorker = connectToWorker; |