Bug 1952682 - Introduce support for SpecialPowers.spawn in a content process r=nika

This patch introduces SpecialPowersForProcess + spawn() to enable
callers to spawn a task in the specified content process, and adds
a helper to ContentPage that is used in tests in bug 1952681.

Differential Revision: https://phabricator.services.mozilla.com/D240779
This commit is contained in:
Rob Wu
2025-05-05 21:20:12 +00:00
committed by rob@robwu.nl
parent 6e7e47de70
commit e538e4ca22
10 changed files with 530 additions and 5 deletions

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>Page that embeds two cross-origin frames</title>
<body>
<script>
async function addFrame(id, filename) {
let url = new URL(filename, document.URL);
url.hostname = url.hostname === "example.com" ? "example.org" : "example.com";
let f = document.createElement("iframe");
f.id = id;
f.src = url.href;
await new Promise(resolve => {
f.onload = resolve;
document.body.append(f);
});
}
if (window === top) {
window.loadedPromise = Promise.all([
addFrame("frame1", "?this_is_child_frame_1"),
addFrame("frame2", "?this_is_child_frame_2"),
]).then(() => "frames_all_loaded");
} else {
document.body.append("This is child frame: " + location);
}
</script>

View File

@@ -0,0 +1,153 @@
"use strict";
// Tests SpecialPowersForProcess spawn() in xpcshell tests.
// A browser chrome mochitest version of this test exists at
// testing/mochitest/tests/browser/browser_SpecialPowersForProcessSpawn.js
const { XPCShellContentUtils } = ChromeUtils.importESModule(
"resource://testing-common/XPCShellContentUtils.sys.mjs"
);
const { SpecialPowersForProcess } = ChromeUtils.importESModule(
"resource://testing-common/SpecialPowersProcessActor.sys.mjs"
);
XPCShellContentUtils.init(this);
const server = XPCShellContentUtils.createHttpServer({
hosts: ["example.com", "example.org"],
});
server.registerFile(
"/file_xorigin_frames.html",
do_get_file("file_xorigin_frames.html")
);
const scope = this;
const interceptedMessages = [];
add_setup(() => {
const orig_do_report_result = scope.do_report_result;
scope.do_report_result = (passed, msg, stack) => {
if (msg?.startsWith?.("CHECK_THIS:")) {
interceptedMessages.push(msg);
}
return orig_do_report_result(passed, msg, stack);
};
const orig_info = scope.info;
scope.info = msg => {
if (msg?.startsWith?.("CHECK_THIS:")) {
interceptedMessages.push(msg);
}
return orig_info(msg);
};
registerCleanupFunction(() => {
scope.do_report_result = orig_do_report_result;
scope.info = orig_info;
});
});
// Tests that SpecialPowersForProcess can spawn() in processes that the test
// grabbed off a contentPage, even after the original page navigated/closed.
add_task(async function test_SpecialPowersForProcess_spawn() {
interceptedMessages.length = 0;
const page = await XPCShellContentUtils.loadContentPage(
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
"http://example.com/file_xorigin_frames.html",
{ remote: true, remoteSubframes: true }
);
await page.spawn([], async () => {
Assert.equal(
await this.content.wrappedJSObject.loadedPromise,
"frames_all_loaded",
"All (cross-origin) frames have finished loading"
);
});
const proc1 = page.browsingContext.children[0].currentWindowGlobal.domProcess;
const proc2 = page.browsingContext.children[1].currentWindowGlobal.domProcess;
Assert.equal(proc1, proc2, "The two child frames share the same process");
const processBoundSpecialPowers = new SpecialPowersForProcess(scope, proc1);
await page.spawn([], async () => {
info("ContentPage.spawn: Change frame1 process");
const frame1 = this.content.document.getElementById("frame1");
Assert.throws(
() => frame1.contentDocument.location.search,
/TypeError: can't access property "location", frame1.contentDocument is null/,
"ContentPage.spawn: Assert, cannot read cross-origin content"
);
await new Promise(resolve => {
frame1.onload = resolve;
frame1.src = "/dummy?3";
});
// Verify that it is same-origin now.
Assert.equal(
frame1.contentDocument.location.search,
"?3",
"CHECK_THIS: ContentPage.spawn: Assert, frame1 is now same-origin"
);
info("CHECK_THIS: ContentPage.spawn: remove frame1");
frame1.remove();
// spawn() implementation has special logic to route Assert messages;
// Prepare to check that Assert can be called after spawn() returns.
this.content.assertAfterSpawnReturns = () => {
Assert.ok(true, "CHECK_THIS: ContentPage.spawn: asssert after return");
};
});
await page.spawn([], () => {
this.content.assertAfterSpawnReturns();
});
Assert.equal(page.browsingContext.children.length, 1, "frame1 was removed");
// Now frame1 has navigated (and switched processes) and removed, so if the
// SpecialPowers implementation were to rely on JSWindowActor, then that
// would break if we try to interact with it at this point. Check that we
// can connect just fine (because JSProcessActor should be used instead).
await processBoundSpecialPowers.spawn([], () => {
info("CHECK_THIS: process-bound spawn: still works");
Assert.equal(
typeof content,
"undefined",
"CHECK_THIS: process-bound spawn: no content global"
);
// Need a shared object that outlives this SpecialPowersSandbox instance:
const sharedGlobalObj = Cu.getGlobalForObject(Services);
// spawn() implementation has special logic to route Assert messages;
// Prepare to check that Assert can be called after spawn() returns.
sharedGlobalObj.assertAfterProcessBoundSpawnReturns = () => {
Assert.ok(true, "CHECK_THIS: process-bound spawn: asssert after return");
};
});
await processBoundSpecialPowers.spawn([], () => {
// Shared object that outlived the previous SpecialPowersSandbox instance:
const sharedGlobalObj = Cu.getGlobalForObject(Services);
sharedGlobalObj.assertAfterProcessBoundSpawnReturns();
delete sharedGlobalObj.assertAfterProcessBoundSpawnReturns;
});
await page.close();
await processBoundSpecialPowers.destroy();
Assert.throws(
() => processBoundSpecialPowers.spawn([], () => {}),
/this.actor is null/,
"Cannot spawn after destroy()"
);
const observedMessages = interceptedMessages.splice(0);
Assert.deepEqual(
observedMessages,
[
`CHECK_THIS: ContentPage.spawn: Assert, frame1 is now same-origin - "?3" == "?3"`,
"CHECK_THIS: ContentPage.spawn: remove frame1",
"CHECK_THIS: ContentPage.spawn: asssert after return - true == true",
"CHECK_THIS: process-bound spawn: still works",
`CHECK_THIS: process-bound spawn: no content global - "undefined" == "undefined"`,
"CHECK_THIS: process-bound spawn: asssert after return - true == true",
],
"Observed calls through spawn"
);
});

View File

@@ -1,5 +1,9 @@
[DEFAULT]
["test_SpecialPowersForProcessSpawn.js"]
prefs = ["dom.security.https_first=false"] #Disable https-first because createHttpServer does not support https
support-files = ["file_xorigin_frames.html"]
["test_SpecialPowersSandbox.js"]
prefs = ["dom.security.https_first=false"] #Disable https-first because createHttpServer does not support https

View File

@@ -8,6 +8,9 @@ skip-if = [
"verify",
]
["browser_SpecialPowersForProcessSpawn.js"]
support-files = ["../Harness_sanity/file_xorigin_frames.html"]
["browser_add_task.js"]
["browser_async.js"]

View File

@@ -0,0 +1,138 @@
"use strict";
// Tests SpecialPowersForProcess spawn() in browser chrome mochitests.
// An xpcshell version of this test exists at
// testing/mochitest/tests/Harness_sanity/test_SpecialPowersForProcessSpawn.js
const { SpecialPowersForProcess } = ChromeUtils.importESModule(
"resource://testing-common/SpecialPowersProcessActor.sys.mjs"
);
const scope = this;
const interceptedMessages = [];
add_setup(() => {
const orig_record = SimpleTest.record;
SimpleTest.record = (condition, name, diag, stack, expected) => {
if (name?.startsWith?.("CHECK_THIS:")) {
interceptedMessages.push(name);
}
return orig_record(condition, name, diag, stack, expected);
};
const orig_info = SimpleTest.info;
SimpleTest.info = msg => {
if (msg?.startsWith?.("CHECK_THIS:")) {
interceptedMessages.push(msg);
}
return orig_info(msg);
};
registerCleanupFunction(() => {
SimpleTest.record = orig_record;
SimpleTest.info = orig_info;
});
});
// Tests that SpecialPowersForProcess can spawn() in processes that the test
// grabbed off a contentPage, even after the original page navigated/closed.
add_task(async function test_SpecialPowersForProcess_spawn() {
interceptedMessages.length = 0;
const tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"https://example.com/browser/testing/mochitest/tests/browser/file_xorigin_frames.html"
);
await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
Assert.equal(
await this.content.wrappedJSObject.loadedPromise,
"frames_all_loaded",
"All (cross-origin) frames have finished loading"
);
});
const browsingContext = tab.linkedBrowser.browsingContext;
const proc1 = browsingContext.children[0].currentWindowGlobal.domProcess;
const proc2 = browsingContext.children[1].currentWindowGlobal.domProcess;
Assert.equal(proc1, proc2, "The two child frames share the same process");
const processBoundSpecialPowers = new SpecialPowersForProcess(scope, proc1);
await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
info("ContentPage.spawn: Change frame1 process");
const frame1 = this.content.document.getElementById("frame1");
Assert.throws(
() => frame1.contentDocument.location.search,
/TypeError: can't access property "location", frame1.contentDocument is null/,
"ContentPage.spawn: Assert, cannot read cross-origin content"
);
await new Promise(resolve => {
frame1.onload = resolve;
frame1.src = "/dummy?3";
});
// Verify that it is same-origin now.
Assert.equal(
frame1.contentDocument.location.search,
"?3",
"CHECK_THIS: ContentPage.spawn: Assert, frame1 is now same-origin"
);
info("CHECK_THIS: ContentPage.spawn: remove frame1");
frame1.remove();
// spawn() implementation has special logic to route Assert messages;
// Prepare to check that Assert can be called after spawn() returns.
this.content.assertAfterSpawnReturns = () => {
Assert.ok(true, "CHECK_THIS: ContentPage.spawn: asssert after return");
};
});
await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
this.content.assertAfterSpawnReturns();
});
Assert.equal(browsingContext.children.length, 1, "frame1 was removed");
// Now frame1 has navigated (and switched processes) and removed, so if the
// SpecialPowers implementation were to rely on JSWindowActor, then that
// would break if we try to interact with it at this point. Check that we
// can connect just fine (because JSProcessActor should be used instead).
await processBoundSpecialPowers.spawn([], () => {
info("CHECK_THIS: process-bound spawn: still works");
Assert.equal(
typeof content,
"undefined",
"CHECK_THIS: process-bound spawn: no content global"
);
// Need a shared object that outlives this SpecialPowersSandbox instance:
const sharedGlobalObj = Cu.getGlobalForObject(Services);
// spawn() implementation has special logic to route Assert messages;
// Prepare to check that Assert can be called after spawn() returns.
sharedGlobalObj.assertAfterProcessBoundSpawnReturns = () => {
Assert.ok(true, "CHECK_THIS: process-bound spawn: asssert after return");
};
});
await processBoundSpecialPowers.spawn([], () => {
// Shared object that outlived the previous SpecialPowersSandbox instance:
const sharedGlobalObj = Cu.getGlobalForObject(Services);
sharedGlobalObj.assertAfterProcessBoundSpawnReturns();
delete sharedGlobalObj.assertAfterProcessBoundSpawnReturns;
});
BrowserTestUtils.removeTab(tab);
await processBoundSpecialPowers.destroy();
Assert.throws(
() => processBoundSpecialPowers.spawn([], () => {}),
/this.actor is null/,
"Cannot spawn after destroy()"
);
const observedMessages = interceptedMessages.splice(0);
Assert.deepEqual(
observedMessages,
[
`CHECK_THIS: ContentPage.spawn: Assert, frame1 is now same-origin - "?3" == "?3"`,
"CHECK_THIS: ContentPage.spawn: remove frame1",
"CHECK_THIS: ContentPage.spawn: asssert after return - true == true",
"CHECK_THIS: process-bound spawn: still works",
`CHECK_THIS: process-bound spawn: no content global - "undefined" == "undefined"`,
"CHECK_THIS: process-bound spawn: asssert after return - true == true",
],
"Observed calls through spawn"
);
});

