bun:test: stop leaking JSC scope objects through mock host functions - #33163
bun:test: stop leaking JSC scope objects through mock host functions#33163robobun wants to merge 1 commit into
Conversation
When a function is called through a closure-captured binding, JSC places the resolved JSLexicalEnvironment in the call's implicit `this` slot. Ordinary JS callees convert it with op_to_this, but host functions see the raw value and must run it through JSValue::toThis(), which is what every built-in host function in JSC does. jsMockFunctionCall read the raw value, so the scope object escaped into mock.contexts, the `this` of the mock implementation, and the return value of mockReturnThis(). Reading a property of that scope whose binding is still in its temporal dead zone yields an empty JSValue, which segfaults as soon as anything inspects it. jsMockFunctionGetter_mockGetLastCall and JSMock__jsSetSystemTime had the same unchecked read, so they get the same toThis().
|
Updated 8:28 PM PT - Jun 30th, 2026
❌ @robobun, your commit 6dabce3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33163That installs a local version of the PR into your bun-33163 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughJSMockFunction.cpp now normalizes the callframe's ChangesThis-value normalization in mock/timer bindings
Related PRs: None identified. Suggested labels: bug, javascript-jsc Suggested reviewers: None identified. 🐰 A mock's own "this" was wild and free, 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Comment |
|
Closing this in favor of #32172, which is the earlier PR for the same bug and covers more of the affected call sites (BunPlugin, JSStringDecoder, and the fake-timers path in addition to JSMockFunction). One site in this PR is not in #32172 and is worth carrying over when that branch gets rebased: |
There was a problem hiding this comment.
LGTM — targeted application of the standard toThis(…, ECMAMode::strict()) pattern to stop JSScope objects leaking into JS, with regression tests.
Extended reasoning...
Overview
Three one-line changes in src/jsc/bindings/JSMockFunction.cpp wrap raw callframe->thisValue() reads with .toThis(globalObject, ECMAMode::strict()) in jsMockFunctionCall, jsMockFunctionGetter_mockGetLastCall, and JSMock__jsSetSystemTime. Two regression tests are added to test/js/bun/test/mock-fn.test.js and one to test/js/bun/test/test-timers.test.ts.
Security risks
None. The change strictly reduces what escapes to userland — internal JSLexicalEnvironment objects (which could expose TDZ-marker empty JSValues and segfault on typeof) are now mapped to undefined, which is what the spec and Jest both mandate. toThis in strict mode is an identity for every non-scope value and cannot throw, so no new exception paths are introduced.
Level of scrutiny
Low-to-medium. This is C++ in the JSC bindings, but the fix is a mechanical application of an established idiom already used in this codebase (JSBuffer.cpp, JSDOMOperation.h, JSEventTargetCustom.h) and throughout upstream JSC (ArrayPrototype, ObjectPrototype, ProxyObject::performCall). The PR description traces the crash to FunctionCallResolveNode::emitBytecode and op_to_this, and the author audited every other thisValue() in the file (all go through dynamicDowncast<JSMockFunction>, which already rejects scopes).
Other factors
- No CODEOWNERS cover the touched paths.
- The bug-hunting system found no issues.
- Tests are placed in the existing test files for the affected modules per repo convention, and the author reports they fail-before/pass-after.
- Normal call shapes (
obj.fn(),fn.call(x),jest.setSystemTime()chaining) are unaffected becausetoThisin strict mode is the identity on ordinary objects. - The follow-up for
BunPlugin.cppis explicitly called out and reasonably scoped out of this PR.
Fixes a deterministic segfault found by fuzzing (fingerprint
f3ce0925415c7f37).What happens
When JavaScript calls a function through a binding that lives in a lexical environment (a
const/letthat an inner function also references), JSC's bytecode generator puts the resolvedJSLexicalEnvironmentinto the call's implicitthisslot (FunctionCallResolveNode::emitBytecode). Ordinary JS callees run it throughop_to_this, which maps aJSScopetoundefined. Host functions see the raw value, which is why JSC's own built-ins all start withcallFrame->thisValue().toThis(globalObject, ECMAMode::strict()).jsMockFunctionCalldid not, so forthe scope object escapes to JavaScript in three places:
fn.mock.contexts, thethispassed to the user implementation, and the return value ofmockReturnThis(). The scope's "own properties" are the enclosing function's captured locals, and the slot of aconstthat has not been initialized yet holds JSC's TDZ marker, the emptyJSValue.typeofon that reads the JSType byte of a cell at address 0:Fix
Run the raw
thisValuethroughJSValue::toThis(globalObject, ECMAMode::strict()), the same thingArrayPrototype,ObjectPrototype, andProxyObject::performCalldo in JSC. It is an identity for every value except aJSScope, which becomesundefined, so the normalfn(),obj.fn(), andfn.call(x)paths are unchanged.undefinedis also what the spec mandates here (a declarative environment record'sWithBaseObject()isundefined) and what Jest reports, since itsmockConstructoris a strict JS function.Two other host functions in the same file read
callframe->thisValue()without a type check and get the same treatment:JSMock__jsSetSystemTimereturns the raw value, so a destructuredsetSystemTime()called through a captured binding returned the scope.jsMockFunctionGetter_mockGetLastCallonly checksisObject(), which a scope passes, then does aget()on it. A capturedconst callsstill in its TDZ would hand an emptyJSValuetojsDynamicCastand null-deref.Every other
thisValue()in the file already goes throughdynamicDowncast<JSMockFunction>, which rejects a scope.The same
return JSValue::encode(callFrame->thisValue())pattern exists inBunPlugin.cpp's three builder methods (onLoad,onResolve,module). That is a different module with its own tests, so it is left for a follow-up.Tests
Added regression tests to
mock-fn.test.js(fail before withReceived: [native code: JSLexicalEnvironment], pass after) andtest-timers.test.ts. The original fuzzer script no longer crashes.