Repository navigation
napi: report Node-API version 10 and accept zero-length external strings - #34146
Conversation
WalkthroughChangesN-API v10 external string support
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced with Rebased on cc0c1e8; the CI status: the napi changes are green on every lane across builds 72876, 72885, and 72943. Remaining red on 72943 is unrelated: |
|
Updated 10:53 AM PT - Jul 14th, 2026
❌ @robobun, your commit 08b5f64 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34146That installs a local version of the PR into your bun-34146 --bun |
|
Independently reproduced and fixed the same two gaps (zero-length rejection + utf16 |
There was a problem hiding this comment.
LGTM — the follow-up commits (2ff5f94, f9e7fe5) address all three of my earlier inline concerns, and the (NULL, 0) question was resolved empirically against Node/V8.
What was reviewed:
length == 0branches:doFinalizernull-checks the callback (napi.h:417),NAPI_RETURN_SUCCESS_UNLESS_EXCEPTIONhandles a throwing finalizer, and the newhasPendingException()guard matchesnapi_create_external_buffer/_arraybuffer so callers cleanly retain ownership on pre-existing exceptions.- utf16 non-empty path now writes
*copied = false; latin1's existing write was hoisted to the same position. napi_get_version→ 10 matchesprocess.versions.napiin BunProcess.cpp:254; vendoredtest_general/test_stringguards updated in lockstep.get_all_property_namesdedup: the surviving{status, keys}variant is still registered and the one call site expecting a bare array was updated.
Extended reasoning...
Overview
This PR bumps napi_get_version() from 9 to 10 (aligning with process.versions.napi), fixes node_api_create_external_string_latin1/_utf16 to accept zero-length input (previously rejected because WTF::ExternalStringImpl cannot represent empty strings), and fixes the utf16 variant to write *copied on success. It also deduplicates a get_all_property_names test helper that was defined twice on main after two PRs merged. Files touched: src/jsc/bindings/napi.cpp, src/runtime/napi/napi_body.rs, and five test files.
Review history
I left three inline comments across two prior runs, all now resolved:
NAPI_RETURN_SUCCESSafter synchronousdoFinalizerwould tripassertNoException()if the finalizer threw — fixed by switching toNAPI_RETURN_SUCCESS_UNLESS_EXCEPTION(2ff5f94).- The switch to
_UNLESS_EXCEPTIONopened a path where a pre-stashed napi exception could causenapi_pending_exceptionto be returned after the finalizer freedstr, risking double-free in callers that free-on-non-ok — fixed by addingNAPI_RETURN_EARLY_IF_FALSE(env, !env->hasPendingException(), napi_pending_exception)before adoption (f9e7fe5), matching the siblingnapi_create_external_buffer/_arraybuffer pattern. (str=NULL, length=0)— the author tested empirically and Node/V8 aborts on this viaCHECK(resource && resource->data()), so Bun returningnapi_invalid_argis strictly better than matching Node's crash. No change needed.
The remaining path-2 case (finalizer itself throws → returns napi_pending_exception after freeing) was consciously left as-is with a clear rationale: returning napi_ok with a live VM exception would trip the assert, and a throwing external-string finalizer is an addon bug in both runtimes.
Security risks
None. No user-controlled parsing, no auth/crypto, no path handling. The ownership-contract concern (double-free on non-ok after finalizer ran) was the closest thing to a memory-safety issue and has been addressed for the reachable case.
Level of scrutiny
NAPI C++ bindings warrant careful review for exception-scope and ownership correctness, which is why this went through three rounds. The final shape now matches established sibling code (napi_create_external_buffer), doFinalizer correctly handles a null callback, and the new test uses checkSameOutput to diff Bun against Node byte-for-byte. The vendored Node test_string suite now exercises empty external strings unconditionally.
Other factors
- The
get_all_property_namesdedup fixes a real build break on main; I verified the surviving definition is still registered and the one call site that expected a bare array (module.js) was updated to destructure{keys}. - robobun mentioned a parallel branch with a templated shared helper — that's a nice-to-have refactor, not a correctness concern, and can be a follow-up.
- Version bump is consistent:
process.versions.napiwas already "10" in BunProcess.cpp.
There was a problem hiding this comment.
LGTM — all three prior concerns addressed (2ff5f94, f9e7fe5, and the empirical (NULL, 0) check against V8).
What was reviewed:
length == 0branches now guardhasPendingException()before adopting, matchingnapi_create_external_buffer/_arraybuffer;doFinalizernull-checksfinalize_cbso a null finalizer is a no-op.napi_get_version()bump to 10 agrees withprocess.versions.napiin BunProcess.cpp:254.- utf16
*copied = falsenow written on the non-empty success path;checkSameOutputtest pins all three behaviors against Node. get_all_property_namesdedup keeps the{status, keys}variant and updates the one bare-array caller in module.js.
Extended reasoning...
Overview
Three focused changes: (1) bump napi_get_version() from 9 to 10 in napi_body.rs to match the already-shipped v10 surface and process.versions.napi; (2) make node_api_create_external_string_latin1/_utf16 accept length == 0 by returning jsEmptyString, writing *copied = false, and invoking the finalizer synchronously (WTF::ExternalStringImpl cannot represent empty strings); (3) fix the utf16 variant to write *copied on the non-empty success path. Also deduplicates get_all_property_names in the test addon (main was broken by two merged PRs each adding a definition), removes Bun-only if (str.length > 0) guards from the vendored test_string/test.js, and bumps the vendored test_general/test.js expected version.
Security risks
None. No user-controlled parsing, no auth/crypto/permissions. The memory-ownership contract (finalizer invocation vs. caller-retains-on-error) was the risk here; three review rounds hardened it — the hasPendingException() guard now rejects before adopting so callers cleanly retain ownership on non-ok, and the remaining path-2 case (a finalizer that itself throws after freeing) is an addon bug in both runtimes as the author noted.
Level of scrutiny
Medium. N-API is a compatibility-critical surface, but the change is narrow (~15 lines × 2 nearly-identical functions), follows the exact pattern of the sibling napi_create_external_buffer/_arraybuffer in the same file, and is pinned by a checkSameOutput test that byte-diffs against Node. The vendored Node test_string suite now exercises the empty-string path directly.
Other factors
This is my third pass. All three prior inline comments are resolved: the assertNoException debug assert (fixed via NAPI_RETURN_SUCCESS_UNLESS_EXCEPTION), the double-free-on-pending-exception path (fixed via the hasPendingException() early return), and the (NULL, 0) question (author verified empirically that V8's String::NewExternalOneByte aborts on null data, so returning napi_invalid_arg is strictly better than Node). doFinalizer null-checks finalize_cb (napi.h:417), so the length == 0 branch is safe with a null finalizer. A separate branch independently arrived at the same fix, and the bug hunting system found nothing on this revision.
napi_get_version() returned 9 while the full v10 symbol set (node_api_create_external_string_*, node_api_create_property_key_*) is exported and functional, and process.versions.napi already reads "10". Addons that feature-detect via napi_get_version() >= 10 took the fallback path even though the v10 APIs work. Also fixes the two v10 gaps that actually existed: node_api_create_external_string_latin1/utf16 rejected length==0 with napi_invalid_arg because WTF::ExternalStringImpl does not allow empty strings. Now return jsEmptyString, set *copied=false, and invoke the finalizer immediately, matching Node.js/V8. The utf16 variant was also never writing *copied on success; it now does. Unblocks the upstream test_string empty-string cases that were guarded for Bun, and bumps the vendored test_general expected version. Also deduplicates get_all_property_names in the test addon (merged twice via #34126 and #34130), which was preventing the napi test addon from compiling on main.
doFinalizer runs user code that may napi_throw_*; the unless-exception variant propagates napi_pending_exception instead of tripping assertNoException under validateExceptionChecks.
Matches napi_create_external_buffer/_arraybuffer: a stashed napi_throw_* passes NAPI_PREAMBLE, so gate on env->hasPendingException() before the finalizer runs so the caller cleanly retains ownership on napi_pending_exception.
eb1537a to
08b5f64
Compare
There was a problem hiding this comment.
LGTM — all prior review threads addressed; the final shape matches the napi_create_external_buffer/_arraybuffer sibling pattern exactly.
Checked: doFinalizer null-checks finalize_cb so the length==0 branch is safe with a null finalizer; *copied is written only after the pending-exception guard so out-params stay untouched on non-ok; the (NULL, 0) case staying napi_invalid_arg is correct given V8 aborts there. Tests pin behavior via checkSameOutput against Node and un-gate the vendored test_string empty-string cases.
Extended reasoning...
Overview
Bumps napi_get_version() from 9 → 10 (aligning with process.versions.napi), and fixes two gaps in node_api_create_external_string_latin1/_utf16: accept length == 0 by returning jsEmptyString + running the finalizer immediately (WTF::ExternalStringImpl can't represent empty), and write *copied = false on the utf16 success path. Adds a hasPendingException() guard before adopting str so callers cleanly retain ownership on napi_pending_exception. ~30 lines of native change plus a standalone C++ test, a checkSameOutput test, and un-gating of vendored Node test_string empty-string assertions.
Security risks
None. No user-controlled parsing, no auth/crypto. The ownership contract ("non-ok ⇒ caller still owns str") was the one memory-safety concern; it's now enforced by rejecting pending exceptions before the finalizer runs, matching the sibling external-buffer/arraybuffer adopters line-for-line.
Level of scrutiny
Medium — native N-API code with finalizer/ownership semantics. This went through three rounds of review here (exception-scope assert, pending-exception-before-adoption, (NULL, 0) edge case), each addressed with either a fix or an empirical Node.js counter-test. The final code is small, mechanical, and copies the exact guard pattern already shipping in napi_create_external_buffer/_arraybuffer.
Other factors
The new test uses checkSameOutput so it asserts byte-for-byte parity with Node for version, status, copied, finalizer-fired, and length across both encodings plus a non-empty utf16 control. The vendored test_string/test.js guards are removed so empty strings now round-trip through the external creators, and test_general/test.js pins version 10. doFinalizer (napi.h:417) early-returns on null finalize_cb, so passing nullptr in the length==0 path is safe. The remaining "throwing finalizer returns napi_pending_exception after freeing" edge is an addon bug in both runtimes and was reasonably left as-is.
Problem
napi_get_version()returns 9, but Bun already exports and implements the complete Node-API v10 surface (node_api_create_external_string_latin1/utf16,node_api_create_property_key_latin1/utf8/utf16), andprocess.versions.napialready reads"10". Spec-following addons that feature-detect withnapi_get_version() >= 10take the degraded fallback path even though the v10 APIs work.While auditing the v10 surface, two actual gaps:
node_api_create_external_string_latin1/_utf16rejectlength == 0withnapi_invalid_arg(WTF::ExternalStringImpl cannot represent empty strings). Node.js returns an empty string, writes*copied = false, and invokes the finalizer immediately.node_api_create_external_string_utf16never wrote*copiedon success (the latin1 variant did).Fix
src/runtime/napi/napi_body.rs: bump the hardcoded version from 9 to 10, noting it must trackprocess.versions.napi.src/jsc/bindings/napi.cpp: for both external-string creators, whenlength == 0returnjsEmptyString, set*copied = false, and call the finalizer synchronously. The utf16 variant now also writes*copied = falseon the non-empty success path. Both now reject withnapi_pending_exceptionbefore adoptingstrwhen a napi exception is already stashed, matchingnapi_create_external_buffer/_arraybuffer, so callers cleanly retain ownership on non-ok.Tests
test_napi_v10_surfaceintest/napi/napi.test.tscompares Bun against Node viacheckSameOutput: assertsnapi_get_version() >= 10, empty external strings returnstatus=0 copied=0 finalized=1 length=0for both encodings, andcopied=0for a non-empty utf16 external string.if (str.length > 0)guards in the vendoredtest_string/test.jsso empty strings now round-trip throughTestLatin1External/TestUtf16External.test_general/test.jsexpected version to 10.Verification
no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi.test.ts