You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The file structure within the Habitat package system gives rise to unique challenges concerning shared library loading and linking. These challenges are categorized into three scenarios, each requiring the Habitat toolchain's careful handling to ensure correct operation. This discussion aims to shed light on these challenges and act as a reference for users who might face these issues in the future.
Static Linking: This method consolidates all required library code into one executable during the compilation phase. Luckily, Habitat packages can manage static linking seamlessly, with no additional efforts required from Habitat.
Dynamic Linking: In this case, the linker integrates placeholders in the executable for addresses of functions and variables, as defined in libraries. The dynamic linker then populates these placeholders either just before the program commences or during its operation (a process known as lazy binding). The libraries' locations are determined by RPATH / RUNPATH and DT_NEEDED entries stored in the executable. The language extension mechanism of most interpreted languages like NodeJS, Ruby, etc builds a C / C++ language extension library during the package installation process which dynamically links against the actual library and serves as a translation layer between the abstractions of the language runtime and the actual library.
Habitat packages with native extensions built in this manner work without any problems as expected. A wrapper around the linker is used to facilitate this process. This wrapper auto-detects all libraries linked into an ELF object and adds the necessary RUNPATH entries to accurately locate them at runtime.
Dynamic Loading: Libraries can also be loaded while the program is running, using the dlopen function (on Unix-like systems). This technique while slower in performance compared to dynamic linking is often used for plugin architectures and to minimize startup dependencies.
Most language runtimes also support some form of FFI extension mechanism which dynamically loads the required libary. This is usually done through some 3rd party module which dynamically links to libffi or dyncall. This module can then be used to dynamically load a library. There is currently no good way to find the location of the library to be loaded when it is located within a Habitat Package. Dynamic loading is not a problem for most package managers because they install libraries in standard locations like /usr/lib, /lib, etc which is automatically searched by dlopen calls.
For more details on how dynamic linking vs dynamic loading works you can checkout this helpful stack overflow answer.
Currently, Habitat packages have issues with dynamic loading that require additional examination. The following sections present potential problematic scenarios and their solutions.
Scenario 1: If an executable / library, built in Package A, requires to dynamically load a library from Package B, and Package B is a runtime dependency of Package A, the location of Package B's library can be determined. This information can be forced into the RUNPATH through a linker wrapper script or explicitly within Package A's plan. This ensures the dynamic linking succeeds.
Scenario 2: Suppose an executable, built in Package A, requires to dynamically load a library via FFI from Package B, and both Packages A and B are dependencies of Package C. This means the ELF RUNPATH for all binaries in Package A and B have already been compiled and cannot be modified. In this state, it becomes challenging for Package A's executable to dynamically load Package B's library via a dlopen (Linux/MacOS) or LoadLibrary (Windows) call, as there is no RUNPATH entry in the Package A binary that references Package B.
Here are three potential solutions for Scenario 2:
Solution 1
We can add all the library paths from Package B to the LD_LIBRARY_PATH (Linux), DYLD_LIBRARY_PATH (macOS), and PATH (Windows) environment variable and include it in Package C's runtime environment. This solution's pros/cons are:
Pros:
This is the officially OS supported mechanism to find libraries in non-standard paths for dynamic linking / loading.
It is not influenced by any optimization of the RUNPATH added to ELF binaries Cons:
This influences the environment of all invoked child processes, potentially forcing non-Habitat binaries to use Habitat libraries unintentionally while dynamic linking, leading to runtime failures. Mitigations:
We can limit the possibility of failure if the plan author knows which libraries are dynamically loaded and carefully chooses to only include those directories.
Solution 2
We could modify the linker to load a library from a cache located at a path defined by an environment variable (e.g., HAB_LD_CACHE). This cache would be located in Package C, and the environment variable would be part of Package C's runtime environment. This solution is similar to the way that the guix folks modified the linker's behavior to first look in a package specific ld cache before attempting to search RUNPATH, greatly speeding up load times for dynamically linked libraries. This solution's pros/cons are:
Pros:
It does not influence the dynamic linking behaviour of non-Habitat binaries
It is not influenced by any optimization of the RUNPATH added to ELF binaries Cons:
Modifies core OS behaviour
Can only be done on OSes where the runtime linker can be modified (Linux)
Solution 3
We modify the application / language runtime / FFI libaries that perform dynamic loading to support the ability to search for libraries from non-standard locations. Once the library is found, we can then pass the absolute path of the library to the dlopen (Linux / MacOS) call. This approach has been taken for dynamically loading libraries from nix packages in the Nim programming language. This solution's pros/cons are:
Pros:
It does not influence the dynamic linking behaviour of non-Habitat binaries
It is not influenced by any optimization of the RUNPATH added to ELF binaries
It would fix the problem for a large number of applications which may use a packager like Habitat / Nix / Guix. Cons:
Such patches to the library search logic may be refused by the library owners. Eg: There is an active issue for adding this capability to ruby's libffi gem which has not been acted upon for a while.
An example of Scenario 1 currently is chef/chef-infra-client, which functioned with the old linker script as the ffi gem builds an language extension library(vendor/gems/ffi-1.15.5/ext/ffi_c/ffi_c.so) during the bundle install process. This language extension library dynamically links libffi but doesn't use libarchive. The old linker script used to put all runtime dependency library directories (including libarchive's) into the language extension libary's RUNPATH. So when libarchive was dynamically loaded by the language extension library, it would be found.
This behaviour of the old linker script however does not solve Scenario 2 and so cannot really be considered as a solution for dynamic loading. It merely solves the problem when the language extension library used for dynamic loading is built within the package installation process. In some languages like NodeJS the language extension library is sometimes prebuilt and simply downloaded by the package manager. It also adds unnecessary performance overhead for every binary in a package in the scenario that we never actually do any dynamic loading. This great blog post by the guix folks explains it well.
Notably, none of the 200 odd base packages in the core origin directly makes use of dynamic loading, for an idea of how uncommon this scenario is for system level binary and library packages. However, this does not mean they are immune to this issue. If you try to attempt use some kind of FFI mechanism with any language like tcl, perl, python, etc, you would run into Scenario 2.
Due to the above reasons the behavior of the older linker wrapper which put the library folders of all package dependencies into the RUNPATH has been replaced with a new linker wrapper binary. The new linker wrapper by default adds all pkg_deps library folders to the RUNPATH. But if an environment flag is defined (HAB_LD_LINK_MODE=minimal) it would add only folders containing actually linked libraries to the RUNPATH. This would give plan authors the option to avoid the overhead of RUNPATH lookup if they care about it and they don't have any form of dynamic loading or choose to use solution 1 or solution 3.
I am open to other thoughts and potential solutions regarding this issue. Also if you find any factual mistakes in the above information please mention it below and I can make the necessary corrections to ensure that this is reliable reference source on this issue and it's potential solutions.
Edit: While thinking more about this, I have come across another extremely promising approach. Have done a small POC test and it seems to work. I am going to attempt implementing this into the new core-packages to validate further.
Solution 4
We can wrap / interpose the dlopen / LoadLibrary function during the linking stage while compiling the application with our own implementation during the compilation process. This can be done without patching / modifying the default system C library code using the following approach:
If the C library is statically linked:
The compiler wrapper will link in a static library libhabw.a that will use the --wrap ld flag to wrap the dlopen function.
The flags that will be added are --wrap=dlopen -lhabw -L/path/to/libhab/package
This will only work on Linux with the GNU ld linker.
This will work with any Linux C Library (glibc, musl, newlib etc). MacOS does not allow static C library linking.
If the C library is dynamically linked:
The compiler will link with in a static library libhabi.a that will interpose the dlopen call.
The flags that will be added are -lhabi -L/path/to/libhab/package
This will work with any Linux C Library (glibc, musl, newlib, etc). This will also work with libSystemB on MacOS.
Windows support needs more research.
The wrapped / interposed dlopen function will search for the library passed to dlopen using the following logic:
If the current binary is not within a hab package, do nothing
If the library name is an absolute path do nothing
If the HAB_LD_LIBRARY_PATH is set, search each folder for a library, if found call dlopen with the full path to the library.
For packages containing binaries that were already compiled outside habitat and that link against the system C library dynamically we can use library interposition on Linux / MacOS via LD_PRELOAD to intercept the call to dlopen. I believe something equivalent should be possible on Windows (@mwrock, any thoughts?).
Pros
Will not affect dynamic loading / linking behavior binaries compiled outside a Habitat plan
It does not require us to patch the linker itself, so it can work on MacOS and Windows as well
As long as the binary was compiled inside habitat It will work regardless of other OS security features which might cause library preloading to be disabled.
It is not influenced by any optimization of the RUNPATH added to ELF binaries
It will work in scenarios where non C languages (Rust, etc) links against the system C library.
It would potentially fix the problem for every single application (Node, Ruby, Java, etc) that is compiled from source inside a Habitat Plan.
Cons
It cannot affect the dynamic loading behavior of static binaries compiled outside a Habitat Plan using an regular non-Habitat compiler.
Library interposition could potentially be disabled by some OS specific security features
The text was updated successfully, but these errors were encountered:
The file structure within the Habitat package system gives rise to unique challenges concerning shared library loading and linking. These challenges are categorized into three scenarios, each requiring the Habitat toolchain's careful handling to ensure correct operation. This discussion aims to shed light on these challenges and act as a reference for users who might face these issues in the future.
Static Linking: This method consolidates all required library code into one executable during the compilation phase. Luckily, Habitat packages can manage static linking seamlessly, with no additional efforts required from Habitat.
Dynamic Linking: In this case, the linker integrates placeholders in the executable for addresses of functions and variables, as defined in libraries. The dynamic linker then populates these placeholders either just before the program commences or during its operation (a process known as lazy binding). The libraries' locations are determined by
RPATH
/RUNPATH
andDT_NEEDED
entries stored in the executable. The language extension mechanism of most interpreted languages like NodeJS, Ruby, etc builds a C / C++language extension library
during the package installation process which dynamically links against the actual library and serves as a translation layer between the abstractions of the language runtime and the actual library.Habitat packages with native extensions built in this manner work without any problems as expected. A wrapper around the linker is used to facilitate this process. This wrapper auto-detects all libraries linked into an ELF object and adds the necessary RUNPATH entries to accurately locate them at runtime.
dlopen
function (on Unix-like systems). This technique while slower in performance compared to dynamic linking is often used for plugin architectures and to minimize startup dependencies.Most language runtimes also support some form of FFI extension mechanism which dynamically loads the required libary. This is usually done through some 3rd party module which dynamically links to libffi or dyncall. This module can then be used to dynamically load a library. There is currently no good way to find the location of the library to be loaded when it is located within a Habitat Package. Dynamic loading is not a problem for most package managers because they install libraries in standard locations like
/usr/lib
,/lib
, etc which is automatically searched bydlopen
calls.For more details on how dynamic linking vs dynamic loading works you can checkout this helpful stack overflow answer.
Currently, Habitat packages have issues with dynamic loading that require additional examination. The following sections present potential problematic scenarios and their solutions.
Scenario 1: If an executable / library, built in Package A, requires to dynamically load a library from Package B, and Package B is a runtime dependency of Package A, the location of Package B's library can be determined. This information can be forced into the RUNPATH through a linker wrapper script or explicitly within Package A's plan. This ensures the dynamic linking succeeds.
Scenario 2: Suppose an executable, built in Package A, requires to dynamically load a library via FFI from Package B, and both Packages A and B are dependencies of Package C. This means the ELF RUNPATH for all binaries in Package A and B have already been compiled and cannot be modified. In this state, it becomes challenging for Package A's executable to dynamically load Package B's library via a
dlopen
(Linux/MacOS) orLoadLibrary
(Windows) call, as there is no RUNPATH entry in the Package A binary that references Package B.Here are three potential solutions for Scenario 2:
Solution 1
We can add all the library paths from Package B to the
LD_LIBRARY_PATH
(Linux),DYLD_LIBRARY_PATH
(macOS), andPATH
(Windows) environment variable and include it in Package C's runtime environment. This solution's pros/cons are:Pros:
Cons:
Mitigations:
Solution 2
We could modify the linker to load a library from a cache located at a path defined by an environment variable (e.g.,
HAB_LD_CACHE
). This cache would be located in Package C, and the environment variable would be part of Package C's runtime environment. This solution is similar to the way that the guix folks modified the linker's behavior to first look in a package specific ld cache before attempting to search RUNPATH, greatly speeding up load times for dynamically linked libraries. This solution's pros/cons are:Pros:
Cons:
Solution 3
We modify the application / language runtime / FFI libaries that perform dynamic loading to support the ability to search for libraries from non-standard locations. Once the library is found, we can then pass the absolute path of the library to the
dlopen
(Linux / MacOS) call. This approach has been taken for dynamically loading libraries from nix packages in the Nim programming language. This solution's pros/cons are:Pros:
Cons:
An example of Scenario 1 currently is chef/chef-infra-client, which functioned with the old linker script as the ffi gem builds an
language extension library
(vendor/gems/ffi-1.15.5/ext/ffi_c/ffi_c.so) during the bundle install process. This language extension library dynamically linkslibffi
but doesn't uselibarchive
. The old linker script used to put all runtime dependency library directories (including libarchive's) into the language extension libary's RUNPATH. So whenlibarchive
was dynamically loaded by the language extension library, it would be found.This behaviour of the old linker script however does not solve Scenario 2 and so cannot really be considered as a solution for dynamic loading. It merely solves the problem when the language extension library used for dynamic loading is built within the package installation process. In some languages like NodeJS the language extension library is sometimes prebuilt and simply downloaded by the package manager. It also adds unnecessary performance overhead for every binary in a package in the scenario that we never actually do any dynamic loading. This great blog post by the guix folks explains it well.
Notably, none of the 200 odd base packages in the
core
origin directly makes use of dynamic loading, for an idea of how uncommon this scenario is for system level binary and library packages. However, this does not mean they are immune to this issue. If you try to attempt use some kind of FFI mechanism with any language like tcl, perl, python, etc, you would run into Scenario 2.Due to the above reasons the behavior of the older linker wrapper which put the library folders of all package dependencies into the RUNPATH has been replaced with a new linker wrapper binary. The new linker wrapper by default adds all pkg_deps library folders to the RUNPATH. But if an environment flag is defined (
HAB_LD_LINK_MODE=minimal
) it would add only folders containing actually linked libraries to the RUNPATH. This would give plan authors the option to avoid the overhead of RUNPATH lookup if they care about it and they don't have any form of dynamic loading or choose to use solution 1 or solution 3.I am open to other thoughts and potential solutions regarding this issue. Also if you find any factual mistakes in the above information please mention it below and I can make the necessary corrections to ensure that this is reliable reference source on this issue and it's potential solutions.
Edit: While thinking more about this, I have come across another extremely promising approach. Have done a small POC test and it seems to work. I am going to attempt implementing this into the new core-packages to validate further.
Solution 4
We can wrap / interpose the
dlopen
/LoadLibrary
function during the linking stage while compiling the application with our own implementation during the compilation process. This can be done without patching / modifying the default system C library code using the following approach:If the C library is statically linked:
libhabw.a
that will use the --wrap ld flag to wrap thedlopen
function.--wrap=dlopen -lhabw -L/path/to/libhab/package
ld
linker.If the C library is dynamically linked:
libhabi.a
that will interpose thedlopen
call.-lhabi -L/path/to/libhab/package
The wrapped / interposed
dlopen
function will search for the library passed to dlopen using the following logic:HAB_LD_LIBRARY_PATH
is set, search each folder for a library, if found calldlopen
with the full path to the library.For packages containing binaries that were already compiled outside habitat and that link against the system C library dynamically we can use library interposition on Linux / MacOS via
LD_PRELOAD
to intercept the call todlopen
. I believe something equivalent should be possible on Windows (@mwrock, any thoughts?).Pros
Cons
The text was updated successfully, but these errors were encountered: