Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
dc3ba54f26 fix(transpiler): don't inline const values into function bodies
Bun's const-inlining optimization was replacing variable references
with literal values inside function bodies, changing the observable
result of Function.prototype.toString(). This broke patterns that
rely on toString() + eval() (serialization for workers, RPC, etc.).

Only applies to the runtime transpiler (non-bundler mode) to preserve
toString() fidelity. The bundler path is unchanged since users
explicitly opt into minification there.

Closes #12710

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:59:23 +00:00
8 changed files with 187 additions and 122 deletions

View File

@@ -57,6 +57,14 @@ pub fn Visit(
p.fn_or_arrow_data_visit = FnOrArrowDataVisit{ .is_async = func.flags.contains(.is_async) };
p.fn_only_data_visit = FnOnlyDataVisit{ .is_this_nested = true, .arguments_ref = func.arguments_ref };
// When not bundling, don't carry over const values from the outer scope into
// function bodies. Inlining them would change the observable result of
// Function.prototype.toString(). See https://github.com/oven-sh/bun/issues/12710
const old_const_values = p.const_values;
if (!p.options.bundle) {
p.const_values = .{};
}
if (func.name) |name| {
if (name.ref) |name_ref| {
p.recordDeclaredSymbol(name_ref) catch unreachable;
@@ -100,6 +108,7 @@ pub fn Visit(
p.fn_or_arrow_data_visit = old_fn_or_arrow_data;
p.fn_only_data_visit = old_fn_only_data;
p.const_values = old_const_values;
return func;
}

View File

@@ -1568,6 +1568,14 @@ pub fn VisitExpr(
.is_async = e_.is_async,
};
// When not bundling, don't carry over const values from the outer scope into
// arrow function bodies. Inlining them would change the observable result of
// Function.prototype.toString(). See https://github.com/oven-sh/bun/issues/12710
const old_const_values = p.const_values;
if (!p.options.bundle) {
p.const_values = .{};
}
// Mark if we're inside an async arrow function. This value should be true
// even if we're inside multiple arrow functions and the closest inclosing
// arrow function isn't async, as long as at least one enclosing arrow
@@ -1600,6 +1608,7 @@ pub fn VisitExpr(
p.fn_only_data_visit.is_inside_async_arrow_fn = old_inside_async_arrow_fn;
p.fn_or_arrow_data_visit = std.mem.bytesToValue(@TypeOf(p.fn_or_arrow_data_visit), &old_fn_or_arrow_data);
p.const_values = old_const_values;
if (react_hook_data) |*hook| try_mark_hook: {
const stmts = p.nearest_stmt_list orelse break :try_mark_hook;

View File

@@ -22,7 +22,6 @@
#include "BunClientData.h"
#include "CallSite.h"
#include "ErrorStackTrace.h"
#include "JSDOMException.h"
#include "headers-handwritten.h"
using namespace JSC;
@@ -380,9 +379,6 @@ static String computeErrorInfoWithoutPrepareStackTrace(
RETURN_IF_EXCEPTION(scope, {});
message = instance->sanitizedMessageString(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
} else if (auto* domException = jsDynamicCast<WebCore::JSDOMException*>(errorInstance)) {
name = domException->wrapped().name();
message = domException->wrapped().message();
}
}

View File

@@ -46,10 +46,6 @@
#include <wtf/PointerPreparations.h>
#include <wtf/URL.h>
#include "FormatStackTraceForJS.h"
#include "ZigGlobalObject.h"
#include <JavaScriptCore/Interpreter.h>
namespace WebCore {
using namespace JSC;
@@ -124,34 +120,6 @@ static const HashTableValue JSDOMExceptionConstructorTableValues[] = {
{ "DATA_CLONE_ERR"_s, JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::ConstantInteger, NoIntrinsic, { HashTableValue::ConstantType, 25 } },
};
static void captureStackTraceForDOMException(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSObject* errorObject)
{
if (!vm.topCallFrame)
return;
auto* zigGlobalObject = jsDynamicCast<Zig::GlobalObject*>(lexicalGlobalObject);
if (!zigGlobalObject)
zigGlobalObject = ::defaultGlobalObject(lexicalGlobalObject);
size_t stackTraceLimit = zigGlobalObject->stackTraceLimit().value();
if (stackTraceLimit == 0)
stackTraceLimit = Bun::DEFAULT_ERROR_STACK_TRACE_LIMIT;
WTF::Vector<JSC::StackFrame> stackTrace;
vm.interpreter.getStackTrace(errorObject, stackTrace, 0, stackTraceLimit);
if (stackTrace.isEmpty())
return;
unsigned int line = 0;
unsigned int column = 0;
String sourceURL;
JSValue result = Bun::computeErrorInfoWrapperToJSValue(vm, stackTrace, line, column, sourceURL, errorObject, nullptr);
if (result)
errorObject->putDirect(vm, vm.propertyNames->stack, result, 0);
}
template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDOMExceptionDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
@@ -394,12 +362,26 @@ void JSDOMExceptionOwner::finalize(JSC::Handle<JSC::Unknown> handle, void* conte
// #endif
// #endif
JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Ref<DOMException>&& impl)
JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref<DOMException>&& impl)
{
auto* wrapper = createWrapper<DOMException>(globalObject, WTF::move(impl));
auto& vm = globalObject->vm();
captureStackTraceForDOMException(vm, lexicalGlobalObject ? lexicalGlobalObject : globalObject, wrapper);
return wrapper;
// if constexpr (std::is_polymorphic_v<DOMException>) {
// #if ENABLE(BINDING_INTEGRITY)
// // const void* actualVTablePointer = getVTablePointer(impl.ptr());
// #if PLATFORM(WIN)
// void* expectedVTablePointer = __identifier("??_7DOMException@WebCore@@6B@");
// #else
// // void* expectedVTablePointer = &_ZTVN7WebCore12DOMExceptionE[2];
// #endif
// // If you hit this assertion you either have a use after free bug, or
// // DOMException has subclasses. If DOMException has subclasses that get passed
// // to toJS() we currently require DOMException you to opt out of binding hardening
// // by adding the SkipVTableValidation attribute to the interface IDL definition
// // RELEASE_ASSERT(actualVTablePointer == expectedVTablePointer);
// #endif
// }
return createWrapper<DOMException>(globalObject, WTF::move(impl));
}
JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, DOMException& impl)

View File

@@ -56,7 +56,8 @@ describe("DOMException in Node.js environment", () => {
expect(DOMException.DATA_CLONE_ERR).toBe(25);
});
it("inherits prototype properties from Error", () => {
// TODO: missing stack trace on DOMException
it.failing("inherits prototype properties from Error", () => {
const error = new DOMException("Test error");
expect(error.toString()).toBe("Error: Test error");
expect(error.stack).toBeDefined();

View File

@@ -67,7 +67,7 @@ describe("AbortSignal", () => {
function fmt(value: any) {
const res = {};
for (const key in value) {
if (key === "column" || key === "line" || key === "sourceURL" || key === "stack") continue;
if (key === "column" || key === "line" || key === "sourceURL") continue;
res[key] = value[key];
}
return res;

View File

@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/12710
// Const-inlining should not change the observable result of Function.prototype.toString()
// by replacing variable references with literal values inside function bodies.
test("const values are not inlined into function bodies (require + eval toString)", async () => {
using dir = tempDir("issue-12710", {
"entry.js": `
const { log } = require("./helper");
const hi = "hi";
log(() => console.log(hi));
`,
"helper.js": `
export const log = (fun) => {
try {
eval("(" + fun.toString() + ")()");
console.log("NO_ERROR");
} catch (e) {
console.log(e.constructor.name + ": " + e.message);
}
};
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The eval'd function should throw ReferenceError because `hi` is not
// defined in the eval scope. If const inlining replaced `hi` with `"hi"`,
// this would incorrectly print "hi" instead.
expect(stdout.trim()).toBe("ReferenceError: hi is not defined");
expect(exitCode).toBe(0);
});
test("const values are still inlined at the same scope level", async () => {
using dir = tempDir("issue-12710-same-scope", {
"entry.js": `
const hi = "hi";
console.log(hi);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("hi");
expect(exitCode).toBe(0);
});
test("const values declared inside function bodies are still inlined within that function", async () => {
using dir = tempDir("issue-12710-inner", {
"entry.js": `
function foo() {
const x = "hello";
console.log(x);
}
foo();
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
test("toString() preserves variable references in arrow functions", async () => {
using dir = tempDir("issue-12710-tostring", {
"entry.js": `
const hi = "hi";
const fn = () => console.log(hi);
// The toString should contain the identifier 'hi', not the literal '"hi"'
const str = fn.toString();
console.log(str.includes("hi") ? "has_reference" : "no_reference");
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("has_reference");
expect(exitCode).toBe(0);
});
test("let variables are not inlined (unchanged behavior)", async () => {
using dir = tempDir("issue-12710-let", {
"entry.js": `
const { log } = require("./helper");
let hi = "hi";
log(() => console.log(hi));
`,
"helper.js": `
export const log = (fun) => {
try {
eval("(" + fun.toString() + ")()");
console.log("NO_ERROR");
} catch (e) {
console.log(e.constructor.name + ": " + e.message);
}
};
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("ReferenceError: hi is not defined");
expect(exitCode).toBe(0);
});

View File

@@ -1,79 +0,0 @@
import { expect, test } from "bun:test";
test("DOMException from new DOMException() has a stack trace", () => {
const e = new DOMException("test error", "AbortError");
expect(typeof e.stack).toBe("string");
expect(e.stack).toContain("AbortError: test error");
expect(e.stack).toContain("17877.test");
expect(e instanceof DOMException).toBe(true);
expect(e instanceof Error).toBe(true);
});
test("DOMException from AbortSignal.abort() has a stack trace", () => {
const signal = AbortSignal.abort();
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
expect(typeof err.stack).toBe("string");
expect(err.stack).toContain("AbortError");
expect(err.stack).toContain("The operation was aborted");
expect(err instanceof DOMException).toBe(true);
expect(err instanceof Error).toBe(true);
}
});
test("DOMException stack trace includes correct name and message", () => {
const e = new DOMException("custom message", "NotFoundError");
expect(typeof e.stack).toBe("string");
expect(e.stack).toStartWith("NotFoundError: custom message\n");
});
test("DOMException with default args has a stack trace", () => {
const e = new DOMException();
expect(typeof e.stack).toBe("string");
expect(e.name).toBe("Error");
expect(e.message).toBe("");
});
test("DOMException stack trace shows correct call site", () => {
function createException() {
return new DOMException("inner", "DataError");
}
const e = createException();
expect(typeof e.stack).toBe("string");
expect(e.stack).toContain("createException");
});
test("DOMException.stack is writable", () => {
const e = new DOMException("test", "AbortError");
expect(typeof e.stack).toBe("string");
e.stack = "custom stack";
expect(e.stack).toBe("custom stack");
});
test("DOMException from AbortSignal.abort() with custom reason has no stack on reason", () => {
const reason = "custom reason string";
const signal = AbortSignal.abort(reason);
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
// When a custom reason (non-DOMException) is used, it's thrown as-is
expect(err).toBe("custom reason string");
}
});
test("DOMException from AbortSignal.abort() with DOMException reason has stack", () => {
const reason = new DOMException("custom abort", "AbortError");
const signal = AbortSignal.abort(reason);
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
expect(err).toBe(reason);
expect(typeof err.stack).toBe("string");
expect(err.stack).toContain("AbortError: custom abort");
}
});