diff options
Diffstat (limited to 'devtools/server/actors/watcher')
8 files changed, 1251 insertions, 0 deletions
diff --git a/devtools/server/actors/watcher/WatchedDataHelpers.jsm b/devtools/server/actors/watcher/WatchedDataHelpers.jsm new file mode 100644 index 0000000000..9acd5a612c --- /dev/null +++ b/devtools/server/actors/watcher/WatchedDataHelpers.jsm @@ -0,0 +1,124 @@ +/* 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"; + +/** + * Helper module alongside WatcherRegistry, which focus on updating the "watchedData" object. + * This object is shared across processes and threads and have to be maintained in all these runtimes. + */ + +var EXPORTED_SYMBOLS = ["WatchedDataHelpers"]; + +// List of all arrays stored in `watchedData`, which are replicated across processes and threads +const SUPPORTED_DATA = { + BREAKPOINTS: "breakpoints", + RESOURCES: "resources", + TARGETS: "targets", +}; + +// Optional function, if data isn't a primitive data type in order to produce a key +// for the given data entry +const DATA_KEY_FUNCTION = { + [SUPPORTED_DATA.BREAKPOINTS]: function({ + location: { sourceUrl, sourceId, line, column }, + }) { + if (!sourceUrl && !sourceId) { + throw new Error( + `Breakpoints expect to have either a sourceUrl or a sourceId.` + ); + } + if (sourceUrl && typeof sourceUrl != "string") { + throw new Error( + `Breakpoints expect to have sourceUrl string, got ${typeof sourceUrl} instead.` + ); + } + // sourceId may be undefined for some sources keyed by URL + if (sourceId && typeof sourceId != "string") { + throw new Error( + `Breakpoints expect to have sourceId string, got ${typeof sourceId} instead.` + ); + } + if (typeof line != "number") { + throw new Error( + `Breakpoints expect to have line number, got ${typeof line} instead.` + ); + } + if (typeof column != "number") { + throw new Error( + `Breakpoints expect to have column number, got ${typeof column} instead.` + ); + } + return `${sourceUrl}:${sourceId}:${line}:${column}`; + }, +}; + +function idFunction(v) { + if (typeof v != "string") { + throw new Error( + `Expect data entry values to be string, or be using custom data key functions. Got ${typeof v} type instead.` + ); + } + return v; +} + +const WatchedDataHelpers = { + SUPPORTED_DATA, + + /** + * Add new values to the shared "watchedData" object. + * + * @param Object watchedData + * The data object to update. + * @param string type + * The type of data to be added + * @param Array<Object> entries + * The values to be added to this type of data + */ + addWatchedDataEntry(watchedData, type, entries) { + const toBeAdded = []; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + const alreadyExists = watchedData[type].some(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }); + if (!alreadyExists) { + toBeAdded.push(entry); + } + } + watchedData[type].push(...toBeAdded); + }, + + /** + * Remove values from the shared "watchedData" object. + * + * @param Object watchedData + * The data object to update. + * @param string type + * The type of data to be remove + * @param Array<Object> entries + * The values to be removed from this type of data + * @return Boolean + * True, if at least one entries existed and has been removed. + * False, if none of the entries existed and none has been removed. + */ + removeWatchedDataEntry(watchedData, type, entries) { + let includesAtLeastOne = false; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + const idx = watchedData[type].findIndex(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }); + if (idx !== -1) { + watchedData[type].splice(idx, 1); + includesAtLeastOne = true; + } + } + if (!includesAtLeastOne) { + return false; + } + + return true; + }, +}; diff --git a/devtools/server/actors/watcher/WatcherRegistry.jsm b/devtools/server/actors/watcher/WatcherRegistry.jsm new file mode 100644 index 0000000000..f8fc2aed49 --- /dev/null +++ b/devtools/server/actors/watcher/WatcherRegistry.jsm @@ -0,0 +1,346 @@ +/* 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"; + +/** + * Helper module around `sharedData` object that helps storing the state + * of all observed Targets and Resources, that, for all DevTools connections. + * Here is a few words about the C++ implementation of sharedData: + * https://searchfox.org/mozilla-central/rev/bc3600def806859c31b2c7ac06e3d69271052a89/dom/ipc/SharedMap.h#30-55 + * + * We may have more than one DevToolsServer and one server may have more than one + * client. This module will be the single source of truth in the parent process, + * in order to know which targets/resources are currently observed. It will also + * be used to declare when something starts/stops being observed. + * + * `sharedData` is a platform API that helps sharing JS Objects across processes. + * We use it in order to communicate to the content process which targets and resources + * should be observed. Content processes read this data only once, as soon as they are created. + * It isn't used beyond this point. Content processes are not going to update it. + * We will notify about changes in observed targets and resources for already running + * processes by some other means. (Via JS Window Actor queries "DevTools:(un)watch(Resources|Target)") + * This means that only this module will update the "DevTools:watchedPerWatcher" value. + * From the parent process, we should be going through this module to fetch the data, + * while from the content process, we will read `sharedData` directly. + */ + +var EXPORTED_SYMBOLS = ["WatcherRegistry"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { ActorManagerParent } = ChromeUtils.import( + "resource://gre/modules/ActorManagerParent.jsm" +); +const { WatchedDataHelpers } = ChromeUtils.import( + "resource://devtools/server/actors/watcher/WatchedDataHelpers.jsm" +); +const { SUPPORTED_DATA } = WatchedDataHelpers; + +// Define the Map that will be saved in `sharedData`. +// It is keyed by WatcherActor ID and values contains following attributes: +// - targets: Set of strings, refering to target types to be listened to +// - resources: Set of strings, refering to resource types to be observed +// - browserId: Optional, if set, restrict the observation to one specific Browser Element tree. +// It can be a tab, a top-level window or a top-level iframe (e.g. special privileged iframe) +// See https://searchfox.org/mozilla-central/rev/31d8600b73dc85b4cdbabf45ac3f1a9c11700d8e/dom/chrome-webidl/BrowsingContext.webidl#114-121 +// for more information. +// - connectionPrefix: The DevToolsConnection prefix of the watcher actor. Used to compute new actor ID in the content processes. +// +// Unfortunately, `sharedData` is subject to race condition and may have side effect +// when read/written from multiple places in the same process, +// which is why this map should be considered as the single source of truth. +const watchedDataByWatcherActor = new Map(); + +// In parallel to the previous map, keep all the WatcherActor keyed by the same WatcherActor ID, +// the WatcherActor ID. We don't (and can't) propagate the WatcherActor instances to the content +// processes, but still would like to match them by their ID. +const watcherActors = new Map(); + +// Name of the attribute into which we save this Map in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +/** + * Use `sharedData` to allow processes, early during their creation, + * to know which resources should be listened to. This will be read + * from the Target actor, when it gets created early during process start, + * in order to start listening to the expected resource types. + */ +function persistMapToSharedData() { + Services.ppmm.sharedData.set(SHARED_DATA_KEY_NAME, watchedDataByWatcherActor); + // Request to immediately flush the data to the content processes in order to prevent + // races (bug 1644649). Otherwise content process may have outdated sharedData + // and try to create targets for Watcher actor that already stopped watching for targets. + Services.ppmm.sharedData.flush(); +} + +const WatcherRegistry = { + /** + * Tells if a given watcher currently watches for a given target type. + * + * @param WatcherActor watcher + * The WatcherActor which should be listening. + * @param string targetType + * The new target type to query. + * @return boolean + * Returns true if already watching. + */ + isWatchingTargets(watcher, targetType) { + const watchedData = this.getWatchedData(watcher); + return watchedData && watchedData.targets.includes(targetType); + }, + + /** + * Retrieve the data saved into `sharedData` that is used to know + * about which type of targets and resources we care listening about. + * `watchedDataByWatcherActor` is saved into `sharedData` after each mutation, + * but `watchedDataByWatcherActor` is the source of truth. + * + * @param WatcherActor watcher + * The related WatcherActor which starts/stops observing. + * @param object options (optional) + * A dictionary object with `createData` boolean attribute. + * If this attribute is set to true, we create the data structure in the Map + * if none exists for this prefix. + */ + getWatchedData(watcher, { createData = false } = {}) { + // Use WatcherActor ID as a key as we may have multiple clients willing to watch for targets. + // For example, a Browser Toolbox debugging everything and a Content Toolbox debugging + // just one tab. We might also have multiple watchers, on the same connection when using about:debugging. + const watcherActorID = watcher.actorID; + let watchedData = watchedDataByWatcherActor.get(watcherActorID); + if (!watchedData && createData) { + watchedData = { + // The Browser ID will be helpful to identify which BrowsingContext should be considered + // when running code in the content process. Browser ID, compared to BrowsingContext ID won't change + // if we navigate to the parent process or if a new BrowsingContext is used for the <browser> element + // we are currently inspecting. + browserId: watcher.browserId, + // The DevToolsServerConnection prefix will be used to compute actor IDs created in the content process + connectionPrefix: watcher.conn.prefix, + }; + // Define empty default array for all data + for (const name of Object.values(SUPPORTED_DATA)) { + watchedData[name] = []; + } + watchedDataByWatcherActor.set(watcherActorID, watchedData); + watcherActors.set(watcherActorID, watcher); + } + return watchedData; + }, + + /** + * Given a Watcher Actor ID, return the related Watcher Actor instance. + * + * @param String actorID + * The Watcher Actor ID to search for. + * @return WatcherActor + * The Watcher Actor instance. + */ + getWatcher(actorID) { + return watcherActors.get(actorID); + }, + + /** + * Notify that a given watcher added an entry in a given data type. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param string type + * The type of data to be added + * @param Array<Object> entries + * The values to be added to this type of data + */ + addWatcherDataEntry(watcher, type, entries) { + const watchedData = this.getWatchedData(watcher, { + createData: true, + }); + + if (!(type in watchedData)) { + throw new Error(`Unsupported watcher data type: ${type}`); + } + + WatchedDataHelpers.addWatchedDataEntry(watchedData, type, entries); + + // Register the JS Window Actor the first time we start watching for something (e.g. resource, target, …). + registerJSWindowActor(); + + persistMapToSharedData(); + }, + + /** + * Notify that a given watcher removed an entry in a given data type. + * + * See `addWatcherDataEntry` for argument definition. + * + * @return boolean + * True if we such entry was already registered, for this watcher actor. + */ + removeWatcherDataEntry(watcher, type, entries) { + const watchedData = this.getWatchedData(watcher); + if (!watchedData) { + return false; + } + + if (!(type in watchedData)) { + throw new Error(`Unsupported watcher data type: ${type}`); + } + + if ( + !WatchedDataHelpers.removeWatchedDataEntry(watchedData, type, entries) + ) { + return false; + } + + const isWatchingSomething = Object.values(SUPPORTED_DATA).some( + dataType => watchedData[dataType].length > 0 + ); + if (!isWatchingSomething) { + watchedDataByWatcherActor.delete(watcher.actorID); + watcherActors.delete(watcher.actorID); + } + + persistMapToSharedData(); + + return true; + }, + + /** + * Cleanup everything about a given watcher actor. + * Remove it from any registry so that we stop interacting with it. + * + * The watcher would be automatically unregistered from removeWatcherEntry, + * if we remove all entries. But we aren't removing all breakpoints. + * So here, we force clearing any reference to the watcher actor when it destroys. + */ + unregisterWatcher(watcher) { + watchedDataByWatcherActor.delete(watcher.actorID); + watcherActors.delete(watcher.actorID); + }, + + /** + * Notify that a given watcher starts observing a new target type. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param string targetType + * The new target type to start listening to. + */ + watchTargets(watcher, targetType) { + this.addWatcherDataEntry(watcher, SUPPORTED_DATA.TARGETS, [targetType]); + }, + + /** + * Notify that a given watcher stops observing a given target type. + * + * See `watchTargets` for argument definition. + * + * @return boolean + * True if we were watching for this target type, for this watcher actor. + */ + unwatchTargets(watcher, targetType) { + return this.removeWatcherDataEntry(watcher, SUPPORTED_DATA.TARGETS, [ + targetType, + ]); + }, + + /** + * Notify that a given watcher starts observing new resource types. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param Array<string> resourceTypes + * The new resource types to start listening to. + */ + watchResources(watcher, resourceTypes) { + this.addWatcherDataEntry(watcher, SUPPORTED_DATA.RESOURCES, resourceTypes); + }, + + /** + * Notify that a given watcher stops observing given resource types. + * + * See `watchResources` for argument definition. + * + * @return boolean + * True if we were watching for this resource type, for this watcher actor. + */ + unwatchResources(watcher, resourceTypes) { + return this.removeWatcherDataEntry( + watcher, + SUPPORTED_DATA.RESOURCES, + resourceTypes + ); + }, + + /** + * Unregister the JS Window Actor if there is no more DevTools code observing any target/resource. + */ + maybeUnregisteringJSWindowActor() { + if (watchedDataByWatcherActor.size == 0) { + unregisterJSWindowActor(); + } + }, +}; + +// Boolean flag to know if the DevToolsFrame JS Window Actor is currently registered +let isJSWindowActorRegistered = false; + +/** + * Register the JSWindowActor pair "DevToolsFrame". + * + * We should call this method before we try to use this JS Window Actor from the parent process + * (via `WindowGlobal.getActor("DevToolsFrame")` or `WindowGlobal.getActor("DevToolsWorker")`). + * Also, registering it will automatically force spawing the content process JSWindow Actor + * anytime a new document is opened (via DOMWindowCreated event). + */ + +const JSWindowActorsConfig = { + DevToolsFrame: { + parent: { + moduleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameParent.jsm", + }, + child: { + moduleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameChild.jsm", + events: { + DOMWindowCreated: {}, + }, + }, + allFrames: true, + }, + DevToolsWorker: { + parent: { + moduleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerParent.jsm", + }, + child: { + moduleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerChild.jsm", + events: { + DOMWindowCreated: {}, + }, + }, + allFrames: true, + }, +}; + +function registerJSWindowActor() { + if (isJSWindowActorRegistered) { + return; + } + isJSWindowActorRegistered = true; + ActorManagerParent.addJSWindowActors(JSWindowActorsConfig); +} + +function unregisterJSWindowActor() { + if (!isJSWindowActorRegistered) { + return; + } + isJSWindowActorRegistered = false; + + for (const JSWindowActorName of Object.keys(JSWindowActorsConfig)) { + // ActorManagerParent doesn't expose a "removeActors" method, but it would be equivalent to that: + ChromeUtils.unregisterWindowActor(JSWindowActorName); + } +} diff --git a/devtools/server/actors/watcher/moz.build b/devtools/server/actors/watcher/moz.build new file mode 100644 index 0000000000..227184cf3b --- /dev/null +++ b/devtools/server/actors/watcher/moz.build @@ -0,0 +1,14 @@ +# -*- 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 += [ + "target-helpers", +] + +DevToolsModules( + "WatchedDataHelpers.jsm", + "WatcherRegistry.jsm", +) diff --git a/devtools/server/actors/watcher/target-helpers/frame-helper.js b/devtools/server/actors/watcher/target-helpers/frame-helper.js new file mode 100644 index 0000000000..612febab9e --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/frame-helper.js @@ -0,0 +1,204 @@ +/* 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 { + WatcherRegistry, +} = require("devtools/server/actors/watcher/WatcherRegistry.jsm"); +const { + WindowGlobalLogger, +} = require("devtools/server/connectors/js-window-actor/WindowGlobalLogger.jsm"); +const Targets = require("devtools/server/actors/targets/index"); +const { + getAllRemoteBrowsingContexts, + shouldNotifyWindowGlobal, +} = require("devtools/server/actors/watcher/target-helpers/utils.js"); + +/** + * Force creating targets for all existing BrowsingContext, that, for a given Watcher Actor. + * + * @param WatcherActor watcher + * The Watcher Actor requesting to watch for new targets. + */ +async function createTargets(watcher) { + // Go over all existing BrowsingContext in order to: + // - Force the instantiation of a DevToolsFrameChild + // - Have the DevToolsFrameChild to spawn the BrowsingContextTargetActor + const browsingContexts = getFilteredRemoteBrowsingContext( + watcher.browserElement + ); + const promises = []; + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + // Await for the query in order to try to resolve only *after* we received these + // already available targets. + const promise = browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .instantiateTarget({ + watcherActorID: watcher.actorID, + connectionPrefix: watcher.conn.prefix, + browserId: watcher.browserId, + watchedData: watcher.watchedData, + }); + promises.push(promise); + } + return Promise.all(promises); +} + +/** + * Force destroying all BrowsingContext targets which were related to a given watcher. + * + * @param WatcherActor watcher + * The Watcher Actor requesting to stop watching for new targets. + */ +function destroyTargets(watcher) { + // Go over all existing BrowsingContext in order to destroy all targets + const browsingContexts = getFilteredRemoteBrowsingContext( + watcher.browserElement + ); + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .destroyTarget({ + watcherActorID: watcher.actorID, + browserId: watcher.browserId, + }); + } +} + +/** + * Go over all existing BrowsingContext in order to communicate about new data entries + * + * @param WatcherActor watcher + * The Watcher Actor requesting to stop watching for new targets. + * @param string type + * The type of data to be added + * @param Array<Object> entries + * The values to be added to this type of data + */ +async function addWatcherDataEntry({ watcher, type, entries }) { + const browsingContexts = getWatchingBrowsingContexts(watcher); + const promises = []; + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + const promise = browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .addWatcherDataEntry({ + watcherActorID: watcher.actorID, + browserId: watcher.browserId, + type, + entries, + }); + promises.push(promise); + } + // Await for the queries in order to try to resolve only *after* the remote code processed the new data + return Promise.all(promises); +} + +/** + * Notify all existing frame targets that some data entries have been removed + * + * See addWatcherDataEntry for argument documentation. + */ +function removeWatcherDataEntry({ watcher, type, entries }) { + const browsingContexts = getWatchingBrowsingContexts(watcher); + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .removeWatcherDataEntry({ + watcherActorID: watcher.actorID, + browserId: watcher.browserId, + type, + entries, + }); + } +} + +module.exports = { + createTargets, + destroyTargets, + addWatcherDataEntry, + removeWatcherDataEntry, +}; + +/** + * Return the list of BrowsingContexts which should be targeted in order to communicate + * a new list of resource types to listen or stop listening to. + * + * @param WatcherActor watcher + * The watcher actor will be used to know which target we debug + * and what BrowsingContext should be considered. + */ +function getWatchingBrowsingContexts(watcher) { + // If we are watching for additional frame targets, it means that fission mode is enabled, + // either for a content toolbox or a BrowserToolbox via devtools.browsertoolbox.fission pref. + const watchingAdditionalTargets = WatcherRegistry.isWatchingTargets( + watcher, + Targets.TYPES.FRAME + ); + const { browserElement } = watcher; + const browsingContexts = watchingAdditionalTargets + ? getFilteredRemoteBrowsingContext(browserElement) + : []; + // Even if we aren't watching additional target, we want to process the top level target. + // The top level target isn't returned by getFilteredRemoteBrowsingContext, so add it in both cases. + if (browserElement) { + const topBrowsingContext = browserElement.browsingContext; + // Ignore if we are against a page running in the parent process, + // which would not support JSWindowActor API + // XXX May be we should toggle `includeChrome` and ensure watch/unwatch works + // with such page? + if (topBrowsingContext.currentWindowGlobal.osPid != -1) { + browsingContexts.push(topBrowsingContext); + } + } + return browsingContexts; +} + +/** + * Get the list of all BrowsingContext we should interact with. + * The precise condition of which BrowsingContext we should interact with are defined + * in `shouldNotifyWindowGlobal` + * + * @param BrowserElement browserElement (optional) + * If defined, this will restrict to only the Browsing Context matching this + * Browser Element and any of its (nested) children iframes. + */ +function getFilteredRemoteBrowsingContext(browserElement) { + return getAllRemoteBrowsingContexts( + browserElement?.browsingContext + ).filter(browsingContext => + shouldNotifyWindowGlobal(browsingContext, browserElement?.browserId) + ); +} + +// Set to true to log info about about WindowGlobal's being watched. +const DEBUG = false; + +function logWindowGlobal(windowGlobal, message) { + if (!DEBUG) { + return; + } + + WindowGlobalLogger.logWindowGlobal(windowGlobal, message); +} diff --git a/devtools/server/actors/watcher/target-helpers/moz.build b/devtools/server/actors/watcher/target-helpers/moz.build new file mode 100644 index 0000000000..92413d1f52 --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/moz.build @@ -0,0 +1,12 @@ +# -*- 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( + "frame-helper.js", + "process-helper.js", + "utils.js", + "worker-helper.js", +) diff --git a/devtools/server/actors/watcher/target-helpers/process-helper.js b/devtools/server/actors/watcher/target-helpers/process-helper.js new file mode 100644 index 0000000000..6a3e76f818 --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/process-helper.js @@ -0,0 +1,281 @@ +/* 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 Services = require("Services"); +const { + WatcherRegistry, +} = require("devtools/server/actors/watcher/WatcherRegistry.jsm"); + +loader.lazyRequireGetter( + this, + "ChildDebuggerTransport", + "devtools/shared/transport/child-transport", + true +); + +const CONTENT_PROCESS_SCRIPT = + "resource://devtools/server/startup/content-process-script.js"; + +/** + * Map a MessageManager key to an Array of ContentProcessTargetActor "description" objects. + * A single MessageManager might be linked to several ContentProcessTargetActors if there are several + * Watcher actors instantiated on the DevToolsServer, via a single connection (in theory), but rather + * via distinct connections (ex: a content toolbox and the browser toolbox). + * Note that if we spawn two DevToolsServer, this module will be instantiated twice. + * + * Each ContentProcessTargetActor "description" object is structured as follows + * - {Object} actor: form of the content process target actor + * - {String} prefix: forwarding prefix used to redirect all packet to the right content process's transport + * - {ChildDebuggerTransport} childTransport: Transport forwarding all packets to the target's content process + * - {WatcherActor} watcher: The Watcher actor for which we instantiated this content process target actor + */ +const actors = new WeakMap(); + +// Save the list of all watcher actors that are watching for processes +const watchers = new Set(); + +function onContentProcessActorCreated(msg) { + const { watcherActorID, prefix, actor } = msg.data; + const watcher = WatcherRegistry.getWatcher(watcherActorID); + if (!watcher) { + throw new Error( + `Receiving a content process actor without a watcher actor ${watcherActorID}` + ); + } + // Ignore watchers of other connections. + // We may have two browser toolbox connected to the same process. + // This will spawn two distinct Watcher actor and two distinct process target helper module. + // Avoid processing the event many times, otherwise we will notify about the same target + // multiple times. + if (!watchers.has(watcher)) { + return; + } + const messageManager = msg.target; + const connection = watcher.conn; + + // Pipe Debugger message from/to parent/child via the message manager + const childTransport = new ChildDebuggerTransport(messageManager, prefix); + childTransport.hooks = { + onPacket: connection.send.bind(connection), + }; + childTransport.ready(); + + connection.setForwarding(prefix, childTransport); + + const list = actors.get(messageManager) || []; + list.push({ + prefix, + childTransport, + actor, + watcher, + }); + actors.set(messageManager, list); + + watcher.notifyTargetAvailable(actor); +} + +function onMessageManagerClose(messageManager, topic, data) { + const list = actors.get(messageManager); + if (!list || list.length == 0) { + return; + } + for (const { prefix, childTransport, actor, watcher } of list) { + watcher.notifyTargetDestroyed(actor); + + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + childTransport.close(); + watcher.conn.cancelForwarding(prefix); + } + actors.delete(messageManager); +} + +function closeWatcherTransports(watcher) { + for (let i = 0; i < Services.ppmm.childCount; i++) { + const messageManager = Services.ppmm.getChildAt(i); + let list = actors.get(messageManager); + if (!list || list.length == 0) { + continue; + } + list = list.filter(item => item.watcher != watcher); + for (const item of list) { + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + item.childTransport.close(); + watcher.conn.cancelForwarding(item.prefix); + } + if (list.length == 0) { + actors.delete(messageManager); + } else { + actors.set(messageManager, list); + } + } +} + +function maybeRegisterMessageListeners(watcher) { + const sizeBefore = watchers.size; + watchers.add(watcher); + if (sizeBefore == 0 && watchers.size == 1) { + Services.ppmm.addMessageListener( + "debug:content-process-actor", + onContentProcessActorCreated + ); + Services.obs.addObserver(onMessageManagerClose, "message-manager-close"); + + // Load the content process server startup script only once, + // otherwise it will be evaluated twice, listen to events twice and create + // target actors twice. + // We may try to load it twice when opening one Browser Toolbox via about:debugging + // and another regular Browser Toolbox. Both will spawn a WatcherActor and watch for processes. + const isContentProcessScripLoaded = Services.ppmm + .getDelayedProcessScripts() + .some(([uri]) => uri === CONTENT_PROCESS_SCRIPT); + if (!isContentProcessScripLoaded) { + Services.ppmm.loadProcessScript(CONTENT_PROCESS_SCRIPT, true); + } + } +} +function maybeUnregisterMessageListeners(watcher) { + const sizeBefore = watchers.size; + watchers.delete(watcher); + closeWatcherTransports(watcher); + + if (sizeBefore == 1 && watchers.size == 0) { + Services.ppmm.removeMessageListener( + "debug:content-process-actor", + onContentProcessActorCreated + ); + Services.obs.removeObserver(onMessageManagerClose, "message-manager-close"); + + // We inconditionally remove the process script, while we should only remove it + // once the last DevToolsServer stop watching for processes. + // We might have many server, using distinct loaders, so that this module + // will be spawn many times and we should remove the script only once the last + // module unregister the last watcher of all. + Services.ppmm.removeDelayedProcessScript(CONTENT_PROCESS_SCRIPT); + + Services.ppmm.broadcastAsyncMessage("debug:destroy-process-script"); + } +} + +async function createTargets(watcher) { + // XXX: Should this move to WatcherRegistry?? + maybeRegisterMessageListeners(watcher); + + // Bug 1648499: This could be simplified when migrating to JSProcessActor by using sendQuery. + // For now, hack into WatcherActor in order to know when we created one target + // actor for each existing content process. + // Also, we substract one as the parent process has a message manager and is counted + // in `childCount`, but we ignore it from the process script and it won't reply. + const contentProcessCount = Services.ppmm.childCount - 1; + if (contentProcessCount == 0) { + return; + } + const onTargetsCreated = new Promise(resolve => { + let receivedTargetCount = 0; + const listener = () => { + if (++receivedTargetCount == contentProcessCount) { + watcher.off("target-available-form", listener); + resolve(); + } + }; + watcher.on("target-available-form", listener); + }); + + Services.ppmm.broadcastAsyncMessage("debug:instantiate-already-available", { + watcherActorID: watcher.actorID, + connectionPrefix: watcher.conn.prefix, + watchedData: watcher.watchedData, + }); + + await onTargetsCreated; +} + +function destroyTargets(watcher) { + maybeUnregisterMessageListeners(watcher); + + Services.ppmm.broadcastAsyncMessage("debug:destroy-target", { + watcherActorID: watcher.actorID, + }); +} + +/** + * Go over all existing content processes in order to communicate about new data entries + * + * @param {Object} options + * @param {WatcherActor} options.watcher + * The Watcher Actor providing new data entries + * @param {string} options.type + * The type of data to be added + * @param {Array<Object>} options.entries + * The values to be added to this type of data + */ +async function addWatcherDataEntry({ watcher, type, entries }) { + let expectedCount = Services.ppmm.childCount - 1; + if (expectedCount == 0) { + return; + } + const onAllReplied = new Promise(resolve => { + let count = 0; + const listener = msg => { + if (msg.data.watcherActorID != watcher.actorID) { + return; + } + count++; + maybeResolve(); + }; + Services.ppmm.addMessageListener( + "debug:add-watcher-data-entry-done", + listener + ); + const onContentProcessClosed = (messageManager, topic, data) => { + expectedCount--; + maybeResolve(); + }; + const maybeResolve = () => { + if (count == expectedCount) { + Services.ppmm.removeMessageListener( + "debug:add-watcher-data-entry-done", + listener + ); + Services.obs.removeObserver( + onContentProcessClosed, + "message-manager-close" + ); + resolve(); + } + }; + Services.obs.addObserver(onContentProcessClosed, "message-manager-close"); + }); + + Services.ppmm.broadcastAsyncMessage("debug:add-watcher-data-entry", { + watcherActorID: watcher.actorID, + type, + entries, + }); + + await onAllReplied; +} + +/** + * Notify all existing content processes that some data entries have been removed + * + * See addWatcherDataEntry for argument documentation. + */ +function removeWatcherDataEntry({ watcher, type, entries }) { + Services.ppmm.broadcastAsyncMessage("debug:remove-watcher-data-entry", { + watcherActorID: watcher.actorID, + type, + entries, + }); +} + +module.exports = { + createTargets, + destroyTargets, + addWatcherDataEntry, + removeWatcherDataEntry, +}; diff --git a/devtools/server/actors/watcher/target-helpers/utils.js b/devtools/server/actors/watcher/target-helpers/utils.js new file mode 100644 index 0000000000..f59bbceed3 --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/utils.js @@ -0,0 +1,126 @@ +/* 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 Services = require("Services"); + +/** + * Helper function to know if a given WindowGlobal should be exposed via watchTargets API + * XXX: We probably want to share this function with DevToolsFrameChild, + * but may be not, it looks like the checks are really differents because WindowGlobalParent and WindowGlobalChild + * expose very different attributes. (WindowGlobalChild exposes much less!) + * + * @param {BrowsingContext} browsingContext: The browsing context we want to check the window global for + * @param {String} watchedBrowserId + * @param {Object} options + * @param {Boolean} options.acceptNonRemoteFrame: Set to true to not restrict to remote frame only + */ +function shouldNotifyWindowGlobal( + browsingContext, + watchedBrowserId, + options = {} +) { + const windowGlobal = browsingContext.currentWindowGlobal; + // Loading or destroying BrowsingContext won't have any associated WindowGlobal. + // Ignore them. They should be either handled via DOMWindowCreated event or JSWindowActor destroy + if (!windowGlobal) { + return false; + } + // Ignore extension for now as attaching to them is special. + if (browsingContext.currentRemoteType == "extension") { + return false; + } + // Ignore globals running in the parent process for now as they won't be in a distinct process anyway. + // And JSWindowActor will most likely only be created if we toggle includeChrome + // on the JSWindowActor registration. + if (windowGlobal.osPid == -1 && windowGlobal.isInProcess) { + return false; + } + // Ignore about:blank which are quickly replaced and destroyed by the final URI + // bug 1625026 aims at removing this workaround and allow debugging any about:blank load + if ( + windowGlobal.documentURI && + windowGlobal.documentURI.spec == "about:blank" + ) { + return false; + } + + if (watchedBrowserId && browsingContext.browserId != watchedBrowserId) { + return false; + } + + if (options.acceptNonRemoteFrame) { + return true; + } + + // If `acceptNonRemoteFrame` options isn't true, only mention the "remote frames". + // i.e. the frames which are in a distinct process compared to their parent document + return ( + !browsingContext.parent || + windowGlobal.osPid != browsingContext.parent.currentWindowGlobal.osPid + ); +} + +/** + * Get all the BrowsingContexts. + * + * Really all of them: + * - For all the privileged windows (browser.xhtml, browser console, ...) + * - For all chrome *and* content contexts (privileged windows, as well as <browser> elements and their inner content documents) + * - For all nested browsing context. We fetch the contexts recursively. + * + * @param BrowsingContext topBrowsingContext (optional) + * If defined, this will restrict to this Browsing Context only + * and any of its (nested) children. + */ +function getAllRemoteBrowsingContexts(topBrowsingContext) { + const browsingContexts = []; + + // For a given BrowsingContext, add the `browsingContext` + // all of its children, that, recursively. + function walk(browsingContext) { + if (browsingContexts.includes(browsingContext)) { + return; + } + browsingContexts.push(browsingContext); + + for (const child of browsingContext.children) { + walk(child); + } + + if (browsingContext.window) { + // If the document is in the parent process, also iterate over each <browser>'s browsing context. + // BrowsingContext.children doesn't cross chrome to content boundaries, + // so we have to cross these boundaries by ourself. + for (const browser of browsingContext.window.document.querySelectorAll( + `browser[remote="true"]` + )) { + walk(browser.browsingContext); + } + } + } + + // If a Browsing Context is passed, only walk through the given BrowsingContext + if (topBrowsingContext) { + walk(topBrowsingContext); + // Remove the top level browsing context we just added by calling walk. + browsingContexts.shift(); + } else { + // Fetch all top level window's browsing contexts + // Note that getWindowEnumerator works from all processes, including the content process. + for (const window of Services.ww.getWindowEnumerator()) { + if (window.docShell.browsingContext) { + walk(window.docShell.browsingContext); + } + } + } + + return browsingContexts; +} + +module.exports = { + getAllRemoteBrowsingContexts, + shouldNotifyWindowGlobal, +}; diff --git a/devtools/server/actors/watcher/target-helpers/worker-helper.js b/devtools/server/actors/watcher/target-helpers/worker-helper.js new file mode 100644 index 0000000000..1f0fa00b44 --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/worker-helper.js @@ -0,0 +1,144 @@ +/* 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 { + getAllRemoteBrowsingContexts, + shouldNotifyWindowGlobal, +} = require("devtools/server/actors/watcher/target-helpers/utils.js"); + +const DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME = "DevToolsWorker"; + +/** + * Force creating targets for all existing workers for a given Watcher Actor. + * + * @param WatcherActor watcher + * The Watcher Actor requesting to watch for new targets. + */ +async function createTargets(watcher) { + // Go over all existing BrowsingContext in order to: + // - Force the instantiation of a DevToolsWorkerChild + // - Have the DevToolsWorkerChild to spawn the WorkerTargetActors + const browsingContexts = getFilteredBrowsingContext(watcher.browserElement); + const promises = []; + for (const browsingContext of browsingContexts) { + const promise = browsingContext.currentWindowGlobal + .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME) + .instantiateWorkerTargets({ + watcherActorID: watcher.actorID, + connectionPrefix: watcher.conn.prefix, + browserId: watcher.browserId, + watchedData: watcher.watchedData, + }); + promises.push(promise); + } + + // Await for the different queries in order to try to resolve only *after* we received + // the already available worker targets. + return Promise.all(promises); +} + +/** + * Force destroying all worker targets which were related to a given watcher. + * + * @param WatcherActor watcher + * The Watcher Actor requesting to stop watching for new targets. + */ +async function destroyTargets(watcher) { + // Go over all existing BrowsingContext in order to destroy all targets + const browsingContexts = getFilteredBrowsingContext(watcher.browserElement); + for (const browsingContext of browsingContexts) { + let windowActor; + try { + windowActor = browsingContext.currentWindowGlobal.getActor( + DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME + ); + } catch (e) { + continue; + } + + windowActor.destroyWorkerTargets({ + watcher, + browserId: watcher.browserId, + }); + } +} + +/** + * Go over all existing BrowsingContext in order to communicate about new data entries + * + * @param WatcherActor watcher + * The Watcher Actor requesting to stop watching for new targets. + * @param string type + * The type of data to be added + * @param Array<Object> entries + * The values to be added to this type of data + */ +async function addWatcherDataEntry({ watcher, type, entries }) { + const browsingContexts = getFilteredBrowsingContext(watcher.browserElement); + const promises = []; + for (const browsingContext of browsingContexts) { + const promise = browsingContext.currentWindowGlobal + .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME) + .addWatcherDataEntry({ + watcherActorID: watcher.actorID, + browserId: watcher.browserId, + type, + entries, + }); + promises.push(promise); + } + // Await for the queries in order to try to resolve only *after* the remote code processed the new data + return Promise.all(promises); +} + +/** + * Notify all existing frame targets that some data entries have been removed + * + * See addWatcherDataEntry for argument documentation. + */ +function removeWatcherDataEntry({ watcher, type, entries }) { + const browsingContexts = getFilteredBrowsingContext(watcher.browserElement); + for (const browsingContext of browsingContexts) { + browsingContext.currentWindowGlobal + .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME) + .removeWatcherDataEntry({ + watcherActorID: watcher.actorID, + browserId: watcher.browserId, + type, + entries, + }); + } +} + +/** + * Get the list of all BrowsingContext we should interact with. + * The precise condition of which BrowsingContext we should interact with are defined + * in `shouldNotifyWindowGlobal` + * + * @param BrowserElement browserElement (optional) + * If defined, this will restrict to only the Browsing Context matching this + * Browser Element and any of its (nested) children iframes. + */ +function getFilteredBrowsingContext(browserElement) { + const browsingContexts = getAllRemoteBrowsingContexts( + browserElement?.browsingContext + ); + if (browserElement?.browsingContext) { + browsingContexts.push(browserElement?.browsingContext); + } + return browsingContexts.filter(browsingContext => + shouldNotifyWindowGlobal(browsingContext, browserElement?.browserId, { + acceptNonRemoteFrame: true, + }) + ); +} + +module.exports = { + createTargets, + destroyTargets, + addWatcherDataEntry, + removeWatcherDataEntry, +}; |