Writing this up as a new issue while in the middle of reviewing gh-788. This is quite complicated, and we don't have good test coverage. So this issue aims to lay out as many relevant scenarios as possible, with the expected behavior on Linux and macOS for each case. Reviewing that is a lot easier than reviewing code for which we don't yet have tests.
Once we agree that the behavior is right, we can decide on test packages to add. Also, I'd like to add a table like this in a new page in the docs; I think the audience for this is relatively small, but when you do need this info, you really need it - and I don't know of any other source where this is covered well.
The tables explain desired behavior of extension modules in a wheel built with meson-python (i.e., before auditwheel or delocate repair).
D is the wheel's relocated-library directory, named .<distribution_name>.mesonpy.libs. The examples assume it is beside the directory containing the extension, so the extension reaches it through $ORIGIN/../D on Linux or @loader_path/../D on macOS. Extensions in deeper directories require the corresponding relative path to D.
E is an absolute external dependency prefix, outside the source and build trees. It remains available after installation.
- The tables list the paths relevant to each scenario. “None” means no project-specific search paths; unrelated external runtime paths remain unchanged.
Installed code must work after removing the source and build directories, without loader-path environment overrides. The empty-RUNPATH scenario explicitly requires a loader failure instead. Build-path removal assumes Meson provides the necessary metadata, or has already taken care of the removal itself.
All scenarios must also work when building another wheel from the same build directory, without changing the resulting search paths
Shared behavior
| Scenario |
Linux extension paths |
macOS extension LC_RPATH paths |
Notes |
| Extension without dependencies on additional project-built shared libraries |
None |
None |
Basic regression, ensure nothing is added. |
One library is moved into D |
$ORIGIN/../D |
@loader_path/../D |
Update its consumer to find the library at its installed location. |
Two libraries from different build directories move into the same D |
$ORIGIN/../D, once |
@loader_path/../D, one command |
Add the destination search path once, without duplicates. |
Extension → first library → second library; both libraries are moved into D |
$ORIGIN/../D |
@loader_path/../D |
The first library also needs $ORIGIN/. on Linux or @loader_path/. on macOS. Repair the entire dependency chain, not just the extension. |
| Extension → sibling library, using a platform-correct installation path |
$ORIGIN |
@loader_path |
Keep the sibling library discoverable after installation. |
Extension → sibling library → second library in sub/ |
$ORIGIN |
@loader_path |
The sibling library needs $ORIGIN/sub on Linux or @loader_path/sub on macOS to load its own dependency. |
Extension directly depends on both a sibling library and a library in sub/; an existing working layout |
$ORIGIN, $ORIGIN/sub |
@loader_path, @loader_path/sub |
Preserve both paths and continued functionality. Neither library needs its own project search path. |
Extension depends on two relocated libraries in D and an in-package library in private/ |
$ORIGIN/private, $ORIGIN/../D |
@loader_path/private, @loader_path/../D |
Keep working paths to both groups. |
An external library is linked by absolute filename or through -L/-l, with an explicit runtime path supplied by -Wl,-rpath,E/lib |
E/lib |
E/lib |
Preserve the explicitly requested external runtime path for either linking form. The -L directory alone does not imply a path that must be preserved. |
Link flags explicitly request a relative runtime path with -Wl,-rpath,$ORIGIN/user (Linux) or -Wl,-rpath,@loader_path/user (macOS), and an absolute path with -Wl,-rpath,E/lib; other libraries are relocated into D |
$ORIGIN/user, E/lib, $ORIGIN/../D |
@loader_path/user, E/lib, @loader_path/../D |
Preserve the requested relative or absolute paths and add the relocation path. This applies whether the flags come from target linker arguments, environment flags, or pkg-config, and also to bundled libraries needing path updates. A binary need not request both relative and absolute paths. |
install_rpath requests $ORIGIN/one:$ORIGIN/two:$ORIGIN/one on Linux, or @loader_path/one:@loader_path/two:@loader_path/one on macOS |
$ORIGIN/one, $ORIGIN/two |
@loader_path/one, @loader_path/two, as separate commands |
Keep each distinct installation path once. |
| The same path appears in both build and installation metadata |
$ORIGIN/keep, once |
@loader_path/keep, once |
The explicit installation request takes precedence over removal of the same build path. |
| A build-only path remains, but no installation path is requested |
None |
None |
Remove the build-only path without leaving padding or accidental empty entries. See the intentional empty-RUNPATH exception below. |
Linux: RPATH and RUNPATH
RPATH and RUNPATH are ELF dynamic tags, also used on platforms other than Linux; their search behavior depends on the dynamic loader. The runtime descriptions below refer to Linux with glibc: RUNPATH takes precedence over RPATH in the same binary, and applies only to direct dependencies, whereas RPATH supports transitive lookup. Linux with musl does not make that transitivity distinction: both tag types can supply paths for indirect dependencies. Preserving the input tag type remains a requirement, even where conversion would not change the observed runtime behavior. macOS uses Mach-O LC_RPATH commands and has no RPATH/RUNPATH distinction.
| Scenario |
Linux extension paths |
macOS extension LC_RPATH paths |
Notes |
| Editing paths in a binary containing either RPATH or RUNPATH |
Updated paths in the original tag type |
Not applicable |
Do not silently convert RPATH to RUNPATH or vice versa. |
Explicit installation paths compete with preserved paths; private/ and external/ contain libraries with the same SONAME |
$ORIGIN/private before $ORIGIN/external; preserve input tag type |
Not applicable |
Search installation paths first, so the library in private/ takes precedence over the same-SONAME library in external/. |
| Executable → middle library → leaf library; the executable uses transitive RPATH |
DT_RPATH: $ORIGIN/lib |
Not applicable |
Both libraries are in lib/ and have no project paths. The executable's RPATH must resolve the indirect dependency. |
| Executable → middle library → leaf library; the executable uses direct-dependency RUNPATH |
DT_RUNPATH: $ORIGIN/lib |
Not applicable |
The middle library needs $ORIGIN for its leaf dependency. Preserve its input tag type; the entire chain must load. |
| Executable uses RPATH; the middle library uses RUNPATH and must update its path to the installed leaf |
DT_RPATH: $ORIGIN/middle |
Not applicable |
Middle: DT_RUNPATH containing $ORIGIN/../installed-leaf. Preserve both tag types and load the complete chain. |
| Executable uses RUNPATH; the middle library uses RPATH and must update its path to the installed leaf |
DT_RUNPATH: $ORIGIN/middle |
Not applicable |
Middle: DT_RPATH containing $ORIGIN/../installed-leaf. Preserve both tag types and load the complete chain. |
| One binary contains both tags, pointing at different copies of the same library |
DT_RUNPATH: $ORIGIN/runpath-choice; optional inactive DT_RPATH: $ORIGIN/rpath-choice |
Not applicable |
Load the library found through RUNPATH; the competing copy reachable only through RPATH must be ignored. Remove build-only RUNPATH entries without merging the ignored RPATH into it. The ignored RPATH may be retained or removed. |
| Both tags exist, but RUNPATH is empty |
Empty DT_RUNPATH tag; optional inactive DT_RPATH: $ORIGIN/rpath-choice |
Not applicable |
Preserve the empty RUNPATH so RPATH stays ignored. A dependency available only through RPATH must remain undiscoverable: this deliberately requires a loader failure. |
| Removing build-only paths leaves no paths |
No RPATH or RUNPATH tag |
Not applicable |
Remove the tag instead of leaving an empty search entry. The deliberately empty RUNPATH above is an explicit exception. |
macOS: LC_RPATH
These cases exercise separate Mach-O load commands and install_name_tool behavior.
| Scenario |
macOS extension LC_RPATH paths |
Notes |
Legacy installation paths use literal $ORIGIN, including on macOS |
@loader_path; also @loader_path/sub when required |
Preserve working behavior with native macOS anchors, including subdirectory paths. Literal $ORIGIN is not a usable macOS result. |
| Several installation paths are requested |
@loader_path/one, @loader_path/two, as separate commands |
Do not encode the macOS paths as one colon-containing command. |
An extension already contains two LC_RPATH commands for @loader_path/dupa; this path should be preserved and no libraries are relocated |
@loader_path/dupa, one command |
Reduce the two existing commands to one, even when no other path edits are needed. |
An extension contains two LC_RPATH commands for the same build-only path, @loader_path/dupa |
None |
Remove every copy of @loader_path/dupa successfully. |
Keep existing paths containing spaces, parentheses, and trailing whitespace; add @loader_path/new path/(library) and remove the build-only @loader_path/remove this (build) |
@loader_path/some path/(library), @loader_path/trailing (one trailing space), @loader_path/new path/(library) |
Preserve the exact retained strings, add the new path once, and remove @loader_path/remove this (build) completely. |
Full disclosure: I started this table by hand, then did a lot of testing and synthesis with an AI tool (Codex/Astra), which turned up a few more cases that are new rows in the table above (in particular for mixed RPATH/RUNPATH, after I prompted it for that - we've had issues with that before, e.g. gh-125). Text is mine, and reviewed/polished as carefully as I could for clarity and correctness. I'll comment on the testing results of trying to test gh-788 for these behaviors on that PR, rather than here.
Writing this up as a new issue while in the middle of reviewing gh-788. This is quite complicated, and we don't have good test coverage. So this issue aims to lay out as many relevant scenarios as possible, with the expected behavior on Linux and macOS for each case. Reviewing that is a lot easier than reviewing code for which we don't yet have tests.
Once we agree that the behavior is right, we can decide on test packages to add. Also, I'd like to add a table like this in a new page in the docs; I think the audience for this is relatively small, but when you do need this info, you really need it - and I don't know of any other source where this is covered well.
The tables explain desired behavior of extension modules in a wheel built with
meson-python(i.e., beforeauditwheelordelocaterepair).Dis the wheel's relocated-library directory, named.<distribution_name>.mesonpy.libs. The examples assume it is beside the directory containing the extension, so the extension reaches it through$ORIGIN/../Don Linux or@loader_path/../Don macOS. Extensions in deeper directories require the corresponding relative path toD.Eis an absolute external dependency prefix, outside the source and build trees. It remains available after installation.Installed code must work after removing the source and build directories, without loader-path environment overrides. The empty-RUNPATH scenario explicitly requires a loader failure instead. Build-path removal assumes Meson provides the necessary metadata, or has already taken care of the removal itself.
All scenarios must also work when building another wheel from the same build directory, without changing the resulting search paths
Shared behavior
D$ORIGIN/../D@loader_path/../DD$ORIGIN/../D, once@loader_path/../D, one commandD$ORIGIN/../D@loader_path/../D$ORIGIN/.on Linux or@loader_path/.on macOS. Repair the entire dependency chain, not just the extension.$ORIGIN@loader_pathsub/$ORIGIN@loader_path$ORIGIN/subon Linux or@loader_path/subon macOS to load its own dependency.sub/; an existing working layout$ORIGIN,$ORIGIN/sub@loader_path,@loader_path/subDand an in-package library inprivate/$ORIGIN/private,$ORIGIN/../D@loader_path/private,@loader_path/../D-L/-l, with an explicit runtime path supplied by-Wl,-rpath,E/libE/libE/lib-Ldirectory alone does not imply a path that must be preserved.-Wl,-rpath,$ORIGIN/user(Linux) or-Wl,-rpath,@loader_path/user(macOS), and an absolute path with-Wl,-rpath,E/lib; other libraries are relocated intoD$ORIGIN/user,E/lib,$ORIGIN/../D@loader_path/user,E/lib,@loader_path/../Dinstall_rpathrequests$ORIGIN/one:$ORIGIN/two:$ORIGIN/oneon Linux, or@loader_path/one:@loader_path/two:@loader_path/oneon macOS$ORIGIN/one,$ORIGIN/two@loader_path/one,@loader_path/two, as separate commands$ORIGIN/keep, once@loader_path/keep, onceLinux: RPATH and RUNPATH
RPATH and RUNPATH are ELF dynamic tags, also used on platforms other than Linux; their search behavior depends on the dynamic loader. The runtime descriptions below refer to Linux with glibc: RUNPATH takes precedence over RPATH in the same binary, and applies only to direct dependencies, whereas RPATH supports transitive lookup. Linux with musl does not make that transitivity distinction: both tag types can supply paths for indirect dependencies. Preserving the input tag type remains a requirement, even where conversion would not change the observed runtime behavior. macOS uses Mach-O LC_RPATH commands and has no RPATH/RUNPATH distinction.
private/andexternal/contain libraries with the same SONAME$ORIGIN/privatebefore$ORIGIN/external; preserve input tag typeprivate/takes precedence over the same-SONAME library inexternal/.$ORIGIN/liblib/and have no project paths. The executable's RPATH must resolve the indirect dependency.$ORIGIN/lib$ORIGINfor its leaf dependency. Preserve its input tag type; the entire chain must load.$ORIGIN/middle$ORIGIN/../installed-leaf. Preserve both tag types and load the complete chain.$ORIGIN/middle$ORIGIN/../installed-leaf. Preserve both tag types and load the complete chain.$ORIGIN/runpath-choice; optional inactive DT_RPATH:$ORIGIN/rpath-choice$ORIGIN/rpath-choicemacOS: LC_RPATH
These cases exercise separate Mach-O load commands and
install_name_toolbehavior.$ORIGIN, including on macOS@loader_path; also@loader_path/subwhen required$ORIGINis not a usable macOS result.@loader_path/one,@loader_path/two, as separate commands@loader_path/dupa; this path should be preserved and no libraries are relocated@loader_path/dupa, one command@loader_path/dupa@loader_path/dupasuccessfully.@loader_path/new path/(library)and remove the build-only@loader_path/remove this (build)@loader_path/some path/(library),@loader_path/trailing(one trailing space),@loader_path/new path/(library)@loader_path/remove this (build)completely.Full disclosure: I started this table by hand, then did a lot of testing and synthesis with an AI tool (Codex/Astra), which turned up a few more cases that are new rows in the table above (in particular for mixed RPATH/RUNPATH, after I prompted it for that - we've had issues with that before, e.g. gh-125). Text is mine, and reviewed/polished as carefully as I could for clarity and correctness. I'll comment on the testing results of trying to test gh-788 for these behaviors on that PR, rather than here.