diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 32d233a8bb5a..4b25d79259a9 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -305,7 +305,17 @@ 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. 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(); + (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..bd51c0c150b5 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -46,6 +46,9 @@ struct OnLoadResult { OnLoadResultValue value; 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; }; 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..3308c92d2941 --- /dev/null +++ b/test/js/bun/plugin/plugin-shared-contents-fixture.ts @@ -0,0 +1,87 @@ +// Fixture for plugins.test.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 `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` 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); + // 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. + let seen = Atomics.load(passes, 0); + for (let overlapped = 0, total = 0; overlapped < imports && total < imports * 20; total++) { + 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); + 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] === 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, 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 80283bc38fec..055b457d3842 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1046,6 +1046,91 @@ 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", () => { + 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")], + 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 it was given 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 = 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", + ); + + // 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: /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 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)); + `, + }); + + 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]); + + 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({ "bytes:plain": transpiled, "bytes:promise": transpiled, "bytes-module": transpiled }), + 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({