diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index d1ab66b6c8d6..dc8ac14b07a1 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -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 => [ diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 10210178ea06..9d05ba00d493 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -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)) { @@ -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); diff --git a/src/runtime/napi/libc_check.rs b/src/runtime/napi/libc_check.rs index 1fee058550ef..bb4121045618 100644 --- a/src/runtime/napi/libc_check.rs +++ b/src/runtime/napi/libc_check.rs @@ -1,4 +1,4 @@ -//! 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) @@ -6,9 +6,44 @@ //! 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() }; + } + }); + } +} + /// 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 diff --git a/test/internal/source-lints/musl-static-cxx-runtime.test.ts b/test/internal/source-lints/musl-static-cxx-runtime.test.ts new file mode 100644 index 000000000000..3a96dcc04e95 --- /dev/null +++ b/test/internal/source-lints/musl-static-cxx-runtime.test.ts @@ -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"); +}); diff --git a/test/regression/issue/29681-cxx-exception-addon.cpp b/test/regression/issue/29681-cxx-exception-addon.cpp new file mode 100644 index 000000000000..9a0a0d4eccd1 --- /dev/null +++ b/test/regression/issue/29681-cxx-exception-addon.cpp @@ -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; +} diff --git a/test/regression/issue/29681-cxx-runtime-addon.c b/test/regression/issue/29681-cxx-runtime-addon.c new file mode 100644 index 000000000000..491b2a785138 --- /dev/null +++ b/test/regression/issue/29681-cxx-runtime-addon.c @@ -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; +} diff --git a/test/regression/issue/29681.test.ts b/test/regression/issue/29681.test.ts new file mode 100644 index 000000000000..530a7a92affb --- /dev/null +++ b/test/regression/issue/29681.test.ts @@ -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)( + "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", + }), +);