Skip to content

rust/driver_manager: RTLD_LAZY turns an unloadable driver into a SIGSEGV at 0x0 instead of a load error #4663

Description

@thejens

Hi! Whilst working with dbt's ADBC integration, me and Claude bumped into this issue. Below report is written by Claude, but I have verified it seems to be a real issue.


What happened?

The Rust driver manager opens driver libraries with RTLD_LAZY, so a driver whose own dependency symbols cannot be resolved loads successfully. The unresolved symbols are bound at first call instead, and for a driver linked with -undefined dynamic_lookup (flat namespace) they bind to NULL — so the process jumps to address 0 and dies with SIGSEGV, arbitrarily far from the load site and with no diagnostic.

DriverLibrary::load_library passes the flags here:

libloading::os::unix::Library::open(
    Some(filename.as_ref()),
    libloading::os::unix::RTLD_LAZY | libloading::os::unix::RTLD_LOCAL | RTLD_NODELETE,
)

The error handling here is fine — the dlopen result is checked. The problem is purely that RTLD_LAZY defers the failure past the point where it could be reported. The same dlopen with RTLD_NOW returns a precise, actionable error naming the missing symbol.

The two differ only in that one flag, on the identical library:

flag dlopen result outcome
RTLD_NOW fails symbol not found in flat namespace '_OPENSSL_sk_num'
RTLD_LAZY succeeds SIGSEGV at 0x0 on first call

This matters more for a driver manager than for a typical dlopen caller: the whole point is loading third-party plugins whose link-time hygiene you do not control, and RTLD_LAZY converts "this driver is broken" into "your process crashed somewhere unrelated".

Real-world instance

A PostgreSQL driver built with -undefined dynamic_lookup on macOS ARM64 leaves the OpenSSL symbols libpq needs unresolved. ManagedDriver::load_dynamic_from_filename returns Ok, AdbcDriverInit is resolved and called, and the process dies at the first OPENSSL_* call inside libpq. lldb shows EXC_BAD_ACCESS (code=1, address=0x0) with frame #0: 0x0000000000000000 — no symbol, no backtrace, nothing pointing at the driver.

Because the crash surfaces at the first connection attempt rather than at load, this reads as a bug in whatever was executing at the time. It took a dlopen probe outside the process to identify the real cause.

Stack Trace

* thread #2, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
    frame #0: 0x0000000000000000
error: memory read failed for 0x0

How can we reproduce the bug?

Self-contained; no ADBC build required. It isolates the flag choice, which is the whole of the bug.

fake_driver.c — stands in for any driver linked with -undefined dynamic_lookup that left a dependency symbol unresolved:

extern int OPENSSL_sk_num(const void *);

int AdbcDriverInit(int version, void *raw_driver, void *error) {
    (void)version; (void)raw_driver; (void)error;
    return OPENSSL_sk_num(0);   /* unresolved at load time */
}

loader.c — the driver manager's flags vs. strict ones:

#include <dlfcn.h>
#include <stdio.h>
int main(int argc, char **argv) {
    int lazy = (argc > 2 && argv[2][0] == 'l');
    int flags = (lazy ? RTLD_LAZY : RTLD_NOW) | RTLD_LOCAL;
    printf("dlopen(%s)\n", lazy ? "RTLD_LAZY" : "RTLD_NOW");
    void *h = dlopen(argv[1], flags);
    if (!h) { printf("  -> load error (clean): %s\n", dlerror()); return 0; }
    printf("  -> load reported SUCCESS\n");
    int (*init)(int, void *, void *) = (int (*)(int, void *, void *))dlsym(h, "AdbcDriverInit");
    printf("  -> calling AdbcDriverInit ...\n"); fflush(stdout);
    init(1000000, 0, 0);
    return 0;
}
cc -dynamiclib -o libfake_driver.dylib fake_driver.c \
   -Wl,-undefined,dynamic_lookup -Wl,-flat_namespace
cc -o loader loader.c

# what a strict loader would do
./loader ./libfake_driver.dylib now;  echo "exit=$?"
# what the driver manager does
./loader ./libfake_driver.dylib lazy; echo "exit=$?"

Output:

dlopen(RTLD_NOW)
  -> load error (clean): dlopen(./libfake_driver.dylib, 0x0006): symbol not found in flat namespace '_OPENSSL_sk_num'
exit=0

dlopen(RTLD_LAZY)
  -> load reported SUCCESS
  -> calling AdbcDriverInit ...
exit=139        # SIGSEGV

Suggested fix

Use RTLD_NOW in load_library, so an unloadable driver is reported as a load error rather than becoming a latent crash. The existing map_err(libloading_error_to_adbc_error) then surfaces the linker's own message, which already names the offending symbol.

RTLD_NODELETE and RTLD_LOCAL are unaffected — the reasons documented in the comment above those lines (the Go runtime not honouring -z nodelete on macOS) are orthogonal to eager vs. lazy binding.

If eager binding is deliberate for startup cost, an alternative is to keep RTLD_LAZY but retry with RTLD_NOW when a load succeeds, so the diagnostic is at least available — though that pays the cost twice and still leaves the default path crashing.

Environment/Setup

  • macOS 15 (Darwin 25.6.0), ARM64
  • adbc_driver_manager (Rust) 0.22.0
  • Also present on apache/arrow-adbc main at 00dfb8b9c0f776940781ea42c5b32b6bf3618652 — the flags are unchanged there.

The -undefined dynamic_lookup linkage that triggers it is a property of how a given driver binary was built, not of ADBC itself; the report here is about the driver manager turning that into an undiagnosable crash rather than a load error.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions