Repro (8 lines)
import Fastify from "fastify";
const app = Fastify({ logger: false });
app.get("/healthz", async () => ({ ok: true }));
app.get("/go", async () => {
throw new Error("Unauthorized");
});
await app.listen({ host: "127.0.0.1", port: 18099 });
console.log("listening");
PERRY_ALLOW_UNIMPLEMENTED=1 perry compile repro.ts -o /tmp/repro
/tmp/repro &
sleep 1
curl http://127.0.0.1:18099/go
# → curl: (52) Empty reply from server; server `exit 1`,
# prints `Uncaught exception: Unauthorized` to stderr.
Expected: Fastify's default error handler turns the throw into HTTP 500 with {statusCode:500, error:"Internal Server Error", message:"Unauthorized"}. Process stays alive. (Under tsx/Node the standalone is the same once you tweak the readiness probe — Fastify catches the throw.)
Root cause (located while bisecting)
crates/perry-runtime/src/exception.rs::js_throw:
#[no_mangle]
pub extern "C" fn js_throw(value: f64) -> ! {
// ...
if TRY_DEPTH == 0 {
print_uncaught(value);
std::process::exit(1); // ← kills the process
}
longjmp(JUMP_BUFFERS[depth].as_mut_ptr(), 1)
}
crates/perry-codegen/src/stmt.rs::Stmt::Throw does have an async-aware branch:
Stmt::Throw(expr) => {
let val = lower_expr(ctx, expr)?;
if ctx.is_async_fn && ctx.try_depth == 0 {
let blk = ctx.block();
let handle = blk.call(I64, "js_promise_rejected", &[(DOUBLE, &val)]);
let boxed = nanbox_pointer_inline_pub(blk, &handle);
blk.ret(DOUBLE, &boxed);
} else {
ctx.block().call_void("js_throw", &[(DOUBLE, &val)]);
ctx.block().unreachable();
}
Ok(())
}
— but it's not firing for the async arrow handler passed to app.get(...). The else branch (raw js_throw) takes over and std::process::exit(1) runs. So either ctx.is_async_fn is false for the closure, or ctx.try_depth is non-zero for some reason, or the throw is being lowered through a different code path.
What I've ruled out / variant matrix (against my local checkout)
All in a Fastify async route handler:
| Throw form |
Result |
throw new Error("...") (built-in Error) |
DEAD exit 1 |
throw new Error() after await delay() (named async fn) |
DEAD exit 1 |
throw new Error() from a SYNC route (app.get(..., () => {...})) |
DEAD exit 1 |
throw new HttpError(...) (user-defined class extends Error) |
ALIVE HTTP 500 |
throw new ApiError({...}) (shop-admin's actual error subclass) |
ALIVE HTTP 500 |
throw "string" |
ALIVE HTTP 500 |
throw { message: "...", statusCode: 401 } (object literal) |
ALIVE HTTP 500 |
throw new Error() wrapped in user-side try/catch returning a value |
ALIVE HTTP 200 |
whoops() helper (sync) that throw new Error(...) |
ALIVE but request hangs |
throw new Error() inside an onRequest hook (not route body) |
ALIVE HTTP 200 |
So the deterministic crash is: throw new Error(...) (the built-in Error class, not a subclass) from a Fastify route handler — both sync and async route shapes. Subclasses, primitives, and object literals all flow through Fastify's setErrorHandler correctly.
The subclass-works / built-in-Error-crashes split is the weirdest part: both are Error instances, both should route through the same Stmt::Throw codegen path. Possibly the codegen detects throw <user-subclass-construction> via a different path than throw <native-class-construction> and the latter loses the async-fn context.
How this surfaces in shop-admin
shop-admin throws new ApiError(...) (a subclass of Error) via unauthorized()/forbidden()/etc. helpers — my minimal repro with that exact shape returns HTTP 500 cleanly. But in the full server it still crashes with Uncaught exception: Unauthorized and exit 1 on the first authenticated route. So either:
- something in the full chain re-wraps the throw into a
new Error(message) somewhere
- the auth middleware throws
unauthorized() from a non-async helper (requireUser(req) is sync function) and that sync throw inside an async route context hits an edge of the codegen-emitted async resume path
Either way, the underlying perry issue is the Stmt::Throw → js_throw → process::exit(1) for unconditional throws-without-user-try in async route handlers. Even if the async-arrow detection is correct in some shapes, having js_throw exit the process at all when called from a Fastify-wrapped async frame is a footgun — Fastify's setErrorHandler should always get a chance to catch the rejection.
Suggested fix
Either:
- Make
Stmt::Throw reliably take the js_promise_rejected branch for all throws inside async functions, including arrow expressions / closures, regardless of how they were passed to app.get(...). Verify ctx.is_async_fn is set correctly for those.
- Or, defensively: when
js_throw is called and TRY_DEPTH == 0 but the surrounding frame is an async function, surface as a rejected promise (via a new js_throw_in_async entry point or by tracking async-frame depth in the runtime) instead of process::exit(1).
The first is preferable — it's the cleaner contract. The second is a safety net that would close the user-visible "Fastify can't catch my thrown errors" hole even if codegen misses an async case in the future.
Local checkout clean at v0.5.958, no source edits. Repro committed at
issue-859-repro/throw-in-async-route.ts in the user repo if useful.
Repro (8 lines)
Expected: Fastify's default error handler turns the throw into HTTP 500 with
{statusCode:500, error:"Internal Server Error", message:"Unauthorized"}. Process stays alive. (Undertsx/Node the standalone is the same once you tweak the readiness probe — Fastify catches the throw.)Root cause (located while bisecting)
crates/perry-runtime/src/exception.rs::js_throw:crates/perry-codegen/src/stmt.rs::Stmt::Throwdoes have an async-aware branch:— but it's not firing for the async arrow handler passed to
app.get(...). The else branch (rawjs_throw) takes over andstd::process::exit(1)runs. So eitherctx.is_async_fnis false for the closure, orctx.try_depthis non-zero for some reason, or the throw is being lowered through a different code path.What I've ruled out / variant matrix (against my local checkout)
All in a Fastify async route handler:
throw new Error("...")(built-in Error)throw new Error()afterawait delay()(named async fn)throw new Error()from a SYNC route (app.get(..., () => {...}))throw new HttpError(...)(user-definedclass extends Error)throw new ApiError({...})(shop-admin's actual error subclass)throw "string"throw { message: "...", statusCode: 401 }(object literal)throw new Error()wrapped in user-sidetry/catchreturning a valuewhoops()helper (sync) thatthrow new Error(...)throw new Error()inside anonRequesthook (not route body)So the deterministic crash is:
throw new Error(...)(the built-in Error class, not a subclass) from a Fastify route handler — both sync and async route shapes. Subclasses, primitives, and object literals all flow through Fastify'ssetErrorHandlercorrectly.The subclass-works / built-in-Error-crashes split is the weirdest part: both are Error instances, both should route through the same
Stmt::Throwcodegen path. Possibly the codegen detectsthrow <user-subclass-construction>via a different path thanthrow <native-class-construction>and the latter loses the async-fn context.How this surfaces in shop-admin
shop-admin throws
new ApiError(...)(a subclass of Error) viaunauthorized()/forbidden()/etc. helpers — my minimal repro with that exact shape returns HTTP 500 cleanly. But in the full server it still crashes withUncaught exception: Unauthorizedand exit 1 on the first authenticated route. So either:new Error(message)somewhereunauthorized()from a non-async helper (requireUser(req)is syncfunction) and that sync throw inside an async route context hits an edge of the codegen-emitted async resume pathEither way, the underlying perry issue is the
Stmt::Throw → js_throw → process::exit(1)for unconditional throws-without-user-try in async route handlers. Even if the async-arrow detection is correct in some shapes, havingjs_throwexit the process at all when called from a Fastify-wrapped async frame is a footgun — Fastify'ssetErrorHandlershould always get a chance to catch the rejection.Suggested fix
Either:
Stmt::Throwreliably take thejs_promise_rejectedbranch for all throws inside async functions, including arrow expressions / closures, regardless of how they were passed toapp.get(...). Verifyctx.is_async_fnis set correctly for those.js_throwis called andTRY_DEPTH == 0but the surrounding frame is an async function, surface as a rejected promise (via a newjs_throw_in_asyncentry point or by tracking async-frame depth in the runtime) instead ofprocess::exit(1).The first is preferable — it's the cleaner contract. The second is a safety net that would close the user-visible "Fastify can't catch my thrown errors" hole even if codegen misses an async case in the future.
Local checkout clean at v0.5.958, no source edits. Repro committed at
issue-859-repro/throw-in-async-route.tsin the user repo if useful.