From ca9b13163ff78b8fe86285bc2e509eeb5844bfa0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 19:04:37 -0700 Subject: [PATCH 1/3] fix(node:vm): reject empty cached data across execution modes Extend the cachedData regression from oven-sh/bun#41769 and openclaw/bun#118 with per-tier round trips, invalid input, and Bun 1.4.2 cache rejection. Keep source fallback and cache regeneration working without JIT dependence. --- CHANGELOG.md | 2 + docs/runtime/nodejs-compat.mdx | 2 +- src/jsc/bindings/NodeVMScript.cpp | 8 +- test/js/node/vm/vm.test.ts | 139 ++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c227896f97..b50535837605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Reject explicitly empty `vm.Script` cached data like Node.js and cover cache round-trips and incompatible data in interpreter, baseline, DFG, FTL, and default modes. Extends the crash regression from [oven-sh/bun#41769](https://github.com/oven-sh/bun/pull/41769); thanks @robobun! + - Validate `vm.Script` cached data without compiling an unused executable, avoiding crashes when JSC runs without a JIT. Adapts [oven-sh/bun#41769](https://github.com/oven-sh/bun/pull/41769); thanks @robobun! - Align WebKit and VM source-position assertions with Node's constructor locations and select these regressions for native stack-formatting changes in both fork CI lanes. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 789d14a73bb7..80551bb15142 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -266,7 +266,7 @@ Contextified globals, `DONT_CONTEXTIFY` globals, and the main realm allow a Prox 🟢 Fully implemented, including `vm.Script`, `vm.createContext`, `vm.runInContext`, `vm.runInNewContext`, `vm.runInThisContext`, `vm.compileFunction`, `vm.isContext`, the ES module classes `vm.Module`, `vm.SourceTextModule` and `vm.SyntheticModule` (exported without `--experimental-vm-modules`), and `importModuleDynamically` support. The `timeout`, `breakOnSigint`, `cachedData`, `microtaskMode` and `codeGeneration` options are supported. An `importModuleDynamically` callback that returns a promise for a `vm.Module` resolves `import()` to the module object rather than its namespace. `vm.measureMemory()` reports whole-heap figures for every context. -The OpenClaw fork caches repeated `vm.Script` and `vm.compileFunction` compilations, including the `runInThisContext`, `runInContext`, and `runInNewContext` helpers. Each evaluation still creates fresh values and uses the target context's global object. Source text, filenames, offsets, compilation options, and dynamic import callback identity distinguish entries. Explicit `cachedData` uses the existing validation path. +The OpenClaw fork caches repeated `vm.Script` and `vm.compileFunction` compilations, including the `runInThisContext`, `runInContext`, and `runInNewContext` helpers. Each evaluation still creates fresh values and uses the target context's global object. Source text, filenames, offsets, compilation options, and dynamic import callback identity distinguish entries. Explicit `cachedData` uses the existing validation path. Valid `vm.Script` cached data also works when the JIT is unavailable. Empty, invalid, or incompatible-version data sets `cachedDataRejected` to `true`, and the script falls back to compiling the source. On Linux x64 with glibc, long sources with the same string encoding use an exact byte comparison for cache lookup. Short sources and mixed string encodings keep the engine's comparison path. Every source byte remains part of cache validation. diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 9b9a9f048974..7c4be5246f51 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -182,8 +182,12 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT // Runs link their own block; decoding alone determines whether cachedData is accepted. script->cachedDataRejected(unlinkedBlock ? TriState::False : TriState::True); - } else if (script->options().produceCachedData) - script->cacheBytecode(); + } else { + if (script->options().hasCachedData) + script->cachedDataRejected(TriState::True); + if (script->options().produceCachedData) + script->cacheBytecode(); + } return JSValue::encode(script); } diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index 8b2ff28feb73..64cdaed17160 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -1038,6 +1038,145 @@ test.concurrent("accepts valid bytecode when the JIT is unavailable", async () = expect(exitCode).toBe(0); }); +describe("cachedData across execution modes", () => { + const source = `(() => { + function add(a, b) { return a + b; } + let value; + for (let i = 0; i < 2000; i++) value = add(40, 2); + return value; + })()`; + const filename = "cached-data-modes.js"; + // Bun 1.4.2 (744846f84437), Linux x64: new Script("40+2", { filename: "cached-data-legacy.js", produceCachedData: true }).cachedData. + const legacyData = + "jMs8w7EAAACis+FK////fwAAAAAEAAAAIAAAAAAAAAAEAAAA////fwIAAADYB6EA/////5QAAAAIAAAAAAAAACQAAABMAAAA////f////3////9/AAAAAAAAAAAAAAAABAAAAB0AAAhrXZ0AAAAAAGZpbGU6Ly8vY2FjaGVkLWRhdGEtbGVnYWN5LmpzAAAAFQAACNt5/QABAAAAY2FjaGVkLWRhdGEtbGVnYWN5LmpzhJH6EWn6AAEAAAAABQAA////fyoAAABYAAAAAAAAAAAAAAAEAAAA////f////38AAAAA////fwAAAAD///9/AAAAAP///38AAAAA////fwgXAAYAAhYCDAAAAAIMAAoJCgwCAQAAAG8AAABjDsAKAAAAAAEAAAAyGG1RAAAAAA=="; + const modes: [string, Record][] = [ + ["default", {}], + ["interpreter", { BUN_JSC_useJIT: "false" }], + ["baseline", { BUN_JSC_useDFGJIT: "false", BUN_JSC_useFTLJIT: "false" }], + [ + "DFG", + { BUN_JSC_useFTLJIT: "false", BUN_JSC_useConcurrentJIT: "false", BUN_JSC_thresholdForOptimizeAfterWarmUp: "10" }, + ], + [ + "FTL", + { + BUN_JSC_useConcurrentJIT: "false", + BUN_JSC_thresholdForOptimizeAfterWarmUp: "10", + BUN_JSC_thresholdForFTLOptimizeAfterWarmUp: "20", + }, + ], + ]; + + test.concurrent.each(modes)("round-trips and rejects invalid data in %s mode", async (_, mode) => { + const env = { + ...bunEnv, + BUN_JSC_useJIT: undefined, + BUN_JSC_useDFGJIT: undefined, + BUN_JSC_useFTLJIT: undefined, + BUN_JSC_useConcurrentJIT: undefined, + BUN_JSC_thresholdForOptimizeAfterWarmUp: undefined, + BUN_JSC_thresholdForFTLOptimizeAfterWarmUp: undefined, + ...mode, + }; + const setup = ` + const assert = require("node:assert/strict"); + const { Script, createContext } = require("node:vm"); + const source = ${JSON.stringify(source)}; + const filename = ${JSON.stringify(filename)}; + `; + await using producer = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + setup + + ` + const produced = new Script(source, { filename, produceCachedData: true }); + assert.equal(produced.cachedDataProduced, true); + assert.equal(produced.cachedDataRejected, undefined); + const fresh = new Script(source, { filename }); + const before = fresh.createCachedData(); + assert.equal(fresh.runInThisContext(), 42); + const after = fresh.createCachedData(); + const data = [produced.cachedData, before, after]; + for (const buffer of data) { + assert(Buffer.isBuffer(buffer)); + assert(buffer.length > 0); + } + console.log(JSON.stringify(data.map(buffer => buffer.toString("base64")))); + `, + ], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [data, producerError, producerExit] = await Promise.all([ + producer.stdout.text(), + producer.stderr.text(), + producer.exited, + ]); + expect(producerError).toBe(""); + expect(producerExit).toBe(0); + expect(JSON.parse(data)).toHaveLength(3); + + await using consumer = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + setup + + ` + const data = ${JSON.stringify(JSON.parse(data))}.map(value => Buffer.from(value, "base64")); + for (const cachedData of data) { + const script = new Script(source, { filename, cachedData }); + assert.equal(script.cachedDataRejected, false); + cachedData.fill(0); + globalThis.Bun?.gc(true); + assert.equal(script.runInThisContext(), 42); + assert.equal(script.runInContext(createContext({})), 42); + assert.equal(script.runInNewContext({}), 42); + assert(script.createCachedData().length > 0); + } + const valid = new Script(source, { filename }).createCachedData(); + const invalid = [Buffer.alloc(0), Buffer.from("not bytecode"), valid.subarray(0, 8)]; + for (const [index, cachedData] of invalid.entries()) { + const script = new Script(source + " // invalid " + index, { filename, cachedData }); + assert.equal(script.cachedDataRejected, true); + assert.equal(script.runInThisContext(), 42); + } + const mismatched = new Script("6 * 7", { filename, cachedData: valid }); + assert.equal(mismatched.cachedDataRejected, true); + assert.equal(mismatched.runInThisContext(), 42); + const emptyProduced = new Script("21 * 2", { filename, cachedData: Buffer.alloc(0), produceCachedData: true }); + assert.equal(emptyProduced.cachedDataRejected, true); + assert.equal(emptyProduced.cachedDataProduced, true); + const regenerated = new Script("21 * 2", { filename, cachedData: emptyProduced.cachedData }); + assert.equal(regenerated.cachedDataRejected, false); + assert.equal(regenerated.runInThisContext(), 42); + const legacy = new Script("40+2", { + filename: "cached-data-legacy.js", + cachedData: Buffer.from(${JSON.stringify(legacyData)}, "base64"), + }); + assert.equal(legacy.cachedDataRejected, true); + assert.equal(legacy.runInThisContext(), 42); + console.log("round-trips: 3; rejected: 6; regenerated: 42"); + `, + ], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + consumer.stdout.text(), + consumer.stderr.text(), + consumer.exited, + ]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "round-trips: 3; rejected: 6; regenerated: 42\n", + stderr: "", + exitCode: 0, + }); + }); +}); + test("can't use bytecode from a different script", () => { const firstScript = new Script("1 + 1;"); const cachedData = firstScript.createCachedData(); From d8e3c50534b518f01137797f951cc88f89032006 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 19:10:36 -0700 Subject: [PATCH 2/3] docs: keep cached-data note clear of promotion changes --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50535837605..61cdd5c168d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Reject explicitly empty `vm.Script` cached data like Node.js and cover cache round-trips and incompatible data in interpreter, baseline, DFG, FTL, and default modes. Extends the crash regression from [oven-sh/bun#41769](https://github.com/oven-sh/bun/pull/41769); thanks @robobun! - - Validate `vm.Script` cached data without compiling an unused executable, avoiding crashes when JSC runs without a JIT. Adapts [oven-sh/bun#41769](https://github.com/oven-sh/bun/pull/41769); thanks @robobun! +- Reject explicitly empty `vm.Script` cached data like Node.js and cover cache round-trips and incompatible data in interpreter, baseline, DFG, FTL, and default modes. Extends the crash regression from [oven-sh/bun#41769](https://github.com/oven-sh/bun/pull/41769); thanks @robobun! + - Align WebKit and VM source-position assertions with Node's constructor locations and select these regressions for native stack-formatting changes in both fork CI lanes. - Preserve the native-context count in the fast `v8.getHeapStatistics()` adapter and avoid appending a second strict code-generation flag to inherited worker arguments. From a915fd1ed072d03f009c1049c2958b6df2dcc8b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 19:27:48 -0700 Subject: [PATCH 3/3] test(node:vm): align stale constructor column expectations The earlier source-position fix now reports constructor columns matching Node. Preserve the existing filename, line-offset, column-offset, and caret checks while replacing old JSC-only column expectations. These corrections pass on unchanged fork main as well as the cached-data candidate, and under Node 24. --- test/js/node/test/parallel/test-vm-basic.js | 12 +++++------- test/js/node/test/parallel/test-vm-context.js | 5 ++--- .../node/test/parallel/test-vm-run-in-new-context.js | 4 +--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/test/js/node/test/parallel/test-vm-basic.js b/test/js/node/test/parallel/test-vm-basic.js index be409b937246..cffde3259c89 100644 --- a/test/js/node/test/parallel/test-vm-basic.js +++ b/test/js/node/test/parallel/test-vm-basic.js @@ -277,17 +277,15 @@ const vm = require('vm'); // Setting value to run the last three tests Error.stackTraceLimit = 1; - // Bun's compileFunction stack frames differ from Node's: JSC attributes - // the throw to a different column (and columnOffset is not applied), the - // wrapper costs one line when lineOffset is 0, and the anonymous source is - // labeled differently. + // Bun's compileFunction does not apply columnOffset, its wrapper costs one + // line when lineOffset is 0, and the anonymous source is labeled differently. assert.throws(() => { vm.compileFunction('throw new Error("Sample Error")')(); }, { message: 'Sample Error', stack: typeof Bun === 'undefined' ? 'Error: Sample Error\n at :1:7' - : 'Error: Sample Error\n at (file:///:2:16)' + : 'Error: Sample Error\n at (file:///:2:7)' }); assert.throws(() => { @@ -300,7 +298,7 @@ const vm = require('vm'); message: 'Sample Error', stack: typeof Bun === 'undefined' ? 'Error: Sample Error\n at :4:7' - : 'Error: Sample Error\n at (file:///:4:16)' + : 'Error: Sample Error\n at (file:///:4:7)' }); assert.throws(() => { @@ -313,7 +311,7 @@ const vm = require('vm'); message: 'Sample Error', stack: typeof Bun === 'undefined' ? 'Error: Sample Error\n at :1:10' - : 'Error: Sample Error\n at (file:///:2:16)' + : 'Error: Sample Error\n at (file:///:2:7)' }); assert.strictEqual( diff --git a/test/js/node/test/parallel/test-vm-context.js b/test/js/node/test/parallel/test-vm-context.js index 403eb92989eb..af5d378b3f63 100644 --- a/test/js/node/test/parallel/test-vm-context.js +++ b/test/js/node/test/parallel/test-vm-context.js @@ -112,11 +112,10 @@ assert.strictEqual(script.runInContext(ctx), false); }); }, (err) => { stack = err.stack; - // JSC attributes the throw to a different column than V8, which also - // moves the caret marker. + // Bun's caret marker can contain additional indentation. return typeof Bun === 'undefined' ? /^ \^/m.test(stack) && /expected-filename\.js:33:131/.test(stack) - : /^ *\^/m.test(stack) && /expected-filename\.js:33:140/.test(stack); + : /^ *\^/m.test(stack) && /expected-filename\.js:33:131/.test(stack); }, `stack not formatted as expected: ${stack}`); } diff --git a/test/js/node/test/parallel/test-vm-run-in-new-context.js b/test/js/node/test/parallel/test-vm-run-in-new-context.js index 76684d3e8f6e..c6f8fbf893ca 100644 --- a/test/js/node/test/parallel/test-vm-run-in-new-context.js +++ b/test/js/node/test/parallel/test-vm-run-in-new-context.js @@ -86,9 +86,7 @@ for (const arg of [filename, { filename }]) { assert.strictEqual(lines[1].trim(), code); // Skip lines[2] and lines[3]. They're just a ^ and blank line. assert.strictEqual(lines[4].trim(), 'Error: foo'); - // JSC attributes the throw to a different column than V8. - assert.strictEqual(lines[5].trim(), - typeof Bun === 'undefined' ? `at ${filename}:1:7` : `at ${filename}:1:16`); + assert.strictEqual(lines[5].trim(), `at ${filename}:1:7`); // The rest of the stack is uninteresting. return true; });