Description
What is the issue with the Fetch Standard?
Current Issue(s)
One of the major downsides of certain Promise combinators (specifically Promise.allSettled(...)
, Promise.any(...)
and Promise.all(...)
) is that if any of the promises in the array of promises passed to a Promise combinator rejects, all other promises MUST run to completion and be settled in the background by the Event Loop. This can lead to leaked side-effect from a structured concurrency perspective. In a worst case scenario, it can lead to the await-event horizon issue.
Hence, canceling asynchronous tasks (2 or more) often involves nesting promises, which can make the code more complex and harder to read. The structure of said nested promises may become complex, especially when dealing with multiple (or even a single) abort-able operations.
Affected System of APIs
ReadableStream: These APIs can benefit immensely from my proposal here (stream cancellation for ReadableStream)
- pipeTo and could be paired with this future proposal for the pipeTo() method
- cancel
Preamble Based on Past Conversation(s)
I understand that promise cancellation is quite tricky and has been vehemently opposed in the past. However, i do believe there's a way to come back from this unfortunate decision and innovate forward using AbortSignal
& AbortController
in a sustainable manner. My meaning is as follows:
- The
AbortController
object can only cancel promises that have already started. - Aborting immediately completed promises has unpredictable outcomes at best (it should be a silent no-op).
- Trying to abort requests already in progress might pose challenges (with the current design for Promises).
Today, we have come a long way from the early days of the CancelToken proposal.
Furthermore, AbortControllers are not as compose-able as AbortSignals. The AbortSignal.any(...)
API is apt for composing signals. Yet, a single AbortController
object is only available within the function it is created in, limiting its use for aborting asynchronous operations outside the function it was created in. My point is a single AbortController
object cannot be composed easily over other AbortController
objects.
Also, If the AbortController
object were placed in a larger lexical scope, it is very difficult to write well-structured and easy-to-read code to allow one AbortController
abort either of two or more concurrent tasks that are racing. See this code snippet.
Goal
All these issues i believe are solvable if every instance of a Promise had an abort()
method (which will be a silent no-op when called if the promise is settled). The goal is to create promises that are abort-able.
Half-decent Solution
A half-decent way to make promises abort-able will be to create a safe(...)
function (using a single signal) like so:
function safe (promise, signal) {
return Promise.race([
promise,
new Promise((_, reject) => {
signal.addEventListener('abort', () => {
reject(typeof signal.reason !== "undefined" ? signal.reason : new Error("Abort Error"));
});
})
]);
}
The safe(...)
can be used as such:
function myTransform(yourPromise, signal) {
if (!(signal instanceof AbortSignal)) {
return Promise.reject(
new Error("signal needed; promise cannot be safely aborted...")
);
}
return safe(yourPromise, signal)
.then(value => safe(transform(value), signal))
.then(value => safe(transform2(value), signal));
}
However, this solution is bulky and requires that you stitch and thread the signal
through every point in the promise chain.
Viable Solution
This leads me to my proposal proper. What if we created one AbortController
per Promise
and stitched through the promises (i.e. promise.signal
). Please, read on.
I propose an amendment to the way the Promise Constructor is setup which can be coded in a backwards compatible way.
See below:
const promise = new Promise((resolve, reject, signal) => {
/* @HINT:
This `controlSentinel` is to ensure that we never reject due to
an "aborted promise" after it has been resolved or vice versa
*/
let controlSentinel = 0;
const timerId = setTimeout(() => {
if (typeof timerId === "number") {
clearTimeout(timerId);
}
if (controlSentinel !== 0) {
// Control how aborting the promise impacts ongoing concurrent operation (1)
return;
}
try {
// Control how aborting the promise impacts ongoing concurrent operation (2)
if (signal) {
signal.throwIfAborted();
}
} catch (e) {
return reject(e);
}
controlSentinel = 1;
resolve(true);
}, 3400);
if (!signal) {
// Backward compatibility with native definitions without shim.
return;
}
if (!signal.aborted) {
signal.addEventListener('abort', () => {
if (typeof timerId === "number") {
clearTimeout(timerId);
}
if (controlSentinel !== 0) {
return;
}
controlSentinel = -1;
reject(signal.reason || this.signal.reason);
});
}
});
// Abort the promise
promise.abort();
const controller = new AbortController();
// Set a signal on the promise
promise.signal = AbortSignal.any([
AbortSignal.timeout(4000),
controller.signal
]);
// Stitch signal on another promise
const promise2 = new Promise((_, reject, signal) => {
signal.addEventListener('abort', () => {
reject(signal.reason);
});
});
promise2.signal = promise.signal;
The above code was the result of lots of research from past efforts and my own research as well. I do not believe that cancellation should be a third state of a promise. If we take a look at jQuery Deferreds (which are quite similar to JavaScript promises in how they operate), the notify(...) or notifyWith(...) APIs do not send the deferred into a third state.
Per the issue of a race between downward-settlement and upward-cancellation, i don't believe that cancellation should propagate upwards at all. Cancellation should only apply to the promise it was triggered (i.e. promise.abort()
) on. If anything, cancellation should propagate downwards (just like settlement) but from the point it (i.e. the promise that was aborted) was called on the promise chain (using promise.signal
as shown above).
Implemented Proposal (via Polyfill/Shim)
Polyfill current native implementation of
window.Promise(...)
in a backwards compatibility manner (👇🏾)
;(function (global) {
if (typeof global.AbortSignal === "undefined") {
global.AbortSignal = {};
}
if (global.AbortSignal.timeout !== "function") {
global.AbortSignal.timeout = function timeout (durationInMillis = 0) {
var ctrl = new AbortController()
setTimeout(() => ctrl.abort(), durationInMillis);
return ctrl.signal;
}
}
if (global.AbortSignal.any !== "function") {
global.AbortSignal.any = function any (arrayOfSignals = []) {
if (Array.isArray(arrayOfSignals)) {
var ctrl = new AbortController();
for (signal of arrayOfSignals) {
if (typeof signal['throwIfAborted'] !== "function"
&& signal['addEventListener'] !== "function") {
continue;
}
signal.addEventListener('abort', () => ctrl.abort());
}
return ctrl.signal;
}
}
}
var nativePromiseConstructor = global.Promise.toString().includes('[native code]')
? global.Promise
: null;
if (nativePromiseConstructor === null) {
// Maybe don't polyfill a polyfill "😂"
return;
}
global.Promise = function Promise (callback) {
if (typeof callback !== "function") {
throw new TypeError(
"Promise resolver " + JSON.stringify(callback) + " is not a function"
);
}
/* Prefixed with an underscore character: assume private members */
this._control = new AbortController();
this._settled = false;
this._signal = null;
var BoundConstructor = nativePromiseConstructor.bind(this);
var promiseVal = new BoundConstructor((resolve, reject) => {
callback.apply(this, [resolve, reject, this.signal]);
});
promiseVal.finally(() => {
this._settled = true;
});
return new Proxy(this, {
set(target, prop, value) {
if (prop === "signal") {
target[prop] = value;
}
if (prop !== "abort") {
throw new Error("Cannot set property: '"+prop+"' on Promise instance");
}
},
get(target, prop, receiver) {
switch (prop) {
case "signal":
return target.signal;
case "_control":
case "_settled":
case "_signal":
return undefined;
case "abort":
return target.abort.bind(target);
default:
return prop in promiseVal ? Reflect.get(promiseVal, prop, receiver) : target[prop];
}
},
});
}
Promise.reject = nativePromiseConstructor.reject.bind(nativePromiseConstructor);
Promise.resolve = nativePromiseConstructor.resolve.bind(nativePromiseConstructor);
if (typeof nativePromiseConstructor.all === "function") {
Promise.all = nativePromiseConstructor.all.bind(nativePromiseConstructor);
}
if (typeof nativePromiseConstructor.allSettled === "function") {
Promise.allSettled = nativePromiseConstructor.allSettled.bind(nativePromiseConstructor);
}
if (typeof nativePromiseConstructor.race === "function") {
Promise.race = nativePromiseConstructor.race.bind(nativePromiseConstructor);
}
if (typeof nativePromiseConstructor.any === "function") {
Promise.any = nativePromiseConstructor.any.bind(nativePromiseConstructor);
}
Promise.prototype.abort = function abort (reson = null) {
if (!this._settled && !this._control.signal.aborted) {
return this._control.abort(reason !== null ? reason : undefined);
}
// Silent no-op (especially for immediately completed/settled promises)...
}
Object.defineProperty(Promise.prototype, 'signal', {
enumerable: false,
configurable: true,
get () {
if (this._signal === null) {
return this._control.signal;
}
return this._signal;
},
set ($signal) {
if ($signal instanceof AbortSignal) {
var currentSignal = this._signal || this._control.signal;
this._signal = AbortSignal.any([
currentSignal,
$signal
]);
if (!$signal.aborted) {
$signal.addEventListener('abort', () => {
this.abort();
});
}
}
}
});
Promise.prototype.then = nativePromiseConstructor.prototype.then.bind(Promise.prototype);
Promise.prototype.catch = nativePromiseConstructor.prototype.catch.bind(Promise.prototype);
Promise.prototype.finally = nativePromiseConstructor.prototype.finally.bind(Promise.prototype);
}(window));
Subsequently, this new API for the Promise
constructor defined above can be used as follows: (👇🏾)
const p1 = new Promise((_, reject, signal) => {
signal.addEventListener('abort', () => {
reject(signal.reason);
});
});
const p2 = fetch(url, { signal: p1.signal }).then(response => {
const p3 = drainStream(
response.body.pipeThrough(new StreamingDOMDecoder())
);
p3.signal = p1.signal;
p3.signal.addEventListener('abort', () => {
response.body.cancel(p3.signal.reason);
});
return p3;
});
setTimeout(() => {
p1.abort();
}, 5400);
Additionally, window.fetch
can be updated (without breaking backward compatibility) such that the signal
property on the second argument RequestInit when passed in is written to promise.signal
such that when the signal
is aborted, the logic in the promise resolver function for window.fetch
can further be used to abort the HTTP request (on a granular level) by destroying the TCP socket instance (or a silent no-op if the HTTP request is finished/completed already) since parts of the window.fetch
system of APIs (e.g. read-streams) are cancellable. See also this PR on the nodejs project for reference.
Finally, Promise combinators (except Promise.allSettled(...)
) can use the promise.abort()
API to abort pending promises when any promise among the array of promises passed rejects early.