Skip to content

New Proposal: Making Fetch Promises work better (i.e. abort-able) and with other APIs(e.g. Promise Combinators) #1831

Open
@isocroft

Description

@isocroft

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)

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:

  1. The AbortController object can only cancel promises that have already started.
  2. Aborting immediately completed promises has unpredictable outcomes at best (it should be a silent no-op).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions