Skip to content
Closed
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 docs/runtime/ffi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-types/ffi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion scripts/build/deps/webkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* for local mode. Override via `--webkit-version=<hash>` 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.
Expand Down
27 changes: 21 additions & 6 deletions src/jsc/bindings/JSFFICString.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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);
Expand All @@ -73,6 +68,26 @@ 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);
}

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);
}

}

extern "C" JSC::EncodedJSValue Bun__FFI__CStringConstructor(JSC::JSGlobalObject* globalObject)
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/JSFFICString.h
Original file line number Diff line number Diff line change
Expand Up @@ -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*);
Expand Down
146 changes: 145 additions & 1 deletion test/js/bun/ffi/ffi-error-messages.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading