Repository navigation
async-step driver: sync throw from iter.next() bypasses user catch and resolves to undefined #619
Description
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 heresyncThrower() 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
- Real-world bite: surfaces in hono: app.get("/users/:id") + app.notFound() handlers produce no output on perry-compiled binary #603 (hono fixture). Hono's first call to
app.fetchrebindsthis.match(SmartRouter), then on subsequent callsthis.router.matchthrows syncError("Fatal error"); user'sawait app.fetch(...)silently exits. (hono: app.get("/users/:id") + app.notFound() handlers produce no output on perry-compiled binary #603 also depends on a separate class-instance-method-dispatch bug filed alongside this.) - Adjacent gap: even with both fixes above, statements after a try/catch are skipped — the throw closure ends with
done = true; return { done: true }instead of transitioning to a "post-catch" state. Soconsole.log("done")after the try/catch in repro Support custom menu bar items #1 still wouldn't run. Worth a separate issue if not subsumed by a broader generator-transform refactor.
Environment
- Perry 0.5.713
- macOS 26.4 / arm64
- Discovered while investigating hono: app.get("/users/:id") + app.notFound() handlers produce no output on perry-compiled binary #603
Activity
proggeramlug commentedon May 9, 2026
Closed in v0.5.715:
crates/perry-transform/src/generator.rs::build_async_step_drivernow re-delivers sync throws viaiter.throwinstead ofPromise.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.fix: closes #619 #620 #621 — async sync-throw routing + this.method o…