Skip to content

StringOrBuffer: pin the backing ArrayBuffer lazily on first slice() - #36568

Open
robobun wants to merge 9 commits into
mainfrom
claude/farm/5fd43275/lazy-stringorbuffer-slice
Open

robobun wants to merge 9 commits into
mainfrom
claude/farm/5fd43275/lazy-stringorbuffer-slice

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

What

Makes MarkedArrayBuffer (the payload of StringOrBuffer::Buffer / PathLike::Buffer) defer pinning its backing JSC::ArrayBuffer until the first slice()/bytes() call, then cache that single vector()/byteLength() read. Until then only the JSValue is meaningful, so a later argument's toString()/valueOf()/option getter can freely transfer() or resize the buffer and the first read observes the post-coercion state.

Drop releases the pin; Unprotect clears the flag first so a ThreadSafe<T> released on the JS thread leaves nothing for an off-thread Drop. to_thread_safe() forces the pin+snapshot before a threadpool hand-off.

Why

The sync crypto/hash paths each patched the same use-after-free with an ad-hoc buffer.buffer = ArrayBuffer::from_typed_array(global, buffer.buffer.value) re-snapshot after argument coercion. Root cause: the ArrayBuffer descriptor captures a raw (ptr, byte_len) at conversion time, and any later JS coercion can transfer or resize the backing store under it.

With the lazy pin the per-call-site workaround is no longer needed. Removed:

  • CryptoHasher::hash (2 sites), Bun.sha
  • PBKDF2::from_js, PasswordObject::verifySync, scrypt sync from_js
  • the manual pin-at-end blocks in fs.write/fs.read args (same effect via bytes() on first access)

hash_to_bytes/digest_to_bytes/hash_by_name_inner_to_bytes now take &Buffer and use bytes() for the writable span instead of reading the raw ArrayBuffer.ptr field, so a detached output buffer throws "TypedArray must be at least N bytes" instead of writing through a stale pointer.

Verification

The detach/resize-during-coercion regression tests keep passing unchanged:

bun-cryptohasher.test.ts   400 pass
pbkdf2.test.ts + scrypt.test.ts + password.test.ts   110 pass
node:fs fs.test.ts         435 pass
node:http2 node-http2.test.js  308 pass
test/js/node/test/parallel/test-crypto-{pbkdf2,scrypt}.js  pass

No ArrayBuffer::from_typed_array(.., buffer.buffer.value) re-derive remains:

$ grep -rn "from_typed_array(" src/runtime/ --include="*.rs"
src/runtime/api/UnsafeObject.rs:46:    let array_buffer = jsc::ArrayBuffer::from_typed_array(global, args[0]);

(the one remaining call is an initial construction, not a re-derive)

Related: #34966 and #35757 take the pin-at-construction approach; this defers the pin so a later argument can still detach the buffer, which is what the existing regression tests in bun-cryptohasher.test.ts / pbkdf2.test.ts / scrypt.test.ts assert.


[review] gate passed · iteration 3 · 14 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/167] gen cpp.rs (cppbind)
[2/167] gen BunProcess.lut.h
Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/167] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 84 exported symbols
Generating /workspace/bun/build/debug/codegen/JSSink.lut.h from /workspace/bun/build/debug/codegen/JSSink.lut.txt
[4/167] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[5/167] gen JS modules (bundle-modules)
Preprocess modules (8820ms)
Bundle modules (33ms)
Postprocesss modules (186ms)
Bundle Functions (735ms)
Generate Code (12ms)

[9.81s] Bundled "src/js" for development
  2760 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[5/167] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (f714d93bb)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives GC after a command throws during argument validation [9.24ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [8.56ms]
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [11.98ms]
(pass) RedisClient survives GC across many short-lived instances [9.65ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [16.27ms]
(pass) redis.set reads a Buffer key only after every later argument has been coerced [24.95ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [48.27ms]
(pass) RedisClient read buffer stays bounded when every socket read ends with a partial reply [164.11ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [426.17ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [712.73ms]

 10 pass
 0 fail
 31 expect() calls
Ran 10 tests across 1 file. [921.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
bun test v1.4.0 (0429346b3)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [409.72ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [369.67ms]
(pass) RedisClient survives GC after a command throws during argument validation [524.33ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [459.87ms]
(pass) RedisClient survives GC across many short-lived instances [630.04ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [1190.52ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [2299.53ms]
(pass) redis.set reads a Buffer key only after every later argument has been coerced [1418.91ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [2090.87ms]
(pass) RedisClient read buffer stays bounded when every socket read ends with a partial reply [2600.67ms]

 1
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     0429346b3e
  features     baseline

22 deps, 108 codegen, 1171 objects in 704ms

ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[2/124] gen cpp.rs (cppbind)
[3/124] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[4/124] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[5/124] gen JS modules (bundle-modules)
Preprocess modules (8906ms)
Bundle modules (56ms)
Postprocesss modules (209ms)
Bundle Functions (754ms)
Generate Code (34ms)

[9.98s] Bundled "src/js" for production
  2569 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[5/123] cargo bun_bin → libbun_rust.a (-
... (truncated)
diff hotspot
src/jsc/array_buffer.rs                    | 199 ++++++++++++++++++++++-------
 src/jsc/bindings/bindings.cpp              |  41 ++++++
 src/jsc/bindings/headers.h                 |   1 +
 src/jsc/node_path.rs                       |  22 +---
 src/runtime/api/BunObject.rs               |   5 +-
 src/runtime/api/MarkdownObject.rs          |   2 +-
 src/runtime/api/bun/subprocess/Readable.rs |  13 +-
 src/runtime/crypto/CryptoHasher.rs         | 113 +++++++---------
 src/runtime/crypto/PBKDF2.rs               |  17 +--
 src/runtime/crypto/PasswordObject.rs       |   9 +-
 src/runtime/node/node_crypto_binding.rs    |   8 +-
 src/runtime/node/node_fs.rs                |  64 ++--------
 src/runtime/node/types.rs                  |  64 ++--------
 test/js/valkey/valkey-gc.test.ts           |  60 +++++++++
 14 files changed, 344 insertions(+), 274 deletions(-)

gate history · 4 passed · 1 rejected · iteration 3

evidence per changed file
file                                        reads  edits  tests
src/jsc/array_buffer.rs                        10     25      0
src/jsc/bindings/bindings.cpp                   4      8      0
src/jsc/bindings/headers.h                      2      4      0
src/jsc/node_path.rs                            3      8      0
src/runtime/api/BunObject.rs                    3      2      0
src/runtime/api/MarkdownObject.rs               1      1      0
src/runtime/api/bun/subprocess/Readable.rs      1      1      0
src/runtime/crypto/CryptoHasher.rs              6     17      0
src/runtime/crypto/PBKDF2.rs                    3      3      0
src/runtime/crypto/PasswordObject.rs            2      3      0
src/runtime/node/node_crypto_binding.rs         2      2      0
src/runtime/node/node_fs.rs                     8     12      0
src/runtime/node/types.rs                       7      9      0
test/js/valkey/valkey-gc.test.ts                3      5      0

MarkedArrayBuffer now stores its ArrayBuffer descriptor behind a Cell and
defers pinning: from_js captures only the JSValue, and the first bytes()
or slice() call pins the backing JSC::ArrayBuffer, reads vector()/
byteLength() once, and caches the result. Drop releases the pin;
Unprotect clears the flag first so a ThreadSafe<T> dropped on the JS
thread leaves nothing for an off-thread Drop to do. to_thread_safe()
forces the pin+snapshot before handing off.

This makes argument-coercion order irrelevant for StringOrBuffer::Buffer
without an ad-hoc re-snapshot at each call site: a toString()/valueOf()
on a later argument can transfer or resize the buffer, and the first
slice() observes the post-coercion state (detached -> empty).

The six buffer.buffer = ArrayBuffer::from_typed_array(...) re-derive
lines in CryptoHasher/PBKDF2/PasswordObject/scrypt/Bun.sha are removed,
as are the manual pin-at-end blocks in fs.write/fs.read args (bytes()
now does that on first access). hash_to_bytes and friends take &Buffer
instead of a raw ArrayBuffer copy and use bytes() for the writable span.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR reworks MarkedArrayBuffer to use interior mutability with lazy JS ArrayBuffer pinning through a new FFI function that reads live backing-store bytes. Runtime consumers in crypto, node filesystem, node path, subprocess, and Markdown code switch from direct field access to the new Buffer accessor API.

Changes

ArrayBuffer lifecycle and Buffer consumers

Layer / File(s) Summary
Lazy pinning and live backing stores
src/jsc/array_buffer.rs, src/jsc/bindings/bindings.cpp, src/jsc/bindings/headers.h
Adds JSC__JSValue__pinAndReadArrayBufferBytes to pin an ArrayBuffer and return its live pointer and byte length. MarkedArrayBuffer now stores private Cell-wrapped state, adds from_unpinned, from_owned, borrow, buffer, value, is_pinned, owns_buffer, to_thread_safe, unprotect, and bytes methods, and unpins automatically on Drop.
Buffer ownership and lifecycle consumers
src/jsc/node_path.rs, src/runtime/api/MarkdownObject.rs, src/runtime/api/bun/subprocess/Readable.rs, src/runtime/node/node_fs.rs, src/runtime/node/types.rs, test/js/valkey/valkey-gc.test.ts
PathLike, Readable, MarkdownObject, node filesystem operations, and StringOrBuffer/path parsing now use the new Buffer accessor methods for cloning, protection, thread-safe conversion, and cleanup instead of manual field manipulation. Adds a regression test for lazy key pinning during RedisClient.set.
Crypto output and input handling
src/runtime/api/BunObject.rs, src/runtime/crypto/CryptoHasher.rs, src/runtime/crypto/PBKDF2.rs, src/runtime/crypto/PasswordObject.rs, src/runtime/node/node_crypto_binding.rs
Crypto hashing paths write digest output directly into Buffer backing stores instead of converting through ArrayBuffer::from_typed_array. PBKDF2 relocates and retains salt/password length validation while removing the salt conversion step.

Possibly related PRs

  • oven-sh/bun#36535: Both PRs modify ArrayBuffer pinning and lifecycle management, and the retrieved PR's zero-copy write path depends on the shared MarkedArrayBuffer APIs introduced here.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main change: implementing lazy pinning of the backing ArrayBuffer in StringOrBuffer until the first slice() call.
Description check ✅ Passed The description addresses both required template sections: it explains what the change does and how verification was performed through regression tests.

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

Comment thread src/jsc/JSValue.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/node_path.rs
Covers the lazy-pin guarantee on a call site that has no re-snapshot on
main: js_valkey_functions::set converts the key (Buffer) then the value
(StringObject -> toString), then serializes via .slice(). On main the
key's 19 stale bytes are written to the wire; with lazy pin the first
slice() observes the detached buffer and serializes a zero-length key.

Also trims the longest new doc comments flagged by comment-cop.
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/runtime/node/node_fs.rs Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 3215-3236: Harden JSC__JSValue__arrayBufferLiveBytes in
src/jsc/bindings/bindings.cpp:3215-3236 by guarding non-cells/null cells,
initializing outputs to (nullptr, 0), and using dynamicDowncast for
JSArrayBuffer and JSArrayBufferView, including ObjectType/FinalObjectType cases;
update MarkedArrayBuffer::slice in src/jsc/array_buffer.rs:966-981 to return
cached (ab.ptr, ab.byte_len) when pin_array_buffer() fails and set self.pinned
only after successful pinning.

In `@src/jsc/node_path.rs`:
- Around line 203-204: The repeated Buffer pin/protect and unpin/unprotect
sequences should use shared Buffer helpers. Add the helper methods to the
Buffer/MarkedArrayBuffer API, then replace both directions of the pairing in
src/jsc/node_path.rs at lines 203-204 and 218-219, src/runtime/node/types.rs at
lines 302-303 and 319-320, and src/runtime/node/node_fs.rs at lines 3916-3924;
preserve each existing to_thread_safe and Unprotect flow while routing these
operations through the helpers.

In `@src/runtime/crypto/CryptoHasher.rs`:
- Around line 388-396: Update all five direct Buffer output capacity errors in
src/runtime/crypto/CryptoHasher.rs at lines 388-396, 717-724, 990-997,
1320-1326, and 1483-1489, including the supplied bytes_len, required digest
size, and guidance to provide a larger TypedArray. Preserve each existing error
path while ensuring messages identify the output resource, rejected capacity,
minimum constraint, and remedy.
- Around line 714-727: Update the output-buffer validation in the CryptoHasher
finalization path to derive the required digest length from the active
CryptoHasher variant instead of EVP_MAX_MD_SIZE_USIZE. Reject buffers smaller
than that selected digest length, and create output_digest_slice using the same
required length while preserving the existing error behavior.
- Around line 1327-1329: The digest pointer casts in both sites at
src/runtime/crypto/CryptoHasher.rs lines 1327-1329 and 1490-1492 rely on an
alignment guarantee not enforced by the StaticHasher trait. Seal StaticHasher so
all implementations are constrained to byte-aligned digest types, or replace
both typed mutable casts with &mut [u8] while preserving the existing length and
writable-buffer checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72f82b3f-cd49-41fe-8791-e16855bfc84e

📥 Commits

Reviewing files that changed from the base of the PR and between b7fef25 and 715ab0a.

📒 Files selected for processing (14)
  • src/jsc/JSValue.rs
  • src/jsc/array_buffer.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/jsc/node_path.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/api/MarkdownObject.rs
  • src/runtime/api/bun/subprocess/Readable.rs
  • src/runtime/crypto/CryptoHasher.rs
  • src/runtime/crypto/PBKDF2.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/node/node_crypto_binding.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/types.rs

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/node_path.rs Outdated
Comment thread src/runtime/crypto/CryptoHasher.rs
Comment thread src/runtime/crypto/CryptoHasher.rs
Comment thread src/runtime/crypto/CryptoHasher.rs

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey-gc.test.ts`:
- Around line 641-655: Wrap the Redis client connection, key setup, and set
operation in a try/finally block, moving client.close() and server.close() into
finally so both resources are released even when connect() or set() rejects.
Preserve the existing test behavior and failure propagation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b6cf5ed-b6cd-4857-aba1-6cff3845ab63

📥 Commits

Reviewing files that changed from the base of the PR and between 715ab0a and bcb35b0.

📒 Files selected for processing (4)
  • src/jsc/array_buffer.rs
  • src/jsc/bindings/bindings.cpp
  • src/runtime/node/node_fs.rs
  • test/js/valkey/valkey-gc.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/node/node_fs.rs

Comment thread test/js/valkey/valkey-gc.test.ts Outdated
bindings.cpp: use dynamicDowncast and initialize outputs so an
ObjectType/FinalObjectType buffer cell (matched by as_array_buffer's
fallback arm) is handled correctly instead of reaching an
uncheckedDowncast on the wrong shape.

array_buffer.rs: add MarkedArrayBuffer::{to_thread_safe, unprotect}
and route the five pin+protect / unpin+unprotect pairings in
node_path.rs, types.rs, node_fs.rs through them.

test: close the redis client/server in finally.
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread test/js/valkey/valkey-gc.test.ts
Comment thread src/jsc/array_buffer.rs Outdated
bytes() now only pins a view that already has a backing ArrayBuffer
(hasArrayBuffer()); FastTypedArray and OversizeTypedArray have nothing
to transfer() so there is nothing to pin against, and pinning them
forces slowDownAndWasteMemory()/createAdopted which shows up in
vm.heap.arrayBufferSize() (test-memory-usage.js). to_thread_safe()
passes force_pin=true so off-thread borrows still promote.

Also: route from_js/from_js_pinned through from_unpinned/from_pinned;
add signalCode assertion to the valkey test.
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs Outdated

@claude claude Bot 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.

No bugs found this round, and the earlier nits (dead constructors/accessors, stale SAFETY refs, test consistency) are all addressed as of f714d93. Deferring to a human because this redesigns MarkedArrayBuffer's pinning model — new Drop, Cell<> interior mutability, a new C++ pin-and-read FFI, and changed to_thread_safe/unprotect semantics across the crypto/fs/PathLike hand-off paths — which is more design surface than I should sign off on alone.

What was reviewed: the bytes_() settled/pinned state machine and FORCE_PIN interaction with FastTypedArray/SharedArrayBuffer; Drop↔Unprotect ordering so off-thread ThreadSafe<T> drop is a no-op; that every removed from_typed_array re-snapshot is covered by a bytes()/slice() at the read site; and JSC__JSValue__pinAndReadArrayBufferBytes for non-cell / detached / shared inputs.

Extended reasoning...

Overview

This PR reworks MarkedArrayBuffer (the payload behind StringOrBuffer::Buffer / PathLike::Buffer) from eager to lazy pinning: fields become private Cell<>s, a settled flag caches the first vector()/byteLength() read, bytes()/slice() pin on first access via a new JSC__JSValue__pinAndReadArrayBufferBytes C++ entry point, and a new Drop releases the pin. to_thread_safe()/unprotect() become inherent methods that force-pin (promoting FastTypedArray) before threadpool hand-off. Seven ad-hoc ArrayBuffer::from_typed_array(.., buffer.value) re-snapshot workarounds are removed across CryptoHasher, Bun.sha, PBKDF2, scrypt, PasswordObject, and fs.read/fs.write; the crypto *_to_bytes helpers switch from raw ArrayBuffer to &Buffer + bytes().

Security risks

The change is itself a memory-safety hardening (centralising the "later argument's coercion detaches an earlier argument's buffer" UAF class). The risks are in the redesign: pin/unpin balance across every terminal path (early-return, ThreadSafe<T> drop on the JS thread vs. off-thread, borrow() clones taking their own pin), the new Drop firing on every scope exit including inside arenas or ManuallyDrop wrappers, and the Cell<> model's single-JS-thread assumption holding at every bytes() call site. I traced these and did not find an imbalance, but the blast radius (every StringOrBuffer/PathLike consumer) is large.

Level of scrutiny

High. This is unsafe Rust + C++ FFI touching JSC ArrayBuffer lifetime, GC rooting, and cross-thread hand-off — squarely in REVIEW.md's most-blocked category. The abstraction is used pervasively, so a subtle mistake in the pin lifecycle would surface as a rare UAF far from the change. That warrants a maintainer's eyes on the design (lazy-pin vs. the pin-at-construction approach in the referenced #34966/#35757), not just line-level review.

Other factors

Four prior review rounds surfaced only nits (dead from_pinned/from_js_pinned/accessors, an always-false new() param, a stale SAFETY parenthetical, missing signalCode assertion), all fixed. The existing detach-during-coercion regression suites (bun-cryptohasher, pbkdf2, scrypt, password, fs.test.ts) plus the new redis.set lazy-pin test give reasonable coverage of the removed workarounds. CodeRabbit's concerns were withdrawn or confirmed addressed. Nothing outstanding blocks merge from my side; the deferral is purely about design-level sign-off.

@claude claude Bot 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.

No bugs found this pass; earlier nits (dead from_pinned/from_js_pinned, always-false new(.., pinned) param, unused accessors, stale SAFETY comment, missing signalCode assertion) are all addressed in ba0290f…f714d93b. Deferring to a human — this reworks MarkedArrayBuffer's pin lifecycle (lazy pin-on-first-bytes(), Cell<> interior mutability, new Drop that calls into JSC, to_thread_safe/unprotect pairing) and removes the per-call-site UAF re-snapshots across crypto/fs/PathLike; that's a core memory-safety contract change worth maintainer eyes.

What was reviewed:

  • bytes_<FORCE_PIN> state machine: settled/pinned interaction, FastTypedArray left unpinned on the sync path but promoted via force_pin in to_thread_safe(); detached → (null, 0).
  • Drop/Unprotect ordering: unprotect() clears pinned first so an off-thread ThreadSafe<T> drop is a no-op; PathLike::Drop and StringOrBuffer::Drop no longer touch the pin directly.
  • Removed re-derives (CryptoHasher, PBKDF2, scrypt, PasswordObject, Bun.sha, fs.read/write) — each now reaches bytes() after the last coercion; the new &Buffer output-arg path throws on a detached destination instead of writing through a stale pointer.
  • JSC__JSValue__pinAndReadArrayBufferBytes: non-cell / JSArrayBufferView / JSArrayBuffer / shared / detached arms; out-params zeroed unconditionally.
Extended reasoning...

Overview

Refactors MarkedArrayBuffer (aliased as Buffer in StringOrBuffer/PathLike) from an eager (ptr, byte_len) snapshot to a lazy pin-and-read on the first bytes()/slice() call. Fields go private behind Cell<>; a new Drop impl releases the pin; to_thread_safe()/unprotect() centralise the threadpool hand-off pairing. A new C++ binding JSC__JSValue__pinAndReadArrayBufferBytes does the pin + vector()/byteLength() read atomically, with a force_pin flag to promote FastTypedArrays for off-thread borrows. ~10 call sites that previously re-derived the ArrayBuffer descriptor after argument coercion (the ad-hoc UAF workaround) are deleted, and the fs Read/Write per-args pinning blocks are removed. 14 files, +344/−274.

Security risks

This is memory-safety infrastructure at the JS↔native boundary — the exact class REVIEW.md flags as most-blocked. The lazy-pin design is more defensive than the old shape (a later argument's toString() detaching an earlier buffer now yields (null, 0) instead of a stale pointer), and the digest-output paths now throw on a too-short/detached destination rather than writing through output_buf.ptr. But the correctness depends on: every consumer reaching bytes() only after the last JS coercion; Drop running only on the JS thread (or unprotect() having cleared pinned first); the settled && (!FORCE_PIN || pinned) short-circuit not double-pinning or skipping a required promotion. I traced these through the changed call sites and they hold, but the invariant is now spread across the type rather than open-coded per site.

Level of scrutiny

High. This changes the ownership/lifetime contract of a type used by every buffer-accepting native API (node:fs, node:crypto, Bun.password, Bun.CryptoHasher, valkey, PathLike, subprocess Readable). It adds a Drop that calls into JSC, introduces interior mutability, and removes safety workarounds from ~8 hot paths on the assumption the new abstraction subsumes them. A maintainer should sign off on the design (lazy vs. eager pin) and the thread-affinity story for Drop.

Other factors

All prior review-round findings (mine and CodeRabbit's) are resolved and the threads marked so. The regression suites listed in the PR description pass, and the new valkey test demonstrates fails-without/passes-with under ASAN. The mechanical struct-literal → named-constructor conversions and the .buffer.value → .value() accessor swaps are straightforward. No outstanding unresolved comments.

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 0429346: 193/196 lanes green. The one red is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts on debian-x64-asan, which is tagged [pre-existing] (same assertion failure on main). Everything else is flaky-retried. All tests touching the changed paths (bun-cryptohasher, pbkdf2, scrypt, password, fs.test.ts, valkey-gc, test-memory-usage.js) pass on every lane that ran them.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants