Skip to content

Make 13 hand-written extern "C" declarations agree between Rust and C++ - #38943

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/4758ba8e/extern-c-signatures
Aug 15, 2026
Merged

Make 13 hand-written extern "C" declarations agree between Rust and C++#38943
Jarred-Sumner merged 3 commits into
mainfrom
farm/4758ba8e/extern-c-signatures

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Rust and C++ each spell out bun's internal C-ABI functions by hand, and the two copies only meet in the linker, which matches names, not signatures. 13 symbols are spelled differently on the two sides. Each works by accident of the x64/arm64 calling conventions (an extra argument lands in a register the callee never reads, a 32-bit declaration reads the low half of a 64-bit return) and is undefined behaviour on the Rust side. All of them predate the Rust port (checked against the removed Zig sources).
    • Parameter count: Bun__JSWrappingFunction__create (Rust passes a 5th strong argument, JSWrappingFunction.cpp:57 takes 4), ByteRangeMapping__getSourceID (ZigSourceProvider.cpp:43 passes a 2nd BunString, CodeCoverage.rs:845 takes 1), ffi_vfprintf / ffi_vprintf / ffi_vsscanf (declared variadic in ffi_body.rs, defined with a va_list parameter in c-bindings.cpp).
    • Return width: URL__originLength (url/lib.rs:74 says u32, BunString.cpp:570 returns size_t); Bun__setExitCode, Bun__closeChildIPC, Bun__ensureProcessIPCInitialized (BunProcess.cpp), Bun__setTLSRejectUnauthorizedValue, Bun__setVerboseFetchValue (JSEnvironmentVariableMap.cpp) declared with a scalar return in C++ while the Rust definitions return nothing; Bun__reportUnhandledError returns a constant undefined that ZigGlobalObject.h:90 declares as void; WebCore__AbortSignal__signal returns its argument, which the Rust declaration (void) never reads.

Fix

  • Makes each pair agree, on whichever side carries information:
    • the phantom strong / sourceURL arguments are dropped (C++ never read strong; Rust never read sourceURL, and the Bun::toString that built it is a non-owning view, so nothing was leaked or needs releasing);
    • the three ffi_v* declarations get a va_list parameter, spelled as an opaque pointer (only their addresses are taken, for TinyCC; on every target bun builds for a va_list argument travels as one pointer-sized value);
    • URL__originLength becomes usize, and the as usize at its only call site goes away;
    • the five C++ declarations of void Rust functions become void (every C++ caller already discards the value);
    • report_unhandled_error stops returning its constant (no Rust callers; the C++ declaration and all eight C++ callers already treat it as void);
    • WebCore__AbortSignal__signal returns void in bindings.cpp and headers.h (no C++ callers; the Rust declaration was already void).
  • No behaviour changes: every call site either ignored the dropped value or never passed anything the callee read. Verified with bun bd (the regenerated Bun__reportUnhandledError thunk is now -> ()) and bun bd test on test/js/bun/test/expect-extend*.test.* and jest-extended.test.js (JSWrappingFunction), test/cli/test/coverage.test.ts plus a manual bun:jsc codeCoverageForFile run (ByteRangeMapping__getSourceID), test/js/node/process/process.test.js, test/js/web/abort/abort.test.ts, test/js/bun/spawn/spawn.ipc.test.ts, test/js/node/child_process/child_process_ipc.test.js, test/js/node/events/event-emitter.test.ts, test/js/node/timers/node-timers.test.ts, test/js/web/fetch/fetch.tls.test.ts. cargo fmt --check and clang-format are clean.
  • There is no test: these are declaration-only corrections with nothing observable at runtime. The source lint that found them was part of the first revision and was removed from the PR by @Jarred-Sumner (31377e1); its output is kept below for the record.
  • Related, not duplicated: test(coverage): union coverage across re-imported instances of a module #35346 removes ByteRangeMapping__getSourceID altogether as part of a larger coverage change; this PR only corrects its declaration.

