Aborting a fetch
annevk opened this issue · 326 comments
Goal
Provide developers with a method to abort something initiated with fetch()
in a way that is not overly complicated.
Previous discussion
- #20
- w3c/ServiceWorker#592
- w3c/ServiceWorker#625
- whatwg/streams#297
- tc39/proposal-cancelable-promises#4 (security issues)
Viable solutions
We have two contenders. Either fetch()
returns an object that is more than a promise going forward or fetch()
is passed something, either an object or a callback that gets handed an object.
A promise-subclass
In order to not clash with cancelable promises (if they ever materialize) we should pick a somewhat unique method name for abortion. I think terminate()
would fit that bill.
var f = fetch(url)
f.terminate()
Note: the Twitter-sphere seemed somewhat confused about the capabilities of this method. It would most certainly terminate any ongoing stream activity as well. It's not limited to the "lifetime" of the promise.
A controller
The limited discussion on es-discuss https://esdiscuss.org/topic/cancelable-promises seemed to favor a controller. There are two flavors that keep coming back. Upfront construction:
var c = new FetchController
fetch(url, {controller: c})
c.abort()
Revealing constructor pattern:
fetch(url, {controller: c => c.abort()})
Open issues
- What is the effect on the promise? Both forever-pending and explicit rejection have reasonable arguments. We could offer the choice to the developer, but what should be the default?
- What is the effect on the stream? I suspect the Streams Standard is already conclusive on this.
- What syntax of the above two-three solutions do we favor?
Thanks for the effort folks, I'm chiming in to follow up and with a couple of questions:
- wouldn't be wise to put this resolution on hold until there is a definitive take on Promise-land and cancel-ability?
- in this post you named
abort
andcancel
and you think thatterminate
would pay the naming bill. Wouldn't be wise to use similar XHR intent developers already know instead of introducingterminate
for the fetch andabort
for the controller?
Best Regards
This is only somewhat-related to promises being cancelable. This is about cancelling a fetch. It does matter somewhat for one of the open issues and yes, we might end up having to wait or decide shipping is more important, we'll see.
And we won't have a promise-subclass and a controller. Either will do. The subclass uses terminate()
to avoid conflicting with cancelable-promises which might want to use either cancel()
and/or abort()
(as mentioned btw).
The controller approach is certainly the quickest way we'll solve this, but it's pretty ugly, I'd like to treat it as a last resort & try for the cancellable promises approach.
Cancellation based on ref-counting
I'm still a fan of the ref counting approach, and from the thread on es-discuss it seems that libraries take a similar approach.
var rootFetchP = fetch(url).then(r => r.json());
var childFetchP1 = rootFetchP.then(data => fetch(data[0]));
var childFetchP2 = rootFetchP.then(data => fetch(data[1]));
var childP = Promise.resolve(rootFetchP).then(r => r.text());
childFetchP1.abort();
// …aborts fetch(data[0]), or waits until it hits that point in the chain, then aborts.
// fetch(url) continues
childFetchP2.abort();
// …aborts fetch(data[1]), or waits until it hits that point in the chain, then aborts.
// fetch(url) aborts also, if not already complete. Out of refs.
// childP hangs as a result
rootFetchP.then(data => console.log(data));
// …would hang because the fetch has aborted (unless it completed before abortion)
Cancelling a promise that hadn't already settled would cancel all its child CancellablePromises.
Observing cancellation
If a promise is cancelled, it needs to be observable. Yes, you don't want to do the same as "catch", but you often want to do "finally", as in stop spinners and other such UI. Say we had:
var cancellablePromise = new CancellablePromise(function(resolve, reject) {
// Business as usual
}, {
onCancel() {
// Called when this promise is explicitly cancelled,
// or when all child cancellable promises are cancelled,
// or when the parent promise is cancelled.
}
});
// as a shortcut:
CancellablePromise.resolve().then(onResolve, onReject, onCancel)
// …attaches the onCancel callback to the returned promise
// maybe also:
cancellablePromise.onCancel(func);
// as a shortcut for .then(undefined, undefined, func)
Usage in fetch
Fetch would return a CancellablePromise that would terminate the request onCancel. The stream reading methods response.text()
etc would return their own CancellablePromise that would terminate the stream.
If you're doing your own stream work, you're in charge, and should return your own CancellablePromise:
var p = fetch(url).then(r => r.json());
p.abort(); // cancels either the stream, or the request, or neither (depending on which is in progress)
var p2 = fetch(url).then(response => {
return new CancellablePromise(resolve => {
drainStream(
response.body.pipeThrough(new StreamingDOMDecoder())
).then(resolve);
}, { onCancel: _ => response.body.cancel() });
});
Clearing up a question from IRC:
var fetchPromise = fetch(url).then(response => {
// noooope:
fetchPromise.abort();
var jsonPromise = response.json().then(data => console.log(data));
});
In the above, fetchPromise.abort()
does nothing as the promise has already settled. The correct way to write this would be:
var jsonPromise = fetch(url).then(r => r.json());
Now jsonPromise.abort()
would cancel either the request, or the response, whichever is in progress.
Since calling abort might not abort the fetch (request), I don't think the method should be called abort
(or cancel
, as it is in some other specificatins), but rather ignore
, since that is all it can guarantee to do. For example,
var request = fetch(url);
var json = request.then(r => r.json);
var text = request.then(r => r.text);
text.ignore(); //doesn't abort the fetch, only ignores the result.
This would also work well with promise implementations that don't support cancellations (like the current spec), since calling abort
on it might not abort a promise early in the chain, but calling ignore
will always ignore the result. For example:
//doSomething does not return a cancellablePromise, so calling abort won't abort what is
//happening inside doSomething. ignore makes it clear that only the result will be ignored,
//any data done can't be guaranteed to be aborted.
doSomething().then(url => fetch(url)).then(r => r.json).ignore()
Since calling abort might not abort the fetch (request)
If you call it on the promise returned by fetch()
it will abort the request, but it won't abort the response. Unless of course the request is complete.
Your example will fail because you have two consumers of the same stream, we reject in this case. It should be:
var requestPromise = fetch(url);
var jsonPromise = requestPromise.then(r => r.clone().json());
var textPromise = requestPromise.then(r => r.text());
textPromise.abort();
In this case, textPromise.abort()
cancels the reading of the stream, but wouldn't abort the fetch because there are other uncancelled children. If for some reason the json completed earlier, the raw response would be buffered in memory to allow the other read. Aborting the text read would free up this buffer.
I don't think "ignore" is a great name for something that has these kind of consequences. Maybe there's a better name than abort
though, I'm more interested in the behavior than the name, I just picked abort
because of XHR, maybe cancel
is a better fit.
@jakearchibald, in your proposal, it looks like p.then()
increments the refcount, but Promise.resolve(p)
doesn't? And that promises start out with a 0 refcount, so that you don't have to also abort()
the initial fetch()
? This seems odd to me, although it gets around the need to expose GC.
Does any of this flow through to the async
/await
syntax, or do you have to manipulate the promises directly to use cancellation?
I'm also worried about subsequent uses of the original promise hanging instead of rejecting.
@jyasskin the refcount would be increased by cancellable promises. So cancellablePromise.then()
increases the refcount, as would CancellablePromise.resolve(cancellablePromise)
, Promise.resolve(cancellablePromise)
would not.
If you use async
/await
you're opting into a sync-like flow, so yeah if you want the async stuff you need to use promises, or we decide that cancellation results in a rejection with an abort error.
onCancel
could be passed (resolve, reject)
so the promise vendor could decide what the sync equivalent should be.
I'm also worried about subsequent uses of the original promise hanging instead of rejecting.
Yeah, it should onCancel
.
'k. Say we have a careless or cancellation-ignorant library author who writes:
function myTransform(yourPromise) {
return yourPromise
.then(value => transform(value))
.then(value => transform2(value));
If we had myTransform(fetch(...)).cancel()
, that intermediate, uncancelled .then()
will prevent the fetch from ever aborting, right? (This would be fixed if GC contributed to cancellation, but there's a lot of resistance to making GC visible.)
On the other hand, in a CancellationToken
approach like https://msdn.microsoft.com/en-us/library/dd997364%28v=vs.110%29.aspx, we'd write:
var cancellationSource = new CancellationTokenSource();
var result = myTransform(fetch(..., cancellationSource.token));
cancellationSource.cancel();
And the fetch would wind up rejecting despite the intermediate function's obliviousness to cancellation.
The "revealing constructor pattern" is bad for cancellation tokens because it requires special infrastructure to be able to cancel two fetches from one point. On the other side, cancellation tokens require special infrastructure to be able to use one fetch for multiple different purposes.
Either of Anne's solutions can, of course, be wrapped into something compatible with either CancellablePromise
or CancellationToken
if the goal here is to get something quickly instead of waiting for the long-term plan to emerge.
Another alternative:
let abortFetch;
let p = new Promise((resolve, reject) => { abortFetch = reject; });
fetch(url, { abort: p }).then(success, failure);
// on error
abortFetch();
That would make the activity on fetch dependent on a previous promise in a similar fashion (but more intimate) to this:
promiseYieldingThing().then(result => fetch(url)).then(success, failure);
I don't like the implicit nature of what @jakearchibald suggests here.
TL;DR
I would like to speak strongly in favor of the "controller" approach and strongly opposed to some notion of a cancelable promise (at least externally so).
Also, I believe it's a mistake to consider the cancelation of a promise as a kind of automatic "back pressure" to signal to the promise vendor that it should stop doing what it was trying to do. There are plenty of established notions for that kind of signal, but cancelable promises is the worst of all possible options.
Cancelable Promise
I would observe that it's more appropriate to recognize that promise (observation) and cancelation (control) are two separate classes of capabilities. It is a mistake to conflate those capabilities, exactly as it was (and still is) a mistake to conflate the promise with its other resolutions (resolve/reject).
A couple of years ago this argument played out in promise land with the initial ideas about deferreds. Even though we didn't end up with a separate deferred object, we did end up with the control capabilities belonging only to the promise creation (constructor). If there's a new subclass (or extension of existing) where cancelation is a new kind of control capability, it should be exposed in exactly the same way as resolve
and reject
:
new CancelablePromise(function(resolve,reject,cancel) {
// ..
});
The notion that this cancelation capability would be exposed in a different way (like a method on the promise object itself) than resolve/reject is inconsistent/incoherent at best.
Moreover, making a single promise reference capable of canceling the promise violates a very important tenet in not only software design (avoiding "action at a distance") but specifically promises (that they are externally immutable once created).
If I vend a promise and hand a reference to it to 3 different parties for observation, two of them internal and one external, and that external one can unilaterally call abort(..)
on it, and that affects my internal observation of the promise, then the promise has lost all of its trustability as an immutable value.
That notion of trustability is one of the foundational principles going back 6+'ish years to when promises were first being discussed for JS. It was so important back then that I was impressed that immutable trustability was at least as important a concept as anything about temporality (async future value). In the intervening years of experimentation and standardization, that principle seems to have lost a lot of its luster. But we'd be better served to go back and revisit those initial principles rather than ignore them.
Controller
If a cancelable promise exists, but the cancelation capability is fully self-contained within the promise creation context, then the vendor of the promise is the exclusive entity that can decide if it wants to extract these capabilities and make them publicly available. This has been a suggested pattern long before cancelation was under discussion:
var pResolve, pReject, p = new Promise(function(resolve,reject){
pResolve = resolve; pReject = reject;
});
In fact, as I understand it, this is one of several important reasons why the promise constructor is synchronous, so that capability extraction can be immediate (if necessary). This capability extraction pattern is entirely appropriate to extend to the notion of cancelability, where you'd just extract pCancel
as well.
Now, what do you, promise vendor, do with such extracted capabilities? If you want to provide them to some consumer along with the promise itself, you package these things up together and return them as a single value, like perhaps:
function vendP() {
var pResolve, pReject, pCancel, promise = new CancelablePromise(function(resolve,reject,cancel){
pResolve = resolve; pReject = reject; pCancel = cancel;
});
return { promise, pResolve, pReject, pCancel };
}
Now, you can share the promise
around and it's read-only immutable and observable, and you can separately decide who gets the control capabilities. For example, I'd send only promise
to some external consumer, but I might very well retain the pCancel
internally for some usage.
Of course this return object should be thought of as the controller from the OP.
If we're going to conflate promise cancelation with back-pressure (I don't think we should -- see below!) to signal the fetch should abort, at least this is how we should do it.
Abort != Promise Cancelation... Abort == async
Cancel
In addition to what I've observed about how promise cancelation should be designed, I don't think we should let the cancelation of a promise mean "abort the fetch". That's back-pressure, and there are other more appropriate ways to model that than promise cancelation.
In fact, it seems to me the only reason you would want to do so is merely for the convenience of having the fetch API return promises. Mere convenience should be way down the priority list of viable arguments for a certain design.
I would observe that the concern of what to do with aborting fetches is quite symmetric with the concern of how/if to make an ES7 async
function cancelable.
In that thread, I suggested that an async
function should return an object (ahem, controller) rather than a promise itself.
To do promise chaining from an async function call in that way, it's only slightly less graceful. The same would be true for a fetch API returning a controller.
async foo() { .. }
// ..
foo().promise.then(..);
fetch(..).promise.then(..);
But if you want to access and retain/use the control capabilities for the async function (like signaling it to early return/cancel, just as generators can be), the controller object would look like:
var control = foo();
// control.return(); // or whatever we bikeshed it to be called
control.promise.then(..);
I also drew up this crappy quick draft of a diagram for a cancelable async function via this controller concept:
That's basically identical to what I'm suggesting we should do with fetch.
PS: Is it mere coincidence that canceling a fetch and canceling an async
function both ended up issue number 27 in their respective repos? I think surely not! :)
Related: Composition Function Proposal for ES2016
Might be interested in the toy (but instructive) definition of Task and how it is composed using async/await.
I don't want to deny anyone the ability to rant about XMLHttpRequest
and streams, but I will deny that ability within this thread. Your responses have been removed for being offtopic.
function myTransform(yourPromise) {
return yourPromise
.then(value => transform(value))
.then(value => transform2(value));
}
myTransform(fetch(url)).cancel();
This would:
- Cancel the promise returned by
.then(value => transform2(value))
- Cancel the promise returned by
.then(value => transform(value))
because all its child promises cancelled - Cancel
yourPromise
(which isfetch(url)
) because all its child promises cancelled
This works as expected right?
@getify I appeal to you, once again, to filter out the repetition and verbosity of your posts before posting, rather than all readers having to do it per read. I ask not only for others' benefit, this will also boost the signal of the point you're trying to make.
If there's a … subclass … where cancelation is a … control capability, it should be exposed in … the same way as resolve and reject
resolve
and reject
are internal to the promise. What we're talking about here is a way to let an observer signal disinterest in the result, and let a promise react to all observers becoming disinterested.
making a … promise reference capable of canceling the promise violates … that they are externally immutable once created
Yes, that would be a specific and intentional difference between cancellable promises and regular ones. I understand in great detail that you don't like that, but can you (briefly and with evidence/example) show the problems this creates?
If I vend a promise and hand … it to 3 different parties … one can unilaterally call
abort(..)
on it, and that affects my internal observation of the promise
If you don't want to vend a cancellable promise don't vend a cancellable promise. If you want to retain cancellability, vend a child of the promise each time.
@jakearchibald what is wrong with this way?
var req = fetch('...');
// or req.headers.then(...)
req.response.then(function(response) {
if (response.headers.get('Content-Type') !== 'aplication/json') {
req.cancel();
}
return response.json();
});
req.addEventListener('cancel', function() {
// ...
});
// or Streams-like style
// closed/cancelled/aborted
req.closed.then(function() {
// ...
});
Here fetch
will return some FetchObject, not Promise itself.
Before I get to the other points you've brought up (I have responses), let me focus on and clarify just this one:
signal disinterest in the result, and let a promise react to all observers becoming disinterested.
Let me try to illustrate my question/concern (and perhaps misunderstanding). Assume:
var parent = new Promise(function(resolve){ setTimeout(resolve,100); }),
child1 = parent.then(function(){ console.log("child1"); }),
child2 = parent.then(function(){ console.log("child2"); });
First, what happens here?
parent.cancel();
Do both "child1"
and "child2"
still get printed? Or neither?
If merely passing parent
around to various different parts of a system that separately want to observe it means that any one observer (the code that creates child1
, for example) can unilaterally decide that another part of the system (the code that creates child2
) is prevented from knowing about what happens with parent
-- and thus just hangs around waiting in vein -- that's "action at a distance" and is a software design practice that's usually frowned upon. It makes systems harder to reason about and trust. I can follow up with illustrating a fetch(..)
specific scenario I have in mind, if necessary.
Now, what happens if instead:
child1.cancel();
Does that mean that "child2"
does or does not get printed? Same concerns as above.
@NekR that's already possible in Canary today thanks to the Streams API:
fetch(url).then(response => {
if (response.headers.get('Content-Type') !== 'application/json') {
response.body.cancel();
}
});
We can already abort the response, it's the request we can't abort. The only case this is a problem is when the request is particularly large, say you're uploading a large file.
It's trivial to do what you're suggesting whilst still returning a promise. fetch()
could return a subclass that has an abort method that terminates the request or in-progress response stream. The @@species
of that subclass would be Promise
, so calls to .then
would return a regular promise, and there's no chain to worry about.
The question is whether there's a benefit in the return of cancellablePromise.then()
also being abortable.
// If @@species is a regular promise:
var fetchPromise = fetch(url);
var jsonPromise = fetchPromise.then(r => r.json());
// To abort the request & response:
fetchPromise.abort();
// If @@species is abortable:
var jsonPromise = fetch(url).then(r => r.json());
// To abort the request & response:
jsonPromise.abort();
answering @getify from my POV:
var parent = new Promise(function (res, rej, cancel) {
var cancelable = setTimeout(res,100);
cancel(function () {
clearTimeout(cancelable);
});
}),
child1 = parent.then(function(){ console.log("child1"); }),
child2 = parent.then(function(){ console.log("child2"); });
Since you expose cancel-ability, you setup what should happen when you cancel so that parent.cancel()
will trigger its internal cancellation state.
Now, as quantum physics tough us that the world is not just black or white, we also need a canceled state ( IMO™ ) as a way to ignore or react.
Let's be more pragmatic, as Jake suggested already before ;-)
Do both
"child1"
and"child2"
still get printed? Or neither?
Well, you get cancel is not a function
since those are regular promises, but I'll assume you were meant to create a cancellable promise 😄. In your example, assuming parent.cancel()
is called prior to it resolving, neither "child1"
and "child2"
is logged. I can't see how either could be logged… let me reword your example:
var parent = new CancellablePromise(resolve => setTimeout(_ => resolve("Hello"), 100));
var child1 = parent.then(value => console.log(value + " world"));
var child2 = parent.then(value => console.log(value + " everyone"));
If parent is cancelled before resolving, it doesn't get to provide the value the others need to compose their log messages.
If … passing parent around to … different parts of a system … means that … one observer … can … decide … another part of the system … is prevented from knowing … what happens with parent
This isn't the case in my proposal, you can add a cancel observer in the same way you observe fulfill & reject. This would allow you to stop a spinner, but not display an error message, as cancellation often isn't error worthy.
Now, what happens if instead
child1.cancel()
. Does that mean … "child2" … gets printed?
"child2" does get printed. The parent has a cancellable promise child count of 1 (child2
) so it does not cancel. "child1" is not printed.
Is parent is cancelled before resolving, and you expected something to be logged, what would the value be?
The problem here is differing perspective and actor. I get the desire to want to cancel the parent
and thus have that filter down to all observers not getting resolved (and perhaps getting instead notified of cancelation). I get, and want, cancelable fetch(..)
. I also want cancelable async function
, for the exact same reason.
What I am objecting to is the perspective that the code that creates child1
gets to unilaterally decide that and affect the code that makes child2
. Only the actor that created parent
should get to decide that for all observers.
In fetch(..)
context, the party that fires off the initial request should of course be able to decide later, "hey, forget that request." But downstream, if only one observer of that action (among many others) says "I don't care anymore, forget it", that should only allow that observer to cancel their observation, not affect other observers.
For example, one observer might say "I only wait a max of 3 seconds for a response, then I give up", but another observer may have a longer tolerance and want the request to keep going for awhile longer.
@jakearchibald Oh right, refcount-starts-at-0 again. I think you're right.
I think this all leads to the guideline that, if you have a CancellablePromise
parent
and a child = parent.then(...)
, then if you want to be able to create more children in the future, before you hand child
out to code that might cancel it, you have to create and hold onto your own child=parent.then()
until you're sure you're done creating children. When storing parent
in a cache, you might just write parent.then()
and throw away the result to permanently disable cancellation. Right?
The question is whether there's a benefit in the @@species also being abortable.
Reasonable. Ideally it should, but as we all see it's a bit hard to decide right way for it. This is why I thought about returning non-promise from fetch
, because this will cause no more questions about "is chained promise abortable or not" since .response
will be just property which just returns Promise
not directly related to fetch action.
until you're sure you're done creating children
I can't think of a case you'd want that behaviour but it's Friday and I'm way over my thinking quota for the week.
If you don't want children to be able to cancel the parent, cast to a normal promise before returning.
function nonCancellableFetch(...args) {
return Promise.resolve(fetch(...args));
}
What I am objecting to is … the code that creates child1 gets to … affect the code that makes child2.
But it can't. child1
and child2
would have to cancel for parent
to know about it and cancel the underlying fetch.
I haven't been convinced by this thread that we need to solve the general problem of transitive promise cancellation. That adds a lot of reference for a feature that will be rarely used.
It's a poor analogy, but other multithreaded promise-like things don't have a generic cancellation. Read the c# documentation on the topic, for example.
What is wrong with adding a simpler hook, solely for fetch use?
Sorry, reference should have been complexity. I blame my phone keyboard.
Requoting myself:
the code that creates child1 gets to unilaterally decide...
I'm not suggesting:
child1 = parent.then(..);
child1.cancel();
I am suggesting:
child1 = parent.then(..);
parent.cancel();
By having the reference parent
to observe, I also have the same reference parent
that lets me cancel it for everyone else.
@getify yes that's bad form of the child1 code. It should be written like your first example. Of course, if the vendor doesn't want cancellation, they can cast to Promise
.
I'm not too worried about this. You can have multiple listeners to DOM elements that can also mutate then. World keep turnin'.
What is wrong with adding a simpler hook, solely for fetch use?
If there's no agreement on chainable cancellation, we could go with a promise subclass with an .abort
that rejects the fetch if it's in progress & terminates the stream. This promise would have a @@species
of Promise
so there's no cancellation chaining.
Because this terminates the stream, it may be impossible for the reader (eg .text()
) to know something went wrong, and may resolve with partial content - basically the same that would happen if the connection is abruptly terminated (see my research on this w3c/ServiceWorker#362 (comment)). This isn't an issue with the CancellablePromise, as the explict cancellation can be captured.
With cancellation in the chain, it means that:
function higherLevelFetch(url) {
return fetch(url).then(transformSomeWay).catch(recoverSomeWay);
}
...produces an abortable value automatically. We'd lose that if .then
returned a standard promise.
You also lose the "all children cancelled so cancel parent" behaviour which I think works well for multiple listeners to the same fetch, but maybe if we ever get such a thing fetch could adopt it.
that's bad form of the child1 code
The concern is not about what the "child1 code" should do, it's about what the "child1 code" can do. As promises stand now, "child1 code" cannot affect "child2 code". That's by design, and it's a good thing.
they can cast to
Promise
I had a feeling this suggestion was coming soon. So, you're essentially saying:
var p = fetch(..), p_safe = Promise.resolve(p);
// later
child1 = p_safe.then(..)
..
Sure, that might work to isolate the capability, since p_safe
wouldn't have a cancel(..)
. But then, that two-step is essentially the same in spirit as:
var controller = fetch(..), p = controller.promise;
// later
child1 = p.then(..)
The problem with having "controller" be a CancelablePromise
and expecting people to understand this nuance and pre-cast with Promise.resolve(..)
is that most won't understand, and will just pass the cancelable-promise around, until they get bitten, and it won't be entirely clear why.
I'd call that pit-of-failure design.
I had a feeling this suggestion was coming soon
You might have gotten that feeling because I'd suggested it twice in this thread already.
Because this terminates the stream, it may be impossible for the reader (eg .text()) to know something went wrong
Should it be the same as:
fetch(...).then(function(response) {
setTimeout(function() {
response.body.close();
}, 100);
return response.text();
});
I believe .cancel()
on promise should behave same as response.body.close()
and promise returned by response.text()
should receive an rejection (since it was explicitly abrupted).
Should it be the same as:
No, your call to cancel the stream would fail as .text()
has a lock on the stream. Cancellation is not observable in streams as far as I know. Of course, if the bytes received is < content-length, the reader could choose to reject, but not all responses have content-length.
@jakearchibald, isn't it sufficient to cancel just the request? That is to say, once the fetch promise resolves, the abort would have no effect. The abort would simply cause the promise to reject with a new error type.
In that case, consumers of the response stream aren't affected, because there is no response at the time that any abort is enacted.
An abort on the response stream is sufficient to cancel the response once it starts.
That would mean fetch(url).then(r => r.json())
isn't abortable once the stream starts being read into json.
No, your call to cancel the stream would fail as .text() has a lock on the stream. Cancellation is not observable in streams as far as I know. Of course, if the bytes received is < content-length, the reader could choose to reject, but not all responses have content-length.
That is weirdest behavior I ever saw -- you cannot cancel because you are doing request.
Well, you aren't, .json() is.
That would mean fetch(url).then(r => r.json()) isn't abortable once the stream starts being read into json.
That's OK. At that point, cancelling the stream should suffice. That can be mapped back into a cancellation of the response.
I guess that you could say that it's a little janky that there are apparently two ways to cancel a request then, but I think that it maps nicely into the API. I see each as independently useful, even if both map into RST_STREAM or a connection close in the end.
Now that I've properly considered the upstream propagation of cancellations, I think that I've concluded that it's a real hazard.
If the intent of the sequence is to produce just the final product of a promise chain, then that is the only case where this is OK. However, a promise chain can frequently have side effects that are important to the functioning of a program. Pushing a cancellation upstream risks a downstream consumer of a promise cancelling actions, often unbeknownst to them, in code for that they don't necessarily know the structure of.
Consider:
Obj.prototype.doSomethingInnocuous = function() {
if (this.alreadyRun) { return Promise.resolve(); }
return innocuousActivity(t).then(() => { this.alreadyRun = true; });
}
var commitFetch;
obj.doSomethingInnocuous()
.then(() => commitFetch = fetch(commitUrl, {method: 'POST'}))
.then(showResult);
cancelButton.onclick = e => commitFetch.abort();
Here, you might admit the possibility that innocuousActivity()
is cancellable. But the code that calls it doesn't expect it to be cancelled. Maybe the ability to cancel this operation wasn't even added until a recent update. But a failure there leaves means that the activity is run multiple times if ever it were cancelled. That might be OK, but you don't know if that is true; certainly steps were taken to ensure that it wasn't called twice.
Obviously you can structure the code functions so that important cancellable activities are defended by having unreachable dependencies:
var p = importantThing();
p.then(() => {}); // prevent a cancellation on timeout()
return p.then(() => {});
Or something like that, but that sort of defensive programming is extremely non-obvious. I think that would make cancellation virtually unusable by virtue of creating a strong disincentive to use it for fear of the potential for collateral damage.
@martinthomson doSomethingInnocuous()
already has a bug if innocuousActivity()
ever rejects. I (believe I) differ from @jakearchibald in thinking that cancellation should always result in either a resolved or rejected CancellablePromise
, rather than adding a third state, and if cancellation results in a rejection, then doSomethingInnocuous()
's rejection handler will get to do whatever it did in a pre-cancellation world.
@jyasskin I agree regarding the two vs. three state thing. I don't see people handling cancellation unless it causes rejection. I can see where @jakearchibald is coming from there, with the cancellation avoiding .catch()
handlers all down the chain, but I don't see that as materially different from defining a reallyReject
and matching .reallyCatch()
functions.
I acknowledge the bug in my example - it's been a very long IETF week - but will stick to my thesis here. This is a footgun.
q: What happens to a CancellablePromise
when the anchor promise (the one that can actually be cancelled, not its dependents) resolves? Does the .abort()
function on it just fail with a TooLateError
?
That would mean fetch(url).then(r => r.json()) isn't abortable once the stream starts being read into json.
That's OK. At that point, cancelling the stream should suffice. That can be mapped back into a cancellation of the response.
Readable[Byte]Stream.cancel works only when the stream is not locked. As json()
locks it we cannot abort fetch through stream's methods.
@yukatahirano, I could live with that. .json()
is a function you call to get the entire payload anyway, so having that prevent an abort isn't too surprising. If that turns out to be problematic, add an abort to the thing that it returns too.
differ from @jakearchibald in thinking that cancellation should always result in either a resolved or rejected CancellablePromise
I'm not opposed to cancellation resulting in rejection, as long as you can tell a cancellation-rejection apart from other rejections.
fetch(url).then(r => r.json()).catch(err => {
if (!isCancellation) showErrorMessage();
stopSpinner();
});
@yukatahirano, I could live with that. .json() is a function you call to get the entire payload anyway, so having that prevent an abort isn't too surprising. If that turns out to be problematic, add an abort to the thing that it returns too.
Yeah, every time on autocomplete when I saying to stream what I want response in JSON I do not want to cancel prev. request if user types one more letter. Just add third way to cancel fetch
. And you are saying what fetch
design is not ugly. Okay.
Sorry for jumping back into the discussion so much later.
What we're talking about here is a way to let an observer signal disinterest in the result, and let a promise react to all observers becoming disinterested.
This is exactly why I think it should be called an IgnorablePromise
and should have an ignore
method, rather than an abort
or cancel
method. Note that I'm interested in this on a general level, not just for fetch
but for other promise methods too. As long as you want to chain the promises together there is no way to guarantee that the initial action will be aborted. Instead of suggesting (through the name of the method) that all started actions will be aborted, I believe that ignore
should be used to indicate that the result will not be needed. If the code (through refcounting) can realize that some work is not needed and can indeed be aborted, then that's an added bonus. But the user of the API shouldn't call the cancel
/abort
method expecting the work to be cancelled, since there is no way to guarantee that.
If you have a promise that you need to give to multiple consumers, then you should clone it, just like you have to clone the request/response of fetch. That can easily be done like so:
let parent = fetch(url);
let child1 = parent.then(r => r);
let child2 = parent.then(r => r);
child1.ignore()//child2 will still resolve
@jakearchibald It's up to the producer that receives a cancellation to call reject
with a clear argument, but we should add a CancelledError
to http://heycam.github.io/webidl/#idl-DOMException-error-names to give folks an easy thing to return. This has to be up to the producer because it might not receive the cancellation in time to actually cancel, or it might have failed for a different reason and be cleaning up when the cancellation comes in.
@jyasskin, I agree precisely regarding the error type. Using instanceof
is perfectly good.
As for upstream cancellation, I'd like to expand on the virtues of the .NET CancellationToken
design. While I'll concede that the need for a cooperative cancellation process in a multithreaded system is somewhat diminished in the JS context, the benefits for transparency here are quite significant. In fact, this is considered the primary benefit in the .NET design: https://msdn.microsoft.com/en-us/library/dd997364(v=vs.110).aspx
I'm also concerned that unless all promises are cancellable we well generate surprisingly non-uniform behaviour. Take:
fetch().then(x).abort();
y().then(() => fetch()).then(x).abort();
That alone is surprising, since the second call fails. The actual results can be hard to predict if the call to fetch()
is wrapped.
@NekR the sarcasm isn't helpful. Also, for short responses sending abort messages can take a longer than just receiving the short response. However, I agree that from a developer point of view the request should appear cancelled.
This is exactly why I think it should be called an IgnorablePromise and should have an ignore method, rather than an abort or cancel method
Hm, I think "cancel" is better than "ignore" as the latter doesn't suggest that the underlying operation may cease. Whereas "cancel" can be "cancel my observation" and "cancel the underlying behaviour". Anyway, I'd rather get consensus on the feature design before bikeshedding naming.
If you have a promise that you need to give to multiple consumers, then you should clone it, just like you have to clone the request/response of fetch.
Yep, if you want to retain cancellation but vend a child promise, it's simply cancellablePromise.then()
. If you don't want cancellation it's Promise.resolve(cancellablePromise)
.
That alone is surprising, since the second call fails. The actual results can be hard to predict if the call to fetch() is wrapped.
When calling .catch()
you need to be aware of the chain, so you know which bits you're potentially catching from. I'd argue that the same level of awareness is needed for .abort()
.
FWIW I've implemented a cancelable Promise playground which is already suitable for everything discussed in here: https://gist.github.com/WebReflection/a015c9c02ff2482d327e
Here an example of how it works:
// will be resolved
new Promise(function ($res, $rej, ifCanceled) {
var internal = setTimeout($rej, 1000);
ifCanceled(function () {
clearTimeout(internal);
});
})
// will be resolved without executing
.then(
function () {
console.log('on time');
},
function () {
console.log('error');
}
)
.cancel()
// will simply execute and resolve
.then(function () {
console.log('no time');
});
The Promise is cancelable only if a function to describe how to cancel it is provided. This detail is hidden from the outer world.
In this scenario/case it would virtually look like the following:
function fetch(url) {
return new Promise(function (res, rej, ifCanceled) {
var xhr = new XMLHttpRequest;
xhr.open('GET', url, true);
xhr.send(); // and the rest of the logic
ifCanceled(function () {
xhr.abort();
});
});
}
// outside
var page = fetch('index.html').then(function (text) {
document.body.textContent = text;
});
// at any time later on, if needed
page.cancel().then(function () {
console.log('nothing happened');
});
All involved promises will be silently resolved. The developer has the ability to react and it does not need to expose any cancel-ability if not meant.
The design is backward compatible with current Promise specification, without even requiring a canceled
state.
I'm not sure it's perfect, but it works already and it seems to be suitable for any sort of scenario.
My 2 cents
@WebReflection Thanks for doing this.
var p1 = new Promise(function (resolve, reject, ifCanceled) {
var internal = setTimeout(resolve.bind(null, 123), 1000);
ifCanceled(function () {
console.log('been cancelled');
});
});
var p2 = p1.then(function (val) {
console.log('done1 ' + val);
}, function (err) {
console.log('error1');
});
var p3 = p2.cancel().then(function () {
console.log('post-cancel resolve');
}, function() {
console.log('post-cancel reject');
});
var p4 = p1.then(function(val) {
console.log('done2 ' + val);
}, function() {
console.log('error2');
});
Logs: (live example)
"been cancelled"
"post-cancel resolve"
"done2 undefined"
It feels weird to me that p2.cancel()
would result in the original p1
promise cancelling, affecting other children that didn't cancel and were unable to observe that cancellation. I can't really work out why neither 'done1 ' + val
or 'error1'
is logged, but 'done2 ' + val
is logged, and has resolved with undefined
.
cancel actually cancels up to any promise that hasn't finished yet.
The whole point/ease of Promises is the .then
chain/simplicity so the moment you cancel you want to be sure nothing happening before will reach your promise.
Cancel in my example is the "reset" point and from then on you can use .then
if you want but just as already resolved Promise.
The main misunderstanding I see in your code is that you used ifCanceled
as if it's an onCanceled
or an onRejected
but actually the cancelability should be provided as callback.
Basically that does not work as resolve and reject, that is an optional way to provide cancelability via a function that is responsible to cancel. You are not clearing the setTimeout in there so you provided a callback that does nothing.
Basically you created a cancelable Promise that won't cancel a thing, hence my design that requires you actually do cancel for real because once you've canceled, the Promise is canceled: meaning it cannot possibly be resolved or rejected, it's done, and resolved to avoid the forever pending problem.
It's like invoking reject
after you have invoked resolve
, that will not make your promise rejected.
Accordingly, if you canceled, you canceled.
From the internal Promise world, it's up to you to provide a way to be canceled externally, but you actually delegate the outer world to invoke the .cancel()
method.
You might also want to cancel from the internal world, without having any side-effect of the unknown uter world, hence a way to retrieve the .cancel
method internally too, and the reason all promises before the current one, including the current one, will be silently resolved.
No other value than undefined
came to my mind as default resolution for cancelable promises.
Not sure I've explain myself properly, feel free to ask more or tell me what you think is wrong.
Again, the key here is backward compatible, and the third argument is not the one that resolves or reject, but the one that explicitly expose the ability to be canceled. This is hidden from the outer world, but only if defined, the promise can be canceled: it would throw otherwise (know what you are doing and know what to expect)
as extra clarification, this is how you should write that
var p1 = new Promise(function (resolve, reject, ifCanceled) {
var internal = setTimeout(resolve.bind(null, 123), 1000);
ifCanceled(function () {
clearInterval(interval);
console.log('been cancelled');
});
});
How else could you possibly cancel that promise that would like to resolve in a second? That's the way, if you want resolve to happen then don't setup cancel-ability through the ifCanceled
mechanism.
The main misunderstanding I see in your code is that you used ifCanceled as if it's an onCanceled or an onRejected but actually the cancelability should be provided as callback
I understood. It's the same as the onCancel
in my proposal, except you've put the callback in a much better place. I just wanted to see the order of events even if resolve
was called after cancel.
Again, the key here is backward compatible, and the third argument is not the one that resolves or reject
Yeah, I'm increasingly convinced that .then(… onCancelled)
is a bad idea due to async
/await
, hanging is bad for the same reason. I don't think resolving with undefined
is useful, as that's likely to break code expecting a particular value. Maybe rejecting with undefined
is better (and have that hit each promise down the chain)?
I still like the ref-counting though, as a way to ensure a child cannot break an independent branch of the promise chain.
as that's likely to break code expecting a particular value.
here's the catch: the code that is expecting a value will never be executed once the promise is canceled. It will be silently resolved and ignored for that "predefined time-lapse" that will be resolved and never executed.
Rejecting in my opinion does not really reflect the nature of cancel/ignore intent, but if that's the road then my code becomes way simpler.
Also, rejecting without a reason might be indeed a similar indication that it wasn't a real error but something "ignorable"
Last on ref counting, there's no way my code won't resolve and having silent resolution kinda ensures non breaking branches.
However, since the root is only yes/no and quantum cancel state is not easy to integrate on top of these basis, I start thinking that rejecting might be a better approach.
I might write a different snippet based on such behavior and see how it goes, I still believe the callback to define how to cancel should be private, optional, and eventually defined at Promise initialization time.
I still believe the callback to define how to cancel should be private
Agreed
Anyway, I'd rather get consensus on the feature design before bikeshedding naming.
Sure, I won't bring up naming again.
here's the catch: the code that is expecting a value will never be executed once the promise is canceled. It will be silently resolved and ignored for that "predefined time-lapse" that will be resolved and never executed.
The problem I see is that the next step in the chain might expect something, for example:
fetch(url)
.then(r => r.json())
.cancel() //cancel, the above line resolves to undefined
.then(json => json.something) //this line rejects with an error, because json is undefined
The same applies to rejecting with undefined:
fetch(url)
.cancel()
.catch(logErrorToSomewhere);
This will log a lot of undefined
to a service that is interested in errors on the frontend. With a lot of canceling (for example a search field that does autocomplete) there will be a lot of noise in the catch. To work around this you would need to check for undefined in the catch handlers, which means you need an if check in (potentially) all your catch handlers.
If you cancel you don't expect anything because you canceled. Going through reject
what are you going to expect or why would even set at all that .then
?
Agreed about the catch concern, which is why I went for a silent cancelation.
If you cancel, whoever was interesting in the promise result will never be executed (but all Promises resolved regardless, no side-effect there, that's the goal of my design)
Who canceled, could still do something with that promise, if needed, just to follow the pattern, not because it will exect a value.
However in my initial design .cancel(why)
was working as resolution for the promise, so that you can eventually cancel with a reason that will be passed to the next .then
that is not in the canceled chain, but in the waiting one. Still not sure how much sense it makes.
My example was greatly simplified, it's more likely to look something like this:
inputElement.onkeyup = function(){
search.autocomplete(this.value).then(updateAutocompleteListWithResult);
}
let search = {
active: null,
autocomplete: function(string){
if(this.active) this.active.cancel();
this.active = fetch(`/autocomplete?q=${string}`)
.then(r => r.json())
.then(r => (this.active = null, r), e => (this.active = null, e));
return this.active;
}
}
In this scenario I'm not interested in the result of the autocomplete fetch if a new key up event occurs before the results are ready. In this scenario I'm not interested in neither the resolved nor the rejected value from autocomplete; I don't want updateAutocompleteListWithResult
to be called with anything.
However in my initial design .cancel(why) was working as resolution for the promise
Well, because of the scoping of your cancel function (which looks weird but is great in practice), you have easy access to resolve
and reject
. Maybe, after the cancel callback is called, reject(undefined)
is called automatically. That means the cancel callback could call resolve
/reject
itself. That means the creator of the promise could resolve with a partial value if that makes sense.
My code will not invoke that indeed if canceled. My code silently resolve,
without invoking. Your example will work without problems.
On Mar 30, 2015 2:43 PM, "Marius Gundersen" notifications@github.com
wrote:
My example was greatly simplified, it's more likely to look something like
this:inputElement.onkeyup = function(){
search.autocomplete(this.value).then(updateAutocompleteListWithResult);
}let search = {
active: null,
autocomplete: function(string){
if(this.active) this.active.cancel();
this.active = fetch(/autocomplete?q=${string}
)
.then(r => r.json())
.then(r => (this.active = null, r), e => (this.active = null, e));
return this.active;
}
}In this scenario I'm not interested in the result of the autocomplete
fetch if a new key up event occurs before the results are ready. In this
scenario I'm not interested in neither the resolved nor the rejected value
from autocomplete; I don't want updateAutocompleteListWithResult to be
called with anything.—
Reply to this email directly or view it on GitHub
#27 (comment).
It does make sense to me but I've created something I had to explain
already few times because unknown as pattern. I think this is inevitable
though, we need to re-think promises in order to find a successful cancel
pattern, IMO, cause what we have now does not simply work for that :-)
On Mar 30, 2015 2:47 PM, "Jake Archibald" notifications@github.com wrote:
@WebReflection https://github.com/WebReflection
However in my initial design .cancel(why) was working as resolution for
the promiseWell, because of the scoping of your cancel function (which looks weird
but is great in practice), you have easy access to resolve and reject.
Maybe, after the cancel callback is called, reject(undefined) is called
automatically. That means the cancel callback could call resolve/reject
itself. That means the creator of the promise could resolve with a partial
value if that makes sense.—
Reply to this email directly or view it on GitHub
#27 (comment).
My code will not invoke that indeed if canceled. My code silently resolve, without invoking. Your example will work without problems.
Aha, I've probably misunderstood you all this time. It sounds like we both agree that the state of the cancelled promise should be "cancelled", rather than "fulfilled" or "rejected".
I also think it should not call then
or catch
. If you want to send a reason, then you could do it as @jakearchibald described:
new CancellablePromise(function(resolve, reject, isCancelled){
isCancelled(() => reject("cancelled"));
})
But I don't see how that would work with reference counted cancelling, which is an idea I quite like.
I think you keep misunderstanding my proposal and code. isCancelled
in your example makes no sense, it's exposing the ability to reject
instead which is against Promise principle.
You don't want to expose the ability to resolve or reject, however, you might want expose the ability to cancel on;y if you provide a way to do so
rejecting inside a cancel makes no sense to me, if it's canceled, it's canceled meaning, indeed, not resolved, neither rejected.
Using your snippet you'll end up handling the error per each new autocomplete request. You don't want to do that, you want that nothing happens, you cancel, and you assign a new fetch.
My code provides such ability internally creating a cancel state.
Jake idea was that if silently resolved with undefined, maybe we could actually pass instead a value so that you can f.cancel({})
so that the following then will receive an empty object (simulating in this case an empty JSON response) avoiding any sort of surprise.
While this thread is about fetch(..)
and not explicitly about the async function
functionality, they are extremely symmetric and I think solutions should make sense for both.
If we don't consider both concerns together (or rather, how/if to pair promise observation with upstream cancelation signaling in general), rather than only narrowly thinking about fetch(..)
' API, I think we'll end up at a solution that only works for fetch(..)
and is inconsistent/incoherent compared to how these problems are solved elsewhere.
What's being currently discussed would mean that somehow an async function
would have to be able to declare what its ifCanceled
cleanup code should be in some way... I'm failing to see how that would make any sense:
async function fetchLike(url) {
var response = await ajax(url);
return response.text;
}
fetchLike("http://some.url.1").then(..);
Since the ultimately returned promise is implicitly created by the async function
, I don't see any way that you could specify this ifCanceled
callback to its creation, without some sort of awkward and composition-killing hijacking of its first parameter (as someone on another thread seems to have suggested, of sorts).
FYI I've updated the code so that you can provide a value when canceled.
// will be resolved
new Promise(function ($res, $rej, ifCanceled) {
var internal = setTimeout($rej, 1000);
ifCanceled(function () {
clearTimeout(internal);
});
})
// will be resolved without executing
.then(
function () {
console.log('on time');
},
function () {
console.log('error');
}
)
.cancel({beacuse:'reason'})
// will simply execute and resolve
.then(function (value) {
console.log(value);
});
Invoking cancel again can be done internally (ifCanceled
returns such method) or externally so the first in charge of canceling will be also eventually in charge of passing an arbitrary resolution for such cancellation.
@getify I'm using timers and events to test this stuff, I don't even care much about fetch itself in terms of solution. fetch is just Yet Another Case when you want to cancel something at any time.
providing a canceling mechanism is the only way to go: either (optionally) internally (and that's my favorite, nothing awkward here since it's internally that you resolve or reject) or trough a controller.
Passing a controller around together with a promise in order to cancel seems dumb to me, if you always need boths whhy not just passing a promise with .cancel
ability ?
If you dont' want any of them why not passing a cancelable promise inside a promise so that no cancelability will be exposed ?
The await
problem is also half considered ... where is the catch
in your example?
async function fetchLike(url) {
// where is the catch?
// how do you catch?
var response = await ajax(url);
return response.text;
}
fetchLike("http://some.url.1").then(..);
However, if indeed a value is expected, your example will be as easy as hell to go with .cancel({text:''})
I also believe if await should do exaclty what Promises do, then we have a redundant pattern impostor
I don't even care much about fetch itself in terms of solution
Right, but I think you missed my point, which is that fetch(..)
and others have an explicit promise creation notion, so it's easier to see how the ifCanceled
could be provided to the promise returned from fetch(..)
, but there are other mechanisms like async function
which implicitly create promises, and offer no such mechanism.
That's the major weakness of your idea, IMO, that it only works for explicit promise-creation tasks, but doesn't seem to work for implicit promise-creation tasks.
providing a canceling mechanism is the only way to go
If you're talking about canceling fetch(..)
and async function
and other promise-creating tasks, I agree. If you're talking about canceling the promise directly, rather than indirectly by virtue of the task being canceled, I don't agree. I don't think it follows at all that promises have to be cancelable to achieve the clearly agreed ideals that fetch(..)
should be cancelable.
if you always need boths whhy not just passing a promise with .cancel ability ?
That's precisely the point I've made many times in this thread, that you don't always need both, and in fact it's dangerous to system trustability to always have both. The advantage of the controller with separate observation and cancelability is that in places where you need both, you pass the controller, and in places where that's a bad idea, you pass only the promise (or only the cancelation).
where is the catch in your example?
I think you're suggesting this:
async function fetchLike(url) {
try {
var response = await ajax(url);
return response.text;
}
catch (err) {
// ..
}
}
fetchLike("http://some.url.1").then(..);
Of course, you can do that... but the catch
clause here in no way shape or form could be invoked by the .cancel(..)
call made down on the promise chain, unless you're suggesting conflating cancel
with throw
? The catch
in this example is triggered if there's an error during ajax(..)
, not from what happens with the outside promise chain.
It is precisely because of the limitations of promises that there is currently a proposal to replace async/await with a more extensible syntax that can apply to other types such as as .NET-style Task. Providing syntactic support for promises encourages people to use them where they are inappropriate. Fetch is an excellent example of an API that should not use Promises, because it involves the use of a scarce resource (ie connections).
Promises are a very well articulated concept. They are the asynchronous equivalent of a value returned by a synchronous function. When you call a function synchronously it cannot be canceled. Rather than try and evolve a Promise into something else, or inappropriately use it in order to get better language support, why not invent a new type that is just as compositional as a promise, but has the required semantics for fetch?
A reference-counted Task provides the necessary semantics for fetch. Rather than providing a guarantee of cancellation, you can simply give the producer the ability to determine whether anyone is listening for the outcome of an asynchronous operation. If all consumers stop listening for the outcome, the producer can have the opportunity to cancel the operation because it is not observable. In my opinion this is the global maximum, because consumers do not have to concern themselves with cancellation errors. The request can only be canceled if there are no listeners, which means that when the request is finally canceled, no one is around to hear it. Reference counting also ensures that consumers do not need to be made aware of other consumers.
This approach works very well for Netflix which often uses ref-counted scalar Observables of 1 to represent async operations. We would not use a Promise for asynchronous requests in the browser because there are too many UI interactions that require rapid creation and cancellation of pending data requests (autocomplete box being the most common).
JH
On Mar 30, 2015, at 4:56 PM, Kyle Simpson notifications@github.com wrote:
I don't even care much about fetch itself in terms of solution
Right, but I think you missed my point, which is that fetch(..) and others have an explicit promise creation notion, so it's easier to see how the ifCanceled could be provided to the promise returned from fetch(..), but there are other mechanisms like async function which implicitly create promises, and offer no such mechanism.
That's the major weakness of your idea, IMO, that it only works for explicit promise-creation tasks, but doesn't seem to work for implicit promise-creation tasks.
providing a canceling mechanism is the only way to go
If you're talking about canceling fetch(..) and async function and other promise-creating tasks, I agree. If you're talking about canceling the promise directly, rather than indirectly by virtue of the task being canceled, I don't agree. I don't think it follows at all that promises have to be cancelable to achieve the clearly agreed ideals that fetch(..) should be cancelable.
if you always need boths whhy not just passing a promise with .cancel ability ?
That's precisely the point I've made many times in this thread, that you don't always need both, and in fact it's dangerous to system trustability to always have both. The advantage of the controller with separate observation and cancelability is that in places where you need both, you pass the controller, and in places where that's a bad idea, you pass only the promise (or only the cancelation).
—
Reply to this email directly or view it on GitHub.
If all consumers stop listening for the outcome
The problem I see with this implicit GC-directed version of cancelation is that the producer very quickly stops being in control of who is observing the outcome.
If you make a fetch(..)
call, and pass its promise/Task/whatever return value around to multiple observers in your system, and then something critical happens and you (the producer) decide you need to cancel the fetch(..)
(abort an upload b/c user goes offline, etc), you can't. You can't reach into all those possible places where observers attached and get all of them to cancel(..)
or ignore(..)
or even just unset themselves for GC purposes.
Also, GC is not guaranteed to happen as soon as all refs are unset. If you want to abort a fetch(..)
right now, and you unset all observers, the engine may not GC all those for seconds or more, which means the abort may very well not happen immediately.
That's precisely the point I've made many times in this thread, that you don't always need both
and that's my point and implementation too. You expose the cancel-ability only if you define a method to cancel. Actually my code goes further than that, if you don't internally define a method to cancel and you use p.cancel()
just for fun, you gonna have an error because I am proposing indeed backward compatibility.
The implicit cancellation suits perfectly fetch
because it doesn't matter what happens in the core, behind the scene, as long as we know the object can be canceled, either via contrller or .cancel()
we are good.
My explicit promise creation is like that because as a user, I want to be also able to create cancel-able Promises and there it goes: if core or external APIs provides cancel-able promises, you can cancel them ... otherwise you cannot, as easy as this sound.
The cancel
as reject
is a Jake idea and since I've said my code would have been simplified in that way, here I come with the example that rejects through cancel: here it is
In this case the .cancel
rejects but then it breaks the whole chain so that this will stop at the first catch, although resolving everything without invoking.
new Promise(function ($res, $rej, ifCanceled) {
var internal = setTimeout($rej, 1000);
ifCanceled(function () {
clearTimeout(internal);
});
})
.then(
function () {
console.log('on time');
},
// we'll end up here
function () {
console.log('error');
}
)
.cancel({beacuse:'reason'})
// never executed
.then(function (value) {
console.log(value);
});
If you are careless about errors, this might work ... but ... I honestly prefer my first idea, creating a mark-point in the chain where everything happened before will be ignored but whoever cancel has the ability to react after, eventually providing the resolution.
Anyway, we have 2 playgrounds now.
This is a very reasonable concern in principle, but in my experience is not a problem in practice. Perhaps this is a matter of not clearly defining what I mean by producer and consumer. In this context by producer I mean "fetch" which controls the code which backs the Task. By consumer I mean the code that assigns a callback to a Task and receives a subscription which can be used to stop listening for the result:
var task = fetch(...);
var subscription = task.get(value => console.log(value), error => console.error(error));
// subscription.dispose() can be used to stop listening
In this context the producer (fetch) does not cancel unless there are no more consumers. Fetch doesn't have sufficient context to cancel a request. As for garbage collection, disposing of the subscription removes the reference from the task to the handlers passed to get. This breaks the link and allows GC to occur.
Can you clarify what you mean by producer? All application use cases I have encountered in UIs can be accommodated with the notion of consumer unsubscription. An event may occur which may cause a consumer to stop listening for the result of a task, such as a form being closed. In these situations it can be the consumers responsibility to explicitly unsubscribe from a Task when events occur which cause their eventual data to become irrelevant. This can even be done declaratively with compositional functions as in the example below:
var task = fetch(...);
var subscription =
task.
until(formClosed). // another Task or Promise
get(value => console.log(value),
error => console.error(error));
JH
On Mar 30, 2015, at 5:21 PM, Kyle Simpson notifications@github.com wrote:
If all consumers stop listening for the outcome
The problem I see with this implicit GC-directed version of cancelation is that the producer very quickly stops being in control of who is observing the outcome of the task. If you make a fetch(..) call, and pass its promise/Task/whatever return value around to multiple observers, and then something critical happens and you (the producer) decide you need to cancel the fetch(..) (abort an upload b/c user goes offline, etc), you can't, because you can't reach into all those possible places where observers attached and get all of them to cancel(..) or ignore(..) or even just unset themselves for GC purposes.
Also, GC is not guaranteed to happen as soon as all refs are unset. If you want to abort a fetch(..) right now, and you unset all observers, the engine may not GC all those for seconds or more, which means the abort may very well not happen immediately.
—
Reply to this email directly or view it on GitHub.
Agreed that ref count would be problematic for few reasons:
- GC exposed and also unpredictable in its execution
.then
is a pattern that favorites chainability and for some reason people love chainability, meaning it's not so easy to count down to zero within a chain, there'll always be someone at the end, unreferenced, and already attached, am I right? So how the initial fetch is supposed to abort since everyone unable to stop it can detach itself? Is is about getting rid of the initially assigned fetch? And what if I am an asshole that keeps one of the chained thenable referenced? Is everyone else screwed?
In any case, this perfectly summarizes my general disappointment with Fetch
Fetch is an excellent example of an API that should not use Promises, because it involves the use of a scarce resource (ie connections).
But since we are here ... I guess it's too late so let's try to be pragmatic and cover as many cases as possible, keeping in mind the initial ease goal that was fetch.
Shall we?
Can you clarify what you mean by producer?
Since fetch(..)
itself is an opaque utility, it's not the "producer" I mean. It's a tool the "producer" uses.
By "producer" I really mean the code which actually makes the initial call to the fetch(..)
call (or whatever API)... that may very well be the main consumer of the response. If it's the only consumer, it's no big deal.
But this "producer" code also may very well send that promise to other parts of the system, either directly, or indirectly as a sub-promise chained off the main returned promise. It's these other parts of the system which would have reference (refcount) that the main "producer" would not be able to undo if it later needed to trump the system and say "hey, gotta abort."
I regularly model my systems where I take a single promise from some util and pass it around to various observers in the system as a token to let them know "it's ok to move on with what you were doing" (like a really lightweight event subscription). But I also have cases where the initial "producer" of that promise needs to take over and yell "stop the presses!". It's much harder to design such a system if I also need all those observer parts of the system to expose some heyStopListeningToThatThingISentYouEarlier(..)
API.
I believe it is much easier (and more desirable) to add a version of fetch that returns a Task than it is to change the definition of Promise.
I'm afraid I've been unclear about what I mean when I say reference counting. What I really mean is subscription counting. Every task can keep a simple counter of the number of subscriptions that exist. This mechanism is totally outside of the garbage collector and implemented in plain old JavaScript. Because it is necessary for each consumer to explicitly unsubscribe, there is a clear opportunity to decrement the counter.
The Task I propose would have a "then" method which would have the same signature as a promise. However it would also be lazy and also have a get method which was used to actually retrieve the data. "Then" would not trigger the request, just create a new Task. The get method would be used to actually retrieve the data. You can see an example (minus the reference counting) here:
https://github.com/jhusain/compositional-functions/blob/master/README.md
Now subscription counting is simply a matter of counting the number of get calls in the system, not the number of references to the Task. This is how the Observables in Rx work, using flatMap for Compositon and forEach for consumption.
JH
On Mar 30, 2015, at 5:45 PM, Andrea Giammarchi notifications@github.com wrote:
Agreed that ref count would be problematic for few reasons:
GC exposed and also unpredictable in its execution
.then is a pattern that favorites chainability and for some reason people love chainability, meaning it's not so easy to count down to zero within a chain, there'll always be someone at the end, unreferenced, and already attached, am I right? So how the initial fetch is supposed to abort since everyone unable to stop it can detach itself? Is is about getting rid of the initially assigned fetch? And what if I am an asshole that keeps one of the chained thenable referenced? Is everyone else screwed?
In any case, this perfectly summarizes my general disappointment with FetchFetch is an excellent example of an API that should not use Promises, because it involves the use of a scarce resource (ie connections).
But since we are here ... I guess it's too late so let's try to be pragmatic and cover as many cases as possible, keeping in mind the initial ease goal that was fetch.
Shall we?
—
Reply to this email directly or view it on GitHub.
Now subscription counting is simply a matter of counting the number of get calls in the system
Unless I've missed something, I'm not sure how that changes any of my assertions about the difficulty that such things incur when you've passed this token (call it a "Task" or "promise" or whatever) around to multiple different parts of a system for observation. That's especially true if one of the places you pass such a token to is an external part of the system (a third party lib, etc), where you cannot even change their API to allow for explicit unsubscription.
I've got the feeling that the moment we use .subscribe
and .unsubcribe
we're virtually back in addEventListener
and removeEventListener
world with just an internal counter per listener and magic cancellation provided.
But AFAIK not using Promise wasn't an option ... now I've provided 2 playgrounds so I stop making noise.
Say you have a ServiceWorker with:
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch('/whatever.json').then(function(response) {
return response.json();
}).then(function(data) {
return fetch(data.url);
})
);
});
Say this request is in progress and the user hits X in the browser UI, closes the tab, or navigates. The browser can cancel the stream once the promise it gets resolves, but it cannot abort either fetch or the .json
read of the first fetch.
A chaining cancellable promise solves this problem, as the browser calls cancel, and the currently in-progress part of the promise chain is terminated.
Counter to some of the complaints in this thread, this is a case where you do want the receiver of the promise to have cancellation rights.
this is a case where you do want the receiver of the promise to have cancellation rights.
There are definitely places where you want the cancelation capability to be propagated. No question. There are definitely others where you don't. Composing observation/cancelation means you don't have this choice. The choice is critical.
So why my proposal that exposes cancel-ability only if internally provided wouldn't work, exactly? I'm not sure I follow the problem. There are places where you need/want to expose cancel-ability, you go for it. Is this too KISS?
why my proposal that exposes cancel-ability only if internally provided wouldn't work, exactly?
You never addressed my earlier concern that a general implicitly-creating-promise type mechanism, like async function
, provides no clear way to tell the promise "make yourself cancelable" in any useful way.
fetch(..)
can do so by adding some explicit parameter to its API, which it automatically funnels into its promise. But a general async function
cannot, not without ugly side-effects on composability/signature design.
Say this request is in progress and the user hits X in the browser UI, closes the tab, or navigates. The browser can cancel the stream once the promise it gets resolves, but it cannot abort either fetch or the .json read of the first fetch.
There are probably other places where this same sort of thing applies, but for this particular case the browser has other mechanisms to cancel these operations. At least, in gecko we do.
The await mechanism should silently return without keeping executing the awaited function. Same as I silently resolve all Promises in the chain. Does this answer? You are also asking returns for Generators, right? That's the idea
Does this answer?
No, it doesn't answer my concern. The promise returned externally by the async function
is not at all related to any of the intermediate promises that are await
'd internally. I don't see how my concern and what you're asserting are related. Can you clarify?
Specifically, how will you conditionally decide to tell this function's promise that it should be of the cancelable kind?
async function foo() {
// .. what do we do here?
}
foo().then(..).cancel();
It's not fully specified yet so I'd say it would work similar as generators do ... you externally invoke .next(value)
there and receive the state.
If you have an async
function in a world where there are cancel-able promises I don't see why foo().cancel(resolve);
shouldn't implicitly invoke internal holded ajax(url).cancel(resolve)
too and throw if that's not cancelable since there's nothing to cancel.
My proposal works well with cancelable things if these are cancelable, otherwise it throws regardless. How to know if an API is cancelable? Same way you know you can use any method from any kind of known API/object.
About foo().then(...).cancel(resolve);
? Same exact thing, if you start from a cancelable Promise, all derived Promises (then/catch) are cancelable too.
I might miss something about your concern, or maybe over-simplify something I don't see as concern.
The road otherwise is to have Promises problems in ES7 too ... that does not sound brilliant to me.
you externally invoke
.next(value)
there
Where do you invoke this? On the return value from an async function
? That's currently thought of as being a normal promise, not an iterator, so next(..)
call there wouldn't make any sense.
It's also not clear how that addresses what you're talking about, or answering my direct question: In your view of cancelable promises as being decided based on what you pass into the promise constructor, how do you do that when the promise is constructed implicitly behind the scenes?
The .next was compared to .cancel as method you invoke outside. Calling
cancel on an async function means implicitly invoke cancel to the
cancelable promise hold in await. About constructing cancelable promise is
not your concern. Fetch will do what it has to do, you are not responsible
for that, the API internally knows how to cancel. If you want to make a
promise cancelable you do that, of you don't want a cancelable promise you
don't. Better?
On Mar 30, 2015 8:01 PM, "Kyle Simpson" notifications@github.com wrote:
you externally invoke .next(value) there
Where do you invoke this? On the return value from an async function?
That's currently thought of as being a normal promise, not an iterator, so
next(..) call there wouldn't make any sense.It's also not clear how that addresses what you're talking about, or
answering my direct question: In your view of cancelable promises as
being decided based on what you pass into the promise constructor, how do
you do that when the promise is constructed implicitly behind the scenes?—
Reply to this email directly or view it on GitHub
#27 (comment).
Better?
Sorry, I'm still lost. Perhaps you're missing the fact that the promise which comes back from an async function
call is not the promise you await
inside. Totally diff promises. The external one is implicitly created by the JS engine at the time the async function
starts, and is returned back completely unawares to the code inside the async function
.
Calling cancel on an async function
There is no such thing. You don't call foo.cancel(..)
, you call foo() ... .cancel()
, which means you're calling cancel(..)
or next(..)
or whatever on what that return value is. As design stands, that value is a promise.
We're obviously bogging down this thread and way off track. If you'd like to explore async function
s more, let's go elsewhere. The point that will remain here is that your proposed solution does not have a symmetry which works in that case, which is my main concern WRT this thread.
It's really hard to follow all the things here by now. But can we all agree at least of basic concept things before actual implementation details?
fetch(...).cancel()
should be able cancel request and response (no matter for now how it all will be integrated with Promises/Streams)fetch(...)
promise cancelability should be chainable. It's hard to agree for now how it will be implemented, but it seems what all in this thread (and I believe all other developers will) expect such behavior.- As
fetch(...).cancel()
is chainable thenfetch
also should have some grouping operators for it.fetch.all(...)
orCancelablePromise.all(...)
does not really matter now. - All those stuff should absolutely fine works with
async
/await
(which I believe should work by default since all this API is around Promises).
Any comments about these things? I would like to hear concept comments, where I was wrong, etc. No implementation stuff. Once we have final conclude on concepts, then we can discuss actual implementations.
But can we all agree at least of basic concept things
You purport to describe things in basic concepts, but then you choose a specific solution (promise instance cancelation) for all your assumptions. I object. Not that my objection carries any weight.
My summary of "basic concept things" that seems more sensible and less limiting would be:
fetch(..)
should be cancelable in some way, and that cancels the request and any response "observers".- If the
fetch(..)
action is canceled, the entire down-stream chain of its "observers" must be canceled/aborted/rejected/etc, not just the first level. Ideally this cancelation would be observable down-stream (either as a rejection or as a third state). - The cancelation capability should be something that go wherever the "obvservable"-for-response can go (whether that be as separate values, composed in a single object, or combined into promises).
- Should make sense, and be equally effective, with
async..await
and any other (future) language mechanism that either explicitly or implicitly creates promises to represent the completion of a task.
Just to be clear @getify, I am not talking here about "concepts" in general, but rather about "implementation concepts" and not "implementation details of those concepts". Main goal of this is to stop flying around few differing concepts arguing with implementations details, many heuristics, etc. We need to choose something already here, otherwise it will never end.
So, let's me comment your items, but please, do not go into "I believe this is concept, but this is not". Let's agree on things which easy to decide (some high-level concept over upcoming implementation of them).
fetch(..) should be cancelable in some way, and that cancels the request and any response "observers".
This is exactly that thing about I am talking. We flying around few concepts of should it be promise or not, chainable or not, blah or not-a-blah. We need to decide and then go on.
If the fetch(..) action is canceled, the entire down-stream chain of its "observers" must be canceled/aborted/rejected/etc, not just the first level. Ideally this cancelation would be observable down-stream (either as a rejection or as a third state).
This is implementation detail about how it should be chainable. I am ask about agreement what it should "chainable", implementations things "how" will follow later.
The cancelation capability should be something that go wherever the "obvservable"-for-response can go (whether that be as separate values, composed in a single object, or combined into promises).
Cannot understand this, but please do not continue.
Should make sense, and be equally effective, with async..await and any other (future) language mechanism that either explicitly or implicitly creates promises to represent the completion of a task.
I believe this is same as I wrote in my last item, but just in other words. I can live with this sentence if this makes you more comfortable.
The external one is implicitly created by the JS engine at the time the async function starts, and is returned back completely unawares to the code inside the async function.
If we have cancel-able Promises in place for ES7, the external one you mention will be implicitly created by the JS engines as cancelable so that whatever is on hold internally through await
will receive, implicitly, the .cancel(value)
invoke whenever it happens explicitly outside the async
function.
I really don't have any better way to explain this, but you should really try to open your mind 'cause of course this pattern is not possible yet, which is why we are here: to improve, not to re-iterate the already uncancel-able idea behind.
Accordingly ....
There is no such thing. You don't call foo.cancel(..), you call foo() ... .cancel(), which means you're calling cancel(..) or next(..) or whatever on what that return value is. As design stands, that value is a promise.
There could be such a thing if we move on, so that you can have cancel-able Promises and everything I've already coded already works and makes sense.
If you'd like to explore async functions more, let's go elsewhere.
I've never even brought them up so you should really probably discuss them somewhere else, if you are still confused by my Cancelable Promise solution that integrates perfectly with something not even standard yet: async
and await
. Are you willing to not talk about async and await as if these were long standing standard patterns? 'cause no spec is defined yet for them, regardless written books or blogposts.
We must be able to fix things before these are out, not be stuck with documentation about partially defined standards. Agreed? I hope so ...
@NekR I'm honestly off philosophy because we have a real need and clock is ticking.
I've created 2 playgrounds few here keep ignoring: one that cancels through reject and it ends up in the first catch instead of keep going as a cancel behavior I'd expect, and one that silently resolves everything without executing a thing until the cancel and provides a way to resolve with arbitrary data from that point on. More convolute in terms of specs, but the best/surprise-free behavior I could imagine.
Both woudl work with async and await, as long as we consider async and await not finalized standards.
Otherwise it's pointless being here because everything, even controller, will fail with async and await raw syntax.
@NekR I'm honestly off philosophy because we have a real need and clock is ticking.
I agree with, but that discussion seems going nowhere (all things repeated again and again, including other threads) and I cannot understand why. So I just tried to summarize things with hope what it might help all to focus.
@getify quite sad statement ... we just need to better understand each other I guess ... so here the scenario I am describing against async
and await
:
async function fetchLike(url) {
try {
var response = await ajax(url);
return response.text;
}
catch (err) {
// ..
}
}
var asyncPromise = fetchLike("http://some.url.1");
// after this ...
// var response = await ajax(url);
// we are virtually here^
// but now we explicitly to this
asyncPromise.cancel({});
// ajax(url).cancel({});
// the engine is implicitly doing ^^^^^^^^^^^^
// since canceling, the engine is also
// executing this instead of the original function
async function fetchLike(url) {
try {
var response; return ajax(url).cancel({});
// see this ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// that's what a cancel-able await does
return response.text;
}
catch (err) {
// ..
}
}
// outside, in a better world ...
asyncPromise.then(function (value) {
// empty result, nothing to do
return value; // keep going, if needed
});
That works to me, it's like the return inside a generator, not so different at all.
Do you need to know what happens inside ajax(url)
when ajax(url).cancel({})
happens? NO, because that's the whole point. If you provide a cancelable Promise, you are the ony one that can resolve or reject it internally, so you are the only one able to cancel.
You provide such functionality? Others can consume it. It's exactly all the same with controllers, except you pass around directly a cancel-albe Promise instead of the Promise plus its controller.
Do you want to hide this ability?
async function fetchLike(url) {
try {
var response = await Promise.resolve(ajax(url));
// check this out ^^^^^^^^^^^^^^^^^^^^^^^^^^^
return response.text;
}
catch (err) {
// ..
}
}
You know what's the result? That if you try to cancel fetchLike
now, since Promise.resolve
does not return a cancel-able Promise, it will throw an error, 'cause you are not allowed.
So this pattern covers every scenario discussed in here, or am I missing anything at all?
If the question is again: how do you cancel that from the outer world? ... the answer would be again: You don't! You are not responsible for cancel-ability because as the Promise creator provides resolve
and reject
, the Promise creator is the ony one that can provide a cancel
too without exposing its mechanism.
I rather prefer some sort of agreement instead of abandonment. This is the future of the asynchronous Web, this must be done bloody right! (and I feel it's too late already)
@WebReflection, I'm starting to come around to the idea that cancellation has some sort of retroactive action. I would still (rather strongly) prefer that this not be done without cooperation from the code that is being affected by this change. That said, my objections have been addressed, at least from a technical standpoint.
I'm still totally against the idea that cancellation would manifest as success for several reasons. If you have a long chain of fetches, you have no way of knowing which you are targetting with this, so rejection is the only way to ensure that you don't cause real problems.
Honestly, I never considered anything other than rejection as an option here.
I'm a little perplexed at the notion that you might somehow know to guard against cancellation. If this is the right thing to do, then it should work uniformly and Promise.resolve()
would be insufficient; though creating a new unresolved dependency might be OK, even if that dependency was a noop.