Skip to content

Instantly share code, notes, and snippets.

@G36maid
Last active April 29, 2026 12:45
Show Gist options
  • Select an option

  • Save G36maid/c2aff8c1561b307f38f9e1b3aff215e1 to your computer and use it in GitHub Desktop.

Select an option

Save G36maid/c2aff8c1561b307f38f9e1b3aff215e1 to your computer and use it in GitHub Desktop.

Building Zed Remote Server for FreeBSD via zigbuild

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.

Prerequisites

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)

Step 1: Prepare FreeBSD Sysroot

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, ...)

Step 2: Source Code Patches

Six source files need modification. The changes fall into three categories:

2a. Crash handling — no-op stubs for FreeBSD

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.rscrates/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 = true

2b. GPUI cfg gate — add FreeBSD to queue module

The 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};

2c. fs crate — fix MaybeUninit usage for FreeBSD

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()) };

2d. libz-sys patch — bypass vendored zlib build

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" }

Step 3: Build Command

There are three environment-level problems that require workarounds in the build command itself:

Problem A: libz-sys vendored zlib produces non-PIC object files

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"

Problem B: cargo-zigbuild ignores -L paths for dynamic library resolution

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.

Problem C: Sysroot library search paths

Standard -L flags are still needed for Rust's own linker resolution:

RUSTFLAGS="... -L $HOME/freebsd-sysroot/lib -L $HOME/freebsd-sysroot/usr/lib"

Complete build command

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-freebsd

Build time: ~6 minutes on a modern machine.

Step 4: Post-Build

# 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-stripped

Step 5: Deploy to FreeBSD Machine

The 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+stable

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

Step 6: Client-Side Workaround — uname Override

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

On the FreeBSD VM:

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/uname

SSH non-interactive sessions do not read ~/.zshrc. They read ~/.zshenv instead. Add the PATH override there:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshenv

Verify from the host machine:

ssh -p <port> <user>@<host> uname -sm
# Should output: Linux x86_64

Once this is in place, the Zed client will:

  1. Detect the remote as Linux
  2. Follow the posix path style and shell conventions
  3. Find the pre-placed binary at ~/.zed_server/zed-remote-server-stable-1.0.0+stable
  4. Skip download (binary already exists)
  5. 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_ssh setting or ensure the binary is always present before connecting.
  • The proper upstream fix is to add FreeBSD to the RemoteOs enum in crates/remote/src/remote_client.rs, parse_platform() in crates/remote/src/transport.rs, and the target triple mapping — a ~10 line change.

Summary of Bugs Found

# 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

Files Changed (Summary)

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment