Give borrowed string bytes a lifetime (Utf8Bytes<'a>, EncodedSlice<'a>)
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). NowEncodedSlice<'a>, same 16-byte layout,'a= the bytes it was built over. The lifetime-free POD remains only as theBunStringunion arm.ZigStringSlice— "these characters as UTF-8 bytes": borrowed when already UTF-8/ASCII, a transcodedVecotherwise, or a view kept alive by aStringImplref. Its borrowed variants were raw pointers, so this compiled:
Nowlet s = value.to_bun_string(global)?.to_utf8(); // temp String dropped here use(s.slice()); // only valid because to_utf8() had taken a refUtf8Bytes<'a>: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_borrowedfold intoto_utf8(the&selfborrow is what keeps the impl alive now).StringView<'a>::to_utf8is'a-scoped;JSStringView::to_utf8is scoped to the guard.SliceWithUnderlyingStringbecomesUtf8WithString { utf8: Option<Vec<u8>>, string: String }(built byString::into_utf8_with_string()): theVeconly when transcoding was needed, the slice derived fromstringotherwise, 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, MySQLValue/query string, S3 credential option strings, bake framework config strings, valkey command arguments, hosted-git-info committish,consoleJSX tag /%sargs,Cryptoalgorithm name path.join/path.resolve: each argument'sJSStringViewguard was dropped when the loop advanced while its bytes were kept; for aStringobject argument whosetoStringreturns a fresh string (Bun'sis_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)andudpSocket.send/sendManyget the same scoping (their cells were already rooted).fetch("data:…")moved the URLStringvalue while a slice borrowed through it, andZigStackFrame.source_urlwas reassigned while its UTF-8 borrow was live — both fine at runtime (the bytes live in theStringImpl, 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 addedbun test --rerun-each=Nunder 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 addedBun.cwd,Bun.origin, andBun.mainwhen the entry is[eval]/[stdin]or not openable;bun:fficccompiler error text and non-ASCII symbol names (as thesymbolsproperty key and the function'sname); the"N errors building <path>"AggregateError messagefs.readFile(path, "utf8")(any string encoding) of an empty file embedded in abun build --compileexecutable returnedENOMEM(the empty result was built on aDeadstring); it returns""— test added.Zig::getErrorInstance(C++) used the non-copyingtoString, so anErrorcreated from a temporary Rust buffer viaEncodedSlice::to_error_instancekeptmessagepointing at freed memory (auto-install / package-resolution failure messages, thebun:fficc()compile-error message). It now copies, likegetTypeErrorInstancealready did.
Other observable changes:
- node:http
upgradewithoptions.headers: aSec-WebSocket-Protocol/Sec-WebSocket-Extensionsvalue containing bytes ≥ 0x80 was transcoded Latin-1→UTF-8 twice on the wire (ébecameC3 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/urlproperties of a module-resolution or auto-install error itself fails (string too long / OOM), that exception is cleared and theimport()promise is rejected with the error as built so far (main set such a property to""). valkey.hset(key, {field: value, …})/hmsetread 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, formattedcreate_*_error_instance) are copied once; aStringmessage shares its impl and an argument-free ASCII literal is atomized, all through oneBunString__toErrorInstance(kind); coded errors with a literal message (global.err(CODE, "…"),throw_invalid_arguments("…")) get an atomized message becauseString::create_formatreturnsstatic_for an argument-free ASCII literal;EncodedSlice::to_owned_sliceno longer writes a hidden NUL pastlen(no caller read it;to_owned_slice_zis the NUL-terminated form);EncodedSlice::slice()no longer truncates atu32::MAX;JSC__JSFunction__getSourceCodereturns 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}(wasinit/init_utf8/init_utf16/from_bytes+with_encoding);String::init(an alias offrom_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 areString(Utf8WithString) | ThreadsafeString | Utf8(Utf8Bytes<'a>)(PathLike::Bytes(CowSlice)folded intoUtf8): a path borrowed for a synchronous syscall carries the lifetime of its bytes instead of an uncheckedCowSlice, andto_thread_safe/from_jsexist only on the'staticinstantiation 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 usedEncodedSlice::…(x).to_js/String::…(x).to_js); ASCII literals useString::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_LONGfor 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 byUtf8WithString,Blob.text(),TextDecoder, Build/ResolveMessagetoString);owned_utf16_into_js(global, Vec<u16>)is the UTF-16 counterpart (the separateEncodedSlice__toExternalU16export is removed).EncodedSliceJsc::to_external_value/externalreturnJsResultinstead ofZEROwith 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}Instanceand helpers.hget*ErrorInstancecollapse intoBunString__toErrorInstance(str, global, BunErrorKind).create_aggregate_errortakesformat_args!like its siblings. - The
BunStringPODs (Tag,EncodedSlice,StringImpl,String) move frombun_allocintobun_core::string; the newtype/Dereflayering 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-onlyS3CredentialsWithOptions::_*_slicefields,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}andcss_jsc::error_jsc(callers usebun_jsc::LogJsc/create_error_instance), and unused exportsEncodedSlice__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).