Skip to content

Make bun_core::String own its WTF ref - #40238

Merged
Jarred-Sumner merged 29 commits into
mainfrom
claude/bun-core-string-leaks-3ff1dd
Aug 24, 2026
Merged

Jarred-Sumner merged 29 commits into
mainfrom
claude/bun-core-string-leaks-3ff1dd

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

bun_core::String (the Rust mirror of C++ BunString) was a Copy POD with no destructor, so every +1 producer — clone_utf8, create_format, to_bun_string, URL accessors, C++ toStringRef out-params — relied on the caller remembering .deref() or OwnedString. An audit found ~60 places that didn't (per-import ResolvedSource.source_url/specifier, whole HMR bundles in bake, Postgres error fields, valkey/UDP/S3 option strings, parseArgs argv, QUIC headers, …). This moves the +1/-1 rule into the type so those, and any not yet found, are released by Drop.

Ownership is the type now

// before: Copy, no Drop — caller must remember
let s = value.to_bun_string(global)?;   // +1
do_stuff(&s);
s.deref();                              // forgotten in ~60 places

// after: String owns one ref. Drop = deref, Clone = ref, moves are free.
let s = value.to_bun_string(global)?;
do_stuff(&s);                           // released at scope exit, incl. `?` paths

FFI signatures carry ownership. String has the same layout as C++ BunString, so a by-value String in an extern signature means the ref crosses (like Box<T> in FFI); &String / StringView<'_> mean borrow. C++ that only reads takes const BunString*.

safe fn URL__host(url: &URL) -> String;                        // C++: Bun::toStringRef (+1) → we own it
pub fn host(&self) -> String { URL__host(self) }

extern "C" fn BakeProdResolve(global: &JSGlobalObject, a: &String, b: &String) -> String {
    String::create_format(format_args!("..."))                 // C++: transferToWTFString()
}

safe fn ZigSourceProvider__getSourceSlice(p: &SourceProviderMap) -> StringView<'_>;   // C++: Bun::toStringView (+0)

Borrows are &String / StringView<'a> (a by-value borrow tied to whatever owns the characters: property-iterator names, substring/trunc, JsCell<String>::get), and consuming into JS is into_js(self) (was transfer_to_js(&mut self)):

return String::clone_utf8(name).into_js(global);   // ref moves into the JSString, no ref/deref pair

Reading a JS string's characters is a scoped borrow. JSValue::get_zig_string() returned a ZigString aliasing a JSString's buffer with no lifetime, no ref, and nothing keeping the cell alive. It is removed; callers either take an owned to_bun_string(), or:

let v = value.to_js_string_view(global)?;   // one FFI call: toString + JSString::view (no ref; substring ropes aren't flattened)
use(v.latin1(), v.to_utf8(), v.length());   // derefs to &String
// drop(v) keeps the JSString cell observable to the GC until here (like C++ GCOwnedDataScope)

