Bug 1936774 - [remote] Prototype parent process NavigationListener r=webdriver-reviewers,Sasha

Depends on D245945

Differential Revision: https://phabricator.services.mozilla.com/D245946
This commit is contained in:
Julian Descottes
2025-04-29 06:31:17 +00:00
parent d168b1434d
commit ea1907fec4
7 changed files with 428 additions and 5 deletions

View File

@@ -3918,6 +3918,9 @@ pref("services.common.log.logger.tokenserverclient", "Debug");
// Enable retrying to execute commands in the child process in case the // Enable retrying to execute commands in the child process in case the
// JSWindowActor gets destroyed. // JSWindowActor gets destroyed.
pref("remote.retry-on-abort", true); pref("remote.retry-on-abort", true);
// Enable the NavigationManager using parent process WebProgress listeners
pref("remote.experimental-parent-navigation.enabled", false);
#endif #endif
// Enable the JSON View tool (an inspector for application/json documents). // Enable the JSON View tool (an inspector for application/json documents).

View File

@@ -53,6 +53,7 @@ remote.jar:
content/shared/listeners/NavigationListener.sys.mjs (shared/listeners/NavigationListener.sys.mjs) content/shared/listeners/NavigationListener.sys.mjs (shared/listeners/NavigationListener.sys.mjs)
content/shared/listeners/NetworkEventRecord.sys.mjs (shared/listeners/NetworkEventRecord.sys.mjs) content/shared/listeners/NetworkEventRecord.sys.mjs (shared/listeners/NetworkEventRecord.sys.mjs)
content/shared/listeners/NetworkListener.sys.mjs (shared/listeners/NetworkListener.sys.mjs) content/shared/listeners/NetworkListener.sys.mjs (shared/listeners/NetworkListener.sys.mjs)
content/shared/listeners/ParentWebProgressListener.sys.mjs (shared/listeners/ParentWebProgressListener.sys.mjs)
content/shared/listeners/PromptListener.sys.mjs (shared/listeners/PromptListener.sys.mjs) content/shared/listeners/PromptListener.sys.mjs (shared/listeners/PromptListener.sys.mjs)
# JSWindowActors # JSWindowActors

View File

@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {}; const lazy = {};
@@ -11,6 +12,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
"chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs", "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs",
ParentWebProgressListener:
"chrome://remote/content/shared/listeners/ParentWebProgressListener.sys.mjs",
PromptListener: PromptListener:
"chrome://remote/content/shared/listeners/PromptListener.sys.mjs", "chrome://remote/content/shared/listeners/PromptListener.sys.mjs",
registerWebProgressListenerActor: registerWebProgressListenerActor:
@@ -23,6 +26,13 @@ ChromeUtils.defineESModuleGetters(lazy, {
ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"useParentWebProgressListener",
"remote.experimental-parent-navigation.enabled",
false
);
/** /**
* @typedef {object} BrowsingContextDetails * @typedef {object} BrowsingContextDetails
* @property {string} browsingContextId - The browsing context id. * @property {string} browsingContextId - The browsing context id.
@@ -71,6 +81,7 @@ class NavigationRegistry extends EventEmitter {
#contextListener; #contextListener;
#managers; #managers;
#navigations; #navigations;
#parentWebProgressListener;
#promptListener; #promptListener;
constructor() { constructor() {
@@ -82,6 +93,10 @@ class NavigationRegistry extends EventEmitter {
// Maps navigable id to NavigationInfo. // Maps navigable id to NavigationInfo.
this.#navigations = new Map(); this.#navigations = new Map();
if (lazy.useParentWebProgressListener) {
this.#parentWebProgressListener = new lazy.ParentWebProgressListener();
}
this.#contextListener = new lazy.BrowsingContextListener(); this.#contextListener = new lazy.BrowsingContextListener();
this.#contextListener.on("attached", this.#onContextAttached); this.#contextListener.on("attached", this.#onContextAttached);
this.#contextListener.on("discarded", this.#onContextDiscarded); this.#contextListener.on("discarded", this.#onContextDiscarded);
@@ -121,7 +136,11 @@ class NavigationRegistry extends EventEmitter {
*/ */
startMonitoring(listener) { startMonitoring(listener) {
if (this.#managers.size == 0) { if (this.#managers.size == 0) {
lazy.registerWebProgressListenerActor(); if (lazy.useParentWebProgressListener) {
this.#parentWebProgressListener.startListening();
} else {
lazy.registerWebProgressListenerActor();
}
this.#contextListener.startListening(); this.#contextListener.startListening();
this.#promptListener.startListening(); this.#promptListener.startListening();
} }
@@ -142,7 +161,11 @@ class NavigationRegistry extends EventEmitter {
if (this.#managers.size == 0) { if (this.#managers.size == 0) {
this.#contextListener.stopListening(); this.#contextListener.stopListening();
this.#promptListener.stopListening(); this.#promptListener.stopListening();
lazy.unregisterWebProgressListenerActor(); if (lazy.useParentWebProgressListener) {
this.#parentWebProgressListener.stopListening();
} else {
lazy.unregisterWebProgressListenerActor();
}
// Clear the map. // Clear the map.
this.#navigations = new Map(); this.#navigations = new Map();
} }

View File

@@ -0,0 +1,352 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowsingContextListener:
"chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
isInitialDocument:
"chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs",
notifyFragmentNavigated:
"chrome://remote/content/shared/NavigationManager.sys.mjs",
notifySameDocumentChanged:
"chrome://remote/content/shared/NavigationManager.sys.mjs",
notifyNavigationFailed:
"chrome://remote/content/shared/NavigationManager.sys.mjs",
notifyNavigationStarted:
"chrome://remote/content/shared/NavigationManager.sys.mjs",
notifyNavigationStopped:
"chrome://remote/content/shared/NavigationManager.sys.mjs",
TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
truncate: "chrome://remote/content/shared/Format.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
/**
* Not to be confused with the WebProgressListenerParent which is the parent
* actor for the WebProgressListener JSWindow actor pair.
*
* The ParentWebProgressListener is a listener that supports monitoring
* navigations for the NavigationManager entirely from the parent process.
*
* The NavigationManager will either use the WebProgressListener JSWindow actors
* or this listener, depending on the value of the hidden preference
* remote.experimental-parent-navigation.enabled.
*
* This listener does not implement the same interface as our other listeners
* and is designed to be instantiated only once from the NavigationRegistry
* singleton.
*
* Once we remove the WebProgressListener JS Window actors and only use this
* listener, we may update it for consistency with the rest of the codebase but
* in the meantime, the goal is to avoid the impact on the existing
* implementation used by default.
*/
export class ParentWebProgressListener {
#contextListener;
#listener;
#listening;
#monitoredWebProgress;
constructor() {
this.#monitoredWebProgress = new Map();
this.#contextListener = new lazy.BrowsingContextListener();
this.#contextListener.on("attached", this.#onContextAttached);
this.#contextListener.on("discarded", this.#onContextDiscarded);
this.#listener = {
onLocationChange: this.#onLocationChange,
onStateChange: this.#onStateChange,
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
};
}
get listening() {
return this.#listening;
}
destroy() {
this.stopListening();
this.#contextListener.destroy();
this.#monitoredWebProgress = new Map();
}
#onLocationChange = (progress, request, location, flags) => {
if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
const context = progress.browsingContext;
const payload = {
contextDetails: { context },
url: location.spec,
};
if (location.hasRef) {
// If the target URL contains a hash, handle the navigation as a
// fragment navigation.
this.#trace(
lazy.truncate`Location=fragmentNavigated: ${location.spec}`,
context.id
);
lazy.notifyFragmentNavigated(payload);
return;
}
this.#trace(
lazy.truncate`Location=sameDocumentChanged: ${location.spec}`,
context.id
);
lazy.notifySameDocumentChanged(payload);
}
};
#onStateChange = (progress, request, stateFlags, status) => {
const context = progress.browsingContext;
const targetURI = this.#getTargetURI(request);
const isBindingAborted = status == Cr.NS_BINDING_ABORTED;
const isStart = !!(stateFlags & Ci.nsIWebProgressListener.STATE_START);
const isStop = !!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP);
if (lazy.Log.isTraceLevelOrMore) {
const isNetwork = !!(
stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
);
this.#trace(
`Loading state: flags: ${stateFlags}, status: ${status}, ` +
` isStart: ${isStart}, isStop: ${isStop}, isNetwork: ${isNetwork},` +
` isBindingAborted: ${isBindingAborted},` +
lazy.truncate` targetURI: ${targetURI?.spec}`,
context.id
);
}
const url = targetURI?.spec;
const isInitialDocument = lazy.isInitialDocument(context);
if (isInitialDocument && url === "about:blank") {
this.#trace("Skip initial navigation to about:blank", context.id);
return;
}
try {
if (isStart) {
lazy.notifyNavigationStarted({
contextDetails: { context },
url,
});
return;
}
if (isStop && !isBindingAborted) {
const errorName = ChromeUtils.getXPCOMErrorName(status);
if (this.#isContentBlocked(errorName)) {
lazy.notifyNavigationFailed({
contextDetails: { context },
errorName,
status,
url,
});
} else {
lazy.notifyNavigationStopped({
contextDetails: { context },
status,
url,
});
}
}
} catch (e) {
if (e.name === "InvalidStateError") {
// We'll arrive here if we no longer have our manager, so we can
// just swallow this error.
return;
}
throw e;
}
};
startListening() {
if (this.#listening) {
return;
}
this.#contextListener.startListening();
// Start listening for navigation on all existing contexts.
this.#getAllBrowsingContexts().forEach(browsingContext =>
this.#startWatchingBrowsingContextNavigation(browsingContext)
);
this.#listening = true;
}
stopListening() {
if (!this.#listening) {
return;
}
this.#contextListener.stopListening();
for (const webProgress of this.#monitoredWebProgress.keys()) {
try {
webProgress.removeProgressListener(
this.#listener,
Ci.nsIWebProgress.NOTIFY_STATE_ALL
);
} catch (e) {
this.#trace(`Failed to remove the progress listener`);
}
}
this.#monitoredWebProgress = new Map();
this.#listening = false;
}
#getAllBrowsingContexts() {
return lazy.TabManager.browsers.flatMap(browser =>
browser.browsingContext.getAllBrowsingContextsInSubtree()
);
}
#getTargetURI(request) {
try {
return request.QueryInterface(Ci.nsIChannel).originalURI;
} catch (e) {}
return null;
}
#isContentBlocked(blockedReason) {
return [
// If content is blocked with e.g. CSP meta tag.
"NS_ERROR_CONTENT_BLOCKED",
// If a resource load was blocked because of the CSP header.
"NS_ERROR_CSP_FRAME_ANCESTOR_VIOLATION",
// If a resource load was blocked because of the Cross-Origin-Embedder-Policy header.
"NS_ERROR_DOM_COEP_FAILED",
// If a resource load was blocked because of the X-Frame-Options header.
"NS_ERROR_XFO_VIOLATION",
].includes(blockedReason);
}
#onContextAttached = async (eventName, data) => {
const { browsingContext } = data;
this.#startWatchingBrowsingContextNavigation(browsingContext);
};
#onContextDiscarded = async (eventName, data = {}) => {
const { browsingContext } = data;
this.#stopWatchingBrowsingContextNavigation(browsingContext);
};
#startWatchingBrowsingContextNavigation(browsingContext) {
if (browsingContext.parent) {
// Frame contexts will be monitored through the webprogress listener of
// the top window.
return;
}
this.#trace(
`Start watching updates for browsing context`,
browsingContext.id
);
const webProgress = browsingContext.webProgress;
if (!webProgress) {
this.#trace(
`No web progress attached to this browsing context, bailing out`,
browsingContext.id
);
return;
}
if (!this.#monitoredWebProgress.has(webProgress)) {
this.#trace(
`The web progress was not monitored yet, adding a progress listener`,
browsingContext.id
);
this.#monitoredWebProgress.set(webProgress, new Set());
webProgress.addProgressListener(
this.#listener,
Ci.nsIWebProgress.NOTIFY_ALL
);
}
this.#monitoredWebProgress.get(webProgress).add(browsingContext);
}
#stopWatchingBrowsingContextNavigation(browsingContext) {
if (browsingContext.parent) {
// Frame contexts will be monitored through the webprogress listener of
// the top window.
return;
}
this.#trace(
`Stop watching updates for browsing context`,
browsingContext.id
);
const webProgress = browsingContext.webProgress;
if (!webProgress) {
this.#trace(
`No web progress attached to this browsing context, bailing out`,
browsingContext.id
);
return;
}
const contexts = this.#monitoredWebProgress.get(webProgress);
if (!contexts) {
this.#trace(
`No browsing context tracked for the web progress, bailing out`,
browsingContext.id
);
return;
}
contexts.delete(browsingContext);
if (!contexts.size) {
this.#trace(
`All browsing contexts for this web progress deleted, removing the progress listener`,
browsingContext.id
);
try {
webProgress.removeProgressListener(
this.#listener,
Ci.nsIWebProgress.NOTIFY_STATE_ALL
);
} catch (e) {
this.#trace(
`Failed to remove the progress listener`,
browsingContext.id
);
}
this.#trace(
`Removing the web progress from monitored web progress`,
browsingContext.id
);
this.#monitoredWebProgress.delete(webProgress);
}
}
#trace(message, contextId = null) {
if (contextId !== null) {
lazy.logger.trace(
`${this.constructor.name} ${message} [context=${contextId}]`
);
} else {
lazy.logger.trace(`${this.constructor.name} ${message}`);
}
}
}

View File

@@ -41,6 +41,7 @@ add_task(async function test_navigationManager() {
); );
is(navigation.url, testUrl, "Navigation has the expected URL"); is(navigation.url, testUrl, "Navigation has the expected URL");
await BrowserTestUtils.waitForCondition(() => events.length === 2);
is(events.length, 2, "Received 2 navigation events"); is(events.length, 2, "Received 2 navigation events");
is(events[0].name, "navigation-started"); is(events[0].name, "navigation-started");
is(events[1].name, "navigation-stopped"); is(events[1].name, "navigation-stopped");

View File

@@ -41,6 +41,7 @@ add_task(async function test_simpleNavigation() {
is(events.length, 0, "No event recorded"); is(events.length, 0, "No event recorded");
await loadURL(browser, SECOND_URL); await loadURL(browser, SECOND_URL);
await BrowserTestUtils.waitForCondition(() => events.length === 2);
const firstNavigation = navigationManager.getNavigationForBrowsingContext( const firstNavigation = navigationManager.getNavigationForBrowsingContext(
browser.browsingContext browser.browsingContext
@@ -56,6 +57,7 @@ add_task(async function test_simpleNavigation() {
); );
await loadURL(browser, THIRD_URL); await loadURL(browser, THIRD_URL);
await BrowserTestUtils.waitForCondition(() => events.length === 4);
const secondNavigation = navigationManager.getNavigationForBrowsingContext( const secondNavigation = navigationManager.getNavigationForBrowsingContext(
browser.browsingContext browser.browsingContext
@@ -109,6 +111,7 @@ add_task(async function test_loadTwoTabsSimultaneously() {
info("Wait for the tabs to load"); info("Wait for the tabs to load");
await Promise.all([onLoad1, onLoad2]); await Promise.all([onLoad1, onLoad2]);
await BrowserTestUtils.waitForCondition(() => events.length === 4);
is(events.length, 4, "Recorded 4 navigation events"); is(events.length, 4, "Recorded 4 navigation events");
@@ -132,6 +135,7 @@ add_task(async function test_loadTwoTabsSimultaneously() {
BrowserTestUtils.reloadTab(tab1), BrowserTestUtils.reloadTab(tab1),
BrowserTestUtils.reloadTab(tab2), BrowserTestUtils.reloadTab(tab2),
]); ]);
await BrowserTestUtils.waitForCondition(() => events.length === 8);
is(events.length, 8, "Recorded 8 navigation events"); is(events.length, 8, "Recorded 8 navigation events");
@@ -156,6 +160,19 @@ add_task(async function test_loadTwoTabsSimultaneously() {
}); });
add_task(async function test_loadPageWithIframes() { add_task(async function test_loadPageWithIframes() {
if (
Services.prefs.getBoolPref(
"remote.experimental-parent-navigation.enabled",
false
)
) {
todo(
false,
"The ParentWebProgressListener misses events from same process iframes"
);
return;
}
const events = []; const events = [];
const onEvent = (name, data) => events.push({ name, data }); const onEvent = (name, data) => events.push({ name, data });
@@ -170,6 +187,7 @@ add_task(async function test_loadPageWithIframes() {
const tab = addTab(gBrowser, testUrl); const tab = addTab(gBrowser, testUrl);
const browser = tab.linkedBrowser; const browser = tab.linkedBrowser;
await BrowserTestUtils.browserLoaded(browser, false, testUrl); await BrowserTestUtils.browserLoaded(browser, false, testUrl);
await BrowserTestUtils.waitForCondition(() => events.length === 8);
is(events.length, 8, "Recorded 8 navigation events"); is(events.length, 8, "Recorded 8 navigation events");
const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
@@ -188,6 +206,7 @@ add_task(async function test_loadPageWithIframes() {
assertUniqueNavigationIds(...navigations); assertUniqueNavigationIds(...navigations);
await BrowserTestUtils.reloadTab(tab); await BrowserTestUtils.reloadTab(tab);
await BrowserTestUtils.waitForCondition(() => events.length === 16);
is(events.length, 16, "Recorded 8 additional navigation events"); is(events.length, 16, "Recorded 8 additional navigation events");
const newContexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); const newContexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
@@ -210,6 +229,19 @@ add_task(async function test_loadPageWithIframes() {
}); });
add_task(async function test_loadPageWithCoop() { add_task(async function test_loadPageWithCoop() {
if (
Services.prefs.getBoolPref(
"remote.experimental-parent-navigation.enabled",
false
)
) {
todo(
false,
"The ParentWebProgressListener misses navigation stopped for coop navigation"
);
return;
}
const tab = addTab(gBrowser, FIRST_COOP_URL); const tab = addTab(gBrowser, FIRST_COOP_URL);
const browser = tab.linkedBrowser; const browser = tab.linkedBrowser;
await BrowserTestUtils.browserLoaded(browser, false, FIRST_COOP_URL); await BrowserTestUtils.browserLoaded(browser, false, FIRST_COOP_URL);
@@ -225,6 +257,7 @@ add_task(async function test_loadPageWithCoop() {
const navigableId = TabManager.getIdForBrowser(browser); const navigableId = TabManager.getIdForBrowser(browser);
await loadURL(browser, SECOND_COOP_URL); await loadURL(browser, SECOND_COOP_URL);
await BrowserTestUtils.waitForCondition(() => events.length === 2);
const coopNavigation = navigationManager.getNavigationForBrowsingContext( const coopNavigation = navigationManager.getNavigationForBrowsingContext(
browser.browsingContext browser.browsingContext
@@ -286,6 +319,7 @@ add_task(async function test_sameDocumentNavigation() {
// complete. // complete.
info("Perform a regular navigation"); info("Perform a regular navigation");
await loadURL(browser, url); await loadURL(browser, url);
await BrowserTestUtils.waitForCondition(() => events.length === 3);
const regularNavigation = navigationManager.getNavigationForBrowsingContext( const regularNavigation = navigationManager.getNavigationForBrowsingContext(
browser.browsingContext browser.browsingContext

View File

@@ -50,9 +50,18 @@ add_task(async function testDocumentOpenWriteClose() {
info("Reload the page, which should trigger a navigation"); info("Reload the page, which should trigger a navigation");
await loadURL(browser, url); await loadURL(browser, url);
// See Bug 1844517. if (
// document.open/write/close is identical to same-url + same-hash navigations. Services.prefs.getBoolPref(
todo_is(events.length, 2, "Recorded navigation events"); "remote.experimental-parent-navigation.enabled",
false
)
) {
is(events.length, 2, "Recorded navigation events");
} else {
// See Bug 1844517.
// document.open/write/close is identical to same-url + same-hash navigations.
todo_is(events.length, 2, "Recorded navigation events");
}
navigationManager.off("fragment-navigated", onEvent); navigationManager.off("fragment-navigated", onEvent);
navigationManager.off("navigation-started", onEvent); navigationManager.off("navigation-started", onEvent);