Skip to content

FFI: passing C structs by value still segfaults on Linux x64 in v1.3.12 (blocks DuckDB and other mainstream C libraries) #29464

Closed as duplicate

Description

@shreeve

Summary

bun:ffi cannot invoke a C function whose signature takes a struct by value
on Linux x64. Any such call — including the primary query API of mainstream
libraries like DuckDB — segfaults at address 0x0 when declared through
dlopen().

This is the second of the two FFI bugs in #29346. The first (number as
'ptr' argument corruption, Fix 1) was resolved in v1.3.3 / #25045. The
second (struct-by-value, Fix 2) is still broken in v1.3.12 — re-verified
today, Apr 18, 2026.

Related: #6139 (enhancement, add ctypes-style struct construction — open
since Sep 2023). This issue is narrower and more immediate: existing C
functions with struct-by-value signatures should be callable through FFI
at all, regardless of whether Bun ever grows struct construction.

Current status

Target Result
Bun v1.3.12 (700fc117), Linux x64, kernel 6.17.0, glibc 2.39 Segfault at 0x0
Bun v1.3.12, macOS ARM64 Works — by ABI accident (AAPCS64 passes large structs via hidden pointer, matches 'ptr')
Deno FFI, Linux x64 Works — structs-by-value added in denoland/deno#15060 (2023)

Minimal reproduction

// test-lib.c — gcc -shared -fPIC -o libtest.so test-lib.c
#include <stdint.h>

typedef struct {
    uint64_t a, b, c, d, e, f;  // 48 bytes → MEMORY class on SysV AMD64
} big_struct;

uint64_t sum_struct(big_struct s) {
    return s.a + s.b + s.c + s.d + s.e + s.f;
}
// repro.mjs
import { dlopen, ptr } from 'bun:ffi';

// bun:ffi has no type that expresses "struct by value". 'ptr' is the
// closest approximation and happens to work on ARM64 by ABI accident.
const { symbols } = dlopen('./libtest.so', {
  sum_struct: { args: ['ptr'], returns: 'u64' },
});

const buf = new BigUint64Array([1n, 2n, 3n, 4n, 5n, 6n]);
console.log(symbols.sum_struct(ptr(buf)));  // expected: 21n

On Linux x64:

============================================================
Bun v1.3.12 (700fc117) Linux x64
Linux Kernel v6.17.0 | glibc v2.39
panic(main thread): Segmentation fault at address 0x0

On macOS ARM64: 21n.

Real-world impact

DuckDB — a mainstream analytics database used in production across many
projects — has three core chunk-fetching functions that take the 48-byte
duckdb_result by value:

  • duckdb_fetch_chunk(duckdb_result) — primary chunk-based query API
  • duckdb_result_get_chunk(duckdb_result, idx_t)
  • duckdb_result_chunk_count(duckdb_result)

Any Bun FFI binding for DuckDB on Linux requires shipping a separate C
shim library
that wraps these with struct * signatures. Our production
workaround is live here:

We re-verified today (Apr 18, 2026) that this workaround is still required:
parking the shim .so outside its search path causes an immediate null-deref
segfault; restoring it returns to 99/99 FFI tests passing. Raw output from
the experiment in the comment below.

Struct-by-value is the default for "return a small result with error detail"
in idiomatic C API design. This pattern is common enough that every mature
FFI layer handles it — Python's ctypes, Deno's FFI, Node's node-ffi-napi,
Rust's libloading. bun:ffi is the outlier.

Proposed minimum fix

