โ—index ๐Ÿงฌape-rust-extern-static.md ๐Ÿท๏ธtags ๐Ÿ‘คabout

๐Ÿงฌ Why `const` Doesn't Quite Fit: Turning libc Constants Into Runtime Lookups

Third post in the one-bin-to-rule-them-all series. Previously: intro, the probe matrix.

The previous post ended with a claim: every single portability finding in the probe matrix is the same kind of problem ๐Ÿ•ต๏ธ.. The Rust code has a constant in it, the constant was set at compile time from Linux's header values, and on a cosmo-linked binary running on another kernel, the value that constant should have is different.

This post is about the fix ๐Ÿ”ง.. It's the thesis of the series, and it's one idea:

Don't bake libc constants into the binary at compile time. Expose them as symbols, and let the cosmo loader fill them in at load time with the host's actual values.

That's "Strategy 3." The rest of the post is about why Strategy 3 wins over the alternatives, what it looks like in actual code (spoiler: extern "C" { static X: c_int; }), what the cascade of forks looks like once you commit to it, and how much it costs to maintain.

Three strategies, one pick

When you look at the matrix from post 2 and the pattern behind it, there are really only three shapes of fix available. I'll call them Strategy 1, 2, and 3.

Strategy 1: fix it in cosmopolitan. Have cosmocc emit Linux-musl-compatible layouts and constants on every host. The struct stat that gets written back into memory from a stat() syscall, it would always have the musl-aarch64 offsets, regardless of whether the actual kernel is Darwin or FreeBSD. EINVAL would always be 22, regardless of what the host kernel thinks. Everything would look like Linux to the binary, because cosmopolitan would normalise it.

Where this wins: zero Rust-side work. Every existing Rust crate that assumes Linux constants would Just Work on cosmocc-built binaries.

Where it loses: cosmocc isn't a Rust toolchain. It's a C toolchain with a lot of other C users. Rewriting its ABI to be musl-shaped on every host is months of work, is somebody else's project, affects the whole C ecosystem that builds on it, and requires politically-committed maintenance. Also, not every constant can be normalised without pretending โ€” e.g. you can't give Windows a real fork() or a real SIGUSR1, you can only lie about them. For a Rust-on-cosmo investigation running on a weekend budget, it's out of scope before the conversation starts.

Strategy 2: Rust-side per-target cfg. Sprinkle #[cfg(all(target_env = "cosmo", host_os = "freebsd"))] (or some synthetic equivalent) across std and crates, and pick different values per target OS at compile time.

Where this wins: it's very clear what's happening, and no runtime indirection.

Where it loses: it defeats the entire point of APE. The promise of APE is one binary, many OSes. If you're compiling separate binaries per host OS, you're back to a cross-compile matrix with extra steps, and the whole "single binary" premise is gone. Strategy 2 is what you'd reach for if you wanted to deny cosmo exists and pretend each host is its own rustc target. It's not a cosmo strategy at all.

Strategy 3 (chosen): turn Linux-baked constants into runtime lookups. For every libc constant that diverges across OSes, redefine the Rust view of it as an extern "C" { static X: c_int; } symbol. The cosmopolitan runtime already exports those same names as extern const int X in its C headers, populated on program startup with the host's actual values. The Rust binary does not know the numeric value of EINVAL at compile time. It asks the cosmo loader, which hands it 22 on Linux, 35 on macOS, 87 on Windows, 41 on FreeBSD.

Where this wins: one binary preserved, one fork of libc to maintain, a handful of std patches on top, and every divergent finding from the matrix collapses into the same mechanism.

Where it loses: you do pay at link time and a bit at runtime. An extern static read is a symbol load, one memory indirection. const is zero. For a hot loop, that matters. For EINVAL-classification on an error return, it doesn't. And you've given up const fn composability: unsafe { libc::SOCK_CLOEXEC } can't appear in a const expression, so call sites which used | SOCK_CLOEXEC in a const position have to be demoted to regular code. I'll show a few of those below.

What extern-static looks like in Rust

Two lines:

๐Ÿฆ€rustโ€บ3 lines
  1unsafe extern "C" {
  2    pub static O_NONBLOCK: c_int;
  3}

That's it. The identifier libc::O_NONBLOCK now resolves to a memory load from the symbol O_NONBLOCK, which the linker expects to find somewhere in the link. In a cosmopolitan-linked binary, that somewhere is the cosmopolitan runtime, which has extern const int O_NONBLOCK in its C headers and defines it out of a load-time table keyed by host OS. On Linux that table says 2048; on FreeBSD / OpenBSD / macOS it says 4.

