Skip to content

vm/driver: no unhandled-promise-rejection reporting for a plain async-function-call rejection (only top-level-await rejections are reported) #120

Description

@nooga

Summary

#102 gave top-level-await rejections proper "Uncaught (in promise)"
reporting (vm.unhandledRejectionPrefix / handleUncaughtException). That
mechanism is specific to the TLA path. A plain async function call at
script top level, whose returned promise ends up rejected with zero
.then()/.catch() handlers ever attached, gets no report at all and the
process exits 0 - unlike real Node/V8, which prints the rejection and (as of
modern Node defaults) exits non-zero.

Repro

async function f() { throw new Error("x"); }
f();
console.log("script end");

Real Node:

script end
file:///.../repro.mjs:1
async function f() { throw new Error("x"); }
                           ^
Error: x
    at f (...)
    ...
Node.js v26.3.0

exit code 1.

Current paserati/noderati:

script end

exit code 0 - the rejection is silently dropped.

Scope note

This is a distinct feature gap, not a regression of #118 (which fixed a
different bug: an awaited rejection silently hanging its caller's own
promise forever with no error surfacing anywhere, even internally). Here,
after #118's fix, the promise genuinely does settle as rejected correctly -
there's just nothing watching for "a promise settled rejected and, by the
time the microtask queue goes idle, still has zero reject handlers attached"
the way Node's unhandledRejection/rejectionHandled event pair does.

Where to look

  • No existing tracking of "settled-rejected, no handler ever attached"
    promises found in pkg/vm/promise.go (rejectPromise/
    triggerPromiseReactions) - reactions are read from
    RejectReactions/FulfillReactions at settle time or attach time, but
    nothing records "this promise settled with an empty reaction list" for a
    later check once the event loop is idle.
  • Node's spec-adjacent behavior: a rejection is "unhandled" if, by the time
    the job queue empties, no .then()/.catch() was ever attached (checked
    via a microtask-queued callback after .then(), not synchronously - a
    handler attached after rejection, on the same tick chain, still counts
    as handled). This needs a similar deferred check, likely at whatever
    point paserati's own event-loop driver decides "nothing left to run."

Context

Found while verifying #118's fix (pi --help under noderati) - confirmed
via the repro above, both against a go run ./cmd/paserati build and
through noderati. Not blocking any current work; noted for whenever runtime
event-loop fidelity is revisited.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions