proposal-array-from-async: What happens with rejections in synchronous iterables?

Are synchronous iterables/array-likes being iterated synchronously or asynchronously as they would in a for await? What is the expectation for a rejection within a sync collection that settles before to other promises prior? In a for await it goes unhandled. Would Array.fromAsync() be able to account for that and reject its returned promise or would it to allow for the unhandled rejection to slip through?

const delay = (ms, reject = false) => new Promise((r, j) => setTimeout(reject ? j : r, ms, ms));

for await (let ms of [delay(1000), delay(3000), delay(2000, true)]) {
  console.log(ms) // 1000, (unhandled error 2000), 3000
}

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Comments: 19 (16 by maintainers)

Commits related to this issue

Most upvoted comments

As I mentioned in https://github.com/tc39/proposal-array-from-async/issues/16#issuecomment-995463592, I agree with @ljharb and @mhofman. I think we should continue to use Async-from-Sync iterators (i.e., for await semantics).

The current semantics are such that, if b() will reject before a() resolves, then Array.fromAsync((function * () { yield a(); yield b(); })()) will still eventually return a promise that will eventually reject with b()’s error. However, b’s error intentionally cannot be caught by fromAsync due to its lazy iteration, which makes simultaneously awaiting promises, in parallel, impossible.

The expectation is that, as usual, the creator of the b() promise will catch b()’s rejection wherever b() is created (e.g., at yield b()).

Array.fromAsync already resembles for await in nearly every way, just as Array.from resembles for, and we want to continue this analogy. Like @ljharb says, Array.fromAsync is not intended to be a promise combinator, which would defeat its main purpose for async iterators—instead, it is an operation on potentially lazy iterators. The lazy iterators can be lazy async iterators or lazy sync iterators, but, either way, developers’ mental model is that they are lazy.

Moreover, eager iteration then parallel awaiting is already possible with Promise.all.

The choice between “eager iteration then parallel awaiting” versus “lazy iteration with sequential awaiting” is a crucial and fundamental concern of control flow, and the developer should explicitly decide which they want. Both are useful at different times; both are possible with for loops; but the former is already possible with simple function calls, and the latter is yet not.

Parallel awaiting Sequential awaiting
Lazy iteration Impossible for await (const v of input) f(v);
Eager iteration for (const v of await Promise.all(input)) f(v); Useless
Parallel awaiting Sequential awaiting
Lazy iteration Impossible await Array.fromAsync(input)
Eager iteration await Promise.all(Array.from(input)) Useless

Hopefully this choice is understandable.

I plan to close this issue when I update the explainer to mention this issue.

It sounds like Array.from(await Promise.all(myPromiseArray)) is what you’re looking for. There is no reason an unyielded value should magically be handled on your behalf.

We absolutely must not do that - Array.from takes an iterable, or failing that, an arraylike - it never checks or cares if something’s an actual Array. Similarly, Array.fromAsync not check or care - it takes an async iterable, or failing that, an iterable, or failing that, an arraylike.

To clarify, fromAsync takes an iterable, it doesn’t take an array. That’s where the distinction is. There is no way to know from just holding an iterable whether it has eagerly created all its values or not, and the safe thing to do is assume it hasn’t.

I also doubt we’d want to brand check the argument to see if the iterable is an array or a generic iterable. Now it’s possible a generic utility would be useful to turn an array of promises into an async iterable that handles rejections in the way you expect them. For example it’d attach a .catch to all promises eagerly and recreate a new promise for caught unyielded promised on iterator close.

async function * makeAsyncIteratorFromArray(arr) {
  arr = arr.map((value) => Promise.resolve(value));
  arr.forEach((promise) => promise.catch(() => {}));
  let promise;
  try {
    while(promise = arr.shift()) {
      yield await promise;
    }
  } finally {
    while(promise = arr.shift()) {
      new Promise((r) => r(promise));
    }
  }
}
const delay = (ms, reject = false) => new Promise((r, j) => setTimeout(reject ? j : r, ms, ms));

for await (let ms of makeAsyncIteratorFromArray([delay(1000), delay(3000), delay(2000, true)])) {
  console.log(ms) 
  if (ms === 3000) break;
}
// 1000, 3000, (unhandled rejection 2000)

Right, if you generate the promise when consumed by the iterator, it behaves as expected:

const delay = (ms, reject = false) => new Promise((r, j) => setTimeout(reject ? j : r, ms, ms));

function * getIterator (config) {
    for (const item of config) {
        yield delay.apply(null, item);
    }
}

for await (let ms of getIterator([[1000], [3000], [2000, true]])) {
  console.log(ms) // 1000, 3000, (uncaught error 2000)
}

The Async-from-Sync Iterator objects do not eagerly consume the sync iterator. The problem in the original example was that it eagerly created the promises, which included an unhandled rejection.

I’m a bit confused. You made a rejected promise and didn’t handle it. I’d expect an unhandled rejection here, just like in for-of.

I think I see what you mean now. Because delay(2000, true) rejects before Array.fromAsync can Await it, Array.fromAsync is unable to add a rejection handler to delay(2000, true) before it does reject.

However, as you know, Awaiting an already-rejected promise will in turn throw an error in the current async context. So my understanding is that Array.fromAsync should still return a promise that will reject at step 3.j.iii once it does reach delay(2000, true), even if delay(2000, true) already has rejected.

Anyways, I will raise this with the proposal’s reviewers (CC: @ljharb, @nicolo-ribaudo) and see if this is truly a problem and if we need to do anything special about this. I would wish for no unhandled rejection to ever escape this function, but there probably is no way to avoid that with for await sequential-read semantics.

Perhaps it is then a matter of whether those input promises count as being “inside” Array.fromAsync (and handled by that function) rather than “outside” Array.fromAsync; if they don’t count as being “inside”, then no rejection can escape Array.fromAsync, because those input promises were never inside Array.fromAsync in the first place.

I suppose we could adopt Promise.all semantics for sync iterables and synchronously dump sync iterables before immediately awaiting all of them in parallel…but that feels pretty strange to me. My intuition is to match for await and sequentially read values, to match semantics with async iterables.