From 09d940ccbfb08e9549eccda2e0ab970bf1507fc9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:44:12 +0000 Subject: [PATCH 1/3] bun:ffi: give CString's call and construct handlers one shared body callFFICString forwarded to constructFFICString, so a check that is only valid for a construct could not live in the construct handler. Both are now thin arms over transcodeCString. No behaviour change. --- src/jsc/bindings/JSFFICString.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/JSFFICString.cpp b/src/jsc/bindings/JSFFICString.cpp index f1ae1b4e2f1c..4f458c4d365e 100644 --- a/src/jsc/bindings/JSFFICString.cpp +++ b/src/jsc/bindings/JSFFICString.cpp @@ -36,11 +36,6 @@ void JSFFICStringConstructor::finishCreation(VM& vm) Base::finishCreation(vm, 3, "CString"_s, PropertyAdditionMode::WithoutStructureTransition); } -JSC_DEFINE_HOST_FUNCTION(callFFICString, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - return constructFFICString(globalObject, callFrame); -} - static inline bool isSafeIntegerValue(JSValue value) { if (value.isInt32()) @@ -51,7 +46,7 @@ static inline bool isSafeIntegerValue(JSValue value) return std::isfinite(number) && std::trunc(number) == number && std::abs(number) <= maxSafeInteger(); } -JSC_DEFINE_HOST_FUNCTION(constructFFICString, (JSGlobalObject * globalObject, CallFrame* callFrame)) +static ALWAYS_INLINE EncodedJSValue transcodeCString(JSGlobalObject* globalObject, CallFrame* callFrame) { VM& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -73,6 +68,16 @@ JSC_DEFINE_HOST_FUNCTION(constructFFICString, (JSGlobalObject * globalObject, Ca return JSValue::encode(transcoded); } +JSC_DEFINE_HOST_FUNCTION(callFFICString, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return transcodeCString(globalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(constructFFICString, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return transcodeCString(globalObject, callFrame); +} + } extern "C" JSC::EncodedJSValue Bun__FFI__CStringConstructor(JSC::JSGlobalObject* globalObject) From 1e0a779b2e94899c2ed4e1be09367ced7f4aa3da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:24:18 +0000 Subject: [PATCH 2/3] Upgrade WebKit to the preview build of oven-sh/WebKit#739 In that build a subclass of InternalFunction may take JSCell::getConstructData. No class in bun does it at this commit. --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 47dedb61e337..02a6de616fcc 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "f20ce7744553c910bcf16a33faf976af208de091"; +export const WEBKIT_VERSION = "autobuild-preview-pr-739-42d31a6b"; /** * WebKit (JavaScriptCore) — the JS engine. From 165664d8741a38a002258982fd3172fc39a70598 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:38 +0000 Subject: [PATCH 3/3] bun:ffi: CString is not a constructor to anything that asks first `new CString(ptr)` evaluates to a string primitive, which a constructor must not return. Array.of, Array.from and a species lookup construct what they are given and use the result as an object, so they took the process down. Reflect.construct, bind and Proxy returned the primitive on a release build and failed an assertion on a build with assertions. CString now takes JSCell::getConstructData, so IsConstructor is false for it. `new CString(ptr)` reads no construct data: it still runs the construct handler and returns the string. The handler throws a TypeError when new.target is not CString, which is how `super()` of a class re-parented onto CString gets there. --- docs/runtime/ffi.mdx | 2 + packages/bun-types/ffi.d.ts | 5 + src/jsc/bindings/JSFFICString.cpp | 10 ++ src/jsc/bindings/JSFFICString.h | 5 + test/js/bun/ffi/ffi-error-messages.test.ts | 146 ++++++++++++++++++++- 5 files changed, 167 insertions(+), 1 deletion(-) diff --git a/docs/runtime/ffi.mdx b/docs/runtime/ffi.mdx index 4ff9af5abda2..1bfd720264b8 100644 --- a/docs/runtime/ffi.mdx +++ b/docs/runtime/ffi.mdx @@ -229,6 +229,8 @@ my_library_free(ptr); console.log(myString); ``` +`CString` is a constructor only in a `new CString()` expression: `Reflect.construct(CString, [ptr])`, `new` on `CString.bind()` or on a `Proxy` of `CString`, `class X extends CString`, and a `Symbol.species` that returns `CString` throw a `TypeError`. + When used in `returns`, `FFIType.cstring` coerces the pointer to a JavaScript `string`. When used in `args`, `FFIType.cstring` accepts everything `ptr` does **and** additionally accepts a JavaScript string directly. The engine transcodes the string to a null-terminated UTF-8 buffer that lives for the duration of the call, so you don't need to encode it into a `Buffer` yourself: ```ts diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 93584ffc457d..a696c7277e78 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -1029,6 +1029,11 @@ declare module "bun:ffi" { * pointer, or reading past the end of the memory it points to, can crash * the program or cause undefined behavior. * + * `CString` is a constructor only in a `new CString()` expression: + * `Reflect.construct(CString, [ptr])`, `new` on `CString.bind()` or on a + * `Proxy` of `CString`, `class X extends CString`, and a `Symbol.species` + * that returns `CString` throw a `TypeError`. + * * @example * ```js * var ptr = lib.symbols.getVersion(); diff --git a/src/jsc/bindings/JSFFICString.cpp b/src/jsc/bindings/JSFFICString.cpp index 4f458c4d365e..e9cc97d8d91e 100644 --- a/src/jsc/bindings/JSFFICString.cpp +++ b/src/jsc/bindings/JSFFICString.cpp @@ -73,8 +73,18 @@ JSC_DEFINE_HOST_FUNCTION(callFFICString, (JSGlobalObject * globalObject, CallFra return transcodeCString(globalObject, callFrame); } +static NEVER_INLINE EncodedJSValue throwForeignNewTarget(JSGlobalObject* globalObject) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + return throwVMTypeError(globalObject, scope, "CString cannot be constructed with a new.target other than itself"_s); +} + JSC_DEFINE_HOST_FUNCTION(constructFFICString, (JSGlobalObject * globalObject, CallFrame* callFrame)) { + // CString reports no construct data (see JSFFICString.h), so only bytecode gets here: `new CString()`, and + // `super()` of a class whose [[Prototype]] was set to CString. That `super()` passes the class as new.target. + if (callFrame->newTarget() != JSValue(callFrame->jsCallee())) [[unlikely]] + return throwForeignNewTarget(globalObject); return transcodeCString(globalObject, callFrame); } diff --git a/src/jsc/bindings/JSFFICString.h b/src/jsc/bindings/JSFFICString.h index 9244b969c361..248cb29da5bd 100644 --- a/src/jsc/bindings/JSFFICString.h +++ b/src/jsc/bindings/JSFFICString.h @@ -15,6 +15,11 @@ class JSFFICStringConstructor final : public JSC::InternalFunction { static constexpr unsigned StructureFlags = Base::StructureFlags; static constexpr JSC::DestructionMode needsDestruction = JSC::DoesNotNeedDestruction; + // `new CString(ptr)` evaluates to a string primitive, which no constructor may return. This puts + // JSCell::getConstructData in the method table, so CString is a constructor to nothing that asks first + // (Array.of, Array.from, species, Reflect.construct, bind, Proxy, extends). `new CString()` does not ask. + using JSC::JSCell::getConstructData; + DECLARE_INFO; static JSFFICStringConstructor* create(JSC::VM&, JSC::JSGlobalObject*); diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts index d886d6e0de68..ead46193d057 100644 --- a/test/js/bun/ffi/ffi-error-messages.test.ts +++ b/test/js/bun/ffi/ffi-error-messages.test.ts @@ -1,6 +1,6 @@ import { CString, dlopen, linkSymbols, ptr, toArrayBuffer, toBuffer } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { isMusl } from "harness"; +import { bunEnv, bunExe, isMusl } from "harness"; // Not `toThrow()`: it also accepts an Error that the function returns, which is // what `toBuffer()` and `toArrayBuffer()` did with their TypeError before they @@ -85,6 +85,150 @@ describe("CString argument errors", () => { }); }); +// `new CString(ptr)` evaluates to a string primitive, which a constructor must not return. So CString is a +// constructor to nothing but a `new CString()` expression: what asks IsConstructor first gets false. +// Subprocess because a build where CString is a constructor crashes at most of these doors. +describe("CString is not a constructor to anything that asks first", () => { + async function run(body: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { CString, ptr } from "bun:ffi"; + const hello = Buffer.from("hello\\0"); + globalThis.pinned = hello; + const p = ptr(hello); + class Species extends Array { + static get [Symbol.species]() { + return CString; + } + } + const reparent = X => (Object.setPrototypeOf(X, CString), X); + async function print(door) { + try { + return JSON.stringify(await door()); + } catch (e) { + return e.name; + } + } + ${body} + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.concurrent.each([ + // IsConstructor is false, so these make a plain array. + ["Array.of", `Array.of.call(CString, 1, 2, 3)`, "[1,2,3]"], + ["Array.from", `Array.from.call(CString, [1, 2, 3])`, "[1,2,3]"], + // These need a constructor. + ["Symbol.species", `new Species(1, 2, 3).filter(() => true)`, "TypeError"], + ["Reflect.construct", `Reflect.construct(CString, [p])`, "TypeError"], + ])("%s", async (_, expression, printed) => { + expect(await run(`console.log(await print(() => ${expression}));`)).toEqual({ + stdout: printed + "\n", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("every other door", async () => { + const doors = [ + ["new of a bound CString", `new (CString.bind(null))(p)`, "TypeError"], + ["new of a Proxy of CString", `new (new Proxy(CString, {}))(p)`, "TypeError"], + ["extends", `new (class extends CString {})(p)`, "TypeError"], + // super() asks nothing. It gets to CString with the class as new.target. + ["super() of a re-parented class", `new (reparent(class extends Array { field = 1; }))()`, "TypeError"], + [ + "super(validPointer) of a re-parented class", + `new (reparent(class extends Array { constructor() { super(p); } }))()`, + "TypeError", + ], + ["Array.of on a re-parented class", `Array.of.call(reparent(class extends Array {}), 1, 2, 3)`, "TypeError"], + ["Array.from on a re-parented class", `Array.from.call(reparent(class extends Array {}), [1])`, "TypeError"], + ["Array.of with no items", `Array.of.call(CString)`, "[]"], + ["Array.from an array-like", `Array.from.call(CString, { length: 2, 0: "a", 1: "b" })`, `["a","b"]`], + ["Array.fromAsync", `Array.fromAsync.call(CString, [1, 2])`, "[1,2]"], + ["Uint8Array.of", `Uint8Array.of.call(CString, 1)`, "TypeError"], + ["Symbol.species filter of an empty array", `new Species().filter(() => true)`, "TypeError"], + ["Symbol.species map", `new Species(1, 2, 3).map(x => x)`, "TypeError"], + ["Symbol.species slice", `new Species(1, 2, 3).slice()`, "TypeError"], + ["Symbol.species concat", `new Species(1, 2, 3).concat([4])`, "TypeError"], + [ + "Symbol.species of an own constructor property", + `Object.assign([1, 2, 3], { constructor: { [Symbol.species]: CString } }).map(x => x)`, + "TypeError", + ], + [ + "ArrayBuffer Symbol.species", + `new (class extends ArrayBuffer { static get [Symbol.species]() { return CString; } })(8).slice(0, 4)`, + "TypeError", + ], + ["Reflect.construct with no arguments", `Reflect.construct(CString, [])`, "TypeError"], + ["Reflect.construct with CString as new.target", `Reflect.construct(Object, [], CString)`, "TypeError"], + ]; + // One line per door, printed when the door returns: a crash leaves the lines of the doors before it. + const body = doors.map( + ([door, expression]) => `console.log(${JSON.stringify(door + ": ")} + (await print(() => ${expression})));`, + ); + expect(await run(body.join("\n"))).toEqual({ + stdout: doors.map(([door, , printed]) => `${door}: ${printed}\n`).join(""), + stderr: "", + exitCode: 0, + }); + }); + + test("new CString(ptr) and CString(ptr) return the string when the call site is hot", () => { + const bytes = new TextEncoder().encode("hello\0"); + const address = ptr(bytes); + const args = [address]; + function direct() { + return ( + +(new CString(address) === "hello") + + +(new CString(...args) === "hello") + + +(new CString(address, 1, 3) === "ell") + + +(new CString(0 as any) === "") + + +(CString(address) === "hello") + + +(CString(...args) === "hello") + ); + } + let wrong = 0; + for (let i = 0; i < 10_000; i++) if (direct() !== 6) wrong++; + expect({ wrong, byteLength: bytes.byteLength }).toEqual({ wrong: 0, byteLength: 6 }); + }); + + test("a call through bind or a Proxy returns the string", () => { + const bytes = new TextEncoder().encode("hello\0"); + expect([CString.bind(null, ptr(bytes))(), new Proxy(CString, {})(ptr(bytes))]).toEqual(["hello", "hello"]); + }); + + test("CString is a function with no prototype property", () => { + expect({ type: typeof CString, length: CString.length, hasPrototype: "prototype" in CString }).toEqual({ + type: "function", + length: 3, + hasPrototype: false, + }); + }); + + test("CString prints as a function", () => { + expect({ alone: Bun.inspect(CString), nested: Bun.inspect({ CString }) }).toEqual({ + alone: "[Function: CString]", + nested: "{\n CString: [Function: CString],\n}", + }); + expect({ CString }).toMatchInlineSnapshot(` + { + "CString": [Function: CString], + } + `); + }); +}); + describe("FFI error messages", () => { test("dlopen shows library name when library cannot be opened", () => { // Try to open a non-existent library