Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
45 changes: 45 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,45 @@
import { expect, test } from "bun:test";
import type { Config } from "../../../scripts/build/config.ts";
import { computeFlags } from "../../../scripts/build/flags.ts";

const config = (abi: "gnu" | "musl", arm64: boolean) =>
({
os: "linux",
arch: arm64 ? "aarch64" : "x64",
linux: true,
unix: true,
darwin: false,
windows: false,
freebsd: false,
abi,
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",
rustLld: undefined,
crossTarget: undefined,
}) as Config;

test.each([
["gnu", "x64", false],
["gnu", "aarch64", true],
["musl", "x64", false],
["musl", "aarch64", true],
] as const)("linux-%s-%s embeds the C++ runtime", (abi, _, arm64) => {
const flags = computeFlags(config(abi, arm64)).ldflags;

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;
}
165 changes: 165 additions & 0 deletions test/regression/issue/29681.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// https://github.com/oven-sh/bun/issues/29681
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();

test.skipIf(!isMusl || !readelf)("bun does not depend on the host C++ runtime", () => {
const dynamic = Bun.spawnSync([readelf!, "-d", bunExe()]);
const stdout = dynamic.stdout.toString();
const forbiddenDependency = stdout.match(/NEEDED.*(?:libstdc\+\+\.so\.6|libgcc_s\.so\.1)/)?.[0] ?? "";
expect({ forbiddenDependency, stderr: dynamic.stderr.toString(), exitCode: dynamic.exitCode }).toEqual({
forbiddenDependency: "",
stderr: "",
exitCode: 0,
});
});

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]);
let parsedStdout: unknown = stdout;
try {
parsedStdout = JSON.parse(stdout);
} catch {}
expect({ parsedStdout, stderr, exitCode }).toEqual({
parsedStdout: {
before: [],
result: fixture.expectedResult,
after: expect.arrayContaining([expect.stringContaining("libstdc++.so.6")]),
},
stderr: "",
exitCode: 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",
}),
);