The library that only worked on the machine that built it

TL;DR: Vx 0.0.1 shipped on 10 September and could only run programs on the machine that built it. Both macOS dynamic libraries recorded the build runner's local directory as their install path. Every program linked against them copied that path. Our CI smoke test passed because it ran on that same build machine, leaving the broken release archive published for eleven days. Fixing this bug revealed three additional packaging issues. Here is how each bug occurred, how we fixed it, and two commands to check your own releases.

The front page of this site offers one install command. Running it on a fresh machine produces an immediate error:

$ curl -fsSL https://vxlang.org/install.sh | sh
...
==> Verifying the install
dyld[7851]: Library not loaded: /Users/runner/work/Vx/Vx/target/release/deps/libvx_std_core.dylib
  Reason: tried: (no such file), ...
error: the installed compiler could not run a hello-world program

That path points to a directory on a temporary GitHub Actions build runner. The runner deleted that directory when the release job finished, making the library unavailable on any user's machine.

What a dylib remembers

On macOS, dynamic shared libraries (dylib files in Mach-O format) record their expected install location in a header field called LC_ID_DYLIB. When a compiler links an executable against a shared library, the linker copies that path string into the executable. At program startup, the macOS dynamic linker (dyld) reads the path from the executable to find and load the library.

When a build script does not specify an install name, the compiler defaults to the absolute path where the library was compiled. Neither of our libraries configured an install name. Both binaries stored the temporary build runner path:

$ otool -D ~/.vx/toolchains/v0.0.1/lib/libvx_std_core.dylib
/Users/runner/work/Vx/Vx/target/release/deps/libvx_std_core.dylib

$ otool -D ~/.vx/toolchains/v0.0.1/lib/libvx_mlir_shims.dylib
/Users/runner/work/Vx/Vx/target/release/build/vxc-7e805375eee9c913/out/libvx_mlir_shims.dylib

The published toolchains included paths from the build runner's local filesystem. Linux binaries (ELF format) use similar mechanisms: DT_SONAME defines the library's identity, while DT_RPATH and DT_RUNPATH specify runtime search directories. Both operating systems allow builds to produce this packaging mistake if install paths are omitted.

Why every check was green

The release smoke test passed because it ran on the same virtual machine that compiled the binaries. In CI, a smoke test runs basic checks to verify an installation before publishing. Our release workflow unpacked the release tarball and ran a test program on that runner.

On that build runner, the original build path /Users/runner/work/Vx/Vx/target/release/deps still existed with the compiled library inside it. When dyld searched that path, it loaded the library and the program ran successfully. The test proved that the artifact worked in its build environment, but gave no guarantee of portability to other systems.

Verifying a release artifact requires testing portability as well as execution. Running an executable checks only the current machine. Inspecting library metadata reveals where the binary expects dependencies to exist when copied to a new system.

The first fix broke a test about parallel loops

To fix library paths on macOS, each library must set an @rpath install name, and the compiler must pass matching -rpath linker flags. The @rpath prefix instructs dyld to search a list of runtime paths supplied by the application at launch. The Vx runtime and shims do not share a single directory: in a source checkout, the runtime sits in target/<profile> and the MLIR shims sit in Cargo's build script output directory (OUT_DIR).

Our initial fix gave both libraries an @rpath name, but added only one -rpath entry for the runtime directory. Because the shims directory was missing from the search list, programs could not resolve the shims library at runtime.

An existing test fixture, host_workers_cover_the_loop_once.vx, immediately failed during linking. This fixture tests parallel loop execution by compiling and executing a binary. Because it links and runs a program, it exercised dynamic library loading and caught the missing linker flag before release.

Bug 2: early testing skipped the very `action` that makes vxc so powerful

Our smoke test tested only the --run flag, leaving the --action emit-obj compiler flag unverified. This omission hid an existing bug: emit-obj had been broken on Linux for sometime.

The bug originated in string conversion between Rust and MLIR's C API. In Rust, a string slice (&str) consists of a pointer and a byte length, without a terminating NUL (\0) byte. The C function mlirExecutionEngineCreate constructed an LLVM StringRef using only the pointer, calling strlen and discarding the provided length. The function read past the end of the string into adjacent heap bytes. Because the library path was corrupted, dynamic loading failed and the compiler wrote no object file.

This bug survived testing on macOS, Linux on EC2, and Ubuntu 22.04 in Docker. All three environments executed only --run. The --run flag passes the module to mlir-translate and links through Clang, while emit-obj builds an MLIR execution engine in-process. Testing multiple operating systems failed to detect the issue because every environment exercised the same code path.

Bug 3: the first draft of release missed core

The release packaging script used an outdated, manual list of standard library directories. After merging the library path fixes, the v0.0.2 release build failed on both macOS and Linux:

Frontend failed to parse 'uses_std.vx':
  Resolution error: Could not resolve import 'core::ops'

The packaging script explicitly copied only std and graph. When core was added to the repository later, the script was not updated. The standard library module std::vec imports core::ops. In a local git checkout, the compiler found core on disk. In the release archive, core was missing, causing imports to fail.

Bug 4: the fix for bug 3, caught by its own assertion

We updated the packaging script to copy every directory under stdlib/ dynamically, and added an assertion to verify that std and core were present before building the release archive. That new assertion failed on its very first execution.

The shell pattern stdlib/*/ passed directory names with trailing slashes to cp. On Unix, cp -R dir/ dest copies the files inside dir/ directly into dest, leaving out the enclosing folder name. This flattened all files into stdlib/, leaving no std/ directory for imports.

Because the assertion checked for stdlib/std immediately after copying, the release failed before creating a broken tarball. Adding assertions alongside packaging modifications catches script errors during the build.

What guards the release now

Our release pipeline now includes four automated guards, each verified by reproducing the failure and confirming the test caught it:

Check your own release

Any software project compiling shared libraries in CI can accidentally package build-machine paths. You can inspect your release archives using these commands:

# macOS
$ otool -D lib/*.dylib
$ otool -L bin/*

# Linux
$ readelf -d lib/*.so | grep -E 'RPATH|RUNPATH|SONAME'

On macOS, otool -D prints the library install name, and otool -L lists linked dependencies. On Linux, readelf -d displays dynamic section tags. If any command outputs a build-runner path, the binary will fail on user machines. In Vx v0.0.2, the libraries report relocatable paths:

$ otool -D lib/libvx_std_core.dylib
@rpath/libvx_std_core.dylib

Running an artifact solely on the machine that built it tests only that specific environment. Our original smoke test verified that the toolchain worked on the CI runner, while hiding that the binaries were broken on every other computer.

Vx v0.0.2 is available with all four guards in place. You can install it on macOS (Apple Silicon) or Linux (x86-64) by running: curl -fsSL https://vxlang.org/install.sh | sh. If the installer fails on your machine, please include the terminal output when opening an issue on GitHub.