Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 2 additions & 7 deletions scripts/build/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1201,13 +1201,8 @@ export const linkerFlags: Flag[] = [
},
{
flag: ["-static-libstdc++", "-static-libgcc"],
when: c => c.linux && c.abi === "gnu",
desc: "Static C++ runtime (don't depend on host libstdc++)",
},
{
flag: ["-lstdc++", "-lgcc"],
when: c => c.linux && c.abi === "musl",
desc: "Dynamic C++ runtime on musl (static unavailable)",
when: c => c.linux && (c.abi === "gnu" || c.abi === "musl"),
desc: "Static C++ runtime (don't depend on host libstdc++/libgcc_s)",
},
{
flag: c => [
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ extern "C" void Bun__unlink(const char*, size_t);
extern "C" void CrashHandler__setDlOpenAction(const char* action);
extern "C" bool Bun__VM__allowAddons(void* vm);
extern "C" int32_t Bun__addonNeedsGlibcOnMusl(const char* path, size_t len, char* soname_out, size_t soname_cap);
extern "C" void Bun__loadMuslCxxRuntimeIfPresent();

JSC_DEFINE_HOST_FUNCTION(Process_functionDlopen, (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame))
{
Expand Down Expand Up @@ -564,6 +565,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionDlopen, (JSC::JSGlobalObject * globalOb
return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, msg.toString());
}
}
Bun__loadMuslCxxRuntimeIfPresent();
#endif
CrashHandler__setDlOpenAction(utf8.data());
void* handle = dlopen(utf8.data(), RTLD_LAZY);
Expand Down
37 changes: 36 additions & 1 deletion src/runtime/napi/libc_check.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
//! Pre-`dlopen` libc-mismatch detection for native addons on Linux.
//! Native-addon loader preflight helpers for Linux.
//!
//! A glibc-linked `.node` loaded into a musl process (typically via Alpine's
//! `gcompat` shim, which satisfies the `libc.so.6` soname but not the ABI)
//! segfaults inside the dynamic loader during relocation. That crash is not
//! catchable from JS and looks like a Bun bug. Instead, inspect the addon's
//! ELF `PT_DYNAMIC` segment before calling `dlopen` and surface a
//! `ERR_DLOPEN_FAILED` that names the problem. See issue #15753.
//!
//! On Linux-musl, this module also performs a one-time best-effort load of the
//! optional host C++ runtime into the process-global namespace before addon
//! `dlopen`.

use core::ffi::c_char;

/// A musl Bun embeds its own C++ runtime so the executable can start without
/// Alpine's `libstdc++` and `libgcc` packages. Before the first native addon,
/// opportunistically restore the shared runtime provider that musl addons
/// historically inherited from Bun's startup dependencies. Keeping the
/// handle open for the process lifetime makes its symbols available to this
/// and later addons. Missing packages are not an error here: self-contained
/// addons may still load, and `process.dlopen` reports the original loader
/// error for addons which need them.
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__loadMuslCxxRuntimeIfPresent() {
#[cfg(all(target_os = "linux", target_env = "musl"))]
{
static LOAD: std::sync::Once = std::sync::Once::new();
LOAD.call_once(|| {
// SAFETY: the name is NUL-terminated. The successful handle is
// intentionally retained for the lifetime of the process.
let handle = unsafe {
libc::dlopen(
c"libstdc++.so.6".as_ptr(),
libc::RTLD_NOW | libc::RTLD_GLOBAL,
)
};
if handle.is_null() {
// SAFETY: consume the error from this best-effort probe so it
// cannot leak into the addon's own loader/error handling.
let _ = unsafe { libc::dlerror() };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}
}

/// Called from `Process_functionDlopen` (BunProcess.cpp) immediately before
/// `dlopen`. Returns `1` when the file at `path_ptr[..path_len]` is an ELF
/// shared object whose `DT_NEEDED` list references glibc and this process is
Expand Down
42 changes: 42 additions & 0 deletions test/internal/source-lints/musl-static-cxx-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import type { Config } from "../../../scripts/build/config.ts";
import { computeFlags } from "../../../scripts/build/flags.ts";

const config = (arm64: boolean) =>
({
os: "linux",
arch: arm64 ? "aarch64" : "x64",
linux: true,
unix: true,
darwin: false,
windows: false,
freebsd: false,
abi: "musl",
x64: !arm64,
arm64,
release: true,
debug: false,
asan: false,
valgrind: false,
fuzzilli: false,
lto: false,
canary: true,
ci: true,
buildkite: false,
cwd: "/repo",
buildDir: "/repo/build/release",
ld: "/usr/bin/ld.lld",
crossTarget: undefined,
}) as Config;

test.each([
["x64", false],
["aarch64", true],
] as const)("linux-musl-%s embeds the C++ runtime", (_, arm64) => {
const flags = computeFlags(config(arm64)).ldflags;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

expect(flags).toContain("-static-libstdc++");
expect(flags).toContain("-static-libgcc");
expect(flags).not.toContain("-lstdc++");
expect(flags).not.toContain("-lgcc");
});
24 changes: 24 additions & 0 deletions test/regression/issue/29681-cxx-exception-addon.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#include "node_api.h"

struct ValidationException {
int value;
};

static volatile int validation_seed = 41;

static int throw_and_catch() {
try {
throw ValidationException{validation_seed};
} catch (const ValidationException &exception) {
return exception.value + 1;
}
}

NAPI_MODULE_INIT() {
napi_value caught;
if (napi_create_int32(env, throw_and_catch(), &caught) != napi_ok)
return nullptr;
if (napi_set_named_property(env, exports, "caught", caught) != napi_ok)
return nullptr;
return exports;
}
24 changes: 24 additions & 0 deletions test/regression/issue/29681-cxx-runtime-addon.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#include "node_api.h"

/*
* Model a legacy native addon which expects the host process to provide the
* C++ runtime. The final link intentionally omits libstdc++, leaving these
* relocations for process.dlopen() to resolve from the global loader scope.
*/
extern void *cxx_operator_new(size_t size) __asm__("_Znwm");
extern void cxx_operator_delete(void *ptr) __asm__("_ZdlPv");

static void *(*volatile cxx_operator_new_ptr)(size_t) = cxx_operator_new;
static void (*volatile cxx_operator_delete_ptr)(void *) = cxx_operator_delete;

NAPI_MODULE_INIT() {
void *allocation = cxx_operator_new_ptr(32);
cxx_operator_delete_ptr(allocation);

napi_value loaded;
if (napi_get_boolean(env, true, &loaded) != napi_ok)
return NULL;
if (napi_set_named_property(env, exports, "loaded", loaded) != napi_ok)
return NULL;
return exports;
}
150 changes: 150 additions & 0 deletions test/regression/issue/29681.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isMusl, tempDir } from "harness";
import { join } from "node:path";

const cc = process.env.CC || Bun.which("cc") || Bun.which("clang") || Bun.which("gcc");
const cxx = process.env.CXX || Bun.which("c++") || Bun.which("clang++") || Bun.which("g++");
const readelf = Bun.which("readelf");

function hasOptionalCxxRuntimeProvider(): boolean {
if (!isMusl) return false;

try {
const probe = Bun.spawnSync({
cmd: [
bunExe(),
"-e",
`import { dlopen } from "bun:ffi"; const library = dlopen("libstdc++.so.6", { _Znwm: { args: ["usize"], returns: "ptr" } }); library.close();`,
],
env: bunEnv,
stdout: "ignore",
stderr: "ignore",
});
return probe.exitCode === 0;
} catch {
return false;
}
}

const hasCxxRuntimeProvider = hasOptionalCxxRuntimeProvider();

interface AddonFixture {
compiler: string;
compilerFlags?: string[];
expectedResult: boolean | number;
expectedSymbols: string[];
resultProperty: string;
source: string;
}

async function compileAndLoadAddon(fixture: AddonFixture) {
using dir = tempDir("issue-29681", {
"load.js": `
const { readFileSync } = require("node:fs");
const runtimeMaps = () => readFileSync("/proc/self/maps", "utf8")
.split("\\n")
.filter(line => line.includes("libstdc++.so.6") || line.includes("libgcc_s.so.1"));

const before = runtimeMaps();
const addon = require("./addon.node");
const result = addon[${JSON.stringify(fixture.resultProperty)}];
const after = runtimeMaps();
console.log(JSON.stringify({ before, result, after }));
`,
});
const dirPath = String(dir);
const addonPath = join(dirPath, "addon.node");
const fixturePath = join(import.meta.dir, fixture.source);
const napiInclude = join(import.meta.dir, "..", "..", "..", "src", "runtime", "napi");

await using compile = Bun.spawn({
cmd: [
fixture.compiler,
...(fixture.compilerFlags ?? []),
"-shared",
"-fPIC",
"-nostdlib",
"-Wl,-z,now",
"-I",
napiInclude,
"-o",
addonPath,
fixturePath,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [compileStdout, compileStderr, compileExitCode] = await Promise.all([
compile.stdout.text(),
compile.stderr.text(),
compile.exited,
]);
expect({ stdout: compileStdout, stderr: compileStderr, exitCode: compileExitCode }).toEqual({
stdout: "",
stderr: "",
exitCode: 0,
});

const dynamic = Bun.spawnSync([readelf!, "-d", addonPath]);
const symbols = Bun.spawnSync([readelf!, "-Ws", addonPath]);
expect({ stderr: dynamic.stderr.toString(), exitCode: dynamic.exitCode }).toEqual({ stderr: "", exitCode: 0 });
expect({ stderr: symbols.stderr.toString(), exitCode: symbols.exitCode }).toEqual({ stderr: "", exitCode: 0 });
expect(dynamic.stdout.toString()).not.toContain("NEEDED");
for (const symbol of fixture.expectedSymbols) {
expect(symbols.stdout.toString()).toContain(`UND ${symbol}`);
}

await using run = Bun.spawn({
cmd: [bunExe(), join(dirPath, "load.js")],
cwd: dirPath,
env: { ...bunEnv, LD_PRELOAD: undefined },
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
before: [],
result: fixture.expectedResult,
after: expect.arrayContaining([
expect.stringContaining("libstdc++.so.6"),
expect.stringContaining("libgcc_s.so.1"),
]),
});
expect(exitCode).toBe(0);
}

// The compatibility provider is optional, so clean musl developer images may not have it.
test.skipIf(!isMusl || !cc || !readelf || !hasCxxRuntimeProvider)(
"a legacy new/delete addon can use the optional host C++ runtime",
() =>
compileAndLoadAddon({
compiler: cc!,
expectedResult: true,
expectedSymbols: ["_Znwm", "_ZdlPv"],
resultProperty: "loaded",
source: "29681-cxx-runtime-addon.c",
}),
);

test.skipIf(!isMusl || !cxx || !readelf || !hasCxxRuntimeProvider)(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"a legacy exception addon can use the optional host C++ runtime",
() =>
compileAndLoadAddon({
compiler: cxx!,
compilerFlags: ["-O0", "-fexceptions"],
expectedResult: 42,
expectedSymbols: [
"__cxa_allocate_exception",
"__cxa_throw",
"__cxa_begin_catch",
"__cxa_end_catch",
"__gxx_personality_v0",
"_Unwind_Resume",
"_ZTVN10__cxxabiv117__class_type_infoE",
],
resultProperty: "caught",
source: "29681-cxx-exception-addon.cpp",
}),
);