From a8533efb0b902f7e834c842bcf3eba1840123e85 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:26:35 +0000 Subject: [PATCH 1/2] Bun.plugin: transpile a copy of typed array onLoad contents 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. --- src/jsc/bindings/ModuleLoader.cpp | 11 ++- src/jsc/bindings/ModuleLoader.h | 2 + .../plugin/plugin-shared-contents-fixture.ts | 81 +++++++++++++++++++ test/js/bun/plugin/plugins.test.ts | 78 ++++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/plugin/plugin-shared-contents-fixture.ts diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 32d233a8bb5a..a3fa8680405c 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -305,7 +305,16 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: result.value.sourceText.value = contentsValue; } } else if (JSC::JSArrayBufferView* view = dynamicDowncast(contentsValue)) { - result.value.sourceText.string = EncodedSlice { reinterpret_cast(view->vector()), view->byteLength() }; + // The lexer, the AST and the printer read the source until the transpile ends. + // Another thread can write a SharedArrayBuffer in that time, and a macro runs + // JS that can write, resize or detach any buffer. So they read a copy. + if (!result.sourceTextCopy.tryAppend(view->span())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + result.value.error = scope.exception(); + (void)scope.tryClearException(); + return result; + } + result.value.sourceText.string = EncodedSlice { result.sourceTextCopy.span().data(), result.sourceTextCopy.size() }; result.value.sourceText.value = contentsValue; } } diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index dc31f18625a9..ca14d53a942e 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -46,6 +46,8 @@ struct OnLoadResult { OnLoadResultValue value; OnLoadResultType type; bool wasMock; + // The bytes `value.sourceText.string` points to when `contents` was a typed array. + WTF::Vector sourceTextCopy; }; extern "C" bool isBunTest; diff --git a/test/js/bun/plugin/plugin-shared-contents-fixture.ts b/test/js/bun/plugin/plugin-shared-contents-fixture.ts new file mode 100644 index 000000000000..1e785bdb514e --- /dev/null +++ b/test/js/bun/plugin/plugin-shared-contents-fixture.ts @@ -0,0 +1,81 @@ +// Fixture for plugins.test.ts. +// +// bun plugin-shared-contents-fixture.ts +// +// An onLoad callback returns a Uint8Array over a SharedArrayBuffer as +// `contents` while a worker flips the `_` separators of the numeric literals in +// it to digits and back. The lexer counts the separators of a literal, +// allocates `length - count` bytes, then copies every byte that is not a `_`. +// A transpiler that reads the shared bytes in place aborts the process when a +// separator turns into a digit between the two passes. One that reads a private +// copy sees one value per byte, and every value of a byte gives a valid module. +// Prints "ok" after imports that ran while the worker wrote. +import { plugin } from "bun"; +import { isMainThread, Worker, workerData } from "node:worker_threads"; + +const literals = 64; +const literal = " 1_000_000_000_000_000,\n"; +const base = new TextEncoder().encode( + "export default [\n" + Buffer.alloc(literals * literal.length, literal).toString() + "];\n", +); + +if (isMainThread) { + const bytes = new SharedArrayBuffer(base.length); + // The number of passes the worker has made over the bytes it flips. + const passes = new Int32Array(new SharedArrayBuffer(4)); + const worker = new Worker(new URL(import.meta.url), { workerData: { bytes, passes } }); + worker.unref(); + + const contents = new Uint8Array(bytes); + contents.set(base); + + if (Atomics.wait(passes, 0, 0, 30_000) === "timed-out") { + throw new Error("the worker did not start"); + } + + plugin({ + name: "shared contents", + setup(build) { + build.onResolve({ filter: /.*/, namespace: "shared" }, ({ path }) => ({ path, namespace: "shared" })); + build.onLoad({ filter: /.*/, namespace: "shared" }, () => ({ contents, loader: "ts" })); + }, + }); + + // Count an import only when the worker made a pass while it ran. A debug + // build is slow enough that every import counts. A release build can finish + // many imports before the worker's thread gets a core of its own, so the + // total is capped: a starved worker ends the run, it does not hang it. + const imports = Number(process.argv[2]); + let seen = Atomics.load(passes, 0); + for (let overlapped = 0, total = 0; overlapped < imports && total < imports * 20; total++) { + const { default: values } = await import("shared:" + total); + if (values.length !== literals || !values.every(value => value >= 1e15)) { + throw new Error("import " + total + " gave " + JSON.stringify(values)); + } + + const now = Atomics.load(passes, 0); + if (now !== seen) { + seen = now; + overlapped++; + } + } + + console.log("ok"); + process.exit(0); +} else { + const { bytes, passes } = workerData as { bytes: SharedArrayBuffer; passes: Int32Array }; + const contents = new Uint8Array(bytes); + + const separators: number[] = []; + for (let i = 0; i < base.length; i++) { + if (base[i] === 0x5f) separators.push(i); + } + + // `Atomics.store` so that the compiler keeps every store and the other + // thread sees each one. + for (;;) { + for (const at of separators) Atomics.store(contents, at, 0x30); + for (const at of separators) Atomics.store(contents, at, 0x5f); + if (Atomics.add(passes, 0, 1) === 0) Atomics.notify(passes, 0); + } +} diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 80283bc38fec..9f3d4b53177a 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1046,6 +1046,84 @@ describe.concurrent("Bun.plugin.clearAll()", () => { }); }); +// The transpiler reads the source text until it has printed the module. The +// bytes of a typed array can change in that time, so it reads a copy of them. +describe.concurrent("onLoad contents in a typed array", () => { + // A worker flips the `_` separators of the numeric literals to digits and + // back. The lexer counts the separators of a literal, sizes a buffer for the + // digits, then reads the literal again to fill it. When it read the shared + // bytes in place, the two reads did not agree: within 10 imports a literal had + // the wrong value or the process aborted. + it("transpiles a SharedArrayBuffer that a worker writes", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), resolve(import.meta.dir, "plugin-shared-contents-fixture.ts"), "100"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode }).toEqual({ + stdout: "ok", + stderr: "", + exitCode: 0, + }); + }); + + // A macro runs JS in the middle of the transpile. The printer reads the names + // and the strings of the module back from the source after that. + it("transpiles the bytes onLoad returned when a macro overwrites and detaches them", async () => { + using dir = tempDir("plugin-contents-macro", { + "macro.ts": ` + export function clobber() { + globalThis.contents.fill(0x20); + globalThis.contents.buffer.transfer(); + return "clobbered"; + } + `, + "index.ts": ` + import { plugin } from "bun"; + import { join } from "node:path"; + + const source = + "import { clobber } from " + JSON.stringify(join(import.meta.dir, "macro.ts")) + ' with { type: "macro" };\\n' + + "export const fromTheMacro = clobber();\\n" + + "export const afterTheMacro = 'after the macro';\\n"; + const bytes = new TextEncoder().encode(source); + globalThis.contents = new Uint8Array(new ArrayBuffer(bytes.length)); + globalThis.contents.set(bytes); + + plugin({ + name: "typed array contents", + setup(build) { + build.onResolve({ filter: /.*/, namespace: "bytes" }, ({ path }) => ({ path, namespace: "bytes" })); + build.onLoad({ filter: /.*/, namespace: "bytes" }, () => ({ contents: globalThis.contents, loader: "ts" })); + }, + }); + + const { fromTheMacro, afterTheMacro } = await import("bytes:module"); + console.log(JSON.stringify({ fromTheMacro, afterTheMacro, byteLength: globalThis.contents.byteLength })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // A debug build logs each macro call to stdout first. + expect({ result: stdout.trim().split("\n").at(-1), stderr: stderr.trim(), exitCode }).toEqual({ + result: JSON.stringify({ fromTheMacro: "clobbered", afterTheMacro: "after the macro", byteLength: 0 }), + stderr: "", + exitCode: 0, + }); + }); +}); + it("object loader: an error thrown by a getter on the exports object rejects the require()", () => { const boom = new Error("boom"); plugin({ From ccecc4720c180a9a6d1c89ca471aec1251a28587 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:52:33 +0000 Subject: [PATCH 2/2] Cover async onLoad and build.module, check exact literal values 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. --- src/jsc/bindings/ModuleLoader.cpp | 5 ++- src/jsc/bindings/ModuleLoader.h | 1 + .../plugin/plugin-shared-contents-fixture.ts | 34 +++++++++------- test/js/bun/plugin/plugins.test.ts | 39 +++++++++++-------- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index a3fa8680405c..4b25d79259a9 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -306,8 +306,9 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: } } else if (JSC::JSArrayBufferView* view = dynamicDowncast(contentsValue)) { // The lexer, the AST and the printer read the source until the transpile ends. - // Another thread can write a SharedArrayBuffer in that time, and a macro runs - // JS that can write, resize or detach any buffer. So they read a copy. + // Another thread can write a SharedArrayBuffer in that time. A macro runs JS in + // that time, and JS can overwrite, detach or move the storage of an unshared, + // fixed-length view too. So every view is copied, not only a shared or resizable one. if (!result.sourceTextCopy.tryAppend(view->span())) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); result.value.error = scope.exception(); diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index ca14d53a942e..bd51c0c150b5 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -47,6 +47,7 @@ struct OnLoadResult { OnLoadResultType type; bool wasMock; // The bytes `value.sourceText.string` points to when `contents` was a typed array. + // No inline capacity: the struct is returned by value, and the pointer has to survive the move. WTF::Vector sourceTextCopy; }; diff --git a/test/js/bun/plugin/plugin-shared-contents-fixture.ts b/test/js/bun/plugin/plugin-shared-contents-fixture.ts index 1e785bdb514e..3308c92d2941 100644 --- a/test/js/bun/plugin/plugin-shared-contents-fixture.ts +++ b/test/js/bun/plugin/plugin-shared-contents-fixture.ts @@ -1,23 +1,30 @@ // Fixture for plugins.test.ts. // -// bun plugin-shared-contents-fixture.ts -// // An onLoad callback returns a Uint8Array over a SharedArrayBuffer as // `contents` while a worker flips the `_` separators of the numeric literals in -// it to digits and back. The lexer counts the separators of a literal, -// allocates `length - count` bytes, then copies every byte that is not a `_`. -// A transpiler that reads the shared bytes in place aborts the process when a -// separator turns into a digit between the two passes. One that reads a private +// it to `0` and back. The lexer counts the separators of a literal, allocates +// `length - count` bytes, then copies every byte that is not a `_`. A +// transpiler that reads the shared bytes in place aborts the process when a +// separator turns into a digit between the two passes, and gives the literal a +// wrong value when a digit turns into a separator. One that reads a private // copy sees one value per byte, and every value of a byte gives a valid module. -// Prints "ok" after imports that ran while the worker wrote. +// Prints "ok" after `imports` imports that ran while the worker wrote. import { plugin } from "bun"; import { isMainThread, Worker, workerData } from "node:worker_threads"; +const underscore = 0x5f; +const zero = 0x30; + const literals = 64; const literal = " 1_000_000_000_000_000,\n"; const base = new TextEncoder().encode( "export default [\n" + Buffer.alloc(literals * literal.length, literal).toString() + "];\n", ); +// Each separator that reads as `0` makes the literal ten times larger. +const values = new Set([1e15, 1e16, 1e17, 1e18, 1e19, 1e20]); + +// An unfixed build fails within 10 imports. +const imports = 50; if (isMainThread) { const bytes = new SharedArrayBuffer(base.length); @@ -45,12 +52,11 @@ if (isMainThread) { // build is slow enough that every import counts. A release build can finish // many imports before the worker's thread gets a core of its own, so the // total is capped: a starved worker ends the run, it does not hang it. - const imports = Number(process.argv[2]); let seen = Atomics.load(passes, 0); for (let overlapped = 0, total = 0; overlapped < imports && total < imports * 20; total++) { - const { default: values } = await import("shared:" + total); - if (values.length !== literals || !values.every(value => value >= 1e15)) { - throw new Error("import " + total + " gave " + JSON.stringify(values)); + const { default: exported } = await import("shared:" + total); + if (exported.length !== literals || !exported.every(value => values.has(value))) { + throw new Error("import " + total + " gave " + JSON.stringify(exported)); } const now = Atomics.load(passes, 0); @@ -68,14 +74,14 @@ if (isMainThread) { const separators: number[] = []; for (let i = 0; i < base.length; i++) { - if (base[i] === 0x5f) separators.push(i); + if (base[i] === underscore) separators.push(i); } // `Atomics.store` so that the compiler keeps every store and the other // thread sees each one. for (;;) { - for (const at of separators) Atomics.store(contents, at, 0x30); - for (const at of separators) Atomics.store(contents, at, 0x5f); + for (const at of separators) Atomics.store(contents, at, zero); + for (const at of separators) Atomics.store(contents, at, underscore); if (Atomics.add(passes, 0, 1) === 0) Atomics.notify(passes, 0); } } diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 9f3d4b53177a..055b457d3842 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1049,14 +1049,9 @@ describe.concurrent("Bun.plugin.clearAll()", () => { // The transpiler reads the source text until it has printed the module. The // bytes of a typed array can change in that time, so it reads a copy of them. describe.concurrent("onLoad contents in a typed array", () => { - // A worker flips the `_` separators of the numeric literals to digits and - // back. The lexer counts the separators of a literal, sizes a buffer for the - // digits, then reads the literal again to fill it. When it read the shared - // bytes in place, the two reads did not agree: within 10 imports a literal had - // the wrong value or the process aborted. it("transpiles a SharedArrayBuffer that a worker writes", async () => { await using proc = Bun.spawn({ - cmd: [bunExe(), resolve(import.meta.dir, "plugin-shared-contents-fixture.ts"), "100"], + cmd: [bunExe(), resolve(import.meta.dir, "plugin-shared-contents-fixture.ts")], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -1072,7 +1067,7 @@ describe.concurrent("onLoad contents in a typed array", () => { // A macro runs JS in the middle of the transpile. The printer reads the names // and the strings of the module back from the source after that. - it("transpiles the bytes onLoad returned when a macro overwrites and detaches them", async () => { + it("transpiles the bytes it was given when a macro overwrites and detaches them", async () => { using dir = tempDir("plugin-contents-macro", { "macro.ts": ` export function clobber() { @@ -1085,24 +1080,35 @@ describe.concurrent("onLoad contents in a typed array", () => { import { plugin } from "bun"; import { join } from "node:path"; - const source = + const source = new TextEncoder().encode( "import { clobber } from " + JSON.stringify(join(import.meta.dir, "macro.ts")) + ' with { type: "macro" };\\n' + "export const fromTheMacro = clobber();\\n" + - "export const afterTheMacro = 'after the macro';\\n"; - const bytes = new TextEncoder().encode(source); - globalThis.contents = new Uint8Array(new ArrayBuffer(bytes.length)); - globalThis.contents.set(bytes); + "export const afterTheMacro = 'after the macro';\\n", + ); + + // A view on an ArrayBuffer of its own, so that transfer() detaches exactly these bytes. + function contents() { + globalThis.contents = new Uint8Array(new ArrayBuffer(source.length)); + globalThis.contents.set(source); + return globalThis.contents; + } plugin({ name: "typed array contents", setup(build) { build.onResolve({ filter: /.*/, namespace: "bytes" }, ({ path }) => ({ path, namespace: "bytes" })); - build.onLoad({ filter: /.*/, namespace: "bytes" }, () => ({ contents: globalThis.contents, loader: "ts" })); + build.onLoad({ filter: /plain/, namespace: "bytes" }, () => ({ contents: contents(), loader: "ts" })); + build.onLoad({ filter: /promise/, namespace: "bytes" }, async () => ({ contents: contents(), loader: "ts" })); + build.module("bytes-module", () => ({ contents: contents(), loader: "ts" })); }, }); - const { fromTheMacro, afterTheMacro } = await import("bytes:module"); - console.log(JSON.stringify({ fromTheMacro, afterTheMacro, byteLength: globalThis.contents.byteLength })); + const results = {}; + for (const specifier of ["bytes:plain", "bytes:promise", "bytes-module"]) { + const { fromTheMacro, afterTheMacro } = await import(specifier); + results[specifier] = { fromTheMacro, afterTheMacro, byteLength: globalThis.contents.byteLength }; + } + console.log(JSON.stringify(results)); `, }); @@ -1115,9 +1121,10 @@ describe.concurrent("onLoad contents in a typed array", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const transpiled = { fromTheMacro: "clobbered", afterTheMacro: "after the macro", byteLength: 0 }; // A debug build logs each macro call to stdout first. expect({ result: stdout.trim().split("\n").at(-1), stderr: stderr.trim(), exitCode }).toEqual({ - result: JSON.stringify({ fromTheMacro: "clobbered", afterTheMacro: "after the macro", byteLength: 0 }), + result: JSON.stringify({ "bytes:plain": transpiled, "bytes:promise": transpiled, "bytes-module": transpiled }), stderr: "", exitCode: 0, });