Bug 1416879 - Part 6: Test cancellation of diverted client-intercepted streams. r=bkelly

This adds a test where we have a ServiceWorker return 2 different types
of streams that Firefox recognizes as downloads which are handled by
diversion of the channel to the parent.  The diverted downloads are
then cancelled and we verify that cancellation actually results in the
underlying connections being closed and/or the ServiceWorker notified.

Our 2 types of streams are:
1. A pass-through stream that is incrementally delivered through use of
   an .sjs file that delivers data using setInterval.
2. A SW-authored ReadableStream (which is not enabled by default, so we
   set a pref.)

Determining when the .sjs's stream is canceled is accomplished by
opening a second "monitor" connection that only completes when the
streaming connection is closed.

In all cases we differentiate between cancelation and timeouts firing.

--HG--
extra : rebase_source : bee2d8ae5dedfb88c1bb8fa5cf452014d74afaed
This commit is contained in:
Andrew Sutherland 2018-01-04 18:38:43 -05:00
Родитель 31246a1058
Коммит 3f90214c93
5 изменённых файлов: 488 добавлений и 0 удалений

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

@ -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]

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

@ -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();
});

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

@ -0,0 +1,58 @@
<!--
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/
-->
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src="../utils.js"></script>
<script type="text/javascript">
function wait_until_controlled() {
return new Promise(function(resolve) {
if (navigator.serviceWorker.controller) {
return resolve('controlled');
}
navigator.serviceWorker.addEventListener('controllerchange', function onController() {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.removeEventListener('controllerchange', onController);
return resolve('controlled');
}
});
});
}
addEventListener('load', async function(event) {
window.controlled = wait_until_controlled();
window.registration =
await navigator.serviceWorker.register('sw_download_canceled.js');
let sw = registration.installing || registration.waiting ||
registration.active;
await waitForState(sw, 'activated');
sw.postMessage('claim');
});
// Place to hold promises for stream closures reported by the SW.
window.streamClosed = {};
// The ServiceWorker will postMessage to this BroadcastChannel when the streams
// are closed. (Alternately, the SW could have used the clients API to post at
// us, but the mechanism by which that operates would be different when this
// test is uplifted, and it's desirable to avoid timing changes.)
//
// The browser test will use this promise to wait on stream shutdown.
window.swStreamChannel = new BroadcastChannel("stream-closed");
function trackStreamClosure(path) {
let resolve;
const promise = new Promise(r => { resolve = r });
window.streamClosed[path] = { promise, resolve };
}
window.swStreamChannel.onmessage = ({ data }) => {
window.streamClosed[data.what].resolve(data);
};
</script>
</body>
</html>

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

@ -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);
}
}

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

@ -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());
}
});