This document records the complete process of cross-compiling the Zed remote server
binary for FreeBSD 15.0 from an Arch Linux host using cargo-zigbuild.
| Component | Version / Details |
|---|---|
| Host OS | Arch Linux |
| Target | FreeBSD 15.0-RELEASE (x86_64) |
| Zed source | tag v1.0.0 (commit 69e64dd) |
cargo-zigbuild |
0.22.3 (wrappers in ~/.cache/cargo-zigbuild/0.22.3/) |
| Zig | 0.15.2 (bundled with cargo-zigbuild) |
| Rust target | x86_64-unknown-freebsd (rustup target add x86_64-unknown-freebsd) |
The sysroot provides FreeBSD system libraries and headers that the cross-compiler needs at link time. Download from the official FreeBSD release server:
mkdir -p ~/freebsd-sysroot
curl -L -o /tmp/base.txz \
https://download.freebsd.org/releases/amd64/amd64/15.0-RELEASE/base.txz
# Extract only the lib/ and usr/ directories
tar -xJf /tmp/base.txz -C ~/freebsd-sysroot lib/ usr/The resulting layout:
~/freebsd-sysroot/
lib/ # libkvm.so.7, libgeom.so.5, libz.so.6, ...
usr/lib/ # libkvm.so → ../../lib/libkvm.so.7 (symlinks)
# libprocstat.so → libprocstat.so.1
# libz.so → ../../lib/libz.so.6
# *.a files (static archives)
usr/include/ # system headers (zlib.h, libprocstat.h, kvm.h, ...)
Six source files need modification. The changes fall into three categories:
The crash-handler and minidumper crates don't support FreeBSD. We gate them
behind cfg(not(target_os = "freebsd")) and provide no-op stubs.
New file: crates/crashes/src/lib.rs (dispatcher)
#[cfg(not(target_os = "freebsd"))]
mod crashes_full;
#[cfg(not(target_os = "freebsd"))]
pub use crashes_full::*;
#[cfg(target_os = "freebsd")]
mod crashes_freebsd;
#[cfg(target_os = "freebsd")]
pub use crashes_freebsd::*;New file: crates/crashes/src/crashes_freebsd.rs (no-op stubs)
use std::future::Future;
use std::path::Path;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
pub static REQUESTED_MINIDUMP: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct InitCrashHandler {
pub session_id: String,
pub zed_version: String,
pub binary: String,
pub release_channel: String,
pub commit_sha: String,
}
pub fn init<F: Future<Output = ()> + Send + Sync + 'static>(
_crash_init: InitCrashHandler,
_spawn: impl FnOnce(BoxFuture<'static, ()>),
_wait_timer: impl (Fn(std::time::Duration) -> F) + Send + Sync + 'static,
) {
log::info!("crash handler disabled on FreeBSD");
}
pub fn crash_server(_socket: &Path) {
log::info!("crash server disabled on FreeBSD");
}
pub fn set_gpu_info(_specs: ()) {}
pub fn set_user_info(_info: ()) {}Rename: crates/crashes/src/crashes.rs → crates/crashes/src/crashes_full.rs
The original file is renamed with no content changes. The lib.rs dispatcher
includes it only on non-FreeBSD targets.
Modified: crates/crashes/Cargo.toml
Gate crash-handler, minidumper, and their exclusive deps behind FreeBSD exclusion:
[dependencies]
cfg-if.workspace = true
futures.workspace = true
log.workspace = true
parking_lot.workspace = true
release_channel.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(not(target_os = "freebsd"))'.dependencies]
async-process.workspace = true
crash-handler.workspace = true
minidumper.workspace = true
paths.workspace = true
system_specs.workspace = true
zstd.workspace = true
# ... other platform-specific deps unchanged ...
[lib]
path = "src/lib.rs" # was: "src/crashes.rs"Modified: crates/remote_server/Cargo.toml
Move crash-handler and minidumper to a cfg that also excludes FreeBSD:
[target.'cfg(not(any(target_os = "windows", target_os = "freebsd")))'.dependencies]
crash-handler.workspace = true
minidumper.workspace = true
[target.'cfg(not(windows))'.dependencies]
fork.workspace = true
libc.workspace = trueThe queue module (priority queue for async tasks) is compiled for Linux and
Windows but not FreeBSD. FreeBSD has the same POSIX APIs as Linux here.
Modified: crates/gpui/src/gpui.rs (2 lines)
// Line 38: add target_os = "freebsd"
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd", target_family = "wasm"))]
pub mod queue;
// Line 105: add target_os = "freebsd"
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd", target_family = "wasm"))]
pub use queue::{PriorityQueueReceiver, PriorityQueueSender};The original code called MaybeUninit::<T>::uninit() then accessed fields via
kif.as_mut_ptr() which requires the pointee to be initialized. On FreeBSD the
kinfo_file struct has padding that triggers UB. Fix: use zeroed() + assume_init_mut().
Modified: crates/fs/src/fs.rs (lines ~576-590)
let mut kif = MaybeUninit::<libc::kinfo_file>::zeroed();
let kif = unsafe { kif.assume_init_mut() }; // zeroed is a valid initial state
kif.kf_structsize = libc::KINFO_FILE_SIZE;
let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, kif as *mut _) };
anyhow::ensure!(result != -1, "fcntl returned -1");
let c_str = unsafe { CStr::from_ptr(kif.kf_path.as_ptr()) };Root cause: When cross-compiling for FreeBSD, libz-sys always vendors and
compiles zlib from source (its build.rs has cross_compiling && !target.contains("-apple-")
which forces building from source). The vendored build produces .o files with
non-PIC relocations (R_X86_64_32, R_X86_64_32S) that are incompatible with
PIE linking. This causes linker errors like:
ld.lld: error: relocation R_X86_64_32 cannot be used against local symbol; recompile with -fPIC
Note: zig cc itself correctly handles -fPIC — the clang -cc1 invocation
includes -mrelocation-model pic -pic-level 2 and produces correct GOT-relative
relocations in .rela.text. The issue is specifically in how libz-sys's build
system compiles zlib, not in zig's PIC support.
Related upstream issue: ziglang/zig#24818
addresses PIE failures in FreeBSD's CRT startup code (different symptom, same
error class). That issue is closed/fixed, but it is unrelated to the libz-sys
vendored build problem.
Workaround: Instead of compiling zlib from source, link against the FreeBSD
sysroot's pre-built libz.so.
New directory: patches/libz-sys-1.1.22/
Copy the entire crate from ~/.cargo/registry/src/.../libz-sys-1.1.22/ and
apply this patch to build.rs:
// Insert after `let cross_compiling = target != host;` (line 84)
// FreeBSD cross-compilation: use system libz from sysroot instead of
// building from source, since zig cc produces non-PIC object files.
if cross_compiling && target.contains("freebsd") {
if let Ok(lib_dir) = env::var("ZLIB_LIB_DIR") {
println!("cargo:rustc-link-search=native={}", lib_dir);
println!("cargo:rustc-link-lib=dylib=z");
return;
}
}Then add to Cargo.toml:
[patch.crates-io]
libz-sys = { path = "patches/libz-sys-1.1.22" }There are three environment-level problems that require workarounds in the build command itself:
Already solved by the libz-sys patch above. The vendored zlib .o files
contain non-PIC relocations because libz-sys's build process does not ensure
-fPIC is passed when cross-compiling for FreeBSD. Other -sys crates that
compile C code (libgit2-sys, ring, psm, etc.) use the cc crate which defaults
to -fPIC on FreeBSD, so they produce correct PIC code.
We set CFLAGS for completeness and add the sysroot include path so that crates
like libgit2-sys can find zlib.h:
CFLAGS_x86_64_unknown_freebsd="-fPIC -I$HOME/freebsd-sysroot/usr/include"
When cargo-zigbuild's wrapper script (zigcc-x86_64-unknown-freebsd-*.sh)
invokes the linker, it calls cargo-zigbuild zig cc which has its own internal
sysroot handling. Even when -L /path/to/sysroot/lib is passed, ld.lld
does not search those paths for -lkvm or -lprocstat. The result:
ld.lld: error: undefined symbol: kvm_openfiles
ld.lld: error: undefined symbol: procstat_open_sysctl
Verified: zig cc -target x86_64-freebsd -L ~/freebsd-sysroot/usr/lib -lkvm test.c
works, but cargo-zigbuild zig cc -target x86_64-freebsd -L ~/freebsd-sysroot/usr/lib -lkvm test.c does not.
Workaround: Pass the full .so file paths directly as link arguments:
RUSTFLAGS="-C link-args=$HOME/freebsd-sysroot/usr/lib/libkvm.so \
-C link-args=$HOME/freebsd-sysroot/usr/lib/libprocstat.so"
This bypasses the -l library search entirely.
Standard -L flags are still needed for Rust's own linker resolution:
RUSTFLAGS="... -L $HOME/freebsd-sysroot/lib -L $HOME/freebsd-sysroot/usr/lib"
ZLIB_LIB_DIR="$HOME/freebsd-sysroot/usr/lib" \
CFLAGS_x86_64_unknown_freebsd="-fPIC -I$HOME/freebsd-sysroot/usr/include" \
RUSTFLAGS="-L $HOME/freebsd-sysroot/lib \
-L $HOME/freebsd-sysroot/usr/lib \
-C link-args=$HOME/freebsd-sysroot/usr/lib/libkvm.so \
-C link-args=$HOME/freebsd-sysroot/usr/lib/libprocstat.so" \
cargo zigbuild -p remote_server --release --target x86_64-unknown-freebsdBuild time: ~6 minutes on a modern machine.
# Verify binary
file target/x86_64-unknown-freebsd/release/remote_server
# Output: ELF 64-bit LSB pie executable, x86-64, ..., FreeBSD-style, dynamically linked
# Strip for deployment (520MB → ~80MB)
llvm-strip --strip-all \
target/x86_64-unknown-freebsd/release/remote_server \
-o /tmp/remote_server-strippedThe Zed client expects the remote server binary at a specific path based on the
version string. For Zed 1.0.0+stable:
# On the FreeBSD machine
mkdir -p ~/.zed_server
scp host:/tmp/remote_server-stripped \
~/.zed_server/zed-remote-server-stable-1.0.0+stable
chmod +x ~/.zed_server/zed-remote-server-stable-1.0.0+stableThe binary is dynamically linked against FreeBSD system libraries (libz.so.6,
libkvm.so.7, libprocstat.so.1, etc.) which are pre-installed on
FreeBSD 15.0-RELEASE.
The Zed client (local zeditor) determines the remote OS by running uname -sm
over SSH. The function parse_platform() in crates/remote/src/transport.rs only
recognizes Darwin and Linux — it immediately errors on FreeBSD:
let os = match os {
"Darwin" => RemoteOs::MacOs,
"Linux" => RemoteOs::Linux,
_ => anyhow::bail!(
"Prebuilt remote servers are not yet available for {os:?}. ..."
),
};This error is thrown before the client checks whether a binary already exists on the remote, so even though we manually placed the binary at the correct path, the connection is rejected.
Workaround: Override uname on the FreeBSD machine to report Linux x86_64
when called with -sm, so that Zed treats the remote as a Linux host and follows
the posix code path (which works for FreeBSD since both are POSIX).
mkdir -p ~/.local/bin
cat > ~/.local/bin/uname << 'EOF'
#!/bin/sh
if [ "$1" = "-sm" ]; then
echo "Linux x86_64"
else
/usr/bin/uname "$@"
fi
EOF
chmod +x ~/.local/bin/unameSSH non-interactive sessions do not read ~/.zshrc. They read ~/.zshenv
instead. Add the PATH override there:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshenvVerify from the host machine:
ssh -p <port> <user>@<host> uname -sm
# Should output: Linux x86_64Once this is in place, the Zed client will:
- Detect the remote as Linux
- Follow the posix path style and shell conventions
- Find the pre-placed binary at
~/.zed_server/zed-remote-server-stable-1.0.0+stable - Skip download (binary already exists)
- Launch the proxy successfully
Limitations:
- If Zed ever tries to update the remote binary (e.g. after a client upgrade),
it would download a Linux prebuilt which won't work on FreeBSD. To prevent this,
keep the
upload_binary_over_sshsetting or ensure the binary is always present before connecting. - The proper upstream fix is to add
FreeBSDto theRemoteOsenum incrates/remote/src/remote_client.rs,parse_platform()incrates/remote/src/transport.rs, and the target triple mapping — a ~10 line change.
| # | Bug | Severity | Upstream |
|---|---|---|---|
| 1 | libz-sys always vendors zlib when cross-compiling, producing .o files with non-PIC relocations (R_X86_64_32, R_X86_64_32S) that fail PIE linking — the vendored build does not pass -fPIC through cc crate's default logic |
High | rust-lang/libz-sys |
| 2 | cargo-zigbuild zig cc ignores -L paths when resolving -l for FreeBSD dynamic libs |
Medium | rust-cross/cargo-zigbuild |
| 3 | libz-sys always vendors zlib when cross-compiling, even when a sysroot is available |
Low | rust-lang/libz-sys |
| 4 | crash-handler/minidumper don't support FreeBSD |
Low | EmbarkStudios/crash-handler |
| File | Change |
|---|---|
Cargo.toml |
Add libz-sys patch |
Cargo.lock |
Remove source/checksum for patched libz-sys |
crates/gpui/src/gpui.rs |
Add target_os = "freebsd" to 2 cfg gates |
crates/fs/src/fs.rs |
Fix MaybeUninit usage in current_path() |
crates/crashes/Cargo.toml |
Gate crash-handler deps for FreeBSD |
crates/crashes/src/lib.rs |
New: cfg dispatcher |
crates/crashes/src/crashes_full.rs |
Renamed from crashes.rs (no content change) |
crates/crashes/src/crashes_freebsd.rs |
New: no-op stubs |
crates/remote_server/Cargo.toml |
Gate crash-handler/minidumper for FreeBSD |
patches/libz-sys-1.1.22/build.rs |
Patched: link sysroot libz.so instead of vendored build |