From f2243a9fe7f4df217e07ee7379199ea937df9e22 Mon Sep 17 00:00:00 2001 From: Andrew Sutherland Date: Wed, 7 Oct 2020 16:03:48 +0000 Subject: [PATCH] Bug 1662925 - Test enhancements. r=nika Differential Revision: https://phabricator.services.mozilla.com/D92020 --- dom/workers/test/browser.ini | 1 + ...browser_serviceworker_fetch_new_process.js | 296 ++++++++++++++---- .../file_service_worker_fetch_synthetic.js | 63 +++- dom/workers/test/server_fetch_synthetic.sjs | 50 +++ 4 files changed, 339 insertions(+), 71 deletions(-) create mode 100644 dom/workers/test/server_fetch_synthetic.sjs diff --git a/dom/workers/test/browser.ini b/dom/workers/test/browser.ini index 12e0bb491ee0..5c611b7fc327 100644 --- a/dom/workers/test/browser.ini +++ b/dom/workers/test/browser.ini @@ -25,3 +25,4 @@ support-files = [browser_serviceworker_fetch_new_process.js] support-files = file_service_worker_fetch_synthetic.js + server_fetch_synthetic.sjs diff --git a/dom/workers/test/browser_serviceworker_fetch_new_process.js b/dom/workers/test/browser_serviceworker_fetch_new_process.js index a1fe3b616b01..24238e31928f 100644 --- a/dom/workers/test/browser_serviceworker_fetch_new_process.js +++ b/dom/workers/test/browser_serviceworker_fetch_new_process.js @@ -3,17 +3,29 @@ const DIRPATH = getRootDirectory(gTestPath).replace( "" ); +/** + * We choose blob contents that will roundtrip cleanly through the `textContent` + * of our returned HTML page. + */ +const TEST_BLOB_CONTENTS = `I'm a disk-backed test blob! Hooray!`; + add_task(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ + // Set preferences so that opening a page with the origin "example.org" + // will result in a remoteType of "privilegedmozilla" for both the + // page and the ServiceWorker. ["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true], ["browser.tabs.remote.separatedMozillaDomains", "example.org"], ["dom.ipc.processCount.privilegedmozilla", 1], - ["dom.ipc.processCount.webIsolated", 1], ["dom.ipc.processPrelaunch.enabled", false], ["dom.serviceWorkers.enabled", true], ["dom.serviceWorkers.testing.enabled", true], - ["dom.serviceworkers.parent_intercept", true], + // ServiceWorker worker instances should stay alive until explicitly + // caused to terminate by dropping these timeouts to 0 in + // `waitForWorkerAndProcessShutdown`. + ["dom.serviceWorkers.idle_timeout", 299999], + ["dom.serviceWorkers.idle_extended_timeout", 299999], ], }); }); @@ -24,13 +36,52 @@ function countRemoteType(remoteType) { ).length; } -async function waitForWorkerIdleShutdown(swRegInfo, remoteType) { - info(`waiting for ${remoteType} procs to shut down`); - ok(swRegInfo.activeWorker, "worker isn't currently active?"); +/** + * Helper function to get a list of all current processes and their remote + * types. Note that when in used in a templated literal that it is + * synchronously invoked when the string is evaluated and captures system state + * at that instant. + */ +function debugRemotes() { + return ChromeUtils.getAllDOMProcesses() + .map(p => p.remoteType || "parent") + .join(","); +} + +/** + * Wait for there to be zero processes of the given remoteType. This check is + * considered successful if there are already no processes of the given type + * at this very moment. + */ +async function waitForNoProcessesOfType(remoteType) { + info(`waiting for there to be no ${remoteType} procs`); + await TestUtils.waitForCondition( + () => countRemoteType(remoteType) == 0, + "wait for the worker's process to shutdown" + ); +} + +/** + * Given a ServiceWorkerRegistrationInfo with an active ServiceWorker that + * has no active ExtendableEvents but would otherwise continue running thanks + * to the idle keepalive: + * - Assert that there is a ServiceWorker instance in the given registration's + * active slot. (General invariant check.) + * - Assert that a single process with the given remoteType currently exists. + * (This doesn't mean the SW is alive in that process, though this test + * verifies that via other checks when appropriate.) + * - Induce the worker to shutdown by temporarily dropping the idle timeout to 0 + * and causing the idle timer to be reset due to rapid debugger attach/detach. + * - Wait for the the single process with the given remoteType to go away. + * - Reset the idle timeouts back to their previous high values. + */ +async function waitForWorkerAndProcessShutdown(swRegInfo, remoteType) { + info(`terminating worker and waiting for ${remoteType} procs to shut down`); + ok(swRegInfo.activeWorker, "worker should be in the active slot"); is( countRemoteType(remoteType), 1, - `should have a single ${remoteType} process` + `should have a single ${remoteType} process but have: ${debugRemotes()}` ); // Let's not wait too long for the process to shutdown. @@ -49,35 +100,29 @@ async function waitForWorkerIdleShutdown(swRegInfo, remoteType) { swRegInfo.activeWorker.detachDebugger(); // Eventually the length will reach 0, meaning we're done! - await TestUtils.waitForCondition( - () => countRemoteType(remoteType) == 0, - "wait for the worker's process to shutdown" - ); + await waitForNoProcessesOfType(remoteType); is( countRemoteType(remoteType), 0, - "processes with `remoteType` type should have shut down" + `processes with remoteType=${remoteType} type should have shut down` ); // Make sure we never kill workers on idle except when this is called. - await SpecialPowers.pushPrefEnv({ - set: [ - ["dom.serviceWorkers.idle_timeout", 299999], - ["dom.serviceWorkers.idle_extended_timeout", 299999], - ], - }); + await SpecialPowers.popPrefEnv(); } -async function do_test_sw(host, remoteType) { - info(`entering test: host=${host}, remoteType=${remoteType}`); +async function do_test_sw(host, remoteType, swMode, fileBlob) { + info( + `### entering test: host=${host}, remoteType=${remoteType}, mode=${swMode}` + ); const prin = Services.scriptSecurityManager.createContentPrincipal( Services.io.newURI(`https://${host}`), {} ); const sw = `https://${host}/${DIRPATH}file_service_worker_fetch_synthetic.js`; - const scope = `https://${host}/${DIRPATH}scope`; + const scope = `https://${host}/${DIRPATH}server_fetch_synthetic.sjs`; const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( Ci.nsIServiceWorkerManager @@ -97,55 +142,182 @@ async function do_test_sw(host, remoteType) { () => swRegInfo.activeWorker, "wait for the worker to become active" ); - await waitForWorkerIdleShutdown(swRegInfo, remoteType); + await waitForWorkerAndProcessShutdown(swRegInfo, remoteType); - await BrowserTestUtils.withNewTab("about:blank", async browser => { - // NOTE: We intentionally trigger the navigation from content in order to - // make sure frontend doesn't eagerly process-switch for us. - SpecialPowers.spawn(browser, [scope], async scope => { - content.location.href = `${scope}/intercepted.html`; - }); - await BrowserTestUtils.browserLoaded(browser); + info( + `test navigation interception with mode=${swMode} starting from about:blank` + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + async browser => { + // NOTE: We intentionally trigger the navigation from content in order to + // make sure frontend doesn't eagerly process-switch for us. + SpecialPowers.spawn( + browser, + [scope, swMode, fileBlob], + // eslint-disable-next-line no-shadow + async (scope, swMode, fileBlob) => { + const pageUrl = `${scope}?mode=${swMode}`; + if (!fileBlob) { + content.location.href = pageUrl; + } else { + const doc = content.document; + const formElem = doc.createElement("form"); + doc.body.appendChild(formElem); - is( - countRemoteType(remoteType), - 1, - "should have spawned a content process with the correct remoteType" - ); + formElem.action = pageUrl; + formElem.method = "POST"; + formElem.enctype = "multipart/form-data"; - // Ensure the worker was loaded in this process. - const workerDebuggerURLs = await SpecialPowers.spawn( - browser, - [sw], - async url => { - await content.navigator.serviceWorker.ready; - const wdm = Cc[ - "@mozilla.org/dom/workers/workerdebuggermanager;1" - ].getService(Ci.nsIWorkerDebuggerManager); + const fileElem = doc.createElement("input"); + formElem.appendChild(fileElem); - return Array.from(wdm.getWorkerDebuggerEnumerator()) - .map(wd => { - return wd.url; - }) - .filter(swURL => swURL == url); - } - ); - Assert.deepEqual( - workerDebuggerURLs, - [sw], - "The worker should be running in the correct child process" - ); + fileElem.type = "file"; + fileElem.name = "foo"; - // Unregister the worker. - await SpecialPowers.spawn(browser, [], async () => { - let registration = await content.navigator.serviceWorker.ready; - await registration.unregister(); - }); - }); + fileElem.mozSetFileArray([fileBlob]); - await waitForWorkerIdleShutdown(swRegInfo, remoteType); + formElem.submit(); + } + } + ); + + await BrowserTestUtils.browserLoaded(browser); + + is( + countRemoteType(remoteType), + 1, + `should have spawned a content process with remoteType=${remoteType}` + ); + + const { source, blobContents } = await SpecialPowers.spawn( + browser, + [], + () => { + return { + source: content.document.getElementById("source").textContent, + blobContents: content.document.getElementById("blob").textContent, + }; + } + ); + + is( + source, + swMode === "synthetic" ? "ServiceWorker" : "ServerJS", + "The page contents should come from the right place." + ); + + is( + blobContents, + fileBlob ? TEST_BLOB_CONTENTS : "", + "The request blob contents should be the blob/empty as appropriate." + ); + + // Ensure the worker was loaded in this process. + const workerDebuggerURLs = await SpecialPowers.spawn( + browser, + [sw], + async url => { + if (!content.navigator.serviceWorker.controller) { + throw new Error("document not controlled!"); + } + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].getService(Ci.nsIWorkerDebuggerManager); + + return Array.from(wdm.getWorkerDebuggerEnumerator()) + .map(wd => { + return wd.url; + }) + .filter(swURL => swURL == url); + } + ); + Assert.deepEqual( + workerDebuggerURLs, + [sw], + "The worker should be running in the correct child process" + ); + + // Unregister the ServiceWorker. The registration will continue to control + // `browser` and therefore continue to exist and its worker to continue + // running until the tab is closed. + await SpecialPowers.spawn(browser, [], async () => { + let registration = await content.navigator.serviceWorker.ready; + await registration.unregister(); + }); + } + ); + + // Now that the controlled tab is closed and the registration has been + // removed, the ServiceWorker will be made redundant which will forcibly + // terminate it, which will result in the shutdown of the given content + // process. Wait for that to happen both as a verification and so the next + // test has a sufficiently clean slate. + await waitForNoProcessesOfType(remoteType); +} + +/** + * Create a File-backed blob. This will happen synchronously from the main + * thread, which isn't optimal, but the test blocks on this progress anyways. + * Bug 1669578 has been filed on improving this idiom and avoiding the sync + * writes. + */ +async function makeFileBlob(blobContents) { + const tmpFile = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIDirectoryService) + .QueryInterface(Ci.nsIProperties) + .get("TmpD", Ci.nsIFile); + tmpFile.append("test-file-backed-blob.txt"); + tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + + var outStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + outStream.init( + tmpFile, + 0x02 | 0x08 | 0x20, // write, create, truncate + 0o666, + 0 + ); + outStream.write(blobContents, blobContents.length); + outStream.close(); + + const fileBlob = await File.createFromNsIFile(tmpFile); + return fileBlob; } add_task(async function test() { - await do_test_sw("example.org", "privilegedmozilla"); + // ## Isolated Privileged Process + // Trigger a straightforward intercepted navigation with no request body that + // returns a synthetic response. + await do_test_sw("example.org", "privilegedmozilla", "synthetic", null); + + // Trigger an intercepted navigation with FormData containing an + // which will result in the request body containing a + // RemoteLazyInputStream which will be consumed in the content process by the + // ServiceWorker while generating the synthetic response. + const fileBlob = await makeFileBlob(TEST_BLOB_CONTENTS); + await do_test_sw("example.org", "privilegedmozilla", "synthetic", fileBlob); + + // Trigger an intercepted navigation with FormData containing an + // which will result in the request body containing a + // RemoteLazyInputStream which will be relayed back to the parent process + // via direct invocation of fetch() on the event.request but without any + // cloning. + await do_test_sw("example.org", "privilegedmozilla", "fetch", fileBlob); + + // Same as the above but cloning the request before fetching it. + await do_test_sw("example.org", "privilegedmozilla", "clone", fileBlob); + + // ## Fission Isolation + if (Services.appinfo.fissionAutostart) { + const fissionUrl = "example.com"; + const fissionRemoteType = `webIsolated=https://example.com`; + + await do_test_sw(fissionUrl, fissionRemoteType, "synthetic", null); + await do_test_sw(fissionUrl, fissionRemoteType, "synthetic", fileBlob); + } }); diff --git a/dom/workers/test/file_service_worker_fetch_synthetic.js b/dom/workers/test/file_service_worker_fetch_synthetic.js index 46e991fd0f85..60b61f3adb16 100644 --- a/dom/workers/test/file_service_worker_fetch_synthetic.js +++ b/dom/workers/test/file_service_worker_fetch_synthetic.js @@ -2,14 +2,59 @@ addEventListener("install", function(evt) { evt.waitUntil(self.skipWaiting()); }); +/** + * Given a multipart/form-data encoded string that we know to have only a single + * part, return the contents of the part. (MIME multipart encoding is too + * exciting to delve into.) + */ +function extractBlobFromMultipartFormData(text) { + const lines = text.split(/\r\n/g); + const firstBlank = lines.indexOf(""); + const foo = lines.slice(firstBlank + 1, -2).join("\n"); + return foo; +} + self.addEventListener("fetch", event => { - event.respondWith( - new Response( - ` -

${event.request.url}

-

${self.location.href}

- `, - { headers: { "Content-Type": "text/html" } } - ) - ); + const url = new URL(event.request.url); + const mode = url.searchParams.get("mode"); + + if (mode === "synthetic") { + event.respondWith( + (async () => { + // This works even if there wasn't a body explicitly associated with the + // request. We just get a zero-length string in that case. + const requestBodyContents = await event.request.text(); + const blobContents = extractBlobFromMultipartFormData( + requestBodyContents + ); + + return new Response( + ` +

${event.request.url}

+
ServiceWorker
+
${blobContents}
+ `, + { headers: { "Content-Type": "text/html" } } + ); + })() + ); + } else if (mode === "fetch") { + event.respondWith(fetch(event.request)); + } else if (mode === "clone") { + // In order for the act of cloning to be interesting, we want the original + // request to remain alive so that any pipes end up having to buffer. + self.originalRequest = event.request; + event.respondWith(fetch(event.request.clone())); + } else { + event.respondWith( + new Response( + ` +

Bad mode: ${mode}

+
ServiceWorker::Error
+
No, this is an error.
+ `, + { headers: { "Content-Type": "text/html" }, status: 400 } + ) + ); + } }); diff --git a/dom/workers/test/server_fetch_synthetic.sjs b/dom/workers/test/server_fetch_synthetic.sjs new file mode 100644 index 000000000000..6ad7de09328a --- /dev/null +++ b/dom/workers/test/server_fetch_synthetic.sjs @@ -0,0 +1,50 @@ +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function log(str) { + //dump(`SJS LOG: ${str}\n`); +} + +/** + * Given a multipart/form-data encoded string that we know to have only a single + * part, return the contents of the part. (MIME multipart encoding is too + * exciting to delve into.) + */ +function extractBlobFromMultipartFormData(text) { + const lines = text.split(/\r\n/g); + const firstBlank = lines.indexOf(""); + const foo = lines.slice(firstBlank + 1, -2).join("\n"); + return foo; +} + +async function handleRequest(request, response) { + let blobContents = ""; + if (request.method !== "POST") { + } else { + var body = new BinaryInputStream(request.bodyInputStream); + + var avail; + var bytes = []; + + while ((avail = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(avail)); + } + let requestBodyContents = String.fromCharCode.apply(null, bytes); + log(requestBodyContents); + blobContents = extractBlobFromMultipartFormData(requestBodyContents); + } + + log("Setting Headers") + response.setHeader("Content-Type", "text/html", false); + response.setStatusLine(request.httpVersion, "200", "OK"); + response.write(` +

${request.scheme}${request.host}${request.port}${request.path}

+
ServerJS
+
${blobContents}
+ `); + log("Done"); +}