Use it for a scoped read; use to_bun_string() when the string must be kept. JSString::view() returns the same JSStringView guard; JSPropertyIterator::next() yields (StringView<'_>, JSValue) tied to the iterator. get_class_name/get_name_property return an owned String (they handed out views into C++ temporaries).

ResolvedSource is owned on both sides. Rust ResolvedSource/Errorable<T> own their strings, bytecode and module_info; C++ ErrorableResolvedSource gets a destructor, and Zig::SourceProvider::create takes what it keeps via transferToWTFString(). The unused specifier field is removed. Previously only ~SourceProvider released specifier/source_url, so every loader arm that doesn't build a provider (JSON/TOML/text/object exports, builtins, already-required CJS) leaked two strings per import. The LoaderHooks fn-pointer table between bun_jsc and bun_runtime is replaced by direct extern "Rust" calls; Errorable's error arm is just the JSValue (the code was never read).

Fixes

Leaks (a WTF::StringImpl ref — or the whole string — never released), per occurrence unless noted:

  • every import/require of JSON, JSONC, TOML, YAML, text, HTML, bun:main, internal/*, builtins and already-required CJS: ResolvedSource.specifier and source_url (and source_code for the JSON/object/builtin arms)
  • every async (await import) module fulfilment: source_url
  • bun --hot / bake dev server: the entire server HMR patch bundle on each reload; the initial server runtime; BakeProdResolve/BakeProdLoad/BakeToWindowsPath results
  • Bun.sql (Postgres): every field of every error/notice message; the JSON.stringify result for each json/jsonb bind parameter (1.4.0 regression: bun:sql leaks native memory on every query binding a jsonb parameter #40102); inline column-name atoms in CachedStructure
  • Bun.sql (MySQL): JSON column values
  • new Bun.RedisClient(url): the URL string and each parsed component; every RESP map key
  • Bun.udpSocket({ hostname }) / connect address; Bun.serve({ unix }); the server URL passed to the inspector; error-path hostname strings
  • S3 error code/message
  • node:util parseArgs: one string per argv element and per short option
  • node:quic: header names/values, ALPN/SNI/status strings; node:http2 custom settings keys
  • HTMLRewriter element.attributes iterator name/value
  • node:module new SourceMap(payload) sources/names, findSourceMap path
  • expect(v, label), expect.extend matcher names, describe(fn)/test(fn) derived names, console.log table column names / class display names, console.trace() (built the exception twice)
  • ResolveMessage.code, Bun.$ escape, IPC cmd strings, process.title = …, Worker creation error message, WebSocket client close reason on error paths, Bun.Transpiler transform result when warnings reject, bun:ffi cc/linkSymbols strings on error, import.meta.url define in the bundler, bun install hosted-git URL parts, bun build --bytecode chunk source URL, code-coverage source URL, fs.Dirent name/path on early return, bundler plugin onResolve/onLoad strings when the plugin is tombstoned
  • worker VirtualMachine.main_resolved_path at teardown
  • the .jsc bytecode buffer and ModuleInfo for already-bundled modules on some paths
  • ZigException::Holder: strings of stack frames hidden by the frame filter

Use-after-free / lifetime:

  • Bun.dlopen read its path argument after walking the user symbols object (getters could drop it)
  • JSX tag name in console.log/expect formatting was reused after printing props/children
  • Bun.YAML.stringify kept anchor names past the property iterator that owned them
  • getClassName/getNameProperty returned views into function-local WTF::Strings
  • os.userInfo() options were ptr::read out of C++-owned memory (double free)
  • any toString()-created temporary JSString whose characters were read after a GC point (now pinned by JSStringView)

Other:

  • BakeLoadInitialServerCode copied the whole server HMR runtime into a new StringImpl at dev-server start; it now wraps the embedded bytes
  • non-ASCII paths in async-module load errors were formatted as Latin-1 (mojibake); encoding behaviour is otherwise unchanged
  • UDP socket address/remoteAddress getters swallowed a pending exception as undefined

Also removes Bun::toString(JSGlobalObject*, JSValue) (a +1 producer named like a borrow), StringCell/OwnedString, the duplicate bun_jsc::URL wrapper (now bun_url::whatwg::URL, held via the RAII whatwg::Parsed), and to_slice_or_null/to_slice_clone (identical to to_slice).

No extra refcount traffic or allocations vs main: Drop/Clone are the same tag-check + inc/dec the manual calls were; to_js_string_view is one FFI call like get_zig_string was; and a number of ref()/dupe_ref() pairs, a full transpiler-cache output copy, and per-module specifier refs are gone.

Struct size changes (nothing grows; shared layouts are asserted on both sides):

  • ResolvedSource (Rust ⇄ C++): 160 → 136 bytes — specifier and the unused allocator removed, needsDeref → bytecode_cache_owned. ErrorableResolvedSource follows (168 → 144).
  • Zig::SourceProvider (C++): no longer embeds a ResolvedSource; keeps only m_moduleInfo/m_tag/m_alreadyBundled (~144 bytes smaller per loaded module).
  • Rust-only: TranspilerJob drops its fetcher field; RuntimeTranspilerCache::Entry::output_code is a plain String instead of an enum; ZigException::Holder uses Option<ZigException> instead of MaybeUninit + loaded: bool.
  • Unchanged: bun_core::String (24, asserted), StringView/JsCell<String> (transparent), ZigStackTrace/ZigStackFrame, SystemError, generated class payloads.

Fixes #40102
Supersedes #32293.

How did you verify your code works?

cargo check/clippy on linux/macOS/windows targets; debug+ASAN build driven through module loading (ESM/CJS/JSON/TOML/text/?query, builtins, virtual modules), console.log/Bun.inspect (errors, JSX, boxed primitives, symbols, classes), node:path, node:module SourceMap, workers, process.title, os.userInfo, WebSocket send/publish, UDP/unix-socket serve, parseArgs, TOML/YAML/XML stringify, Bun.dlopen, bake dev server startup. Ran the module, resolve, path, parse-args, worker, inspect(-error), expect, shell, transpiler-cache, websocket-server, serve, zlib, udp, dns, http2 and serve-body-leak test files locally against main's debug build. New RSS regression tests (each repeats the operation with a ~256 KiB string and fails on 1.4.0 with the growth shown): Postgres jsonb bind (166 MiB) and ErrorResponse fields (125 MiB) via the wire-frames.ts mock, util.parseArgs (77), expect(v, label) (76), module.SourceMap (158), Bun.udpSocket({hostname}) (73), HTMLRewriter attributes (90), new RedisClient(url) (145), RESP map keys (150), S3 error (47). The use-after-free fixes are not observable on a release build of 1.4.0 without ASAN, so they have no test.

`bun_core::String` was a `Copy` POD with no destructor; every +1 producer
(`clone_utf8`, `create_format`, `to_bun_string`, URL accessors, C++
`toStringRef` out-params, ...) relied on the caller remembering to
`.deref()` or wrap it in `OwnedString`. An audit found ~60 sites that
did not, several per-request/per-import.

Ownership is now in the type:
- `String` is no longer `Copy`. `Drop` derefs, `Clone` refs. `ref_`/`deref`
  are private; `dupe_ref` and `OwnedString` are gone.
- `RawString` (the `Copy` C layout) is the only thing that crosses FFI by
  value; `String::into_raw`/`from_raw` are the explicit hand-off points.
  C++ functions that only read a string now take `const BunString*`.
- `StringView<'a>` is the by-value borrow (property-iterator names,
  `substring`, `StringCell::get`).
- `StringJsc::into_js(self)` replaces `transfer_to_js(&mut self)`;
  `to_error_instance` borrows.
- `Errorable<T>` owns its payload; `ResolvedSource` is an owning struct
  (no `specifier` field, typed `bytecode_cache`/`module_info`), mirrored
  by a C++ `ErrorableResolvedSource` destructor. `Zig::SourceProvider`
  takes the strings it keeps by transfer, so the builtin/JSON/object
  loader arms no longer leak `source_url`/`source_code`.
- `Bun::toString(JSGlobalObject*, JSValue)` (a +1 producer named like a
  borrow) is removed in favor of `toStringRef`.
@robobun

robobun commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Verified this closes the jsonb bind leak from #40102 with the regression test from #32293 (test/js/sql/sql-postgres-json-bind-leak.test.ts at df38968). It is hermetic: an in-process mock Postgres replies ParameterDescription([114]), then the fixture binds a 512 KiB JSON payload 300 times and measures RSS with the ASAN quarantine off.

Debug+ASAN build, same machine:

main (unfixed):  deltaMiB 163.4  -> fail (threshold 50)
this branch:     deltaMiB  12.6  -> pass

This PR has no test files. If you want a guard for #40102 in the same change, that file cherry-picks cleanly onto this branch. Happy to leave it to you either way.

…ty .into()

- `String::into_raw`/`from_raw_ref` use `ManuallyDrop`/`ptr::from_ref().cast()`.
- `JSFunction::create` takes `impl Into<StringView>` so callers pass `&str`
  / `&String` without a ref; bun:test scope names are `&'static str`.
- `AsyncModule::fulfill`, `write_bind`, shell `handle_js_string_ref` borrow.
- Remove the now-identity `String -> String` `.into()` conversions left
  over from `OwnedString`.
@robobun

robobun commented Aug 23, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 8:56 PM PT - Aug 23rd, 2026

❌ @dylan-conway, your commit 7007f7a has 1 failures in Build #104554 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40238

That installs a local version of the PR into your bun-40238 executable, so you can run:

bun-40238 --bun

…dto errors

- test/js/sql/sql-postgres-json-bind-leak.test.ts: in-process mock Postgres
  answers ParameterDescription([json]); binding a 512 KiB payload 300 times
  must not grow RSS. Fails on 1.4.0 (166 MiB), passes here.
- UDPSocket address getters return JsResult instead of swallowing an
  into_dto failure as undefined.
@dylan-conway

Copy link
Copy Markdown
Member Author

Pulled test/js/sql/sql-postgres-json-bind-leak.test.ts in from #32293 in 07e13f7 (166 MiB on 1.4.0 → passes here).

…g-leaks-3ff1dd

# Conflicts:
#	src/runtime/bake/BakeGlobalObject.cpp
…path

- TOML stringify header path holds borrowed `RawString`s (bracketed by
  the property iterator) instead of taking a ref per key.
- `into_js` where the string is done: ResolveMessage code atom, Blob text
  paths, quic qlog data, Bun.resolveSync result.
- installBinding test helper: borrow instead of clone_utf8 before JSONParse.
- ZigSourceProvider: only build the coverage BunString when coverage is
  on; move sourceURL into the provider. Drop the dead memset in
  ~ErrorableResolvedSource.
Comment thread src/jsc/JSPropertyIterator.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
… out-params

- `StringCell` → `JsCell<String>`; `StringView::to_owned` → `.clone()`.
- Loader hooks return `Option<ResolvedSource>` / `Result<ResolvedSource, _>`
  instead of writing `ErrorableResolvedSource` out-params; `Errorable` is
  now only built at the C++ edge. Same for `resolve_maybe_needs_trailing_slash`
  (returns `Result<String, JSValue>`) and `AsyncModule::fulfill` (takes a
  `Result`).
- `RuntimeTranspilerCache::Entry::output_code` is a plain `String` (the
  `Utf8` arm was only ever empty).
- `to_slice`/`to_thread_safe_slice` consume self (`into_slice`); fold
  `to_utf8_bytes` into `to_owned_slice`, `static_str` into `static_`.
- `Bytecode::{owned,borrowed}` normalize empty; `ZigException::Holder` uses
  `Option` instead of `MaybeUninit` + flag; shell lexer borrows its string
  table immutably; `SocketAddress::into_dto` takes self.
- Remove dead `ErrorableResolvedSource::reset`, `JSCommonJSModule::create`
  (by-value overload), `Bun::toString(const char*, size_t)`, `Route`'s Drop,
  `From<WTFStringImpl> for String`, `Errorable::value{,_mut}`.
…view nits

- JSC__JSValue__putIndex downcast to JSArray; the http2 custom-settings
  object is a plain object. Use getObject()->putDirectIndex.
- Drop a vestigial reborrow in ConsoleObject table printer, a stale
  comment in get_hardcoded_module, and document why JSPropertyIterator
  names carry the global lifetime.
Declare +1 returns from C++ (`Bun::toStringRef`) as `-> String` and Rust
exports that C++ `transferToWTFString()`s as returning `String`, the same
way `Box<T>` is used in FFI. Borrowed (+0) by-value results are
`StringView<'_>`. This removes every `String::from_raw(EXTERN(..))` /
`.into_raw()` call site and the `adopt()` helpers; `RawString` remains
only for the property-iterator out-param slot C++ fills in place.

Also give the TOML stringifier a lifetime so its key path holds
`StringView`s instead of raw strings.
@alii

alii commented Aug 23, 2026

Copy link
Copy Markdown
Member

Awesome

- `process_fetch_log` returns the error `JSValue`; `Errorable::err` takes
  just the value; `loader_hooks()` is infallible; the two transpile shims
  share one error tail. `Errorable::unwrap` and the pre-seeded dummies go.
- Single-out-param exports now return by value: `Bun__VM__entryRootKey`,
  `Bun__Process__getTitle`, `Bun__Node__Path_joinWTF`,
  `Bun__getEnvValueBunString`, `Bun__Node__getRedirectWarnings`,
  `Bun__resolveEmbeddedNodeFile`. `WebWorker__dispatchError` and
  `Bun__Dirent__toJS` take their strings by value (they consume them).
- Drop the dead `ResolvedSource.allocator` field (144 → 136 bytes) and
  derive `Default`; C++ `ErrorableResolvedSource` value-initializes.
- `JSFunction::create` takes `&'static str`; `JSPropertyIterator` fills a
  `StringView` directly, so `RawString`, `String::from_raw` and
  `StringView::from_raw` are no longer exported.
- `String::create_if_different`, `AlreadyBundled::into_bytecode`; remove
  `String::to_wtf`, redundant `StringView` `From` impls; const-correct the
  borrowed FFI params; `putIndex` uses `asObject`.
- Rewrite the jsonb leak test on top of wire-frames.ts with a fixture and
  an ASAN/debug-branched RSS bound.
The backing arrays are fixed-size (32 frames, 6 lines) but C++ only fills
`..frames_len` / `..source_lines_len`; release just those, as before,
instead of running drop glue over every slot.
Comment thread src/shell_parser/parse.rs Outdated
No-Verification-Needed: comment-only change
…RawString alias

No-Verification-Needed: rename-only, no runtime surface
Comment thread src/runtime/node/node_process.rs Outdated
No-Verification-Needed: comment-only change

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

Automated review ran clean on e34fd40 — no findings this pass, and the earlier nits (stale comments, the ConsoleObject reborrow, the JSPropertyIterator lifetime note) are all addressed. Given the scope — a String ownership-model change across 225 files plus FFI-shared struct layout changes on both the Rust and C++ sides — this still warrants a human pass.

Checked: ResolvedSource/ErrorableResolvedSource layout agrees on both sides (136-byte static_assert in headers-handwritten.h ↔ assert_ffi_layout! in ResolvedSource.rs); Zig::SourceProvider::create now takes every retained field by transferToWTFString()/std::exchange so the C++ destructor and Rust Drop don't both release. Errorable<T>::Drop only drops the value arm when success, matching ~ErrorableResolvedSource. The to_error_instance family no longer consumes the caller's ref (C++ side unchanged — it never deref'd), so removing the post-call .deref() is correct. JsCell<String>::get() returning a borrow (was OwnedStringCell) — verified callers either .clone() or use it within the cell's lifetime.

Extended reasoning...

Overview

This PR converts bun_core::String from a Copy POD with manual .deref() to an owning type where Drop derefs and Clone refs. It removes OwnedString/OwnedStringCell/OwnedResolvedSource, introduces StringView<'a> for by-value borrows, and updates ~220 call sites. On the C++ side it removes the Bun::toString(JSGlobalObject*, JSValue) +1 producer, gives ErrorableResolvedSource a destructor, shrinks ResolvedSource (drops specifier/allocator/needsDeref), and makes Zig::SourceProvider take retained fields by transfer instead of storing a ResolvedSource copy. FFI signatures are updated so by-value String = ownership crosses, &String/const BunString* = borrow.

Security risks

None specific to this change. The PR is a memory-ownership refactor; the risk class is use-after-free / double-deref / leak at the FFI boundary, not injection or auth. The regression test added (postgres-json-bind-leak) guards one of the ~60 leaks the PR closes.

Level of scrutiny

High. This is a foundational memory-safety change: every bun_core::String producer/consumer's refcount contract changed, struct layouts shared with C++ changed (with static_asserts on both sides), and the module-loader's ResolvedSource ownership protocol was rewritten. A missed site means either a UAF (extra deref) or a leak (missing one). The author ran ASAN + targeted test files locally; full CI is pending on build #104335.

Other factors

Three prior automated review passes surfaced only comment/cosmetic nits, all fixed. The bug hunting system found nothing on this pass. The change is internally consistent and well-reasoned (the PR description accurately maps every struct-size change and asserts layouts on both sides). But 225 files of refcount-contract changes across an FFI boundary is exactly the category REVIEW.md's "Native code: memory safety" section flags as most-blocked — a human familiar with the ResolvedSource/SourceProvider lifecycle should confirm the C++-side transfer points (ZigSourceProvider::create, ~ErrorableResolvedSource, fetchCommonJSModule's builtin path) match the new Rust ownership.

…ime-tied

`JSValue::to_zig_string` handed back a `ZigString` aliasing a JSString's
characters with no lifetime and no ref. Delete it (and C++
`JSC__JSValue__toZigString`); callers now either take an owned
`to_bun_string()` / `to_slice_clone()`, or hold the `&JSString` from
`to_js_string()` in a local and borrow via `JSString::view()`, which now
returns `StringView<'a>` tied to that handle.

Fixes three places that used the borrowed view across user JS:
`Bun.dlopen` (library name read again after walking the symbols object),
and the JSX tag name in `console.log`/`expect` formatting (reused after
printing props/children).

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

I've reviewed this PR across several rounds and the bug-hunting pass on the latest commits found no issues (earlier rounds surfaced only stale-comment nits, all addressed). Given the scope — 243 files rewriting bun_core::String's ownership model across the Rust/C++ FFI boundary, plus ResolvedSource/ErrorableResolvedSource gaining destructors on both sides and the removal of JSValue::get_zig_string — a human sign-off on the overall ownership design is still warranted before merge.

What was reviewed

  • String Drop/Clone vs. former manual .deref() sites — checked for double-frees where old explicit derefs remained; none found (all scopeguard/.deref() calls in the diff were removed, not left alongside Drop).
  • FFI signature agreement: spot-checked that by-value String returns on the Rust side pair with transferToWTFString()/Bun::toStringRef on the C++ side, and &String ↔ const BunString*.
  • ErrorableResolvedSource C++ destructor vs. Rust Drop for Errorable<T> — both gate on success and release the same fields; Zig::SourceProvider::create now std::exchanges out what it keeps.
  • ZigException::Holder new Drop only releases the populated ..frames_len/..source_lines_len prefixes — matches how C++ populates them.
Extended reasoning...

Overview

This PR changes bun_core::String from a Copy POD (manual .deref()) to an owning type where Drop = deref and Clone = ref, then propagates that through 243 files: every +1 producer now returns an owned String, borrows become &String/StringView<'_>, OwnedString/OwnedStringCell/OwnedResolvedSource are deleted, transfer_to_js becomes consuming into_js, and ResolvedSource/Errorable<T> gain real destructors on both the Rust and C++ sides. It also removes JSValue::get_zig_string/to_zig_string (unsound borrow with no lifetime) and reworks ZigException::Holder, Zig::SourceProvider, and the module-loader hook signatures.

Security risks

None net-new. The change is memory-ownership plumbing; it does not add parsing of untrusted input, auth, or crypto. The risk class is memory safety (double-free / UAF / leak) rather than a security boundary — and the direction of the change is toward fewer leak/UAF hazards by moving the +1/-1 rule into the type system.

Level of scrutiny

High. This is a foundational refactor of the most-used string type across the FFI boundary, with coordinated layout and destructor changes in both Rust and C++ (headers-handwritten.h, ZigSourceProvider, ~ErrorableResolvedSource). Per the approval guidelines this is squarely "complex, large, touches critical code paths" — not a candidate for bot-only approval regardless of how clean the individual hunks look.

Other factors

Four prior bug-hunting rounds surfaced only stale-comment nits (all fixed in b7691c1, e22735d, e34fd40). This run found nothing. A regression test for #40102 was pulled in. Layout is guarded by assert_ffi_layout!/static_assert. One maintainer left a positive comment but no formal approval is visible. The two newest commits (045baf2, 83737ac) are further cleanups in the same vein and were covered by this run.

…gErrorType

- `JSValue::to_js_string_view()` returns `(&JSString, StringView)` in one
  call (C++ `JSC__JSValue__toStringAndView`); `JSString::view()` is a
  direct FFI return. Hot `get_zig_string` replacements (node:path, fs
  flags/mode, CryptoHasher, TextEncoderStream, console formatter) are back
  to a single FFI call. `StringView::to_utf8` never refs.
- `get_class_name` / `get_name_property` / `get_description` return an
  owned `String` (they aliased strings user JS could drop); `symbol_for`
  takes bytes; `js_type_string` returns a view; `as_string` returns
  `&JSString`. `to_slice_or_null`/`to_slice_clone` folded into `to_slice`.
- `ZigErrorType` was `{code, value}` with `code` never read: the
  `Errorable` error arm is now just the `JSValue` on both sides.
- `ZigException::Holder` back to plain array fields: hidden frames swapped
  past `frames_len` still own strings, so dropping only the prefix leaked.
- `AsyncModule::on_done` goes through `fulfill`; `StringView::static_` /
  `borrow_utf8`; const/`&` FFI params; assorted stale comments and
  duplicate helpers removed; jsonb leak fixture renamed to `.fixture.ts`.
…one URL wrapper

- `bun_jsc` calls `__bun_transpile_source_code` / `__bun_fetch_builtin_module`
  as `extern "Rust"` fns defined in `bun_runtime` (same convention as
  `dispatch.rs`); the four C-ABI entry points that only forwarded
  (`Bun__transpileFile`, `Bun__transpileVirtualModule`,
  `Bun__resolveAndFetchBuiltinModule`, `Bun__resolveEmbeddedNodeFile`) live
  next to their bodies and take references.
- `JSPropertyIterator::next(&self)` yields `(StringView<'_>, JSValue)`; the
  name borrows the iterator instead of the global. TOML stringify threads
  the header path as a stack-linked list; parseArgs/XML keep borrowing.
- `bun_jsc::URL` re-exports `bun_url::whatwg::URL` (+ `URLJsc` for the two
  JS-value entry points) instead of a second wrapper with its own externs;
  `hosted_git_info` uses `bun_url::whatwg::Parsed`; `resolver_jsc` uses
  `bun_string_jsc::to_js_array`.
…n, typed TranspileArgs.extra; commit Cargo.lock

- Cargo.lock picks up bun_url's bun_opaque dep and sourcemap_jsc's bun_url
  dep (CI builds with --locked).
- `symbol_for` takes a `'static` key (`String::static_`, no UTF-8 copy);
  `get_description` returns a view borrowed from the Symbol; `getClassName`
  fallback returns the static `className()` without allocating.
- `JSC__JSValue__toStringAndView` checks for an exception after `value()`.
- `TranspileArgs.extra` is a typed `*mut TranspileExtra`; the duplicate
  param on `transpile_source_code_inner` is gone.
- JSBundler/Glob/Blob/dns/WebSocketServerContext borrow the argument
  string via `to_js_string_view` instead of `to_slice`.
- `JSValue::type_name`, `StringView::static_` delegates to `String::static_`,
  naming/doc nits.
Comment thread src/jsc/ZigException.rs Outdated
dylan-conway added a commit that referenced this pull request Aug 24, 2026
Adapt telemetry code to bun_core::String owning its ref (#40238):
drop OwnedString wrappers, destructure JSPropertyIterator::next(),
ManuallyDrop the String member of the AttrValue union.
Jarred-Sumner pushed a commit that referenced this pull request Aug 24, 2026
…ng for any syscall (#38383)

### Problem
- `fs.stat(p, cb)`, `fs.readFile(p, cb)`, `fs.access`, `open`,
`readdir`, `realpath`, `unlink`, `mkdir`, `readlink`, `rmdir`, `rename`,
`link`, `symlink`, `copyFile`, `writeFile`, `appendFile`, `truncate`,
`statfs`, `mkdtemp`, `opendir`, `chmod`, `chown`, `utimes` (every
callback API taking a path) throw `ENAMETOOLONG: name too long, open`
synchronously when the path is `MAX_PATH_BYTES` (4096 Linux, 1024 macOS,
98302 Windows) or longer, and the callback never runs. Node 26 calls
back with `ENAMETOOLONG` for all of them (outputs below).
- Cause: `PathLike::from_js` (src/runtime/node/types.rs,
`Valid::path_string_length` / `Valid::path_buffer`) throws the error
while the binding is still converting arguments. `fs.promises.*` only
behaves because its `asyncWrap` is an `async function`; the callback
layer in src/js/node/fs.ts calls the native binding directly, so the
throw escapes to the caller. `Bun.file(p)` throws at construction for
the same reason; that is a different entry point and is not changed here
(see below).
- Fixes #25659. #36324 is an earlier attempt at the same bug that
removes the parse-time length guard and re-adds the check at every
dispatch site; this PR keeps the guard and is the smaller alternative
(trade-off: #36324 also fills in a per-operation `err.syscall`, this PR
leaves it `undefined` as today).

### Fix
- `ArgumentsSlice` (src/jsc/CallFrame.rs) gets `deferred_error:
Option<Box<bun_sys::SystemError>>`.
- `Valid::path_length` (types.rs) runs once for every path form (string,
`file:` URL, Buffer, ArrayBuffer) after conversion. Sync parsing
(`will_be_async == false`) throws exactly as before. When the binding is
parsing for an async operation it records the error on the slice instead
and returns an empty placeholder path, so the remaining arguments are
still validated (an invalid `encoding` still throws synchronously, as in
node). The first recorded error wins for two-path operations.
- `parse_async_args` (src/runtime/node/node_fs_binding.rs) is the
argument-parsing step the three promise-returning bindings (`run_async`,
`cp`, `readdir`) now share; after a successful parse it returns a
promise rejected with the recorded error, so the operation is never
started on the placeholder. The already-aborted `AbortSignal` check
stays ahead of it (node rejects with `AbortError` for `readFile(tooLong,
{ signal })` too). `args::Cp` joins the `FsArgument` list so `cp` can
use the helper. This is the line that changes behaviour; the rest of the
diff in that file is the three copies of the parse block collapsing into
the helper.
- The already-aborted branch builds its rejected promise with
`JSPromise::rejected_promise` like the deferred-error branch, instead of
the deprecated
`dangerously_create_rejected_promise_value_without_notifying_vm`.
Nothing observable changes through `fs.*`/`fs.promises.*`, which always
attach handlers to the binding's promise; it removes the deprecated call
from the shared helper.
- The error now also carries `err.path` (sync and async). Since
`to_system_error` formats the message into a 4096-byte buffer, the
message of a Linux-length path is cut off after 4096 bytes (#38201 is
changing that formatter); `err.path` itself is complete. `err.syscall`
stays `undefined`, as before.
- Order change for a path that is both too long and contains NUL bytes:
the NUL-byte `ERR_INVALID_ARG_VALUE` now wins, which is node's order (it
validates NUL bytes before issuing the syscall).
- Not changed: `Bun.file(p)` / `Bun.write(p, ...)` still throw
synchronously at argument conversion. Making those lazy means letting an
over-long path into a Blob store and auditing every Blob/sendfile/copy
path that copies the path into a fixed buffer, which is separate work.
- Verified: test/js/node/fs/fs-path-length.test.ts gains a describe
covering 31 callback operations (string, Buffer and URL paths, both
operands of `rename`, `mkdir`/`readdir` recursive, `opendir`,
`realpath.native`), the callback not running synchronously, `fs.exists`
answering `false`, an invalid option still throwing synchronously, the
sync forms still throwing (now with `path`), and the #25659 repro. 34 of
the 35 new tests fail on the unfixed build: 31 because the error is
thrown synchronously, and `rm`, `cp` (already routed through a JS
promise) and the sync-forms test because `err.path` was missing. All
pass with the fix.
- Also run against the debug build: fs.test.ts, cp.test.ts,
promises.test.js, dir.test.ts, fs-mkdir.test.ts,
readdirSync-recursive-error-leak.test.ts and 35 ported node `test-fs-*`
files touching path validation, abort signals and error shapes: no
failures (abort-signal-leak-read-write-file.test.ts times out in this
container on an unmodified build as well, 100k iterations at ~7ms each
under debug ASAN). The matrix below also runs clean under
`BUN_JSC_validateExceptionChecks=1`. `cargo clippy` on `bun_jsc` and
`bun_runtime` is clean.

### Background
- `ArgumentsSlice` is the cursor the native bindings walk over a call's
arguments. node:fs async bindings set its `will_be_async` flag before
parsing so string arguments get copied into thread-safe forms; this PR
uses the same flag to mean "this binding reports errors through a
promise".
- `PathLike` is the parsed path argument. Every fs operation copies it
into a `PathBuffer` (`[u8; MAX_PATH_BYTES]`, plus NUL) right before the
syscall, which is why the parser rejects paths of `MAX_PATH_BYTES` or
more up front: the copy is infallible and the rest of node_fs.rs relies
on the length invariant. The kernel's own limit is the same number
(PATH_MAX), so the early `ENAMETOOLONG` is the error the syscall would
have produced; only its delivery was wrong.
- `bun_sys::SystemError` is the JS-facing error record (`code`, `errno`,
`message`, `path`, ...); `to_error_instance` turns it into the JS
`Error` that node-style fs errors are made of. The deferred slot holds
this record and the binding converts it when it builds the rejected
promise.
- The callback APIs in fs.ts are all of the form `binding.stat(path,
options).then(ok, callback)`: whatever the binding returns as a rejected
promise reaches the callback, whatever it throws reaches the caller.

Rebases: onto #40251 (main dropped the `has_exception()` guards after
`?`-checked calls; the helper follows) and #40238 (`bun_core::String`
owns its WTF ref, so `from_bun_string` / `path_like_from_string` take
the string by value and use `into_slice` / `into_thread_safe_slice`).
Both resolutions are mechanical; the behaviour is the one described
above.

<details>
<summary>Matrix: node 26.3.0 vs this branch (sync = did the call throw;
cb = code passed to the callback)</summary>

Node 26.3.0:

```
access             returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:access
appendFile         returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:open
chmod              returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:chmod
chown              returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:chown
copyFile (src)     returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:copyfile
lstat              returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:lstat
mkdir              returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:mkdir
mkdir recursive    returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:mkdir
mkdtemp            returned  cb:ENAMETOOLONG  syncCb:false path===input:false syscall:mkdtemp
open               returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:open
opendir            returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:opendir
readdir            returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:scandir
readdir recursive  returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:scandir
readFile           returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:open
readlink           returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:readlink
realpath           returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:lstat
realpath.native    returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:realpath
rename (oldPath)   returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:rename
rename (newPath)   returned  cb:ENAMETOOLONG  syncCb:false path===input:false syscall:rename
rm                 returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:lstat
rmdir              returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:rmdir
stat               returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:stat
statfs             returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:statfs
symlink (path)     returned  cb:ENAMETOOLONG  syncCb:false path===input:false syscall:symlink
truncate           returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:open
unlink             returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:unlink
utimes             returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:utime
writeFile          returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:open
stat (Buffer)      returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:stat
stat (URL)         returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:stat
cp                 returned  cb:ENAMETOOLONG  syncCb:false path===input:true  syscall:lstat
exists             -> false
readdir bogus encoding: THREW ERR_INVALID_ARG_VALUE
statSync: ENAMETOOLONG path===input:true syscall:stat
```

Bun 1.4.0 (unfixed): every line above except `rm`, `cp` and `exists`
reads `THREW SYNCHRONOUSLY ENAMETOOLONG, callback NOT called`;
`statSync` has no `path`.

This branch: every line reads `returned cb:ENAMETOOLONG syncCb:false
path===input:true syscall:undefined` (`path` is the over-long operand
also for `rename (newPath)`, `symlink` and `mkdtemp`, where node reports
it as `dest` / with the template suffix), `exists -> false`, `readdir
bogus encoding: THREW ERR_INVALID_ARG_VALUE`, `statSync: ENAMETOOLONG
path===input:true`.

Known remaining difference: for `copyFile(missing, tooLong)` and
`link(missing, tooLong)` node's kernel call fails on the first operand
and reports `ENOENT`; this branch reports `ENAMETOOLONG` for the second.
Both arrive through the callback. The tests use `rename`/`symlink` for
the second-operand cases, where node also reports `ENAMETOOLONG`.

`readFile`/`writeFile` with an already-aborted signal and an over-long
path reject with `AbortError` on both node and this branch; with a live
signal both give `ENAMETOOLONG`; an unhandled
`fs.promises.stat(tooLong)` reaches `unhandledRejection` on both.
</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/fs/fs-path-length.test.ts

<!-- robobun:evidence:end -->
robobun pushed a commit that referenced this pull request Aug 24, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.
dylan-conway added a commit that referenced this pull request Aug 25, 2026
…>) (#40374)

### What does this PR do?

Follow-up to #40238. Two string helper types still had no lifetime:

- `ZigString` — a `{ptr, len}` view with encoding bits (Latin-1 / UTF-8
/ UTF-16). Now **`EncodedSlice<'a>`**, same 16-byte layout, `'a` = the
bytes it was built over. The lifetime-free POD remains only as the
`BunString` union arm.
- `ZigStringSlice` — "these characters as UTF-8 bytes": borrowed when
already UTF-8/ASCII, a transcoded `Vec` otherwise, or a view kept alive
by a `StringImpl` ref. Its borrowed variants were raw pointers, so this
compiled:
  ```rust
let s = value.to_bun_string(global)?.to_utf8(); // temp String dropped
here
use(s.slice()); // only valid because to_utf8() had taken a ref
  ```
  Now **`Utf8Bytes<'a>`**:
  ```rust
pub enum Utf8Bytes<'a> { Borrowed(&'a [u8]), Owned(Vec<u8>),
Shared(String) /* 8-bit ASCII, holds the ref */ }

  impl String {
fn to_utf8(&self) -> Utf8Bytes<'_>; // borrow (no ref) or transcode;
scoped to self
fn into_utf8(self) -> Utf8Bytes<'static>; // for storing: moves self's
ref into Shared
  }
  ```
`to_utf8_without_ref` / `to_utf8_borrowed` fold into `to_utf8` (the
`&self` borrow is what keeps the impl alive now).
`StringView<'a>::to_utf8` is `'a`-scoped; `JSStringView::to_utf8` is
scoped to the guard. `SliceWithUnderlyingString` becomes `Utf8WithString
{ utf8: Option<Vec<u8>>, string: String }` (built by
`String::into_utf8_with_string()`): the `Vec` only when transcoding was
needed, the slice derived from `string` otherwise, replacing a
self-referential raw view.

With lifetimes in place the compiler pointed at code that kept a slice
past the Rust value it came from. At runtime these were kept valid by
the ref the old `to_utf8()` took (so none is a live use-after-free on
`main`), but that was invisible in the types; they now own their bytes
via `into_utf8()` or keep the source alive explicitly:
- code-coverage source-URL map (`ByteRangeMapping`), shell worker argv
(`vm_args_utf8`), `SocketConfig.hostname_or_unix`, MySQL `Value`/query
string, S3 credential option strings, bake framework config strings,
valkey command arguments, hosted-git-info committish, `console` JSX tag
/ `%s` args, `Crypto` algorithm name
- `path.join`/`path.resolve`: each argument's `JSStringView` guard was
dropped when the loop advanced while its bytes were kept; for a `String`
object argument whose `toString` returns a fresh string (Bun's
`is_string()` accepts boxed strings, unlike Node), nothing else rooted
that string. The guards are now held until the join/resolve runs.
`path.basename(path, ext)` and `udpSocket.send`/`sendMany` get the same
scoping (their cells were already rooted).
- `fetch("data:…")` moved the URL `String` value while a slice borrowed
through it, and `ZigStackFrame.source_url` was reassigned while its
UTF-8 borrow was live — both fine at runtime (the bytes live in the
`StringImpl`, which held a ref), both rejected by the borrow checker
now; restructured

### Fixes

Renaming the neutral `ZigString::init` to `EncodedSlice::latin1` made
these visibly wrong — they built a Latin-1 view over UTF-8 bytes (the
same code existed in Zig and on `main`), so non-ASCII input decoded as
mojibake:
- `Bun.Transpiler#scan` / `scanImports`: import specifiers
(`"./módulo-ü.js"` came back as `"./módulo-ü.js"`) — test added
- `bun test --rerun-each=N` under a non-ASCII path: the module-registry
key was Latin-1-decoded, the entry was never evicted, and the file body
ran once instead of N times — test added
- `Bun.cwd`, `Bun.origin`, and `Bun.main` when the entry is
`[eval]`/`[stdin]` or not openable; `bun:ffi` `cc` compiler error text
and non-ASCII symbol names (as the `symbols` property key and the
function's `name`); the `"N errors building <path>"` AggregateError
message
- `fs.readFile(path, "utf8")` (any string encoding) of an empty file
embedded in a `bun build --compile` executable returned `ENOMEM` (the
empty result was built on a `Dead` string); it returns `""` — test
added.
- `Zig::getErrorInstance` (C++) used the non-copying `toString`, so an
`Error` created from a temporary Rust buffer via
`EncodedSlice::to_error_instance` kept `message` pointing at freed
memory (auto-install / package-resolution failure messages, the
`bun:ffi` `cc()` compile-error message). It now copies, like
`getTypeErrorInstance` already did.

Other observable changes:
- node:http `upgrade` with `options.headers`: a `Sec-WebSocket-Protocol`
/ `Sec-WebSocket-Extensions` value containing bytes ≥ 0x80 was
transcoded Latin-1→UTF-8 twice on the wire (`é` became `C3 83 C2 A9`);
it is passed through once now.
- `new Blob([...parts])` where the string parts come from nested arrays
or non-literal values: an all-ASCII result now takes the ASCII fast path
(main pessimistically marked it as possibly non-ASCII because the old
slice type couldn't tell "borrowed with a ref" from "transcoded").
- If building the `specifier`/`referrer`/`url` properties of a
module-resolution or auto-install error itself fails (string too long /
OOM), that exception is cleared and the `import()` promise is rejected
with the error as built so far (main set such a property to `""`).
- `valkey.hset(key, {field: value, …})` / `hmset` read the field-name
bytes after the property iterator that owned them was destroyed; the
iterator now outlives the command send.
- Not JS-visible: error messages built from a temporary Rust buffer
(`EncodedSlice::utf8(bytes).to_*_error_instance`, formatted
`create_*_error_instance`) are copied once; a `String` message shares
its impl and an argument-free ASCII literal is atomized, all through one
`BunString__toErrorInstance(kind)`; coded errors with a literal message
(`global.err(CODE, "…")`, `throw_invalid_arguments("…")`) get an
atomized message because `String::create_format` returns `static_` for
an argument-free ASCII literal; `EncodedSlice::to_owned_slice` no longer
writes a hidden NUL past `len` (no caller read it; `to_owned_slice_z` is
the NUL-terminated form); `EncodedSlice::slice()` no longer truncates at
`u32::MAX`; `JSC__JSFunction__getSourceCode` returns an owned string
instead of a view into the SourceProvider; S3 credential option strings
are stored once (main kept both a boxed copy and the source slice,
pinning the JS string for the request).

Also done while every site was being touched:
- Constructors say what they tag: `EncodedSlice::{latin1, utf8, utf16,
from_bytes}` (was
`init`/`init_utf8`/`init_utf16`/`from_bytes`+`with_encoding`);
`String::init` (an alias of `from_bytes`/`static_`) is removed.
`JSValue::to_slice` → `JSValue::to_utf8`;
`eql_comptime`/`has_prefix_comptime` → `eq_ascii`/`starts_with_ascii`;
`PathLike<'a>`/`StringOrBuffer<'a>`/`PathOrFileDescriptor<'a>` arms are
`String(Utf8WithString) | ThreadsafeString | Utf8(Utf8Bytes<'a>)`
(`PathLike::Bytes(CowSlice)` folded into `Utf8`): a path borrowed for a
synchronous syscall carries the lifetime of its bytes instead of an
unchecked `CowSlice`, and `to_thread_safe`/`from_js` exist only on the
`'static` instantiation so a borrowed path cannot reach the work pool.
- One spelling for "Rust UTF-8 bytes → JS string":
`bun_string_jsc::create_utf8_for_js(global, bytes)` (≈95 sites that used
`EncodedSlice::…(x).to_js` / `String::…(x).to_js`); ASCII literals use
`String::static_("..").to_js`. Decoding is identical for valid and
invalid UTF-8; the only difference is that a result over the maximum JS
string length now throws (`STRING_TOO_LONG` for 8-bit, an out-of-memory
error for 16-bit) instead of evaluating to `""`, and this path does not
consult the synthetic allocation limit
(`setSyntheticAllocationLimitForTesting` /
`BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT`), so strings between that
limit and the real maximum are returned rather than `""`.
- One path for "owned UTF-8 `Vec` → JS string"
(`String::from_owned_utf8` / `owned_utf8_into_js`, used by
`Utf8WithString`, `Blob.text()`, `TextDecoder`, Build/ResolveMessage
`toString`); `owned_utf16_into_js(global, Vec<u16>)` is the UTF-16
counterpart (the separate `EncodedSlice__toExternalU16` export is
removed). `EncodedSliceJsc::to_external_value`/`external` return
`JsResult` instead of `ZERO` with a pending exception.
- Errors from a message:
`global.create_*_error_instance(format_args!(..))` everywhere;
`JSC__create{Error,TypeError,RangeError}`,
`EncodedSlice__to{Error,TypeError,SyntaxError,RangeError}Instance` and
helpers.h `get*ErrorInstance` collapse into
`BunString__toErrorInstance(str, global, BunErrorKind)`.
`create_aggregate_error` takes `format_args!` like its siblings.
- The `BunString` PODs (`Tag`, `EncodedSlice`, `StringImpl`, `String`)
move from `bun_alloc` into `bun_core::string`; the newtype/`Deref`
layering over them is gone.
- Removed duplicates/dead code the rename exposed:
`EncodedSliceJsc::with_encoding` (= `from_bytes`),
`EncodedSlice::static_` (= `latin1`),
`set_output_encoding`/`detect_encoding` (= `from_bytes`), node_os's
private copy of the trait, `Utf8Bytes::{init_dupe, is_allocated,
take_owned_raw}`, nine write-only `S3CredentialsWithOptions::_*_slice`
fields, `Terminal::_term_name`, `Response::get_utf8_url`,
`bun_ast_jsc::{msg_to_js, log_to_js, log_to_js_aggregate_error,
log_to_js_array}` and `css_jsc::error_jsc` (callers use
`bun_jsc::LogJsc` / `create_error_instance`), and unused exports
`EncodedSlice__free/__toAtomicValue/__to16BitValue/__toExternalValueWithCallback`,
`JSC__JSValue__symbolKeyFor/createRangeError/createTypeError/putRecord/createStringArray/hasOwnProperty`,
`JSC__JSGlobalObject__{get,put}CachedObject`,
`JSC__JSObject__{get,put}Direct`, `WebCore__DOMURL__href_/pathname_`,
`Zig::toJSString`, `Zig::toString(EncodedSlice, StringPointer)`.

C++ side renamed to match: `struct EncodedSlice`,
`BunStringTag::EncodedSlice`/`StaticEncodedSlice`,
`Zig::toEncodedSlice`, `EncodedSlice__*` exports (cppbind type map
updated). New debug assertions: `String::static_` requires ASCII,
`EncodedSliceJsc::to_js` requires a non-global-allocated slice,
`EncodedSlice__toExternalValue` requires a non-UTF-8 tag. No behaviour
change intended beyond the fixes above; `to_utf8()` on a WTF-backed
ASCII string is now zero atomics instead of a ref/deref pair.

### How did you verify your code works?

New tests: `test/bundler/transpiler/transpiler.test.js` (scanImports
non-ASCII specifiers), `test/cli/test/rerun-each.test.ts` (non-ASCII
path), and `test/bundler/compile-asset-bunfs.test.ts` (empty embedded
file via `readFileSync(.., "utf8")`, dirent `parentPath`, ENOENT
`err.path`) — all fail on 1.4. `cargo check`/`clippy` on linux,
windows-msvc and macOS targets; debug+ASAN build driven through
`node:path` (join/resolve/basename/relative with non-ASCII), `fs` with
string/Buffer/URL paths, `Bun.$` interpolation and `$.escape`,
`udpSocket.send`/`sendMany`, `fetch("data:")`, `CryptoHasher`,
`Bun.inspect`, WebSocket send/publish, module loading, `Bun.Transpiler`,
`RedisClient` URL parsing; `BUN_JSC_validateExceptionChecks=1` clean on
the same script. Ran path, fs, shell, udp, inspect, inspect-error,
fetch, serve, http2, websocket-server, socket, text-encoder, zlib,
postgres-string-leak and valkey-gc test files on debug and release
builds; results match `main`'s debug build (remaining local failures —
`mkdtempSync() empty name` EACCES, `broken pipe subproc`, Blob utf16 gc
— reproduce on `main`).
robobun pushed a commit that referenced this pull request Aug 25, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).
robobun pushed a commit that referenced this pull request Aug 25, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).
robobun pushed a commit that referenced this pull request Aug 25, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).

