diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/actors/watcher | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/watcher')
9 files changed, 2085 insertions, 0 deletions
diff --git a/devtools/server/actors/watcher/SessionDataHelpers.jsm b/devtools/server/actors/watcher/SessionDataHelpers.jsm new file mode 100644 index 0000000000..9d448d7bc4 --- /dev/null +++ b/devtools/server/actors/watcher/SessionDataHelpers.jsm @@ -0,0 +1,206 @@ +/* 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 "sessionData" object. + * This object is shared across processes and threads and have to be maintained in all these runtimes. + */ + +var EXPORTED_SYMBOLS = ["SessionDataHelpers"]; + +const lazy = {}; + +if (typeof module == "object") { + // Allow this JSM to also be loaded as a CommonJS module + // Because this module is used from the worker thread, + // (via target-actor-mixin), and workers can't load JSMs via ChromeUtils.import. + loader.lazyRequireGetter( + lazy, + "validateBreakpointLocation", + "resource://devtools/shared/validate-breakpoint.jsm", + true + ); + + loader.lazyRequireGetter( + lazy, + "validateEventBreakpoint", + "resource://devtools/server/actors/utils/event-breakpoints.js", + true + ); +} else { + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + // Ignore the "duplicate" definitions here as this are also defined + // in the if block above. + // eslint-disable-next-line mozilla/valid-lazy + XPCOMUtils.defineLazyGetter(lazy, "validateBreakpointLocation", () => { + return ChromeUtils.import( + "resource://devtools/shared/validate-breakpoint.jsm" + ).validateBreakpointLocation; + }); + // eslint-disable-next-line mozilla/valid-lazy + XPCOMUtils.defineLazyGetter(lazy, "validateEventBreakpoint", () => { + const { loader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return loader.require( + "resource://devtools/server/actors/utils/event-breakpoints.js" + ).validateEventBreakpoint; + }); +} + +// List of all arrays stored in `sessionData`, which are replicated across processes and threads +const SUPPORTED_DATA = { + BLACKBOXING: "blackboxing", + BREAKPOINTS: "breakpoints", + XHR_BREAKPOINTS: "xhr-breakpoints", + EVENT_BREAKPOINTS: "event-breakpoints", + RESOURCES: "resources", + TARGET_CONFIGURATION: "target-configuration", + THREAD_CONFIGURATION: "thread-configuration", + 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.BLACKBOXING]({ url, range }) { + return ( + url + + (range + ? `:${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}` + : "") + ); + }, + [SUPPORTED_DATA.BREAKPOINTS]({ location }) { + lazy.validateBreakpointLocation(location); + const { sourceUrl, sourceId, line, column } = location; + return `${sourceUrl}:${sourceId}:${line}:${column}`; + }, + [SUPPORTED_DATA.TARGET_CONFIGURATION]({ key }) { + // Configuration data entries are { key, value } objects, `key` can be used + // as the unique identifier for the entry. + return key; + }, + [SUPPORTED_DATA.THREAD_CONFIGURATION]({ key }) { + // See target configuration comment + return key; + }, + [SUPPORTED_DATA.XHR_BREAKPOINTS]({ path, method }) { + if (typeof path != "string") { + throw new Error( + `XHR Breakpoints expect to have path string, got ${typeof path} instead.` + ); + } + if (typeof method != "string") { + throw new Error( + `XHR Breakpoints expect to have method string, got ${typeof method} instead.` + ); + } + return `${path}:${method}`; + }, + [SUPPORTED_DATA.EVENT_BREAKPOINTS](id) { + if (typeof id != "string") { + throw new Error( + `Event Breakpoints expect the id to be a string , got ${typeof id} instead.` + ); + } + if (!lazy.validateEventBreakpoint(id)) { + throw new Error( + `The id string should be a valid event breakpoint id, ${id} is not.` + ); + } + return id; + }, +}; + +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 SessionDataHelpers = { + SUPPORTED_DATA, + + /** + * Add new values to the shared "sessionData" object. + * + * @param Object sessionData + * 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 + */ + addSessionDataEntry(sessionData, type, entries) { + const toBeAdded = []; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + if (!sessionData[type]) { + sessionData[type] = []; + } + const existingIndex = sessionData[type].findIndex(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }); + if (existingIndex === -1) { + // New entry. + toBeAdded.push(entry); + } else { + // Existing entry, update the value. This is relevant if the data-entry + // is not a primitive data-type, and the value can change for the same + // key. + sessionData[type][existingIndex] = entry; + } + } + sessionData[type].push(...toBeAdded); + }, + + /** + * Remove values from the shared "sessionData" object. + * + * @param Object sessionData + * 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. + */ + removeSessionDataEntry(sessionData, type, entries) { + let includesAtLeastOne = false; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + const idx = sessionData[type] + ? sessionData[type].findIndex(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }) + : -1; + if (idx !== -1) { + sessionData[type].splice(idx, 1); + includesAtLeastOne = true; + } + } + if (!includesAtLeastOne) { + return false; + } + + return true; + }, +}; + +// Allow this JSM to also be loaded as a CommonJS module +// Because this module is used from the worker thread, +// (via target-actor-mixin), and workers can't load JSMs. +if (typeof module == "object") { + module.exports.SessionDataHelpers = SessionDataHelpers; +} diff --git a/devtools/server/actors/watcher/WatcherRegistry.sys.mjs b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs new file mode 100644 index 0000000000..1c8870506a --- /dev/null +++ b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs @@ -0,0 +1,379 @@ +/* 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/. */ + +/** + * 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. + */ + +import { ActorManagerParent } from "resource://gre/modules/ActorManagerParent.sys.mjs"; + +const { SessionDataHelpers } = ChromeUtils.import( + "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm" +); + +const { SUPPORTED_DATA } = SessionDataHelpers; +const SUPPORTED_DATA_TYPES = Object.values(SUPPORTED_DATA); + +// 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 +// - sessionContext Object, The Session Context to help know what is debugged. +// See devtools/server/actors/watcher/session-context.js +// - 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 sessionDataByWatcherActor = 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, sessionDataByWatcherActor); + // 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(); +} + +export 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 sessionData = this.getSessionData(watcher); + return !!sessionData?.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. + * `sessionDataByWatcherActor` is saved into `sharedData` after each mutation, + * but `sessionDataByWatcherActor` 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. + */ + getSessionData(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 sessionData = sessionDataByWatcherActor.get(watcherActorID); + if (!sessionData && createData) { + sessionData = { + // The "session context" object help understand what should be debugged and which target should be created. + // See WatcherActor constructor for more info. + sessionContext: watcher.sessionContext, + // The DevToolsServerConnection prefix will be used to compute actor IDs created in the content process + connectionPrefix: watcher.conn.prefix, + }; + sessionDataByWatcherActor.set(watcherActorID, sessionData); + watcherActors.set(watcherActorID, watcher); + } + return sessionData; + }, + + /** + * 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); + }, + + /** + * Return an array of the watcher actors that match the passed browserId + * + * @param {Number} browserId + * @returns {Array<WatcherActor>} An array of the matching watcher actors + */ + getWatchersForBrowserId(browserId) { + const watchers = []; + for (const watcherActor of watcherActors.values()) { + if ( + watcherActor.sessionContext.type == "browser-element" && + watcherActor.sessionContext.browserId === browserId + ) { + watchers.push(watcherActor); + } + } + + return watchers; + }, + + /** + * 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 + */ + addSessionDataEntry(watcher, type, entries) { + const sessionData = this.getSessionData(watcher, { + createData: true, + }); + + if (!SUPPORTED_DATA_TYPES.includes(type)) { + throw new Error(`Unsupported session data type: ${type}`); + } + + SessionDataHelpers.addSessionDataEntry(sessionData, 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. + * + * @param WatcherActor watcher + * The WatcherActor which stops observing. + * @param string type + * The type of data to be removed + * @param Array<Object> entries + * The values to be removed to this type of data + * @params {Object} options + * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the + * result of a change to the devtools.browsertoolbox.scope pref. + * + * @return boolean + * True if we such entry was already registered, for this watcher actor. + */ + removeSessionDataEntry(watcher, type, entries, options) { + const sessionData = this.getSessionData(watcher); + if (!sessionData) { + return false; + } + + if (!SUPPORTED_DATA_TYPES.includes(type)) { + throw new Error(`Unsupported session data type: ${type}`); + } + + if ( + !SessionDataHelpers.removeSessionDataEntry(sessionData, type, entries) + ) { + return false; + } + + const isWatchingSomething = SUPPORTED_DATA_TYPES.some( + dataType => sessionData[dataType] && !!sessionData[dataType].length + ); + + // Remove the watcher reference if it's not watching for anything anymore, unless we're + // doing a mode switch; in such case we don't mean to end the DevTools session, so we + // still want to have access to the underlying data (furthermore, such case should only + // happen in tests, in a regular workflow we'd still be watching for resources). + if (!isWatchingSomething && !options?.isModeSwitching) { + sessionDataByWatcherActor.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) { + sessionDataByWatcherActor.delete(watcher.actorID); + watcherActors.delete(watcher.actorID); + this.maybeUnregisteringJSWindowActor(); + }, + + /** + * 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.addSessionDataEntry(watcher, SUPPORTED_DATA.TARGETS, [targetType]); + }, + + /** + * Notify that a given watcher stops observing a given target type. + * + * @param WatcherActor watcher + * The WatcherActor which stops observing. + * @param string targetType + * The new target type to stop listening to. + * @params {Object} options + * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the + * result of a change to the devtools.browsertoolbox.scope pref. + * @return boolean + * True if we were watching for this target type, for this watcher actor. + */ + unwatchTargets(watcher, targetType, options) { + return this.removeSessionDataEntry( + watcher, + SUPPORTED_DATA.TARGETS, + [targetType], + options + ); + }, + + /** + * 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.addSessionDataEntry(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.removeSessionDataEntry( + watcher, + SUPPORTED_DATA.RESOURCES, + resourceTypes + ); + }, + + /** + * Unregister the JS Window Actor if there is no more DevTools code observing any target/resource. + */ + maybeUnregisteringJSWindowActor() { + if (sessionDataByWatcherActor.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: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs", + }, + child: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs", + events: { + DOMWindowCreated: {}, + DOMDocElementInserted: {}, + pageshow: {}, + pagehide: {}, + }, + }, + allFrames: true, + }, + DevToolsWorker: { + parent: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs", + }, + child: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs", + 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/browsing-context-helpers.sys.mjs b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs new file mode 100644 index 0000000000..d52cbc5708 --- /dev/null +++ b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs @@ -0,0 +1,428 @@ +/* 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/. */ + +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); + +const WEBEXTENSION_FALLBACK_DOC_URL = + "chrome://devtools/content/shared/webextension-fallback.html"; + +/** + * Retrieve the addon id corresponding to a given window global. + * This is usually extracted from the principal, but in case we are dealing + * with a DevTools webextension fallback window, the addon id will be available + * in the URL. + * + * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal + * The WindowGlobal from which we want to extract the addonId. Either a + * WindowGlobalParent or a WindowGlobalChild depending on where this + * helper is used from. + * @return {String} Returns the addon id if any could found, null otherwise. + */ +export function getAddonIdForWindowGlobal(windowGlobal) { + const browsingContext = windowGlobal.browsingContext; + const isParent = CanonicalBrowsingContext.isInstance(browsingContext); + // documentPrincipal is only exposed on WindowGlobalParent, + // use a fallback for WindowGlobalChild. + const principal = isParent + ? windowGlobal.documentPrincipal + : browsingContext.window.document.nodePrincipal; + + // On Android we can get parent process windows where `documentPrincipal` and + // `documentURI` are both unavailable. Bail out early. + if (!principal) { + return null; + } + + // Most webextension documents are loaded from moz-extension://{addonId} and + // the principal provides the addon id. + if (principal.addonId) { + return principal.addonId; + } + + // If no addon id was available on the principal, check if the window is the + // DevTools fallback window and extract the addon id from the URL. + const href = isParent + ? windowGlobal.documentURI?.displaySpec + : browsingContext.window.document.location.href; + + if (href && href.startsWith(WEBEXTENSION_FALLBACK_DOC_URL)) { + const [, addonId] = href.split("#"); + return addonId; + } + + return null; +} + +/** + * Helper function to know if a given BrowsingContext should be debugged by scope + * described by the given session context. + * + * @param {BrowsingContext} browsingContext + * The browsing context we want to check if it is part of debugged context + * @param {Object} sessionContext + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * @param {Boolean} options.forceAcceptTopLevelTarget + * If true, we will accept top level browsing context even when server target switching + * is disabled. In case of client side target switching, the top browsing context + * is debugged via a target actor that is being instantiated manually by the frontend. + * And this target actor isn't created, nor managed by the watcher actor. + * @param {Boolean} options.acceptInitialDocument + * By default, we ignore initial about:blank documents/WindowGlobals. + * But some code cares about all the WindowGlobals, this flag allows to also accept them. + * (Used by _validateWindowGlobal) + * @param {Boolean} options.acceptSameProcessIframes + * If true, we will accept WindowGlobal that runs in the same process as their parent document. + * That, even when EFT is disabled. + * (Used by _validateWindowGlobal) + * @param {Boolean} options.acceptNoWindowGlobal + * By default, we will reject BrowsingContext that don't have any WindowGlobal, + * either retrieved via BrowsingContext.currentWindowGlobal in the parent process, + * or via the options.windowGlobal argument. + * But in some case, we are processing BrowsingContext very early, before any + * WindowGlobal has been created for it. But they are still relevant BrowsingContexts + * to debug. + * @param {WindowGlobal} options.windowGlobal + * When we are in the content process, we can't easily retrieve the WindowGlobal + * for a given BrowsingContext. So allow to pass it via this argument. + * Also, there is some race conditions where browsingContext.currentWindowGlobal + * is null, while the callsite may have a reference to the WindowGlobal. + */ +// The goal of this method is to gather all checks done against BrowsingContext and WindowGlobal interfaces +// which leads it to be a lengthy method. So disable the complexity rule which is counter productive here. +// eslint-disable-next-line complexity +export function isBrowsingContextPartOfContext( + browsingContext, + sessionContext, + options = {} +) { + let { + forceAcceptTopLevelTarget = false, + acceptNoWindowGlobal = false, + windowGlobal, + } = options; + + // For now, reject debugging chrome BrowsingContext. + // This is for example top level chrome windows like browser.xhtml or webconsole/index.html (only the browser console) + // + // Tab and WebExtension debugging shouldn't target any such privileged document. + // All their document should be of type "content". + // + // This may only be an issue for the Browser Toolbox. + // For now, we expect the ParentProcessTargetActor to debug these. + // Note that we should probably revisit that, and have each WindowGlobal be debugged + // by one dedicated WindowGlobalTargetActor (bug 1685500). This requires some tweaks, at least in console-message + // resource watcher, which makes the ParentProcessTarget's console message resource watcher watch + // for all documents messages. It should probably only care about window-less messages and have one target per window global, + // each target fetching one window global messages. + // + // Such project would be about applying "EFT" to the browser toolbox and non-content documents + if ( + CanonicalBrowsingContext.isInstance(browsingContext) && + !browsingContext.isContent + ) { + return false; + } + + if (!windowGlobal) { + // When we are in the parent process, WindowGlobal can be retrieved from the BrowsingContext, + // while in the content process, the callsites have to pass it manually as an argument + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + windowGlobal = browsingContext.currentWindowGlobal; + } else if (!windowGlobal && !acceptNoWindowGlobal) { + throw new Error( + "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process" + ); + } + } + // If we have a WindowGlobal, there is some additional checks we can do + if ( + windowGlobal && + !_validateWindowGlobal(windowGlobal, sessionContext, options) + ) { + return false; + } + // Loading or destroying BrowsingContext won't have any associated WindowGlobal. + // Ignore them by default. They should be either handled via DOMWindowCreated event or JSWindowActor destroy + if (!windowGlobal && !acceptNoWindowGlobal) { + return false; + } + + // Now do the checks specific to each session context type + if (sessionContext.type == "all") { + return true; + } + if (sessionContext.type == "browser-element") { + // Check if the document is: + // - part of the Browser element, or, + // - a popup originating from the browser element (the popup being loaded in a distinct browser element) + const isMatchingTheBrowserElement = + browsingContext.browserId == sessionContext.browserId; + if ( + !isMatchingTheBrowserElement && + !isPopupToDebug(browsingContext, sessionContext) + ) { + return false; + } + + // For client-side target switching, 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 which we want to ignore. + // + // `forceAcceptTopLevelTarget` is set: + // * when navigating to and from pages in the bfcache, we ignore client side target + // and start emitting top level target from the server. + // * when the callsite care about all the debugged browsing contexts, + // no matter if their related targets are created by client or server. + const isClientSideTargetSwitching = + !sessionContext.isServerTargetSwitchingEnabled; + const isTopLevelBrowsingContext = !browsingContext.parent; + if ( + isClientSideTargetSwitching && + !forceAcceptTopLevelTarget && + isTopLevelBrowsingContext + ) { + return false; + } + return true; + } + + if (sessionContext.type == "webextension") { + // Next and last check expects a WindowGlobal. + // As we have no way to really know if this BrowsingContext is related to this add-on, + // ignore it. Even if callsite accepts browsing context without a window global. + if (!windowGlobal) { + return false; + } + + return getAddonIdForWindowGlobal(windowGlobal) == sessionContext.addonId; + } + throw new Error("Unsupported session context type: " + sessionContext.type); +} + +/** + * Return true for popups to debug when debugging a browser-element. + * + * @param {BrowsingContext} browsingContext + * The browsing context we want to check if it is part of debugged context + * @param {Object} sessionContext + * WatcherActor's session context. This helps know what is the overall debugged scope. + * See watcher actor constructor for more info. + */ +function isPopupToDebug(browsingContext, sessionContext) { + // If enabled, create targets for popups (i.e. window.open() calls). + // If the opener is the tab we are currently debugging, accept the WindowGlobal and create a target for it. + // + // Note that it is important to do this check *after* the isInitialDocument one. + // Popups end up involving three WindowGlobals: + // - a first WindowGlobal loading an initial about:blank document (so isInitialDocument is true) + // - a second WindowGlobal which looks exactly as the first one + // - a final WindowGlobal which loads the URL passed to window.open() (so isInitialDocument is false) + // + // For now, we only instantiate a target for the last WindowGlobal. + return ( + sessionContext.isPopupDebuggingEnabled && + browsingContext.opener && + browsingContext.opener.browserId == sessionContext.browserId + ); +} + +/** + * Helper function of isBrowsingContextPartOfContext to execute all checks + * against WindowGlobal interface which aren't specific to a given SessionContext type + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The WindowGlobal we want to check if it is part of debugged context + * @param {Object} sessionContext + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * See `isBrowsingContextPartOfContext` jsdoc. + */ +function _validateWindowGlobal( + windowGlobal, + sessionContext, + { acceptInitialDocument, acceptSameProcessIframes } +) { + // By default, before loading the actual document (even an about:blank document), + // we do load immediately "the initial about:blank document". + // This is expected by the spec. Typically when creating a new BrowsingContext/DocShell/iframe, + // we would have such transient initial document. + // `Document.isInitialDocument` helps identify this transient document, which + // we want to ignore as it would instantiate a very short lived target which + // confuses many tests and triggers race conditions by spamming many targets. + // + // We also ignore some other transient empty documents created while using `window.open()` + // When using this API with cross process loads, we may create up to three documents/WindowGlobals. + // We get a first initial about:blank document, and a second document created + // for moving the document in the right principal. + // The third document will be the actual document we expect to debug. + // The second document is an implementation artifact which ideally wouldn't exist + // and isn't expected by the spec. + // Note that `window.print` and print preview are using `window.open` and are going through this. + // + // WindowGlobalParent will have `isInitialDocument` attribute, while we have to go through the Document for WindowGlobalChild. + const isInitialDocument = + windowGlobal.isInitialDocument || + windowGlobal.browsingContext.window?.document.isInitialDocument; + if (isInitialDocument && !acceptInitialDocument) { + return false; + } + + // We may process an iframe that runs in the same process as its parent and we don't want + // to create targets for them if same origin targets (=EFT) are not enabled. + // Instead the WindowGlobalTargetActor will inspect these children document via docShell tree + // (typically via `docShells` or `windows` getters). + // This is quite common when Fission is off as any iframe will run in same process + // as their parent document. But it can also happen with Fission enabled if iframes have + // children iframes using the same origin. + const isSameProcessIframe = !windowGlobal.isProcessRoot; + if ( + isSameProcessIframe && + !acceptSameProcessIframes && + !isEveryFrameTargetEnabled + ) { + return false; + } + + return true; +} + +/** + * Helper function to know if a given WindowGlobal should be debugged by scope + * described by the given session context. This method could be called from any process + * as so accept either WindowGlobalParent or WindowGlobalChild instances. + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The WindowGlobal we want to check if it is part of debugged context + * @param {Object} sessionContext + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * See `isBrowsingContextPartOfContext` jsdoc. + */ +export function isWindowGlobalPartOfContext( + windowGlobal, + sessionContext, + options +) { + return isBrowsingContextPartOfContext( + windowGlobal.browsingContext, + sessionContext, + { + ...options, + windowGlobal, + } + ); +} + +/** + * Get all the BrowsingContexts that should be debugged by the given session context. + * Consider using WatcherActor.getAllBrowsingContexts(options) which will automatically pass the right sessionContext. + * + * 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 {Object} sessionContext + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * @param {Boolean} options.acceptSameProcessIframes + * If true, we will accept WindowGlobal that runs in the same process as their parent document. + * That, even when EFT is disabled. + */ +export function getAllBrowsingContextsForContext( + sessionContext, + { acceptSameProcessIframes = false } = {} +) { + 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 ( + (sessionContext.type == "all" || sessionContext.type == "webextension") && + 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. + // (This is also the reason why we aren't using BrowsingContext.getAllBrowsingContextsInSubtree()) + for (const browser of browsingContext.window.document.querySelectorAll( + `browser[type="content"]` + )) { + walk(browser.browsingContext); + } + } + } + + // If target a single browser element, only walk through its BrowsingContext + if (sessionContext.type == "browser-element") { + const topBrowsingContext = BrowsingContext.getCurrentTopByBrowserId( + sessionContext.browserId + ); + // topBrowsingContext can be null if getCurrentTopByBrowserId is called for a tab that is unloaded. + if (topBrowsingContext) { + // Unfortunately, getCurrentTopByBrowserId is subject to race conditions and may refer to a BrowsingContext + // that already navigated away. + // Query the current "live" BrowsingContext by going through the embedder element (i.e. the <browser>/<iframe> element) + // devtools/client/responsive/test/browser/browser_navigation.js covers this with fission enabled. + const realTopBrowsingContext = + topBrowsingContext.embedderElement.browsingContext; + walk(realTopBrowsingContext); + } + } else if ( + sessionContext.type == "all" || + sessionContext.type == "webextension" + ) { + // For the browser toolbox and web extension, retrieve all possible BrowsingContext. + // For WebExtension, we will then filter out the BrowsingContexts via `isBrowsingContextPartOfContext`. + // + // Fetch all top level window's browsing contexts + for (const window of Services.ww.getWindowEnumerator()) { + if (window.docShell.browsingContext) { + walk(window.docShell.browsingContext); + } + } + } else { + throw new Error("Unsupported session context type: " + sessionContext.type); + } + + return browsingContexts.filter(bc => + // We force accepting the top level browsing context, otherwise + // it would only be returned if sessionContext.isServerSideTargetSwitching is enabled. + isBrowsingContextPartOfContext(bc, sessionContext, { + forceAcceptTopLevelTarget: true, + acceptSameProcessIframes, + }) + ); +} + +if (typeof module == "object") { + module.exports = { + isBrowsingContextPartOfContext, + isWindowGlobalPartOfContext, + getAddonIdForWindowGlobal, + getAllBrowsingContextsForContext, + }; +} diff --git a/devtools/server/actors/watcher/moz.build b/devtools/server/actors/watcher/moz.build new file mode 100644 index 0000000000..46a9d89718 --- /dev/null +++ b/devtools/server/actors/watcher/moz.build @@ -0,0 +1,16 @@ +# -*- 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( + "browsing-context-helpers.sys.mjs", + "session-context.js", + "SessionDataHelpers.jsm", + "WatcherRegistry.sys.mjs", +) diff --git a/devtools/server/actors/watcher/session-context.js b/devtools/server/actors/watcher/session-context.js new file mode 100644 index 0000000000..35183e6ed8 --- /dev/null +++ b/devtools/server/actors/watcher/session-context.js @@ -0,0 +1,215 @@ +/* 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"; + +// Module to create all the Session Context objects. +// +// These are static JSON serializable object that help describe +// the debugged context. It is passed around to most of the server codebase +// in order to know which object to consider inspecting and communicating back to the client. +// +// These objects are all instantiated by the Descriptor actors +// and passed as a constructor argument to the Watcher actor. +// +// These objects have attributes used by all the Session contexts: +// - type: String +// Describes which type of context we are debugging. +// See SESSION_TYPES for all possible values. +// See each create* method for more info about each type and their specific attributes. +// - isServerTargetSwitchingEnabled: Boolean +// If true, targets should all be spawned by the server codebase. +// Especially the first, top level target. +// - supportedTargets: Boolean +// An object keyed by target type, whose value indicates if we have watcher support +// for the target. +// - supportedResources: Boolean +// An object keyed by resource type, whose value indicates if we have watcher support +// for the resource. + +const Targets = require("resource://devtools/server/actors/targets/index.js"); +const Resources = require("resource://devtools/server/actors/resources/index.js"); + +const SESSION_TYPES = { + ALL: "all", + BROWSER_ELEMENT: "browser-element", + CONTENT_PROCESS: "content-process", + WEBEXTENSION: "webextension", + WORKER: "worker", +}; + +/** + * Create the SessionContext used by the Browser Toolbox and Browser Console. + * + * This context means debugging everything. + * The whole browser: + * - all processes: parent and content, + * - all privileges: privileged/chrome and content/web, + * - all components/targets: HTML documents, processes, workers, add-ons,... + */ +function createBrowserSessionContext() { + const type = SESSION_TYPES.ALL; + + return { + type, + // For now, the top level target (ParentProcessTargetActor) is created via ProcessDescriptor.getTarget + // and is never replaced by any other, nor is it created by the WatcherActor. + isServerTargetSwitchingEnabled: false, + supportedTargets: getWatcherSupportedTargets(type), + supportedResources: getWatcherSupportedResources(type), + }; +} + +/** + * Create the SessionContext used by the regular web page toolboxes as well as remote debugging android device tabs. + * + * @param {BrowserElement} browserElement + * The tab to debug. It should be a reference to a <browser> element. + * @param {Object} config + * An object with optional configuration. Only supports "isServerTargetSwitchingEnabled" attribute. + * See jsdoc in this file header for more info. + */ +function createBrowserElementSessionContext(browserElement, config) { + const type = SESSION_TYPES.BROWSER_ELEMENT; + return { + type, + browserId: browserElement.browserId, + // Nowaday, it should always be enabled except for WebExtension special + // codepath and some tests. + isServerTargetSwitchingEnabled: config.isServerTargetSwitchingEnabled, + // Should we instantiate targets for popups opened in distinct tabs/windows? + // Driven by devtools.popups.debug=true preference. + isPopupDebuggingEnabled: config.isPopupDebuggingEnabled, + supportedTargets: getWatcherSupportedTargets(type), + supportedResources: getWatcherSupportedResources(type), + }; +} + +/** + * Create the SessionContext used by the web extension toolboxes. + * + * @param {Object} addon + * First object argument to describe the add-on. + * @param {String} addon.addonId + * The web extension ID, to uniquely identify the debugged add-on. + * @param {String} addon.browsingContextID + * The ID of the BrowsingContext into which this add-on is loaded. + * For now the top level target is associated with this one precise BrowsingContext. + * Knowing about it later helps associate resources to the same BrowsingContext ID and so the same target. + * @param {String} addon.innerWindowId + * The ID of the WindowGlobal into which this add-on is loaded. + * This is used for the same reason as browsingContextID. It helps match the resource with the right target. + * We now also use the WindowGlobal ID/innerWindowId to identify the targets. + * @param {Object} config + * An object with optional configuration. Only supports "isServerTargetSwitchingEnabled" attribute. + * See jsdoc in this file header for more info. + */ +function createWebExtensionSessionContext( + { addonId, browsingContextID, innerWindowId }, + config +) { + const type = SESSION_TYPES.WEBEXTENSION; + return { + type, + addonId, + addonBrowsingContextID: browsingContextID, + addonInnerWindowId: innerWindowId, + // For now, there is only one target (WebExtensionTargetActor), it is never replaced, + // and is only created via WebExtensionDescriptor.getTarget (and never by the watcher actor). + isServerTargetSwitchingEnabled: config.isServerTargetSwitchingEnabled, + supportedTargets: getWatcherSupportedTargets(type), + supportedResources: getWatcherSupportedResources(type), + }; +} + +/** + * Create the SessionContext used by the Browser Content Toolbox, to debug only one content process. + * Or when debugging XpcShell via about:debugging, where we instantiate only one content process target. + */ +function createContentProcessSessionContext() { + const type = SESSION_TYPES.CONTENT_PROCESS; + return { + type, + supportedTargets: getWatcherSupportedTargets(type), + supportedResources: getWatcherSupportedResources(type), + }; +} + +/** + * Create the SessionContext used when debugging one specific Service Worker or special chrome worker. + * This is only used from about:debugging. + */ +function createWorkerSessionContext() { + const type = SESSION_TYPES.WORKER; + return { + type, + supportedTargets: getWatcherSupportedTargets(type), + supportedResources: getWatcherSupportedResources(type), + }; +} + +/** + * Get the supported targets by the watcher given a session context type. + * + * @param {String} type + * @returns {Object} + */ +function getWatcherSupportedTargets(type) { + return { + [Targets.TYPES.FRAME]: true, + [Targets.TYPES.PROCESS]: true, + [Targets.TYPES.WORKER]: + type == SESSION_TYPES.BROWSER_ELEMENT || + type == SESSION_TYPES.WEBEXTENSION, + }; +} + +/** + * Get the supported resources by the watcher given a session context type. + * + * @param {String} type + * @returns {Object} + */ +function getWatcherSupportedResources(type) { + // All resources types are supported for tab debugging and web extensions. + // Some watcher classes are still disabled for the Multiprocess Browser Toolbox (type=SESSION_TYPES.ALL). + // And they may also be disabled for workers once we start supporting them by the watcher. + // So set the traits to false for all the resources that we don't support yet + // and keep using the legacy listeners. + const isTabOrWebExtensionToolbox = + type == SESSION_TYPES.BROWSER_ELEMENT || type == SESSION_TYPES.WEBEXTENSION; + + return { + [Resources.TYPES.CONSOLE_MESSAGE]: true, + [Resources.TYPES.CSS_CHANGE]: isTabOrWebExtensionToolbox, + [Resources.TYPES.CSS_MESSAGE]: true, + [Resources.TYPES.DOCUMENT_EVENT]: true, + [Resources.TYPES.CACHE_STORAGE]: true, + [Resources.TYPES.COOKIE]: true, + [Resources.TYPES.ERROR_MESSAGE]: true, + [Resources.TYPES.EXTENSION_STORAGE]: true, + [Resources.TYPES.INDEXED_DB]: true, + [Resources.TYPES.LOCAL_STORAGE]: true, + [Resources.TYPES.SESSION_STORAGE]: true, + [Resources.TYPES.PLATFORM_MESSAGE]: true, + [Resources.TYPES.NETWORK_EVENT]: true, + [Resources.TYPES.NETWORK_EVENT_STACKTRACE]: true, + [Resources.TYPES.REFLOW]: true, + [Resources.TYPES.STYLESHEET]: true, + [Resources.TYPES.SOURCE]: true, + [Resources.TYPES.THREAD_STATE]: true, + [Resources.TYPES.SERVER_SENT_EVENT]: true, + [Resources.TYPES.WEBSOCKET]: true, + [Resources.TYPES.TRACING_STATE]: true, + [Resources.TYPES.LAST_PRIVATE_CONTEXT_EXIT]: true, + }; +} + +module.exports = { + createBrowserSessionContext, + createBrowserElementSessionContext, + createWebExtensionSessionContext, + createContentProcessSessionContext, + createWorkerSessionContext, +}; 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..855a64ae5e --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/frame-helper.js @@ -0,0 +1,322 @@ +/* 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 } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs", + { + // WatcherRegistry needs to be a true singleton and loads ActorManagerParent + // which also has to be a true singleton. + loadInDevToolsLoader: false, + } +); +const { WindowGlobalLogger } = ChromeUtils.importESModule( + "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs" +); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const browsingContextAttachedObserverByWatcher = new Map(); + +/** + * 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 WindowGlobalTargetActor + + // If we have a browserElement, set the watchedByDevTools flag on its related browsing context + // TODO: We should also set the flag for the "parent process" browsing context when we're + // in the browser toolbox. This is blocked by Bug 1675763, and should be handled as part + // of Bug 1709529. + if (watcher.sessionContext.type == "browser-element") { + // The `watchedByDevTools` enables gecko behavior tied to this flag, such as: + // - reporting the contents of HTML loaded in the docshells + // - capturing stacks for the network monitor. + watcher.browserElement.browsingContext.watchedByDevTools = true; + } + + if (!browsingContextAttachedObserverByWatcher.has(watcher)) { + // We store the browserId here as watcher.browserElement.browserId can momentary be + // set to 0 when there's a navigation to a new browsing context. + const browserId = watcher.sessionContext.browserId; + const onBrowsingContextAttached = browsingContext => { + // We want to set watchedByDevTools on new top-level browsing contexts: + // - in the case of the BrowserToolbox/BrowserConsole, that would be the browsing + // contexts of all the tabs we want to handle. + // - for the regular toolbox, browsing context that are being created when navigating + // to a page that forces a new browsing context. + // Then BrowsingContext will propagate to all the tree of children BrowsingContext's. + if ( + !browsingContext.parent && + (watcher.sessionContext.type != "browser-element" || + browserId === browsingContext.browserId) + ) { + browsingContext.watchedByDevTools = true; + } + }; + Services.obs.addObserver( + onBrowsingContextAttached, + "browsing-context-attached" + ); + // We store the observer so we can retrieve it elsewhere (e.g. for removal in destroyTargets). + browsingContextAttachedObserverByWatcher.set( + watcher, + onBrowsingContextAttached + ); + } + + if ( + watcher.sessionContext.isServerTargetSwitchingEnabled && + watcher.sessionContext.type == "browser-element" + ) { + // If server side target switching is enabled, process the top level browsing context first, + // so that we guarantee it is notified to the client first. + // If it is disabled, the top level target will be created from the client instead. + await createTargetForBrowsingContext({ + watcher, + browsingContext: watcher.browserElement.browsingContext, + retryOnAbortError: true, + }); + } + + const browsingContexts = watcher.getAllBrowsingContexts().filter( + // Filter out the top browsing context we just processed. + browsingContext => + browsingContext != watcher.browserElement?.browsingContext + ); + // Await for the all the queries in order to resolve only *after* we received all + // already available targets. + // i.e. each call to `createTargetForBrowsingContext` should end up emitting + // a target-available-form event via the WatcherActor. + await Promise.allSettled( + browsingContexts.map(browsingContext => + createTargetForBrowsingContext({ watcher, browsingContext }) + ) + ); +} + +/** + * (internal helper method) Force creating the target actor for a given BrowsingContext. + * + * @param WatcherActor watcher + * The Watcher Actor requesting to watch for new targets. + * @param BrowsingContext browsingContext + * The context for which a target should be created. + * @param Boolean retryOnAbortError + * Set to true to retry creating existing targets when receiving an AbortError. + * An AbortError is sent when the JSWindowActor pair was destroyed before the query + * was complete, which can happen if the document navigates while the query is pending. + */ +async function createTargetForBrowsingContext({ + watcher, + browsingContext, + retryOnAbortError = false, +}) { + logWindowGlobal(browsingContext.currentWindowGlobal, "Existing WindowGlobal"); + + // We need to set the watchedByDevTools flag on all top-level browsing context. In the + // case of a content toolbox, this is done in the tab descriptor, but when we're in the + // browser toolbox, such descriptor is not created. + // Then BrowsingContext will propagate to all the tree of children BbrowsingContext's. + if (!browsingContext.parent) { + browsingContext.watchedByDevTools = true; + } + + try { + await browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .instantiateTarget({ + watcherActorID: watcher.actorID, + connectionPrefix: watcher.conn.prefix, + sessionContext: watcher.sessionContext, + sessionData: watcher.sessionData, + }); + } catch (e) { + console.warn( + "Failed to create DevTools Frame target for browsingContext", + browsingContext.id, + ": ", + e, + retryOnAbortError ? "retrying" : "" + ); + if (retryOnAbortError && e.name === "AbortError") { + await createTargetForBrowsingContext({ + watcher, + browsingContext, + retryOnAbortError, + }); + } else { + throw e; + } + } +} + +/** + * 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. + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ +function destroyTargets(watcher, options) { + // Go over all existing BrowsingContext in order to destroy all targets + const browsingContexts = watcher.getAllBrowsingContexts(); + + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + if (!browsingContext.parent) { + browsingContext.watchedByDevTools = false; + } + + browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .destroyTarget({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + options, + }); + } + + if (watcher.sessionContext.type == "browser-element") { + watcher.browserElement.browsingContext.watchedByDevTools = false; + } + + if (browsingContextAttachedObserverByWatcher.has(watcher)) { + Services.obs.removeObserver( + browsingContextAttachedObserverByWatcher.get(watcher), + "browsing-context-attached" + ); + browsingContextAttachedObserverByWatcher.delete(watcher); + } +} + +/** + * 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 addSessionDataEntry({ 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") + .addSessionDataEntry({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + 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 addSessionDataEntry for argument documentation. + */ +function removeSessionDataEntry({ watcher, type, entries }) { + const browsingContexts = getWatchingBrowsingContexts(watcher); + for (const browsingContext of browsingContexts) { + logWindowGlobal( + browsingContext.currentWindowGlobal, + "Existing WindowGlobal" + ); + + browsingContext.currentWindowGlobal + .getActor("DevToolsFrame") + .removeSessionDataEntry({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + type, + entries, + }); + } +} + +module.exports = { + createTargets, + destroyTargets, + addSessionDataEntry, + removeSessionDataEntry, +}; + +/** + * Return the list of BrowsingContexts which should be targeted in order to communicate + * updated session data. + * + * @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 the multiprocess or fission mode is enabled, + // either for a content toolbox or a BrowserToolbox via scope set to everything. + const watchingAdditionalTargets = WatcherRegistry.isWatchingTargets( + watcher, + Targets.TYPES.FRAME + ); + if (watchingAdditionalTargets) { + return watcher.getAllBrowsingContexts(); + } + // By default, when we are no longer watching for frame targets, we should no longer try to + // communicate with any browsing-context. But. + // + // For "browser-element" debugging, all targets are provided by watching by watching for frame targets. + // So, when we are no longer watching for frame, we don't expect to have any frame target to talk to. + // => we should no longer reach any browsing context. + // + // For "all" (=browser toolbox), there is only the special ParentProcessTargetActor we might want to return here. + // But this is actually handled by the WatcherActor which uses `WatcherActor.getTargetActorInParentProcess` to convey session data. + // => we should no longer reach any browsing context. + // + // For "webextension" debugging, there is the special WebExtensionTargetActor, which doesn't run in the parent process, + // so that we can't rely on the same code as the browser toolbox. + // => we should always reach out this particular browsing context. + if (watcher.sessionContext.type == "webextension") { + const browsingContext = BrowsingContext.get( + watcher.sessionContext.addonBrowsingContextID + ); + // The add-on browsing context may be destroying, in which case we shouldn't try to communicate with it + if (browsingContext.currentWindowGlobal) { + return [browsingContext]; + } + } + return []; +} + +// 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..d78a2a6dbe --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/moz.build @@ -0,0 +1,11 @@ +# -*- 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", + "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..cef24c2f93 --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/process-helper.js @@ -0,0 +1,380 @@ +/* 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 } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs", + { + // WatcherRegistry needs to be a true singleton and loads ActorManagerParent + // which also has to be a true singleton. + loadInDevToolsLoader: false, + } +); + +loader.lazyRequireGetter( + this, + "ChildDebuggerTransport", + "resource://devtools/shared/transport/child-transport.js", + 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 onContentProcessActorDestroyed(msg) { + const { watcherActorID } = msg.data; + const watcher = WatcherRegistry.getWatcher(watcherActorID); + if (!watcher) { + throw new Error( + `Receiving a content process actor destruction 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; + unregisterWatcherForMessageManager(watcher, messageManager); +} + +function onMessageManagerClose(messageManager, topic, data) { + const list = actors.get(messageManager); + if (!list || !list.length) { + 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); +} + +/** + * Unregister everything created for a given watcher against a precise message manager: + * - clear up things from `actors` WeakMap, + * - notify all related target actors as being destroyed, + * - close all DevTools Transports being created for each Message Manager. + * + * @param {WatcherActor} watcher + * @param {MessageManager} + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ +function unregisterWatcherForMessageManager(watcher, messageManager, options) { + const targetActorDescriptions = actors.get(messageManager); + if (!targetActorDescriptions || !targetActorDescriptions.length) { + return; + } + + // Destroy all transports related to this watcher and tells the client to purge all related actors + const matchingTargetActorDescriptions = targetActorDescriptions.filter( + item => item.watcher === watcher + ); + for (const { + prefix, + childTransport, + actor, + } of matchingTargetActorDescriptions) { + watcher.notifyTargetDestroyed(actor, options); + + childTransport.close(); + watcher.conn.cancelForwarding(prefix); + } + + // Then update global `actors` WeakMap by stripping all data about this watcher + const remainingTargetActorDescriptions = targetActorDescriptions.filter( + item => item.watcher !== watcher + ); + if (!remainingTargetActorDescriptions.length) { + actors.delete(messageManager); + } else { + actors.set(messageManager, remainingTargetActorDescriptions); + } +} + +/** + * Destroy everything related to a given watcher that has been created in this module: + * (See unregisterWatcherForMessageManager) + * + * @param {WatcherActor} watcher + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ +function closeWatcherTransports(watcher, options) { + for (let i = 0; i < Services.ppmm.childCount; i++) { + const messageManager = Services.ppmm.getChildAt(i); + unregisterWatcherForMessageManager(watcher, messageManager, options); + } +} + +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.ppmm.addMessageListener( + "debug:content-process-actor-destroyed", + onContentProcessActorDestroyed + ); + 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); + } + } +} + +/** + * @param {WatcherActor} watcher + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ +function maybeUnregisterMessageListeners(watcher, options = {}) { + const sizeBefore = watchers.size; + watchers.delete(watcher); + closeWatcherTransports(watcher, options); + + if (sizeBefore == 1 && watchers.size == 0) { + Services.ppmm.removeMessageListener( + "debug:content-process-actor", + onContentProcessActorCreated + ); + Services.ppmm.removeMessageListener( + "debug:content-process-actor-destroyed", + onContentProcessActorDestroyed + ); + 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", { + options, + }); + } +} + +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. + let contentProcessCount = Services.ppmm.childCount - 1; + if (contentProcessCount == 0) { + return; + } + const onTargetsCreated = new Promise(resolve => { + let receivedTargetCount = 0; + const listener = () => { + receivedTargetCount++; + mayBeResolve(); + }; + watcher.on("target-available-form", listener); + const onContentProcessClosed = () => { + // Update the content process count as one has been just destroyed + contentProcessCount--; + mayBeResolve(); + }; + Services.obs.addObserver(onContentProcessClosed, "message-manager-close"); + function mayBeResolve() { + if (receivedTargetCount >= contentProcessCount) { + watcher.off("target-available-form", listener); + Services.obs.removeObserver( + onContentProcessClosed, + "message-manager-close" + ); + resolve(); + } + } + }); + + Services.ppmm.broadcastAsyncMessage("debug:instantiate-already-available", { + watcherActorID: watcher.actorID, + connectionPrefix: watcher.conn.prefix, + sessionData: watcher.sessionData, + }); + + await onTargetsCreated; +} + +/** + * @param {WatcherActor} watcher + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ +function destroyTargets(watcher, options) { + maybeUnregisterMessageListeners(watcher, options); + + 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 addSessionDataEntry({ 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-session-data-entry-done", + listener + ); + const onContentProcessClosed = (messageManager, topic, data) => { + expectedCount--; + maybeResolve(); + }; + const maybeResolve = () => { + if (count == expectedCount) { + Services.ppmm.removeMessageListener( + "debug:add-session-data-entry-done", + listener + ); + Services.obs.removeObserver( + onContentProcessClosed, + "message-manager-close" + ); + resolve(); + } + }; + Services.obs.addObserver(onContentProcessClosed, "message-manager-close"); + }); + + Services.ppmm.broadcastAsyncMessage("debug:add-session-data-entry", { + watcherActorID: watcher.actorID, + type, + entries, + }); + + await onAllReplied; +} + +/** + * Notify all existing content processes that some data entries have been removed + * + * See addSessionDataEntry for argument documentation. + */ +function removeSessionDataEntry({ watcher, type, entries }) { + Services.ppmm.broadcastAsyncMessage("debug:remove-session-data-entry", { + watcherActorID: watcher.actorID, + type, + entries, + }); +} + +module.exports = { + createTargets, + destroyTargets, + addSessionDataEntry, + removeSessionDataEntry, +}; 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..6fda83e6bb --- /dev/null +++ b/devtools/server/actors/watcher/target-helpers/worker-helper.js @@ -0,0 +1,128 @@ +/* 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 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 = watcher.getAllBrowsingContexts({ + acceptSameProcessIframes: true, + forceAcceptTopLevelTarget: true, + }); + 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, + sessionContext: watcher.sessionContext, + sessionData: watcher.sessionData, + }); + 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 = watcher.getAllBrowsingContexts({ + acceptSameProcessIframes: true, + forceAcceptTopLevelTarget: true, + }); + for (const browsingContext of browsingContexts) { + let windowActor; + try { + windowActor = browsingContext.currentWindowGlobal.getActor( + DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME + ); + } catch (e) { + continue; + } + + windowActor.destroyWorkerTargets({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + }); + } +} + +/** + * 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 addSessionDataEntry({ watcher, type, entries }) { + const browsingContexts = watcher.getAllBrowsingContexts({ + acceptSameProcessIframes: true, + forceAcceptTopLevelTarget: true, + }); + const promises = []; + for (const browsingContext of browsingContexts) { + const promise = browsingContext.currentWindowGlobal + .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME) + .addSessionDataEntry({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + 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 addSessionDataEntry for argument documentation. + */ +function removeSessionDataEntry({ watcher, type, entries }) { + const browsingContexts = watcher.getAllBrowsingContexts({ + acceptSameProcessIframes: true, + forceAcceptTopLevelTarget: true, + }); + for (const browsingContext of browsingContexts) { + browsingContext.currentWindowGlobal + .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME) + .removeSessionDataEntry({ + watcherActorID: watcher.actorID, + sessionContext: watcher.sessionContext, + type, + entries, + }); + } +} + +module.exports = { + createTargets, + destroyTargets, + addSessionDataEntry, + removeSessionDataEntry, +}; |