Skip to content

Commit bd6748d

Browse files
kraenhansenclaude
andcommitted
Route napi_fatal_exception through ErrorUtils.reportFatalError (#432)
HostContext::fatalException previously stringified the error and called abort() unconditionally, matching Hermes' null-host default but giving node-addon-api's tsfn error path (which calls napi_fatal_exception whenever an exception escapes a thread-safe-function callback) no chance of being observed or handled — a single throwing tsfn callback hard-killed the app with no LogBox and no JS-side handler getting a say. napi_fatal_exception (unlike the noreturn napi_fatal_error) is a plain napi_status-returning function, and the pinned Hermes commit's hermes_napi_error.cpp explicitly supports the host hook returning normally, so routing can be done synchronously against the passed env with plain Node-API calls, keeping HermesNapiHost.cpp free of React Native/JSI includes: - Attempt global.ErrorUtils.reportFatalError(err) via napi_get_global + napi_get_named_property (x2, type-checked at each step) + napi_call_function. - On success, return normally (napi_ok reaches the addon), matching Node's process.emit('uncaughtException') returning to the caller when a handler is installed. - On any failure (ErrorUtils/reportFatalError absent or not the right type, or the call itself throwing) fall back to the previous stringify + log_error + abort() path, clearing any pending exception first so the fallback's own Node-API calls aren't defeated by a stale exception. - Guard reentrancy with a HostContext member flag: if the ErrorUtils handler itself triggers another napi_fatal_exception, the nested call skips straight to the fallback instead of recursing. Adds a "minor" changeset for react-native-node-api: this is an observable behavior change for addons/apps that relied on the previous immediate abort. Not compiled or exercised on-device in this environment (no Android/iOS toolchain here) — see the PR description for what remains to be verified. Closes #402 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed0d4c5 commit bd6748d

3 files changed

Lines changed: 98 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-node-api": minor
3+
---
4+
5+
Route `napi_fatal_exception` through React Native's `ErrorUtils.reportFatalError` instead of unconditionally logging and calling `abort()`. This is what node-addon-api calls whenever an exception escapes a thread-safe-function callback, so a single throwing tsfn callback no longer hard-kills the app: in dev the error and its stack now surface in LogBox, in release RN's default handler rethrows into the native crash path, and apps can observe or handle it via `ErrorUtils.setGlobalHandler` — the moral equivalent of Node's `'uncaughtException'`. The previous stringify-and-abort behavior remains as a fallback for when `ErrorUtils`/`reportFatalError` isn't available (non-RN embedders, very early startup) or the handler itself throws.

‎packages/host/cpp/HermesNapiHost.cpp‎

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,63 @@ std::string describeError(napi_env env, napi_value err) {
149149
return "(unable to stringify the error value)";
150150
}
151151

152+
// Stringifies `err`, logs it and aborts — the pre-#402 behavior, kept as the
153+
// fallback for whenever routing through ErrorUtils isn't possible.
154+
[[noreturn]] void abortWithFatalException(napi_env env, napi_value err) {
155+
log_error("napi_fatal_exception: %s", describeError(env, err).c_str());
156+
abort();
157+
}
158+
159+
// Attempts to route `err` through React Native's
160+
// `global.ErrorUtils.reportFatalError`, the moral equivalent of Node's
161+
// process.emit('uncaughtException'): in dev this surfaces the real error and
162+
// stack in LogBox, in release it feeds RN's default rethrow-to-native-crash
163+
// handler, and apps can observe/handle it via ErrorUtils.setGlobalHandler.
164+
// Returns whether the error was routed successfully. On any failure —
165+
// ErrorUtils or reportFatalError absent/not the right type, or the call
166+
// itself throwing — clears any pending exception before returning false, so
167+
// the caller's fallback (which does its own Node-API calls) starts clean.
168+
bool tryReportFatalError(napi_env env, napi_value err) {
169+
napi_value global = nullptr;
170+
if (napi_get_global(env, &global) != napi_ok) {
171+
return false;
172+
}
173+
174+
napi_valuetype type = napi_undefined;
175+
napi_value error_utils = nullptr;
176+
if (napi_get_named_property(env, global, "ErrorUtils", &error_utils) !=
177+
napi_ok ||
178+
napi_typeof(env, error_utils, &type) != napi_ok ||
179+
type != napi_object) {
180+
return false;
181+
}
182+
183+
napi_value report_fatal_error = nullptr;
184+
if (napi_get_named_property(env, error_utils, "reportFatalError",
185+
&report_fatal_error) != napi_ok ||
186+
napi_typeof(env, report_fatal_error, &type) != napi_ok ||
187+
type != napi_function) {
188+
return false;
189+
}
190+
191+
napi_value argv[] = {err};
192+
napi_status call_status = napi_call_function(
193+
env, error_utils, report_fatal_error, 1, argv, nullptr);
194+
if (call_status != napi_ok) {
195+
// Most likely napi_pending_exception (the handler itself threw). Clear
196+
// it so the fallback path — which makes further Node-API calls — isn't
197+
// itself defeated by a stale pending exception.
198+
bool is_pending = false;
199+
if (napi_is_exception_pending(env, &is_pending) == napi_ok && is_pending) {
200+
napi_value discarded = nullptr;
201+
napi_get_and_clear_last_exception(env, &discarded);
202+
}
203+
return false;
204+
}
205+
206+
return true;
207+
}
208+
152209
} // namespace
153210

