Skip to content

Let downstream binaries bundle the native library without runtime env vars - #14

Merged
chinaux merged 2 commits into
mainfrom
feat/downstream-rpath-bundling
Sep 15, 2026
Merged

chinaux merged 2 commits into
mainfrom
feat/downstream-rpath-bundling

Conversation

@egolearner

Copy link
Copy Markdown
Contributor

Problem

Binaries that link zvec-rust fail to locate libzvec_c_api at runtime unless the user exports DYLD_LIBRARY_PATH (macOS) / LD_LIBRARY_PATH (Linux).

Root cause: zvec-sys/build.rs tried to set the runtime search path with

println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display());

but cargo:rustc-link-arg only affects the emitting package's own binary/cdylib targets. zvec-sys (and zvec) compile to rlibs, so the flag is never applied to a downstream executable. The final binary therefore carries no LC_RPATH / DT_RUNPATH pointing at the shared library, and the loader can only find it via environment variables. There is no supported way for a build script to inject an rpath into a binary defined in another crate further down the graph.

Solution

Stop trying to set the rpath from the library crates. Instead, propagate the resolved native-library directory through the dependency graph and let the final binary crate set its own rpath (and, for local dev, stage the library beside the binary).

  • zvec-sys: publish the resolved lib dir as links metadata via cargo:lib_dir=.... Cargo exposes it to direct dependents' build scripts as DEP_ZVEC_C_API_LIB_DIR. Removed the ineffective rpath link-arg. Also fixed Windows library detection to distinguish msvc (needs the .lib import library) from gnu/mingw (can link .dll.a or .dll directly).
  • zvec: declare links = "zvec_rust" and forward DEP_ZVEC_C_API_LIB_DIR as its own cargo:lib_dir, so crates depending directly on zvec-rust receive it as DEP_ZVEC_RUST_LIB_DIR.
  • zvec-build (new helper crate, zvec-rust-build): called from a downstream binary crate's build.rs. configure():
    • emits per-OS rpaths via cargo:rustc-link-arg-bins: @executable_path and @executable_path/../lib on macOS, $ORIGIN and $ORIGIN/../lib on Linux, none on Windows (the loader searches the executable's own directory);
    • in non-release builds also emits an absolute rpath to the resolved lib dir;
    • stages the runtime library next to the binary so cargo run / a freshly built binary work with no dylib search-path env vars.

The rpath is written at link time (not patched in afterward with install_name_tool), so macOS code signing is unaffected.

Downstream usage

A binary crate depends directly on zvec-rust, adds zvec-rust-build as a build-dependency, and calls it from build.rs:

fn main() {
    zvec_rust_build::configure();
}

Deployment layout options:

  • dev / single-dir: binary + shared library side by side (@executable_path / $ORIGIN);
  • release: bin/<exe> + lib/<shared lib> (@executable_path/../lib / $ORIGIN/../lib).

Note: DEP_* metadata only reaches direct dependents, so the binary crate must depend on zvec-rust directly (not merely transitively) for configure() to locate the library.

Verification

Validated end-to-end from the zvec-grep consumer on macOS arm64 (via a local [patch.crates-io], not included here):

  • otool -l zg shows the expected LC_RPATH entries; install name is @rpath/libzvec_c_api.dylib;
  • env -u DYLD_LIBRARY_PATH -u DYLD_FALLBACK_LIBRARY_PATH ./zg --version succeeds;
  • a simulated bin/+lib/ deploy runs with all dylib search-path vars cleared;
  • npm packaging smoke test passes.

Linux ($ORIGIN) and Windows (loader searches exe dir) paths are wired but were not executed on the macOS host.

…t env vars

zvec-rust-sys emitted an rpath via `cargo:rustc-link-arg=-Wl,-rpath,...`, but
that link-arg only applies to the emitting rlib's own targets and never
propagates to a downstream executable. As a result, binaries linking zvec-rust
had no LC_RPATH/DT_RUNPATH pointing at libzvec_c_api, forcing users to set
DYLD_LIBRARY_PATH / LD_LIBRARY_PATH at runtime.

Propagate the resolved library directory through the dependency graph and let
the final binary crate configure its own rpath:

- zvec-sys: publish the resolved lib dir as `links` metadata (cargo:lib_dir),
  exposed to direct dependents as DEP_ZVEC_C_API_LIB_DIR; drop the ineffective
  rpath link-arg. Also fix Windows lib detection to distinguish msvc (.lib
  import lib required) from gnu/mingw (.dll.a or .dll).
- zvec: declare `links = "zvec_rust"` and forward DEP_ZVEC_C_API_LIB_DIR as its
  own cargo:lib_dir, so direct dependents receive DEP_ZVEC_RUST_LIB_DIR.
- zvec-build: new helper crate. From a binary crate's build.rs, configure()
  emits per-OS rpaths (@executable_path[/../lib] on macOS, $ORIGIN[/../lib] on
  Linux, none on Windows) and stages the runtime library beside the binary for
  local development, so the binary runs with no dylib search-path env vars.
@egolearner
egolearner requested a review from chinaux September 14, 2026 08:54

@chinaux chinaux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this — the diagnosis is spot-on: cargo:rustc-link-arg emitted from zvec-sys only applies to that package's own targets, and since both crates are rlibs, the old rpath never reached any downstream binary. I verified a few things on my side before reviewing:

Verified OK

  • Removing the old rpath link-arg is regression-free — CI and the Makefile have always relied on DYLD_LIBRARY_PATH/LD_LIBRARY_PATH; the flag never affected anything.
  • zvec_c_api sets no VERSION/SOVERSION in its CMakeLists, so DT_NEEDED is exactly libzvec_c_api.so — staging by that file name is sufficient on Linux.
  • links = "zvec_rust" requires a build script, and zvec/build.rs already exists on main, so no conflict.
  • The OUT_DIR → 3-levels-up → binary output dir logic is correct for both native and cross builds.

That said, I'd like two blockers addressed before merging:

Blockers

  1. zvec-rust-build is never published to crates.io. publish-crates.yml only publishes zvec-rust-sys and zvec-rust, and its version-verification step doesn't cover the new crate. As-is, downstream zvec-rust-build = "0.7" build-dependencies won't resolve. Please add dry-run + publish steps (and the version check) for the new crate.

  2. rpaths are emitted for binaries only. cargo:rustc-link-arg-bins doesn't cover tests, examples, or benches, so a downstream crate's integration-test binaries get no rpath at all and cargo test still requires the env vars. (The verification in the PR description only exercised the binary.) Consider plain cargo:rustc-link-arg — which applies to all target kinds of the emitting package — or additionally emit the -tests / -examples / -benches variants.

Should-fix

  1. jieba dict is not staged. The auto-registration added in 37e50fa discovers data/jieba_dict/ relative to the loaded library (via dladdr/GetModuleHandleExW) or via a build-time-recorded path. In the recommended bin/ + lib/ deployment layout on another machine, both discovery paths fail because copy_runtime_libs_to copies only the shared library. Suggest staging data/jieba_dict/ (when present in lib_dir()) next to the library, or at minimum documenting that packagers must ship it.

  2. Root READMEs still over-promise. README.md:57 / README_CN.md:57 claim the bundled feature "sets up the library path via rpath" — that was never true, and remains untrue without the helper crate. Worth correcting here and introducing zvec-rust-build in the Prerequisites section.

  3. Linux ($ORIGIN) and Windows paths are wired but untested (as the PR notes), and workspace CI doesn't cover the downstream-binary scenario at all. Suggest adding a tiny example bin that calls configure() and running env -u LD_LIBRARY_PATH ./target/debug/<smoke> (plus the macOS equivalent) in CI.

Nits

  • let _ = target_env(); // reserved for future — drop the placeholder or use it.
  • configure() emits cargo:rerun-if-changed=build.rs on behalf of the downstream package, which disables Cargo's default "re-run if any file changed" policy — worth documenting.
  • The ZVEC_LIB_DIR fallback in lib_dir() can stage a host-arch library during cross-compilation — worth a doc caveat.
  • No unit tests in the new crate; runtime_lib_file_names / binary_output_dir are cheap to test.

Overall this fixes a real long-standing flaw and the design (links metadata + downstream helper crate) is the right pattern — looking forward to the next iteration.

…verage, jieba staging, docs

Follow-up to PR review on the downstream rpath bundling change.

- copy_runtime_libs_to: guard against copying a file onto itself. fs::copy
  truncates the destination before reading the source, so when ZVEC_LIB_DIR
  already points at the binary output directory the staged library was zeroed
  out (reproduced on macOS). stage_file() now skips when source and
  destination canonicalize to the same object, which also covers symlinked
  layouts. Added unit tests including a symlink case.
- rpath: emit via cargo:rustc-link-arg (not -bins) so integration tests,
  examples, and benches of the downstream crate also carry the rpath and run
  without dylib search-path env vars.
- jieba dict: stage data/jieba_dict beside the library so the FTS jieba
  tokenizer's runtime discovery (<lib_dir>/data/jieba_dict) works in a
  self-contained bin/+lib/ deployment.
- publish-crates.yml: add version check, dry-run, and publish steps for the
  new zvec-rust-build crate so downstream build-deps resolve on crates.io.
- CI: add zvec-rpath-smoke, a downstream binary that wires zvec-rust-build,
  executed with all dylib search-path vars cleared to exercise the
  @executable_path / $ORIGIN paths on macOS/Linux/Windows.
- README/README_CN: correct the false "bundled sets up the library path via
  rpath" claim and document the zvec-rust-build helper.
- Nits: drop the target_env() placeholder, document that emitting rerun-if-*
  overrides Cargo's default rerun policy, note the ZVEC_LIB_DIR cross-compile
  caveat, and add unit tests for runtime_lib_file_names / binary_output_dir.
@egolearner

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all points addressed in 40a6412.

Blockers

  1. Publishpublish-crates.yml now version-checks, dry-runs, and publishes zvec-rust-build (first, since it has no deps and no C-library link). Verified cargo publish -p zvec-rust-build --dry-run packages cleanly.
  2. rpath for all target kinds — switched emit_rpath from cargo:rustc-link-arg-bins to plain cargo:rustc-link-arg, which the emitting package applies to bins, integration tests, examples, and benches. cargo test in a downstream crate now runs without env vars.

Should-fix
3. jieba dictcopy_runtime_libs_to now also stages data/jieba_dict/ next to the library (when present), matching the runtime <lib_dir>/data/jieba_dict discovery for bin/ + lib/ deploys.
4. READMEs — dropped the false "sets up the library path via rpath" claim in README.md / README_CN.md; cargo run/cargo test work out of the box, and the Prerequisites now introduce zvec-rust-build for directly-executed / deployed binaries.
5. Untested Linux/Windows + no CI coverage — added zvec-rpath-smoke, a downstream binary wiring configure() from its build.rs; a new CI step builds it and runs the artifact directly with DYLD_* / LD_LIBRARY_PATH cleared, on macOS / Linux / Windows.

Nits — removed the target_env() placeholder; documented that emitting any rerun-if-* opts out of Cargo's default rerun policy; added the ZVEC_LIB_DIR cross-compile caveat to lib_dir's docs; added unit tests for runtime_lib_file_names and binary_output_dir.

Additional bug fixed (reported separately): copy_runtime_libs_to called fs::copy directly, which truncates the destination before reading the source — so a same-file copy (e.g. ZVEC_LIB_DIR pointing at the binary output dir) zeroed the library out (reproduced on macOS). stage_file now skips when source and destination canonicalize to the same object, which also covers symlinked layouts. Covered by unit tests (incl. a symlink case) and verified end-to-end: with ZVEC_LIB_DIR = output dir, the staged 23 MB dylib stays intact and the binary runs with DYLD_* cleared.

@chinaux
chinaux merged commit 5b0a30e into main Sep 15, 2026
8 checks passed
@egolearner
egolearner deleted the feat/downstream-rpath-bundling branch September 15, 2026 03:08
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.

2 participants