Twenty-fourth rebase (9 more commits, onto 82123d3): six files, all
with #40478 (RefPtr releases on Drop). This PR's StoreRef::adopt is
retired: main's RefPtr<Store> is the same owning handle, so
webcore_types.rs is main's again and store_backed_buffer_to_js moves a
RefPtr<Store> into the JS object as the *_from_owner owner (the view
closure reaches the bytes through Store::data_mut). The sql event-loop
guard keeps this PR's safe EventLoop::scope under main's renamed ref
guard; expect.rs keeps this PR's wrapping over main's RefPtr comments.
The vm-thread-door inventory follows main's StoreRef-to-Store rename.
robobun pushed a commit that referenced this pull request Aug 27, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).

Twenty-fourth rebase (9 more commits, onto 82123d3): six files, all
with #40478 (RefPtr releases on Drop). This PR's StoreRef::adopt is
retired: main's RefPtr<Store> is the same owning handle, so
webcore_types.rs is main's again and store_backed_buffer_to_js moves a
RefPtr<Store> into the JS object as the *_from_owner owner (the view
closure reaches the bytes through Store::data_mut). The sql event-loop
guard keeps this PR's safe EventLoop::scope under main's renamed ref
guard; expect.rs keeps this PR's wrapping over main's RefPtr comments.
The vm-thread-door inventory follows main's StoreRef-to-Store rename.

Twenty-fifth rebase (23 more commits, onto 0e395c2): four files, all
with #40511 (async fs calls no longer pin Buffer paths). pbkdf2 and
scrypt take main's from_js_async parsers (ThreadIsolated params) under
the scoped signatures, StringOrBuffer keeps main's from_js_async next to
this PR's from_js_scoped / from_js_deferred, and the
BlobOrStringOrBuffer::from_js_async this PR's insertion sat beside is
gone with main. Import merges in node.rs and MarkdownObject.rs. The
vm-thread-door inventory follows main's ThreadSafe-to-ThreadIsolated
rename.
robobun pushed a commit that referenced this pull request Aug 27, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).

Twenty-fourth rebase (9 more commits, onto 82123d3): six files, all
with #40478 (RefPtr releases on Drop). This PR's StoreRef::adopt is
retired: main's RefPtr<Store> is the same owning handle, so
webcore_types.rs is main's again and store_backed_buffer_to_js moves a
RefPtr<Store> into the JS object as the *_from_owner owner (the view
closure reaches the bytes through Store::data_mut). The sql event-loop
guard keeps this PR's safe EventLoop::scope under main's renamed ref
guard; expect.rs keeps this PR's wrapping over main's RefPtr comments.
The vm-thread-door inventory follows main's StoreRef-to-Store rename.

Twenty-fifth rebase (23 more commits, onto 0e395c2): four files, all
with #40511 (async fs calls no longer pin Buffer paths). pbkdf2 and
scrypt take main's from_js_async parsers (ThreadIsolated params) under
the scoped signatures, StringOrBuffer keeps main's from_js_async next to
this PR's from_js_scoped / from_js_deferred, and the
BlobOrStringOrBuffer::from_js_async this PR's insertion sat beside is
gone with main. Import merges in node.rs and MarkdownObject.rs. The
vm-thread-door inventory follows main's ThreadSafe-to-ThreadIsolated
rename.

Twenty-sixth rebase (8 more commits, onto 72ffcd8): one import-line
conflict in ffi_body.rs, where #40592 added ErrorCode next to this PR's
scoped imports. Both kept; no inventory changes.
robobun pushed a commit that referenced this pull request Aug 28, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).

Twenty-fourth rebase (9 more commits, onto 82123d3): six files, all
with #40478 (RefPtr releases on Drop). This PR's StoreRef::adopt is
retired: main's RefPtr<Store> is the same owning handle, so
webcore_types.rs is main's again and store_backed_buffer_to_js moves a
RefPtr<Store> into the JS object as the *_from_owner owner (the view
closure reaches the bytes through Store::data_mut). The sql event-loop
guard keeps this PR's safe EventLoop::scope under main's renamed ref
guard; expect.rs keeps this PR's wrapping over main's RefPtr comments.
The vm-thread-door inventory follows main's StoreRef-to-Store rename.

Twenty-fifth rebase (23 more commits, onto 0e395c2): four files, all
with #40511 (async fs calls no longer pin Buffer paths). pbkdf2 and
scrypt take main's from_js_async parsers (ThreadIsolated params) under
the scoped signatures, StringOrBuffer keeps main's from_js_async next to
this PR's from_js_scoped / from_js_deferred, and the
BlobOrStringOrBuffer::from_js_async this PR's insertion sat beside is
gone with main. Import merges in node.rs and MarkdownObject.rs. The
vm-thread-door inventory follows main's ThreadSafe-to-ThreadIsolated
rename.

Twenty-sixth rebase (8 more commits, onto 72ffcd8): one import-line
conflict in ffi_body.rs, where #40592 added ErrorCode next to this PR's
scoped imports. Both kept; no inventory changes.

Twenty-seventh rebase (24 more commits, onto 49ff888): five files, all
with #40516 (refcounted types own their teardown). The serve-plugins
.then callbacks adopt their ref through main's RefPtr::from_raw under the
scoped argument spellings (this PR's ServePluginsRef guard is gone with
main's newtypes), FileSink keeps this PR's with_mut spelling over main's
RefPtr<FileSink> construction (create is main's one-liner), the
StatWatcher deinit hook stays deleted next to the scoped do_ref, and
ipc_host.rs / socket_body.rs are import and return-spelling merges. No
inventory changes.
robobun pushed a commit that referenced this pull request Aug 28, 2026
…rship contracts compile errors

Rebased onto main as a single commit; the branch history (with its merge
commits) is not preserved.

Adds a zero-cost branded scope layer over the raw JSC FFI (`src/jsc/scope.rs`:
`Scope<'s>`, `Local<'s>`) that makes two boundary bug classes compile
errors for code that stays on the scoped API:

- a JS value escaping its host call unrooted (persisting requires the
  explicit `Scope::persist` -> `Strong`);
- a JS-heap view (`Local::array_buffer_bytes`) held across an operation
  that can re-enter user JS (`&mut Scope`), e.g. a coercion that detaches
  the buffer.

Codegen integration:

- `#[bun_jsc::host_fn(scoped)]`: functions written as
  `fn(scope: &mut Scope, callframe) -> JsResult<Local>` get a
  macro-synthesized wrapper under their original name and unscoped
  signature, so js2native / `.classes.ts` / direct-call wiring stays
  byte-compatible. User `cfg`/doc/lint attributes propagate to the public
  wrapper and the extern shims.
- `ZIG_EXPORT(tag, reenters_js | no_user_js)` effect markers on the C++
  declarations; explicitly classified functions get branded wrappers
  generated into `bun_jsc::cpp::scoped` (`&mut Scope` / `&Scope`),
  unclassified and `null_is_throw` functions get none. All classified
  exports are verified against their C++ (`toMatch` and `putMayBeIndex`
  are `reenters_js`: a non-uint32 `lastIndex` goes through ToNumber, and
  index puts on exotic receivers reach `defineOwnProperty` traps).

Migration: ~470 host functions are converted to the scoped form
(behavior-preserving); the remaining escape hatches
(`unscoped_global()` / `unscoped_bun_vm()` / `.unscoped()` and unscoped
`#[host_fn]`s) are pinned per file by
`test/internal/source-lints/scope-escapes.test.ts`.

Also expresses the `make_*_with_bytes_no_copy` "pointer stays valid until
the deallocator runs" contracts as ownership transfer
(`typed_array_from_owned_slice` / `_from_vec` / `_from_owner`,
`ForeignBytes`, `external_string_from_utf16`, `OwnedUrl`,
`OwnedTextCodec`, `StoreRef::adopt`, `bun_sys::Mmap`,
`EventLoop::scope`), replacing hand-paired create/destroy and
leak-and-remember-to-free code paths.

The static `Bun.CryptoHasher.hash` / `Bun.password.verifySync` argument
detach bugs that motivated the layer were fixed independently on main
(#36165) by coercing every argument first; here the same behavior is
expressed through deferred `materialize` under the shared scope borrow,
so reordering the view capture before a coercion is a borrow error. Both
main's regression tests and the layer's suites pass.

Rebase onto main (461 commits): 40 files conflicted; resolved by taking
main's text and re-applying only the scope transformation. Changes that
main made obsolete were dropped (TextEncoderStreamEncoder host fns,
EventLoop::with_pipe_read_buffer, JSC__JSMap__size global arg, the
sendHelperChild scoping). Follow-ups main's newer code required:
JSValue::create_buffer_from_foreign now returns JsResult (the binding
became fallible on main), ArrayBufferSink::end_from_js uses
or_pending_exception (empty-jsvalue-laundering lint), TextDecoder
createForStream uses struct update syntax (clippy, since the PR removes
TextDecoder's Drop impl), ForeignBytes recorded in the vm-thread-door
inventory, scope-escape limits regenerated, and the ratchet's
regeneration mode is gated on an explicit --update flag.

Second rebase (30 more commits, onto 8bc4d2a): two conflicts from the
zero-fill removal (#39417). The zstd sync functions keep main's Failure
enum and create_buffer_from_box behind the scoped signatures, and the
latin1 TextDecoder path keeps main's uninitialized Vec but hands it to
JSC through external_string_from_utf16_vec instead of the raw
to_external_u16, matching the file's other two decode paths.

Third rebase (6 more commits, onto 0002bf8): conflicts were all with the
dead-code sweeps (#39420, #39448). Dropped the scoping of things main
deleted (Bun.nanoseconds' host fn, ArrayBufferSink::to_js, the
unreachable csrf error arm, three unused node:: re-exports) and kept the
ownership refactor of ArrayBufferSink::end_from_js. Inventories
regenerated; the jsresult-swallow one also picks up a count #39448 left
stale on main.

Fourth rebase (17 more commits, onto 258517a): do_publish keeps #39389's
shape (topic JSString held and ensure_still_alive'd across the message
conversion) under the scoped signature, and the valkey publish scoping
sits after the command block #29339 added.

Fifth rebase (42 more commits, onto 6948a12): OwnedTextCodec is gone
with the WebKit codecs (#39485); TextDecoder keeps main's encoding_rs
path and this PR's external_string_from_utf16_vec hand-offs, including
on the new path. ArrayBuffer::from_owned_bytes stays deleted (no callers;
its u32 cast that #39558 fixed never existed in the replacements), and
the no-copy deallocator contract now states both the Err-path timing
(the deallocator can run before Err, per #39558) and the cross-thread
timing this PR's Send bounds rely on.

Sixth rebase (18 more commits, onto 681a49b): three dead-code conflicts.
The scoped js_assert_settings goes away with the native assertSettings
(#38900, node:http2 validates in JS now), ParseArgumentsCfg's unused
Default impl stays removed (#39585), and TimeoutObject keeps main's
generated cached-accessor import next to the scoped imports.

Seventh rebase (14 more commits, onto 32e8703): valkey subscribe() keeps
the structure #39547 gave it (channel type check first, dial plus
send_rejection() before a listener is stored, no trailing else) with the
rejection and the new check spelled through the scope.

Eighth rebase (36 more commits, onto 56c4e3d): the three expect matcher
utils take #36912's propagating print_value; the conflict was only the
line wrapping. memory_pressure.rs (new on main) is added to the
scope-escape limits.

Ninth rebase (17 more commits, onto 72ec6e2, which includes the #39839
build fix): FileSink::on_close combines this PR's with_mut probe with
the parameterless ReadableStream::done() and is_some() guard from #39732.

Tenth rebase (5 more commits, onto 4448a2e): the Windows cluster handle
path keeps #39804's `?` on attach_windows_socket_payload under the scoped
argument spelling.

Eleventh rebase (7 more commits, onto a21f02a): pbkdf2/pbkdf2Sync take
the bodies #39922 gave them (from_js also returns the callback, pbkdf2
returns undefined, length 6) under the scoped signatures.

Twelfth rebase (16 more commits, onto 6fb7102): the safe vm_loop_ctx is
re-applied onto #40002's Cell-based upgrade client, including inside the
new clear_data's with_mut.

Thirteenth rebase (15 more commits, onto d0f6486): the assert binding is
scoped (5 hatches now, was 1); #39995's named-pipe live-count testing fn
is scoped like its neighbours.

Fourteenth rebase (12 more commits, onto e8300da): cron_remove's tail
takes #40024's safe ThisPtr start_linux call under the scoped return; the
rest of cron.rs's Cell/ThisPtr rewrite auto-merged. node_crypto_binding's
scope-escape limit rises by the two unscoped argon2 host fns #37015 added.

Fifteenth rebase (6 more commits, onto 1423031): two conflicts with
the defer-comment sweep (#40051): PasswordObject's verifySync keeps this
PR's deferred materialize of both arguments, and NodeHTTPResponse's
on_resolve keeps the scoped call, both without the removed defer comments.

Sixteenth rebase (31 more commits, onto 01008f8): the socket ref()/unref()
host fns take #39856's bodies (hold the loop while connecting, apply the
recorded state on open) under the scoped signatures.

Seventeenth rebase (8 more commits, onto 4bb20e5): #40055 made the
websocket upgrade client's loop-context plumbing safe itself (and dropped
the adapter), so this PR's vm_loop_ctx change there is retired and both
http_jsc files are main's.

Eighteenth rebase (8 more commits, onto 3e347b3): the rootError crash
hook keeps #37181's one-argument handle_root_error under the scoped
signature.

Nineteenth rebase (6 more commits, onto 7a5d837): #40251 removed the
exception checks that follow already-checked calls and made
JSString::to_slice / view, JSValue::get_zig_string and
handle_ipc_message return JsResult. Eight files conflicted inside scoped
bodies (BunObject, CryptoHasher, PasswordObject, ipc_host,
node_util_binding, server_body, expect, ObjectURLRegistry); main's
control flow is kept (the guards go, the ? is added) under the scoped
spellings. The four has_exception checks left in BunObject.rs are the
ones main kept (print_table / format2 swallow nested throws).

Twentieth rebase (9 more commits, onto f2fe7d3): 32 files conflicted,
nearly all with #40238 (bun_core::String owns its WTF ref). Main's
ownership idioms replace this PR's: OwnedString / scopeguard deref
wrappers and manual .deref() calls go (String drops its ref), into_js
replaces transfer_to_js (Scope::transfer_string now consumes the
String), JSValue::get_zig_string is gone so Local::get_zig_string becomes
Local::to_js_string_view (the JSStringView guard keeps the cell alive),
and to_slice_or_null collapses into to_slice. OwnedUrl is retired:
main's whatwg::Parsed is the same RAII handle, so src/jsc/URL.rs and
js_valkey.rs are main's again. ScopeFunctions.rs is rebuilt from main's
text (the strings module is gone, names are &'static str) with the 14
host fns scoped and rustfmt applied; jest.rs and expect.rs take main's
literals under this PR's wrapping. CachedStructure keeps main's
assume_init_mut / drop_in_place sequence over this PR's slice-taking
create_structure. UDP address getters add the ? main's create_sock_addr
now needs. Scope-escape limits drop by one in BunObject, node_util_binding
and server_body and by two in FormData (hatches replaced by scoped calls).

Twenty-first rebase (7 more commits, onto 8335017): one import-line
conflict in node_fs_binding.rs, where #38383 added the SystemErrorJsc
trait import next to this PR's scoped imports. Both kept; no inventory
changes.

Twenty-second rebase (6 more commits, onto 0823e50): 39 files
conflicted, all with #40374 (Utf8Bytes<'a> / EncodedSlice<'a>). Main's
types replace the PR's spellings inside scoped bodies: Local::to_slice is
now Local::to_utf8 (Utf8Bytes<'static>), ZigString::init(..).to_js and
create_utf8_for_js calls become scope.string_utf8 / scope.string, and
ScopedStringOrBuffer names StringOrBuffer<'static>. Main's
owned_utf16_into_js supersedes this PR's external_string_from_utf16*, so
src/jsc/ZigString.rs stays deleted and bun_string_jsc.rs and
TextDecoder.rs are main's again. Scope-escape limits drop in
filesystem_router (13 to 7), server_body (17 to 15) and Listener (11 to
9).

Twenty-third rebase (9 more commits, onto adc354d): two files.
FileSystemRouter::routes takes #40410's fallible JSValue::from_entries
(mapped into the scope), and advanceTimersByTime keeps #40414's NaN
check and main's message text under the scoped throws. The
jsresult-swallow inventory is main's again (#40410 fixed the FakeTimers
entry).

Twenty-fourth rebase (9 more commits, onto 82123d3): six files, all
with #40478 (RefPtr releases on Drop). This PR's StoreRef::adopt is
retired: main's RefPtr<Store> is the same owning handle, so
webcore_types.rs is main's again and store_backed_buffer_to_js moves a
RefPtr<Store> into the JS object as the *_from_owner owner (the view
closure reaches the bytes through Store::data_mut). The sql event-loop
guard keeps this PR's safe EventLoop::scope under main's renamed ref
guard; expect.rs keeps this PR's wrapping over main's RefPtr comments.
The vm-thread-door inventory follows main's StoreRef-to-Store rename.

Twenty-fifth rebase (23 more commits, onto 0e395c2): four files, all
with #40511 (async fs calls no longer pin Buffer paths). pbkdf2 and
scrypt take main's from_js_async parsers (ThreadIsolated params) under
the scoped signatures, StringOrBuffer keeps main's from_js_async next to
this PR's from_js_scoped / from_js_deferred, and the
BlobOrStringOrBuffer::from_js_async this PR's insertion sat beside is
gone with main. Import merges in node.rs and MarkdownObject.rs. The
vm-thread-door inventory follows main's ThreadSafe-to-ThreadIsolated
rename.

Twenty-sixth rebase (8 more commits, onto 72ffcd8): one import-line
conflict in ffi_body.rs, where #40592 added ErrorCode next to this PR's
scoped imports. Both kept; no inventory changes.

Twenty-seventh rebase (24 more commits, onto 49ff888): five files, all
with #40516 (refcounted types own their teardown). The serve-plugins
.then callbacks adopt their ref through main's RefPtr::from_raw under the
scoped argument spellings (this PR's ServePluginsRef guard is gone with
main's newtypes), FileSink keeps this PR's with_mut spelling over main's
RefPtr<FileSink> construction (create is main's one-liner), the
StatWatcher deinit hook stays deleted next to the scoped do_ref, and
ipc_host.rs / socket_body.rs are import and return-spelling merges. No
inventory changes.

Twenty-eighth rebase (36 more commits, onto 69c6138): one import-line
conflict in csrf_jsc.rs, where #40697 added IntegerRange next to this
PR's scoped imports. Both kept; no inventory changes.
kernoeb added a commit to kernoeb/PlanningSup that referenced this pull request Sep 4, 2026
* chore(bun): bump to 1.4.1 (jsonb leak fixed upstream)

Bun 1.4.1 ships the fix for the bun:sql jsonb native memory leak that
forced the revert to 1.3.14 in v3.3.6 (oven-sh/bun#40102, fixed by
oven-sh/bun#40238).

Verified against a real Postgres with 300 jsonb-bound queries of ~780 KB:
1.4.0 grows 33 -> 128 MB and keeps climbing, 1.4.1 stays flat.

DO NOT MERGE yet: 1.4.1 has a bundler regression that makes the compiled
server fail to boot with

  SyntaxError: Cannot declare a var variable that shadows a let/const/class
  variable: 'Check2'.

The bundler emits `var Check2 = Check2` beside a `let Check2` in the same
scope. Waiting for 1.4.2.

* fix(api): minify identifiers and add an inline sourcemap

Minifying identifiers shrinks the compiled server and sidesteps a Bun
1.4.1 renamer bug that emits invalid JS for Elysia, so the binary failed
to start with

  SyntaxError: Cannot declare a var variable that shadows a let/const/class
  variable: 'Check2'.

Upstream confirmed this approach in oven-sh/bun#41351.

Minified names alone would leave stack traces hard to read, so add an
inline sourcemap. Traces keep the real source file and line, which is more
useful than the bundled offsets they carried before. The sourcemap costs
what the minification saves, so the binary stays at 63.5 MB.

Verified with 16 integration and 14 auth integration tests on Bun 1.4.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (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.4.0 regression: bun:sql leaks native memory on every query binding a jsonb parameter

4 participants