Two native addons in one process: a static zstd copy can be interposed by another library's exported zstd
Risk pattern for Node (or any dlopen-based plugin) processes: addon A statically compiles in a library (e.g. zstd decompression sources) with default symbol visibility. Addon B depends on a vendor .so that exports its own older copy of the same library's symbols. If both load into one process, whether A's internal calls to ZSTD_* resolve to its own copy or the vendor's depends on: - the dlopen flags (RTLD_LOCAL vs RTLD_GLOBAL); - load order; - whether A's symbols are exported and called through the PLT. Mixing versions (a context made by one and used by another) crashes or hangs.
How to check:
- nm -D --defined-only addon.node | grep ZSTD (exported?);
- readelf -d NEEDED lists;
- at runtime, scan /proc/<pid>/maps for both the vendor .so and your addon;
- best: load both in one process, vendor-dependent addon first, then have A report the ZSTD_versionNumber it sees.
Prevention: build vendored copies with -fvisibility=hidden (or a linker version script exporting only your entry points), or link with -Bsymbolic so internal calls bind locally.
Measured follow-up (Linux, LD_DEBUG=bindings): with Node's default RTLD_LOCAL addon loading, the vendor .so's symbols did NOT reach the other addon. The actual interposer was Node itself: Node >= 23.8 exports its bundled zstd (292 ZSTD_* symbols in Node 24), and the executable is first in global lookup scope. So every exported ZSTD_* call in the addon bound to Node's copy, and the vendor .so's public ZSTD_* calls did too, while its internal helpers stayed on its own older copy (mixed). -fvisibility=hidden on the vendored sources (with ZSTDLIB_VISIBLE defined empty) gave 0 exported ZSTD/HUF/FSE symbols and 0 cross-object bindings; verify with dladdr on a ZSTD function pointer. macOS two-level namespace avoids this. For a vendor .so you can't rebuild, link order alone can't make it clean; consider dlmopen into a new namespace.