napi/v8: never leave a JS exception on the VM while addon code runs

DY
2mo ago
+476-241

What does this PR do?

A Node-API or V8-API call returns to the addon's C/C++ code, which cannot check a JS exception; the first frame that can is the trampoline the addon eventually returns to. This makes the addon boundary work the way Node's does (NAPI_PREAMBLE's TryCatch → env->last_exception → rethrown by CallIntoModule) and the way JSC's own C API does (APIUtils.h handleExceptionIfNeeded): an exception raised inside an API call is taken off the VM and latched on the napi_env, the call returns its status, and the trampoline that entered the addon throws it once the addon returns — NapiClass call/construct/getter/setter, process.dlopen (both napi_register_module_v1 and napi_module_register), finalizers, async-work completion, threadsafe-function call_js, and bun:ffi cc() symbols that take napi_env/napi_value. napi_throw* already worked like this; now every path does:

  • NAPI_RETURN_IF_EXCEPTION / NAPI_RETURN_STATUS_IF_EXCEPTION / NAPI_CHECK_TO_OBJECT and the Rust napi functions latch instead of returning with the exception still on the VM. A pending termination is the one thing left there — it is not the addon's to catch and it unwinds as soon as the addon returns.
  • napi_is_exception_pending / napi_get_and_clear_last_exception read only the latch, as Node reads only last_exception (previously a VM termination read as "pending" but could never be cleared, which trips node-addon-api's swallow-on-shutdown heuristic).
  • napi_run_script evaluates without JSC::evaluate's catch-and-return, so a throw is latched like any other (status napi_generic_failure, as Node) and a termination stays put. The value latched is what the script threw, not the engine's Exception wrapper.
  • napi_call_function no longer throws a latched exception into the VM before refusing the call; the preamble already returns napi_pending_exception.
  • napi_create_dataview, napi_create_buffer[_copy], node_api_create_buffer_from_arraybuffer, napi_create_bigint_words, napi_run_script use NAPI_PREAMBLE like the rest of the file (and like Node, so they now honour the can-call-into-JS gate during teardown); napi_create_dataview over a detached ArrayBuffer is a TypeError instead of a crash; napi_get_all_property_names checks each get / toPropertyKey / getOwnPropertyDescriptor / push.
  • The exception checks read the scope directly rather than through JSC's RETURN_IF_EXCEPTION, so they no longer service VM traps: a worker.terminate() is observed at the addon's next JS entry / the can-call-into-JS gate rather than at an arbitrary napi call.
  • bun:ffi cc(): napi_throw_error from compiled C used to crash; an API-raised exception there is now thrown when the symbol returns.
  • An addon that calls napi_throw* from an Init registered via napi_module_register now fails the require() with that error (it was dropped).
  • V8 shim: v8::Isolate::ThrowException / ThrowError latch on the isolate and the FunctionTemplate call/construct and accessor trampolines throw on return (v8::Function::Call / Object::Get etc. still report a JS throw through their empty Maybe with it pending, as before). node_module_register reports an init-time throw through the slot process.dlopen throws from; process.dlopen clears its module/exports slots on every exit and throws whatever registration left there.
  • JSValue::to_object (Rust) goes through the generated null_is_throw wrapper so the validator sees its check.

How did you verify your code works?

New tests: napi_module_register + throwing Init; napi_create_dataview on a detached buffer (napi_pending_exception + TypeError); napi_run_script → napi_get_and_clear_last_exception yields the thrown TypeError / a thrown primitive unchanged (compared with Node; on current release napi_typeof rejects the value); v8::Isolate::ThrowException(42) followed by further API use before returning (compared with Node); cc() symbols that napi_throw_error / call a throwing JS function through napi_call_function (crash / lost on current release). Under BUN_JSC_validateExceptionChecks=1 on a debug build: test/napi/napi.test.ts 191/191, every test/napi/node-napi-tests js-native-api and node-api suite, test/v8/v8.test.ts 80/80, test/js/bun/ffi/cc.test.ts. node-addon-api (Error::New / NAPI_THROW_IF_FAILED / ObjectWrap), napi-rs (check_pending_exception! / throw_into) and neon error paths were traced against the new semantics and match Node's.