Skip to content

Commit a0b8876

Browse files
authored
emit an -rpath for tbb in LdFlags() on macos (#271)
* emit an -rpath for tbb in LdFlags() on macos 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. * test that a downstream package loads standalone 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.
1 parent 222d02e commit a0b8876

4 files changed

Lines changed: 194 additions & 1 deletion

File tree

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# RcppParallel (development version)
22

3+
* On macOS, `RcppParallel::LdFlags()` now also emits an `-rpath` entry for the
4+
directory containing the TBB libraries. The libraries record an
5+
`@rpath`-relative install name, so packages linking against them previously
6+
produced binaries with no runtime search path for TBB; those binaries could
7+
only be loaded when RcppParallel (and hence TBB) already happened to be
8+
loaded into the process, and failed with "Library not loaded:
9+
@rpath/libtbb.dylib" otherwise. (#209)
10+
311
* Fixed an issue where compiling code including `tbb/parallel_for_each.h`
412
could fail with toolchains that accept `-std=c++20` but provide a
513
pre-C++20 standard library, with errors of the form "no member named

R/tbb.R

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,15 @@ tbbLdFlags <- function() {
121121

122122
# explicitly link on macOS
123123
# https://github.com/RcppCore/RcppParallel/issues/206
124+
#
125+
# the bundled libraries record an '@rpath'-relative install name (e.g.
126+
# '@rpath/libtbb.dylib'), so '-L' alone is not enough: the client library
127+
# ends up with no runtime search path for TBB at all, and only loads
128+
# because RcppParallel -- and hence TBB -- normally happens to be loaded
129+
# into the process first. Emit a matching '-rpath' so the client can
130+
# resolve TBB on its own. (#209)
124131
if (is_mac()) {
125-
fmt <- "-L%s -l%s -l%s"
132+
fmt <- "-L%1$s -Wl,-rpath,%1$s -l%2$s -l%3$s"
126133
return(sprintf(fmt, asBuildPath(tbbLibraryPath()), TBB_NAME, TBB_MALLOC_NAME))
127134
}
128135

tests/test-downstream-load.R

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
2+
# Check that a package linking against RcppParallel's TBB can be loaded on its
3+
# own, in a process that has not already loaded RcppParallel.
4+
#
5+
# The TBB libraries we ship on macOS record an '@rpath'-relative install name,
6+
# so LdFlags() must emit an '-rpath' alongside its '-L'. With only the '-L' the
7+
# link still succeeds -- so the package builds, and loads fine via its
8+
# NAMESPACE, whose importFrom(RcppParallel, ...) pulls TBB into the process
9+
# first -- but the shared object itself carries no runtime search path, and
10+
# dyld fails with "Library not loaded: @rpath/libtbb.dylib" for anything that
11+
# loads it directly. Hence the load below runs in a fresh R process which never
12+
# touches RcppParallel; loading it from here would prove nothing.
13+
#
14+
# See also tests/test-ld-flags.R, the cheap flag-level check that still runs
15+
# where no toolchain is available.
16+
#
17+
# https://github.com/RcppCore/RcppParallel/issues/209
18+
19+
RcppParallel:::test_init()
20+
21+
# building the test package requires a toolchain, so keep this test
22+
# off of CRAN's machines
23+
ci <- nzchar(Sys.getenv("CI")) || identical(Sys.getenv("NOT_CRAN"), "true")
24+
if (!ci) {
25+
writeLines("Not running on CI; skipping downstream load test.")
26+
quit(save = "no")
27+
}
28+
29+
if (!TBB_ENABLED) {
30+
writeLines("TBB is not enabled; skipping downstream load test.")
31+
quit(save = "no")
32+
}
33+
34+
# Windows has no rpath: the loader resolves DLLs through the process search
35+
# path, and downstream packages link '-lRcppParallel' rather than TBB itself
36+
if (is_windows()) {
37+
writeLines("Windows resolves DLLs via the search path; skipping.")
38+
quit(save = "no")
39+
}
40+
41+
# A standalone load can only work where TBB is linked into the client
42+
# explicitly: macOS (#206), and any platform configured against a system TBB.
43+
# Elsewhere the TBB symbols are deliberately left undefined at link time and
44+
# resolved from the libraries RcppParallel loads, so a standalone load is
45+
# expected to fail. Note this mirrors the branches in tbbLdFlags() rather than
46+
# inspecting its output: gating on the '-rpath' we are checking for would skip
47+
# precisely the configuration that regressed.
48+
tbbLib <- Sys.getenv("TBB_LINK_LIB", Sys.getenv("TBB_LIB", unset = TBB_LIB))
49+
if (!is_mac() && !nzchar(tbbLib)) {
50+
writeLines("TBB is not linked into downstream packages here; skipping.")
51+
quit(save = "no")
52+
}
53+
54+
# generate the test package
55+
pkgRoot <- file.path(tempdir(), "downstreamtest")
56+
dir.create(file.path(pkgRoot, "src"), recursive = TRUE, showWarnings = FALSE)
57+
58+
writeLines(con = file.path(pkgRoot, "DESCRIPTION"), c(
59+
"Package: downstreamtest",
60+
"Type: Package",
61+
"Title: Test Loading a Package Linked Against RcppParallel's TBB",
62+
"Version: 0.1.0",
63+
"Author: RcppParallel Authors",
64+
"Maintainer: RcppParallel Authors <noreply@example.com>",
65+
"Description: Confirms that such packages can be loaded standalone.",
66+
"License: GPL-2",
67+
"Imports: RcppParallel"
68+
))
69+
70+
writeLines(con = file.path(pkgRoot, "NAMESPACE"), c(
71+
"useDynLib(downstreamtest)",
72+
"importFrom(RcppParallel, RcppParallelLibs)"
73+
))
74+
75+
writeLines(con = file.path(pkgRoot, "src", "Makevars"), c(
76+
'PKG_CXXFLAGS = $(shell "${R_HOME}/bin/Rscript" -e "RcppParallel::CxxFlags()")',
77+
'PKG_LIBS = $(shell "${R_HOME}/bin/Rscript" -e "RcppParallel::RcppParallelLibs()")'
78+
))
79+
80+
# call into TBB proper, so that the shared object records a real load-time
81+
# dependency on the TBB library rather than merely being linked against it
82+
writeLines(con = file.path(pkgRoot, "src", "concurrency.cpp"), c(
83+
"#include <tbb/task_arena.h>",
84+
"",
85+
"#include <R.h>",
86+
"#include <Rinternals.h>",
87+
"",
88+
'extern "C" SEXP downstream_max_concurrency(void)',
89+
"{",
90+
" return Rf_ScalarInteger(tbb::this_task_arena::max_concurrency());",
91+
"}"
92+
))
93+
94+
# install it, making sure child processes resolve this RcppParallel
95+
libDir <- file.path(tempdir(), "library")
96+
dir.create(libDir, recursive = TRUE, showWarnings = FALSE)
97+
Sys.setenv(R_LIBS = paste(.libPaths(), collapse = .Platform$path.sep))
98+
99+
rExe <- file.path(R.home("bin"), "R")
100+
args <- c("CMD", "INSTALL", "--no-multiarch", paste0("--library=", shQuote(libDir)), shQuote(pkgRoot))
101+
102+
output <- suppressWarnings(system2(rExe, args, stdout = TRUE, stderr = TRUE))
103+
writeLines(output)
104+
105+
status <- attr(output, "status")
106+
if (is.numeric(status) && status != 0L)
107+
stop("error installing test package (status code ", status, ")")
108+
109+
# locate the shared object that was just built
110+
pattern <- paste0("^downstreamtest\\", .Platform$dynlib.ext, "$")
111+
shlib <- list.files(
112+
file.path(libDir, "downstreamtest", "libs"),
113+
pattern = pattern,
114+
recursive = TRUE,
115+
full.names = TRUE
116+
)
117+
118+
assert(length(shlib) == 1L)
119+
120+
# load it from a process which has never loaded RcppParallel; deparse() is used
121+
# to embed the path as a properly-quoted R string literal
122+
script <- file.path(tempdir(), "downstream-load.R")
123+
writeLines(con = script, c(
124+
'if ("RcppParallel" %in% loadedNamespaces())',
125+
' stop("RcppParallel is loaded; this check would prove nothing")',
126+
"",
127+
sprintf("dll <- dyn.load(%s)", deparse(shlib)),
128+
'value <- .Call(getNativeSymbolInfo("downstream_max_concurrency", dll))',
129+
'cat("max concurrency:", value, "\\n")',
130+
"",
131+
'if (!is.integer(value) || is.na(value) || value < 1L)',
132+
' stop("unexpected concurrency: ", value)'
133+
))
134+
135+
rscript <- file.path(R.home("bin"), "Rscript")
136+
output <- suppressWarnings(system2(rscript, c("--vanilla", shQuote(script)), stdout = TRUE, stderr = TRUE))
137+
writeLines(output)
138+
139+
status <- attr(output, "status")
140+
assert(is.null(status) || status == 0L)

tests/test-ld-flags.R

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Unit tests for tbbLdFlags(), the linker flags handed to downstream packages
2+
# via RcppParallel::LdFlags().
3+
#
4+
# On macOS, the TBB libraries RcppParallel ships record an '@rpath'-relative
5+
# install name (e.g. '@rpath/libtbb.dylib'), so a '-L' search path alone only
6+
# satisfies the linker: the resulting binary carries no runtime search path for
7+
# TBB, and dyld can resolve it only when RcppParallel has already pulled TBB
8+
# into the process. Every '-L' we emit must therefore be paired with a matching
9+
# '-rpath'. (#209)
10+
#
11+
# This is only a check on the shape of the flags, so that something still runs
12+
# where no toolchain is available (e.g. CRAN). tests/test-downstream-load.R
13+
# covers the behaviour itself: it builds a package against these flags and
14+
# loads it in a process that never loads RcppParallel.
15+
16+
RcppParallel:::test_init()
17+
18+
flags <- tbbLdFlags()
19+
20+
assert(is.character(flags))
21+
assert(length(flags) == 1L)
22+
assert(!is.na(flags))
23+
24+
if (is_mac()) {
25+
26+
parts <- strsplit(flags, "\\s+")[[1L]]
27+
28+
libPaths <- sub("^-L", "", grep("^-L", parts, value = TRUE))
29+
rpaths <- sub("^-Wl,-rpath,", "", grep("^-Wl,-rpath,", parts, value = TRUE))
30+
31+
# we always link TBB explicitly on macOS, so there is a search path to check
32+
assert(length(libPaths) > 0L)
33+
assert(all(nzchar(libPaths)))
34+
35+
# and each one must also be searched at load time
36+
assert(all(libPaths %in% rpaths))
37+
38+
}

0 commit comments

Comments
 (0)