Skip to content

throw new Error(...) in async Fastify route handler crashes process (exit 1) instead of being caught by setErrorHandler #928

Description

@proggeramlug

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:

  1. 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.
  2. 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.

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