Skip to content

perf(runtime): reuse the marshalled UTF-8 buffer for repeated C string arguments - #450

Closed
edusperoni wants to merge 1 commit into
mainfrom
feat/cstring-external-fastpath
Closed

edusperoni wants to merge 1 commit into
mainfrom
feat/cstring-external-fastpath

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

What

Marshalling a JS string to a native char* argument previously copied it with strdup on every call, parking each copy on a throwaway external string for GC to reclaim. The IsExternalOneByte fast path above that code could never fire for runtime-created strings, because the argument string itself was never externalized — the reuse design was half-wired.

This PR completes it:

  • Interop::WriteValue's CStringEncoding path now externalizes the argument string in place via v8::String::MakeExternal when the string is pure ASCII and V8 permits it. Later marshals of the same string hit the fast path and reuse the same buffer — zero copies, zero allocations.
  • The ASCII gate is exact: a one-byte external string's content is Latin-1, which coincides with the UTF-8 buffer precisely when every character is ASCII, i.e. when the UTF-8 byte count equals the UTF-16 length.
  • Non-ASCII, young-generation, internalized, and otherwise non-externalizable strings keep the previous copy-per-call behavior (the anchor external string that ties the buffer's lifetime to GC).

Hardening

The pre-existing fast path blindly trusted resource->data() of any external one-byte string as NUL-terminated UTF-8 — but V8's resource contract promises Latin-1 content with no terminator. OneByteStringResource now keeps a thread-safe ownership registry, and the fast path only reuses buffers this runtime created (OneByteStringResource::Owns).

Safety notes

  • The echoed-pointer return path (Reference::FromPointer → Pointer::NewInstance) takes no ownership of the buffer, so there is no double-free against the externalized string's resource. The ~ReferenceWrapper disposeData_ fix from addf287 is load-bearing for this interaction.
  • MakeExternal failure is handled by falling back to the anchor path, which then owns the resource; ownership is unambiguous on every branch.
  • Buffer lifetime changes from "until the next GC" to "as long as the JS string lives" for externalized (ASCII) strings.

Tests

Two new specs in ReferenceTests.js, built on functionWithCharPtr echoing its argument pointer:

  • Reuse: repeated marshals of the same old-space ASCII string yield the identical buffer address (fails on any runtime without reuse, since both copies stay alive simultaneously). The spec promotes the string with __collect() first — V8 refuses to externalize young-generation strings, mirroring the real-world shape where long-lived strings are the ones marshalled repeatedly.
  • Fallback: non-ASCII strings round-trip as UTF-8 on every call.

Full suite: 1378 tests, 0 failures (Debug, iOS simulator).

Open question for review

Whether the perf win justifies the added state (the resource registry) without a motivating benchmark — this PR exists to make that discussion concrete. If a real workload shows C-string marshalling is never hot, closing unmerged is a fine outcome.

…g arguments

Marshalling a JS string to char* copied it with strdup on every call and
parked each copy on a throwaway external string for GC to reclaim; the
IsExternalOneByte fast path above it could never fire for runtime-created
strings because the argument itself was never externalized.

WriteValue now externalizes the argument in place via MakeExternal when the
string is pure ASCII (UTF-8 byte count == UTF-16 length, the exact condition
under which the buffer equals the string's one-byte content) and V8 permits
it, so later marshals of the same string reuse the buffer with no copy.
Non-ASCII, young-generation, and otherwise non-externalizable strings keep
the previous copy-per-call behavior.

The fast path is also now gated on an ownership registry in
OneByteStringResource: only buffers this runtime created are known to be
NUL-terminated UTF-8, while a foreign resource's data is Latin-1 with no
terminator guarantee and can no longer be handed to native code blindly.
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@edusperoni

Copy link
Copy Markdown
Collaborator Author

Closing in favor of two smaller changes. Externalizing the caller's string in place isn't sound, because V8 controls the resource's lifetime once the string is externalized:

  • Use-after-free when JS uses the string as a property key. If an identical internalized string already exists, V8 turns the argument into a ThinString and frees our resource immediately (MigrateExternalString → FinalizeExternalString). If that happens during the call (a native callback, a getter in a later argument), native code reads freed memory. Repro: sqlite3_exec(db, s, cb), where cb does ({})[s] = 1, returns 0 but skips the second statement. ASan reports the free from String::MakeThin. Both cases pass on main.
  • Writes through char*. The metadata maps char* and const char* to the same CString type. A native function that writes into its argument (mkstemp, strtok) would modify the JS string, possibly an internalized literal.
  • Locking. The ownership registry needs a process-wide mutex. External resources are freed inside Isolate::Dispose, which runs after per-isolate state is torn down and possibly on a background thread, so the registry can't be made per-isolate.

The review also found two bugs on main, which will be fixed in a separate PR:

  • An empty string passed as a C string is a use-after-free: NewExternalOneByte disposes zero-length resources immediately.
  • The IsExternalOneByte fast path trusts any external one-byte resource. On main it's only reachable for Node-API Latin-1 strings, which aren't NUL-terminated or UTF-8.

The performance goal will be handled by a follow-up that reduces the copy path to a single allocation without changing pointer lifetimes, with benchmarks.

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