diff options
Diffstat (limited to 'devtools/server/connectors/js-process-actor')
10 files changed, 2203 insertions, 341 deletions
diff --git a/devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs b/devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs new file mode 100644 index 0000000000..41ce80c9fd --- /dev/null +++ b/devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs @@ -0,0 +1,430 @@ +/* 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 lazy = {}; +ChromeUtils.defineESModuleGetters( + lazy, + { + releaseDistinctSystemPrincipalLoader: + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", + useDistinctSystemPrincipalLoader: + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", + loader: "resource://devtools/shared/loader/Loader.sys.mjs", + }, + { global: "contextual" } +); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +// Map(String => Object) +// Map storing the data objects for all currently active watcher actors. +// The data objects are defined by `createWatcherDataObject()`. +// The main attribute of interest is the `sessionData` one which is set alongside +// various other attributes necessary to maintain state per watcher in the content process. +// +// The Session Data object is maintained by ParentProcessWatcherRegistry, in the parent process +// and is fetched from the content process via `sharedData` API. +// It is then manually maintained via DevToolsProcess JS Actor queries. +let gAllWatcherData = null; + +export const ContentProcessWatcherRegistry = { + _getAllWatchersDataMap() { + if (gAllWatcherData) { + return gAllWatcherData; + } + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActorID = sharedData.get(SHARED_DATA_KEY_NAME); + if (!sessionDataByWatcherActorID) { + throw new Error("Missing session data in `sharedData`"); + } + + // Initialize a distinct Map to replicate the one read from `sharedData`. + // This distinct Map will be updated via DevToolsProcess JS Actor queries. + // This helps better control the execution flow. + gAllWatcherData = new Map(); + + // The Browser Toolbox will load its server modules in a distinct global/compartment whose name is "DevTools global". + // (See https://searchfox.org/mozilla-central/rev/0e9ea50a999420d93df0e4e27094952af48dd3b8/js/xpconnect/loader/mozJSModuleLoader.cpp#699) + // It means that this class will be instantiated twice, one in each global (the shared one and the browser toolbox one). + // We then have to distinguish the two subset of watcher actors accordingly within `sharedMap`, + // as `sharedMap` will be shared between the two module instances. + // Session type "all" relates to the Browser Toolbox. + const isInBrowserToolboxLoader = + // eslint-disable-next-line mozilla/reject-globalThis-modification + Cu.getRealmLocation(globalThis) == "DevTools global"; + + for (const [watcherActorID, sessionData] of sessionDataByWatcherActorID) { + // Filter in/out the watchers based on the current module loader and the watcher session type. + const isBrowserToolboxWatcher = sessionData.sessionContext.type == "all"; + if ( + (isInBrowserToolboxLoader && !isBrowserToolboxWatcher) || + (!isInBrowserToolboxLoader && isBrowserToolboxWatcher) + ) { + continue; + } + + gAllWatcherData.set( + watcherActorID, + createWatcherDataObject(watcherActorID, sessionData) + ); + } + + return gAllWatcherData; + }, + + /** + * Get all data objects for all currently active watcher actors. + * If a specific target type is passed, this will only return objects of watcher actively watching for a given target type. + * + * @param {String} targetType + * Optional target type to filter only a subset of watchers. + * @return {Array|Iterator} + * List of data objects. (see createWatcherDataObject) + */ + getAllWatchersDataObjects(targetType) { + if (targetType) { + const list = []; + for (const watcherDataObject of this._getAllWatchersDataMap().values()) { + if (watcherDataObject.sessionData.targets?.includes(targetType)) { + list.push(watcherDataObject); + } + } + return list; + } + return this._getAllWatchersDataMap().values(); + }, + + /** + * Get the watcher data object for a given watcher actor. + * + * @param {String} watcherActorID + * @param {Boolean} onlyFromCache + * If set explicitly to true, will avoid falling back to shared data. + * This is typically useful on destructor/removing/cleanup to avoid creating unexpected data. + * It is also used to avoid the exception thrown when sharedData is cleared on toolbox destruction. + */ + getWatcherDataObject(watcherActorID, onlyFromCache = false) { + let data = + ContentProcessWatcherRegistry._getAllWatchersDataMap().get( + watcherActorID + ); + if (!data && !onlyFromCache) { + // When there is more than one DevTools opened, the DevToolsProcess JS Actor spawned by the first DevTools + // created a cached Map in `_getAllWatchersDataMap`. + // When opening a second DevTools, this cached Map may miss some new SessionData related to this new DevTools instance, + // and new Watcher Actor. + // When such scenario happens, fallback to `sharedData` which should hopefully be containing the latest DevTools instance SessionData. + // + // May be the watcher should trigger a very first JS Actor query before any others in order to transfer the base Session Data object? + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActorID = sharedData.get(SHARED_DATA_KEY_NAME); + const sessionData = sessionDataByWatcherActorID.get(watcherActorID); + if (!sessionData) { + throw new Error("Unable to find data for watcher " + watcherActorID); + } + data = createWatcherDataObject(watcherActorID, sessionData); + gAllWatcherData.set(watcherActorID, data); + } + return data; + }, + + /** + * Instantiate a DevToolsServerConnection for a given Watcher. + * + * This function will be the one forcing to load the first DevTools CommonJS modules + * and spawning the DevTools Loader as well as the DevToolsServer. So better call it + * only once when it is strictly necessary. + * + * This connection will be the communication channel for RDP between this content process + * and the parent process, which will route RDP packets from/to the client by using + * a unique "forwarding prefix". + * + * @param {String} watcherActorID + * @param {Boolean} useDistinctLoader + * To be set to true when debugging a privileged context running the shared system principal global. + * This is a requirement for spidermonkey Debugger API used by the thread actor. + * @return {Object} + * Object with connection (DevToolsServerConnection) and loader (DevToolsLoader) attributes. + */ + getOrCreateConnectionForWatcher(watcherActorID, useDistinctLoader) { + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID); + let { connection, loader } = watcherDataObject; + + if (connection) { + return { connection, loader }; + } + + const { sessionContext, forwardingPrefix } = watcherDataObject; + // For the browser toolbox, we need to use a distinct loader in order to debug privileged JS. + // The thread actor ultimately need to be in a distinct compartments from its debuggees. + loader = + useDistinctLoader || sessionContext.type == "all" + ? lazy.useDistinctSystemPrincipalLoader(watcherDataObject) + : lazy.loader; + watcherDataObject.loader = loader; + + // Note that this a key step in loading DevTools backend / modules. + const { DevToolsServer } = loader.require( + "resource://devtools/server/devtools-server.js" + ); + + DevToolsServer.init(); + + // Within the content process, we only need the target scoped actors. + // (inspector, console, storage,...) + DevToolsServer.registerActors({ target: true }); + + // Instantiate a DevToolsServerConnection which will pipe all its outgoing RDP packets + // up to the parent process manager via DevToolsProcess JS Actor messages. + connection = DevToolsServer.connectToParentWindowActor( + watcherDataObject.jsProcessActor, + forwardingPrefix, + "DevToolsProcessChild:packet" + ); + watcherDataObject.connection = connection; + + return { connection, loader }; + }, + + /** + * Method to be called each time a new target actor is instantiated. + * + * @param {Object} watcherDataObject + * @param {Actor} targetActor + * @param {Boolean} isDocumentCreation + */ + onNewTargetActor(watcherDataObject, targetActor, isDocumentCreation = false) { + // There is no root actor in content processes and so + // the target actor can't be managed by it, but we do have to manage + // the actor to have it working and be registered in the DevToolsServerConnection. + // We make it manage itself and become a top level actor. + targetActor.manage(targetActor); + + const { watcherActorID } = watcherDataObject; + targetActor.once("destroyed", options => { + // Maintain the registry and notify the parent process + ContentProcessWatcherRegistry.destroyTargetActor( + watcherDataObject, + targetActor, + options + ); + }); + + watcherDataObject.actors.push(targetActor); + + // Immediately queue a message for the parent process, + // in order to ensure that the JSWindowActorTransport is instantiated + // before any packet is sent from the content process. + // As messages are guaranteed to be delivered in the order they + // were queued, we don't have to wait for anything around this sendAsyncMessage call. + // In theory, the Target Actor may emit events in its constructor. + // If it does, such RDP packets may be lost. But in practice, no events + // are emitted during its construction. Instead the frontend will start + // the communication first. + const { forwardingPrefix } = watcherDataObject; + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetAvailable", + { + watcherActorID, + forwardingPrefix, + targetActorForm: targetActor.form(), + } + ); + + // Pass initialization data to the target actor + const { sessionData } = watcherDataObject; + for (const type in sessionData) { + // `sessionData` will also contain `browserId` as well as entries with empty arrays, + // which shouldn't be processed. + const entries = sessionData[type]; + if (!Array.isArray(entries) || !entries.length) { + continue; + } + targetActor.addOrSetSessionDataEntry( + type, + sessionData[type], + isDocumentCreation, + "set" + ); + } + }, + + /** + * Method to be called each time a target actor is meant to be destroyed. + * + * @param {Object} watcherDataObject + * @param {Actor} targetActor + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ + destroyTargetActor(watcherDataObject, targetActor, options) { + const idx = watcherDataObject.actors.indexOf(targetActor); + if (idx != -1) { + watcherDataObject.actors.splice(idx, 1); + } + const form = targetActor.form(); + targetActor.destroy(options); + + // And this will destroy the parent process one + try { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetDestroyed", + { + actors: [ + { + watcherActorID: watcherDataObject.watcherActorID, + targetActorForm: form, + }, + ], + options, + } + ); + } catch (e) { + // Ignore exception when the JSProcessActorChild has already been destroyed. + // We often try to emit this message while the process is being destroyed, + // but sendAsyncMessage doesn't have time to complete and throws. + if ( + !e.message.includes("JSProcessActorChild cannot send at the moment") + ) { + throw e; + } + } + }, + + /** + * Method to know if a given Watcher Actor is still registered. + * + * @param {String} watcherActorID + * @return {Boolean} + */ + has(watcherActorID) { + return gAllWatcherData.has(watcherActorID); + }, + + /** + * Method to unregister a given Watcher Actor. + * + * @param {Object} watcherDataObject + */ + remove(watcherDataObject) { + // We do not need to destroy each actor individually as they + // are all registered in this DevToolsServerConnection, which will + // destroy all the registered actors. + if (watcherDataObject.connection) { + watcherDataObject.connection.close(); + } + // If we were using a distinct and dedicated loader, + // we have to manually release it. + if (watcherDataObject.loader && watcherDataObject.loader !== lazy.loader) { + lazy.releaseDistinctSystemPrincipalLoader(watcherDataObject); + } + + gAllWatcherData.delete(watcherDataObject.watcherActorID); + if (gAllWatcherData.size == 0) { + gAllWatcherData = null; + } + }, + + /** + * Method to know if there is no more Watcher registered. + * + * @return {Boolean} + */ + isEmpty() { + return !gAllWatcherData || gAllWatcherData.size == 0; + }, + + /** + * Method to unregister all the Watcher Actors + */ + clear() { + if (!gAllWatcherData) { + return; + } + // Query gAllWatcherData internal map directly as we don't want to re-create the map from sharedData + for (const watcherDataObject of gAllWatcherData.values()) { + ContentProcessWatcherRegistry.remove(watcherDataObject); + } + gAllWatcherData = null; + }, +}; + +function createWatcherDataObject(watcherActorID, sessionData) { + // The prefix of the DevToolsServerConnection of the Watcher Actor in the parent process. + // This is used to compute a unique ID for this process. + const parentConnectionPrefix = sessionData.connectionPrefix; + + // Compute a unique prefix, just for this DOM Process. + // (nsIDOMProcessChild's childID should be unique across processes) + // + // This prefix will be used to create a JSWindowActorTransport pair between content and parent processes. + // This is slightly hacky as we typically compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, + // but here, we can't have access to any DevTools connection as we could run really early in the content process startup. + // + // Ensure appending a final slash, otherwise the prefix may be the same between childID 1 and 10... + const forwardingPrefix = + parentConnectionPrefix + + "process" + + ChromeUtils.domProcessChild.childID + + "/"; + + // The browser toolbox uses a distinct JS Actor, loaded in the "devtools" ESM loader. + const jsActorName = + sessionData.sessionContext.type == "all" + ? "BrowserToolboxDevToolsProcess" + : "DevToolsProcess"; + const jsProcessActor = ChromeUtils.domProcessChild.getActor(jsActorName); + + return { + // {String} + // Actor ID for this watcher + watcherActorID, + + // {Array<String>} + // List of currently watched target types for this watcher + watchingTargetTypes: [], + + // {DevtoolsServerConnection} + // Connection bridge made from this content process to the parent process. + connection: null, + + // {JSActor} + // Reference to the related DevToolsProcessChild instance. + jsProcessActor, + + // {Object} + // Watcher's sessionContext object, which help identify the browser toolbox usecase. + sessionContext: sessionData.sessionContext, + + // {Object} + // Watcher's sessionData object, which is initiated with `sharedData` version, + // but is later updated on each Session Data update (addOrSetSessionDataEntry/removeSessionDataEntry). + // `sharedData` isn't timely updated and can be out of date. + sessionData, + + // {String} + // Prefix used against all RDP packets to route them correctly from/to this content process + forwardingPrefix, + + // {Array<Object>} + // List of active WindowGlobal and ContentProcess target actor instances. + actors: [], + + // {Array<Object>} + // We store workers independently as we don't have access to the TargetActor instance (it is in the worker thread) + // and we need to keep reference to some other specifics + // - {WorkerDebugger} dbg + workers: [], + + // {Set<Array<Object>>} + // A Set of arrays which will be populated with concurrent Session Data updates + // being done while a worker target is being instantiated. + // Each pending worker being initialized register a new dedicated array which will be removed + // from the Set once its initialization is over. + pendingWorkers: new Set(), + }; +} diff --git a/devtools/server/connectors/js-process-actor/DevToolsProcessChild.sys.mjs b/devtools/server/connectors/js-process-actor/DevToolsProcessChild.sys.mjs index 9e8ad64eea..d98c416e34 100644 --- a/devtools/server/connectors/js-process-actor/DevToolsProcessChild.sys.mjs +++ b/devtools/server/connectors/js-process-actor/DevToolsProcessChild.sys.mjs @@ -3,260 +3,260 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters( lazy, { - releaseDistinctSystemPrincipalLoader: - "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", - useDistinctSystemPrincipalLoader: - "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", + ProcessTargetWatcher: + "resource://devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs", + SessionDataHelpers: + "resource://devtools/server/actors/watcher/SessionDataHelpers.sys.mjs", + ServiceWorkerTargetWatcher: + "resource://devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs", + WorkerTargetWatcher: + "resource://devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs", + WindowGlobalTargetWatcher: + "resource://devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs", }, { global: "contextual" } ); -// Name of the attribute into which we save data in `sharedData` object. -const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; - -// If true, log info about DOMProcess's being created. -const DEBUG = false; - -/** - * Print information about operation being done against each content process. - * - * @param {nsIDOMProcessChild} domProcessChild - * The process for which we should log a message. - * @param {String} message - * Message to log. - */ -function logDOMProcess(domProcessChild, message) { - if (!DEBUG) { - return; - } - dump(" [pid:" + domProcessChild + "] " + message + "\n"); -} +// TargetActorRegistery has to be shared between all devtools instances +// and so is loaded into the shared global. +ChromeUtils.defineESModuleGetters( + lazy, + { + TargetActorRegistry: + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", + }, + { global: "shared" } +); export class DevToolsProcessChild extends JSProcessActorChild { constructor() { super(); - // The map is indexed by the Watcher Actor ID. - // The values are objects containing the following properties: - // - connection: the DevToolsServerConnection itself - // - actor: the ContentProcessTargetActor instance - this._connections = new Map(); - - this._onConnectionChange = this._onConnectionChange.bind(this); + // The EventEmitter interface is used for DevToolsTransport's packet-received event. EventEmitter.decorate(this); } + #watchers = { + // Keys are target types, which are defined in this CommonJS Module: + // https://searchfox.org/mozilla-central/rev/0e9ea50a999420d93df0e4e27094952af48dd3b8/devtools/server/actors/targets/index.js#7-14 + // We avoid loading it as this ESM should be lightweight and avoid spawning DevTools CommonJS Loader until + // whe know we have to instantiate a Target Actor. + frame: { + // Number of active watcher actors currently watching for the given target type + activeListener: 0, + + // Instance of a target watcher class whose task is to observe new target instances + get watcher() { + return lazy.WindowGlobalTargetWatcher; + }, + }, + + process: { + activeListener: 0, + get watcher() { + return lazy.ProcessTargetWatcher; + }, + }, + + worker: { + activeListener: 0, + get watcher() { + return lazy.WorkerTargetWatcher; + }, + }, + + service_worker: { + activeListener: 0, + get watcher() { + return lazy.ServiceWorkerTargetWatcher; + }, + }, + }; + + #initialized = false; + + /** + * Called when this JSProcess Actor instantiate either when we start observing for first target types, + * or when the process just started. + */ instantiate() { - const { sharedData } = Services.cpmm; - const watchedDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); - if (!watchedDataByWatcherActor) { - throw new Error( - "Request to instantiate the target(s) for the process, but `sharedData` is empty about watched targets" - ); + if (this.#initialized) { + return; } - - // Create one Target actor for each prefix/client which listen to processes - for (const [watcherActorID, sessionData] of watchedDataByWatcherActor) { - const { connectionPrefix } = sessionData; - - if (sessionData.targets?.includes("process")) { - this._createTargetActor(watcherActorID, connectionPrefix, sessionData); - } + this.#initialized = true; + // Create and watch for future target actors for each watcher currently watching some target types + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) { + this.#watchInitialTargetsForWatcher(watcherDataObject); } } /** - * Instantiate a new ProcessTarget for the given connection. + * Instantiate and watch future target actors based on the already watched targets. * - * @param String watcherActorID - * The ID of the WatcherActor who requested to observe and create these target actors. - * @param String parentConnectionPrefix - * The prefix of the DevToolsServerConnection of the Watcher Actor. - * This is used to compute a unique ID for the target actor. - * @param Object sessionData - * All data managed by the Watcher Actor and WatcherRegistry.sys.mjs, containing - * target types, resources types to be listened as well as breakpoints and any - * other data meant to be shared across processes and threads. + * @param Object watcherDataObject + * See ContentProcessWatcherRegistry. */ - _createTargetActor(watcherActorID, parentConnectionPrefix, sessionData) { - // This method will be concurrently called from `observe()` and `DevToolsProcessParent:instantiate-already-available` - // When the JSprocessActor initializes itself and when the watcher want to force instantiating existing targets. - // Simply ignore the second call as there is nothing to return, neither to wait for as this method is synchronous. - if (this._connections.has(watcherActorID)) { - return; - } - - // Compute a unique prefix, just for this DOM Process, - // which will be used to create a JSWindowActorTransport pair between content and parent processes. - // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, - // but here, we can't have access to any DevTools connection as we are really early in the content process startup - // XXX: nsIDOMProcessChild's childID should be unique across processes, I think. So that should be safe? - // (this.manager == nsIDOMProcessChild interface) - // Ensure appending a final slash, otherwise the prefix may be the same between childID 1 and 10... - const forwardingPrefix = - parentConnectionPrefix + "contentProcess" + this.manager.childID + "/"; - - logDOMProcess( - this.manager, - "Instantiate ContentProcessTarget with prefix: " + forwardingPrefix - ); - - const { connection, targetActor } = this._createConnectionAndActor( - watcherActorID, - forwardingPrefix, - sessionData - ); - this._connections.set(watcherActorID, { - connection, - actor: targetActor, - }); - - // Immediately queue a message for the parent process, - // in order to ensure that the JSWindowActorTransport is instantiated - // before any packet is sent from the content process. - // As the order of messages is guaranteed to be delivered in the order they - // were queued, we don't have to wait for anything around this sendAsyncMessage call. - // In theory, the ContentProcessTargetActor may emit events in its constructor. - // If it does, such RDP packets may be lost. But in practice, no events - // are emitted during its construction. Instead the frontend will start - // the communication first. - this.sendAsyncMessage("DevToolsProcessChild:connectFromContent", { - watcherActorID, - forwardingPrefix, - actor: targetActor.form(), - }); - - // Pass initialization data to the target actor - for (const type in sessionData) { - // `sessionData` will also contain `browserId` as well as entries with empty arrays, - // which shouldn't be processed. - const entries = sessionData[type]; - if (!Array.isArray(entries) || !entries.length) { - continue; - } - targetActor.addOrSetSessionDataEntry( - type, - sessionData[type], - false, - "set" + #watchInitialTargetsForWatcher(watcherDataObject) { + const { sessionData, sessionContext } = watcherDataObject; + + // About WebExtension, see note in addOrSetSessionDataEntry. + // Their target actor aren't created by this class, but session data is still managed by it + // and we need to pass the initial session data coming to already instantiated target actor. + if (sessionContext.type == "webextension") { + const { watcherActorID } = watcherDataObject; + const connectionPrefix = watcherActorID.replace(/watcher\d+$/, ""); + const targetActors = lazy.TargetActorRegistry.getTargetActors( + sessionContext, + connectionPrefix ); + if (targetActors.length) { + // Pass initialization data to the target actor + for (const type in sessionData) { + // `sessionData` will also contain `browserId` as well as entries with empty arrays, + // which shouldn't be processed. + const entries = sessionData[type]; + if (!Array.isArray(entries) || !entries.length) { + continue; + } + targetActors[0].addOrSetSessionDataEntry(type, entries, false, "set"); + } + } } - } - _destroyTargetActor(watcherActorID, isModeSwitching) { - const connectionInfo = this._connections.get(watcherActorID); - // This connection has already been cleaned? - if (!connectionInfo) { - throw new Error( - `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` - ); + // Ignore the call if the watched targets property isn't populated yet. + // This typically happens when instantiating the JS Process Actor on toolbox opening, + // where the actor is spawn early and a watchTarget message comes later with the `targets` array set. + if (!sessionData.targets) { + return; } - connectionInfo.connection.close({ isModeSwitching }); - this._connections.delete(watcherActorID); - if (this._connections.size == 0) { - this.didDestroy({ isModeSwitching }); + + for (const targetType of sessionData.targets) { + this.#watchNewTargetTypeForWatcher(watcherDataObject, targetType, true); } } - _createConnectionAndActor(watcherActorID, forwardingPrefix, sessionData) { - if (!this.loader) { - this.loader = lazy.useDistinctSystemPrincipalLoader(this); + /** + * Instantiate and watch future target actors based on the already watched targets. + * + * @param Object watcherDataObject + * See ContentProcessWatcherRegistry. + * @param String targetType + * New typeof target to start watching. + * @param Boolean isProcessActorStartup + * True when we are watching for targets during this JS Process actor instantiation. + * It shouldn't be the case on toolbox opening, but only when a new process starts. + * On toolbox opening, the Actor will receive an explicit watchTargets query. + */ + #watchNewTargetTypeForWatcher( + watcherDataObject, + targetType, + isProcessActorStartup + ) { + const { watchingTargetTypes } = watcherDataObject; + // Ensure creating and watching only once per target type and watcher actor. + if (watchingTargetTypes.includes(targetType)) { + return; } - const { DevToolsServer } = this.loader.require( - "devtools/server/devtools-server" + watchingTargetTypes.push(targetType); + + // Update sessionData as watched target types are a Session Data + // used later for example by worker target watcher + lazy.SessionDataHelpers.addOrSetSessionDataEntry( + watcherDataObject.sessionData, + "targets", + [targetType], + "add" ); - const { ContentProcessTargetActor } = this.loader.require( - "devtools/server/actors/targets/content-process" - ); - - DevToolsServer.init(); + this.#watchers[targetType].activeListener++; - // For browser content toolbox, we do need a regular root actor and all tab - // actors, but don't need all the "browser actors" that are only useful when - // debugging the parent process via the browser toolbox. - DevToolsServer.registerActors({ target: true }); - DevToolsServer.on("connectionchange", this._onConnectionChange); + // Start listening for platform events when we are observing this type for the first time + if (this.#watchers[targetType].activeListener === 1) { + this.#watchers[targetType].watcher.watch(); + } - const connection = DevToolsServer.connectToParentWindowActor( - this, - forwardingPrefix, - "DevToolsProcessChild:packet" + // And instantiate targets for the already existing instances + this.#watchers[targetType].watcher.createTargetsForWatcher( + watcherDataObject, + isProcessActorStartup ); - - // Create the actual target actor. - const targetActor = new ContentProcessTargetActor(connection, { - sessionContext: sessionData.sessionContext, - }); - // There is no root actor in content processes and so - // the target actor can't be managed by it, but we do have to manage - // the actor to have it working and be registered in the DevToolsServerConnection. - // We make it manage itself and become a top level actor. - targetActor.manage(targetActor); - - const form = targetActor.form(); - targetActor.once("destroyed", options => { - // This will destroy the content process one - this._destroyTargetActor(watcherActorID, options.isModeSwitching); - // And this will destroy the parent process one - try { - this.sendAsyncMessage("DevToolsProcessChild:destroy", { - actors: [ - { - watcherActorID, - form, - }, - ], - options, - }); - } catch (e) { - // Ignore exception when the JSProcessActorChild has already been destroyed. - // We often try to emit this message while the process is being destroyed, - // but sendAsyncMessage doesn't have time to complete and throws. - if ( - !e.message.includes("JSProcessActorChild cannot send at the moment") - ) { - throw e; - } - } - }); - - return { connection, targetActor }; } /** - * Destroy the server once its last connection closes. Note that multiple - * frame scripts may be running in parallel and reuse the same server. + * Stop watching for all target types and destroy all existing targets actor + * related to a given watcher actor. + * + * @param {Object} watcherDataObject + * @param {String} targetType + * @param {Object} options */ - _onConnectionChange() { - if (this._destroyed) { + #unwatchTargetsForWatcher(watcherDataObject, targetType, options) { + const { watchingTargetTypes } = watcherDataObject; + const targetTypeIndex = watchingTargetTypes.indexOf(targetType); + // Ignore targetTypes which were not observed + if (targetTypeIndex === -1) { return; } - this._destroyed = true; + // Update to the new list of currently watched target types + watchingTargetTypes.splice(targetTypeIndex, 1); + + // Update sessionData as watched target types are a Session Data + // used later for example by worker target watcher + lazy.SessionDataHelpers.removeSessionDataEntry( + watcherDataObject.sessionData, + "targets", + [targetType] + ); - const { DevToolsServer } = this.loader.require( - "devtools/server/devtools-server" + this.#watchers[targetType].activeListener--; + + // Stop observing for platform events + if (this.#watchers[targetType].activeListener === 0) { + this.#watchers[targetType].watcher.unwatch(); + } + + // Destroy all targets which are still instantiated for this type + this.#watchers[targetType].watcher.destroyTargetsForWatcher( + watcherDataObject, + options ); - // Only destroy the server if there is no more connections to it. It may be - // used to debug another tab running in the same process. - if (DevToolsServer.hasConnection() || DevToolsServer.keepAlive) { - return; + // Unregister the watcher if we stopped watching for all target types + if (!watchingTargetTypes.length) { + ContentProcessWatcherRegistry.remove(watcherDataObject); } - DevToolsServer.off("connectionchange", this._onConnectionChange); - DevToolsServer.destroy(); + // If we removed the last watcher, clean the internal state of this class. + if (ContentProcessWatcherRegistry.isEmpty()) { + this.didDestroy(options); + } } /** - * Supported Queries + * Cleanup everything around a given watcher actor + * + * @param {Object} watcherDataObject */ + #destroyWatcher(watcherDataObject) { + const { watchingTargetTypes } = watcherDataObject; + // Clone the array as it will be modified during the loop execution + for (const targetType of [...watchingTargetTypes]) { + this.#unwatchTargetsForWatcher(watcherDataObject, targetType); + } + } + /** + * Used by DevTools Transport to send packets to the content process. + * + * @param {JSON} packet + * @param {String} prefix + */ sendPacket(packet, prefix) { this.sendAsyncMessage("DevToolsProcessChild:packet", { packet, prefix }); } @@ -276,23 +276,33 @@ export class DevToolsProcessChild extends JSProcessActorChild { } } + /** + * Called by the JSProcessActor API when the process process sent us a message. + */ receiveMessage(message) { switch (message.name) { - case "DevToolsProcessParent:instantiate-already-available": { - const { watcherActorID, connectionPrefix, sessionData } = message.data; - return this._createTargetActor( - watcherActorID, - connectionPrefix, - sessionData + case "DevToolsProcessParent:watchTargets": { + const { watcherActorID, targetType } = message.data; + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID); + return this.#watchNewTargetTypeForWatcher( + watcherDataObject, + targetType ); } - case "DevToolsProcessParent:destroy": { - const { watcherActorID, isModeSwitching } = message.data; - return this._destroyTargetActor(watcherActorID, isModeSwitching); + case "DevToolsProcessParent:unwatchTargets": { + const { watcherActorID, targetType, options } = message.data; + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID); + return this.#unwatchTargetsForWatcher( + watcherDataObject, + targetType, + options + ); } case "DevToolsProcessParent:addOrSetSessionDataEntry": { const { watcherActorID, type, entries, updateType } = message.data; - return this._addOrSetSessionDataEntry( + return this.#addOrSetSessionDataEntry( watcherActorID, type, entries, @@ -301,7 +311,20 @@ export class DevToolsProcessChild extends JSProcessActorChild { } case "DevToolsProcessParent:removeSessionDataEntry": { const { watcherActorID, type, entries } = message.data; - return this._removeSessionDataEntry(watcherActorID, type, entries); + return this.#removeSessionDataEntry(watcherActorID, type, entries); + } + case "DevToolsProcessParent:destroyWatcher": { + const { watcherActorID } = message.data; + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject( + watcherActorID, + true + ); + // The watcher may already be destroyed if the client unwatched for all target types. + if (watcherDataObject) { + return this.#destroyWatcher(watcherDataObject); + } + return null; } case "DevToolsProcessParent:packet": return this.emit("packet-received", message); @@ -312,51 +335,160 @@ export class DevToolsProcessChild extends JSProcessActorChild { } } - _getTargetActorForWatcherActorID(watcherActorID) { - const connectionInfo = this._connections.get(watcherActorID); - return connectionInfo?.actor; - } - - _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) { - const targetActor = this._getTargetActorForWatcherActorID(watcherActorID); - if (!targetActor) { - throw new Error( - `No target actor for this Watcher Actor ID:"${watcherActorID}"` - ); - } - return targetActor.addOrSetSessionDataEntry( + /** + * The parent process requested that some session data have been added or set. + * + * @param {String} watcherActorID + * The Watcher Actor ID requesting to add new session data + * @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 #addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) { + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID); + + // Maintain the copy of `sessionData` so that it is up-to-date when + // a new worker target needs to be instantiated + const { sessionData } = watcherDataObject; + lazy.SessionDataHelpers.addOrSetSessionDataEntry( + sessionData, type, entries, - false, updateType ); + + // This type is really specific to Service Workers and doesn't need to be transferred to any target. + // We only need to instantiate and destroy the target actors based on this new host. + const { watchingTargetTypes } = watcherDataObject; + if (type == "browser-element-host") { + if (watchingTargetTypes.includes("service_worker")) { + this.#watchers.service_worker.watcher.updateBrowserElementHost( + watcherDataObject + ); + } + return; + } + + const promises = []; + for (const targetActor of watcherDataObject.actors) { + promises.push( + targetActor.addOrSetSessionDataEntry(type, entries, false, updateType) + ); + } + + // Very special codepath for Web Extensions. + // Their WebExtension Target Actor is still created manually by WebExtensionDescritpor.getTarget, + // via a message manager. That, instead of being instantiated via the WatcherActor.watchTargets and this JSProcess actor. + // The Watcher Actor will still instantiate a JS Actor for the WebExt DOM Content Process + // and send the addOrSetSessionDataEntry query. But as the target actor isn't managed by the JS Actor, + // we have to manually retrieve it via the TargetActorRegistry. + if (sessionData.sessionContext.type == "webextension") { + const connectionPrefix = watcherActorID.replace(/watcher\d+$/, ""); + const targetActors = lazy.TargetActorRegistry.getTargetActors( + sessionData.sessionContext, + connectionPrefix + ); + // We will have a single match only in the DOM Process where the add-on runs + if (targetActors.length) { + promises.push( + targetActors[0].addOrSetSessionDataEntry( + type, + entries, + false, + updateType + ) + ); + } + } + await Promise.all(promises); + + if (watchingTargetTypes.includes("worker")) { + await this.#watchers.worker.watcher.addOrSetSessionDataEntry( + watcherDataObject, + type, + entries, + updateType + ); + } + if (watchingTargetTypes.includes("service_worker")) { + await this.#watchers.service_worker.watcher.addOrSetSessionDataEntry( + watcherDataObject, + type, + entries, + updateType + ); + } } - _removeSessionDataEntry(watcherActorID, type, entries) { - const targetActor = this._getTargetActorForWatcherActorID(watcherActorID); - // By the time we are calling this, the target may already have been destroyed. - if (!targetActor) { - return null; + /** + * The parent process requested that some session data have been removed. + * + * @param {String} watcherActorID + * The Watcher Actor ID requesting to remove session data + * @param {String}} type + * The type of data to be removed + * @param {Array<Object>} entries + * The values to be removed to this type of data + */ + #removeSessionDataEntry(watcherActorID, type, entries) { + const watcherDataObject = + ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID, true); + + // When we unwatch resources after targets during the devtools shutdown, + // the watcher will be removed on last target type unwatch. + if (!watcherDataObject) { + return; + } + + // Maintain the copy of `sessionData` so that it is up-to-date when + // a new worker target needs to be instantiated + lazy.SessionDataHelpers.removeSessionDataEntry( + watcherDataObject.sessionData, + type, + entries + ); + + for (const targetActor of watcherDataObject.actors) { + targetActor.removeSessionDataEntry(type, entries); } - return targetActor.removeSessionDataEntry(type, entries); } - observe(subject, topic) { + /** + * Observer service notification handler. + * + * @param {DOMWindow|Document} subject + * A window for *-document-global-created + * A document for *-page-{shown|hide} + * @param {String} topic + */ + observe = (subject, topic) => { if (topic === "init-devtools-content-process-actor") { // This is triggered by the process actor registration and some code in process-helper.js // which defines a unique topic to be observed this.instantiate(); } - } + }; - didDestroy(options) { - for (const { connection } of this._connections.values()) { - connection.close(options); - } - this._connections.clear(); - if (this.loader) { - lazy.releaseDistinctSystemPrincipalLoader(this); - this.loader = null; + /** + * Called by JS Process Actor API when the current process is destroyed, + * but also within this class when the last watcher stopped watching for targets. + */ + didDestroy() { + // Stop watching for all target types + for (const entry of Object.values(this.#watchers)) { + if (entry.activeListener > 0) { + entry.watcher.unwatch(); + entry.activeListener = 0; + } } + + ContentProcessWatcherRegistry.clear(); } } + +export class BrowserToolboxDevToolsProcessChild extends DevToolsProcessChild {} diff --git a/devtools/server/connectors/js-process-actor/DevToolsProcessParent.sys.mjs b/devtools/server/connectors/js-process-actor/DevToolsProcessParent.sys.mjs index 28e11def68..303c85e68f 100644 --- a/devtools/server/connectors/js-process-actor/DevToolsProcessParent.sys.mjs +++ b/devtools/server/connectors/js-process-actor/DevToolsProcessParent.sys.mjs @@ -5,9 +5,9 @@ import { loader } from "resource://devtools/shared/loader/Loader.sys.mjs"; import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; -const { WatcherRegistry } = ChromeUtils.importESModule( - "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs", - // WatcherRegistry needs to be a true singleton and loads ActorManagerParent +const { ParentProcessWatcherRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/ParentProcessWatcherRegistry.sys.mjs", + // ParentProcessWatcherRegistry needs to be a true singleton and loads ActorManagerParent // which also has to be a true singleton. { global: "shared" } ); @@ -24,8 +24,6 @@ export class DevToolsProcessParent extends JSProcessActorParent { constructor() { super(); - this._destroyed = false; - // Map of DevToolsServerConnection's used to forward the messages from/to // the client. The connections run in the parent process, as this code. We // may have more than one when there is more than one client debugging the @@ -44,41 +42,38 @@ export class DevToolsProcessParent extends JSProcessActorParent { // which can be considered as a kind of id. On top of this, parent process // DevToolsServerConnections also have forwarding prefixes because they are // responsible for forwarding messages to content process connections. - this._connections = new Map(); - this._onConnectionClosed = this._onConnectionClosed.bind(this); EventEmitter.decorate(this); } + #destroyed = false; + #connections = new Map(); + /** - * Request the content process to create the ContentProcessTarget + * Request the content process to create all the targets currently watched + * and start observing for new ones to be created later. */ - instantiateTarget({ - watcherActorID, - connectionPrefix, - sessionContext, - sessionData, - }) { - return this.sendQuery( - "DevToolsProcessParent:instantiate-already-available", - { - watcherActorID, - connectionPrefix, - sessionContext, - sessionData, - } - ); + watchTargets({ watcherActorID, targetType }) { + return this.sendQuery("DevToolsProcessParent:watchTargets", { + watcherActorID, + targetType, + }); } - destroyTarget({ watcherActorID, isModeSwitching }) { - this.sendAsyncMessage("DevToolsProcessParent:destroy", { + /** + * Request the content process to stop observing for currently watched targets + * and destroy all the currently active ones. + */ + unwatchTargets({ watcherActorID, targetType, options }) { + this.sendAsyncMessage("DevToolsProcessParent:unwatchTargets", { watcherActorID, - isModeSwitching, + targetType, + options, }); } /** - * Communicate to the content process that some data have been added. + * Communicate to the content process that some data have been added or set. */ addOrSetSessionDataEntry({ watcherActorID, type, entries, updateType }) { return this.sendQuery("DevToolsProcessParent:addOrSetSessionDataEntry", { @@ -100,8 +95,17 @@ export class DevToolsProcessParent extends JSProcessActorParent { }); } - connectFromContent({ watcherActorID, forwardingPrefix, actor }) { - const watcher = WatcherRegistry.getWatcher(watcherActorID); + destroyWatcher({ watcherActorID }) { + return this.sendAsyncMessage("DevToolsProcessParent:destroyWatcher", { + watcherActorID, + }); + } + + /** + * Called when the content process notified us about a new target actor + */ + #onTargetAvailable({ watcherActorID, forwardingPrefix, targetActorForm }) { + const watcher = ParentProcessWatcherRegistry.getWatcher(watcherActorID); if (!watcher) { throw new Error( @@ -110,45 +114,86 @@ export class DevToolsProcessParent extends JSProcessActorParent { } const connection = watcher.conn; - connection.on("closed", this._onConnectionClosed); + // If this is the first target actor for this watcher, + // hook up the DevToolsServerConnection which will bridge + // communication between the parent process DevToolsServer + // and the content process. + if (!this.#connections.get(watcher.conn.prefix)) { + connection.on("closed", this.#onConnectionClosed); - // Create a js-window-actor based transport. - const transport = new lazy.JsWindowActorTransport( - this, - forwardingPrefix, - "DevToolsProcessParent:packet" - ); - transport.hooks = { - onPacket: connection.send.bind(connection), - onClosed() {}, - }; - transport.ready(); + // Create a js-window-actor based transport. + const transport = new lazy.JsWindowActorTransport( + this, + forwardingPrefix, + "DevToolsProcessParent:packet" + ); + transport.hooks = { + onPacket: connection.send.bind(connection), + onClosed() {}, + }; + transport.ready(); - connection.setForwarding(forwardingPrefix, transport); + connection.setForwarding(forwardingPrefix, transport); - this._connections.set(watcher.conn.prefix, { - watcher, - connection, - // This prefix is the prefix of the DevToolsServerConnection, running - // in the content process, for which we should forward packets to, based on its prefix. - // While `watcher.connection` is also a DevToolsServerConnection, but from this process, - // the parent process. It is the one receiving Client packets and the one, from which - // we should forward packets from. - forwardingPrefix, - transport, - actor, - }); + this.#connections.set(watcher.conn.prefix, { + watcher, + connection, + // This prefix is the prefix of the DevToolsServerConnection, running + // in the content process, for which we should forward packets to, based on its prefix. + // While `watcher.connection` is also a DevToolsServerConnection, but from this process, + // the parent process. It is the one receiving Client packets and the one, from which + // we should forward packets from. + forwardingPrefix, + transport, + targetActorForms: [], + }); + } - watcher.notifyTargetAvailable(actor); + this.#connections + .get(watcher.conn.prefix) + .targetActorForms.push(targetActorForm); + + watcher.notifyTargetAvailable(targetActorForm); } - _onConnectionClosed(status, prefix) { - if (this._connections.has(prefix)) { - const { connection } = this._connections.get(prefix); - this._cleanupConnection(connection); + /** + * Called when the content process notified us about a target actor that has been destroyed. + */ + #onTargetDestroyed({ actors, options }) { + for (const { watcherActorID, targetActorForm } of actors) { + const watcher = ParentProcessWatcherRegistry.getWatcher(watcherActorID); + // As we instruct to destroy all targets when the watcher is destroyed, + // we may easily receive the target destruction notification *after* + // the watcher has been removed from the registry. + if (!watcher || watcher.isDestroyed()) { + continue; + } + watcher.notifyTargetDestroyed(targetActorForm, options); + const connectionInfo = this.#connections.get(watcher.conn.prefix); + if (connectionInfo) { + const idx = connectionInfo.targetActorForms.findIndex( + form => form.actor == targetActorForm.actor + ); + if (idx != -1) { + connectionInfo.targetActorForms.splice(idx, 1); + } + // Once the last active target is removed, disconnect the DevTools transport + // and cleanup everything bound to this DOM Process. We will re-instantiate + // a new connection/transport on the next reported target actor. + if (!connectionInfo.targetActorForms.length) { + this.#cleanupConnection(connectionInfo.connection); + } + } } } + #onConnectionClosed = (status, prefix) => { + if (this.#connections.has(prefix)) { + const { connection } = this.#connections.get(prefix); + this.#cleanupConnection(connection); + } + }; + /** * Close and unregister a given DevToolsServerConnection. * @@ -157,30 +202,27 @@ export class DevToolsProcessParent extends JSProcessActorParent { * @param {boolean} options.isModeSwitching * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref */ - async _cleanupConnection(connection, options = {}) { - const connectionInfo = this._connections.get(connection.prefix); - if (!connectionInfo) { - return; + async #cleanupConnection(connection, options = {}) { + const watcherConnectionInfo = this.#connections.get(connection.prefix); + if (watcherConnectionInfo) { + const { forwardingPrefix, transport } = watcherConnectionInfo; + if (transport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this transport. + transport.close(options); + } + // When cancelling the forwarding, one RDP event is sent to the client to purge all requests + // and actors related to a given prefix. + // Be careful that any late RDP event would be ignored by the client passed this call. + connection.cancelForwarding(forwardingPrefix); } - const { forwardingPrefix, transport } = connectionInfo; - connection.off("closed", this._onConnectionClosed); - if (transport) { - // If we have a child transport, the actor has already - // been created. We need to stop using this transport. - transport.close(options); - } + connection.off("closed", this.#onConnectionClosed); - this._connections.delete(connection.prefix); - if (!this._connections.size) { - this._destroy(options); + this.#connections.delete(connection.prefix); + if (!this.#connections.size) { + this.#destroy(options); } - - // When cancelling the forwarding, one RDP event is sent to the client to purge all requests - // and actors related to a given prefix. Do this *after* calling _destroy which will emit - // the target-destroyed RDP event. This helps the Watcher Front retrieve the related target front, - // otherwise it would be too eagerly destroyed by the purge event. - connection.cancelForwarding(forwardingPrefix); } /** @@ -190,20 +232,26 @@ export class DevToolsProcessParent extends JSProcessActorParent { * @param {boolean} options.isModeSwitching * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref */ - _destroy(options) { - if (this._destroyed) { + #destroy(options) { + if (this.#destroyed) { return; } - this._destroyed = true; + this.#destroyed = true; - for (const { actor, connection, watcher } of this._connections.values()) { - watcher.notifyTargetDestroyed(actor, options); - this._cleanupConnection(connection, options); + for (const { + targetActorForms, + connection, + watcher, + } of this.#connections.values()) { + for (const actor of targetActorForms) { + watcher.notifyTargetDestroyed(actor, options); + } + this.#cleanupConnection(connection, options); } } /** - * Supported Queries + * Used by DevTools Transport to send packets to the content process. */ sendPacket(packet, prefix) { @@ -211,7 +259,7 @@ export class DevToolsProcessParent extends JSProcessActorParent { } /** - * JsWindowActor API + * JsProcessActor API */ async sendQuery(msg, args) { @@ -225,24 +273,43 @@ export class DevToolsProcessParent extends JSProcessActorParent { } } + /** + * Called by the JSProcessActor API when the content process sent us a message + */ receiveMessage(message) { switch (message.name) { - case "DevToolsProcessChild:connectFromContent": - return this.connectFromContent(message.data); + case "DevToolsProcessChild:targetAvailable": + return this.#onTargetAvailable(message.data); case "DevToolsProcessChild:packet": return this.emit("packet-received", message); - case "DevToolsProcessChild:destroy": - for (const { form, watcherActorID } of message.data.actors) { - const watcher = WatcherRegistry.getWatcher(watcherActorID); - // As we instruct to destroy all targets when the watcher is destroyed, - // we may easily receive the target destruction notification *after* - // the watcher has been removed from the registry. - if (watcher) { - watcher.notifyTargetDestroyed(form, message.data.options); - this._cleanupConnection(watcher.conn, message.data.options); - } + case "DevToolsProcessChild:targetDestroyed": + return this.#onTargetDestroyed(message.data); + case "DevToolsProcessChild:bf-cache-navigation-pageshow": { + const browsingContext = BrowsingContext.get( + message.data.browsingContextId + ); + for (const watcherActor of ParentProcessWatcherRegistry.getWatchersForBrowserId( + browsingContext.browserId + )) { + watcherActor.emit("bf-cache-navigation-pageshow", { + windowGlobal: browsingContext.currentWindowGlobal, + }); } return null; + } + case "DevToolsProcessChild:bf-cache-navigation-pagehide": { + const browsingContext = BrowsingContext.get( + message.data.browsingContextId + ); + for (const watcherActor of ParentProcessWatcherRegistry.getWatchersForBrowserId( + browsingContext.browserId + )) { + watcherActor.emit("bf-cache-navigation-pagehide", { + windowGlobal: browsingContext.currentWindowGlobal, + }); + } + return null; + } default: throw new Error( "Unsupported message in DevToolsProcessParent: " + message.name @@ -250,7 +317,12 @@ export class DevToolsProcessParent extends JSProcessActorParent { } } + /** + * Called by the JSProcessActor API when this content process is destroyed. + */ didDestroy() { - this._destroy(); + this.#destroy(); } } + +export class BrowserToolboxDevToolsProcessParent extends DevToolsProcessParent {} diff --git a/devtools/server/connectors/js-process-actor/content-process-jsprocessactor-startup.js b/devtools/server/connectors/js-process-actor/content-process-jsprocessactor-startup.js new file mode 100644 index 0000000000..fbc71e2d90 --- /dev/null +++ b/devtools/server/connectors/js-process-actor/content-process-jsprocessactor-startup.js @@ -0,0 +1,33 @@ +/* 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"; + +/* + We want this to only startup the DevToolsProcess JS Actor on process start + and not when we only register the JS Process Actor when watching the first target type. + The Watcher Actor will query each individual JS Process Actor and fine control + the ordering of requests. It is especially important to spawn the top level target first. +*/ +const isContentProcessStartup = !Services.ww + .getWindowEnumerator() + .hasMoreElements(); +if (isContentProcessStartup) { + /* + 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 ?? + */ + Services.tm.dispatchToMainThread(() => { + /* + 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-content-process-actor"); + /* + Instead of using observer service, we could also manually call some method of the actor: + ChromeUtils.domProcessChild.getActor("DevToolsProcess").observe(null, "init-devtools-content-process-actor"); + */ + }); +} diff --git a/devtools/server/connectors/js-process-actor/moz.build b/devtools/server/connectors/js-process-actor/moz.build index e1a1f5dc9d..c1843b4e16 100644 --- a/devtools/server/connectors/js-process-actor/moz.build +++ b/devtools/server/connectors/js-process-actor/moz.build @@ -4,7 +4,13 @@ # 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-watchers", +] + DevToolsModules( + "content-process-jsprocessactor-startup.js", + "ContentProcessWatcherRegistry.sys.mjs", "DevToolsProcessChild.sys.mjs", "DevToolsProcessParent.sys.mjs", ) diff --git a/devtools/server/connectors/js-process-actor/target-watchers/moz.build b/devtools/server/connectors/js-process-actor/target-watchers/moz.build new file mode 100644 index 0000000000..0574b0399e --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "process.sys.mjs", + "service_worker.sys.mjs", + "window-global.sys.mjs", + "worker.sys.mjs", +) diff --git a/devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs b/devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs new file mode 100644 index 0000000000..c2b6dd807c --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; + +function watch() { + // There is nothing to watch. This JS Process Actor will automatically be spawned + // for each new DOM Process. +} +function unwatch() {} + +function createTargetsForWatcher(watcherDataObject) { + // Always ignore the parent process. A special WindowGlobal target actor will be spawned. + if (ChromeUtils.domProcessChild.childID == 0) { + return; + } + + createContentProcessTargetActor(watcherDataObject); +} + +/** + * Instantiate a content process target actor for the current process + * and for a given watcher actor. + * + * @param {Object} watcherDataObject + */ +function createContentProcessTargetActor(watcherDataObject) { + logDOMProcess( + ChromeUtils.domProcessChild, + "Instantiate ContentProcessTarget" + ); + + const { connection, loader } = + ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( + watcherDataObject.watcherActorID + ); + + const { ContentProcessTargetActor } = loader.require( + "devtools/server/actors/targets/content-process" + ); + + // Create the actual target actor. + const targetActor = new ContentProcessTargetActor(connection, { + sessionContext: watcherDataObject.sessionContext, + }); + + ContentProcessWatcherRegistry.onNewTargetActor( + watcherDataObject, + targetActor + ); +} + +function destroyTargetsForWatcher(watcherDataObject, options) { + // Unregister and destroy the existing target actors for this target type + const actorsToDestroy = watcherDataObject.actors.filter( + actor => actor.targetType == "process" + ); + watcherDataObject.actors = watcherDataObject.actors.filter( + actor => actor.targetType != "process" + ); + + for (const actor of actorsToDestroy) { + ContentProcessWatcherRegistry.destroyTargetActor( + watcherDataObject, + actor, + options + ); + } +} + +// If true, log info about DOMProcess's being created. +const DEBUG = false; + +/** + * Print information about operation being done against each content process. + * + * @param {nsIDOMProcessChild} domProcessChild + * The process for which we should log a message. + * @param {String} message + * Message to log. + */ +function logDOMProcess(domProcessChild, message) { + if (!DEBUG) { + return; + } + dump(" [pid:" + domProcessChild + "] " + message + "\n"); +} + +export const ProcessTargetWatcher = { + watch, + unwatch, + createTargetsForWatcher, + destroyTargetsForWatcher, +}; diff --git a/devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs b/devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs new file mode 100644 index 0000000000..f2f307f297 --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { WorkerTargetWatcherClass } from "resource://devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +class ServiceWorkerTargetWatcherClass extends WorkerTargetWatcherClass { + constructor() { + super("service_worker"); + } + + /** + * Called whenever the debugged browser element navigates to a new page + * and the URL's host changes. + * This is used to maintain the list of active Service Worker targets + * based on that host name. + * + * @param {Object} watcherDataObject + * See ContentProcessWatcherRegistry + */ + async updateBrowserElementHost(watcherDataObject) { + const { sessionData } = watcherDataObject; + + // Create target actor matching this new host. + // Note that we may be navigating to the same host name and the target will already exist. + const promises = []; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + const alreadyCreated = watcherDataObject.workers.some( + info => info.dbg === dbg + ); + if ( + this.shouldHandleWorker(sessionData, dbg, "service_worker") && + !alreadyCreated + ) { + promises.push(this.createWorkerTargetActor(watcherDataObject, dbg)); + } + } + await Promise.all(promises); + } +} + +export const ServiceWorkerTargetWatcher = new ServiceWorkerTargetWatcherClass(); diff --git a/devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs b/devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs new file mode 100644 index 0000000000..66c71cbc1e --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs @@ -0,0 +1,574 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters( + lazy, + { + isWindowGlobalPartOfContext: + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", + WindowGlobalLogger: + "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs", + }, + { global: "contextual" } +); + +// TargetActorRegistery has to be shared between all devtools instances +// and so is loaded into the shared global. +ChromeUtils.defineESModuleGetters( + lazy, + { + TargetActorRegistry: + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", + }, + { global: "shared" } +); + +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); + +// If true, log info about DOMProcess's being created. +const DEBUG = false; + +/** + * Print information about operation being done against each Window Global. + * + * @param {WindowGlobalChild} windowGlobal + * The window global for which we should log a message. + * @param {String} message + * Message to log. + */ +function logWindowGlobal(windowGlobal, message) { + if (!DEBUG) { + return; + } + lazy.WindowGlobalLogger.logWindowGlobal(windowGlobal, message); +} + +function watch() { + // Set the following preference in this function, so that we can easily + // toggle these preferences on and off from tests and have the new value being picked up. + + // bfcache-in-parent changes significantly how navigation behaves. + // We may start reusing previously existing WindowGlobal and so reuse + // previous set of JSWindowActor pairs (i.e. DevToolsProcessParent/DevToolsProcessChild). + // When enabled, regular navigations may also change and spawn new BrowsingContexts. + // If the page we navigate from supports being stored in bfcache, + // the navigation will use a new BrowsingContext. And so force spawning + // a new top-level target. + ChromeUtils.defineLazyGetter( + lazy, + "isBfcacheInParentEnabled", + () => + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent", false) + ); + + // Observe for all necessary event to track new and destroyed WindowGlobals. + Services.obs.addObserver(observe, "content-document-global-created"); + Services.obs.addObserver(observe, "chrome-document-global-created"); + Services.obs.addObserver(observe, "content-page-shown"); + Services.obs.addObserver(observe, "chrome-page-shown"); + Services.obs.addObserver(observe, "content-page-hidden"); + Services.obs.addObserver(observe, "chrome-page-hidden"); + Services.obs.addObserver(observe, "inner-window-destroyed"); + Services.obs.addObserver(observe, "initial-document-element-inserted"); +} + +function unwatch() { + // Observe for all necessary event to track new and destroyed WindowGlobals. + Services.obs.removeObserver(observe, "content-document-global-created"); + Services.obs.removeObserver(observe, "chrome-document-global-created"); + Services.obs.removeObserver(observe, "content-page-shown"); + Services.obs.removeObserver(observe, "chrome-page-shown"); + Services.obs.removeObserver(observe, "content-page-hidden"); + Services.obs.removeObserver(observe, "chrome-page-hidden"); + Services.obs.removeObserver(observe, "inner-window-destroyed"); + Services.obs.removeObserver(observe, "initial-document-element-inserted"); +} + +function createTargetsForWatcher(watcherDataObject, isProcessActorStartup) { + const { sessionContext } = watcherDataObject; + // Bug 1785266 - For now, in browser, when debugging the parent process (childID == 0), + // we spawn only the ParentProcessTargetActor, which will debug all the BrowsingContext running in the process. + // So that we have to avoid instantiating any here. + if ( + sessionContext.type == "all" && + ChromeUtils.domProcessChild.childID === 0 + ) { + return; + } + + function lookupForTargets(window) { + // Do not only track top level BrowsingContext in this content process, + // but also any nested iframe which may be running in the same process. + for (const browsingContext of window.docShell.browsingContext.getAllBrowsingContextsInSubtree()) { + const { currentWindowContext } = browsingContext; + // Only consider Window Global which are running in this process + if (!currentWindowContext || !currentWindowContext.isInProcess) { + continue; + } + + // WindowContext's windowGlobalChild should be defined for WindowGlobal running in this process + const { windowGlobalChild } = currentWindowContext; + + // getWindowEnumerator will expose somewhat unexpected WindowGlobal when a tab navigated. + // This will expose WindowGlobals of past navigations. Document which are in the bfcache + // and aren't the current WindowGlobal of their BrowsingContext. + if (!windowGlobalChild.isCurrentGlobal) { + continue; + } + + // Accept the initial about:blank document: + // - only from createTargetsForWatcher, when instantiating the target for the already existing WindowGlobals, + // - when we do that on toolbox opening, to prevent creating one when the process is starting. + // + // This is to allow debugging blank tabs, which are on an initial about:blank document. + // + // We want to avoid creating transient targets for initial about blank when a new WindowGlobal + // just get created as it will most likely navigate away just after and confuse the frontend with short lived target. + const acceptInitialDocument = !isProcessActorStartup; + + if ( + lazy.isWindowGlobalPartOfContext(windowGlobalChild, sessionContext, { + acceptInitialDocument, + }) + ) { + createWindowGlobalTargetActor(watcherDataObject, windowGlobalChild); + } else if ( + !browsingContext.parent && + sessionContext.browserId && + browsingContext.browserId == sessionContext.browserId && + browsingContext.window.document.isInitialDocument + ) { + // In order to succesfully get the devtools-html-content event in SourcesManager, + // we have to ensure flagging the initial about:blank document... + // While we don't create a target for it, we need to set this flag for this event to be emitted. + browsingContext.watchedByDevTools = true; + } + } + } + for (const window of Services.ww.getWindowEnumerator()) { + lookupForTargets(window); + + // `lookupForTargets` uses `getAllBrowsingContextsInSubTree`, but this will ignore browser elements + // using type="content". So manually retrieve the windows for these browser elements, + // in case we have tabs opened on document loaded in the same process. + // This codepath is meant when we are in the parent process, with browser.xhtml having these <browser type="content"> + // elements for tabs. + for (const browser of window.document.querySelectorAll( + `browser[type="content"]` + )) { + const childWindow = browser.browsingContext.window; + // If the tab isn't on a document loaded in the parent process, + // the window will be null. + if (childWindow) { + lookupForTargets(childWindow); + } + } + } +} + +function destroyTargetsForWatcher(watcherDataObject, options) { + // Unregister and destroy the existing target actors for this target type + const actorsToDestroy = watcherDataObject.actors.filter( + actor => actor.targetType == "frame" + ); + watcherDataObject.actors = watcherDataObject.actors.filter( + actor => actor.targetType != "frame" + ); + + for (const actor of actorsToDestroy) { + ContentProcessWatcherRegistry.destroyTargetActor( + watcherDataObject, + actor, + options + ); + } +} + +/** + * Called whenever a new WindowGlobal is instantiated either: + * - when navigating to a new page (DOMWindowCreated) + * - by a bfcache navigation (pageshow) + * + * @param {Window} window + * @param {Object} options + * @param {Boolean} options.isBFCache + * True, if the request to instantiate a new target comes from a bfcache navigation. + * i.e. when we receive a pageshow event with persisted=true. + * This will be true regardless of bfcacheInParent being enabled or disabled. + * @param {Boolean} options.ignoreIfExisting + * By default to false. If true is passed, we avoid instantiating a target actor + * if one already exists for this windowGlobal. + */ +function onWindowGlobalCreated( + window, + { isBFCache = false, ignoreIfExisting = false } = {} +) { + try { + const windowGlobal = window.windowGlobalChild; + + // For bfcache navigations, we only create new targets when bfcacheInParent is enabled, + // as this would be the only case where new DocShells will be created. This requires us to spawn a + // new WindowGlobalTargetActor as such actor is bound to a unique DocShell. + const forceAcceptTopLevelTarget = + isBFCache && lazy.isBfcacheInParentEnabled; + + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + "frame" + )) { + const { sessionContext } = watcherDataObject; + if ( + lazy.isWindowGlobalPartOfContext(windowGlobal, sessionContext, { + forceAcceptTopLevelTarget, + }) + ) { + // If this was triggered because of a navigation, we want to retrieve the existing + // target we were debugging so we can destroy it before creating the new target. + // This is important because we had cases where the destruction of an old target + // was unsetting a flag on the **new** target document, breaking the toolbox (See Bug 1721398). + + // We're checking for an existing target given a watcherActorID + browserId + browsingContext. + // Note that a target switch might create a new browsing context, so we wouldn't + // retrieve the existing target here. We are okay with this as: + // - this shouldn't happen much + // - in such case we weren't seeing the issue of Bug 1721398 (the old target can't access the new document) + const existingTarget = findTargetActor({ + watcherDataObject, + innerWindowId: windowGlobal.innerWindowId, + }); + + // See comment in `observe()` method and `DOMDocElementInserted` condition to know why we sometime + // ignore this method call if a target actor already exists. + // It means that we got a previous DOMWindowCreated event, related to a non-about:blank document, + // and we should ignore the DOMDocElementInserted. + // In any other scenario, destroy the already existing target and re-create a new one. + if (existingTarget && ignoreIfExisting) { + continue; + } + + // Bail if there is already an existing WindowGlobalTargetActor which wasn't + // created from a JSWIndowActor. + // This means we are reloading or navigating (same-process) a Target + // which has not been created using the Watcher, but from the client (most likely + // the initial target of a local-tab toolbox). + // However, we force overriding the first message manager based target in case of + // BFCache navigations. + if ( + existingTarget && + !existingTarget.createdFromJsWindowActor && + !isBFCache + ) { + continue; + } + + // If we decide to instantiate a new target and there was one before, + // first destroy the previous one. + // Otherwise its destroy sequence will be executed *after* the new one + // is being initialized and may easily revert changes made against platform API. + // (typically toggle platform boolean attributes back to default…) + if (existingTarget) { + existingTarget.destroy({ isTargetSwitching: true }); + } + + // When navigating to another process, the Watcher Actor won't have sent any query + // to the new process JS Actor as the debugged tab was on another process before navigation. + // But `sharedData` will have data about all the current watchers. + // Here we have to ensure calling watchTargetsForWatcher in order to populate #connections + // for the currently processed watcher actor and start listening for future targets. + if ( + !ContentProcessWatcherRegistry.has(watcherDataObject.watcherActorID) + ) { + throw new Error("Watcher data seems out of sync"); + } + + createWindowGlobalTargetActor(watcherDataObject, windowGlobal, true); + } + } + } catch (e) { + // Ensure logging exception as they are silently ignore otherwise + dump( + " Exception while observing a new window: " + e + "\n" + e.stack + "\n" + ); + } +} + +/** + * Called whenever a WindowGlobal just got destroyed, when closing the tab, or navigating to another one. + * + * @param {innerWindowId} innerWindowId + * The WindowGlobal's unique identifier. + */ +function onWindowGlobalDestroyed(innerWindowId) { + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + "frame" + )) { + const existingTarget = findTargetActor({ + watcherDataObject, + innerWindowId, + }); + + if (!existingTarget) { + continue; + } + + // Do not do anything if both bfcache in parent and server targets are disabled + // As history navigations will be handled within the same DocShell and by the + // same WindowGlobalTargetActor. The actor will listen to pageshow/pagehide by itself. + // We should not destroy any target. + if ( + !lazy.isBfcacheInParentEnabled && + !watcherDataObject.sessionContext.isServerTargetSwitchingEnabled + ) { + continue; + } + // If the target actor isn't in watcher data object, it is a top level actor + // instantiated via a Descriptor's getTarget method. It isn't registered into Watcher objects. + // But we still want to destroy such target actor, and need to manually emit the targetDestroyed to the parent process. + // Hopefully bug 1754452 should allow us to get rid of this workaround by making the top level actor + // be created and managed by the watcher universe, like all the others. + const isTopLevelActorRegisteredOutsideOfWatcherActor = + !watcherDataObject.actors.find( + actor => actor.innerWindowId == innerWindowId + ); + const targetActorForm = isTopLevelActorRegisteredOutsideOfWatcherActor + ? existingTarget.form() + : null; + + existingTarget.destroy(); + + if (isTopLevelActorRegisteredOutsideOfWatcherActor) { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetDestroyed", + { + actors: [ + { + watcherActorID: watcherDataObject.watcherActorID, + targetActorForm, + }, + ], + options: {}, + } + ); + } + } +} + +/** + * Instantiate a WindowGlobal target actor for a given browsing context + * and for a given watcher actor. + * + * @param {Object} watcherDataObject + * @param {BrowsingContext} windowGlobalChild + * @param {Boolean} isDocumentCreation + */ +function createWindowGlobalTargetActor( + watcherDataObject, + windowGlobalChild, + isDocumentCreation = false +) { + logWindowGlobal(windowGlobalChild, "Instantiate WindowGlobalTarget"); + + // When debugging privileged pages running a the shared system compartment, and we aren't in the browser toolbox (which already uses a distinct loader), + // we have to use the distinct loader in order to ensure running DevTools in a distinct compartment than the page we are about to debug + // Such page could be about:addons, chrome://browser/content/browser.xhtml,... + const { browsingContext } = windowGlobalChild; + const useDistinctLoader = + browsingContext.associatedWindow.document.nodePrincipal.isSystemPrincipal; + const { connection, loader } = + ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( + watcherDataObject.watcherActorID, + useDistinctLoader + ); + + const { WindowGlobalTargetActor } = loader.require( + "devtools/server/actors/targets/window-global" + ); + + // In the case of the browser toolbox, tab's BrowsingContext don't have + // any parent BC and shouldn't be considered as top-level. + // This is why we check for browserId's. + const { sessionContext } = watcherDataObject; + const isTopLevelTarget = + !browsingContext.parent && + browsingContext.browserId == sessionContext.browserId; + + // Create the actual target actor. + const targetActor = new WindowGlobalTargetActor(connection, { + docShell: browsingContext.docShell, + // Targets created from the server side, via Watcher actor and DevToolsProcess JSWindow + // actor pairs are following WindowGlobal lifecycle. i.e. will be destroyed on any + // type of navigation/reload. + followWindowGlobalLifeCycle: true, + isTopLevelTarget, + ignoreSubFrames: isEveryFrameTargetEnabled, + sessionContext, + }); + targetActor.createdFromJsWindowActor = true; + + ContentProcessWatcherRegistry.onNewTargetActor( + watcherDataObject, + targetActor, + isDocumentCreation + ); +} + +/** + * Observer service notification handler. + * + * @param {DOMWindow|Document} subject + * A window for *-document-global-created + * A document for *-page-{shown|hide} + * @param {String} topic + */ +function observe(subject, topic) { + if ( + topic == "content-document-global-created" || + topic == "chrome-document-global-created" + ) { + onWindowGlobalCreated(subject); + } else if (topic == "inner-window-destroyed") { + const innerWindowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + onWindowGlobalDestroyed(innerWindowId); + } else if (topic == "content-page-shown" || topic == "chrome-page-shown") { + // The observer service notification doesn't receive the "persisted" DOM Event attribute, + // but thanksfully is fired just before the dispatching of that DOM event. + subject.defaultView.addEventListener("pageshow", handleEvent, { + capture: true, + once: true, + }); + } else if (topic == "content-page-hidden" || topic == "chrome-page-hidden") { + // Same as previous elseif branch + subject.defaultView.addEventListener("pagehide", handleEvent, { + capture: true, + once: true, + }); + } else if (topic == "initial-document-element-inserted") { + // We may be notified about SVG documents which we don't care about here. + if (!subject.location || !subject.defaultView) { + return; + } + // We might have ignored the DOMWindowCreated event because it was the initial about:blank document. + // But when loading same-process iframes, we reuse the WindowGlobal of the previously ignored about:bank document + // to load the actual URL loaded in the iframe. This means we won't have a new DOMWindowCreated + // for the actual document. But there is a DOMDocElementInserted fired just after, that we are processing here + // to create a target for same-process iframes. We only have to tell onWindowGlobalCreated to ignore + // the call if a target was created on the DOMWindowCreated event (if that was a non-about:blank document). + // + // All this means that we still do not create any target for the initial documents. + // It is complex to instantiate targets for initial documents because: + // - it would mean spawning two targets for the same WindowGlobal and sharing the same innerWindowId + // - or have WindowGlobalTargets to handle more than one document (it would mean reusing will-navigate/window-ready events + // both on client and server) + onWindowGlobalCreated(subject.defaultView, { ignoreIfExisting: true }); + } +} + +/** + * DOM Event handler. + * + * @param {String} type + * DOM event name + * @param {Boolean} persisted + * A flag set to true in cache of BFCache navigation + * @param {Document} target + * The navigating document + */ +function handleEvent({ type, persisted, target }) { + // If persisted=true, this is a BFCache navigation. + // + // With Fission enabled and bfcacheInParent, BFCache navigations will spawn a new DocShell + // in the same process: + // * the previous page won't be destroyed, its JSWindowActor will stay alive (`didDestroy` won't be called) + // and a 'pagehide' with persisted=true will be emitted on it. + // * the new page page won't emit any DOMWindowCreated, but instead a pageshow with persisted=true + // will be emitted. + if (type === "pageshow" && persisted) { + // Notify all bfcache navigations, even the one for which we don't create a new target + // as that's being useful for parent process storage resource watchers. + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:bf-cache-navigation-pageshow", + { + browsingContextId: target.defaultView.browsingContext.id, + } + ); + } + + // Here we are going to re-instantiate a target that got destroyed before while processing a pagehide event. + // We force instantiating a new top level target, within `instantiate()` even if server targets are disabled. + // But we only do that if bfcacheInParent is enabled. Otherwise for same-process, same-docshell bfcache navigation, + // we don't want to spawn new targets. + onWindowGlobalCreated(target.defaultView, { + isBFCache: true, + }); + } + + if (type === "pagehide" && persisted) { + // Notify all bfcache navigations, even the one for which we don't create a new target + // as that's being useful for parent process storage resource watchers. + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:bf-cache-navigation-pagehide", + { + browsingContextId: target.defaultView.browsingContext.id, + } + ); + } + + // We might navigate away for the first top level target, + // which isn't using JSWindowActor (it still uses messages manager and is created by the client, via TabDescriptor.getTarget). + // We have to unregister it from the TargetActorRegistry, otherwise, + // if we navigate back to it, the next DOMWindowCreated won't create a new target for it. + onWindowGlobalDestroyed(target.defaultView.windowGlobalChild.innerWindowId); + } +} + +/** + * Return an existing Window Global target for given a WatcherActor + * and against a given WindowGlobal. + * + * @param {Object} options + * @param {String} options.watcherDataObject + * @param {Number} options.innerWindowId + * The WindowGlobal inner window ID. + * + * @returns {WindowGlobalTargetActor|null} + */ +function findTargetActor({ watcherDataObject, innerWindowId }) { + // First let's check if a target was created for this watcher actor in this specific + // DevToolsProcessChild instance. + const targetActor = watcherDataObject.actors.find( + actor => actor.innerWindowId == innerWindowId + ); + if (targetActor) { + return targetActor; + } + + // Ensure retrieving the one target actor related to this connection. + // This allows to distinguish actors created for various toolboxes. + // For ex, regular toolbox versus browser console versus browser toolbox + const connectionPrefix = watcherDataObject.watcherActorID.replace( + /watcher\d+$/, + "" + ); + const targetActors = lazy.TargetActorRegistry.getTargetActors( + watcherDataObject.sessionContext, + connectionPrefix + ); + + return targetActors.find(actor => actor.innerWindowId == innerWindowId); +} + +export const WindowGlobalTargetWatcher = { + watch, + unwatch, + createTargetsForWatcher, + destroyTargetsForWatcher, +}; diff --git a/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs b/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs new file mode 100644 index 0000000000..0b67e8b038 --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs @@ -0,0 +1,457 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +const { TYPE_DEDICATED, TYPE_SERVICE, TYPE_SHARED } = Ci.nsIWorkerDebugger; + +export class WorkerTargetWatcherClass { + constructor(workerTargetType = "worker") { + this.#workerTargetType = workerTargetType; + this.#workerDebuggerListener = { + onRegister: this.#onWorkerRegister.bind(this), + onUnregister: this.#onWorkerUnregister.bind(this), + }; + } + + // {String} + #workerTargetType; + // {nsIWorkerDebuggerListener} + #workerDebuggerListener; + + watch() { + lazy.wdm.addListener(this.#workerDebuggerListener); + } + + unwatch() { + lazy.wdm.removeListener(this.#workerDebuggerListener); + } + + createTargetsForWatcher(watcherDataObject) { + const { sessionData } = watcherDataObject; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + if (!this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { + continue; + } + this.createWorkerTargetActor(watcherDataObject, dbg); + } + } + + async addOrSetSessionDataEntry(watcherDataObject, type, entries, updateType) { + // Collect the SessionData update into `pendingWorkers` in order to notify + // about the updates to workers which are still in process of being hooked by devtools. + for (const concurrentSessionUpdates of watcherDataObject.pendingWorkers) { + concurrentSessionUpdates.push({ + type, + entries, + updateType, + }); + } + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherDataObject.workers) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + /** + * Called whenever a new Worker is instantiated in the current process + * + * @param {WorkerDebugger} dbg + */ + #onWorkerRegister(dbg) { + // Create a Target Actor for each watcher currently watching for Workers + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + this.#workerTargetType + )) { + const { sessionData } = watcherDataObject; + if (this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { + this.createWorkerTargetActor(watcherDataObject, dbg); + } + } + } + + /** + * Called whenever a Worker is destroyed in the current process + * + * @param {WorkerDebugger} dbg + */ + #onWorkerUnregister(dbg) { + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + this.#workerTargetType + )) { + const { watcherActorID, workers } = watcherDataObject; + // Check if the worker registration was handled for this watcherActorID. + const unregisteredActorIndex = workers.findIndex(worker => { + try { + // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED). + return worker.dbg.id === dbg.id; + } catch (e) { + return false; + } + }); + if (unregisteredActorIndex === -1) { + continue; + } + + const { workerTargetForm, transport } = workers[unregisteredActorIndex]; + // Close the transport made to the worker thread + transport.close(); + + try { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetDestroyed", + { + actors: [ + { + watcherActorID, + targetActorForm: workerTargetForm, + }, + ], + options: {}, + } + ); + } catch (e) { + // This often throws as the JSActor is being destroyed when DevTools closes + // and we are trying to notify about the destroyed targets. + } + + workers.splice(unregisteredActorIndex, 1); + } + } + + /** + * Instantiate a worker target actor related to a given WorkerDebugger object + * and for a given watcher actor. + * + * @param {Object} watcherDataObject + * @param {WorkerDebugger} dbg + */ + async createWorkerTargetActor(watcherDataObject, dbg) { + // Prevent the debuggee from executing in this worker until the client has + // finished attaching to it. This call will throw if the debugger is already "registered" + // (i.e. if this is called outside of the register listener) + // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66 + try { + dbg.setDebuggerReady(false); + } catch (e) { + if (!e.message.startsWith("Component returned failure code")) { + throw e; + } + } + + const { watcherActorID } = watcherDataObject; + const { connection, loader } = + ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( + watcherActorID + ); + + // Compute a unique prefix for the bridge made between this content process main thread + // and the worker thread. + const workerThreadServerForwardingPrefix = + connection.allocID("workerTarget"); + + const { connectToWorker } = loader.require( + "resource://devtools/server/connectors/worker-connector.js" + ); + + // Create the actual worker target actor, in the worker thread. + const { sessionData, sessionContext } = watcherDataObject; + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + sessionData, + sessionContext, + } + ); + + // Only add data to the connection if we successfully send the + // workerTargetAvailable message. + const workerInfo = { + dbg, + workerThreadServerForwardingPrefix, + }; + watcherDataObject.workers.push(workerInfo); + + // The onConnectToWorker is async and we may receive new Session Data (e.g breakpoints) + // while we are instantiating the worker targets. + // Let cache the pending session data and flush it after the targets are being instantiated. + const concurrentSessionUpdates = []; + watcherDataObject.pendingWorkers.add(concurrentSessionUpdates); + + try { + await onConnectToWorker; + } catch (e) { + // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution. + // But if anything goes wrong and an exception is thrown, ensure releasing its execution, + // otherwise if devtools is broken, it will freeze the worker indefinitely. + // + // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to + // resume the debugger if it is not closed (otherwise it can cause crashes). + if (!dbg.isClosed) { + dbg.setDebuggerReady(true); + } + // Also unregister the worker + watcherDataObject.workers.splice( + watcherDataObject.workers.indexOf(workerInfo), + 1 + ); + watcherDataObject.pendingWorkers.delete(concurrentSessionUpdates); + return; + } + watcherDataObject.pendingWorkers.delete(concurrentSessionUpdates); + + const { workerTargetForm, transport } = await onConnectToWorker; + workerInfo.workerTargetForm = workerTargetForm; + workerInfo.transport = transport; + + const { forwardingPrefix } = watcherDataObject; + // Immediately queue a message for the parent process, before applying any SessionData + // as it may start emitting RDP events on the target actor and be lost if the client + // didn't get notified about the target actor first + try { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetAvailable", + { + watcherActorID, + forwardingPrefix, + targetActorForm: workerTargetForm, + } + ); + } catch (e) { + // If there was an error while sending the message, we are not going to use this + // connection to communicate with the worker. + transport.close(); + // Also unregister the worker + watcherDataObject.workers.splice( + watcherDataObject.workers.indexOf(workerInfo), + 1 + ); + return; + } + + // Dispatch to the worker thread any SessionData updates which may have been notified + // while we were waiting for onConnectToWorker to resolve. + const promises = []; + for (const { type, entries, updateType } of concurrentSessionUpdates) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + destroyTargetsForWatcher(watcherDataObject) { + // Notify to all worker threads to destroy their target actor running in them + for (const { + dbg, + workerThreadServerForwardingPrefix, + transport, + } of watcherDataObject.workers) { + if (isWorkerDebuggerAlive(dbg)) { + try { + dbg.postMessage( + JSON.stringify({ + type: "disconnect", + forwardingPrefix: workerThreadServerForwardingPrefix, + }) + ); + } catch (e) {} + } + // Also cleanup the DevToolsTransport created in the main thread to bridge RDP to the worker thread + if (transport) { + transport.close(); + } + } + // Wipe all workers info + watcherDataObject.workers = []; + } + + /** + * Indicates whether or not we should handle the worker debugger + * + * @param {Object} sessionData + * The session data for a given watcher, which includes metadata + * about the debugged context. + * @param {WorkerDebugger} dbg + * The worker debugger we want to check. + * @param {String} targetType + * The expected worker target type. + * @returns {Boolean} + */ + shouldHandleWorker(sessionData, dbg, targetType) { + if (!isWorkerDebuggerAlive(dbg)) { + return false; + } + + if ( + (dbg.type === TYPE_DEDICATED && targetType != "worker") || + (dbg.type === TYPE_SERVICE && targetType != "service_worker") || + (dbg.type === TYPE_SHARED && targetType != "shared_worker") + ) { + return false; + } + + const { type: sessionContextType } = sessionData.sessionContext; + if (sessionContextType == "all") { + return true; + } + if (sessionContextType == "content-process") { + throw new Error( + "Content process session type shouldn't try to spawn workers" + ); + } + if (sessionContextType == "worker") { + throw new Error( + "worker session type should spawn only one target via the WorkerDescriptor" + ); + } + + if (dbg.type === TYPE_DEDICATED) { + // Assume that all dedicated workers executes in the same process as the debugged document. + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + sessionData.sessionContext.browserId + ); + // If we aren't executing in the same process as the worker and its BrowsingContext, + // it will be undefined. + if (!browsingContext) { + return false; + } + for (const subBrowsingContext of browsingContext.getAllBrowsingContextsInSubtree()) { + if ( + subBrowsingContext.currentWindowContext && + dbg.windowIDs.includes( + subBrowsingContext.currentWindowContext.innerWindowId + ) + ) { + return true; + } + } + return false; + } + + if (dbg.type === TYPE_SERVICE) { + // Accessing `nsIPrincipal.host` may easily throw on non-http URLs. + // Ignore all non-HTTP as they most likely don't have any valid host name. + if (!dbg.principal.scheme.startsWith("http")) { + return false; + } + + const workerHost = dbg.principal.hostPort; + return workerHost == sessionData["browser-element-host"][0]; + } + + if (dbg.type === TYPE_SHARED) { + // We still don't fully support instantiating targets for shared workers from the server side + throw new Error( + "Server side listening for shared workers isn't supported" + ); + } + + return false; + } +} + +/** + * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger. + * + * @param {WorkerDebugger} dbg + * @param {String} workerThreadServerForwardingPrefix + * @param {String} type + * Session data type name + * @param {Array} entries + * Session data entries to add or set. + * @param {String} updateType + * Either "add" or "set", to control if we should only add some items, + * or replace the whole data set with the new entries. + * @returns {Promise} Returns a Promise that resolves once the data entry were handled + * by the worker target. + */ +function addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, +}) { + if (!isWorkerDebuggerAlive(dbg)) { + return Promise.resolve(); + } + + return new Promise(resolve => { + // Wait until we're notified by the worker that the resources are watched. + // This is important so we know existing resources were handled. + const listener = { + onMessage: message => { + message = JSON.parse(message); + if (message.type === "session-data-entry-added-or-set") { + dbg.removeListener(listener); + resolve(); + } + }, + // Resolve if the worker is being destroyed so we don't have a dangling promise. + onClose: () => { + dbg.removeListener(listener); + resolve(); + }, + }; + + dbg.addListener(listener); + + dbg.postMessage( + JSON.stringify({ + type: "add-or-set-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + updateType, + }) + ); + }); +} + +function isWorkerDebuggerAlive(dbg) { + if (dbg.isClosed) { + return false; + } + // Some workers are zombies. `isClosed` is false, but nothing works. + // `postMessage` is a noop, `addListener`'s `onClosed` doesn't work. + return ( + dbg.window?.docShell || + // consider dbg without `window` as being alive, as they aren't related + // to any docShell and probably do not suffer from this issue + !dbg.window + ); +} + +export const WorkerTargetWatcher = new WorkerTargetWatcherClass(); |