fix(vm): restore async-generator invariants and PromiseResolve abrupt handling (#706, #707, #708, #709)#950
Conversation
…rynova#708, trynova#709) Harden async generator state transitions and queue-drain handling to avoid unreachable/panic paths, and convert invalid eval VM await/yield completions into SyntaxError instead of crashes. Also updates Test262 expectations for the now-passing async generator and SM async crash tests. Fixes trynova#706 Fixes trynova#707 Fixes trynova#708 Fixes trynova#709
|
i am out of my depth here but the tests pass 🤷♀️ |
aapoalas
left a comment
There was a problem hiding this comment.
Hey,
Thank you for the interest and contribution, but unfortunately this is not The Way. Removing all the assertions will get you code that does not crash, but it does not fix the actual mistakes. That the assertions fire means one of three things:
- We have some related code which should be making the asserted things impossible, but due to a mistake is not doing so. That code should be fixed.
- We have found an ECMAScript specification bug. That should be reported, but this is unlikely.
- There was a specification bug and the specification has been updated, but we haven't updated our sources to match the fixed specification. This should be checked and discrepancies fixed.
| let generator = generator.unbind(); | ||
| let return_value = value.unbind(); | ||
| // 7. Let promiseCompletion be Completion(PromiseResolve(%Promise%, completion.[[Value]])). | ||
| let constructor_error = if let Value::Promise(promise) = return_value { |
There was a problem hiding this comment.
issue: This stuff is pretty egregiously wrong. You're doing promise.constructor here, seeing if it throws an error and if it does then you use that error to stop the async generator... What?
| let execution_context = agent.pop_execution_context().unwrap(); | ||
| let generator = scoped_generator.get(agent).bind(gc.nogc()); | ||
| generator.transition_to_awaiting(agent, vm, kind, execution_context); | ||
| let generator = generator.unbind(); |
There was a problem hiding this comment.
issue: let x = x.unbind() is always wrong, see https://github.com/trynova/nova/blob/main/GARBAGE_COLLECTOR.md#never-unbind-a-value-into-a-local-variable
| }; | ||
|
|
||
| // 7. Perform PerformPromiseThen(promise, onFulfilled, onRejected). | ||
| inner_promise_then(agent, promise, handler, handler, None, gc.nogc()); |
There was a problem hiding this comment.
note: handler is now use-after-free because try_resolve can trigger GC and handler is unrooted.
| // Assert: generator.[[AsyncGeneratorState]] is draining-queue. | ||
| // 2. Let queue be generator.[[AsyncGeneratorQueue]]. | ||
| let Some(AsyncGeneratorState::DrainingQueue(queue)) = &mut data.async_generator_state else { | ||
| let AsyncGeneratorState::DrainingQueue(queue) = |
| let async_generator_state = &mut self.get_mut(agent).async_generator_state; | ||
| let (vm, execution_context, queue) = match async_generator_state.take() { | ||
| Some(AsyncGeneratorState::SuspendedStart { | ||
| let state = async_generator_state.take().unwrap(); |
| /// | ||
| /// This variant implements abrupt-completion behavior for internal callers | ||
| /// that must handle `? PromiseResolve(%Promise%, x)`. | ||
| pub fn try_resolve(agent: &mut Agent, x: Value, mut gc: GcScope<'a, '_>) -> JsResult<'a, Self> { |
There was a problem hiding this comment.
issue: try_ methods should always take NoGcScope, otherwise they're not try-methods.
| let promise = match Promise::try_resolve(agent, awaited_value, gc.reborrow()) { | ||
| Ok(promise) => promise.unbind().bind(gc.nogc()), | ||
| Err(err) => { | ||
| generator.resume_await(agent, PromiseReactionType::Reject, err.value().unbind(), gc); |
There was a problem hiding this comment.
thought: This seems suspect; I am not sure if this is correct.
| .map(|promise| promise.unbind()) | ||
| .map_err(|err| err.unbind()); | ||
| let promise = match promise_completion { | ||
| // 8. If promiseCompletion is an abrupt completion, then |
There was a problem hiding this comment.
praise: Based on the comment at least, this indeed seems like a missing handling. Nice catch.
aapoalas
left a comment
There was a problem hiding this comment.
So I'm assuming this is largely if not entirely vibe-coded. That's... fine I guess, but preferably do mention it (and the extent of it) so that I can review accordingly.
As it stands, there's too much changes in this for me to really make heads or tails of what is actually necessary and what is just LLM hallucination.
| // Assert: generator.[[AsyncGeneratorState]] is draining-queue. | ||
| // 2. Let queue be generator.[[AsyncGeneratorQueue]]. | ||
| let Some(AsyncGeneratorState::DrainingQueue(queue)) = &mut data.async_generator_state else { | ||
| let AsyncGeneratorState::DrainingQueue(queue) = |
There was a problem hiding this comment.
issue: This is meaningless; unwrap the Option or unreachable the None – it's the same thing.
| let data = generator.get_mut(agent); | ||
| // iii. If queue is empty, then | ||
| let Some(AsyncGeneratorState::DrainingQueue(queue)) = &mut data.async_generator_state | ||
| let AsyncGeneratorState::DrainingQueue(queue) = |
There was a problem hiding this comment.
issue: This is meaningless; unwrap the Option or unreachable the None – it's the same thing.
| /// > NOTE: In our implementation, EXECUTING is split into an extra | ||
| /// > EXECUTING-AWAIT state. This also checks for that. | ||
| pub(crate) fn is_active(self, agent: &Agent) -> bool { | ||
| pub(crate) fn is_draining_queue(self, agent: &Agent) -> bool { |
There was a problem hiding this comment.
nitpick: Unnecessary code churn from moving two functions around.
| Some(AsyncGeneratorState::SuspendedStart { | ||
| let state = async_generator_state.take().unwrap(); | ||
| let (vm, execution_context, queue) = match state { | ||
| AsyncGeneratorState::SuspendedStart { |
There was a problem hiding this comment.
thought: I'm not sure if this can happen... If it can, then this is a really nice catch and explains a lot about the failures. I'd like to see only these changes in a separate PR and see the effect that has.
| // 1. Assert: generator.[[AsyncGeneratorState]] is either suspended-start or suspended-yield. | ||
| let state = self.get_mut(agent).async_generator_state.take().unwrap(); | ||
| let (vm, execution_context, queue, kind) = match state { | ||
| AsyncGeneratorState::SuspendedStart { |
| let result = match Vm::execute(agent, exe.clone(), None, gc.reborrow()) { | ||
| ExecutionResult::Return(value) => Ok(value.unbind()), | ||
| ExecutionResult::Throw(err) => Err(err.unbind()), | ||
| ExecutionResult::Await { .. } | ExecutionResult::Yield { .. } => Err(agent |
There was a problem hiding this comment.
issue: This should be impossible to happen, we should get an early SyntaxError from our parser. This also does not seem to have anything to do with the async-generator work.
i'm grateful for the detailed feedback, this definitely isn't my domain, i'm going to give it one more round and if it's not close you can say so without elaboration, i want to respect your time and energy and will be happy to close the pr |
I think splitting the PR up into smaller pieces would be nice; just add the |
ok i broke #709 out into #958 to start, may come back around for a second on |
Restore async-generator state invariants and PromiseResolve abrupt-completion handling in async-generator await/return paths.
This does not attempt to silence assertions; it fixes invalid transition paths and preserves spec preconditions where algorithms use
Assert:.Scope
#706,#707,#708,#709? PromiseResolve(%Promise%, x)SyntaxError) retained as originally proposedChange Grouping (for review)
Issue Mapping
#706 AsyncGenerator::append_to_queue called on completed AsyncGenerators
nova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects.rs#707 AsyncGenerator::resume_await could enter unreachable code
resume_awaitcould be entered from an invalid state sequence.nova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects.rsnova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects/async_generator_abstract_operations.rs#708 async_generator_resume could enter unreachable code
AsyncGeneratorResumepreconditions were not enforced by strict transition usage.Assert-style checks in resume/drain algorithms.nova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects/async_generator_abstract_operations.rs#709 specification assertion failure in AsyncGenerator.prototype.return
PromiseResolvein return-await flow was not modeled correctly.nova_vm/src/ecmascript/builtins/promise.rsnova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects/async_generator_abstract_operations.rsnova_vm/src/ecmascript/builtins/control_abstraction_objects/async_generator_objects/async_generator_prototype.rsValidation Matrix
tests/test262/test/built-ins/AsyncGeneratorPrototype/return/return-state-completed-broken-promise.jstests/test262/test/built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-broken-promise.jstests/test262/test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-broken-promise-try-catch.jstests/test262/test/staging/sm/AsyncGenerators/for-await-of-error.jstests/test262/test/staging/sm/async-functions/await-error.jsCommands Run
cargo check -p nova_clicargo check -p testscargo run -p tests --bin test262 -- eval-test built-ins/AsyncGeneratorPrototype/return/return-state-completed-broken-promise.jscargo run -p tests --bin test262 -- eval-test built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-broken-promise.jscargo run -p tests --bin test262 -- eval-test built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-broken-promise-try-catch.jscargo run -p tests --bin test262 -- eval-test staging/sm/AsyncGenerators/for-await-of-error.jscargo run -p tests --bin test262 -- eval-test staging/sm/async-functions/await-error.jsFixes #706
Fixes #707
Fixes #708
Fixes #709