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