fix(async): #256 spec-compliant microtask ordering for plain async functions#262
Merged
Conversation
…nctions (v0.5.371)
Pre-fix Perry's async functions ran their entire body synchronously on
the calling thread with each await busy-waiting for the Promise to
settle. This diverges from spec semantics where an await must yield to
the microtask queue, so synchronous code following an unawaited async
call runs before the awaited body's continuation.
The fix is a CPS / state-machine transform mirroring the existing
function* generator transform:
1. New `crates/perry-transform/src/async_to_generator.rs` pre-pass
rewrites await→yield in plain async function bodies, hoists
non-top-level awaits into fresh Lets, and flips the function flags.
2. `transform_generator_function` reads `was_plain_async` and wraps
the resulting iterator in an inline async-step driver built from
plain Promise.then chains (no new runtime helper).
3. `linearize_body` Try-arm guard widened to also fire when finally
has yields (await using desugars to try { } finally { await
dispose() }).
4. Hoisted-Let rewrite extended to recursively descend into nested
control-flow (for-of loops inside state bodies).
5. `compute_max_local_id` extended to scan class member bodies to
prevent ID collisions with v0.5.323 issue #212 method-local
rebind ids.
6. Microtask runner unwraps thenables when .then callbacks return
Promises (spec requirement; chains the next promise to the
returned thenable's eventual state).
Conservative scope: skips modules with __perry_cap_* classes (issue
#212 marker) and functions with capturing nested closures, both
tracked as follow-up work.
Gap tests 25/28 = baseline. 13 prototype regression tests all pass.
28776c9 to
c415fa5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #256. Pre-fix Perry's
async functiondeclarations compiled to direct-call functions whose body ran synchronously to completion, with eachawaitlowered to a busy-wait poll loop. This diverges from spec semantics: anawaitshould always yield to the microtask queue, even on already-resolved Promises, so synchronous code following an unawaited async call runs before the awaited body's continuation.The reproducer from the issue now matches Node byte-for-byte:
Pre-fix Perry:
main-1 / inner-1 / inner-2 / main-2 / top-1 / top-2Post-fix Perry & Node:
main-1 / inner-1 / top-1 / top-2 / inner-2 / main-2Approach
A CPS / state-machine transform that mirrors the existing
function*generator transform. Pure HIR — no new runtime helpers, which sidesteps the prior prototype's LLVM constant-folding mystery (thedone: 0.0sentinel mutating toTAG_TRUEbetween IR call site and runtime function entry under stripped -O3 builds).Six pieces:
New
crates/perry-transform/src/async_to_generator.rspre-pass — for every top-level function withis_async=true && !is_generator:console.log(\"x: \" + await y)) into freshlet __awaitN = await y;so every Await ends up in a position the generator transform'slinearize_bodycan split states at.Expr::Await(x)→Expr::Yield { value: Some(x), delegate: false }.is_async=false,is_generator=true,was_plain_async=true.Existing
transform_generator_functionextended — whenwas_plain_asyncis set, replaces the plainreturn iter_objwith an inline async-step driver:The two-step
let __step; __step = ...;pattern is required because Perry's closure-capture analysis silently produces NaN for theconst f = (...)=>f(...)form.linearize_bodyTry-arm guard widened to also fire when finally has yields (the prior prototype's blocker linux compilation and README #2).await usingdesugars totry { body } finally { await dispose() }and pre-fix the yield-in-finally hit codegen'sExpr::Yield => double_literal(0.0)arm and got fire-and-forgotten.Hoisted-Let rewrite extended to recursively descend into nested control-flow (For init/body, While body, If branches, Try, Switch, Labeled). Without this a for-of loop inside a state body keeps its inner
let v = arr[i]as a Let that creates a shadow slot hiding the outer captured box; manifested asfor (const v of arr) sum += vreturningsum=0inside transformed async functions.compute_max_local_idextended (in bothasync_to_generator.rsandgenerator.rs) to scan class member bodies — the v0.5.323 issue Class method body cannot capture enclosing-function locals #212 capture rewrite allocates method-local fresh ids per class method per captured outer local; without this scan, the generator transform's freshly-allocated state/done/sent/wrapper ids could collide and corrupt unrelated class-method codegen.js_promise_run_microtasksnow unwraps thenables when the.thencallback returns a Promise — pre-fix the runtime stored the Promise pointer directly as the chained promise's value, so the async-step driver's recursivestep()returns producedPromise<Promise<...>>chains that never unwrapped to the real value (await fn()returnedPromise { <pending> }for any async function with multiple awaits).Conservative scope
To avoid exposing pre-existing closure-capture bugs that the new state-machine path surfaces in different ways, the pre-pass skips:
__perry_cap_*instance fields (the v0.5.323 issue Class method body cannot capture enclosing-function locals #212 capture rewrite marker). The async-step driver's fresh LocalId allocations can collide with the v0.5.323 method-local rebind ids and the collision is path-dependent.js_box_set: null box pointerwarning surfaces when an inner closure's captured-from-outer box ends up null at runtime.Both are tracked separately as follow-up work to fully fix the closure-capture-inside-async-state-machine class of bugs. The issue #256 microtask-ordering reproducer (no classes, no nested closures) gets the full fix.
Test plan
test_issue_256_microtask_ordering.ts(the issue's exact reproducer) — passes byte-for-byte againstnode --experimental-strip-typesarray_methods/console_methods/typed_arrays)test_async,test_async2-5,test_async_chain— top-level await of async function with multi-await chains, all passtest_issue_235_method_default_param_padding— the test the issue called out as exposed by the eager-await gap, now passes byte-for-bytetest_issue_154_using_dispose—await usingexercises the linearize_body finally-side fix, passestest_issue_212_class_method_capture— class-method-captures-outer pattern, passes (validates conservative scope works)test_issue_233_async_array_param_push— async + Array.push + for-of, passes (validates the hoisted-Let recursive descent)test_issue_236_fetch_then_console_log,test_issue_240_interface_dispatch,test_issue_167_loop_alloca_stack_eat,test_issue_221_module_array_index_set— all passOut of scope (follow-ups)
async function*) andfor await of— separate feature surface; these already hadis_generator=truefrom lowering so the pre-pass intentionally skips them. Tracked as async/await: spec-compliant microtask ordering (eager-await semantics gap) #256 follow-up if needed.module.functionsonly.