154211
HostContext::HostContext(JsDispatcher dispatchToJs)
@@ -232,20 +289,37 @@ void HostContext::postTask(void *loop_data, void *task_data,
232289
}
233290
}
234291

235-
void HostContext::fatalException(void *, napi_env env,
292+
void HostContext::fatalException(void *data, napi_env env,
236293
napi_value err) noexcept {
237-
// Called on the JS thread by napi_fatal_exception(). Node routes this to
238-
// process.emit('uncaughtException'); with no process object we log the
239-
// error and abort — the same observable outcome as Hermes' null-host
240-
// default, but surfaced through the host logger. `err` is only valid for
241-
// the duration of this call, so it is stringified before returning.
242-
// TODO: Route through React Native's error handling (ErrorUtils / LogBox),
243-
// with abort() as the fallback, to get closer to Node's observable and
244-
// handleable 'uncaughtException' — note node-addon-api calls
245-
// napi_fatal_exception whenever an exception escapes a thread-safe
246-
// function callback, so today a single throwing tsfn callback is fatal.
247-
log_error("napi_fatal_exception: %s", describeError(env, err).c_str());
248-
abort();
294+
// Called on the JS thread by napi_fatal_exception() — Hermes returns
295+
// napi_ok to the caller once this returns, matching Node, where emitting
296+
// 'uncaughtException' returns to the caller and only aborts the process if
297+
// no handler is installed (unlike napi_fatal_error, this hook has no
298+
// noreturn contract). `err` is only valid for the duration of this call.
299+
//
300+
// Routed through ErrorUtils.reportFatalError, RN's own
301+
// uncaughtException-equivalent: in dev the default handler shows LogBox
302+
// with the real error and stack, in release it rethrows into the native
303+
// crash path, and apps can observe/handle it via
304+
// ErrorUtils.setGlobalHandler. node-addon-api calls napi_fatal_exception
305+
// whenever an exception escapes a thread-safe function callback, so this
306+
// is what stands between a throwing tsfn callback and a silent, unhandled
307+
// abort.
308+
auto *self = static_cast<HostContext *>(data);
309+
if (self->inFatalException_) {
310+
// Reentrant call: the ErrorUtils handler (or something it triggered)
311+
// itself hit a fatal exception. Recursing back into reportFatalError
312+
// could loop forever, so go straight to the fallback.
313+
abortWithFatalException(env, err);
314+
}
315+
self->inFatalException_ = true;
316+
bool routed = tryReportFatalError(env, err);
317+
self->inFatalException_ = false;
318+
if (!routed) {
319+
// ErrorUtils/reportFatalError absent (non-RN embedder, or called before
320+
// React Native has installed it) or the handler itself threw.
321+
abortWithFatalException(env, err);
322+
}
249323
}
250324

251325
} // namespace callstack::react_native_node_api

‎packages/host/cpp/HermesNapiHost.hpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ class HostContext : public std::enable_shared_from_this<HostContext> {
120120

121121
JsDispatcher dispatchToJs_;
122122
hermes_napi_host host_;
123+
// Reentrancy guard for fatalException(): true for the duration of routing
124+
// an error through ErrorUtils.reportFatalError. fatalException always runs
125+
// synchronously on the JS thread (see its doc comment), so a plain member
126+
// — no atomics or thread_local — is sufficient to detect a handler that
127+
// itself triggers napi_fatal_exception before the outer call returns.
128+
bool inFatalException_ = false;
123129
};
124130

125131
} // namespace callstack::react_native_node_api

0 commit comments

Comments
 (0)