Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions src/jsc/bindings/NodeVMScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 5 additions & 7 deletions test/js/node/test/parallel/test-vm-basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <anonymous>:1:7'
: 'Error: Sample Error\n at <anonymous> (file:///:2:16)'
: 'Error: Sample Error\n at <anonymous> (file:///:2:7)'
});

assert.throws(() => {
Expand All @@ -300,7 +298,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at <anonymous>:4:7'
: 'Error: Sample Error\n at <anonymous> (file:///:4:16)'
: 'Error: Sample Error\n at <anonymous> (file:///:4:7)'
});

assert.throws(() => {
Expand All @@ -313,7 +311,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at <anonymous>:1:10'
: 'Error: Sample Error\n at <anonymous> (file:///:2:16)'
: 'Error: Sample Error\n at <anonymous> (file:///:2:7)'
});

assert.strictEqual(
Expand Down
5 changes: 2 additions & 3 deletions test/js/node/test/parallel/test-vm-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
4 changes: 1 addition & 3 deletions test/js/node/test/parallel/test-vm-run-in-new-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
139 changes: 139 additions & 0 deletions test/js/node/vm/vm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>][] = [
["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();
Expand Down
Loading