diff --git a/dom/workers/test/serviceworkers/browser.ini b/dom/workers/test/serviceworkers/browser.ini index aaf9ef7da8ed..edd0bf8702f4 100644 --- a/dom/workers/test/serviceworkers/browser.ini +++ b/dom/workers/test/serviceworkers/browser.ini @@ -4,16 +4,21 @@ support-files = browser_cached_force_refresh.html download/window.html download/worker.js + download_canceled/page_download_canceled.html + download_canceled/server-stream-download.sjs + download_canceled/sw_download_canceled.js fetch.js file_multie10s_update.html file_userContextId_openWindow.js force_refresh_browser_worker.js empty.html server_multie10s_update.sjs + utils.js [browser_devtools_serviceworker_interception.js] [browser_force_refresh.js] [browser_download.js] +[browser_download_canceled.js] [browser_multie10s_update.js] skip-if = !e10s || os != "win" # Bug 1404914 [browser_userContextId_openWindow.js] diff --git a/dom/workers/test/serviceworkers/browser_download_canceled.js b/dom/workers/test/serviceworkers/browser_download_canceled.js new file mode 100644 index 000000000000..8f86393a7f7c --- /dev/null +++ b/dom/workers/test/serviceworkers/browser_download_canceled.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test cancellation of a download in order to test edge-cases related to + * channel diversion. Channel diversion occurs in cases of file (and PSM cert) + * downloads where we realize in the child that we really want to consume the + * channel data in the parent. For data "sourced" by the parent, like network + * data, data streaming to the child is suspended and the parent waits for the + * child to send back the data it already received, then the channel is resumed. + * For data generated by the child, such as (the current, to be mooted by + * parent-intercept) child-side intercept, the data (currently) stream is + * continually pumped up to the parent. + * + * In particular, we want to reproduce the circumstances of Bug 1418795 where + * the child-side input-stream pump attempts to send data to the parent process + * but the parent has canceled the channel and so the IPC Actor has been torn + * down. Diversion begins once the nsURILoader receives the OnStartRequest + * notification with the headers, so there are two ways to produce + */ + +Cu.import('resource://gre/modules/Services.jsm'); +const { Downloads } = Cu.import("resource://gre/modules/Downloads.jsm", {}); + +/** + * Clear the downloads list so other tests don't see our byproducts. + */ +async function clearDownloads() { + const downloads = await Downloads.getList(Downloads.ALL); + downloads.removeFinished(); +} + +/** + * Returns a Promise that will be resolved once the download dialog shows up and + * we have clicked the given button. + * + * Derived from browser/components/downloads/test/browser/head.js's + * self-contained promiseAlertDialogOpen helper, but modified to work on the + * download dialog instead of commonDialog.xul. + */ +function promiseClickDownloadDialogButton(buttonAction) { + return new Promise(resolve => { + Services.ww.registerNotification(function onOpen(win, topic, data) { + if (topic === "domwindowopened" && win instanceof Ci.nsIDOMWindow) { + // The test listens for the "load" event which guarantees that the alert + // class has already been added (it is added when "DOMContentLoaded" is + // fired). + win.addEventListener("load", function() { + info(`found window of type: ${win.document.documentURI}`); + if (win.document.documentURI === + "chrome://mozapps/content/downloads/unknownContentType.xul") { + Services.ww.unregisterNotification(onOpen); + + // nsHelperAppDlg.js currently uses an eval-based setTimeout(0) to + // invoke its postShowCallback that results in a misleading error to + // the console if we close the dialog before it gets a chance to + // run. Just a setTimeout is not sufficient because it appears we + // get our "load" listener before the document's, so we use + // executeSoon to defer until after its load handler runs, then + // use setTimeout(0) to end up after its eval. + executeSoon(function() { + setTimeout(function() { + const button = win.document.documentElement.getButton(buttonAction); + button.disabled = false; + info(`clicking ${buttonAction} button`); + button.click(); + resolve(); + }, 0); + }); + } + }, {once: true}); + } + }); + }); +} + +async function performCanceledDownload(tab, path) { + // Start waiting for the download dialog before triggering the download. + info("watching for download popup"); + const cancelDownload = promiseClickDownloadDialogButton("cancel"); + + // Trigger the download. + info(`triggering download of "${path}"`); + await ContentTask.spawn( + tab.linkedBrowser, + path, + function(path) { + // Put a Promise in place that we can wait on for stream closure. + content.wrappedJSObject.trackStreamClosure(path); + // Create the link and trigger the download. + const link = content.document.createElement('a'); + link.href = path; + link.download = path; + content.document.body.appendChild(link); + link.click(); + }); + + // Wait for the cancelation to have been triggered. + info("waiting for download popup"); + await cancelDownload; + ok(true, "canceled download"); + + // Wait for confirmation that the stream stopped. + info(`wait for the ${path} stream to close.`); + const why = await ContentTask.spawn( + tab.linkedBrowser, + path, + function(path) { + return content.wrappedJSObject.streamClosed[path].promise; + }); + is(why.why, "canceled", "Ensure the stream canceled instead of timing out."); + // Note that for the "sw-stream-download" case, we end up with a bogus + // reason of "'close' may only be called on a stream in the 'readable' state." + // Since we aren't actually invoking close(), I'm assuming this is an + // implementation bug that will be corrected in the web platform tests. + info(`Cancellation reason: ${why.message} after ${why.ticks} ticks`); +} + +const gTestRoot = getRootDirectory(gTestPath) + .replace("chrome://mochitests/content/", "http://mochi.test:8888/"); + + +const PAGE_URL = `${gTestRoot}download_canceled/page_download_canceled.html`; + +add_task(async function interruptedDownloads() { + await SpecialPowers.pushPrefEnv({'set': [ + ['dom.serviceWorkers.enabled', true], + ['dom.serviceWorkers.exemptFromPerDomainMax', true], + ['dom.serviceWorkers.testing.enabled', true], + ["javascript.options.streams", true], + ["dom.streams.enabled", true], + ]}); + + // Open the tab + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: PAGE_URL + }); + + // Wait for it to become controlled. Check that it was a promise that + // resolved as expected rather than undefined by checking the return value. + const controlled = await ContentTask.spawn( + tab.linkedBrowser, + null, + function() { + // This is a promise set up by the page during load, and we are post-load. + return content.wrappedJSObject.controlled; + }); + is(controlled, "controlled", "page became controlled"); + + // Download a pass-through fetch stream. + await performCanceledDownload(tab, "sw-passthrough-download"); + + // Download a SW-generated stream + await performCanceledDownload(tab, "sw-stream-download"); + + // Cleanup + await ContentTask.spawn( + tab.linkedBrowser, + null, + function() { + return content.wrappedJSObject.registration.unregister(); + }); + await BrowserTestUtils.removeTab(tab); + await clearDownloads(); +}); \ No newline at end of file diff --git a/dom/workers/test/serviceworkers/download_canceled/page_download_canceled.html b/dom/workers/test/serviceworkers/download_canceled/page_download_canceled.html new file mode 100644 index 000000000000..e3904c4967ef --- /dev/null +++ b/dom/workers/test/serviceworkers/download_canceled/page_download_canceled.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + diff --git a/dom/workers/test/serviceworkers/download_canceled/server-stream-download.sjs b/dom/workers/test/serviceworkers/download_canceled/server-stream-download.sjs new file mode 100644 index 000000000000..87b0253143c1 --- /dev/null +++ b/dom/workers/test/serviceworkers/download_canceled/server-stream-download.sjs @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); + +// stolen from file_blocked_script.sjs +function setGlobalState(data, key) +{ + x = { data: data, QueryInterface: function(iid) { return this } }; + x.wrappedJSObject = x; + setObjectState(key, x); +} + +function getGlobalState(key) +{ + var data; + getObjectState(key, function(x) { + data = x && x.wrappedJSObject.data; + }); + return data; +} + +/* + * We want to let the sw_download_canceled.js service worker know when the + * stream was canceled. To this end, we let it issue a monitor request which we + * fulfill when the stream has been canceled. In order to coordinate between + * multiple requests, we use the getObjectState/setObjectState mechanism that + * httpd.js exposes to let data be shared and/or persist between requests. We + * handle both possible orderings of the requests because we currently don't + * try and impose an ordering between the two requests as issued by the SW, and + * file_blocked_script.sjs encourages us to do this, but we probably could order + * them. + */ +const MONITOR_KEY = "stream-monitor"; +function completeMonitorResponse(response, data) { + response.write(JSON.stringify(data)); + response.finish(); +} +function handleMonitorRequest(request, response) { + response.setHeader("Content-Type", "application/json"); + response.setStatusLine(request.httpVersion, 200, "Found"); + + response.processAsync(); + // Necessary to cause the headers to be flushed; that or touching the + // bodyOutputStream getter. + response.write(""); + dump("server-stream-download.js: monitor headers issued\n"); + + const alreadyCompleted = getGlobalState(MONITOR_KEY); + if (alreadyCompleted) { + completeMonitorResponse(response, alreadyCompleted); + setGlobalState(null, MONITOR_KEY); + } else { + setGlobalState(response, MONITOR_KEY); + } +} + +const MAX_TICK_COUNT = 3000; +const TICK_INTERVAL = 2; +function handleStreamRequest(request, response) { + const name = "server-stream-download"; + + // Create some payload to send. + let strChunk = + 'Static routes are the future of ServiceWorkers! So say we all!\n'; + while (strChunk.length < 1024) { + strChunk += strChunk; + } + + response.setHeader("Content-Disposition", `attachment; filename="${name}"`); + response.setHeader("Content-Type", `application/octet-stream; name="${name}"`); + response.setHeader("Content-Length", `${strChunk.length * MAX_TICK_COUNT}`); + response.setStatusLine(request.httpVersion, 200, "Found"); + + response.processAsync(); + response.write(strChunk); + dump("server-stream-download.js: stream headers + first payload issued\n"); + + let count = 0; + let intervalId; + function closeStream(why, message) { + dump("server-stream-download.js: closing stream: " + why + "\n"); + clearInterval(intervalId); + response.finish(); + + const data = { why, message }; + const monitorResponse = getGlobalState(MONITOR_KEY); + if (monitorResponse) { + completeMonitorResponse(monitorResponse, data); + setGlobalState(null, MONITOR_KEY); + } else { + setGlobalState(data, MONITOR_KEY); + } + } + function tick() { + try { + // bound worst-case behavior. + if (count++ > MAX_TICK_COUNT) { + closeStream("timeout", "timeout"); + return; + } + response.write(strChunk); + } catch(e) { + closeStream("canceled", e.message); + } + } + intervalId = setInterval(tick, TICK_INTERVAL); +} + +Components.utils.importGlobalProperties(["URLSearchParams"]); +function handleRequest(request, response) { + dump("server-stream-download.js: processing request for " + request.path + + "?" + request.queryString + "\n"); + const query = new URLSearchParams(request.queryString); + if (query.has("monitor")) { + handleMonitorRequest(request, response); + } else { + handleStreamRequest(request, response); + } +} \ No newline at end of file diff --git a/dom/workers/test/serviceworkers/download_canceled/sw_download_canceled.js b/dom/workers/test/serviceworkers/download_canceled/sw_download_canceled.js new file mode 100644 index 000000000000..49ed5af692a6 --- /dev/null +++ b/dom/workers/test/serviceworkers/download_canceled/sw_download_canceled.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This file is derived from :bkelly's https://glitch.com/edit/#!/html-sw-stream + +addEventListener("install", evt => { + evt.waitUntil(self.skipWaiting()); +}); + +// Create a BroadcastChannel to notify when we have closed our streams. +const channel = new BroadcastChannel("stream-closed"); + +const MAX_TICK_COUNT = 3000; +const TICK_INTERVAL = 4; +/** + * Generate a continuous stream of data at a sufficiently high frequency that a + * there"s a good chance of racing channel cancellation. + */ +function handleStream(evt, filename) { + // Create some payload to send. + const encoder = new TextEncoder(); + let strChunk = + "Static routes are the future of ServiceWorkers! So say we all!\n"; + while (strChunk.length < 1024) { + strChunk += strChunk; + } + const dataChunk = encoder.encode(strChunk); + + evt.waitUntil(new Promise(resolve => { + let body = new ReadableStream({ + start: controller => { + const closeStream = (why) => { + console.log("closing stream: " + JSON.stringify(why) + "\n"); + clearInterval(intervalId); + resolve(); + // In event of error, the controller will automatically have closed. + if (why.why != "canceled") { + try { + controller.close(); + } catch(ex) { + // If we thought we should cancel but experienced a problem, + // that's a different kind of failure and we need to report it. + // (If we didn't catch the exception here, we'd end up erroneously + // in the tick() method's canceled handler.) + channel.postMessage({ + what: filename, + why: "close-failure", + message: ex.message, + ticks: why.ticks + }); + return; + } + } + // Post prior to performing any attempt to close... + channel.postMessage(why); + }; + + controller.enqueue(dataChunk); + let count = 0; + let intervalId; + function tick() { + try { + // bound worst-case behavior. + if (count++ > MAX_TICK_COUNT) { + closeStream({ + what: filename, why: "timeout", message: "timeout", ticks: count + }); + return; + } + controller.enqueue(dataChunk); + } catch(e) { + closeStream({ + what: filename, why: "canceled", message: e.message, ticks: count + }); + } + } + // Alternately, streams' pull mechanism could be used here, but this + // test doesn't so much want to saturate the stream as to make sure the + // data is at least flowing a little bit. (Also, the author had some + // concern about slowing down the test by overwhelming the event loop + // and concern that we might not have sufficent back-pressure plumbed + // through and an infinite pipe might make bad things happen.) + intervalId = setInterval(tick, TICK_INTERVAL); + tick(); + }, + }); + evt.respondWith(new Response(body, { + headers: { + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Type": "application/octet-stream" + } + })); + })); +} + +/** + * Use an .sjs to generate a similar stream of data to the above, passing the + * response through directly. Because we're handing off the response but also + * want to be able to report when cancellation occurs, we create a second, + * overlapping long-poll style fetch that will not finish resolving until the + * .sjs experiences closure of its socket and terminates the payload stream. + */ +function handlePassThrough(evt, filename) { + evt.waitUntil((async () => { + console.log("issuing monitor fetch request"); + const response = await fetch("server-stream-download.sjs?monitor"); + console.log("monitor headers received, awaiting body"); + const data = await response.json(); + console.log("passthrough monitor fetch completed, notifying."); + channel.postMessage({ + what: filename, + why: data.why, + message: data.message + }); + })()); + evt.respondWith(fetch("server-stream-download.sjs").then(response => { + console.log("server-stream-download.sjs Response received, propagating"); + return response; + })); +} + +addEventListener("fetch", evt => { + console.log(`SW processing fetch of ${evt.request.url}`); + if (evt.request.url.indexOf("sw-stream-download") >= 0) { + return handleStream(evt, "sw-stream-download"); + } + if (evt.request.url.indexOf("sw-passthrough-download") >= 0) { + return handlePassThrough(evt, "sw-passthrough-download"); + } +}) + +addEventListener("message", evt => { + if (evt.data === "claim") { + evt.waitUntil(clients.claim()); + } +});