ecma262 icon indicating copy to clipboard operation
ecma262 copied to clipboard

Normative: handle broken promises in AsyncGenerator.prototype.return

Open bakkot opened this issue 2 years ago • 11 comments

Fixes https://github.com/tc39/ecma262/issues/2412 by trapping the exception and using it to reject the promise returned by the call to AsyncGenerator.prototype.return.

Background 1: broken promises

You can make a promise which throws when you try to await or Promise.resolve it:

let brokenPromise = Promise.resolve(42);
Object.defineProperty(brokenPromise, 'constructor', {
  get: function () {
    throw new Error('broken promise');
  }
});

(see PromiseResolve step 1.a.)

If you await such a value, or pass it to Promise.resolve, it will synchronously throw an exception.

Background 2: AsyncGenerator.prototype.return

Generators have a .next() method which asks for the next value. Async generators have the same thing, except it returns a promise. Because the generator might be blocked on an await when you call .next(), they have an internal queue of requests to service once they're unblocked.

Generators also have a .return(x) method, used for cleanup, which injects a return completion at the current yield and resumes execution of the generator. If called before the generator has started, or after it's finished, the call to .return returns { done: true, value: [the value passed to .return] }.

Async generators also have a .return, with some differences:

  • It returns a promise
  • As with .next, it's queued (in fact it shares the queue with .next)
  • Once a call gets to the head of the queue, if the generator has completed, it will await the passed value before returning it in the result object (matching what happens if you do a normal return x; from an async generator).

The problem

Right now the spec doesn't account for the possibility of a broken promise passed to .return. It has an assertion that this doesn't happen, and that assertion can be violated:

let brokenPromise = Promise.resolve(42);
Object.defineProperty(brokenPromise, 'constructor', {
  get: function () {
    throw new Error('broken promise');
  }
});

{
  let gen = (async function* () { })();
  gen.return(brokenPromise);
}

// or
{
  let unblock;
  let blocking = new Promise(res => { unblock = res; });

  let gen = (async function* (){ await blocking; })();
  gen.next();

  // generator is now blocked on the blocking promise
  // further calls to `.next` or `.return` will be queued until it settles

  gen.return(brokenPromise);

  unblock();
}

Both of these calls violate an assertion in the spec: the first in AsyncGenerator.prototype.return step 8.a, the second in AsyncGeneratorDrainQueue step 5.c.ii.

Current behavior

XS never triggers the broken promise, and ChakraCore doesn't implement async generators.

For the first example, calling .return with a broken promise while the generator is not blocked: In SpiderMonkey, V8, and GraalJS, it synchronously throws. In JavaScriptCore, it returns a promise which never settles, and the exception disappears into the void.

For the second example, calling .return with a broken promise while the generator is blocked: all engines return a promise which never settles. In V8 and JavaScriptCore, the exception disappears into the void. In SpiderMonkey and GraalJS, the exception is thrown outside of any handlers (in SpiderMonkey this is observable in the console or with onerror).

Proposed behavior

The behavior this PR implements is to catch the exception and use it to reject the promise returned by AsyncGenerator.prototype.return. This doesn't match any implementation but seems like the only reasonable behavior, holding everything else constant.

bakkot avatar Mar 05 '22 04:03 bakkot

I wish we didn't have this constructor lookup gross-ness

mhofman avatar Mar 28 '22 15:03 mhofman

I wish we didn't have this constructor lookup gross-ness

Das Leben ist kein Wunschkonzert :((

syg avatar Apr 05 '22 00:04 syg

I wish we didn't have this constructor lookup gross-ness

I’ve wondered if there’s any reason not to change step 3 of SpeciesConstructor to “If Type(C) is not Object, return defaultConstructor”. This wouldn’t solve for the general problem, but it would get rid of the subset of cases like the following, I think. Currently this is all it takes to kill every async function / await expression in the current realm, no accessor needed:

Promise.prototype.constructor = null;
(async () => "Das Leben ist kein Wunschkonzert")();
// > Uncaught (in promise) TypeError: The .constructor property is not an object

bathos avatar Apr 05 '22 01:04 bathos

@bathos If we're worried about code which is actively trying to break things that's not really an improvement (since you can still add a throwy accessor), and I don't think there's a reason to believe non-malicious code is going to be doing anything like that. So there's no clear-to-me benefit to such a change.

bakkot avatar Apr 05 '22 02:04 bakkot

@bakkot Wasn’t thinking malicious so much as “why is this even here”: I figured a path by which ES code can “break syntax” (with no user value) would be worth eliminating even if the broader capability can’t be. But you’d have a better sense of the trade-offs than me.

bathos avatar Apr 05 '22 03:04 bathos

@jugglinmike awesome! typically test PRs are merged prior to the spec PR being marked as "has tests".

ljharb avatar Apr 29 '22 23:04 ljharb

Alrighty, I've merged the tests.

jugglinmike avatar May 12 '22 18:05 jugglinmike

https://github.com/tc39/proposal-faster-promise-adoption might actually make it impossible to have "broken promises" in this sense, so I want to hold off on landing this for a little while to see if we can fix this more comprehensively.

bakkot avatar Aug 03 '22 21:08 bakkot

@bakkot should implementations be notified, since there's been (failing in latest v8 at least) tests for this in test262 for almost 3 months?

ljharb avatar Aug 07 '22 05:08 ljharb

@ljharb On consideration we should just merge this. I'll rebase.

bakkot avatar Jan 04 '23 22:01 bakkot