From 34d7caa284321017b1b8e659dd727384b83e9958 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:39:48 +0000 Subject: [PATCH 1/5] bun:ffi: copy the TinyCC diagnostic into the error cc() throws The Step::Failed message was wrapped into the Error instance through an untagged ZigString, which does not copy, and the buffer was freed together with the CompileC when cc() returned. Tag it UTF-8 so toErrorInstance copies it, and drop the leading-byte skipping in both TinyCC error callbacks, which was working around the symptom of that free. --- src/runtime/ffi/ffi_body.rs | 32 ++++------------- test/js/bun/ffi/cc.test.ts | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 679a87b1e36a..c04843ebbd61 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -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. @@ -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)) }; @@ -1232,7 +1221,10 @@ impl FFI { } match &function.step { Step::Failed { msg, .. } => { - let res = ZigString::init(msg).to_error_instance(global_this); + // `to_error_instance` wraps an untagged `ZigString` without + // copying, and `msg` is freed with `compile_c` when this + // returns; the UTF-8 tag makes it copy. + let res = ZigString::init_utf8(msg).to_error_instance(global_this); return Err(global_this.throw_value(res)); } Step::Pending => { @@ -1988,19 +1980,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. diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 0726df91e629..99f8b544def1 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -139,6 +139,76 @@ describe("given a source file with syntax errors", () => { }); }); +// 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, and cc() freed them on return, so the message +// reached JS with its first bytes replaced by allocator metadata (a use after +// free under ASan). +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. + 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; } + `, + "fixture.js": /* js */ ` + import { cc } from "bun:ffi"; + import path from "path"; + + function messageOf(options) { + try { + cc(options); + } catch (error) { + return error.message; + } + return "cc() did not throw"; + } + + const source = path.join(import.meta.dir, "unresolved.c"); + const unresolved = messageOf({ source, symbols: { add: { args: ["int", "int"], returns: "int" } } }); + const clash = messageOf({ + source: path.join(import.meta.dir, "clash.c"), + symbols: { JSFunctionCall: { args: ["int"], returns: "int" } }, + }); + console.log(JSON.stringify({ source, unresolved, clash })); + `, + }); + + 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: { + unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to 'bun_test_missing_symbol'\n`, + clash: expect.stringMatching(/^:\d+: error: .*'JSFunctionCall'$/), + }, + exitCode: 0, + }); + }); +}); + describe.skip("given a ping(cstr) function", () => { const library = makeValidCase( "ping", From 9f87f170d18c30758d47c3eae51d8d4df5e3e23e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:15:29 +0000 Subject: [PATCH 2/5] test: cover a wrapper diagnostic that quotes a non-ASCII token --- test/js/bun/ffi/cc.test.ts | 40 +++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 99f8b544def1..dac496766174 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -141,16 +141,20 @@ describe("given a source file with syntax errors", () => { // 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, and cc() freed them on return, so the message -// reached JS with its first bytes replaced by allocator metadata (a use after -// free under ASan). +// 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. + // 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 */ ` @@ -160,26 +164,35 @@ describe("TinyCC diagnostics thrown by cc()", () => { "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(options) { + function messageOf(file, symbols) { try { - cc(options); + cc({ source: path.join(import.meta.dir, file), symbols }); } catch (error) { return error.message; } return "cc() did not throw"; } - const source = path.join(import.meta.dir, "unresolved.c"); - const unresolved = messageOf({ source, symbols: { add: { args: ["int", "int"], returns: "int" } } }); - const clash = messageOf({ - source: path.join(import.meta.dir, "clash.c"), - symbols: { JSFunctionCall: { args: ["int"], returns: "int" } }, - }); - console.log(JSON.stringify({ source, unresolved, clash })); + 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" } }), + }), + ); `, }); @@ -203,6 +216,7 @@ describe("TinyCC diagnostics thrown by cc()", () => { messages: { unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to 'bun_test_missing_symbol'\n`, clash: expect.stringMatching(/^:\d+: error: .*'JSFunctionCall'$/), + nonascii: expect.stringMatching(/^:\d+: error: .*'ñ'/), }, exitCode: 0, }); From 72c1b86c8e32eb99227c5024b862ff6ea0ae5015 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:33:35 +0000 Subject: [PATCH 3/5] bun:ffi: shorten the comment on the copied diagnostic --- src/runtime/ffi/ffi_body.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index c04843ebbd61..3675ab378401 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1221,9 +1221,7 @@ impl FFI { } match &function.step { Step::Failed { msg, .. } => { - // `to_error_instance` wraps an untagged `ZigString` without - // copying, and `msg` is freed with `compile_c` when this - // returns; the UTF-8 tag makes it copy. + // 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)); } From e80b5c493d4d5933dd96b4a15c0cfe2bed5374de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:08:03 +0000 Subject: [PATCH 4/5] test: expect TinyCC's Mach-O underscore in the unresolved-reference diagnostic --- test/js/bun/ffi/cc.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index dac496766174..49d688243742 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -10,7 +10,7 @@ import { symlinkSync, writeFileSync, } from "fs"; -import { bunEnv, bunExe, isASAN, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isASAN, isMacOS, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; import path from "path"; // TODO: we need to install build-essential and Apple SDK in CI. @@ -145,16 +145,19 @@ describe("given a source file with syntax errors", () => { // 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()", () => { + // On Mach-O, TinyCC prefixes C symbols with "_" and reports them that way; + // asm labels are taken verbatim, so the one below supplies the prefix itself. + const symbolPrefix = isMacOS ? "_" : ""; + // 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. + // - nonascii.c exports its function under the asm label "y ñ", 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 */ ` @@ -165,11 +168,7 @@ describe("TinyCC diagnostics thrown by cc()", () => { 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) __asm__("${symbolPrefix}y ñ"); int add(int a, int b) { return a + b; } `, "fixture.js": /* js */ ` @@ -214,7 +213,7 @@ describe("TinyCC diagnostics thrown by cc()", () => { // but it is not asserted empty: debug builds print benign warnings. expect({ messages, stderr, exitCode }).toMatchObject({ messages: { - unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to 'bun_test_missing_symbol'\n`, + unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to '${symbolPrefix}bun_test_missing_symbol'\n`, clash: expect.stringMatching(/^:\d+: error: .*'JSFunctionCall'$/), nonascii: expect.stringMatching(/^:\d+: error: .*'ñ'/), }, From 8a306cf12cb3ba108308a4a15b451d8a311a1ab9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:50:35 +0000 Subject: [PATCH 5/5] test: pin that a CompileC diagnostic keeps the leading byte of a relative source path --- test/js/bun/ffi/cc.test.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 49d688243742..da5f30cccc15 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -139,11 +139,13 @@ describe("given a source file with syntax errors", () => { }); }); -// 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. +// TinyCC reports each diagnostic through a callback; cc() stores the text and +// throws it later. Two things used to mangle it on the way to JS: the callbacks +// dropped leading bytes outside 0x21..0x7e (a diagnostic starts with the source +// path exactly as it was passed in), and the wrapper path built the Error around +// the stored bytes without copying them (cc() freed them on return, so the +// message arrived 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. describe("TinyCC diagnostics thrown by cc()", () => { // On Mach-O, TinyCC prefixes C symbols with "_" and reports them that way; // asm labels are taken verbatim, so the one below supplies the prefix itself. @@ -152,6 +154,9 @@ 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; + // - " relative.c" has a syntax error and is passed by its relative name, so + // its diagnostic starts with a space (a non-ASCII name would do as well, + // but TinyCC's narrow open() cannot find one on Windows); // - 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; @@ -164,6 +169,7 @@ describe("TinyCC diagnostics thrown by cc()", () => { int bun_test_missing_symbol(int); int add(int a, int b) { return bun_test_missing_symbol(a) + b; } `, + " relative.c": "int add(int a, int b) { return a b; }\n", "clash.c": /* c */ ` int JSFunctionCall(int a) { return a + 1; } `, @@ -175,21 +181,25 @@ describe("TinyCC diagnostics thrown by cc()", () => { import { cc } from "bun:ffi"; import path from "path"; - function messageOf(file, symbols) { + const add = { add: { args: ["int", "int"], returns: "int" } }; + + function messageOf(source, symbols) { try { - cc({ source: path.join(import.meta.dir, file), symbols }); + cc({ source, symbols }); } catch (error) { return error.message; } return "cc() did not throw"; } + const unresolvedSource = path.join(import.meta.dir, "unresolved.c"); 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" } }), + unresolvedSource, + unresolved: messageOf(unresolvedSource, add), + relative: messageOf(" relative.c", add), + clash: messageOf(path.join(import.meta.dir, "clash.c"), { JSFunctionCall: { args: ["int"], returns: "int" } }), + nonascii: messageOf(path.join(import.meta.dir, "nonascii.c"), { "y ñ": { args: ["int", "int"], returns: "int" } }), }), ); `, @@ -213,7 +223,8 @@ describe("TinyCC diagnostics thrown by cc()", () => { // but it is not asserted empty: debug builds print benign warnings. expect({ messages, stderr, exitCode }).toMatchObject({ messages: { - unresolved: `1 errors while compiling ${messages.source}\ntcc: error: unresolved reference to '${symbolPrefix}bun_test_missing_symbol'\n`, + unresolved: `1 errors while compiling ${messages.unresolvedSource}\ntcc: error: unresolved reference to '${symbolPrefix}bun_test_missing_symbol'\n`, + relative: "1 errors while compiling relative.c\n relative.c:1: error: ';' expected (got 'b')\n", clash: expect.stringMatching(/^:\d+: error: .*'JSFunctionCall'$/), nonascii: expect.stringMatching(/^:\d+: error: .*'ñ'/), },