View File

@@ -23,6 +23,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
ContentTask: "resource://testing-common/ContentTask.sys.mjs",
HttpServer: "resource://testing-common/httpd.sys.mjs",
SpecialPowersParent: "resource://testing-common/SpecialPowersParent.sys.mjs",
SpecialPowersForProcess:
"resource://testing-common/SpecialPowersProcessActor.sys.mjs",
TestUtils: "resource://testing-common/TestUtils.sys.mjs",
});
@@ -259,6 +261,15 @@ export class ContentPage {
return this.SpecialPowers.spawn(this.browser, params, task);
}
// Get a SpecialPowersForProcess instance associated with the content process
// of the currently loaded page. This allows callers to spawn() tasks that
// outlive the page (for as long as the page's process is around).
getCurrentContentProcessSpecialPowers() {
const testScope = XPCShellContentUtils.currentScope;
const domProcess = this.browsingContext.currentWindowGlobal.domProcess;
return new lazy.SpecialPowersForProcess(testScope, domProcess);
}
// Like spawn(), but uses the legacy ContentTask infrastructure rather than
// SpecialPowers. Exists only because the author of the SpecialPowers
// migration did not have the time to fix all of the legacy users who relied

View File

@@ -303,6 +303,7 @@ export class SpecialPowersChild extends JSWindowActorChild {
case "Assert":
{
// Handles info & Assert reports from SpecialPowersSandbox.sys.mjs.
if ("info" in message.data) {
(this.xpcshellScope || this.SimpleTest).info(message.data.info);
break;
@@ -311,12 +312,11 @@ export class SpecialPowersChild extends JSWindowActorChild {
// An assertion has been done in a mochitest chrome script
let { name, passed, stack, diag, expectFail } = message.data;
let { SimpleTest } = this;
if (SimpleTest) {
let expected = expectFail ? "fail" : "pass";
SimpleTest.record(passed, name, diag, stack, expected);
} else if (this.xpcshellScope) {
if (this.xpcshellScope) {
this.xpcshellScope.do_report_result(passed, name, stack);
} else if (this.SimpleTest) {
let expected = expectFail ? "fail" : "pass";
this.SimpleTest.record(passed, name, diag, stack, expected);
} else {
// Well, this is unexpected.
dump(name + "\n");
@@ -1506,6 +1506,13 @@ export class SpecialPowersChild extends JSWindowActorChild {
* The sandbox also has access to an Assert object, as provided by
* Assert.sys.mjs. Any assertion methods called before the task resolves
* will be relayed back to the test environment of the caller.
* Assertions triggered after a task returns may be relayed back if
* setAsDefaultAssertHandler() has been called, until this SpecialPowers
* instance is destroyed.
*
* If your assertions need to outlive this SpecialPowers instance,
* use SpecialPowersForProcess from SpecialPowersProcessActor.sys.mjs,
* which lives until the specified child process terminates.
*
* @param {BrowsingContext or FrameLoaderOwner or WindowProxy} target
* The target in which to run the task. This may be any element

View File

@@ -194,10 +194,21 @@ export class SpecialPowersParent extends JSWindowActorParent {
esModuleURI: "resource://testing-common/SpecialPowersParent.sys.mjs",
},
});
ChromeUtils.registerProcessActor("SpecialPowersProcessActor", {
child: {
esModuleURI:
"resource://testing-common/SpecialPowersProcessActor.sys.mjs",
},
parent: {
esModuleURI:
"resource://testing-common/SpecialPowersProcessActor.sys.mjs",
},
});
}
static unregisterActor() {
ChromeUtils.unregisterWindowActor("SpecialPowers");
ChromeUtils.unregisterProcessActor("SpecialPowersProcessActor");
}
init() {

View File

@@ -0,0 +1,170 @@
/* 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, {
SpecialPowersSandbox:
"resource://testing-common/SpecialPowersSandbox.sys.mjs",
});
let nextSpfpId = 1;
/**
* SpecialPowersForProcess wraps a content process, and allows the caller to
* spawn() tasks like SpecialPowers.spawn() and contentPage.spawn(), including
* Assert functionality. Assertion messages are passed back to the test scope,
* which must be passed along with the process to the constructor.
*/
export class SpecialPowersForProcess {
static instances = new Map();
/**
* Create a new SpecialPowersForProcess that enables callers to spawn tasks
* in the given content process.
*
* @param {any} scope
* The test scope to receive assertion messages.
* In test files this is often equivalent to globalThis.
* @param {nsIDOMProcessParent} domProcess
* The content process where the spawned code should run.
*/
constructor(testScope, domProcess) {
this.isXpcshellScope = !!testScope.do_get_profile;
this.isSimpleTestScope = !this.isXpcshellScope && !!testScope.SimpleTest;
if (!this.isXpcshellScope && !this.isSimpleTestScope) {
// Must be global of xpcshell test, or global of browser chrome mochitest.
throw new Error("testScope cannot receive assertion messages!");
}
if (!(domProcess instanceof Ci.nsIDOMProcessParent)) {
throw new Error("domProcess is not a nsIDOMProcessParent!");
}
// This actor is registered via SpecialPowersParent.registerActor().
// In mochitests that is part of the SpecialPowers add-on initialization.
// Xpcshell tests can initialize it with XPCShellContentUtils.init(), via
// XPCShellContentUtils.ensureInitialized().
this.actor = domProcess.getActor("SpecialPowersProcessActor");
this.testScope = testScope;
this.spfpId = nextSpfpId++;
SpecialPowersForProcess.instances.set(this.spfpId, this);
testScope.registerCleanupFunction(() => this.destroy());
}
destroy() {
if (!this.testScope) {
// Already destroyed.
return;
}
SpecialPowersForProcess.instances.delete(this.spfpId);
this.actor = null;
this.testScope = null;
}
/**
* Like `SpecialPowers.spawn`, but spawns a task in a child process instead.
* The task has access to Assert.
*
* @param {Array<any>} args
* An array of arguments to pass to the task. All arguments
* must be structured clone compatible, and will be cloned
* before being passed to the task.
* @param {function} task
* The function to run in the context of the target process. The
* function will be stringified and re-evaluated in the context
* of the target's content process. It may return any structured
* clone compatible value, or a Promise which resolves to the
* same, which will be returned to the caller.
* @returns {Promise<any>}
* A promise which resolves to the return value of the task, or
* which rejects if the task raises an exception. As this is
* being written, the rejection value will always be undefined
* in the cases where the task throws an error, though that may
* change in the future.
*/
spawn(args, task) {
return this.actor.sendQuery("Spawn", {
args,
task: String(task),
caller: Cu.getFunctionSourceLocation(task),
spfpId: this.spfpId,
});
}
// Called when ProxiedAssert is received; this data is sent from
// SpecialPowersSandbox's reportCallback in response to assertion messages.
reportCallback(data) {
if ("info" in data) {
if (this.isXpcshellScope) {
this.testScope.info(data.info);
} else if (this.isSimpleTestScope) {
this.testScope.SimpleTest.info(data.info);
} else {
// We checked this in the constructor, so this is unexpected.
throw new Error(`testScope cannot receive assertion messages!?!`);
}
return;
}
const { name, diag, passed, stack, expectFail } = data;
if (this.isXpcshellScope) {
this.testScope.do_report_result(passed, name, stack);
} else if (this.isSimpleTestScope) {
// browser chrome mochitest
let expected = expectFail ? "fail" : "pass";
this.testScope.SimpleTest.record(passed, name, diag, stack, expected);
} else {
// We checked this in the constructor, so this is unexpected.
throw new Error(`testScope cannot receive assertion messages!?!`);
}
}
}
// A minimal process actor that allows spawn() to run in the given process.
export class SpecialPowersProcessActorParent extends JSProcessActorParent {
receiveMessage(aMessage) {
switch (aMessage.name) {
case "ProxiedAssert": {
const { spfpId, data } = aMessage.data;
const spfp = SpecialPowersForProcess.instances.get(spfpId);
if (!spfp) {
dump(`Unexpected ProxiedAssert: ${uneval(data)}\n`);
throw new Error(`Unexpected message for ${spfpId} `);
}
spfp.reportCallback(data);
return undefined;
}
default:
throw new Error(
`Unknown SpecialPowersProcessActorParent action: ${aMessage.name}`
);
}
}
}
export class SpecialPowersProcessActorChild extends JSProcessActorChild {
receiveMessage(aMessage) {
switch (aMessage.name) {
case "Spawn": {
return this._spawnTaskInChild(aMessage.data);
}
default:
throw new Error(
`Unknown SpecialPowersProcessActorChild action: ${aMessage.name}`
);
}
}
_spawnTaskInChild({ task, args, caller, spfpId }) {
let sb = new lazy.SpecialPowersSandbox(null, data => {
this.sendAsyncMessage("ProxiedAssert", { spfpId, data });
});
return sb.execute(task, args, caller);
}
}

View File

@@ -30,6 +30,7 @@ modules = [
"content/SpecialPowersChild.sys.mjs",
"content/SpecialPowersEventUtils.sys.mjs",
"content/SpecialPowersParent.sys.mjs",
"content/SpecialPowersProcessActor.sys.mjs",
"content/SpecialPowersSandbox.sys.mjs",
"content/WrapPrivileged.sys.mjs",
]