Every place in the Rust code that used to be if flags & libc::O_NONBLOCK != 0 { ... } now has to be if flags & unsafe { libc::O_NONBLOCK } != 0 { ... }. The value is still a c_int, type-safety preserved, no allocations, no runtime dispatch beyond the one symbol load. Just: ask the symbol what its value is, instead of reading off a const.

Doing this once for O_NONBLOCK is a handful of lines. Doing it across all of rust-lang/libc for every constant that might vary under cosmopolitan is the project I'll walk through below.

Here's the actual diff that lives in libc-cosmo's aarch64 file, verbatim from libc-cosmo/src/unix/linux_like/linux/musl/b64/aarch64/mod.rs:

๐Ÿฆ€rustโ€บ11 lines
  1pub const O_EXCL: c_int = 128;
  2pub const O_NOCTTY: c_int = 256;
  3#[cfg(not(cosmo))]
  4pub const O_NONBLOCK: c_int = 2048;
  5#[cfg(cosmo)]
  6unsafe extern "C" {
  7    // Cosmopolitan runtime value varies per-OS: Linux 2048,
  8    // FreeBSD/OpenBSD/macOS = 4, Windows 2048.
  9    pub static O_NONBLOCK: c_int;
 10}
 11pub const O_SYNC: c_int = 1052672;

That's about five lines of diff to promote one constant to a runtime lookup. The same shape repeats for O_CLOEXEC, SOCK_STREAM, AF_INET, CLOCK_MONOTONIC, SOL_SOCKET, MSG_NOSIGNAL, and every SO_* socket option the code actually references. That's the first fork.

The fork: libc-cosmo

libc-cosmo is a fork of rust-lang/libc that leaves everything alone except the constants that actually diverge under cosmo. Every original pub const X: c_int = N; stays exactly where it was. Beside it, under #[cfg(cosmo)], a conditional twin redeclares X as an extern static, so when the crate is compiled with --cfg=cosmo the linker goes looking for X in the cosmo runtime instead of baking a number into the rlib. No call site has to move, no types change, and a non-cosmo build of the fork is indistinguishable from upstream.

The constants that need this treatment are the file flags (O_NONBLOCK, O_CLOEXEC and friends), the socket constants (SOCK_*, SOL_SOCKET, and the whole SO_* family), FIONBIO, and the CLOCK_* IDs. A handful of derived values like O_NDELAY were defined in terms of the above and get dropped under cfg(cosmo) because they can no longer appear in a const expression.

And the one struct-layout fix that F-001 demands:

๐Ÿฆ€rustโ€บ9 lines
  1#[cfg(cosmo)]
  2pub struct stat {
  3    pub st_dev: crate::dev_t,              // @ 0,  8B
  4    pub st_ino: crate::ino_t,              // @ 8,  8B
  5    pub st_nlink: c_ulong,                 // @ 16, 8B
  6    pub st_mode: crate::mode_t,            // @ 24, 4B
  7    pub st_uid: crate::uid_t,              // @ 28, 4B
  8    // ...
  9}

Measured with a separate offsets.com probe that does the equivalent of offsetof(struct stat, st_mode) at runtime on each target. The measurements agreed across every cosmo-aarch64 target, which is the culprit: it's cosmocc's layout, not any particular kernel's, and F-001 is fixed the same way everywhere.

Errno values are the other half of the story, and they needed something slightly different. Cosmo's libc exposes every errno name as extern const errno_t X populated at load time with the host's value โ€” not as a pub const we could gate in libc-cosmo. So errno lives in a std patch instead (see below).

The libc-cosmo diff against upstream is modest: a batch of constants promoted from const to extern static, one struct redefined for aarch64, and a handful of derived-constant removals. The work is mechanical rather than clever, and sitting on a named branch keeps the rebase story against upstream tractable.

[patch.crates-io] doesn't work, and the workaround

Getting a forked libc into a normal Rust project is a one-line Cargo.toml edit:

โš™tomlโ€บ2 lines
  1[patch.crates-io]
  2libc = { path = "../libc-cosmo" }

Getting a forked libc into std is not that. Under -Z build-std, std is rebuilt from the rustup sysroot, and it has its own Cargo.toml that doesn't look at the application's [patch.crates-io]. cargo re-extracts std's libc from the real crates.io, ignores your patch, and you end up with two different libcs linked into the same binary. If they have the same feature set you get worse: std uses musl values for the same constants your code uses cosmo values for, and you can't tell from the type system.

