summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/watcher
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/watcher')
-rw-r--r--devtools/server/actors/watcher/SessionDataHelpers.jsm244
-rw-r--r--devtools/server/actors/watcher/WatcherRegistry.sys.mjs397
-rw-r--r--devtools/server/actors/watcher/browsing-context-helpers.sys.mjs428
-rw-r--r--devtools/server/actors/watcher/moz.build16
-rw-r--r--devtools/server/actors/watcher/session-context.js219
-rw-r--r--devtools/server/actors/watcher/target-helpers/frame-helper.js331
-rw-r--r--devtools/server/actors/watcher/target-helpers/moz.build13
-rw-r--r--devtools/server/actors/watcher/target-helpers/process-helper.js389
-rw-r--r--devtools/server/actors/watcher/target-helpers/service-worker-helper.js220
-rw-r--r--devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js26
-rw-r--r--devtools/server/actors/watcher/target-helpers/worker-helper.js137
11 files changed, 2420 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..c70df1744f
--- /dev/null
+++ b/devtools/server/actors/watcher/SessionDataHelpers.jsm
@@ -0,0 +1,244 @@
+/* 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 {
+ ChromeUtils.defineLazyGetter(lazy, "validateBreakpointLocation", () => {
+ return ChromeUtils.import(
+ "resource://devtools/shared/validate-breakpoint.jsm"
+ ).validateBreakpointLocation;
+ });
+ ChromeUtils.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",
+ BROWSER_ELEMENT_HOST: "browser-element-host",
+ 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;
+ },
+};
+// Optional validation method to assert the shape of each session data entry
+const DATA_VALIDATION_FUNCTION = {
+ [SUPPORTED_DATA.BREAKPOINTS]({ location }) {
+ lazy.validateBreakpointLocation(location);
+ },
+ [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.`
+ );
+ }
+ },
+ [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.`
+ );
+ }
+ },
+};
+
+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
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ addOrSetSessionDataEntry(sessionData, type, entries, updateType) {
+ const validationFunction = DATA_VALIDATION_FUNCTION[type];
+ if (validationFunction) {
+ entries.forEach(validationFunction);
+ }
+
+ // When we are replacing the whole entries, things are significantly simplier
+ if (updateType == "set") {
+ sessionData[type] = entries;
+ return;
+ }
+
+ if (!sessionData[type]) {
+ sessionData[type] = [];
+ }
+ const toBeAdded = [];
+ const keyFunction = DATA_KEY_FUNCTION[type] || idFunction;
+ for (const entry of entries) {
+ 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..1068a253c9
--- /dev/null
+++ b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs
@@ -0,0 +1,397 @@
+/* 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 or set some entries for 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
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ addOrSetSessionDataEntry(watcher, type, entries, updateType) {
+ const sessionData = this.getSessionData(watcher, {
+ createData: true,
+ });
+
+ if (!SUPPORTED_DATA_TYPES.includes(type)) {
+ throw new Error(`Unsupported session data type: ${type}`);
+ }
+
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ type,
+ entries,
+ updateType
+ );
+
+ // 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.addOrSetSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.TARGETS,
+ [targetType],
+ "add"
+ );
+ },
+
+ /**
+ * 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.addOrSetSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.RESOURCES,
+ resourceTypes,
+ "add"
+ );
+ },
+
+ /**
+ * 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..6457399455
--- /dev/null
+++ b/devtools/server/actors/watcher/session-context.js
@@ -0,0 +1,219 @@
+/* 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,
+ [Targets.TYPES.SERVICE_WORKER]: type == SESSION_TYPES.BROWSER_ELEMENT,
+ };
+}
+
+/**
+ * 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.CSS_REGISTERED_PROPERTIES]: 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.JSTRACER_TRACE]: true,
+ [Resources.TYPES.JSTRACER_STATE]: true,
+ [Resources.TYPES.LAST_PRIVATE_CONTEXT_EXIT]: true,
+ };
+}
+
+module.exports = {
+ createBrowserSessionContext,
+ createBrowserElementSessionContext,
+ createWebExtensionSessionContext,
+ createContentProcessSessionContext,
+ createWorkerSessionContext,
+ SESSION_TYPES,
+};
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..0e6f4f80d3
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/frame-helper.js
@@ -0,0 +1,331 @@
+/* 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
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ const browsingContexts = getWatchingBrowsingContexts(watcher);
+ const promises = [];
+ for (const browsingContext of browsingContexts) {
+ logWindowGlobal(
+ browsingContext.currentWindowGlobal,
+ "Existing WindowGlobal"
+ );
+
+ const promise = browsingContext.currentWindowGlobal
+ .getActor("DevToolsFrame")
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ 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 addOrSetSessionDataEntry 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,
+ addOrSetSessionDataEntry,
+ 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..b7c8983590
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "frame-helper.js",
+ "process-helper.js",
+ "service-worker-helper.js",
+ "service-worker-jsprocessactor-startup.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..8895d7ed66
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/process-helper.js
@@ -0,0 +1,389 @@
+/* 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
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ 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-or-set-session-data-entry-done",
+ listener
+ );
+ const onContentProcessClosed = (messageManager, topic, data) => {
+ expectedCount--;
+ maybeResolve();
+ };
+ const maybeResolve = () => {
+ if (count == expectedCount) {
+ Services.ppmm.removeMessageListener(
+ "debug:add-or-set-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-or-set-session-data-entry", {
+ watcherActorID: watcher.actorID,
+ type,
+ entries,
+ updateType,
+ });
+
+ await onAllReplied;
+}
+
+/**
+ * Notify all existing content processes that some data entries have been removed
+ *
+ * See addOrSetSessionDataEntry 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,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
diff --git a/devtools/server/actors/watcher/target-helpers/service-worker-helper.js b/devtools/server/actors/watcher/target-helpers/service-worker-helper.js
new file mode 100644
index 0000000000..53fceead17
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/service-worker-helper.js
@@ -0,0 +1,220 @@
+/* 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 { waitForTick } = require("resource://devtools/shared/DevToolsUtils.js");
+
+const PROCESS_SCRIPT_URL =
+ "resource://devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js";
+
+const PROCESS_ACTOR_NAME = "DevToolsServiceWorker";
+const PROCESS_ACTOR_OPTIONS = {
+ // Ignore the parent process.
+ includeParent: false,
+
+ parent: {
+ esModuleURI:
+ "resource://devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs",
+ },
+
+ child: {
+ esModuleURI:
+ "resource://devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs",
+
+ observers: [
+ // Tried various notification to ensure starting the actor
+ // from webServiceWorker processes... but none of them worked.
+ /*
+ "chrome-event-target-created",
+ "webnavigation-create",
+ "chrome-webnavigation-create",
+ "webnavigation-destroy",
+ "chrome-webnavigation-destroy",
+ "browsing-context-did-set-embedder",
+ "browsing-context-discarded",
+ "ipc:content-initializing",
+ "ipc:content-created",
+ */
+
+ // Fallback on firing a very custom notification from a "process script" (loadProcessScript)
+ "init-devtools-service-worker-actor",
+ ],
+ },
+};
+
+// List of all active watchers
+const gWatchers = new Set();
+
+/**
+ * Register the DevToolsServiceWorker JS Process Actor,
+ * if we are registering the first watcher actor.
+ *
+ * @param {Watcher Actor} watcher
+ */
+function maybeRegisterProcessActor(watcher) {
+ const sizeBefore = gWatchers.size;
+ gWatchers.add(watcher);
+
+ if (sizeBefore == 0 && gWatchers.size == 1) {
+ ChromeUtils.registerProcessActor(PROCESS_ACTOR_NAME, PROCESS_ACTOR_OPTIONS);
+
+ // For some reason JSProcessActor doesn't work out of the box for `webServiceWorker` content processes.
+ // So manually spawn our JSProcessActor from a process script emitting an observer service notification...
+ // The Process script are correctly executed on all process types during their early startup.
+ Services.ppmm.loadProcessScript(PROCESS_SCRIPT_URL, true);
+ }
+}
+
+/**
+ * Unregister the DevToolsServiceWorker JS Process Actor,
+ * if we are unregistering the last watcher actor.
+ *
+ * @param {Watcher Actor} watcher
+ */
+function maybeUnregisterProcessActor(watcher) {
+ const sizeBefore = gWatchers.size;
+ gWatchers.delete(watcher);
+
+ if (sizeBefore == 1 && gWatchers.size == 0) {
+ ChromeUtils.unregisterProcessActor(
+ PROCESS_ACTOR_NAME,
+ PROCESS_ACTOR_OPTIONS
+ );
+
+ Services.ppmm.removeDelayedProcessScript(PROCESS_SCRIPT_URL);
+ }
+}
+
+/**
+ * Return the list of all DOM Processes except the one for the parent process
+ *
+ * @return Array<nsIDOMProcessParent>
+ */
+function getAllContentProcesses() {
+ return ChromeUtils.getAllDOMProcesses().filter(
+ process => process.childID !== 0
+ );
+}
+
+/**
+ * Force creating targets for all existing service workers for a given Watcher Actor.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to watch for new targets.
+ */
+async function createTargets(watcher) {
+ maybeRegisterProcessActor(watcher);
+ // Go over all existing content process in order to:
+ // - Force the instantiation of a DevToolsServiceWorkerChild
+ // - Have the DevToolsServiceWorkerChild to spawn the WorkerTargetActors
+
+ const promises = [];
+ for (const process of getAllContentProcesses()) {
+ const promise = process
+ .getActor(PROCESS_ACTOR_NAME)
+ .instantiateServiceWorkerTargets({
+ 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 content processes in order to destroy all targets
+ for (const process of getAllContentProcesses()) {
+ let processActor;
+ try {
+ processActor = process.getActor(PROCESS_ACTOR_NAME);
+ } catch (e) {
+ // Ignore any exception during destroy as we may be closing firefox/devtools/tab
+ // and that can easily lead to many exceptions.
+ continue;
+ }
+
+ processActor.destroyServiceWorkerTargets({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ });
+ }
+
+ // browser_dbg-breakpoints-columns.js crashes if we unregister the Process Actor
+ // in the same event loop as we call destroyServiceWorkerTargets.
+ await waitForTick();
+
+ maybeUnregisterProcessActor(watcher);
+}
+
+/**
+ * Go over all existing JSProcessActor in order to communicate about new data entries
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to update data entries.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ maybeRegisterProcessActor(watcher);
+ const promises = [];
+ for (const process of getAllContentProcesses()) {
+ const promise = process
+ .getActor(PROCESS_ACTOR_NAME)
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ 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 addOrSetSessionDataEntry for argument documentation.
+ */
+function removeSessionDataEntry({ watcher, type, entries }) {
+ for (const process of getAllContentProcesses()) {
+ process.getActor(PROCESS_ACTOR_NAME).removeSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ });
+ }
+}
+
+module.exports = {
+ createTargets,
+ destroyTargets,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
diff --git a/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js b/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js
new file mode 100644
index 0000000000..03f042ad68
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js
@@ -0,0 +1,26 @@
+/* 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 { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+/*
+ We can't spawn the JSProcessActor right away and have to spin the event loop.
+ Otherwise it isn't registered yet and isn't listening to observer service.
+ Could it be the reason why JSProcessActor aren't spawn via process actor option's child.observers notifications ??
+*/
+setTimeout(function () {
+ /*
+ This notification is registered in DevToolsServiceWorker JS process actor's options's `observers` attribute
+ and will force the JS Process actor to be instantiated in all processes.
+ */
+ Services.obs.notifyObservers(null, "init-devtools-service-worker-actor");
+ /*
+ Instead of using observer service, we could also manually call some method of the actor:
+ ChromeUtils.domProcessChild.getActor("DevToolsServiceWorker").observe(null, "foo");
+ */
+}, 0);
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..671d1dc706
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/worker-helper.js
@@ -0,0 +1,137 @@
+/* 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
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ 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)
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ 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 addOrSetSessionDataEntry 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,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};