diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/connectors/js-window-actor | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/connectors/js-window-actor')
6 files changed, 1893 insertions, 0 deletions
diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs new file mode 100644 index 0000000000..c7d5bd0718 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs @@ -0,0 +1,689 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import * as Loader from "resource://devtools/shared/loader/Loader.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + isWindowGlobalPartOfContext: + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", + releaseDistinctSystemPrincipalLoader: + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", + TargetActorRegistry: + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", + useDistinctSystemPrincipalLoader: + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", + WindowGlobalLogger: + "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs", +}); + +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +// If true, log info about WindowGlobal's being created. +const DEBUG = false; +function logWindowGlobal(windowGlobal, message) { + if (!DEBUG) { + return; + } + lazy.WindowGlobalLogger.logWindowGlobal(windowGlobal, message); +} + +export class DevToolsFrameChild extends JSWindowActorChild { + constructor() { + super(); + + // The map is indexed by the Watcher Actor ID. + // The values are objects containing the following properties: + // - connection: the DevToolsServerConnection itself + // - actor: the WindowGlobalTargetActor instance + this._connections = new Map(); + + EventEmitter.decorate(this); + + // Set the following preference on the constructor, 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. DevToolsFrameParent/DevToolsFrameChild). + // 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. + XPCOMUtils.defineLazyGetter( + this, + "isBfcacheInParentEnabled", + () => + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent", false) + ); + } + + /** + * Try to instantiate new target actors for the current WindowGlobal, and that, + * for all the currently registered Watcher actors. + * + * Read the `sharedData` to get metadata about all registered watcher actors. + * If these watcher actors are interested in the current WindowGlobal, + * instantiate a new dedicated target actor for each of the watchers. + * + * @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. + */ + instantiate({ isBFCache = false, ignoreIfExisting = false } = {}) { + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!sessionDataByWatcherActor) { + throw new Error( + "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets" + ); + } + + // Create one Target actor for each prefix/client which listen to frames + for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { + const { connectionPrefix, sessionContext } = sessionData; + // 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 one such actor is bound to a unique DocShell. + const forceAcceptTopLevelTarget = + isBFCache && this.isBfcacheInParentEnabled; + if ( + sessionData.targets?.includes("frame") && + lazy.isWindowGlobalPartOfContext(this.manager, 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 = this._findTargetActor({ + watcherActorID, + sessionContext, + browsingContextId: this.manager.browsingContext.id, + }); + + // See comment in handleEvent(DOMDocElementInserted) to know why we try to + // create targets if none already exists + 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 }); + } + + this._createTargetActor({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + isDocumentCreation: true, + }); + } + } + } + + /** + * Instantiate a new WindowGlobalTarget for the given connection. + * + * @param Object options + * @param String options.watcherActorID + * The ID of the WatcherActor who requested to observe and create these target actors. + * @param String options.parentConnectionPrefix + * The prefix of the DevToolsServerConnection of the Watcher Actor. + * This is used to compute a unique ID for the target actor. + * @param Object options.sessionData + * All data managed by the Watcher Actor and WatcherRegistry.jsm, containing + * target types, resources types to be listened as well as breakpoints and any + * other data meant to be shared across processes and threads. + * @param Boolean options.isDocumentCreation + * Set to true if the function is called from `instantiate`, i.e. when we're + * handling a new document being created. + * @param Boolean options.fromInstantiateAlreadyAvailable + * Set to true if the function is called from handling `DevToolsFrameParent:instantiate-already-available` + * query. + */ + _createTargetActor({ + watcherActorID, + parentConnectionPrefix, + sessionData, + isDocumentCreation, + fromInstantiateAlreadyAvailable, + }) { + if (this._connections.get(watcherActorID)) { + // If this function is called as a result of a `DevToolsFrameParent:instantiate-already-available` + // message, we might have a legitimate race condition: + // In frame-helper, we want to create the initial targets for a given browser element. + // It might happen that the `DevToolsFrameParent:instantiate-already-available` is + // aborted if the page navigates (and the document is destroyed) while the query is still pending. + // In such case, frame-helper will try to send a new message. In the meantime, + // the DevToolsFrameChild `DOMWindowCreated` handler may already have been called and + // the new target already created. + // We don't want to throw in such case, as our end-goal, having a target for the document, + // is achieved. + if (fromInstantiateAlreadyAvailable) { + return; + } + throw new Error( + "DevToolsFrameChild _createTargetActor was called more than once" + + ` for the same Watcher (Actor ID: "${watcherActorID}")` + ); + } + + // Compute a unique prefix, just for this WindowGlobal, + // which will be used to create a JSWindowActorTransport pair between content and parent processes. + // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, + // but here, we can't have access to any DevTools connection as we are really early in the content process startup + // XXX: WindowGlobal's innerWindowId should be unique across processes, I think. So that should be safe? + // (this.manager == WindowGlobalChild interface) + const forwardingPrefix = + parentConnectionPrefix + "windowGlobal" + this.manager.innerWindowId; + + logWindowGlobal( + this.manager, + "Instantiate WindowGlobalTarget with prefix: " + forwardingPrefix + ); + + const { connection, targetActor } = this._createConnectionAndActor( + forwardingPrefix, + sessionData + ); + const form = targetActor.form(); + // Ensure unregistering and destroying the related DevToolsServerConnection+Transport + // on both content and parent process JSWindowActors. + targetActor.once("destroyed", options => { + // This will destroy the content process one + this._destroyTargetActor(watcherActorID, options); + // And this will destroy the parent process one + try { + this.sendAsyncMessage("DevToolsFrameChild:destroy", { + actors: [ + { + watcherActorID, + form, + }, + ], + options, + }); + } catch (e) { + // Ignore exception when the JSWindowActorChild has already been destroyed. + // We often try to emit this message while the WindowGlobal is in the process of being + // destroyed. We eagerly destroy the target actor during reloads, + // just before the WindowGlobal starts destroying, but sendAsyncMessage + // doesn't have time to complete and throws. + if ( + !e.message.includes("JSWindowActorChild cannot send at the moment") + ) { + throw e; + } + } + }); + 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 WindowGlobalTargetActor may emit events in its constructor. + // If it does, such RDP packets may be lost. + // The important point here is to send this message before processing the sessionData, + // which will start the Watcher and start emitting resources on the target actor. + this.sendAsyncMessage("DevToolsFrameChild: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.addSessionDataEntry(type, entries, isDocumentCreation); + } + } + + /** + * @param {string} watcherActorID + * @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(watcherActorID, options) { + const connectionInfo = this._connections.get(watcherActorID); + // This connection has already been cleaned? + if (!connectionInfo) { + throw new Error( + `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` + ); + } + connectionInfo.connection.close(options); + this._connections.delete(watcherActorID); + if (this._connections.size == 0) { + this.didDestroy(options); + } + } + + _createConnectionAndActor(forwardingPrefix, sessionData) { + this.useCustomLoader = this.document.nodePrincipal.isSystemPrincipal; + + if (!this.loader) { + // When debugging chrome pages, use a new dedicated loader, using a distinct chrome compartment. + this.loader = this.useCustomLoader + ? lazy.useDistinctSystemPrincipalLoader(this) + : Loader; + } + const { DevToolsServer } = this.loader.require( + "resource://devtools/server/devtools-server.js" + ); + + const { WindowGlobalTargetActor } = this.loader.require( + "resource://devtools/server/actors/targets/window-global.js" + ); + + DevToolsServer.init(); + + // We want a special server without any root actor and only target-scoped actors. + // We are going to spawn a WindowGlobalTargetActor instance in the next few lines, + // it is going to act like a root actor without being one. + DevToolsServer.registerActors({ target: true }); + + const connection = DevToolsServer.connectToParentWindowActor( + this, + forwardingPrefix + ); + + // 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 browsingContext = this.manager.browsingContext; + const isTopLevelTarget = + !browsingContext.parent && + browsingContext.browserId == sessionData.sessionContext.browserId; + + // Create the actual target actor. + const targetActor = new WindowGlobalTargetActor(connection, { + docShell: this.docShell, + // Targets created from the server side, via Watcher actor and DevToolsFrame JSWindow + // actor pair are following WindowGlobal lifecycle. i.e. will be destroyed on any + // type of navigation/reload. + followWindowGlobalLifeCycle: true, + isTopLevelTarget, + ignoreSubFrames: isEveryFrameTargetEnabled, + sessionContext: sessionData.sessionContext, + }); + targetActor.manage(targetActor); + targetActor.createdFromJsWindowActor = true; + + return { connection, targetActor }; + } + + /** + * Supported Queries + */ + + sendPacket(packet, prefix) { + this.sendAsyncMessage("DevToolsFrameChild:packet", { packet, prefix }); + } + + /** + * JsWindowActor API + */ + + async sendQuery(msg, args) { + try { + const res = await super.sendQuery(msg, args); + return res; + } catch (e) { + console.error("Failed to sendQuery in DevToolsFrameChild", msg); + console.error(e.toString()); + throw e; + } + } + + receiveMessage(message) { + // Assert that the message is intended for this window global, + // except for "packet" messages which use a dedicated routing + if ( + message.name != "DevToolsFrameParent:packet" && + message.data.sessionContext.type == "browser-element" + ) { + const { browserId } = message.data.sessionContext; + // Re-check here, just to ensure that both parent and content processes agree + // on what should or should not be watched. + if ( + this.manager.browsingContext.browserId != browserId && + !lazy.isWindowGlobalPartOfContext( + this.manager, + message.data.sessionContext, + { + forceAcceptTopLevelTarget: true, + } + ) + ) { + throw new Error( + "Mismatch between DevToolsFrameParent and DevToolsFrameChild " + + (this.manager.browsingContext.browserId == browserId + ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)" + : `expected browsing context with browserId ${browserId}, but got ${this.manager.browsingContext.browserId}`) + ); + } + } + switch (message.name) { + case "DevToolsFrameParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, sessionData } = message.data; + + return this._createTargetActor({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + fromInstantiateAlreadyAvailable: true, + }); + } + case "DevToolsFrameParent:destroy": { + const { watcherActorID, options } = message.data; + return this._destroyTargetActor(watcherActorID, options); + } + case "DevToolsFrameParent:addSessionDataEntry": { + const { watcherActorID, sessionContext, type, entries } = message.data; + return this._addSessionDataEntry( + watcherActorID, + sessionContext, + type, + entries + ); + } + case "DevToolsFrameParent:removeSessionDataEntry": { + const { watcherActorID, sessionContext, type, entries } = message.data; + return this._removeSessionDataEntry( + watcherActorID, + sessionContext, + type, + entries + ); + } + case "DevToolsFrameParent:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsFrameParent: " + message.name + ); + } + } + + /** + * Return an existing target given a WatcherActor id, a browserId and an optional + * browsing context id. + * /!\ Note that we may have multiple targets for a given (watcherActorId, browserId) couple, + * for example if we have 2 remote iframes sharing the same origin, which is why you + * might want to pass a specific browsing context id to filter the list down. + * + * @param {Object} options + * @param {Object} options.watcherActorID + * @param {Object} options.sessionContext + * @param {Object} options.browsingContextId: Optional browsing context id to narrow the + * search to a specific browsing context. + * + * @returns {WindowGlobalTargetActor|null} + */ + _findTargetActor({ watcherActorID, sessionContext, browsingContextId }) { + // First let's check if a target was created for this watcher actor in this specific + // DevToolsFrameChild instance. + const connectionInfo = this._connections.get(watcherActorID); + const targetActor = connectionInfo ? connectionInfo.actor : null; + if (targetActor) { + return targetActor; + } + + // If we couldn't find such target, we want to see if a target was created for this + // (watcherActorId,browserId, {browsingContextId}) in another DevToolsFrameChild instance. + // This might be the case if we're navigating to a new page with server side target + // enabled and we want to retrieve the target of the page we're navigating from. + if ( + lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, { + forceAcceptTopLevelTarget: true, + }) + ) { + // Ensure retrieving the one target actor related to this connection. + // This allows to distinguish actors created for various toolboxes. + // For ex, regular toolbox versus browser console versus browser toolbox + const connectionPrefix = watcherActorID.replace(/watcher\d+$/, ""); + const targetActors = lazy.TargetActorRegistry.getTargetActors( + sessionContext, + connectionPrefix + ); + + if (!browsingContextId) { + return targetActors[0] || null; + } + return targetActors.find( + actor => actor.browsingContextID === browsingContextId + ); + } + return null; + } + + _addSessionDataEntry(watcherActorID, sessionContext, type, entries) { + // /!\ We may have an issue here as there could be multiple targets for a given + // (watcherActorID,browserId) pair. + // This should be clarified as part of Bug 1725623. + const targetActor = this._findTargetActor({ + watcherActorID, + sessionContext, + }); + + if (!targetActor) { + throw new Error( + `No target actor for this Watcher Actor ID:"${watcherActorID}" / BrowserId:${sessionContext.browserId}` + ); + } + return targetActor.addSessionDataEntry(type, entries); + } + + _removeSessionDataEntry(watcherActorID, sessionContext, type, entries) { + // /!\ We may have an issue here as there could be multiple targets for a given + // (watcherActorID,browserId) pair. + // This should be clarified as part of Bug 1725623. + const targetActor = this._findTargetActor({ + watcherActorID, + sessionContext, + }); + // By the time we are calling this, the target may already have been destroyed. + if (targetActor) { + return targetActor.removeSessionDataEntry(type, entries); + } + return null; + } + + handleEvent({ type, persisted, target }) { + // Ignore any event that may fire for children WindowGlobals/documents + if (target != this.document) { + return; + } + + // DOMWindowCreated is registered from FrameWatcher via `ActorManagerParent.addJSWindowActors` + // as a DOM event to be listened to and so is fired by JS Window Actor code platform code. + if (type == "DOMWindowCreated") { + this.instantiate(); + 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 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. There is a DOMDocElementInserted fired just after, that we can catch + // to create a target for same-process iframes. + // 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) + if (type == "DOMDocElementInserted") { + this.instantiate({ ignoreIfExisting: true }); + return; + } + + // 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. + this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pageshow"); + + // 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. + this.instantiate({ + isBFCache: true, + }); + return; + } + + 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. + this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pagehide"); + + // 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. + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!sessionDataByWatcherActor) { + throw new Error( + "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets" + ); + } + + const actors = []; + // A flag to know if the following for loop ended up destroying all the actors. + // It may not be the case if one Watcher isn't having server target switching enabled. + let allActorsAreDestroyed = true; + for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { + const { sessionContext } = sessionData; + + // /!\ We may have an issue here as there could be multiple targets for a given + // (watcherActorID,browserId) pair. + // This should be clarified as part of Bug 1725623. + const existingTarget = this._findTargetActor({ + watcherActorID, + sessionContext, + }); + + if (!existingTarget) { + continue; + } + + // Use `originalWindow` as `window` can be set when a document was selected from + // the iframe picker in the toolbox toolbar. + if (existingTarget.originalWindow.document != target) { + throw new Error("Existing target actor is for a distinct document"); + } + // 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 ( + !this.isBfcacheInParentEnabled && + !sessionContext.isServerTargetSwitchingEnabled + ) { + allActorsAreDestroyed = false; + continue; + } + + actors.push({ + watcherActorID, + form: existingTarget.form(), + }); + existingTarget.destroy(); + } + + if (actors.length) { + // The most important is to unregister the actor from TargetActorRegistry, + // so that it is no longer present in the list when new DOMWindowCreated fires. + // This will also help notify the client that the target has been destroyed. + // And if we navigate back to this target, the client will receive the same target actor ID, + // so that it is really important to destroy it correctly on both server and client. + this.sendAsyncMessage("DevToolsFrameChild:destroy", { actors }); + } + + if (allActorsAreDestroyed) { + // Completely clear this JSWindow Actor. + // Do this after having called _findTargetActor, + // as it would clear the registered target actors. + this.didDestroy(); + } + } + } + + didDestroy(options) { + logWindowGlobal(this.manager, "Destroy WindowGlobalTarget"); + for (const [, connectionInfo] of this._connections) { + connectionInfo.connection.close(options); + } + this._connections.clear(); + + if (this.loader) { + if (this.useCustomLoader) { + lazy.releaseDistinctSystemPrincipalLoader(this); + } + this.loader = null; + } + } +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs new file mode 100644 index 0000000000..867453a798 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs @@ -0,0 +1,272 @@ +/* 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 { 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 + // which also has to be a true singleton. + loadInDevToolsLoader: false, + } +); + +const lazy = {}; + +loader.lazyRequireGetter( + lazy, + "JsWindowActorTransport", + "resource://devtools/shared/transport/js-window-actor-transport.js", + true +); + +export class DevToolsFrameParent extends JSWindowActorParent { + constructor() { + super(); + + // Map of DevToolsServerConnection's used to forward the messages from/to + // the client. The connections run in the parent process, as this code. We + // may have more than one when there is more than one client debugging the + // same frame. For example, a content toolbox and the browser toolbox. + // + // The map is indexed by the connection prefix. + // The values are objects containing the following properties: + // - actor: the frame target actor(as a form) + // - connection: the DevToolsServerConnection used to communicate with the + // frame target actor + // - prefix: the forwarding prefix used by the connection to know + // how to forward packets to the frame target + // - transport: the JsWindowActorTransport + // + // Reminder about prefixes: all DevToolsServerConnections have a `prefix` + // which can be considered as a kind of id. On top of this, parent process + // DevToolsServerConnections also have forwarding prefixes because they are + // responsible for forwarding messages to content process connections. + this._connections = new Map(); + + this._onConnectionClosed = this._onConnectionClosed.bind(this); + EventEmitter.decorate(this); + } + + /** + * Request the content process to create the Frame Target if there is one + * already available that matches the Browsing Context ID + */ + async instantiateTarget({ + watcherActorID, + connectionPrefix, + sessionContext, + sessionData, + }) { + await this.sendQuery("DevToolsFrameParent:instantiate-already-available", { + watcherActorID, + connectionPrefix, + sessionContext, + sessionData, + }); + } + + /** + * @param {object} arg + * @param {object} arg.sessionContext + * @param {object} arg.options + * @param {boolean} arg.options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ + destroyTarget({ watcherActorID, sessionContext, options }) { + this.sendAsyncMessage("DevToolsFrameParent:destroy", { + watcherActorID, + sessionContext, + options, + }); + } + + /** + * Communicate to the content process that some data have been added. + */ + async addSessionDataEntry({ watcherActorID, sessionContext, type, entries }) { + try { + await this.sendQuery("DevToolsFrameParent:addSessionDataEntry", { + watcherActorID, + sessionContext, + type, + entries, + }); + } catch (e) { + console.warn( + "Failed to add session data entry for frame targets in browsing context", + this.browsingContext.id + ); + console.warn(e); + } + } + + /** + * Communicate to the content process that some data have been removed. + */ + removeSessionDataEntry({ watcherActorID, sessionContext, type, entries }) { + this.sendAsyncMessage("DevToolsFrameParent:removeSessionDataEntry", { + watcherActorID, + sessionContext, + type, + entries, + }); + } + + connectFromContent({ watcherActorID, forwardingPrefix, actor }) { + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + const connection = watcher.conn; + + connection.on("closed", this._onConnectionClosed); + + // Create a js-window-actor based transport. + const transport = new lazy.JsWindowActorTransport(this, forwardingPrefix); + transport.hooks = { + onPacket: connection.send.bind(connection), + onTransportClosed() {}, + }; + transport.ready(); + + connection.setForwarding(forwardingPrefix, transport); + + this._connections.set(watcher.conn.prefix, { + watcher, + connection, + // This prefix is the prefix of the DevToolsServerConnection, running + // in the content process, for which we should forward packets to, based on its prefix. + // While `watcher.connection` is also a DevToolsServerConnection, but from this process, + // the parent process. It is the one receiving Client packets and the one, from which + // we should forward packets from. + forwardingPrefix, + transport, + actor, + }); + + watcher.notifyTargetAvailable(actor); + } + + _onConnectionClosed(status, connectionPrefix) { + this._unregisterWatcher(connectionPrefix); + } + + /** + * Given a watcher connection prefix, unregister everything related to the Watcher + * in this JSWindowActor. + * + * @param {String} connectionPrefix + * The connection prefix of the watcher to unregister + */ + async _unregisterWatcher(connectionPrefix) { + const connectionInfo = this._connections.get(connectionPrefix); + if (!connectionInfo) { + return; + } + const { forwardingPrefix, transport, connection } = connectionInfo; + this._connections.delete(connectionPrefix); + + connection.off("closed", this._onConnectionClosed); + if (transport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this transport. + transport.close(); + } + + connection.cancelForwarding(forwardingPrefix); + } + + /** + * Destroy everything that we did related to the current WindowGlobal that + * this JSWindow Actor represents: + * - close all transports that were used as bridge to communicate with the + * DevToolsFrameChild, running in the content process + * - unregister these transports from DevToolsServer (cancelForwarding) + * - notify the client, via the WatcherActor that all related targets, + * one per client/connection are all destroyed + * + * Note that with bfcacheInParent, we may reuse a JSWindowActor pair after closing all connections. + * This is can happen outside of the destruction of the actor. + * We may reuse a DevToolsFrameParent and DevToolsFrameChild pair. + * When navigating away, we will destroy them and call this method. + * Then when navigating back, we will reuse the same instances. + * So that we should be careful to keep the class fully function and only clear all its state. + * + * @param {object} options + * @param {boolean} options.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref + */ + _closeAllConnections(options) { + for (const { actor, watcher } of this._connections.values()) { + watcher.notifyTargetDestroyed(actor, options); + this._unregisterWatcher(watcher.conn.prefix); + } + this._connections.clear(); + } + + /** + * Supported Queries + */ + + sendPacket(packet, prefix) { + this.sendAsyncMessage("DevToolsFrameParent:packet", { packet, prefix }); + } + + /** + * JsWindowActor API + */ + + receiveMessage(message) { + switch (message.name) { + case "DevToolsFrameChild:connectFromContent": + return this.connectFromContent(message.data); + case "DevToolsFrameChild:packet": + return this.emit("packet-received", message); + case "DevToolsFrameChild: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._unregisterWatcher(watcher.conn.prefix); + } + } + return null; + case "DevToolsFrameChild:bf-cache-navigation-pageshow": + for (const watcherActor of WatcherRegistry.getWatchersForBrowserId( + this.browsingContext.browserId + )) { + watcherActor.emit("bf-cache-navigation-pageshow", { + windowGlobal: this.browsingContext.currentWindowGlobal, + }); + } + return null; + case "DevToolsFrameChild:bf-cache-navigation-pagehide": + for (const watcherActor of WatcherRegistry.getWatchersForBrowserId( + this.browsingContext.browserId + )) { + watcherActor.emit("bf-cache-navigation-pagehide", { + windowGlobal: this.browsingContext.currentWindowGlobal, + }); + } + return null; + default: + throw new Error( + "Unsupported message in DevToolsFrameParent: " + message.name + ); + } + } + + didDestroy() { + this._closeAllConnections(); + } +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs new file mode 100644 index 0000000000..023f617186 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs @@ -0,0 +1,550 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +XPCOMUtils.defineLazyGetter(lazy, "Loader", () => + ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs") +); + +XPCOMUtils.defineLazyGetter(lazy, "DevToolsUtils", () => + lazy.Loader.require("resource://devtools/shared/DevToolsUtils.js") +); +XPCOMUtils.defineLazyModuleGetters(lazy, { + SessionDataHelpers: + "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm", +}); +ChromeUtils.defineESModuleGetters(lazy, { + isWindowGlobalPartOfContext: + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", +}); + +// Name of the attribute into which we save data in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +export class DevToolsWorkerChild extends JSWindowActorChild { + constructor() { + super(); + + // The map is indexed by the Watcher Actor ID. + // The values are objects containing the following properties: + // - connection: the DevToolsServerConnection itself + // - workers: An array of object containing the following properties: + // - dbg: A WorkerDebuggerInstance + // - workerTargetForm: The associated worker target instance form + // - workerThreadServerForwardingPrefix: The prefix used to forward events to the + // worker target on the worker thread (). + // - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate + // between content and parent processes. + // - sessionData: Data (targets, resources, …) the watcher wants to be notified about. + // See WatcherRegistry.getSessionData to see the full list of properties. + this._connections = new Map(); + + EventEmitter.decorate(this); + } + + _onWorkerRegistered(dbg) { + if (!this._shouldHandleWorker(dbg)) { + return; + } + + for (const [watcherActorID, { connection, forwardingPrefix }] of this + ._connections) { + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }); + } + } + + _onWorkerUnregistered(dbg) { + for (const [watcherActorID, { workers, forwardingPrefix }] of this + ._connections) { + // Check if the worker registration was handled for this watcherActorID. + const unregisteredActorIndex = workers.findIndex(worker => { + try { + // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED). + return worker.dbg.id === dbg.id; + } catch (e) { + return false; + } + }); + if (unregisteredActorIndex === -1) { + continue; + } + + const { workerTargetForm, transport } = workers[unregisteredActorIndex]; + transport.close(); + + try { + this.sendAsyncMessage("DevToolsWorkerChild:workerTargetDestroyed", { + watcherActorID, + forwardingPrefix, + workerTargetForm, + }); + } catch (e) { + return; + } + + workers.splice(unregisteredActorIndex, 1); + } + } + + onDOMWindowCreated() { + const { sharedData } = Services.cpmm; + const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); + if (!sessionDataByWatcherActor) { + throw new Error( + "Request to instantiate the target(s) for the Worker, but `sharedData` is empty about watched targets" + ); + } + + // Create one Target actor for each prefix/client which listen to workers + for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { + const { targets, connectionPrefix, sessionContext } = sessionData; + if ( + targets?.includes("worker") && + lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, { + acceptInitialDocument: true, + forceAcceptTopLevelTarget: true, + acceptSameProcessIframes: true, + }) + ) { + this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + } + } + + /** + * Function handling messages sent by DevToolsWorkerParent (part of JSWindowActor API). + * + * @param {Object} message + * @param {String} message.name + * @param {*} message.data + */ + receiveMessage(message) { + // All messages pass `sessionContext` (except packet) and are expected + // to match isWindowGlobalPartOfContext result. + if (message.name != "DevToolsWorkerParent:packet") { + const { browserId } = message.data.sessionContext; + // Re-check here, just to ensure that both parent and content processes agree + // on what should or should not be watched. + if ( + this.manager.browsingContext.browserId != browserId && + !lazy.isWindowGlobalPartOfContext( + this.manager, + message.data.sessionContext, + { + acceptInitialDocument: true, + } + ) + ) { + throw new Error( + "Mismatch between DevToolsWorkerParent and DevToolsWorkerChild " + + (this.manager.browsingContext.browserId == browserId + ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)" + : `expected browsing context with ID ${browserId}, but got ${this.manager.browsingContext.browserId}`) + ); + } + } + + switch (message.name) { + case "DevToolsWorkerParent:instantiate-already-available": { + const { watcherActorID, connectionPrefix, sessionData } = message.data; + + return this._watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix: connectionPrefix, + sessionData, + }); + } + case "DevToolsWorkerParent:destroy": { + const { watcherActorID } = message.data; + return this._destroyTargetActors(watcherActorID); + } + case "DevToolsWorkerParent:addSessionDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._addSessionDataEntry(watcherActorID, type, entries); + } + case "DevToolsWorkerParent:removeSessionDataEntry": { + const { watcherActorID, type, entries } = message.data; + return this._removeSessionDataEntry(watcherActorID, type, entries); + } + case "DevToolsWorkerParent:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsWorkerParent: " + message.name + ); + } + } + + /** + * Instantiate targets for existing workers, watch for worker registration and listen + * for resources on those workers, for given connection and context. Targets are sent + * to the DevToolsWorkerParent via the DevToolsWorkerChild:workerTargetAvailable message. + * + * @param {Object} options + * @param {Integer} options.watcherActorID: The ID of the WatcherActor who requested to + * observe and create these target actors. + * @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection + * of the Watcher Actor. This is used to compute a unique ID for the target actor. + * @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants + * to be notified about. See WatcherRegistry.getSessionData to see the full list + * of properties. + */ + async _watchWorkerTargets({ + watcherActorID, + parentConnectionPrefix, + sessionData, + }) { + if (this._connections.has(watcherActorID)) { + throw new Error( + "DevToolsWorkerChild _watchWorkerTargets was called more than once" + + ` for the same Watcher (Actor ID: "${watcherActorID}")` + ); + } + + // Listen for new workers that will be spawned. + if (!this._workerDebuggerListener) { + this._workerDebuggerListener = { + onRegister: this._onWorkerRegistered.bind(this), + onUnregister: this._onWorkerUnregistered.bind(this), + }; + lazy.wdm.addListener(this._workerDebuggerListener); + } + + // Compute a unique prefix, just for this WindowGlobal, + // which will be used to create a JSWindowActorTransport pair between content and parent processes. + // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, + // but here, we can't have access to any DevTools connection as we are really early in the content process startup + // WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe? + // (this.manager == WindowGlobalChild interface) + const forwardingPrefix = + parentConnectionPrefix + "workerGlobal" + this.manager.innerWindowId; + + const connection = this._createConnection(forwardingPrefix); + + this._connections.set(watcherActorID, { + connection, + workers: [], + forwardingPrefix, + sessionData, + }); + + await Promise.all( + Array.from(lazy.wdm.getWorkerDebuggerEnumerator()) + .filter(dbg => this._shouldHandleWorker(dbg)) + .map(dbg => + this._createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) + ) + ); + } + + _createConnection(forwardingPrefix) { + const { DevToolsServer } = lazy.Loader.require( + "resource://devtools/server/devtools-server.js" + ); + + DevToolsServer.init(); + + // We want a special server without any root actor and only target-scoped actors. + // We are going to spawn a WorkerTargetActor instance in the next few lines, + // it is going to act like a root actor without being one. + DevToolsServer.registerActors({ target: true }); + + const connection = DevToolsServer.connectToParentWindowActor( + this, + forwardingPrefix + ); + + return connection; + } + + /** + * Indicates whether or not we should handle the worker debugger + * + * @param {WorkerDebugger} dbg: The worker debugger we want to check. + * @returns {Boolean} + */ + _shouldHandleWorker(dbg) { + // We only want to create targets for non-closed dedicated worker, in the same document + return ( + lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg) && + dbg.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED && + dbg.windowIDs.includes(this.manager.innerWindowId) + ); + } + + async _createWorkerTargetActor({ + dbg, + connection, + forwardingPrefix, + watcherActorID, + }) { + // Prevent the debuggee from executing in this worker until the client has + // finished attaching to it. This call will throw if the debugger is already "registered" + // (i.e. if this is called outside of the register listener) + // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66 + try { + dbg.setDebuggerReady(false); + } catch (e) {} + + const watcherConnectionData = this._connections.get(watcherActorID); + const { sessionData } = watcherConnectionData; + const workerThreadServerForwardingPrefix = + connection.allocID("workerTarget"); + + // Create the actual worker target actor, in the worker thread. + const { connectToWorker } = lazy.Loader.require( + "resource://devtools/server/connectors/worker-connector.js" + ); + + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + sessionData, + sessionContext: sessionData.sessionContext, + } + ); + + try { + await onConnectToWorker; + } catch (e) { + // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to + // resume the debugger if it is not closed (otherwise it can cause crashes). + if (!dbg.isClosed) { + dbg.setDebuggerReady(true); + } + return; + } + + const { workerTargetForm, transport } = await onConnectToWorker; + + try { + this.sendAsyncMessage("DevToolsWorkerChild:workerTargetAvailable", { + watcherActorID, + forwardingPrefix, + workerTargetForm, + }); + } catch (e) { + // If there was an error while sending the message, we are not going to use this + // connection to communicate with the worker. + transport.close(); + return; + } + + // Only add data to the connection if we successfully send the + // workerTargetAvailable message. + watcherConnectionData.workers.push({ + dbg, + transport, + workerTargetForm, + workerThreadServerForwardingPrefix, + }); + } + + _destroyTargetActors(watcherActorID) { + const watcherConnectionData = this._connections.get(watcherActorID); + this._connections.delete(watcherActorID); + + // This connection has already been cleaned? + if (!watcherConnectionData) { + console.error( + `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` + ); + return; + } + + for (const { + dbg, + transport, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + try { + if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + dbg.postMessage( + JSON.stringify({ + type: "disconnect", + forwardingPrefix: workerThreadServerForwardingPrefix, + }) + ); + } + } catch (e) {} + + transport.close(); + } + + watcherConnectionData.connection.close(); + } + + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsWorkerChild:packet", { + packet, + prefix, + }); + } + + async _addSessionDataEntry(watcherActorID, type, entries) { + const watcherConnectionData = this._connections.get(watcherActorID); + if (!watcherConnectionData) { + return; + } + + lazy.SessionDataHelpers.addSessionDataEntry( + watcherConnectionData.sessionData, + type, + entries + ); + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + promises.push( + addSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + }) + ); + } + await Promise.all(promises); + } + + _removeSessionDataEntry(watcherActorID, type, entries) { + const watcherConnectionData = this._connections.get(watcherActorID); + + if (!watcherConnectionData) { + return; + } + + lazy.SessionDataHelpers.removeSessionDataEntry( + watcherConnectionData.sessionData, + type, + entries + ); + + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherConnectionData.workers) { + if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + dbg.postMessage( + JSON.stringify({ + type: "remove-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + }) + ); + } + } + } + + handleEvent({ type }) { + // DOMWindowCreated is registered from the WatcherRegistry via `ActorManagerParent.addJSWindowActors` + // as a DOM event to be listened to and so is fired by JSWindowActor platform code. + if (type == "DOMWindowCreated") { + this.onDOMWindowCreated(); + } + } + + _removeExistingWorkerDebuggerListener() { + if (this._workerDebuggerListener) { + lazy.wdm.removeListener(this._workerDebuggerListener); + this._workerDebuggerListener = null; + } + } + + /** + * Part of JSActor API + * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52 + * + * > The didDestroy method, if present, will be called after the actor is no + * > longer able to receive any more messages. + */ + didDestroy() { + this._removeExistingWorkerDebuggerListener(); + + for (const [watcherActorID, watcherConnectionData] of this._connections) { + const { connection } = watcherConnectionData; + this._destroyTargetActors(watcherActorID); + + connection.close(); + } + + this._connections.clear(); + } +} + +/** + * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger. + * + * @returns {Promise} Returns a Promise that resolves once the data entry were handled + * by the worker target. + */ +function addSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, +}) { + if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) { + return Promise.resolve(); + } + + return new Promise(resolve => { + // Wait until we're notified by the worker that the resources are watched. + // This is important so we know existing resources were handled. + const listener = { + onMessage: message => { + message = JSON.parse(message); + if (message.type === "session-data-entry-added") { + resolve(); + dbg.removeListener(listener); + } + }, + // Resolve if the worker is being destroyed so we don't have a dangling promise. + onClose: () => resolve(), + }; + + dbg.addListener(listener); + + dbg.postMessage( + JSON.stringify({ + type: "add-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + }) + ); + }); +} diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs new file mode 100644 index 0000000000..adf5ce175f --- /dev/null +++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs @@ -0,0 +1,293 @@ +/* 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 { 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 + // which also has to be a true singleton. + loadInDevToolsLoader: false, + } +); + +const lazy = {}; + +loader.lazyRequireGetter( + lazy, + "JsWindowActorTransport", + "resource://devtools/shared/transport/js-window-actor-transport.js", + true +); + +export class DevToolsWorkerParent extends JSWindowActorParent { + constructor() { + super(); + + this._destroyed = false; + + // Map of DevToolsServerConnection's used to forward the messages from/to + // the client. The connections run in the parent process, as this code. We + // may have more than one when there is more than one client debugging the + // same worker. For example, a content toolbox and the browser toolbox. + // + // The map is indexed by the connection prefix, and the values are object with the + // following properties: + // - watcher: The WatcherActor + // - actors: A Map of the worker target actors form, indexed by WorkerTarget actorID + // - transport: the JsWindowActorTransport + // + // Reminder about prefixes: all DevToolsServerConnections have a `prefix` + // which can be considered as a kind of id. On top of this, parent process + // DevToolsServerConnections also have forwarding prefixes because they are + // responsible for forwarding messages to content process connections. + this._connections = new Map(); + + this._onConnectionClosed = this._onConnectionClosed.bind(this); + EventEmitter.decorate(this); + } + + /** + * Request the content process to create Worker Targets if workers matching the context + * are already available. + */ + async instantiateWorkerTargets({ + watcherActorID, + connectionPrefix, + sessionContext, + sessionData, + }) { + try { + await this.sendQuery( + "DevToolsWorkerParent:instantiate-already-available", + { + watcherActorID, + connectionPrefix, + sessionContext, + sessionData, + } + ); + } catch (e) { + console.warn( + "Failed to create DevTools Worker target for browsingContext", + this.browsingContext.id, + "and watcher actor id", + watcherActorID + ); + console.warn(e); + } + } + + destroyWorkerTargets({ watcherActorID, sessionContext }) { + return this.sendAsyncMessage("DevToolsWorkerParent:destroy", { + watcherActorID, + sessionContext, + }); + } + + /** + * Communicate to the content process that some data have been added. + */ + async addSessionDataEntry({ watcherActorID, sessionContext, type, entries }) { + try { + await this.sendQuery("DevToolsWorkerParent:addSessionDataEntry", { + watcherActorID, + sessionContext, + type, + entries, + }); + } catch (e) { + console.warn( + "Failed to add session data entry for worker targets in browsing context", + this.browsingContext.id, + "and watcher actor id", + watcherActorID + ); + console.warn(e); + } + } + + /** + * Communicate to the content process that some data have been removed. + */ + removeSessionDataEntry({ watcherActorID, sessionContext, type, entries }) { + this.sendAsyncMessage("DevToolsWorkerParent:removeSessionDataEntry", { + watcherActorID, + sessionContext, + type, + entries, + }); + } + + workerTargetAvailable({ + watcherActorID, + forwardingPrefix, + workerTargetForm, + }) { + if (this._destroyed) { + return; + } + + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + + const connection = watcher.conn; + const { prefix } = connection; + if (!this._connections.has(prefix)) { + connection.on("closed", this._onConnectionClosed); + + // Create a js-window-actor based transport. + const transport = new lazy.JsWindowActorTransport(this, forwardingPrefix); + transport.hooks = { + onPacket: connection.send.bind(connection), + onTransportClosed() {}, + }; + transport.ready(); + + connection.setForwarding(forwardingPrefix, transport); + + this._connections.set(prefix, { + watcher, + transport, + actors: new Map(), + }); + } + + const workerTargetActorId = workerTargetForm.actor; + this._connections + .get(prefix) + .actors.set(workerTargetActorId, workerTargetForm); + watcher.notifyTargetAvailable(workerTargetForm); + } + + workerTargetDestroyed({ + watcherActorID, + forwardingPrefix, + workerTargetForm, + }) { + const watcher = WatcherRegistry.getWatcher(watcherActorID); + + if (!watcher) { + throw new Error( + `Watcher Actor with ID '${watcherActorID}' can't be found.` + ); + } + + const connection = watcher.conn; + const { prefix } = connection; + if (!this._connections.has(prefix)) { + return; + } + + const workerTargetActorId = workerTargetForm.actor; + const { actors } = this._connections.get(prefix); + if (!actors.has(workerTargetActorId)) { + return; + } + + actors.delete(workerTargetActorId); + watcher.notifyTargetDestroyed(workerTargetForm); + } + + _onConnectionClosed(status, prefix) { + this._unregisterWatcher(prefix); + } + + async _unregisterWatcher(connectionPrefix) { + const connectionInfo = this._connections.get(connectionPrefix); + if (!connectionInfo) { + return; + } + + const { watcher, transport } = connectionInfo; + const connection = watcher.conn; + + connection.off("closed", this._onConnectionClosed); + if (transport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this transport. + connection.cancelForwarding(transport._prefix); + transport.close(); + } + + this._connections.delete(connectionPrefix); + + if (!this._connections.size) { + this._destroy(); + } + } + + _destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + for (const { actors, watcher } of this._connections.values()) { + for (const actor of actors.values()) { + watcher.notifyTargetDestroyed(actor); + } + + this._unregisterWatcher(watcher.conn.prefix); + } + } + + /** + * Part of JSActor API + * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52 + * + * > The didDestroy method, if present, will be called after the (JSWindow)actor is no + * > longer able to receive any more messages. + */ + didDestroy() { + this._destroy(); + } + + /** + * Supported Queries + */ + + async sendPacket(packet, prefix) { + return this.sendAsyncMessage("DevToolsWorkerParent:packet", { + packet, + prefix, + }); + } + + /** + * JsWindowActor API + */ + + async sendQuery(msg, args) { + try { + const res = await super.sendQuery(msg, args); + return res; + } catch (e) { + console.error("Failed to sendQuery in DevToolsWorkerParent", msg, e); + throw e; + } + } + + receiveMessage(message) { + switch (message.name) { + case "DevToolsWorkerChild:workerTargetAvailable": + return this.workerTargetAvailable(message.data); + case "DevToolsWorkerChild:workerTargetDestroyed": + return this.workerTargetDestroyed(message.data); + case "DevToolsWorkerChild:packet": + return this.emit("packet-received", message); + default: + throw new Error( + "Unsupported message in DevToolsWorkerParent: " + message.name + ); + } + } +} diff --git a/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs new file mode 100644 index 0000000000..ae15c030fe --- /dev/null +++ b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs @@ -0,0 +1,76 @@ +/* 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/. */ + +function getWindowGlobalUri(windowGlobal) { + let windowGlobalUri = ""; + + if (windowGlobal.documentURI) { + // If windowGlobal is a WindowGlobalParent documentURI should be available. + windowGlobalUri = windowGlobal.documentURI.spec; + } else if (windowGlobal.browsingContext?.window) { + // If windowGlobal is a WindowGlobalChild, this code runs in the same + // process as the document and we can directly access the window.location + // object. + windowGlobalUri = windowGlobal.browsingContext.window.location.href; + if (!windowGlobalUri) { + windowGlobalUri = + windowGlobal.browsingContext.window.document.documentURI; + } + } + + return windowGlobalUri; +} + +export const WindowGlobalLogger = { + /** + * This logger can run from the content or parent process, and windowGlobal + * will either be of type `WindowGlobalParent` or `WindowGlobalChild`. + * + * The interface for each type can be found in WindowGlobalActors.webidl + * (https://searchfox.org/mozilla-central/source/dom/chrome-webidl/WindowGlobalActors.webidl) + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The window global to log. See WindowGlobalActors.webidl for details + * about the types. + * @param {String} message + * A custom message that will be displayed at the beginning of the log. + */ + logWindowGlobal(windowGlobal, message) { + const { browsingContext } = windowGlobal; + const { parent } = browsingContext; + const windowGlobalUri = getWindowGlobalUri(windowGlobal); + const isInitialDocument = + "isInitialDocument" in windowGlobal + ? windowGlobal.isInitialDocument + : windowGlobal.browsingContext.window?.document.isInitialDocument; + + const details = []; + details.push( + "BrowsingContext.browserId: " + browsingContext.browserId, + "BrowsingContext.id: " + browsingContext.id, + "innerWindowId: " + windowGlobal.innerWindowId, + "opener.id: " + browsingContext.opener?.id, + "pid: " + windowGlobal.osPid, + "isClosed: " + windowGlobal.isClosed, + "isInProcess: " + windowGlobal.isInProcess, + "isCurrentGlobal: " + windowGlobal.isCurrentGlobal, + "isProcessRoot: " + windowGlobal.isProcessRoot, + "currentRemoteType: " + browsingContext.currentRemoteType, + "hasParent: " + (parent ? parent.id : "no"), + "uri: " + (windowGlobalUri ? windowGlobalUri : "no uri"), + "isProcessRoot: " + windowGlobal.isProcessRoot, + "BrowsingContext.isContent: " + windowGlobal.browsingContext.isContent, + "isInitialDocument: " + isInitialDocument + ); + + const header = "[WindowGlobalLogger] " + message; + + // Use a padding for multiline display. + const padding = " "; + const formattedDetails = details.map(s => padding + s); + const detailsString = formattedDetails.join("\n"); + + dump(header + "\n" + detailsString + "\n"); + }, +}; diff --git a/devtools/server/connectors/js-window-actor/moz.build b/devtools/server/connectors/js-window-actor/moz.build new file mode 100644 index 0000000000..faaaa8dd54 --- /dev/null +++ b/devtools/server/connectors/js-window-actor/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "DevToolsFrameChild.sys.mjs", + "DevToolsFrameParent.sys.mjs", + "DevToolsWorkerChild.sys.mjs", + "DevToolsWorkerParent.sys.mjs", + "WindowGlobalLogger.sys.mjs", +) |