I tried three avoidance paths: a registry symlink (broke when cargo re-extracted), cargo vendor --sync pointed at the std manifest (checksum mismatch), and, briefly, just shipping two libcs and hoping (no).

Only working path: edit rustup's own library/std/Cargo.toml to point libc's dep at libc-cosmo directly. Something like:

โš™tomlโ€บ3 lines
  1# .../rustup/toolchains/nightly-.../lib/rustlib/src/rust/library/std/Cargo.toml
  2[target.'cfg(all(not(target_os = "aix"), not(all(windows, target_env = "msvc", not(target_vendor = "uwp")))))'.dependencies]
  3libc = { path = "/abs/path/to/libc-cosmo", default-features = false, ... }

Yes, editing files inside a rustup-managed toolchain feels wrong, and it is: if you upgrade nightly the file reverts. The project's patches/apply.sh script does this edit with a placeholder (@LIBC_COSMO_PATH@) substituted in at apply time, and revert.sh undoes it. That way the patch is reproducible without being bound to any one absolute path, and the rustup toolchain can be returned to stock before every nightly update.

This is the item that silently wastes a day. I mentioned it at the end of post 2 because it's the kind of thing you'd never find from reading the Rust docs; [patch.crates-io] looks like it should work, and when it doesn't, the error message is about rlib candidates, not about std's dep resolution.

The std patches (the painful part)

Once std resolves libc through the fork, the cascade begins. Every place std's source code used to treat libc::X as a compile-time value breaks, because X is now an extern static that can't appear in const expressions. The patches live in patches/std/ with an apply.sh / revert.sh pair. A quick tour:

sys/time/unix.rs was the cleanest example, because it previewed everything else. The old code had pub(crate) const CLOCK_ID: libc::clockid_t = libc::CLOCK_MONOTONIC;. A const that references an extern static is not a thing. The patch turns the constant into a function:

๐Ÿฆ€rustโ€บ4 lines
  1// was: pub(crate) const CLOCK_ID: libc::clockid_t = libc::CLOCK_MONOTONIC;
  2pub(crate) fn clock_id() -> libc::clockid_t {
  3    unsafe { libc::CLOCK_MONOTONIC }
  4}

And every Self::CLOCK_ID call site becomes Self::clock_id(). That's a pattern you see over and over across the patch set: const X = libc::Y โ†’ fn x() { unsafe { libc::Y } }, call sites updated. Nothing exotic, just tedious.

sys/pal/unix/sync/condvar.rs is the same pattern: const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC becomes fn CLOCK() -> libc::clockid_t { unsafe { libc::CLOCK_MONOTONIC } }, closes F-005 and F-011 together.

os/unix/net/ancillary.rs is the pattern's match-arm flavour. Original code does match (*cmsg).cmsg_level { SOL_SOCKET => ... }. You can't match against a non-const pattern. Rewrite as if/else:

๐Ÿฆ€rustโ€บ2 lines
  1let cmsg_level = (*cmsg).cmsg_level;
  2if cmsg_level == unsafe { libc::SOL_SOCKET } { ... }

The rest โ€” sys/fs/unix/dir.rs, sys/fs/unix.rs, sys/pal/unix/futex.rs, sys/thread/unix.rs, os/unix/net/stream.rs โ€” is all unsafe { libc::X } wrapping at each read site. The changes are small and easy to spot in a diff.

The money shot: sys/io/error/unix.rs

The biggest patch is the one that closes F-002 and F-008 (errno divergence). It's also the one that most clearly shows the cost of the strategy.

The original std code looks like this, simplified:

๐Ÿฆ€rustโ€บ11 lines
  1pub fn decode_error_kind(errno: i32) -> io::ErrorKind {
  2    use io::ErrorKind::*;
  3    match errno as libc::c_int {
  4        libc::E2BIG => ArgumentListTooLong,
  5        libc::EADDRINUSE => AddrInUse,
  6        libc::EADDRNOTAVAIL => AddrNotAvailable,
  7        libc::EBUSY => ResourceBusy,
  8        // ... 30 more arms ...
  9        _ => Uncategorized,
 10    }
 11}

Every errno value matched against io::ErrorKind, one match expression, exhaustive, compiler-checked. Relies absolutely on every libc::E* being a const i32 so the match arms are valid patterns.

Under cosmo, libc::E* can't be const. Every errno on every host is different. So: declare the whole set as extern statics:

๐Ÿฆ€rustโ€บ8 lines
  1#[cfg(cosmo)]
  2unsafe extern "C" {
  3    #[link_name = "E2BIG"]       static COSMO_E2BIG: libc::c_int;
  4    #[link_name = "EACCES"]      static COSMO_EACCES: libc::c_int;
  5    #[link_name = "EADDRINUSE"]  static COSMO_EADDRINUSE: libc::c_int;
  6    // ... 35 more ...
  7    #[link_name = "EXDEV"]       static COSMO_EXDEV: libc::c_int;
  8}

#[link_name = "E2BIG"] points each Rust-side symbol at the actual cosmopolitan C symbol (the COSMO_ prefix is a namespace trick to keep these declarations out of libc::*). Cosmo's runtime populates them at load time with the host's value: EAGAIN is 11 on Linux, 35 on macOS, and so on.

The match rewrites into an if/else cascade because a match can't match against an extern static pattern:

๐Ÿฆ€rustโ€บ15 lines
  1#[cfg(cosmo)]
  2pub fn decode_error_kind(errno: i32) -> io::ErrorKind {
  3    use io::ErrorKind::*;
  4    let e = errno as libc::c_int;
  5    unsafe {
  6        if e == COSMO_E2BIG { return ArgumentListTooLong; }
  7        if e == COSMO_EADDRINUSE { return AddrInUse; }
  8        if e == COSMO_EADDRNOTAVAIL { return AddrNotAvailable; }
  9        if e == COSMO_EBUSY { return ResourceBusy; }
 10        // ... 30 more ...
 11        if e == COSMO_EACCES || e == COSMO_EPERM { return PermissionDenied; }
 12        if e == COSMO_EAGAIN { return WouldBlock; }
 13    }
 14    Uncategorized
 15}

Same errnos, same classifications, but each is now a runtime comparison against a runtime-resolved symbol. Once this landed, Mac's errno=35 correctly classified as WouldBlock โ€” it had been mis-classified as Deadlock, because Linux's 35 is EDEADLK, which is what was causing process::Command to hang on Mac. The same patch closes F-002 on BSD and F-008 on Windows.

If there's one code change in this series that embodies the whole thing, it's probably this one: every errno symbol that std matches against gets redeclared as an extern static, and the match becomes an if/else cascade.

The supporting cast

Once libc-cosmo exists, every other crate in your dependency graph that reads a now-extern-static libc symbol in a const position also has to be patched. The change per crate is usually very small โ€” an unsafe { ... } wrap, a const fn demoted to a regular fn โ€” but it has to happen in every crate in the graph.

For the Rust I ended up porting across posts 4 and 5 (ripgrep, dog, xh), the downstream forks are all small and share the same shape:

  • getrandom-cosmo skips getrandom's Linux syscall probe under cfg(cosmo) and goes straight to /dev/urandom, because the probe checks errno == libc::ENOSYS and the ENOSYS value on cosmo isn't what Linux uses.
  • socket2-cosmo wraps each place libc::SOCK_NONBLOCK, SOCK_CLOEXEC, SOL_SOCKET, and O_NONBLOCK show up in non-unsafe positions, and drops const from a couple of helper methods (Type::nonblocking, Type::_cloexec) that now read extern statics.
  • mio-cosmo and tokio-cosmo are each a few unsafe { } wraps around the same constants in the unix socket and pipe paths.

The ripple effect is real but it's bounded by how many distinct libc constants the crate actually touches, which is rarely more than a handful.

Should you bother?

The bigger "is this a real path or a tech demo?" question is the one I already wrote about in post 1, and I don't want to relitigate the whole thing here. The short version for post 3 is: Strategy 3 seems to me like the right technical shape for the problem: it's the only fix that keeps single-binary intact and puts the change where the divergence actually lives, inside libc's assumption that its constants are compile-time knowable.

If you're just here for the tech demo, the binaries from post 1 are built on exactly this strategy. Download probe.com or dog.com, run them on a BSD, and watch the binary figure out what OS it's really on at startup and behave correctly ๐Ÿฆ€.

Next post: a ported dog DNS CLI, six targets, and the Windows-specific rabbit hole (GetAdaptersAddresses and a new cosmo-sysconf crate) that turned out to be less about libc constants and more about "some things cosmo can't normalise at all" ๐Ÿ‡..

:discuss share / comment on Mastodon โ†’