Skip to content

fix(ext/napi): implement zero-copy external Latin-1 strings#33283

Open
bartlomieju wants to merge 2 commits intomainfrom
fix/napi-external-strings
Open

fix(ext/napi): implement zero-copy external Latin-1 strings#33283
bartlomieju wants to merge 2 commits intomainfrom
fix/napi-external-strings

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

Summary

node_api_create_external_string_latin1 previously always copied the string data and immediately called the finalize callback. This implements true zero-copy using V8's external one-byte string API.

node_api_create_external_string_utf16 still copies because rusty_v8 doesn't expose a non-static external two-byte string API. The *copied flag is set correctly so well-behaved callers handle both cases.

How it works

When a finalize callback is provided:

  1. Creates a V8 external string via v8::String::new_external_onebyte_raw() pointing directly to the caller's buffer (zero-copy)
  2. Registers the finalize callback in a global metadata map keyed by buffer address
  3. V8 calls our destructor when the string is GC'd, which looks up and invokes the NAPI finalize callback
  4. Sets *copied = false

Falls back to copy path when:

  • No finalize callback (can't guarantee buffer lifetime without one)
  • V8 rejects the external string (e.g. buffer too small for V8's external string threshold)

Refs: Node.js ExternalOneByteStringResource

Test plan

  • test_external_latin1 -- creates external Latin-1 string, verifies content roundtrip
  • ./x test-napi -- 115 passed, 1 pre-existing failure
  • ./x lint --js passes

🤖 Generated with Claude Code

node_api_create_external_string_latin1 previously always copied the
string data and immediately called the finalize callback. This
implements true zero-copy for Latin-1 strings using V8's external
string API (v8::String::new_external_onebyte_raw).

When a finalize callback is provided, V8 takes ownership of the
buffer and calls a destructor when the string is garbage collected.
The destructor invokes the NAPI finalize callback via a global
metadata map keyed by buffer address.

Falls back to the copy path if:
- No finalize callback provided (can't guarantee buffer lifetime)
- V8 rejects the external string (e.g. buffer too small)

node_api_create_external_string_utf16 still copies because rusty_v8
doesn't expose a non-static external two-byte string API. The
*copied flag and null check are now handled correctly for both.

Tests:
- test_external_latin1: creates an external Latin-1 string, verifies
  content roundtrip, reports zero-copy status

Refs: Node.js src/js_native_api_v8.cc ExternalOneByteStringResource

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread ext/napi/js_native_api.rs
};
if let Some(v8_str) = v8_str {
// Register the finalize callback
EXTERNAL_STRING_METADATA.lock().unwrap().insert(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This should probably be stored on the Env not globally

The test declared node_api_create_external_string_latin1 via a manual
extern "C" block, which works on macOS/Linux (flat namespace) but
fails on Windows because the symbol needs to come through the NAPI
symbol resolution mechanism.

Fix: add node_api_create_external_string_latin1 and
node_api_create_external_string_utf16 declarations to
libs/napi_sys/src/functions.rs so they're properly resolved via
dlopen on all platforms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread ext/napi/js_native_api.rs
Comment on lines +1422 to +1423
// rusty_v8 doesn't expose a non-static external two-byte string API,
// so we always copy for UTF-16 strings.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

bartlomieju added a commit to denoland/rusty_v8 that referenced this pull request Apr 15, 2026
Add two-byte (UTF-16) equivalents of the existing external one-byte
string APIs:

- new_external_twobyte: creates a v8::String from a Box<[u16]>,
  V8 takes ownership and frees via free_rust_external_twobyte on GC
- new_external_twobyte_raw: creates a v8::String from a raw *mut u16
  with a custom destructor, called when the string is GC'd

These mirror new_external_onebyte and new_external_onebyte_raw.
The C++ binding adds ExternalTwoByteString which implements
v8::String::ExternalStringResource, tracks external memory via
AdjustAmountOfExternalAllocatedMemory, and calls the Rust destructor.

Needed by denoland/deno#33283 for zero-copy UTF-16 external strings
in the Node-API implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bartlomieju added a commit to denoland/rusty_v8 that referenced this pull request Apr 16, 2026
Add two-byte (UTF-16) equivalents of the existing external one-byte
string APIs:

- new_external_twobyte: creates a v8::String from a Box<[u16]>,
  V8 takes ownership and frees via free_rust_external_twobyte on GC
- new_external_twobyte_raw: creates a v8::String from a raw *mut u16
  with a custom destructor, called when the string is GC'd

These mirror new_external_onebyte and new_external_onebyte_raw.
The C++ binding adds ExternalTwoByteString which implements
v8::String::ExternalStringResource, tracks external memory via
AdjustAmountOfExternalAllocatedMemory, and calls the Rust destructor.

Needed by denoland/deno#33283 for zero-copy UTF-16 external strings
in the Node-API implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to 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.

1 participant