Skip to content

async fn with throw new Error crashes the process when caller has try/catch around the await (silent exit, no JS error) #921

Description

@proggeramlug

Symptom

In a binary compiled with Perry (commit 32352733), the process exits silently — no JS error message, no stack trace, just a clean exit code that PM2 (or any supervisor) treats as a crash and restarts. The trigger is a pattern that should be a basic try/catch:

async function refreshToken(refresh: string): Promise<RefreshResp> {
  const r = await fetch(URL, ...);
  if (!r.ok) {
    const txt = await r.text();
    throw new Error("refresh failed: " + r.status + " " + txt);  // ← throw across await
  }
  return JSON.parse(await r.text());
}

async function getAll(userId: string): Promise<...> {
  // ...
  try {
    const tokens = await refreshToken(account.refresh_token);   // ← await + try/catch
    // ...
  } catch (e) {                                                 // ← never reached
    console.error("caught:", e);
    // ...
  }
}

Expected: the throw is caught by the catch block, console.error runs.
Actual: the process dies. No log line from catch. The supervisor restarts the binary.

Real-world manifestation

A production service (gscmaster-api, Fastify+Perry) crashed every 30 minutes for 7 days (330 PM2 restarts) before the root cause was tracked down. The 30-minute cron called refreshAllUsers(), which iterated users; for one specific user whose Google refresh_token had been revoked (invalid_grant), the throw above fired inside getAllGoogleAccounts()'s try/catch. The whole node process died with no JS trace — the outer caller's try/catch in refreshAllUsers() (also around an await) never saw the error either.

Out log:

[bg-refresh] Refreshing user 2/10: 7ba959e7-...
[bg-refresh] Starting refresh for user 7ba959e7-...
<— process exits here, no error from any catch block —>
[cron] schedulers registered: ...
Server listening on http://0.0.0.0:3004

Error log alongside it was full of [PERRY WARN] js_box_get: invalid box pointer 0x... and [WARN_NULL_PTR] js_object_set_field: null POINTER_TAG ... — replacing with undefined, but no thrown error.

Workaround that resolved the production crash

Stop crossing throw with await + try/catch. Convert error-signaling to a result tag, so the await boundary never sees an in-flight exception:

interface Result<T> { ok: boolean; value: T | null; error: string; status: number; }
async function refreshToken(refresh: string): Promise<Result<RefreshResp>> {
  const r = await fetch(URL, ...);
  const status = r ? r.status : 0;
  if (!r.ok) {
    return { ok: false, value: null, error: "refresh failed: " + status, status };
  }
  return { ok: true, value: JSON.parse(await r.text()), error: "", status };
}

async function getAll(userId: string): Promise<...> {
  const result = await refreshToken(account.refresh_token);
  if (!result.ok || result.value === null) {
    console.error("refresh failed:", result.error);
    return null;
  }
  // use result.value
}

After this change, the cron completed all users instead of crashing on the second one.

Suspected cause

The codebase under question (gscmaster-api) carries inline comments that already note this — e.g. src/middleware/auth.ts:

// Perry: no try/catch (setjmp/longjmp incompatible with async context).
// On error, js_jwt_verify returns null → js_json_parse(null) → JSValue::null() → payload falsy.

This is consistent with #856 (`_setjmp` redeclared with mismatched signatures in gc.rs vs promise.rs, potential UB). If the setjmp buffer is being interpreted as the wrong type at one of those two call sites, an await-boundary unwind may stomp on it and corrupt subsequent state, eventually faulting silently.

Asks

  1. Confirm whether this is the user-facing surface of fix: _setjmp redeclared with mismatched signatures in gc.rs vs promise.rs (potential UB) #856, or a distinct codegen issue.
  2. Either way, detect and refuse to compile throw patterns where the catch is across an await boundary, OR emit a clear runtime error instead of silently exiting. Right now this pattern looks like normal JS to anyone who writes it, and the failure mode (week-long PM2 restart loop with no JS log) is genuinely scary.
  3. A doc note in the language guide ("Don't throw from async + try/catch around await — use result tags") would have saved a week of production debugging.

Environment

  • Perry: git rev-parse HEAD32352733 (fix(codegen): #678 — re-export rename resolves to origin export name (#785))
  • Target: x86_64 Ubuntu 22.04
  • Runtime: Fastify (Perry's bundled shim) + mysql2

Filed from gscmaster-api investigation; full conversation thread available on request.

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