Repository navigation
Conversation
handleOnLoadResultNotPromise handed the transpiler a pointer into the typed array that a runtime plugin's onLoad or build.module callback returned as `contents`. The lexer, the AST and the printer read those bytes until the transpile ends, and the bytes can change in that time. A worker that writes a SharedArrayBuffer changes them between two reads of the lexer. The numeric literal path counts the `_` separators, allocates `length - count` bytes, then copies every byte that is not a `_`. When a separator turns into a digit in between, the copy runs past the allocation: "panic: index out of bounds: the len is 16 but the index is 16". The other direction gives a literal with the wrong value. A macro runs JS in the middle of the transpile. That JS can write, resize or detach an unshared buffer, and the printer reads identifier names and string literals back from the source after the macro returns. A macro that calls `contents.buffer.transfer()` leaves the printer with a pointer it does not own. Copy the bytes into storage that the OnLoadResult owns and point the source text at the copy. The copy lives until handleVirtualModuleResult returns, which is after the transpile. An allocation failure rejects the import with an OutOfMemoryError.
The macro test now loads the module through a sync onLoad, an async onLoad and build.module. The fixture accepts only the six values that a consistent copy of the literal can have, so a literal that the lexer read in place as 570000000000000000000 fails the check. The import count is fixed at 50 in the fixture. The comments say why the copy does not depend on a shared or resizable buffer, and why the vector has no inline capacity.
|
Warning Review limit reached
On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file. Or wait 11 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (4)
Comment |
|
Status Reproduced on canary 1.4.3 (4ff9193) with the fixture from this PR. CI (build 114496, finished): 180 of 181 jobs passed. The one red test is PR: #42405 |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes memory ownership in the JSC module-loader bindings, a maintainer look at the OnLoadResult lifetime reasoning would still be worthwhile.
What was reviewed:
- OOM path:
result.typeis stillOnLoadResultTypeErrorat the early return, so the caller's switch takes thereject(onLoadResult.value.error)arm; the throw/stash/tryClearExceptionsequence matches the neighboring error paths. - Lifetime:
onLoadResultis a stack local inhandleVirtualModuleResultthat outlivesBun__transpileVirtualModule;WTF::Vector<uint8_t>has no inline capacity so its heap buffer pointer survives the two return-by-value moves that the stashedEncodedSlicedepends on. - Tests follow harness conventions (concurrent drain, combined-object assert,
tempDir/bunEnv,Atomics.waitdeadline + bounded overlap loop,Buffer.allocover.repeat) and cover sync/asynconLoadplusbuild.module.
Extended reasoning...
Overview
The PR fixes a TOCTOU/memory-safety bug in src/jsc/bindings/ModuleLoader.cpp: when a Bun.plugin onLoad/build.module callback returns typed-array contents, the transpiler previously read the live view->vector() bytes for the duration of the transpile, so a worker writing a SharedArrayBuffer or a macro that mutates/detaches the buffer mid-transpile could abort the process or produce garbage output. The fix copies view->span() into a new WTF::Vector<uint8_t> sourceTextCopy on OnLoadResult and points the EncodedSlice at that owned copy, throwing OutOfMemoryError on tryAppend failure. The header gains the field with a comment stating the no-inline-capacity invariant. Two tests are added to test/js/bun/plugin/plugins.test.ts (a SharedArrayBuffer race fixture and a deterministic macro-overwrite/detach test covering all three entry paths).
Security risks
None introduced. The change strictly narrows the attack surface by replacing a borrowed pointer into user-controlled memory with an owned copy, which is the shape REVIEW.md prescribes ("network/parser callback buffers stored without cloning" is a rejected pattern; "read mutable typed-array vectors once after all observable side effects or copy them"). The OOM branch fails closed via a rejected import. No auth/crypto/permissions code is touched.
Level of scrutiny
Moderate-to-high. The C++ change is only ~10 lines and mirrors neighboring error-path shape exactly, but it lives in JSC bindings and its correctness rests on two lifetime facts I verified: (1) result.type is initialized to OnLoadResultTypeError at function entry and is not changed before the OOM early return, so handleVirtualModuleResult's switch takes the reject arm; (2) the onLoadResult local in handleVirtualModuleResult lives on the stack through the Bun__transpileVirtualModule call, and WTF::Vector<uint8_t>'s default (zero) inline capacity means its move constructor transfers the heap buffer pointer rather than relocating bytes, so the EncodedSlice stashed before the return-by-value chain stays valid. Both hold, and the header comment records the second one. Still, native memory-ownership changes in the module loader are exactly the kind of thing a maintainer familiar with this file should sign off on.
Other factors
Test quality is high and matches repo conventions: added to the existing plugins.test.ts under describe.concurrent, subprocess pipes drained via Promise.all, assertions on a combined { stdout, stderr, exitCode } object, tempDir/bunExe/bunEnv from harness, Buffer.alloc(n, fill).toString() instead of .repeat, no sleep-for-condition (worker readiness via Atomics.wait with a 30s deadline; overlap counted via Atomics.load with a hard total-iteration cap so a starved worker cannot hang the run). The deterministic macro test covers the same code line without any timing dependency and exercises sync onLoad, async onLoad, and build.module, satisfying the "fix the whole class" and "cover the variant matrix" rules. No CODEOWNERS entries cover the changed paths, and there are no outstanding third-party review objections in the timeline.
|
Updated 6:33 PM PT - Sep 11th, 2026
❌ @robobun, your commit ccecc47 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 42405That installs a local version of the PR into your bun-42405 --bun |
Problem
Bun.plugin: whenonLoadorbuild.modulereturnscontentsas a typed array, the transpiler gets a pointer into that array (src/jsc/bindings/ModuleLoader.cpp:308). It reads them until the transpile ends.SharedArrayBufferbehindcontentsaborts the process:panic: index out of bounds: the len is 16 but the index is 16. The lexer counts the_separators of a numeric literal, allocateslength - countbytes, then reads the literal again (src/js_parser/lexer.rs:3280). Canary 1.4.3 fails 18 of 18 runs.contentsbuffer, the printer emits garbage:SyntaxError: Unexpected token '='.Fix
handleOnLoadResultNotPromisecopies the bytes into aWTF::Vectorthat theOnLoadResultowns. The copy lives untilhandleVirtualModuleResultreturns, after the transpile. An allocation failure rejects the import with anOutOfMemoryError.test/js/bun/plugin/plugins.test.ts. Both fail on the unfixed debug build and on canary. Alsotest/js/bun/plugin/andmock-module.test.ts.Background
OnLoadResultcarries the parsed{ contents, loader }result toBun__transpileVirtualModule. It runs the parser and the printer oncontentsin place of a file.Bun.buildplugins already copycontents(JSBundlerPlugin__onLoadAsync).Notes
Repro for the abort (the fixture in this PR is a tighter version of it). Run
bun plugin-sab.ts:What the unfixed builds do with the new tests.
1,570000000000000000000). In those a digit turned into a_between the two reads, or the lexer parsed a_as a digit. All 18 fail within 10 imports.bun bd testreports this one as a timeout.onLoad, asynconLoadandbuild.module:SyntaxError: Unexpected token '='. Expected a parameter pattern or a ')' in parameter list.The printer wrote spaces where the names were.Why the copy does not depend on
isShared()orisResizableOrGrowableShared(). Those checks are correct for a native call that does not run JS. This call runs JS: the visit pass calls macros (src/js_parser/visit/visit_expr.rs,src/js_parser_jsc/Macro.rs) on the same thread and the same global object, and the printer runs after them. JS can do these things to an unshared, fixed-length view:fill)transfer(),structuredClonewith a transfer list,postMessagewith a transfer list).bufferon a small typed array moves the bytes out of the GC cell, and the old block goes away at the next collectionThe macro test in this PR uses a plain
new ArrayBuffer(n). It fails with a shared-or-resizable check.Why the test counts overlapped imports. A release build finishes an import in about 50 µs. On a busy machine the worker's thread may not run during many of them. The fixture counts an import only when the worker completed a pass while it ran, and caps the total so that a starved worker cannot hang the run.
Cost. One
mallocand onememcpyof the module source for each plugin-loaded module whosecontentsis a typed array. The lexer, the visitor and the printer then walk the same bytes, and the printed output is copied once more. Stringcontentstake the same path as before.Behavior that does not change. I compared the fixed debug build with canary on these
contentsvalues, through synconLoad, asynconLoad,build.moduleandrequire(): empty view, detached view,Buffer.subarray,DataView,Uint16Array, a view on aSharedArrayBuffer, a view on a resizable buffer, an out-of-bounds length-tracking view, a 4 MB module, a string. The results are the same. The fixture and the macro probe also pass withBUN_JSC_validateExceptionChecks=1.Self-review, the 2 concerns not taken.
OutOfMemoryError(theWTF::Vectorlimit). Before, it failed with "File is too large to parse (2 GiB maximum)". Neither build can load such a module.Related work.
Bun.Transpiler,Bun.markdown,Bun.YAML.parseandBun.TOML.parse. It names thisonLoadcase as a follow-up.transformSyncalso runs macros, so a fixed-length buffer has the same macro hazard there.Bun::stableBytes(copy when shared) for the UTF-8 decoders. It takes the sameVector<uint8_t>&storage, so this site can call it later withshared = true.ArrayBufferorSharedArrayBufferis still rejected. plugin: accept ArrayBuffer/SharedArrayBuffer for onLoad contents #33798 accepts them. It adds a second branch next to this one that borrowsimpl()->data(). That branch needs the same copy.Zig::utf8Slice(view->span())), also as a borrow.