Bytecode cache: hand out looked-up symbols with the reference the decoder's callers adopt - #504
Conversation
…::decode with the reference its callers adopt Every branch of decode() hands back a UniquedStringImpl* carrying one reference (the callers adoptRef it, and CachedRefPtr also schedules a deref in the Decoder's finalizer), except the one resolving well-known symbols and BuiltinNames private names: 'String str = symbol; str.releaseImpl().unsafeGet()' takes a reference and drops it again when the temporary RefPtr dies, so the symbol went out at +0 and each Decoder released one more reference on it than it took. JSC's own entries are StaticSymbolImpls, which never die, so nothing showed. An embedder that registers heap-allocated PrivateSymbolImpls through BuiltinNames::appendExternalName() and decodes cached builtin-mode code that names them eventually has them freed underneath BuiltinNames.
|
Warning Review limit reached
On-demand reviews are free for the next 28 days. After that, they cost $0.25 per reviewed file. Or wait 17 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 63 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The refcount analysis checks out, but since this touches ref-counting on the bytecode-cache decode path (memory-safety-adjacent in the engine runtime), a human look would still be worthwhile.
What was reviewed:
- Verified
lookUpWellKnownSymbol/lookUpPrivateNamereturn raw +0 pointers and that the oldString str = symbol; str.releaseImpl().unsafeGet()round-trip nets to +0 (temporaryRefPtrdestructs afterunsafeGet), so callers thatadoptRefwere over-releasing; the addedsymbol->ref()restores the +1 contract. - Checked the
m_isRegisteredbranch still returns +1 viasymbolForKey(...).leakRef()now that the String round-trip is dropped. - Confirmed the collapsed
ASSERT(m_isWellKnownSymbol != symbol->isPrivate())is equivalent to the old two-armed assert and holds for the registered-symbol paths as well. RELEASE_ASSERT(symbol)moving inside theelseis fine —symbolForKeyreturns a reference, so the registered branch can't be null.
Extended reasoning...
Overview
This PR touches a single function, the create lambda inside CachedUniquedStringImplBase::decode() in Source/JavaScriptCore/runtime/CachedTypes.cpp. It fixes a reference-counting mismatch: callers of decode() adopt the returned pointer (i.e., expect it at +1), and every branch returned +1 except the well-known-symbol / private-name lookup branch, which returned +0. The fix adds an explicit symbol->ref() on that branch and drops a now-redundant String round-trip on the registered-symbol branch (which already had its +1 from leakRef()).
I traced the old sequence against WTF headers: String str = symbol refs (+1), releaseImpl() returns a by-value RefPtr<StringImpl>, unsafeGet() (RefPtr.h:119) returns the raw pointer without leaking, and the temporary RefPtr then destructs (-1) — net 0. BuiltinNames::lookUpWellKnownSymbol/lookUpPrivateName return raw pointers, so that branch was indeed handing out a +0 pointer to be adopted. The description's claim that stock JSC masks this because those are StaticSymbolImpls (whose deref is a no-op) while Bun's appendExternalName names are heap-allocated is consistent with BuiltinNames.h.
Security risks
This is a memory-safety fix (under-ref → potential UAF of Bun's heap-allocated private-name symbols after enough decoded code blocks are collected). The change itself introduces no new attack surface; it only adds a ref() on one branch and removes a no-op round-trip on another. No input parsing, auth, or crypto involved.
Level of scrutiny
Higher than a typical mechanical change: ref-counting bugs in a JS engine's decoder can turn into use-after-free, and the correctness hinges on the caller contract (adoptRef + Decoder finalizer) described in the PR body rather than being visible in the diff itself. The change is small and I believe correct, but a maintainer who owns the bytecode-cache series should confirm the +1 contract at all three call sites named in the description.
Other factors
- The collapsed
ASSERT(m_isWellKnownSymbol != symbol->isPrivate())is logically identical to the previous two-armed form and, per theShapeencode logic, also holds for both registered-symbol sub-branches. - Moving
RELEASE_ASSERT(symbol)inside the lookupelseis safe becausesymbolForKeyreturns a reference (&...leakRef()cannot yield null). - This is part of an active series of bytecode-cache PRs (#490/#494/#497/#499/#502) from the same area; a quick human sign-off keeps that series consistent.
Preview Builds
|
CachedUniquedStringImplBase::decode()returns a rawUniquedStringImpl*that its callers take ownership of:CachedRefPtr::decode()doesadoptRef(decodedPtr)and registers a Decoder finalizer that derefs it once more,CachedJSValue::decode()doesjsString(vm, adoptRef(*impl)), and the lean-decoder path adopts it directly. So every branch of thecreatelambda has to return the impl at +1, and they do —AtomStringImpl::add(...).leakRef(),symbolForKey(...).leakRef(),createNullSymbol().leakRef()— except the branch that resolves well-known symbols and private names throughBuiltinNames:Each Decoder that materializes such a symbol therefore releases one more reference than it took. In stock JSC those lookups resolve to
StaticSymbolImpls, whose static flag keepsderef()from ever destroying them, so nothing is observable. Bun registers its builtin private names as heap-allocatedPrivateSymbolImpls viaBuiltinNames::appendExternalName()(BunBuiltinNames.cpp), so decoding cached builtin-mode code that references them (the builtin bytecode cache work in #502 / oven-sh/bun#33316) would free them underneathBuiltinNamesonce enough decoded code blocks are collected.The lookup branch now takes the reference explicitly; the registered-symbol branch already had its +1 from
leakRef()and no longer round-trips through aString.Tested: Release build; a script using generators/async functions (JSC private-name identifiers),
for-of(well-known symbol identifiers) and class private fields round-trips throughJSC_diskCachePath+JSC_forceDiskCache=1.