Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 1 addition & 1 deletion src/jsc/bindings/ErrorCode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ JSC_DEFINE_HOST_FUNCTION(NodeError_proto_toString, (JSC::JSGlobalObject * global
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto thisVal = callFrame->thisValue();
auto thisVal = callFrame->thisValue().toThis(globalObject, JSC::ECMAMode::strict());

auto name = thisVal.get(globalObject, vm.propertyNames->name);
RETURN_IF_EXCEPTION(scope, {});
Expand Down
11 changes: 7 additions & 4 deletions src/jsc/bindings/JSStringDecoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,7 @@ JSC::EncodedJSValue JSStringDecoderConstructor::construct(JSC::JSGlobalObject* l
return Bun::ERR::UNKNOWN_ENCODING(throwScope, lexicalGlobalObject, view);
}
}
JSValue thisValue = callFrame->newTarget();
auto* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
JSObject* newTarget = asObject(thisValue);
auto* constructor = globalObject->JSStringDecoder();
Structure* structure = globalObject->JSStringDecoderStructure();

Expand All @@ -593,9 +591,14 @@ JSC::EncodedJSValue JSStringDecoderConstructor::construct(JSC::JSGlobalObject* l
// This is a hack to make express' body-parser work
// It does something weird with the prototype
// Not exactly a subclass
if (constructor != newTarget && callFrame->thisValue().isObject()) {
//
// The this slot holds the receiver of a call and new.target of a construct. A call
// resolved through a scope (captured or module-level binding) has that scope object as
// its receiver; strict toThis maps it to undefined so it is not decorated and returned.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict());
if (thisValue.isObject() && thisValue != constructor) {
auto clientData = WebCore::clientData(vm);
JSObject* thisObject = asObject(callFrame->thisValue());
JSObject* thisObject = asObject(thisValue);

thisObject->putDirect(vm, clientData->builtinNames().decodePrivateName(), jsObject, JSC::PropertyAttribute::DontEnum | 0);
thisObject->putDirect(vm, clientData->builtinNames().encodingPublicName(), convertEnumerationToJS<BufferEncodingType>(*lexicalGlobalObject, encoding), JSC::PropertyAttribute::DontEnum | 0);
Expand Down
4 changes: 3 additions & 1 deletion src/jsc/bindings/NodeFSStatBinding.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
auto* thisObject = dynamicDowncast<JSObject>(callFrame->thisValue());
// A bare call through a captured or module binding carries the scope object as its receiver;
// strict toThis maps it to undefined so `mode` is not read out of the scope's variable slots.
Comment thread
robobun marked this conversation as resolved.
Outdated
auto* thisObject = dynamicDowncast<JSObject>(callFrame->thisValue().toThis(globalObject, JSC::ECMAMode::strict()));

Check warning on line 144 in src/jsc/bindings/NodeFSStatBinding.cpp

View check run for this annotation

Claude / Claude Code Review

getDateField sibling site left unfixed for scope-object receivers

`getDateField` (~line 222) and `jsStatsPrototypeFunction_DatePutter` in this same file have the identical `dynamicDowncast<JSObject>(thisValue)` + get-by-public-name pattern that the second commit fixed in `modeStatFunction`, and are left unfixed. The atime/mtime/ctime/birthtime getters are `CustomAccessor` without `DOMAttribute`, so the reified getter from `Object.getOwnPropertyDescriptor(Stats.prototype, 'atime').get` passes a scope-object receiver straight through — with a TDZ `atimeMs` bindi
Comment thread
robobun marked this conversation as resolved.
Outdated
if (!thisObject)
return JSC::jsUndefined();

Expand Down
25 changes: 11 additions & 14 deletions src/jsc/bindings/napi.h
Original file line number Diff line number Diff line change
Expand Up @@ -980,20 +980,17 @@ class NAPICallFrame {
, m_dataPtr(dataPtr)
{
// Node-API function calls always run in "sloppy mode," even if the JS side is in strict
// mode. So if `this` is null or undefined, we use globalThis instead; otherwise, we convert
// `this` to an object.
// TODO change to global? or find another way to avoid JSGlobalProxy
JSC::JSObject* jscThis = globalObject->globalThis();
if (!m_callFrame->thisValue().isUndefinedOrNull()) {
// TopExceptionScope: this runs before the addon's callback and its
// first NAPI_PREAMBLE; a ThrowScope would simulate a throw on
// destruction that the next preamble would see as unchecked.
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject));
jscThis = m_callFrame->thisValue().toObject(globalObject);
// https://tc39.es/ecma262/#sec-toobject
// toObject only throws for undefined and null, which we checked for
scope.assertNoException();
}
// mode: null, undefined and the scope object JSC leaves in the this slot of a call
// resolved through a captured or module binding all become globalThis, and primitives
// are boxed. Host functions never run op_to_this, so apply it here.
//
// TopExceptionScope: this runs before the addon's callback and its
// first NAPI_PREAMBLE; a ThrowScope would simulate a throw on
// destruction that the next preamble would see as unchecked.
Comment thread
robobun marked this conversation as resolved.
Outdated
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject));
JSValue jscThis = m_callFrame->thisValue().toThis(globalObject, JSC::ECMAMode::sloppy());
// Sloppy toThis only allocates wrapper objects for primitives; it has no throwing path.
scope.assertNoException();
m_callFrame->setThisValue(jscThis);
}

Expand Down
12 changes: 4 additions & 8 deletions src/jsc/bindings/v8/shim/FunctionTemplate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,10 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb
{
auto* callee = dynamicDowncast<Function>(callFrame->jsCallee());

// V8 function calls always run in "sloppy mode," even if the JS side is in strict mode. So if
// `this` is null or undefined, we use globalThis instead; otherwise, we convert `this` to an
// object.
JSC::JSObject* jscThis = globalObject->globalThis();
if (!callFrame->thisValue().isUndefinedOrNull()) {
// TODO(@190n) throwscope, assert no exception
jscThis = callFrame->thisValue().toObject(globalObject);
}
// V8 function calls always run in "sloppy mode," even if the JS side is in strict mode: null,
// undefined and the scope object JSC leaves in the this slot of a call resolved through a
// captured or module binding all become globalThis, and primitives are boxed.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSC::JSObject* jscThis = JSC::asObject(callFrame->thisValue().toThis(globalObject, JSC::ECMAMode::sloppy()));

JSC::ArgList args(callFrame);
return JSValue::encode(invokeCallback(globalObject, callee, jscThis, args, false));
Expand Down
9 changes: 3 additions & 6 deletions src/jsc/bindings/v8/shim/TemplateProperty.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,9 @@ static JSC::JSValue invokeAccessor(

HandleScope hs(isolate);

JSC::JSObject* jscThis = globalObject->globalThis();
if (!thisObject.isUndefinedOrNull()) {
jscThis = thisObject.toObject(globalObject);
if (!jscThis) [[unlikely]]
return JSC::jsUndefined();
}
// Same sloppy-mode receiver rule as FunctionTemplate::functionCall: null, undefined and a
// scope object passed as the receiver of a bare call become globalThis, primitives are boxed.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSC::JSObject* jscThis = JSC::asObject(thisObject.toThis(globalObject, JSC::ECMAMode::sloppy()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Local<v8::Object> holder = hs.createLocal<v8::Object>(vm, jscThis);
Local<v8::Name> property = hs.createLocal<v8::Name>(vm, name);
Local<v8::Value> dataLocal = hs.createLocal<v8::Value>(vm, data);
Expand Down
72 changes: 72 additions & 0 deletions test/js/node/errors/error-code-toString-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { readFileSync } from "node:fs";

// Errors with a Node `code` get their prototype, including `toString`, from the native
// error table (src/jsc/bindings/ErrorCode.cpp).
function nodeError(): Error & { code: string } {
try {
// @ts-expect-error the missing argument is the point
readFileSync();
} catch (e) {
return e as Error & { code: string };
}
throw new Error("readFileSync() did not throw");
}

describe("node error toString()", () => {
test("formats name, code and message", () => {
const err = nodeError();
expect({ code: err.code, string: err.toString() }).toEqual({
code: "ERR_INVALID_ARG_TYPE",
string: `TypeError [ERR_INVALID_ARG_TYPE]: ${err.message}`,
});
});

// A call resolved through a binding that a closure captures is compiled with the scope
// object itself in the this slot; native functions see it raw. toString() used to read
// name/code/message off that scope object and return "undefined [undefined]: undefined".
// Node's toString is strict-mode JS, so it throws on the undefined receiver instead.
test("called without a receiver through a captured binding throws like Node", () => {
const { toString } = nodeError();
function keep() {
return toString;
}
expect(() => toString()).toThrow(TypeError);
expect(keep()).toBe(toString);
});

// Reading a binding that is still in its temporal dead zone through the scope object yields
// an empty value rather than a TDZ error, and toString() crashed the process (SIGSEGV at
// address 0x5) when it tried to stringify it.
test("called through a scope whose `name` binding is in its TDZ does not crash", async () => {
const src = `
let toString;
try {
require("node:fs").readFileSync();
} catch (e) {
({ toString } = e);
}
let result;
try {
result = toString();
} catch (e) {
result = e.constructor.name;
}
console.log(result);
let name = "initialized only after the call above";
function keep() {
return [toString, name];
}
keep();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "TypeError\n", stderr: "", exitCode: 0 });
});
});
52 changes: 51 additions & 1 deletion test/js/node/fs/fs-stats-constructor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { Stats, statSync } from "node:fs";

// Node.js's Stats constructor signature (deprecated, DEP0180):
Expand Down Expand Up @@ -54,3 +55,52 @@ test("Stats instances share Stats.prototype", () => {
expect(Object.getPrototypeOf(bigint).constructor.name).toBe("BigIntStats");
expect(bigint instanceof Object.getPrototypeOf(bigint).constructor).toBe(true);
});

// A call resolved through a binding that a closure captures is compiled with the scope object
// itself in the this slot, and native methods see it raw. isFile() and friends used to read
// `mode` out of that scope object: a missing binding produced a bogus false, and a `mode` binding
// still in its temporal dead zone crashed the process. Such a receiver now gets the same answer as
// any other non-object receiver.
describe("Stats mode methods called without a receiver", () => {
test("a call through a captured binding is treated like an undefined receiver", () => {
const stats = statSync(import.meta.path);
const bigintStats = statSync(import.meta.dir, { bigint: true });
const { isFile } = stats;
const { isDirectory } = bigintStats;
function keep() {
return [isFile, isDirectory];
}
expect({
bare: [isFile(), isDirectory()],
undefinedReceiver: [isFile.call(undefined), isDirectory.call(undefined)],
statsReceiver: [isFile.call(stats), isDirectory.call(bigintStats)],
}).toEqual({
bare: [undefined, undefined],
undefinedReceiver: [undefined, undefined],
statsReceiver: [true, true],
});
expect(keep()).toEqual([isFile, isDirectory]);
});

test("a call through a scope whose `mode` binding is in its TDZ does not crash", async () => {
const src = `
const { statSync } = require("node:fs");
const { isFile } = statSync(${JSON.stringify(import.meta.path)});
const { isDirectory } = statSync(${JSON.stringify(import.meta.dir)}, { bigint: true });
console.log(isFile(), isDirectory());
let mode = 0;
function keep() {
return [isFile, isDirectory, mode];
}
keep();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "undefined undefined\n", stderr: "", exitCode: 0 });
});
});
44 changes: 44 additions & 0 deletions test/js/node/string_decoder/string-decoder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,50 @@ it("normalizes the encoding name like Node", () => {
});
});

// A call resolved through a binding that a closure captures (or through a module binding) is
// compiled with the scope object itself in the this slot, and native callees see it raw.
// StringDecoder called without new used to mistake that scope object for a body-parser style
// receiver: it wrote the decoder state onto it and returned it instead of a decoder.
describe("StringDecoder called without new", () => {
it("returns a decoder when the callee is resolved through a captured binding", () => {
const StringDecoder = RealStringDecoder;
function keep() {
return StringDecoder;
}
const decoder = StringDecoder("latin1");
expect(decoder).toBeInstanceOf(RealStringDecoder);
expect(decoder.encoding).toBe("latin1");
expect(decoder.write(Buffer.from([0xe9]))).toBe("é");
expect(keep()).toBe(RealStringDecoder);
});

it("still initializes an explicit object receiver (body-parser style)", () => {
const receiver = {};
expect(RealStringDecoder.call(receiver, "latin1")).toBe(receiver);
expect(receiver.encoding).toBe("latin1");
});

// The constructor used to call asObject() on the receiver before checking that it is one, which
// aborts a debug build for any non-object receiver. Run it in a child so the abort shows up as a
// failed assertion instead of taking the test runner down.
it("returns a decoder for an undefined or primitive receiver", async () => {
const src = `
const { StringDecoder } = require("node:string_decoder");
const local = StringDecoder;
const results = [local("utf8"), StringDecoder.call(undefined, "utf8"), StringDecoder.call("x", "utf8")];
console.log(results.every(d => d instanceof StringDecoder && d.encoding === "utf8"));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 });
});
});

// Node's lastTotal getter is MissingBytes + BufferedBytes; Node clears BufferedBytes when a buffered
// partial is emitted so lastTotal returns to 0. end() resets lastNeed/lastTotal but leaves lastChar.
// Decoded output was always correct; this is purely about the observable legacy state triple.
Expand Down
12 changes: 12 additions & 0 deletions test/napi/napi-app/js_test_helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,19 @@ static napi_value test_reference_unref_underflow(const Napi::CallbackInfo &info)
return result;
}

// Returns the this_arg that napi_get_cb_info reports for this call, so JS can
// check what a callback sees as its receiver for different call shapes.
static napi_value return_this(const Napi::CallbackInfo &info) {
napi_env env = info.Env();
napi_value this_arg;
NODE_API_CALL(env,
napi_get_cb_info(env, static_cast<napi_callback_info>(info),
nullptr, nullptr, &this_arg, nullptr));
return this_arg;
}

void register_js_test_helpers(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, return_this);
REGISTER_FUNCTION(env, exports, create_ref_with_finalizer);
REGISTER_FUNCTION(env, exports, was_finalize_called);
REGISTER_FUNCTION(env, exports, call_and_get_exception);
Expand Down
16 changes: 16 additions & 0 deletions test/napi/napi-app/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,22 @@ nativeTests.test_type_tag = () => {
console.log("o2 matches o2:", nativeTests.check_tag(o2, 3, 4));
};

nativeTests.test_this_value_of_bare_call_through_closure = () => {
const { return_this } = nativeTests;
// return_this is captured by keep, so the bare call below is resolved through the closure's
// scope object, which JSC leaves in the call's this slot. A Node-API callback must still see
// the sloppy-mode receiver (globalThis), never that scope object.
function keep() {
return return_this;
}
console.log("bare call through closure returned globalThis:", return_this() === globalThis);
console.log("call(undefined) returned globalThis:", return_this.call(undefined) === globalThis);
console.log("call(5) returned a Number object:", return_this.call(5) instanceof Number);
const receiver = {};
console.log("call(receiver) returned receiver:", return_this.call(receiver) === receiver);
keep();
};

nativeTests.test_napi_class = () => {
const NapiClass = nativeTests.get_class_with_constructor();
const instance = new NapiClass();
Expand Down
7 changes: 7 additions & 0 deletions test/napi/napi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,13 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => {
});
});

describe("napi_get_cb_info this_arg", () => {
it("is globalThis for a bare call resolved through a closure scope", async () => {
const output = await checkSameOutput("test_this_value_of_bare_call_through_closure", []);
expect(output).toContain("bare call through closure returned globalThis: true");
});
});

describe("bigint conversion to int64/uint64", () => {
it("works", async () => {
const tests = [-1n, 0n, 1n];
Expand Down
12 changes: 12 additions & 0 deletions test/v8/v8-module/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ module.exports = debugMode => {
}
},

call_function_bare_through_closure() {
const { return_this } = nativeModule;
// return_this is captured by keep, so the bare call below is resolved through the closure's
// scope object, which JSC leaves in the call's this slot. The callback must still see the
// sloppy-mode receiver (globalThis), never that scope object.
function keep() {
return return_this;
}
console.log("bare call returned globalThis:", return_this() === globalThis);
keep();
},

test_v8_object_get_set_exceptions() {
for (const key of [0, "key"]) {
for (const access of ["get", "set"]) {
Expand Down
5 changes: 5 additions & 0 deletions test/v8/v8.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", ()
it("correctly receives the this value from JS", async () => {
await checkSameOutput("call_function_with_weird_this_values");
});

it("receives globalThis as this when called bare through a closure", async () => {
const output = await checkSameOutput("call_function_bare_through_closure");
expect(output).toContain("bare call returned globalThis: true");
});
});

describe("error handling", () => {
Expand Down
Loading