Skip to content

emit an -rpath for tbb in LdFlags() on macos - #271

Merged
kevinushey merged 2 commits into
masterfrom
bugfix/macos-tbb-rpath
Jul 28, 2026
Merged

emit an -rpath for tbb in LdFlags() on macos#271
kevinushey merged 2 commits into
masterfrom
bugfix/macos-tbb-rpath

Conversation

@kevinushey

Copy link
Copy Markdown
Contributor

Fixes #209.

The TBB libraries we ship on macOS record an @rpath-relative install name
(@rpath/libtbb.dylib), but LdFlags() emitted only a -L search path. That
satisfies the linker -- downstream packages build fine -- but the resulting
binary carries no LC_RPATH at all, so dyld can resolve TBB only when
RcppParallel has already pulled it into the process. That is why the
importFrom(RcppParallel, RcppParallelLibs) requirement works in practice, and
why anything loading its shared object without it fails:

Library not loaded: @rpath/libtbb.dylib
Reason: tried: '/Library/Frameworks/R.framework/Resources/lib/libtbb.dylib' (no such file), ...

This pairs the -L with a matching -rpath, using the same format string as
the TBB_LIB branch just above, which already did this.

Verification

Built a small dylib against an installed RcppParallel using the flags
RcppParallel::LdFlags() reports, then dyn.load()ed it:

before after
with RcppParallel loaded ok ok
without RcppParallel loaded Library not loaded: @rpath/libtbb.dylib ok

tests/test-ld-flags.R asserts the invariant that every -L directory is also
emitted as an -rpath; it fails against the unpatched package and passes here.
The rest of the suite passes.

Note on stale rpaths

The rpath is an absolute path resolved at build time, so a binary package built
where RcppParallel lived elsewhere ends up with an rpath that does not resolve
on the target machine. That is harmless -- dyld skips it and falls back to
matching the already-loaded image, i.e. today's behaviour (checked by linking a
probe with only a bogus rpath). A @loader_path-relative rpath would avoid
staleness but assumes both packages sit in the same library directory, which is
not guaranteed.

The TBB libraries RcppParallel ships on macOS record an '@rpath'-relative
install name, but LdFlags() emitted only a '-L' search path. That satisfies
the linker, so downstream packages built fine, but the resulting binary
carried no LC_RPATH at all: dyld could resolve '@rpath/libtbb.dylib' only
when RcppParallel -- and hence TBB -- had already been loaded into the
process. Loading such a binary on its own failed with "Library not loaded:
@rpath/libtbb.dylib".

Pair the '-L' with a matching '-rpath', as the TBB_LIB branch just above
already does. A stale rpath (e.g. in a binary package built where
RcppParallel lived at a different path) is harmless: dyld skips a path that
does not resolve and falls back to matching the already-loaded image, which
is the current behaviour.

Fixes #209.
The flag-level check only asserts that LdFlags() looks right. Add a test
that exercises the behaviour: build a package against those flags, then
load its shared object from a fresh R process that never loads
RcppParallel.

That last part is the whole point. Loading such a package the usual way
goes through its NAMESPACE, whose importFrom(RcppParallel, ...) pulls TBB
into the process first and so succeeds either way -- as does the install,
since the missing '-rpath' only affects load time, not linking. Only a
direct dyn.load() in a clean process distinguishes the two, failing with
"Library not loaded: @rpath/libtbb.dylib" without the fix.

The test is CI-gated, as it needs a toolchain, and runs only where TBB is
linked into the client explicitly (macOS, or a system TBB); elsewhere the
symbols are deliberately resolved from the libraries RcppParallel loads.
That gate mirrors the branches of tbbLdFlags() rather than inspecting its
output, since gating on the '-rpath' under test would skip the very
configuration that regressed.
@kevinushey

Copy link
Copy Markdown
Contributor Author

Added tests/test-downstream-load.R, which exercises the fix rather than the shape of the flags: it builds a package against RcppParallel::RcppParallelLibs(), then loads its shared object from a fresh R process that never loads RcppParallel.

That last part is what makes it a real test. Both the install and a normal loadNamespace() succeed with or without the fix -- the install only needs the -L, and the namespace's importFrom(RcppParallel, ...) pulls TBB into the process before the shared object is loaded. Only a direct dyn.load() in a clean process tells them apart:

# against the unpatched package
Error in dyn.load(".../downstreamtest.so") :
  dlopen(...): Library not loaded: @rpath/libtbb.dylib
  Reason: tried: '/Library/Frameworks/R.framework/Resources/lib/libtbb.dylib' (no such file), ...

# against the patched package
max concurrency: 14

The test is CI-gated (it needs a toolchain) and runs only where TBB is linked into the client explicitly -- macOS, or a system-TBB configuration. Its gate mirrors the branches of tbbLdFlags() rather than checking the emitted flags for -rpath, which would have skipped the exact configuration that regressed.

tests/test-ld-flags.R stays as the cheap always-on check for platforms with no toolchain. Full suite passes with CI=1 (11 files).

@kevinushey
kevinushey merged commit a0b8876 into master Jul 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include rpath in linking flags

1 participant