ecma262
ecma262 copied to clipboard
Normative: handle broken promises in AsyncGenerator.prototype.return
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 normalreturn 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.
I wish we didn't have this constructor
lookup gross-ness
I wish we didn't have this constructor lookup gross-ness
Das Leben ist kein Wunschkonzert :((
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 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 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.
@jugglinmike awesome! typically test PRs are merged prior to the spec PR being marked as "has tests".
Alrighty, I've merged the tests.
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 should implementations be notified, since there's been (failing in latest v8 at least) tests for this in test262 for almost 3 months?
@ljharb On consideration we should just merge this. I'll rebase.