Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
30 changes: 4 additions & 26 deletions src/runtime/ffi/ffi_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ impl CompileC {
if this_.is_null() {
return;
}
let mut msg: &[u8] = if message.is_null() {
let msg: &[u8] = if message.is_null() {
b""
} else {
// SAFETY: TCC guarantees `message` is a valid NUL-terminated string when non-null.
Expand All @@ -475,17 +475,6 @@ impl CompileC {
return;
}

let mut offset: usize = 0;
// the message we get from TCC sometimes has garbage in it
// i think because we're doing in-memory compilation
while offset < msg.len() {
if msg[offset] > 0x20 && msg[offset] < 0x7f {
break;
}
offset += 1;
}
msg = &msg[offset..];

// SAFETY: TinyCC threads our own `&mut CompileC` back as `ctx`; the
// caller is suspended in the TCC call, so this access is exclusive.
unsafe { (*this_).deferred_errors.push(Box::<[u8]>::from(msg)) };
Expand Down Expand Up @@ -1232,7 +1221,8 @@ impl FFI {
}
match &function.step {
Step::Failed { msg, .. } => {
let res = ZigString::init(msg).to_error_instance(global_this);
// UTF-8-tagged so it is copied: `msg` dies with `compile_c` below.
let res = ZigString::init_utf8(msg).to_error_instance(global_this);
return Err(global_this.throw_value(res));
}
Step::Pending => {
Expand Down Expand Up @@ -1988,19 +1978,7 @@ impl Function {
pub(crate) unsafe extern "C" fn handle_tcc_error(ctx: *mut Function, message: *const c_char) {
debug_assert!(!ctx.is_null());
// SAFETY: TCC passes a valid NUL-terminated string
let mut msg: &[u8] = unsafe { bun_core::ffi::cstr(message) }.to_bytes();
if !msg.is_empty() {
let mut offset: usize = 0;
// the message we get from TCC sometimes has garbage in it
// i think because we're doing in-memory compilation
while offset < msg.len() {
if msg[offset] > 0x20 && msg[offset] < 0x7f {
break;
}
offset += 1;
}
msg = &msg[offset..];
}
let msg: &[u8] = unsafe { bun_core::ffi::cstr(message) }.to_bytes();

// SAFETY: TinyCC threads our own `&mut Function` back as `ctx`; the
// caller is suspended in the TCC call, so this access is exclusive.
Expand Down
84 changes: 84 additions & 0 deletions test/js/bun/ffi/cc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,90 @@
});
});

// TinyCC reports a diagnostic through a callback; cc() stores the text and
// throws it later. The wrapper path used to build the Error around the stored
// bytes without copying them (cc() freed them on return, so the message reached
// JS with its first bytes replaced by allocator metadata, a use after free
// under ASan) and read them as Latin-1 although TinyCC echoes UTF-8 source.
describe("TinyCC diagnostics thrown by cc()", () => {
// One spawned fixture covers both places cc() can fail:
// - unresolved.c compiles but does not link, so the diagnostic is collected
// and thrown under a "N errors while compiling" header;
// - clash.c defines JSFunctionCall, the entry point of the wrapper cc()
// compiles around every symbol, so the user's C compiles and the wrapper
// does not, and that diagnostic is thrown on its own;
// - nonascii.c exports its function under the asm label "y ñ" (TinyCC keeps
// asm labels verbatim, hence the underscore macOS symbols otherwise get),
// so the wrapper's declaration of it is a syntax error whose diagnostic
// quotes the non-ASCII token.
it("reach JS byte for byte", async () => {
using dir = tempDir("bun-ffi-cc-diagnostics", {
"unresolved.c": /* c */ `
int bun_test_missing_symbol(int);
int add(int a, int b) { return bun_test_missing_symbol(a) + b; }
`,
"clash.c": /* c */ `
int JSFunctionCall(int a) { return a + 1; }
`,
"nonascii.c": /* c */ `
#ifdef __APPLE__
int add(int a, int b) __asm__("_y ñ");
#else
int add(int a, int b) __asm__("y ñ");
#endif
int add(int a, int b) { return a + b; }
`,
"fixture.js": /* js */ `
import { cc } from "bun:ffi";
import path from "path";

function messageOf(file, symbols) {
try {
cc({ source: path.join(import.meta.dir, file), symbols });
} catch (error) {
return error.message;
}
return "cc() did not throw";
}

console.log(
JSON.stringify({
source: path.join(import.meta.dir, "unresolved.c"),
unresolved: messageOf("unresolved.c", { add: { args: ["int", "int"], returns: "int" } }),
clash: messageOf("clash.c", { JSFunctionCall: { args: ["int"], returns: "int" } }),
nonascii: messageOf("nonascii.c", { "y ñ": { args: ["int", "int"], returns: "int" } }),
}),
);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "fixture.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

let messages: any = stdout;
try {
messages = JSON.parse(stdout);
} catch {}

// stderr is part of the received object so a crashed fixture shows why,
// but it is not asserted empty: debug builds print benign warnings.
expect({ messages, stderr, exitCode }).toMatchObject({
messages: {

Check failure on line 216 in test/js/bun/ffi/cc.test.ts

View check run for this annotation

Claude / Claude Code Review

Test's exact-string 'unresolved' assertion fails on macOS due to TinyCC leading underscore

The exact-string `unresolved` assertion will fail on macOS: TinyCC sets `leading_underscore = 1` under `TCC_TARGET_MACHO`, so `put_extern_sym2` stores `_bun_test_missing_symbol` in the symtab and `relocate_syms` formats the diagnostic with the un-stripped name — the message on darwin is `unresolved reference to '_bun_test_missing_symbol'`. Either branch on `process.platform === 'darwin'` or relax to `expect.stringMatching(/'_?bun_test_missing_symbol'/)` (the PR already handles this convention fo
Comment thread
claude[bot] marked this conversation as resolved.
unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to 'bun_test_missing_symbol'\n`,
clash: expect.stringMatching(/^<string>:\d+: error: .*'JSFunctionCall'$/),
nonascii: expect.stringMatching(/^<string>:\d+: error: .*'ñ'/),
},
exitCode: 0,
});
});
});

describe.skip("given a ping(cstr) function", () => {
const library = makeValidCase(
"ping",
Expand Down
Loading