w3c/ServiceWorker

Cancelling HTTP fetch

horo-t opened this issue · 19 comments

When the user clicks the stop button while the page is loading a large file (example: a movie file) via the ServiceWorker, the ServiceWoer should stop the HTTP fetching to reduce the resource usage.

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
});

Is this behavior specified in the Fetch spec or the ServiceWorker spec?

There's a concept of https://fetch.spec.whatwg.org/#concept-fetch-terminate which results in a network error of sorts. However, we haven't really detailed what UI actions can trigger such a thing and for which fetches.

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch('/whatever.json').then(function(response) {
      return response.json();
    }).then(function(data) {
      return fetch(data.url);
    })
  );
});

We may not have a request to cancel when the user hits the stop button. As far as I know, we can only tie a response to a destination window once the promise passed to respondWith resolves, which is when fetch(data.url) gets headers.

If the user hits X (or even closes the tab) while /whatever.json is fetching, I don't think we can simply cancel the request. We may be able to terminate the worker, but only if other tabs aren't using it.

How does this sound? If the request is cancelled from the browser's point of view:

  1. That fetch event may no longer keep the SW running, so if no other events are keeping the SW open, the SW may terminate
  2. If respondWith has been called with a promise that has not resolved, wait for it to resolve
  3. If the response given to respondWith has a stream, abort it

In Gecko, we have a loadgroup that knows about network requests. When a document goes away, the loadgroup terminates the relevant requests. So the fetch() will fail with an AbortError when called from documents. I'll let @wanderview comment if the same thing will work on workers now that he implemented loadgroups for them.

Ben, when a SW is created for handling a document's request, will it (or can we make it) acquire that document's loadgroup?

Ben, when a SW is created for handling a document's request, will it (or can we make it) acquire that document's loadgroup?

We definitely don't do that today. The SW has its own load group. Anything is possible, but it will be tricky.

Maybe this should be merged into whatwg/fetch#20?

I think this is https://www.w3.org/Bugs/Public/show_bug.cgi?id=23878 because it's about UI and not API.

Ah, I see. Yeah, how to deal with such user actions should be discussed at the bug.

Regarding how to inform not yet fulfilled fetch() of cancellation (Jake's comment #592 (comment)), once we have a canceller or cancellable promise (whatwg/streams#297 (comment) @domenic), it sounds like we should pass the canceller to respondWith() together with the promise.

Or more generally, it might be named controller.

Promise<Response> fetch(
    RequestInfo input,
    optional RequestInit,
    optional controllerReceiver);
let controller;
fetch(url, {}, controller_ => {
  controller = controller_;
});
...
controller.abort();

It seems it's not appropriate to include the controller receiver to RequestInit since it's associated with each fetch() operation invocation. Not with each Request object. So, maybe, as a third parameter (or in the third parameter which is a dictionary).

Coming back to this a few days after the discussion in whatwg/streams#297 (comment) the key question is whether we want to require: (a) two separate cancellation mechanisms, one for before headers arrive and one after; or (b) a single cancellation mechanism that works for both.

Any promise-cancellation--based approach seems to lean toward (a). You use promise cancellation for before headers arrive. After headers arrive, the promise is already settled, and you can't do anything to affect it. So after headers arrive you use stream cancellation.

Approaches like @tyoshino's controller, or maybe an approach like req.abort() (note: would not work with RequestInit version of fetch()), seem to allow more flexibility to achieve (b).

(I thought we were discussing this particular API problem in #625 but I'm happy to move it here.)

@tyoshino we could create FetchInit : RequestInit (inherited dictionary) for fetch(). I think we should have something distinct from Request. @wanderview convinced me that treating Request as more than just input (or output in case of a service worker fetch event) is problematic. Various things from the original Request object might not end up being taken into account. fetch() only modifying parts of the input but not others would be weird.

So that leaves us with either a promise subclass or controller of sorts. Given the promise discussions on es-discuss it seems like we want a controller.

And as far as @domenic's question goes I think it should be a mechanism that cancels the promise and the stream. The action is terminating network activity. The only worry I have is that the code gets a little awkward:

var c = new FetchController,
    resp = fetch(url, { controller: c })
c.abort()

Also, if we go down this route I think the controller object should also expose state whether it is in use or not. And fetch() will throw if it is passed a controller that is already in use. Or do we want to allow for the ability of a single controller controlling several fetches?

Yeah, I think a controller type thing will work well. We can later explain it in terms of promise cancellation + stream cancellation.

A promise subclass would be confusing since fetchPromise.cancel() (or abort?) would probably have different semantics than genericCancelablePromise.cancel(), since e.g. the latter would not be able to affect anything after genericCancelablePromise settles.

var c = new FetchController,
resp = fetch(url, { controller: c })
c.abort()

This is pretty reasonable. Not sure whether I like it more or less than @tyoshino's equivalent:

resp = fetch(url, { control(controller) {
  goElsewhereButton.addEventListener("click", () => controller.abort());
} });

Yeah, I guess my suggestion only makes sense if we want it to be reusable or usable for several fetches at once (probably makes little sense if we add more features, such as postMessage()). We do still need to decide if this makes the promise forever pending.

In whatwg/streams#297 (comment) we were thinking it would error the stream, since it simulates the browser terminating the connection, and can happen even if someone else has an exclusive reader (which normally prevents cancellation). That doesn't necessarily mean that the promise should reject. But it would be a bit more consistent that way. Hmm.

I'm not sure how the controller approach works with promise/stream combinators. Any ideas? It seems like it would be an awkward additional set of arguments you have to pass in so it can synthesize a new combined controller, or else a separate controller-centric copy of each combinator.

NekR commented

I have one more proposal for canceling fetch. Let's image what fetch should not return Promise (or any subclasses of it if it's hard for now). but some FetchObject with similar signature as Promise:

FetchObject = {
  // promise-like
  then: function() {},
  catch: function() {},

  // cancel() method not related to promise at all
  cancel: function() {}
};

then() and catch() will pretend as FetchObject is promise (like third-party promises), but will return native Promise. In the meantime, cancel() will be related to fetch itself and its execution e.g. aborting request and preventing promise to be settled (or rejecting it, I cannot remember which action you already choose for it) if it was not already, and closing the stream (sorry I am not familiar with steams terminology here) if promise was fullfilled/stream was used.

In general, usage would look like this:

var reqSomething = fetch('...');
var asyncDoSomethig = ...;

var onceDone = Promise.all([
  reqSomething.then(function handleStream() { ... });,
  asyncRenderSomething
]);

onceDone.then(function(args) {
  // apply some stuff
});

onEscPressed(function() {
   reqSomething.cancel();
});

So here onceDoen.then will never called if esc would be pressed before fetch is completely handled.

Few other things:
As someone pointed out, canceling is not always required it will be used once needed, for example this will not work, because reqSomething will not be FetchObject, but regular Promise:

var reqSomething = fetch('...').reqSomething.then(function handleStream() { ... });
reqSomething.cancel();

So once someone needed to cancel fetch, then they may store origin FetchObject to do so. And, of course, it would be to have cancel event for FetchObject, so it probably should be implement EventTarget too.

This was seems not ideal, but other ways are not better. Thanks.

edit: my cancel-ability concern has been solved, nothing to see here

@WebReflection the comment you're quoting of mine is months old & you're taking it out of context. The browser will be able to cancel streams when the user hits x, the problem with that particular example is that the initial request does not go to the browser, it stays within the ServiceWorker.

Fetches will become cancellable. Its underlying stream is already exposed in Canary and is cancellable.

Then my apologies, I've got mislead by a recent tweet that was pointing to this discussion and since it's still open and there's no reference on "how to cancel" in the mentioned WHATWG page I thought it was still in the middle of its resolution.

Closing this in favor of whatwg/fetch#27