Even without full composite-struct support (#6139), Bun could accept a
"struct by size + alignment" descriptor so the marshaler can lay the
argument out per-ABI:

dlopen('./libtest.so', {
  sum_struct: {
    args: [{ type: 'struct', size: 48, align: 8 }],
    returns: 'u64',
  },
});

The caller supplies a pointer or a Uint8Array/BigUint64Array of the
right size; Bun copies it into the argument location expected by the ABI
(stack for MEMORY-class on SysV AMD64, registers for smaller cases, X8 for
hidden pointer on AAPCS64). Full ctypes-style field descriptors can
follow later — this unblocks real bindings immediately.

Environment

Bun v1.3.12 (700fc117) Linux x64
Linux Kernel v6.17.0 | glibc v2.39
CPU: sse42 popcnt avx avx2 avx512
DuckDB 1.5.2

Related: #6139, #29346, #13910

Activity

  1. shreeve commented on Apr 18, 2026

    @shreeve
    Author

    Raw output from today's re-verification (Apr 18, 2026), for the record.

    Setup: Fresh Linux x64 environment, Bun 1.3.12, DuckDB 1.5.2 installed at /home/shreeve/.duckdb/1.5.2/libduckdb.so, shim available at /home/shreeve/.duckdb/1.5.2/libduckdb-shim.so.

    With shim in place (fallback not taken):

    $ bun test packages/db
    bun test v1.3.12 (700fc117)
    ...
     99 pass
     0 fail
     192 expect() calls
    Ran 99 tests across 2 files. [242.00ms]
    

    Shim parked outside the search path (forces direct FFI call to duckdb_fetch_chunk):

    $ mv /home/shreeve/.duckdb/1.5.2/libduckdb-shim.so /tmp/libduckdb-shim.so.parked
    $ bun test packages/db
    bun test v1.3.12 (700fc117)
    
    packages/db/test/duckdb-ffi.test.mjs:
    (pass) lifecycle > open and close a database [11.00ms]
    (pass) lifecycle > connect and disconnect [9.00ms]
    (pass) lifecycle > version returns a string
    ============================================================
    Bun v1.3.12 (700fc117) Linux x64
    Linux Kernel v6.17.0 | glibc v2.39
    CPU: sse42 popcnt avx avx2 avx512
    Args: "bun" "test" "packages/db"
    Features: Bun.stderr(2) Bun.stdin(2) Bun.stdout(2) bunfig jsc transpiler_cache(5)
    Builtins: "bun:ffi" "bun:test" "node:fs" "node:process" "node:url"
    Elapsed: 148ms | User: 105ms | Sys: 33ms
    RSS: 1.11GB | Peak: 86.48MB | Commit: 1.11GB | Faults: 0 | Machine: 33.65GB
    
    panic(main thread): Segmentation fault at address 0x0
    oh no: Bun has crashed. This indicates a bug in Bun, not your code.
    

    Crashes mid-suite on the first query that triggers duckdb_fetch_chunk. Same signature on the standalone 100k-row stress query:

    $ bun -e "
      const { open } = await import('./packages/db/lib/duckdb.mjs');
      const db = open(':memory:');
      const conn = db.connect();
      const rows = await conn.query('SELECT i FROM range(100000) t(i)');
      ...
    "
    ...
    panic(main thread): Segmentation fault at address 0x0
    

    Restoring the shim returns to 99/99 passing with no code change:

    $ mv /tmp/libduckdb-shim.so.parked /home/shreeve/.duckdb/1.5.2/libduckdb-shim.so
    $ bun test packages/db 2>&1 | tail -3
     0 fail
     192 expect() calls
    Ran 99 tests across 2 files. [227.00ms]
    

    The shim is genuinely load-bearing on Bun 1.3.12 + Linux x64. Happy to rerun on a future release when this is addressed.

  2. github-actions commented on Apr 18, 2026

    @github-actions
    Contributor

    Found 1 possible duplicate issue:

    1. Bun FFI does not support functions with return types bigger than register size, causing UB #21441

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  3. shreeve commented on Apr 18, 2026

    @shreeve
    Author

    Thanks for the check, but #21441 and this issue are sister bugs of the same root cause, not duplicates. They hit different ABI positions and different code paths in the FFI marshaler.

    #21441 — large struct return values. Function signature like Foo Bar(...) where Foo is 32 bytes. The caller-side ABI allocates a hidden out-pointer (SRET slot on SysV AMD64 / X8 on AAPCS64); the callee writes the struct there and RAX / X0 is the same out-pointer. Bun's returns: 'ptr' doesn't describe this, so the caller reads whatever happens to be in the return register while the real data landed in the out-slot. Symptom: corrupted data, no crash.

    #29464 (this issue) — large struct arguments. Function signature like duckdb_fetch_chunk(duckdb_result) where the struct is 48 bytes. SysV AMD64 classifies this as MEMORY and requires the caller to copy the struct onto the stack before the call; Bun's args: ['ptr'] puts a pointer in a register instead, so the callee reads garbage from the stack location the ABI expects. Symptom: hard null-deref segfault.

    Different ABI rules, different marshaler paths, different symptoms. Fixing the SRET-return case doesn't fix the MEMORY-class argument case, and vice versa — though a single struct-descriptor API in dlopen() could address both at once.

    Both bugs are subordinate to #6139 as the umbrella feature. Keeping them as sibling issues rather than merging them means the argument-side and return-side fixes can land independently, and anyone searching for "FFI crash passing struct" vs "FFI garbage returning struct" lands on the right bug.

    Requesting this stay open.

  4. Jarred-Sumner commented on Apr 18, 2026

    @Jarred-Sumner
    Collaborator

    Not supported yet

  5. Mte90 commented on May 6, 2026

    @Mte90

    I think that is the same issue on #18742

    That is generating issues with OpenCode only on linux 64 (but some old reports are also for OSX) so right now is unusable.

  6. gretel commented on May 6, 2026

    @gretel

    That is generating issues with OpenCode only on linux 64 (but some old reports are also for OSX) so right now is unusable.

    ran into this just yesterday on macos. regards

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions