Follow-up to #398, tracking the TODO in packages/host/cpp/HermesNapiHost.cpp (HostContext::fatalException).
Current behavior
The hermes_napi_host::fatal_exception hook stringifies the error (stack, falling back to coercion), logs it through the host logger, and abort()s — the same observable outcome as Hermes' null-host default, just with better diagnostics. In Node, napi_fatal_exception instead triggers process.emit('uncaughtException'), which is observable and handleable. node-addon-api calls napi_fatal_exception whenever an exception escapes a thread-safe-function callback, so today a single throwing tsfn callback hard-kills a React Native app with no LogBox and no JS-side handler getting a say.
Returning is legal — only napi_fatal_error is noreturn
Worth stating explicitly because the two fatal APIs are easy to conflate:
-
napi_fatal_error is NAPI_NO_RETURN void (node_api.h) — it has no env and no error value, and must not return. Our override in RuntimeNodeApi.cpp logs and aborts; that stays as-is.
-
napi_fatal_exception is a plain napi_status-returning function, and Hermes' implementation at the pinned commit explicitly supports the host hook returning normally (API/napi/hermes_napi_error.cpp):
if (env->host_ && env->host_->fatal_exception) {
env->host_->fatal_exception(env->host_->data, env, err);
return napi_clear_last_error(env);
}
The hook is void (*)(void *data, napi_env env, napi_value err) with no noreturn contract; after it returns, the addon's napi_fatal_exception call returns napi_ok — matching Node, where emitting 'uncaughtException' returns to the caller (the process only dies if no handler is installed).
Proposed approach (open to alternatives)
fatal_exception runs on the JS thread with a live env and err valid for the duration of the call, so the routing can be done synchronously with pure Node-API against the passed env — keeping HermesNapiHost.cpp free of React Native/JSI includes:
napi_get_global → napi_get_named_property(global, "ErrorUtils") → napi_get_named_property(errorUtils, "reportFatalError"), type-checking each step.
napi_call_function(env, errorUtils, reportFatalError, 1, &err, nullptr) and return normally on success.
- On any failure —
ErrorUtils absent (non-RN embedder, early startup), the handler itself throwing (napi_pending_exception) — fall back to today's stringify + log_error + abort(), and guard reentrancy (a handler that itself triggers napi_fatal_exception) with a flag that short-circuits straight to the fallback.
Resulting semantics: in dev, RN's default handler shows LogBox with the real error and stack; in release, the default handler rethrows into the native crash path (approximating Node's process exit); apps can install ErrorUtils.setGlobalHandler to observe/handle, which is the moral equivalent of listening for 'uncaughtException'.
This also becomes testable on-device: a test addon calls napi_fatal_exception while the driver has a temporary ErrorUtils.setGlobalHandler installed, asserting the handler receives the error, the call returns napi_ok, and the app survives.
Follow-up to #398, tracking the
TODOinpackages/host/cpp/HermesNapiHost.cpp(HostContext::fatalException).Current behavior
The
hermes_napi_host::fatal_exceptionhook stringifies the error (stack, falling back to coercion), logs it through the host logger, andabort()s — the same observable outcome as Hermes' null-host default, just with better diagnostics. In Node,napi_fatal_exceptioninstead triggersprocess.emit('uncaughtException'), which is observable and handleable. node-addon-api callsnapi_fatal_exceptionwhenever an exception escapes a thread-safe-function callback, so today a single throwing tsfn callback hard-kills a React Native app with no LogBox and no JS-side handler getting a say.Returning is legal — only
napi_fatal_erroris noreturnWorth stating explicitly because the two fatal APIs are easy to conflate:
napi_fatal_errorisNAPI_NO_RETURN void(node_api.h) — it has no env and no error value, and must not return. Our override inRuntimeNodeApi.cpplogs and aborts; that stays as-is.napi_fatal_exceptionis a plainnapi_status-returning function, and Hermes' implementation at the pinned commit explicitly supports the host hook returning normally (API/napi/hermes_napi_error.cpp):The hook is
void (*)(void *data, napi_env env, napi_value err)with no noreturn contract; after it returns, the addon'snapi_fatal_exceptioncall returnsnapi_ok— matching Node, where emitting'uncaughtException'returns to the caller (the process only dies if no handler is installed).Proposed approach (open to alternatives)
fatal_exceptionruns on the JS thread with a live env anderrvalid for the duration of the call, so the routing can be done synchronously with pure Node-API against the passed env — keepingHermesNapiHost.cppfree of React Native/JSI includes:napi_get_global→napi_get_named_property(global, "ErrorUtils")→napi_get_named_property(errorUtils, "reportFatalError"), type-checking each step.napi_call_function(env, errorUtils, reportFatalError, 1, &err, nullptr)and return normally on success.ErrorUtilsabsent (non-RN embedder, early startup), the handler itself throwing (napi_pending_exception) — fall back to today's stringify +log_error+abort(), and guard reentrancy (a handler that itself triggersnapi_fatal_exception) with a flag that short-circuits straight to the fallback.Resulting semantics: in dev, RN's default handler shows LogBox with the real error and stack; in release, the default handler rethrows into the native crash path (approximating Node's process exit); apps can install
ErrorUtils.setGlobalHandlerto observe/handle, which is the moral equivalent of listening for'uncaughtException'.This also becomes testable on-device: a test addon calls
napi_fatal_exceptionwhile the driver has a temporaryErrorUtils.setGlobalHandlerinstalled, asserting the handler receives the error, the call returnsnapi_ok, and the app survives.