Background

  • extern "C" linkage: a Rust extern "C" { fn X(..); } item (or #[unsafe(no_mangle)] extern "C" fn X definition) and a C++ extern "C" declaration or definition are matched by the linker purely by the name X; each compiler generates its calls and prologues from its own local copy of the signature, so the copies can disagree without any diagnostic.
  • HOST_EXPORT: a // HOST_EXPORT(Sym) comment above a safe Rust fn makes src/codegen/generate-host-exports.ts emit the #[unsafe(no_mangle)] thunk for Sym with the impl's parameters and return type, which is why changing report_unhandled_error's Rust signature is what changes the exported symbol's.
  • headers.h spells extern "C" as CPP_DECL; bindings.cpp includes it, so its WebCore__AbortSignal__signal line has to change together with the definition.
How the 13 were found (lint output from the first revision; the lint itself is no longer in this PR)
(fail) every extern "C" symbol is declared with the same parameter count at every site
  Bun__JSWrappingFunction__create
      rust src/runtime/test_runner/expect.rs:3253: 5 params, returns JSValue
      c++ src/jsc/bindings/JSWrappingFunction.cpp:57: 4 params, returns JSC::EncodedJSValue
  ByteRangeMapping__getSourceID
      rust src/sourcemap_jsc/CodeCoverage.rs:845: 1 params, returns i32
      c++ src/jsc/bindings/ZigSourceProvider.cpp:43: 2 params, returns int
  ffi_vfprintf
      rust src/runtime/ffi/ffi_body.rs:322: 2+... params, returns c_int
      c++ src/jsc/bindings/c-bindings.cpp:822: 3 params, returns int
  ffi_vprintf
      rust src/runtime/ffi/ffi_body.rs:323: 1+... params, returns c_int
      c++ src/jsc/bindings/c-bindings.cpp:815: 2 params, returns int
  ffi_vsscanf
      rust src/runtime/ffi/ffi_body.rs:329: 2+... params, returns c_int
      c++ src/jsc/bindings/c-bindings.cpp:867: 3 params, returns int

(fail) every extern "C" symbol is declared with the same return width at every site
  Bun__closeChildIPC
      rust src/runtime/hw_exports.rs:171: 1 params, returns void
      c++ src/jsc/bindings/BunProcess.cpp:175: 1 params, returns bool
  Bun__ensureProcessIPCInitialized
      rust src/runtime/ipc_host.rs:602: 1 params, returns void
      c++ src/jsc/bindings/BunProcess.cpp:179: 1 params, returns bool
  Bun__reportUnhandledError
      rust src/jsc/virtual_machine_exports.rs:75: 2 params, returns JSValue
      c++ src/jsc/bindings/ZigGlobalObject.h:90: 2 params, returns void
  Bun__setExitCode
      rust src/jsc/VirtualMachine.rs:592: 2 params, returns void
      c++ src/jsc/bindings/BunProcess.cpp:174: 2 params, returns uint8_t
  Bun__setTLSRejectUnauthorizedValue
      rust src/jsc/virtual_machine_exports.rs:196: 1 params, returns void
      c++ src/jsc/bindings/JSEnvironmentVariableMap.cpp:355: 1 params, returns int
  Bun__setVerboseFetchValue
      rust src/jsc/virtual_machine_exports.rs:241: 1 params, returns void
      c++ src/jsc/bindings/JSEnvironmentVariableMap.cpp:357: 1 params, returns int
  URL__originLength
      rust src/url/lib.rs:74: 2 params, returns u32
      c++ src/jsc/bindings/BunString.cpp:570: 2 params, returns size_t
  WebCore__AbortSignal__signal
      rust src/jsc/AbortSignal.rs:57: 3 params, returns void
      c++ src/jsc/bindings/bindings.cpp:5969: 3 params, returns WebCore::AbortSignal*
      c++ src/jsc/bindings/headers.h:127: 3 params, returns WebCore::AbortSignal*

The lint compared parameter counts and return widths of every hand-written extern "C" site on both sides (1342 symbols declared in both languages); these 13 were the only disagreements in the tree.

… and lint for it

Rust and C++ only meet in the linker, which matches names, not signatures,
so a declaration that disagrees with the definition links and silently
relies on the calling convention tolerating the difference. Thirteen such
symbols exist; all of them predate the Rust port:

- Bun__JSWrappingFunction__create: Rust passed a fifth `strong` argument
  the C++ definition never had.
- ByteRangeMapping__getSourceID: C++ passed a second BunString argument
  the Rust definition never had.
- ffi_vfprintf / ffi_vprintf / ffi_vsscanf: declared variadic in Rust,
  defined with a va_list parameter in C++.
- URL__originLength: declared `-> u32`, defined returning size_t.
- Bun__setExitCode, Bun__closeChildIPC, Bun__ensureProcessIPCInitialized,
  Bun__setTLSRejectUnauthorizedValue, Bun__setVerboseFetchValue: declared
  with a scalar return in C++, defined returning nothing in Rust (every
  caller ignores the value).
- Bun__reportUnhandledError: returned a constant `undefined` that C++
  declares as void and never reads; the impl now returns nothing.
- WebCore__AbortSignal__signal: returned its argument, which the Rust
  declaration (void) never read; the C++ now returns void.

test/internal/source-lints/extern-c-signatures.test.ts collects every
hand-written site of every C-ABI symbol (Rust extern blocks including
jsc_abi_extern! and #[link_name], #[unsafe(no_mangle)] definitions,
HOST_EXPORT impls; C++ extern "C" declarations and definitions, extern "C"
blocks, and headers.h's CPP_DECL/ZIG_DECL) and requires all sites of a
symbol to agree on parameter count and, where both return types are
scalars of known width, on return width. Anything it cannot parse is
skipped. The source-lints workflow now also triggers on the C++ it reads.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:43 AM PT - Aug 15th, 2026

@robobun, your commit d6d42a9 is building: #97626

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bef1a348-4ebe-4141-8ee5-d9e208296204

📥 Commits

Reviewing files that changed from the base of the PR and between d3f975b and 79e1d6e.

📒 Files selected for processing (9)
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/ffi/ffi_body.rs
  • src/runtime/test_runner/expect.rs
  • src/url/lib.rs
💤 Files with no reviewable changes (1)
  • src/runtime/test_runner/expect.rs

Walkthrough

The pull request aligns native and Rust FFI declarations with their callers. It updates return types, removes redundant parameters, models va_list arguments explicitly, and adjusts related call sites.

Changes

FFI declaration alignment

Layer / File(s) Summary
JSC process and environment bindings
src/jsc/bindings/BunProcess.cpp, src/jsc/bindings/JSEnvironmentVariableMap.cpp
Process IPC and environment setter declarations now return void.
JSC signal and source mappings
src/jsc/bindings/ZigSourceProvider.cpp, src/jsc/bindings/bindings.cpp, src/jsc/bindings/headers.h
Source ID lookup passes only the resolved mapping. Abort signaling returns void. The header spacing is normalized.
Rust runtime FFI contracts
src/jsc/virtual_machine_exports.rs, src/runtime/ffi/ffi_body.rs, src/runtime/test_runner/expect.rs
Unhandled error reporting returns unit. va_list parameters use *mut c_void. Matcher wrapper creation no longer receives strong.
URL origin length contract
src/url/lib.rs
URL__originLength returns usize, and the caller uses the result without a cast.

Possibly related PRs

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: aligning 13 hand-written extern "C" declarations between Rust and C++.
Description check ✅ Passed The description clearly explains the problem, fixes, scope, background, and verification, although it does not use the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Scope is now the 13 signature corrections only.

The source lint from the first revision was removed from this PR in 31377e1; 79e1d6e drops the workflow trigger change that only existed for it and shortens the ffi_body.rs comment. The description has been rewritten for the reduced scope (the lint's output that identified the 13 symbols is kept in a details block there).

Verification: bun bd builds; the test files listed in the description pass against that build. There is no new test, since these are declaration-only changes with nothing observable at runtime.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I checked the C++ call sites of the five functions whose declared return type changed to void (Bun__setExitCode, Bun__closeChildIPC, Bun__ensureProcessIPCInitialized in BunProcess.cpp; Bun__setTLSRejectUnauthorizedValue, Bun__setVerboseFetchValue in JSEnvironmentVariableMap.cpp) — every caller is a statement expression that already discards the result. WebCore__AbortSignal__signal has no C++ callers at all (only the Rust AbortSignal.rs wrapper, which was already -> ()).

Extended reasoning...

This PR adds ~960 lines of new source-lint infrastructure (hand-rolled regex scanners for Rust and C++ extern-"C" declarations) plus 13 FFI signature corrections across production Rust and C++. The signature changes are individually small and well-justified in the description, and I spot-checked the void-return conversions against their call sites — none read the return value. But the combination of a large new lint that every future extern-"C" edit will run against, plus scattered ABI-boundary edits, is not the kind of change I should approve without a human look. The one inline nit (mislabeled safe fn shapes-table entry) is minor and does not affect correctness of the lint today.

Comment thread test/internal/source-lints/extern-c-signatures.test.ts Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment on lines +321 to +323
// Defined in c-bindings.cpp. Only their addresses are taken (handed to
// TinyCC below); the `ap` parameter of the `v*` variants is a `va_list`,
// declared here as an opaque pointer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut down to one line in 79e1d6e.

@robobun robobun changed the title Make hand-written extern "C" declarations agree between Rust and C++, and lint for it Make 13 hand-written extern "C" declarations agree between Rust and C++ Aug 15, 2026
@Jarred-Sumner
Jarred-Sumner merged commit 63ffef8 into main Aug 15, 2026
9 of 10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/4758ba8e/extern-c-signatures branch August 15, 2026 08:37

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 .github/workflows/source-lints.yml:15-19 — Commit 31377e1 ("Delete slop test") removed test/internal/source-lints/extern-c-signatures.test.ts, so the net diff ships no test at all — the 13 extern-C signature fixes have zero regression coverage (REVIEW.md: "Every behavioral change ships an automated test in the same PR"), and the PR title's "and lint for it" plus the description's "Adds test/internal/source-lints/extern-c-signatures.test.ts" are now false. Either restore the lint (addressing whatever made it "slop", plus the previously-flagged safe fn shapes-entry issue) or update the PR title/description and add targeted regression tests for the signature changes. The workflow path-filter widening added here does incidentally benefit no-iostream-include.test.ts, so it's not dead — but its stated motivation in this PR ("the lint reads" C++) no longer applies.

    Extended reasoning...

    What the bug is

    The PR's second commit, 31377e13 "Delete slop test" (authored by Jarred Sumner), deletes the 959-line test/internal/source-lints/extern-c-signatures.test.ts that the first commit d6d42a9f added. ls test/internal/source-lints/ confirms the file is absent from HEAD, and the PR's net changed-files list (10 files) contains no test file. Yet the PR title still says "…and lint for it", and the PR description devotes multiple paragraphs to documenting a test that no longer ships ("Adds test/internal/source-lints/extern-c-signatures.test.ts, which collects every hand-written site of every C-ABI symbol…"; "the lint fails on main with exactly the 13 symbols above… and passes on this branch"; "the lint takes about 2 s on a release bun"). Both the title and description are now materially false.

    Why this is blocking

    REVIEW.md is explicit under Tests reviewers reject: "Every behavioral change ships an automated test in the same PR. 'Verified manually', unnamed 'existing tests', and benchmarks don't count, even for one-liners." The 13 extern-C signature corrections across BunProcess.cpp, JSEnvironmentVariableMap.cpp, ZigSourceProvider.cpp, bindings.cpp, headers.h, virtual_machine_exports.rs, ffi_body.rs, expect.rs, and url/lib.rs are behavioral changes to the FFI ABI contract, and they now ship with zero regression coverage. Nothing in the tree prevents the next PR from re-introducing a mismatched declaration — which is the exact class of undefined behaviour this PR set out to eliminate. REVIEW.md also states "Never silently weaken, skip, or delete an existing test or safety net. Every deletion needs a stated reason or replacement" — the commit message "Delete slop test" states no reason and provides no replacement.

    Step-by-step proof

    1. git show --stat 31377e13test/internal/source-lints/extern-c-signatures.test.ts | 959 --- (part of this PR's range d3f975bd..31377e13 per the coderabbit metadata).
    2. ls test/internal/source-lints/ → 22 test files, extern-c-signatures.test.ts not among them.
    3. The 10-file net diff (workflow yml + 9 source files) contains no .test.ts.
    4. Suppose a future PR reintroduces extern "C" bool Bun__setExitCode(void*, uint8_t) in BunProcess.cpp (the pre-fix signature). The linker matches by name only, so it links; every C++ caller ignores the return value, so it passes existing runtime tests; and no lint exists to catch the mismatch. The invariant the PR title claims to enforce is unenforced.
    5. The earlier review comment on line 907 (the safe fn shapes entry pointing at URL__originLength, which is a plain fn, not safe fn) was resolved by deleting the whole file rather than by fixing the entry — the underlying feedback was never addressed.

    The workflow path-filter widening

    One correction to a possible reading of this finding: the added paths in .github/workflows/source-lints.yml (src/**/*.cpp, src/**/*.h, packages/bun-usockets/src/crypto/**) are not entirely dead. no-iostream-include.test.ts:25 already sets roots = ['src', 'packages/bun-uws', 'packages/bun-usockets'] and globs C++ headers/sources, so the widening incidentally closes a pre-existing under-triggering gap for that lint. However, the PR description's stated motivation for the widening — "widens .github/workflows/source-lints.yml's path filter to the C++ the lint reads" — refers to the deleted lint, so the rationale as written no longer applies.

    How to fix

    Either (a) restore extern-c-signatures.test.ts — addressing whatever made it "slop" (presumably size/complexity), plus fixing the safe fn shapes entry to point at an actual safe fn sibling like URL__protocol — or (b) if the deletion is intentional: drop "and lint for it" from the PR title, remove the test documentation from the PR description, and add targeted regression tests for the 13 signature changes so the PR meets REVIEW.md's automated-test requirement. The workflow widening can stay in either case (it benefits no-iostream-include.test.ts), but under option (b) its description-line rationale should be updated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants