Skip to content

async-step driver: sync throw from iter.next() bypasses user catch and resolves to undefined #619

Closed

Description

@proggeramlug

Summary

build_async_step_driver in crates/perry-transform/src/generator.rs:1383+ catches a synchronous throw from iter.next() with return Promise.reject(e). But the user's try/catch around the await is encoded as the iterator's iter.throw closure — never invoked. Net effect: a sync throw at an await operand position is never seen by the user's catch handler. Combined with a related throw-closure issue (below), the program either silently exits or resolves the await to undefined.

Minimal repro #1 — sync fn throws at await position

function syncThrower(): any { throw new Error("sync error"); }
async function main() {
  console.log("before try");
  try { await syncThrower(); }
  catch (e) { console.log("caught:", (e as any).message); }
  console.log("done");
}
main();
  • Node: before try / caught: sync error / done
  • Perry: silent exit after before try

Minimal repro #2 — async fn whose body throws sync

async function inner() { throw new Error("from inner"); }
async function main() {
  try { await inner(); }
  catch (e) { console.log("caught:", (e as any).message); }
}
main();
  • Node: caught: from inner
  • Perry: silent exit, no output

Trace

For repro #1, the generator transform produces a next() closure roughly:

case state == 0:
    log("before try");
    state = 1;
    return { value: syncThrower(), done: false };  // ← throws sync here

syncThrower() is evaluated in the value: field of the iter-result. The throw escapes next() (no try/catch in next()), lands in the driver's catch:

// generator.rs:1383+
__async_step(value, isError) {
  let r;
  try {
    r = isError ? __iter.throw(value) : __iter.next(value);
  } catch (e) {
    return Promise.reject(e);   // ← BUG: bypasses user's catch
  }
  ...
}

The user's catch (e) is encoded as the iter.throw closure (line 1126+). The driver's Promise.reject skips it entirely. The outer main() returns a rejected Promise that nobody awaited — appears as silent exit.

Fix shape

Two coordinated changes needed:

1. Driver — re-deliver sync throws via iter.throw (generator.rs:1393-1402)

// BEFORE
catch: Some(CatchClause {
    param: Some((catch_e_id, "__step_catch_e".to_string())),
    body: vec![Stmt::Return(Some(promise_reject(Expr::LocalGet(catch_e_id))))],
}),

// AFTER (sketch)
catch: Some(CatchClause {
    param: Some((catch_e_id, "__step_catch_e".to_string())),
    body: vec![
        // If we were already delivering an error and the catch handler rethrew,
        // surface as Promise rejection (matches "uncaught throw rejects the promise").
        Stmt::If {
            condition: Expr::LocalGet(is_error_param_id),
            then_branch: vec![Stmt::Return(Some(promise_reject(Expr::LocalGet(catch_e_id))))],
            else_branch: None,
        },
        // Otherwise: sync throw came from iter.next's body — re-deliver via iter.throw
        // so the user's catch handler runs.
        Stmt::Return(Some(Expr::Call {
            callee: Box::new(Expr::LocalGet(step_id)),
            args: vec![Expr::LocalGet(catch_e_id), Expr::Bool(true)],
            type_args: vec![],
        })),
    ],
}),

2. Throw closure — rethrow when no user catch was seen (generator.rs:1138-1169)

When catches.first() is None, the throw closure currently swallows the error and returns { value: undefined, done: true }. Per spec, iter.throw(e) on a generator with no active try/catch should rethrow e. Without this, the driver fix above masks the error: re-delivery via iter.throw → swallowed → driver returns Promise.resolve(undefined) → user sees the await resolve to undefined instead of rejecting.

// In throw_body construction at generator.rs:1138+:
if let Some((catch_param_id, catch_body)) = catches.first().cloned() {
    // existing inline-catch path …
} else {
    // No user catch — rethrow so the driver's outer try sees it.
    throw_body.push(Stmt::Throw(Expr::LocalGet(throw_param_id)));
    // (unreachable after Throw, but keep structure consistent)
}

Both fixes together: with user catch → the catch fires; without user catch → the outer Promise rejects.

Related

Environment

Activity

  1. proggeramlug commented on May 9, 2026

    @proggeramlug
    ContributorAuthor

    Closed in v0.5.715: crates/perry-transform/src/generator.rs::build_async_step_driver now re-delivers sync throws via iter.throw instead of Promise.reject, AND the throw closure rethrows when no user catch was seen. Both repros (sync-fn-throws-at-await, async-fn-body-throws-sync) now match Node byte-for-byte. Test: test-files/test_issue_619.ts.

  2. added a commit that references this issue on May 9, 2026
    13544d9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions