JSC Node-API: reference primitives and coerce property receivers (fixes a RELEASE_ASSERT on non-object exceptions) - #239
Conversation
ToJSObject is a reinterpret_cast whose assert is compiled out in RelWithDebInfo, so every entry point that handed a caller value straight to a JSObject* API tripped JavaScriptCore's RELEASE_ASSERT (JSCell::getOwnPropertySlot) on a primitive. The reachable case is any non-object exception: node-addon-api wraps a pending exception with napi_create_reference, and the watchdog's termination exception is the bare string "JavaScript execution terminated.", as is `throw "text"`. - napi_create_reference follows Node: objects keep the sentinel scheme, symbols are held for the life of the reference (the C API has no weak handle for them), other primitives are strong while the count is positive and released at zero (Node-API 10; napi_invalid_arg before). The status of the reference's init is now propagated instead of being dropped, napi_get_reference_value reports NULL instead of leaving *result unset, and napi_reference_unref refuses an already-zero count. - Property get/set/has/delete and napi_get_prototype coerce the receiver with ToObject as Node does; null/undefined report napi_object_expected with the TypeError pending. - napi_wrap/unwrap/add_finalizer, napi_get_value_external, napi_get_array_length, napi_call_function, napi_new_instance and napi_instanceof validate their object/function argument first.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in the implementation, along with test-coverage gaps.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR hardens the JavaScriptCore Node-API backend against primitive values and adds regression coverage.
Changes:
- Adds primitive-aware reference lifecycle handling.
- Coerces property receivers and validates API arguments.
- Adds JavaScriptCore-specific tests and build configuration.
File summaries
| File | Changes and final review findings |
|---|---|
Tests/UnitTests/Shared/Shared.cpp |
Adds exception, property, and reference regression tests. Nit (2 votes): NAPI 10 reference behavior is not exercised by the default test build. |
Tests/UnitTests/CMakeLists.txt |
Enables JavaScriptCore-specific tests. Nit (2 votes): The Android JSC target lacks the corresponding test definition. |
Core/Node-API/Source/js_native_api_javascriptcore.cc |
Implements coercion, validation, and primitive references. Critical (2 votes): Zero-count object references may promote stale pointers. Critical (3 votes): napi_call_function does not handle primitive receivers. Moderate (3 votes): napi_instanceof accepts any object as a constructor. Moderate (1 vote): Zero-count primitive references incorrectly return success when re-referenced. Moderate (1 vote): Callable validation is missing in napi_call_function. Moderate (1 vote): napi_get_value_external accepts ordinary objects instead of rejecting them. |
Review details
Suppressed comments (3)
Core/Node-API/Source/js_native_api_javascriptcore.cc:808
- After a Node-API 10 primitive reference is unref'd from 1 to 0,
unref()clears_value. A subsequentnapi_reference_ref()enters this early return, leaves the count at zero, and the public function still returnsnapi_ok; the API contract requires an error when a zero-count target is unavailable. Return success/failure fromref()and propagatenapi_generic_failurehere, with a regression assertion.
if (_value == nullptr) {
// A primitive released at count zero cannot come back; Node reports a count of zero too.
return;
Core/Node-API/Source/js_native_api_javascriptcore.cc:1765
JSValueIsObjectis not a callable check. A plain object passes this guard and is still handed toJSObjectCallAsFunction, so the call reports a pending TypeError instead of rejecting the non-function during argument validation; the existingnapi_typeofpredicate (JSObjectIsFunction/JSObjectIsConstructor) shows how this backend handles callable constructors. Check that predicate before invoking.
RETURN_STATUS_IF_FALSE(env, JSValueIsObject(env->context, ToJSValue(func)), napi_function_expected);
Core/Node-API/Source/js_native_api_javascriptcore.cc:2161
- The new object check prevents the cast from crashing on primitives, but ordinary objects still take the
info == nullptrpath and returnnapi_okwith*result == nullptr.napi_get_value_externalshould reject a non-external value; the V8 backend returnsnapi_invalid_argfor this case (js_native_api_v8.cc:2632-2638). Check the retrievedExternalInfotype and returnnapi_invalid_arginstead.
RETURN_STATUS_IF_FALSE(env, JSValueIsObject(env->context, ToJSValue(value)), napi_invalid_arg);
ExternalInfo* info = NativeInfo::Get<ExternalInfo>(ToJSObject(env, value));
*result = (info != nullptr && info->Type() == NativeType::External) ? info->Data() : nullptr;
- Files reviewed: 3/3 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| #if NAPI_VERSION >= 10 | ||
| napi_value value{}; | ||
| uint32_t count{1}; | ||
| bool ok{status == napi_ok && | ||
| napi_get_reference_value(nenv, ref, &value) == napi_ok && |
There was a problem hiding this comment.
Agreed that the NAPI_VERSION >= 10 branch is not exercised by the checked-in configuration: the tree is pinned to 5 (see the discussion on #229), and the only difference above 10 is that non-symbol primitives are held instead of refused, which the fork's NAPI_VERSION-bump branch builds with. I would rather not add a second JavaScriptCore job to the matrix in a crash-fix PR; if you prefer, I can drop the version-gated branch here and reintroduce it with the version bump.
ba8c334 to
32ab92c
Compare
PrimitiveExceptionSurvivesNativeCatch runs on every engine: `throw 'plain text'` must surface as a catchable Napi::Error whose Message() is callable, with the runtime still usable afterwards. It killed the process on JavaScriptCore before the previous commit. The coercion and reference semantics are Node's, and the other engines diverge (Chakra references any value, some reject a primitive receiver), so PropertyAccessCoercesPrimitiveReceiver and ReferencesToPrimitivesFollowNode build for JavaScriptCore only, behind a new JSRUNTIMEHOST_NAPI_ENGINE_JAVASCRIPTCORE test define. The reference test pins both the pre-10 napi_invalid_arg branch and the Node-API 10 hold-then-release branch.
32ab92c to
2a07b6a
Compare
…, instanceof constructors - napi_reference_ref no longer promotes a weak object reference whose target was collected (or whose address a newer object reuses); the liveness check that napi_get_reference_value already performs is shared and the count stays at zero, as in Node. - napi_call_function boxes a primitive receiver (ToObject) and maps undefined/null to the null receiver instead of reinterpreting the value. - napi_instanceof requires a function or constructor for the constructor argument (napi_function_expected), not any object. - The JavaScriptCore-only regression tests also build into the Android UnitTestsJNI target, and the receiver coercion is covered.
|
Review addressed in f8e8152 (weak-object promotion check, receiver coercion in napi_call_function, instanceof constructor check, Android test define + receiver regression test). Fork twin rebeckerspecialties#24 at that head: 24/24 green — https://github.com/rebeckerspecialties/JsRuntimeHost/actions/runs/34841369594 |
|
Follow-ups filed separately: #246 makes the JSI shim surface script exceptions from |
Problem
ToJSObjectin the JavaScriptCore Node-API backend is areinterpret_castwhoseassertis compiled out in RelWithDebInfo, so every entry point that handed a caller value straight to aJSObject*API tripped JavaScriptCore'sRELEASE_ASSERT(JSC::JSCell::getOwnPropertySlot) when given a primitive. macOS reports that asEXC_BREAKPOINT/SIGKILL, so the process just disappears with exit 137.The reachable case is any non-object exception: node-addon-api's
Napi::Errorwraps a pending exception withnapi_create_reference, which walked straight intoJSObjectHasPropertyForKeyon the value.throw "text"from script does it, and so does JavaScriptCore's own execution-time-limit termination exception, which has been the bare string"JavaScript execution terminated."since 2021 (VM::ensureTerminationException) — terminating a busy worker throughJSContextGroupSetExecutionTimeLimitkilled the process.Changes
napi_create_referencefollows Node's rules: objects keep the existing sentinel scheme; symbols are held for the life of the reference (the C API has no weak handle for them, and a weak symbol reference must keep resolving while the symbol is alive); other primitives are strong while the count is positive and released at zero (Node-API 10 semantics;napi_invalid_argbefore 10, as Node does).napi_get_reference_valuenow reportsNULLinstead of leaving*resultunset, andnapi_reference_unrefrefuses an already-zero count (napi_generic_failure, as Node).napi_get_prototypecoerce the receiver withToObjectlike Node'sCHECK_TO_OBJECT;null/undefinedreportnapi_object_expectedwith the TypeError pending.napi_wrap/napi_unwrap/napi_remove_wrap/napi_add_finalizer,napi_get_value_external,napi_get_array_length(napi_array_expected),napi_call_function,napi_new_instanceandnapi_instanceof(napi_function_expected) validate their object/function argument first instead of reinterpreting it.Tests
NodeApi.PrimitiveExceptionSurvivesNativeCatch(every engine except the V8JSI shim, which surfaces a script throw of a primitive asjsi::JSErrorrather thanNapi::Error):throw 'plain text'caught asNapi::Error,Message()callable, runtime still evaluates afterwards. Crashed the process on JavaScriptCore before this change; on Hermes the script has to go throughNapi::Eval, sinceEnv::RunScriptcalls the 4-argumentnapi_run_scriptthat Hermes does not implement.NodeApi.PropertyAccessCoercesPrimitiveReceiverandNodeApi.ReferencesToPrimitivesFollowNode(JavaScriptCore only, via a newJSRUNTIMEHOST_NAPI_ENGINE_JAVASCRIPTCOREtest define): pin the coercion and reference semantics, including theNAPI_VERSION >= 10branch.Verified locally on macOS (system JavaScriptCore): full
UnitTestsgreen. Fork twin: rebeckerspecialties#24.