Skip to content

QuickJS: report success from the napi_throw family - #225

Merged
bkaradzic-microsoft merged 1 commit into
BabylonJS:mainfrom
bkaradzic-microsoft:quickjs-throw-status
Aug 20, 2026
Merged

QuickJS: report success from the napi_throw family#225
bkaradzic-microsoft merged 1 commit into
BabylonJS:mainfrom
bkaradzic-microsoft:quickjs-throw-status

Conversation

@bkaradzic-microsoft

Copy link
Copy Markdown
Member

Problem

napi_throw, napi_throw_error, napi_throw_type_error and napi_throw_range_error returned napi_pending_exception after successfully scheduling the throw.

In Node-API that status means "this call failed because an exception was already pending", not "a throw is now pending". The upstream implementation returns napi_clear_last_error(env) (i.e. napi_ok).

Because the QuickJS port reported failure, Error::ThrowAsJavaScriptException in napi-inl.h took its failure branch on every native throw:

napi_status status = napi_throw(_env, Value());
#ifdef NAPI_CPP_EXCEPTIONS
if (status != napi_ok) {
throwError::New(_env); // consumes the exception that was just set
}
#endif

Error::New(env) calls napi_get_and_clear_last_exception, so the pending JS exception is discarded and a fresh C++ exception is thrown out of details::WrapCallback. ExternalCallback::Callback then catches it, observes !JS_HasException(ctx), and rebuilds the error from e.what().

By that point the HandleScope opened by ThrowAsJavaScriptException has been destroyed during unwinding, so stringifying the message reads freed memory.

Impact

Two symptoms, both of which reproduce today:

  1. Wrong error surfaced to JS. The real error is replaced by InternalError: Uncaught C++ exception: <message>. Every native throw on QuickJS is affected, so err.name and err instanceof TypeError are wrong throughout.
  2. Use-after-free. On Linux this segfaults. Backtrace from a BabylonNative CI core dump:
#0 js_dup quickjs.c:1628 <-- SIGSEGV
#1 js_force_tostring quickjs.c:4813
#3 JS_ToCStringLen
#4 napi_get_value_string_utf8 js_native_api_quickjs.cc:696
#5 Napi::String::Utf8Value napi-inl.h:1118
#7 Napi::Error::Message napi-inl.h:3087
#8 Napi::Error::what napi-inl.h:3157
#9 ExternalCallback::Callback js_native_api_quickjs.cc:164

The JSValue being stringified carries JS_TAG_STRING with an unaligned, freed pointer.

I instrumented the catch in ExternalCallback::Callback in a BabylonNative QuickJS build and confirmed that all ~50 native throws in that test run escaped WrapCallback with hasExc=0. After this change the count is 0.

Fix

Return napi_ok from the four throw entry points, matching upstream. The exception stays pending, WrapCallback returns normally, and the fragile e.what() fallback is never entered.

Test

Added a strict assertion to the existing URLSearchParams.set() arity throw, checking the error type and exact message rather than a substring. The pre-existing .to.throw() test could not catch this, because "Uncaught C++ exception: <msg>" still contains the expected substring.

Verified on Linux QuickJS (RelWithDebInfo):

result
without the C++ changeexpected 'InternalError' to equal 'Error' — 212 passing, 1 failing
with the C++ change213 passing, 10/10 gtest

Also verified in a BabylonNative QuickJS build on Windows: 21/21 gtest, 49 JS assertions, exit 0, and zero escapes from WrapCallback.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns the QuickJS Node-API (napi_*) throw APIs with upstream Node-API behavior by reporting success (napi_ok) after scheduling a JavaScript throw, preventing node-addon-api from taking an error-handling path that discards the real JS exception and can lead to use-after-free when building a fallback error message.

Changes:

  • Update QuickJS napi_throw* functions to clear last error and return napi_ok after throwing, matching upstream semantics.
  • Add a regression test ensuring native-thrown errors preserve their original JS type and exact message (no “Uncaught C++ exception …” fallback).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
Tests/UnitTests/Scripts/tests.tsAdds a regression assertion for preserved error type/message when native code throws.
Core/Node-API/Source/js_native_api_quickjs.ccMakes napi_throw, napi_throw_error, napi_throw_type_error, and napi_throw_range_error report success after scheduling the exception.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

napi_throw, napi_throw_error, napi_throw_type_error and napi_throw_range_error
returned napi_pending_exception after successfully scheduling the throw. That
status means "the call failed because an exception is already pending", so
node-addon-api's Error::ThrowAsJavaScriptException treated every native throw as
a failed throw and re-threw Error::New(env). That constructor consumes the
pending exception via napi_get_and_clear_last_exception, so the C++ exception
escaped WrapCallback with no JS exception set.
ExternalCallback::Callback then took its fallback path and rebuilt the error
from e.what(). By that point the handle scope opened by
ThrowAsJavaScriptException had closed, so stringifying the message read freed
memory: on Linux this segfaults in js_dup, and elsewhere it silently replaces
the error with "InternalError: Uncaught C++ exception: ...".
Return napi_ok instead, matching the upstream Node-API implementation, so the
exception stays pending and propagates unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09

@bghgarybghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

LGTM

@bkaradzic-microsoft
bkaradzic-microsoft merged commit 2390c63 into BabylonJS:mainAug 20, 2026
25 checks passed
bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Aug 20, 2026
BabylonJS/JsRuntimeHost#225 makes the QuickJS napi_throw family report success,
so a native throw no longer escapes the callback wrapper and get rebuilt from a
freed error. Without it, the addPath() arity check added here segfaults the
QuickJS unit tests on Linux. Also picks up BabylonJS#223.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/JsRuntimeHost that referenced this pull request Aug 21, 2026
…escape UAF, BabylonJS#225 QuickJS throw)
Takes upstream's escapable-handle-scope implementation (BabylonJS#223) over shotgun's
earlier 111efc5 attempt. Upstream's is strictly more correct: it keys scopes by
a monotonic counter rather than a position in handle_scope_stack (two scopes
opened with no handle allocated between them share a position, so a
position-derived token cannot tell them apart), holds the escaped handle beside
the scope instead of inserting it into the middle of the stack (which shifted
every entry above it and invalidated the recorded start of any still-open nested
scope), and rejects out-of-LIFO closes rather than corrupting the stack.
Shared.cpp is resolved hunk-by-hunk rather than with `git checkout --theirs`:
the two quickjs files carry only the superseded 111efc5, but Shared.cpp also
carries shotgun's unhandled-promise-rejection tests (still unlanded upstream as
BabylonJS#204), which taking the whole file would have silently dropped. The merged file
is exactly the union of both sides' test cases, 9 + 10 -> 12 with 7 shared.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Aug 21, 2026
BabylonJS/JsRuntimeHost#225 makes the QuickJS napi_throw family report success,
so a native throw no longer escapes the callback wrapper and get rebuilt from a
freed error. Without it, the addPath() arity check added here segfaults the
QuickJS unit tests on Linux. Also picks up BabylonJS#223.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
bkaradzic-microsoft added a commit to BabylonJS/BabylonNative that referenced this pull request Aug 24, 2026
Moves the JsRuntimeHost pin from `9271f13` (2026-07-28) to `cb988ba`,
which is current JsRuntimeHost `master`. That picks up three fixes:
- BabylonJS/JsRuntimeHost#223 — keep escaped handles alive when their
escapable scope closes (use-after-free)
- BabylonJS/JsRuntimeHost#225 — QuickJS: report success from the
`napi_throw` family
- BabylonJS/JsRuntimeHost#227 — fix V8 N-API leaking every promise ever
created
## Validation
Windows/x64, RelWithDebInfo, MSVC 14.44 — run against both engines whose
N-API ports these fixes touch:
| Engine | UnitTests | Validation sweep | Peak |
|---|---|---|---:|
| V8 | 21/21 | `ran=305 passed=305 failed=0` | 2598 MB |
| QuickJS | 21/21 | `ran=305 passed=305 failed=0` | 1910 MB |
To be clear about what this does and does not buy: the bump is
**behaviour-neutral on this suite**. A same-tree V8 baseline on the old
pin also measured `305/305` at 2558 MB, so the promise leak is not large
enough to surface over 305 tests. The value here is the three
correctness fixes, not a measurable win on the current suite.
It does matter at larger scale. On the NativeDawn branch, whose sweep
runs 632 tests, the same pin difference is the gap between the sweep
completing at ~5.4 GB and aborting partway through at ~12.1 GB.
Co-authored-by: Branimir Karadzic <branimirkaradzic@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@bkaradzic-microsoft@bghgary@bkaradzic