Skip to content
74 changes: 74 additions & 0 deletions test/regression/issue/29346.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { dlopen, read as ffiRead, FFIType, ptr } from "bun:ffi";
import { jscDescribe } from "bun:jsc";
import { expect, test } from "bun:test";
import { isLinux, tempDir } from "harness";
import { join } from "node:path";

// https://github.com/oven-sh/bun/issues/29346
//
// Exercises the Int32 branch of `JSVALUE_TO_PTR`: open → read the handle
// back with `ffiRead.ptr` (which picks an Int32 JSValue when the pointer
// fits in 32 bits) → pass it in as a `ptr` arg. Linux-only because
// landing a pointer inside the first 2 GiB needs `MAP_FIXED_NOREPLACE`.
test.skipIf(!isLinux)("JS number argument marshals correctly as a `ptr`", async () => {
using dir = tempDir("issue-29346", {
"lib.c": `\
#include <sys/mman.h>
#include <unistd.h>

// Write a pointer into the low 2 GiB and store the magic 0xDEADBEEF at it.
int open_handle(void **out) {
size_t pagesize = getpagesize();
char *attempt = (char *)(1 << 20);
void *mapping = MAP_FAILED;
for (int i = 0; i < 400 && mapping == MAP_FAILED;
i++, attempt += 64 * pagesize) {
mapping = mmap((void *)attempt, pagesize, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
}
if (mapping == MAP_FAILED) { *out = 0; return -1; }
*((unsigned int *)mapping) = 0xDEADBEEFu;
*out = mapping;
return 0;
}

// Read the u32 at \`handle\`. Returns 0xDEADBEEF when the caller passed the
// correct pointer; a corrupted handle segfaults here.
unsigned int read_handle(void *handle) {
if (!handle) return 0;
return *((unsigned int *)handle);
}
`,
});

const libPath = join(String(dir), "lib.so");
await using compiler = Bun.spawn({
cmd: ["cc", "-shared", "-fPIC", "-o", libPath, "lib.c"],
cwd: String(dir),
stderr: "pipe",
});
const cErr = await compiler.stderr.text();
const cExit = await compiler.exited;
if (cExit !== 0) expect(cErr).toBe("");
expect(cExit).toBe(0);
Comment thread
robobun marked this conversation as resolved.

const { symbols } = dlopen(libPath, {
open_handle: { args: [FFIType.ptr], returns: FFIType.i32 },
read_handle: { args: [FFIType.ptr], returns: FFIType.u32 },
});
Comment on lines +55 to +58

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.

🟡 The dlopen handle is silently discarded: const { symbols } = dlopen(...) drops the Library object, so .close() (which invokes dlclose()) can never be called. This is a minor resource-leak inconsistency — the underlying shared library stays mapped until process exit instead of being explicitly released.

Extended reasoning...

What the bug is

dlopen in bun:ffi returns an object with both symbols and a .close() method. Line 55 destructures only symbols, immediately dropping the Library handle:

const { symbols } = dlopen(libPath, { ... });

Because no reference to the Library object is retained, .close() can never be called and the underlying dlclose() is never invoked. The shared library's memory mapping and file descriptor remain open until the process exits.

The specific code path

bun:ffi's dlopen() returns { symbols, close() { ... } }. The close() method calls the native dlclose() to release the library handle. Without storing the return value, there is simply no way to reach that method from anywhere in the test.

Why existing code does not prevent it

JavaScript destructuring silently discards any properties not listed in the pattern — no warning, no error. Bun's test runner does not enforce that dlopen handles must be closed, and the process-exit finalizer eventually releases everything at the OS level.

Addressing the refutations

Both refutations correctly note that (1) this is a single-shot Linux-only test running in its own process, (2) the OS unconditionally reclaims all FDs and mappings on exit, and (3) addr32.test.ts in the same directory uses the identical destructuring pattern — so this is already established local convention. These points are well-taken and explain why the severity is nit, not normal. The speculative concern about multiple test-runner iterations does not apply to Bun's architecture. The practical impact is zero.

What the impact would be

No functional test failure, no correctness issue. The using dir = tempDir(...) cleanup also succeeds on Linux regardless of open mappings because Linux allows unlinking files that are still mapped. This is purely a style inconsistency with the cc.test.ts convention of closing handles in afterAll.

How to fix

Store the full return value and close the handle at the end of the test:

const lib = dlopen(libPath, {
  open_handle: { args: [FFIType.ptr], returns: FFIType.i32 },
  read_handle: { args: [FFIType.ptr], returns: FFIType.u32 },
});
const { symbols } = lib;
// ... test body ...
lib.close();

Alternatively, add an afterAll hook as cc.test.ts does.

Step-by-step proof

  1. Line 55: const { symbols } = dlopen(libPath, { ... }) — the returned Library object is not assigned to any variable.
  2. The Library object is immediately eligible for garbage collection; no reference exists.
  3. .close() is a method on the Library object, not on symbols, so symbols.close is undefined.
  4. When the test completes, dlclose() is never called; the shared library mapping persists until process exit.
  5. On Linux, when the temp-dir cleanup runs (using dir = tempDir(...)), lib.so is unlinked successfully — the file disappears from the directory even though it is still mapped — so no test failure occurs. The OS releases the mapping on exit.


const outBuf = new Uint8Array(8);
expect(symbols.open_handle(ptr(outBuf))).toBe(0);

const handle = ffiRead.ptr(ptr(outBuf), 0);
expect(handle).toBeGreaterThan(0);
expect(handle).toBeLessThan(2 ** 31);
// Confirm we're actually exercising the Int32 marshaling path — if the
// handle ever got boxed as a double the test would silently pass even on
// broken builds.
expect(jscDescribe(handle)).toContain("Int32");

// Pre-fix this segfaulted at 0xFFFFFFFFFFFFFFFF. Post-fix the pointer
// round-trips and the callee reads back the magic word we wrote.
expect(symbols.read_handle(handle)).toBe(0xdeadbeef);
});
Loading