diff --git a/fetch/api/abort/cache.https.html b/fetch/api/abort/cache.https.html
new file mode 100644
index 00000000000000..25ed0e1e9183b9
--- /dev/null
+++ b/fetch/api/abort/cache.https.html
@@ -0,0 +1,57 @@
+
+
+
+
+ Request signals & the cache API
+
+
+
+
+
+
+
diff --git a/fetch/api/abort/general-serviceworker.https.html b/fetch/api/abort/general-serviceworker.https.html
new file mode 100644
index 00000000000000..c1334e035f51a4
--- /dev/null
+++ b/fetch/api/abort/general-serviceworker.https.html
@@ -0,0 +1,23 @@
+
+
+
+
+ General fetch abort tests in a service worker
+
+
+
+
+
+
+
diff --git a/fetch/api/abort/general.html b/fetch/api/abort/general.html
new file mode 100644
index 00000000000000..0d5baf2a8e82da
--- /dev/null
+++ b/fetch/api/abort/general.html
@@ -0,0 +1,19 @@
+
+
+
+
+ General fetch abort tests
+
+
+
+
+
+
+
+
+
diff --git a/fetch/api/abort/general.js b/fetch/api/abort/general.js
new file mode 100644
index 00000000000000..8416e3a43dac7e
--- /dev/null
+++ b/fetch/api/abort/general.js
@@ -0,0 +1,469 @@
+const BODY_METHODS = ['arrayBuffer', 'blob', 'formData', 'json', 'text'];
+
+if (self.importScripts) {
+ // Load scripts if being run from a worker
+ importScripts(
+ '/resources/testharness.js',
+ '/common/utils.js'
+ );
+}
+
+// This is used to close connections that weren't correctly closed during the tests,
+// otherwise you can end up running out of HTTP connections.
+let requestAbortKeys = [];
+
+function abortRequests() {
+ const keys = requestAbortKeys;
+ requestAbortKeys = [];
+ return Promise.all(
+ keys.map(key => fetch(`../resources/stash-put.py?key=${key}&value=close`))
+ );
+}
+
+// Add the global name to the test name
+function contextualTestName(name) {
+ return `${self.constructor.name}: ${name}`;
+}
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const fetchPromise = fetch('../resources/data.json', { signal });
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Aborting rejects with AbortError"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const url = new URL('../resources/data.json', location);
+ url.hostname = 'www1.' + url.hostname;
+
+ const fetchPromise = fetch(url, {
+ signal,
+ mode: 'no-cors'
+ });
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Aborting rejects with AbortError - no-cors"));
+
+test(() => {
+ const request = new Request('');
+ assert_true(Boolean(request.signal), "Signal member is present & truthy");
+ assert_equals(request.signal.constructor, AbortSignal);
+}, contextualTestName("Request objects have a signal property"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', { signal });
+
+ assert_true(Boolean(request.signal), "Signal member is present & truthy");
+ assert_equals(request.signal.constructor, AbortSignal);
+ assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference');
+
+ const fetchPromise = fetch(request);
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Signal on request object"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', { signal });
+ const requestFromRequest = new Request(request);
+
+ const fetchPromise = fetch(requestFromRequest);
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Signal on request object created from request object"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json');
+ const requestFromRequest = new Request(request, { signal });
+
+ const fetchPromise = fetch(requestFromRequest);
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Signal on request object created from request object, with signal on second request"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', { signal: new AbortController().signal });
+ const requestFromRequest = new Request(request, { signal });
+
+ const fetchPromise = fetch(requestFromRequest);
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Signal on request object created from request object, with signal on second request overriding another"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', { signal });
+
+ const fetchPromise = fetch(request, {method: 'POST'});
+
+ await promise_rejects(t, "AbortError", fetchPromise);
+}, contextualTestName("Signal retained after unrelated properties are overridden by fetch"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', { signal });
+
+ const data = await fetch(request, { signal: null }).then(r => r.json());
+ assert_equals(data.key, 'value', 'Fetch fully completes');
+}, contextualTestName("Signal removed by setting to null"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const log = [];
+
+ await Promise.all([
+ fetch('../resources/data.json', { signal }).then(
+ () => assert_unreached("Fetch must not resolve"),
+ () => log.push('fetch-reject')
+ ),
+ Promise.resolve().then(() => log.push('next-microtask'))
+ ]);
+
+ assert_array_equals(log, ['fetch-reject', 'next-microtask']);
+}, contextualTestName("Already aborted signal rejects immediately"));
+
+promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const request = new Request('../resources/data.json', {
+ signal,
+ method: 'POST',
+ body: 'foo',
+ headers: { 'Content-Type': 'text/plain' }
+ });
+
+ await fetch(request).catch(() => {});
+
+ assert_true(request.bodyUsed, "Body has been used");
+}, contextualTestName("Request is still 'used' if signal is aborted before fetching"));
+
+for (const bodyMethod of BODY_METHODS) {
+ promise_test(async t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const log = [];
+ const response = await fetch('../resources/data.json', { signal });
+
+ controller.abort();
+
+ const bodyPromise = response[bodyMethod]();
+
+ await Promise.all([
+ bodyPromise.catch(() => log.push(`${bodyMethod}-reject`)),
+ Promise.resolve().then(() => log.push('next-microtask'))
+ ]);
+
+ await promise_rejects(t, "AbortError", bodyPromise);
+
+ assert_array_equals(log, [`${bodyMethod}-reject`, 'next-microtask']);
+ }, contextualTestName(`response.${bodyMethod}() rejects if already aborted`));
+}
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+ controller.abort();
+
+ await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }).catch(() => {});
+
+ // I'm hoping this will give the browser enough time to (incorrectly) make the request
+ // above, if it intends to.
+ await fetch('../resources/data.json').then(r => r.json());
+
+ const response = await fetch(`../resources/stash-take.py?key=${stateKey}`);
+ const data = await response.json();
+
+ assert_equals(data, null, "Request hasn't been made to the server");
+}, contextualTestName("Already aborted signal does not make request"));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ const fetches = [];
+
+ for (let i = 0; i < 3; i++) {
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ fetches.push(
+ fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal })
+ );
+ }
+
+ for (const fetchPromise of fetches) {
+ await promise_rejects(t, "AbortError", fetchPromise);
+ }
+}, contextualTestName("Already aborted signal can be used for many fetches"));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ await fetch('../resources/data.json', { signal }).then(r => r.json());
+
+ controller.abort();
+
+ const fetches = [];
+
+ for (let i = 0; i < 3; i++) {
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ fetches.push(
+ fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal })
+ );
+ }
+
+ for (const fetchPromise of fetches) {
+ await promise_rejects(t, "AbortError", fetchPromise);
+ }
+}, contextualTestName("Signal can be used to abort other fetches, even if another fetch succeeded before aborting"));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
+
+ const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ assert_equals(beforeAbortResult, "open", "Connection is open");
+
+ controller.abort();
+
+ // The connection won't close immediately, but it should close at some point:
+ const start = Date.now();
+
+ while (true) {
+ // Stop spinning if 10 seconds have passed
+ if (Date.now() - start > 10000) throw Error('Timed out');
+
+ const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ if (afterAbortResult == 'closed') break;
+ }
+}, contextualTestName("Underlying connection is closed when aborting after receiving response"));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ const url = new URL(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, location);
+ url.hostname = 'www1.' + url.hostname;
+
+ await fetch(url, {
+ signal,
+ mode: 'no-cors'
+ });
+
+ const stashTakeURL = new URL(`../resources/stash-take.py?key=${stateKey}`);
+ stashTakeURL.hostname = 'www1.' + stashTakeURL.hostname;
+
+ const beforeAbortResult = await fetch(stashTakeURL).then(r => r.json());
+ assert_equals(beforeAbortResult, "open", "Connection is open");
+
+ controller.abort();
+
+ // The connection won't close immediately, but it should close at some point:
+ const start = Date.now();
+
+ while (true) {
+ // Stop spinning if 10 seconds have passed
+ if (Date.now() - start > 10000) throw Error('Timed out');
+
+ const afterAbortResult = await fetch(stashTakeURL).then(r => r.json());
+ if (afterAbortResult == 'closed') break;
+ }
+}, contextualTestName("Underlying connection is closed when aborting after receiving response - no-cors"));
+
+for (const bodyMethod of BODY_METHODS) {
+ promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
+
+ const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ assert_equals(beforeAbortResult, "open", "Connection is open");
+
+ const bodyPromise = response[bodyMethod]();
+
+ controller.abort();
+
+ await promise_rejects(t, "AbortError", bodyPromise);
+
+ const start = Date.now();
+
+ while (true) {
+ // Stop spinning if 10 seconds have passed
+ if (Date.now() - start > 10000) throw Error('Timed out');
+
+ const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ if (afterAbortResult == 'closed') break;
+ }
+ }, contextualTestName(`Fetch aborted & connection closed when aborted after calling response.${bodyMethod}()`));
+}
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
+ const reader = response.body.getReader();
+
+ controller.abort();
+
+ await promise_rejects(t, "AbortError", reader.read());
+ await promise_rejects(t, "AbortError", reader.closed);
+
+ // The connection won't close immediately, but it should close at some point:
+ const start = Date.now();
+
+ while (true) {
+ // Stop spinning if 10 seconds have passed
+ if (Date.now() - start > 10000) throw Error('Timed out');
+
+ const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ if (afterAbortResult == 'closed') break;
+ }
+}, contextualTestName("Stream errors once aborted. Underlying connection closed."));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const stateKey = token();
+ const abortKey = token();
+ requestAbortKeys.push(abortKey);
+
+ const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
+ const reader = response.body.getReader();
+
+ await reader.read();
+
+ controller.abort();
+
+ await promise_rejects(t, "AbortError", reader.read());
+ await promise_rejects(t, "AbortError", reader.closed);
+
+ // The connection won't close immediately, but it should close at some point:
+ const start = Date.now();
+
+ while (true) {
+ // Stop spinning if 10 seconds have passed
+ if (Date.now() - start > 10000) throw Error('Timed out');
+
+ const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
+ if (afterAbortResult == 'closed') break;
+ }
+}, contextualTestName("Stream errors once aborted, after reading. Underlying connection closed."));
+
+promise_test(async t => {
+ await abortRequests();
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const response = await fetch(`../resources/empty.txt`, { signal });
+ const reader = response.body.getReader();
+
+ controller.abort();
+
+ const item = await reader.read();
+
+ assert_true(item.done, "Stream is done");
+}, contextualTestName("Stream will not error if body is empty. It's closed with an empty queue before it errors."));
+
+
+test(t => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ controller.abort();
+
+ let cancelReason;
+
+ const body = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(new Uint8Array([42]));
+ },
+ cancel(reason) {
+ cancelReason = reason;
+ }
+ });
+
+ fetch('../resources/empty.txt', {
+ body, signal,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/plain'
+ }
+ });
+
+ assert_true(!!cancelReason, 'Cancel called sync');
+ assert_equals(cancelReason.constructor, DOMException);
+ assert_equals(cancelReason.name, 'AbortError');
+}, contextualTestName("Readable stream synchronously cancels with AbortError if aborted before reading"));
diff --git a/fetch/api/abort/serviceworker-intercepted.https.html b/fetch/api/abort/serviceworker-intercepted.https.html
new file mode 100644
index 00000000000000..6df6aef2cdaa1a
--- /dev/null
+++ b/fetch/api/abort/serviceworker-intercepted.https.html
@@ -0,0 +1,102 @@
+
+
+
+
+ Aborting fetch when intercepted by a service worker
+
+
+
+
+
+
+
+
diff --git a/fetch/api/resources/basic.html b/fetch/api/resources/basic.html
new file mode 100644
index 00000000000000..e23afd4bf6a7ec
--- /dev/null
+++ b/fetch/api/resources/basic.html
@@ -0,0 +1,5 @@
+
+
diff --git a/fetch/api/resources/empty.txt b/fetch/api/resources/empty.txt
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/fetch/api/resources/infinite-slow-response.py b/fetch/api/resources/infinite-slow-response.py
new file mode 100644
index 00000000000000..fa2c482ec19d94
--- /dev/null
+++ b/fetch/api/resources/infinite-slow-response.py
@@ -0,0 +1,36 @@
+import time
+
+
+def url_dir(request):
+ return '/'.join(request.url_parts.path.split('/')[:-1]) + '/'
+
+
+def stash_write(request, key, value):
+ """Write to the stash, overwriting any previous value"""
+ request.server.stash.take(key, url_dir(request))
+ request.server.stash.put(key, value, url_dir(request))
+
+
+def main(request, response):
+ stateKey = request.GET.first("stateKey", "")
+ abortKey = request.GET.first("abortKey", "")
+
+ if stateKey:
+ stash_write(request, stateKey, 'open')
+
+ response.headers.set("Content-type", "text/plain")
+ response.write_status_headers()
+
+ # Writing an initial 2k so browsers realise it's there. *shrug*
+ response.writer.write("." * 2048)
+
+ while True:
+ if not response.writer.flush():
+ break
+ if abortKey and request.server.stash.take(abortKey, url_dir(request)):
+ break
+ response.writer.write(".")
+ time.sleep(0.01)
+
+ if stateKey:
+ stash_write(request, stateKey, 'closed')
diff --git a/fetch/api/resources/stash-put.py b/fetch/api/resources/stash-put.py
new file mode 100644
index 00000000000000..dd84ff1fc852cf
--- /dev/null
+++ b/fetch/api/resources/stash-put.py
@@ -0,0 +1,7 @@
+def main(request, response):
+ url_dir = '/'.join(request.url_parts.path.split('/')[:-1]) + '/'
+ key = request.GET.first("key")
+ value = request.GET.first("value")
+ request.server.stash.put(key, value, url_dir)
+ response.headers.set('Access-Control-Allow-Origin', '*')
+ return "done"
diff --git a/fetch/api/resources/stash-take.py b/fetch/api/resources/stash-take.py
new file mode 100644
index 00000000000000..fcaf96c96a6411
--- /dev/null
+++ b/fetch/api/resources/stash-take.py
@@ -0,0 +1,9 @@
+from wptserve.handlers import json_handler
+
+
+@json_handler
+def main(request, response):
+ dir = '/'.join(request.url_parts.path.split('/')[:-1]) + '/'
+ key = request.GET.first("key")
+ response.headers.set('Access-Control-Allow-Origin', '*')
+ return request.server.stash.take(key, dir)
diff --git a/fetch/api/resources/sw-intercept.js b/fetch/api/resources/sw-intercept.js
new file mode 100644
index 00000000000000..b8166b62a5c939
--- /dev/null
+++ b/fetch/api/resources/sw-intercept.js
@@ -0,0 +1,10 @@
+async function broadcast(msg) {
+ for (const client of await clients.matchAll()) {
+ client.postMessage(msg);
+ }
+}
+
+addEventListener('fetch', event => {
+ event.waitUntil(broadcast(event.request.url));
+ event.respondWith(fetch(event.request));
+});
diff --git a/tools/wptserve/wptserve/response.py b/tools/wptserve/wptserve/response.py
index 6c073feea9ee56..50ff00dde9440e 100644
--- a/tools/wptserve/wptserve/response.py
+++ b/tools/wptserve/wptserve/response.py
@@ -465,9 +465,11 @@ def encode(self, data):
raise ValueError
def flush(self):
- """Flush the output."""
+ """Flush the output. Returns False if the flush failed due to
+ the socket being closed by the remote end."""
try:
self._wfile.flush()
+ return True
except socket.error:
# This can happen if the socket got closed by the remote end
- pass
+ return False