Bug 1759300 - Cancel suspend on idle if the extension EventPage has StreamFilter instances still active. r=mixedpuppy

Differential Revision: https://phabricator.services.mozilla.com/D145257
This commit is contained in:
Luca Greco 2022-05-09 20:33:28 +00:00
Родитель 4e6096c73d
Коммит 6035e48afd
8 изменённых файлов: 430 добавлений и 2 удалений

Просмотреть файл

@ -2084,6 +2084,8 @@ const PROXIED_EVENTS = new Set([
"test-harness-message",
"add-permissions",
"remove-permissions",
"background-script-suspend",
"background-script-suspend-canceled",
"background-script-suspend-ignored",
]);
@ -2413,6 +2415,18 @@ class Extension extends ExtensionData {
return frameLoader || ExtensionParent.DebugUtils.getFrameLoader(this.id);
}
get backgroundContext() {
for (let view of this.views) {
if (
view.viewType === "background" ||
view.viewType === "background_worker"
) {
return view;
}
}
return undefined;
}
on(hook, f) {
return this.emitter.on(hook, f);
}

Просмотреть файл

@ -557,6 +557,12 @@ class BrowserExtensionContent extends EventEmitter {
super.emit(event, ...args);
}
// TODO(Bug 1768471): consider folding this back into emit if we will change it to
// return a value as EventEmitter and Extension emit methods do.
emitLocalWithResult(event, ...args) {
return super.emit(event, ...args);
}
receiveMessage({ name, data }) {
if (name === this.MESSAGE_EMIT_EVENT) {
super.emit(data.event, ...data.args);
@ -777,7 +783,7 @@ class ChildAPIManager {
"AddListener",
"RemoveListener",
],
recv: ["CallResult", "RunListener"],
recv: ["CallResult", "RunListener", "StreamFilterSuspendCancel"],
});
this.conduit.sendCreateProxyContext({
@ -854,6 +860,20 @@ class ChildAPIManager {
}
}
async recvStreamFilterSuspendCancel() {
const promise = this.context.extension.emitLocalWithResult(
"internal:stream-filter-suspend-cancel"
);
// if all listeners throws emitLocalWithResult returns undefined.
if (!promise) {
return false;
}
return promise.then(results =>
results.some(hasActiveStreamFilter => hasActiveStreamFilter === true)
);
}
/**
* Call a function in the parent process and ignores its return value.
*

Просмотреть файл

@ -59,6 +59,8 @@ function getConsole() {
XPCOMUtils.defineLazyGetter(this, "console", getConsole);
const BACKGROUND_SCRIPTS_VIEW_TYPES = ["background", "background_worker"];
var ExtensionCommon;
// Run a function and report exceptions.
@ -547,6 +549,10 @@ class BaseContext {
return this.extension.privateBrowsingAllowed;
}
get isBackgroundContext() {
return BACKGROUND_SCRIPTS_VIEW_TYPES.includes(this.viewType);
}
/**
* Whether the extension context is using the WebIDL bindings for the
* WebExtensions APIs.

Просмотреть файл

@ -465,6 +465,7 @@ class ProxyContextParent extends BaseContext {
constructor(envType, extension, params, xulBrowser, principal) {
super(envType, extension);
this.childId = params.childId;
this.uri = Services.io.newURI(params.url);
this.incognito = params.incognito;
@ -814,7 +815,7 @@ ParentAPIManager = {
"RemoveListener",
],
send: ["CallResult"],
query: ["RunListener"],
query: ["RunListener", "StreamFilterSuspendCancel"],
});
},
@ -853,6 +854,10 @@ ParentAPIManager = {
}
},
queryStreamFilterSuspendCancel(childId) {
return this.conduit.queryStreamFilterSuspendCancel(childId);
},
recvCreateProxyContext(data, { actor, sender }) {
let { envType, extensionId, childId, principal } = data;
let target = actor.browsingContext?.top.embedderElement;

Просмотреть файл

@ -6,7 +6,55 @@
"use strict";
var { ExtensionError } = ExtensionUtils;
this.webRequest = class extends ExtensionAPI {
STREAM_FILTER_INACTIVE_STATUSES = ["closed", "disconnected", "failed"];
hasActiveStreamFilter(filtersWeakSet) {
const iter = ChromeUtils.nondeterministicGetWeakSetKeys(filtersWeakSet);
for (let filter of iter) {
if (!this.STREAM_FILTER_INACTIVE_STATUSES.includes(filter.status)) {
return true;
}
}
return false;
}
watchStreamFilterSuspendCancel({
context,
filters,
onSuspend,
onSuspendCanceled,
}) {
if (
!context.isBackgroundContext ||
context.extension.persistentBackground !== false
) {
return;
}
const { extension } = context;
const cancelSuspendOnActiveStreamFilter = () =>
this.hasActiveStreamFilter(filters);
context.callOnClose({
close() {
extension.off(
"internal:stream-filter-suspend-cancel",
cancelSuspendOnActiveStreamFilter
);
extension.off("background-script-suspend", onSuspend);
extension.off("background-script-suspend-canceled", onSuspend);
},
});
extension.on(
"internal:stream-filter-suspend-cancel",
cancelSuspendOnActiveStreamFilter
);
extension.on("background-script-suspend", onSuspend);
extension.on("background-script-suspend-canceled", onSuspendCanceled);
}
getAPI(context) {
let filters = new WeakSet();
@ -24,9 +72,22 @@ this.webRequest = class extends ExtensionAPI {
},
});
let isSuspending = false;
this.watchStreamFilterSuspendCancel({
context,
filters,
onSuspend: () => (isSuspending = true),
onSuspendCanceled: () => (isSuspending = false),
});
return {
webRequest: {
filterResponseData(requestId) {
if (isSuspending) {
throw new ExtensionError(
"filterResponseData method calls forbidden while background extension global is suspending"
);
}
requestId = parseInt(requestId, 10);
let streamFilter = context.cloneScope.StreamFilter.create(

Просмотреть файл

@ -423,6 +423,22 @@ this.backgroundPage = class extends ExtensionAPI {
return;
}
const childId = extension.backgroundContext?.childId;
if (childId !== undefined) {
// Ask to the background page context in the child process to check if there are
// StreamFilter instances active (e.g. ones with status "transferringdata" or "suspended",
// see StreamFilterStatus enum defined in StreamFilter.webidl).
// TODO(Bug 1748533): consider additional changes to prevent a StreamFilter that never gets to an
// inactive state from preventing an even page from being ever suspended.
const hasActiveStreamFilter = await ExtensionParent.ParentAPIManager.queryStreamFilterSuspendCancel(
extension.backgroundContext.childId
);
if (hasActiveStreamFilter) {
extension.emit("background-script-reset-idle");
return;
}
}
extension.backgroundState = BACKGROUND_STATE.SUSPENDING;
this.clearIdleTimer();
// call runtime.onSuspend

Просмотреть файл

@ -0,0 +1,305 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
AddonTestUtils.init(this);
AddonTestUtils.createAppInfo(
"xpcshell@tests.mozilla.org",
"XPCShell",
"42",
"42"
);
const server = createHttpServer({ hosts: ["example.com"] });
server.registerPathHandler("/pending_request", (request, response) => {
response.processAsync();
response.setHeader("Content-Length", "10000", false);
response.write("somedata\n");
let intervalID = setInterval(() => response.write("continue\n"), 50);
registerCleanupFunction(() => {
try {
clearInterval(intervalID);
response.finish();
} catch (e) {
// This will throw, but we don't care at this point.
}
});
});
server.registerPathHandler("/completed_request", (request, response) => {
response.write("somedata\n");
});
add_setup(async () => {
await AddonTestUtils.promiseStartupManager();
});
async function test_idletimeout_on_streamfilter({
manifest_version,
expectResetIdle,
expectStreamFilterStop,
requestUrlPath,
}) {
const extension = ExtensionTestUtils.loadExtension({
background: `(${async function(urlPath) {
browser.webRequest.onBeforeRequest.addListener(
request => {
browser.test.log(`webRequest request intercepted: ${request.url}`);
const filter = browser.webRequest.filterResponseData(
request.requestId
);
const decoder = new TextDecoder("utf-8");
const encoder = new TextEncoder();
filter.onstart = () => {
browser.test.sendMessage("streamfilter:started");
};
filter.ondata = event => {
let str = decoder.decode(event.data, { stream: true });
filter.write(encoder.encode(str));
};
filter.onstop = () => {
filter.close();
browser.test.sendMessage("streamfilter:stopped");
};
},
{
urls: [`http://example.com/${urlPath}`],
},
["blocking"]
);
browser.test.sendMessage("bg:ready");
}})("${requestUrlPath}")`,
useAddonManager: "temporary",
manifest: {
manifest_version,
background: manifest_version >= 3 ? {} : { persistent: false },
granted_host_permissions: manifest_version >= 3,
permissions: ["webRequest", "webRequestBlocking"],
// host_permissions are merged with permissions on a MV2 test extension.
host_permissions: ["http://example.com/*"],
},
});
await extension.startup();
await extension.awaitMessage("bg:ready");
const { contextId } = extension.extension.backgroundContext;
notEqual(contextId, undefined, "Got a contextId for the background context");
info("Trigger a webRequest");
ExtensionTestUtils.fetch(
"http://example.com/",
`http://example.com/${requestUrlPath}`
);
info("Wait for the stream filter to be started");
await extension.awaitMessage("streamfilter:started");
if (expectStreamFilterStop) {
await extension.awaitMessage("streamfilter:stopped");
}
info("Terminate the background script (simulated idle timeout)");
if (expectResetIdle) {
const promiseResetIdle = promiseExtensionEvent(
extension,
"background-script-reset-idle"
);
await extension.terminateBackground();
info("Wait for 'background-script-reset-idle' event to be emitted");
await promiseResetIdle;
equal(
extension.extension.backgroundContext.contextId,
contextId,
"Initial background context is still available as expected"
);
} else {
const { Management } = ChromeUtils.import(
"resource://gre/modules/Extension.jsm"
);
const promiseProxyContextUnloaded = new Promise(resolve => {
function listener(evt, context) {
if (context.extension.id === extension.id) {
Management.off("proxy-context-unload", listener);
resolve();
}
}
Management.on("proxy-context-unload", listener);
});
await extension.terminateBackground();
await promiseProxyContextUnloaded;
equal(
extension.extension.backgroundContext,
undefined,
"Initial background context should have been terminated as expected"
);
}
await extension.unload();
}
add_task(
{
pref_set: [["extensions.eventPages.enabled", true]],
},
async function test_idletimeout_on_active_streamfilter_mv2_eventpage() {
await test_idletimeout_on_streamfilter({
manifest_version: 2,
requestUrlPath: "pending_request",
expectStreamFilterStop: false,
expectResetIdle: true,
});
}
);
add_task(
{
pref_set: [["extensions.manifestV3.enabled", true]],
},
async function test_idletimeout_on_active_streamfilter_mv3() {
await test_idletimeout_on_streamfilter({
manifest_version: 3,
requestUrlPath: "pending_request",
expectStreamFilterStop: false,
expectResetIdle: true,
});
}
);
add_task(
{
pref_set: [["extensions.eventPages.enabled", true]],
},
async function test_idletimeout_on_inactive_streamfilter_mv2_eventpage() {
await test_idletimeout_on_streamfilter({
manifest_version: 2,
requestUrlPath: "completed_request",
expectStreamFilterStop: true,
expectResetIdle: false,
});
}
);
add_task(
{
pref_set: [["extensions.manifestV3.enabled", true]],
},
async function test_idletimeout_on_inactive_streamfilter_mv3() {
await test_idletimeout_on_streamfilter({
manifest_version: 3,
requestUrlPath: "completed_request",
expectStreamFilterStop: true,
expectResetIdle: false,
});
}
);
async function test_create_new_streamfilter_while_suspending({
manifest_version,
}) {
const extension = ExtensionTestUtils.loadExtension({
async background() {
let interceptedRequestId;
let resolvePendingWebRequest;
browser.runtime.onSuspend.addListener(async () => {
await browser.test.assertThrows(
() => browser.webRequest.filterResponseData(interceptedRequestId),
/forbidden while background extension global is suspending/,
"Got the expected exception raised from filterResponseData calls while suspending"
);
browser.test.sendMessage("suspend-listener");
});
browser.runtime.onSuspendCanceled.addListener(async () => {
// Once onSuspendCanceled is emitted, filterResponseData
// is expected to don't throw.
const filter = browser.webRequest.filterResponseData(
interceptedRequestId
);
resolvePendingWebRequest();
filter.onstop = () => {
filter.disconnect();
browser.test.sendMessage("suspend-canceled-listener");
};
});
browser.webRequest.onBeforeRequest.addListener(
request => {
browser.test.log(`webRequest request intercepted: ${request.url}`);
interceptedRequestId = request.requestId;
return new Promise(resolve => {
resolvePendingWebRequest = resolve;
browser.test.sendMessage("webrequest-listener:done");
});
},
{
urls: [`http://example.com/completed_request`],
},
["blocking"]
);
browser.test.sendMessage("bg:ready");
},
useAddonManager: "temporary",
manifest: {
manifest_version,
background: manifest_version >= 3 ? {} : { persistent: false },
granted_host_permissions: manifest_version >= 3,
permissions: ["webRequest", "webRequestBlocking"],
// host_permissions are merged with permissions on a MV2 test extension.
host_permissions: ["http://example.com/*"],
},
});
await extension.startup();
await extension.awaitMessage("bg:ready");
const { contextId } = extension.extension.backgroundContext;
notEqual(contextId, undefined, "Got a contextId for the background context");
info("Trigger a webRequest");
ExtensionTestUtils.fetch(
"http://example.com/",
`http://example.com/completed_request`
);
info("Wait for the web request to be intercepted and suspended");
await extension.awaitMessage("webrequest-listener:done");
info("Terminate the background script (simulated idle timeout)");
extension.terminateBackground();
await extension.awaitMessage("suspend-listener");
info("Simulated idle timeout canceled");
extension.extension.emit("background-script-reset-idle");
await extension.awaitMessage("suspend-canceled-listener");
await extension.unload();
}
add_task(
{
pref_set: [["extensions.eventPages.enabled", true]],
},
async function test_error_creating_new_streamfilter_while_suspending_mv2_eventpage() {
await test_create_new_streamfilter_while_suspending({
manifest_version: 2,
});
}
);
add_task(
{
pref_set: [["extensions.manifestV3.enabled", true]],
},
async function test_error_creating_new_streamfilter_while_suspending_mv3() {
await test_create_new_streamfilter_while_suspending({
manifest_version: 3,
});
}
);

Просмотреть файл

@ -1,6 +1,7 @@
# Similar to xpcshell-common.ini, except tests here only run
# when e10s is enabled (with or without out-of-process extensions).
[test_ext_webRequest_eventPage_StreamFilter.js]
[test_ext_webRequest_filterResponseData.js]
# tsan failure is for test_filter_301 timing out, bug 1674773
skip-if =