From d8bbc7858622b6d9c278469aab701ca0b609cddf Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 15 May 2024 05:35:49 +0200 Subject: Merging upstream version 126.0. Signed-off-by: Daniel Baumann --- .../js-process-actor/target-watchers/moz.build | 12 + .../target-watchers/process.sys.mjs | 95 ++++ .../target-watchers/service_worker.sys.mjs | 51 ++ .../target-watchers/window-global.sys.mjs | 574 +++++++++++++++++++++ .../target-watchers/worker.sys.mjs | 457 ++++++++++++++++ 5 files changed, 1189 insertions(+) create mode 100644 devtools/server/connectors/js-process-actor/target-watchers/moz.build create mode 100644 devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs create mode 100644 devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs create mode 100644 devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs create mode 100644 devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs (limited to 'devtools/server/connectors/js-process-actor/target-watchers') 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 + // 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(); -- cgit v1.2.3