Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamically Loading libraries from Habitat Packages #56

Open
atrniv opened this issue Jul 7, 2023 · 0 comments
Open

Dynamically Loading libraries from Habitat Packages #56

atrniv opened this issue Jul 7, 2023 · 0 comments

Comments

@atrniv
Copy link
Collaborator

atrniv commented Jul 7, 2023

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.

  1. 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.

  2. 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.

  1. 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
@atrniv atrniv changed the title Dynamic Linking at Run Time Dynamically Loading libraries from Habitat Packages Jul 31, 2023
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

No branches or pull requests

1 participant