fune/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs
Alexandre Poirot 8e9b448440 Bug 1766485 - [devtools] Consider server side target switching always enabled for tab toolboxes. r=devtools-reviewers,nchevobbe
This removes the devtools.target-switching.server.enabled preference,
which was only having an impact on tab debugging.
Toolboxes other than tabs aren't yet using "server side target switching",
which will probably happen once we enable EFT in these and
we would probably not use a preference for enabling this.

This remove all assertions in tests covering client side target switching.
And it removed a couple of tests that became irrelevant.

Differential Revision: https://phabricator.services.mozilla.com/D166921
2023-01-30 12:49:54 +00:00

689 lines
28 KiB
JavaScript

/* 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;
}
}
}