Pre-RFC: Build only one target by default on docs.rs

Summary

Currently, docs.rs builds documentation for 5 targets by default for each crate published to crates.io. This (pre-)RFC proposes for docs.rs to only build x86_64-unknown-linux-gnu by default.

Motivation

Most crates are the same on all targets, so there's no point in building them for every target.

Most crates do not compile successfully when cross-compiled. As a result, trying to cross-compile them wastes time and resources for little benefit. By reducing the number of targets built by default, we can speed up queue time for all crates and additionally reduce the resource cost of operating docs.rs.

For most crates, the documentation is the same on every platform, so there's no need to build it many times. Building 5 targets means that builds take 5 times as long to finish and that docs.rs stores 5 times as much documentation, increasing our fixed costs. If docs.rs only builds for one target, then

  • queue times will go down for crate maintainers
  • storage costs will down for the docs.rs team, and
  • there will be fewer pages built that will never be looked at.

In particular, the very largest crates built by docs.rs are for embedded systems (usually generated using svd2rust). These crates can have several gigabytes of documentation and thousands of HTML files, all of which are the same on the current default targets.

For crates where the documentation is different, there's a simple way to opt-in to more targets in Cargo.toml:

[package.metadata.docs.rs]
targets = ["target1", "target2", ...]

Guide-level explanation

Target triples are used to determine the platform that code will run on. Some crates have different functionality on different platforms, and use conditional compilation.

docs.rs has an idea of a 'default target', which is the target shown on docs.rs/:crate/:name. docs.rs also builds other targets, which can be accessed by visiting docs.rs/:crate/:name/:target, and are linked in a 'Platform' dropdown on each documentation page.

Currently, docs.rs builds documentation for 5 tier 1 platforms by default:

  • x86_64-unknown-linux-gnu
  • i686-unknown-linux-gnu
  • x86_64-apple-darwin
  • i686-pc-windows-msvc
  • x86_64-pc-windows-msvc

After this change, docs.rs will only build one platform by default: x86_64-unknown-linux-gnu. This only changes the default, you can still opt-in to more or different targets if you choose by adding targets = [ ... ] to the docs.rs metadata in Cargo.toml.

All existing documentation will be kept. No previous releases will be affected, only releases made after the RFC was merged.

For most users, this will have no impact. Some crates that target many different platforms will be affected, especially if their features are substantially different between platforms. However, it is easy to opt-in to the old behavior, and they will have plenty of advance notice before the change is made.

For example, winapi chose to target all Windows platforms as follows:

targets = ["aarch64-pc-windows-msvc", "i686-pc-windows-msvc", "x86_64-pc-windows-msvc"] 

Reference-level explanation

  1. If a crate published to crates.io has neither targets nor default-target configured in [package.metadata.docs.rs], it will be built for the x86_64-unknown-linux-gnu target.
  2. If it has default-target set but not targets, it will be built for default-target but no other platform.
  3. If it has targets set but not default-target, it will be treated as if the first entry in targets is the default target.
  4. If both are set, all targets will be built and default-target shown on /:crate/:version. Targets are deduplicated, so specifying the same target twice has no effect.
  5. If targets is set to an empty list,
    • If default-target is unset, it will be built for x86_64-unknown-linux-gnu.
    • Otherwise, it will be built for default-target.

Examples

  • This crate will be built for x86_64-unknown-linux-gnu.
[package.metadata.docs.rs]
all-features = true
  • These crates will be built for x86_64-pc-windows-msvc.
[package.metadata.docs.rs]
default-target = "x86_64-pc-windows-msvc"
[package.metadata.docs.rs]
targets = ["x86_64-pc-windows-msvc"]
  • These crates will be built for all Windows platforms.

In this case the default target will be aarch64-pc-windows-msvc.

targets = ["aarch64-pc-windows-msvc", "i686-pc-windows-msvc", "x86_64-pc-windows-msvc"]

In this case the default target will be x86_64-pc-windows-msvc.

default-target = "x86_64-pc-windows-msvc"
targets = ["aarch64-pc-windows-msvc", "i686-pc-windows-msvc", "x86_64-pc-windows-msvc"] 
  • This crate will be built for x86_64-apple-darwin.
default-target = "x86_64-apple-darwin"
targets = []

Background: How many crates will be affected?

The answer to this question is not clear. docs.rs can measure the number of visits to /:crate/:version/:platform, but this only shows how many users are looking at pages built for different targets, not whether those pages are different. It also does not show pages which are different but have no visitors viewing the documentation.

With that said, here is a list of the visits to non-default platforms between 2020-06-29 and 2020-07-12. It does not include crates with fewer than 5 visits to non-default platforms.

The list was generated as follows:

Shell script
zgrep -h -o '"GET [^"]*" 200 ' /var/log/nginx/access.log* | grep -v winapi | cut -d / -f 2-4 | grep -v '^crate/' | grep -f ~/targets | cut -d / -f 1 | sort | uniq -c | awk '{ if ($1 >= 5) print $0 }' | sort -k 1 -h

Note that winapi is excluded since it explicitly gives its targets in Cargo.toml.

Background: Why do crates fail to build when cross-compiled?

  1. The most common reason by far is that they have native C dependencies.

Most crates with C dependencies have a dependency on pkg-config somewhere, and pkg-config does not allow cross compiling by default. See for example the build failure for crates-index-diff 7.0.2:

error: failed to run custom build command for `openssl-sys v0.9.57`
--- stdout
run pkg_config fail: "Cross compilation detected. Use PKG_CONFIG_ALLOW_CROSS=1 to override"

Here, pkg_config fails because docs.rs is cross compiling from x86_64-unknown-linux-gnu to i686-unknown-linux-gnu, and the entire build fails as a result.

  1. The second most common reason is that crates do feature detection in build.rs via the $TARGET variable passed by cargo, and assume that the host and target are the same (e.g. pass options for link.exe to cc). This is similar to the above case, but without pkg_config involved.

In both these cases, it is possible to fix the crate so it will cross-compile, but it has to be done by the crate maintainers, not by the docs.rs maintainers. The maintainers are often not aware that their crate fails to build when cross-compiled, or are not interested in putting in the effort to make it succeed.

Background: How long are queue times currently?

docs.rs has a default execution time limit of 15 minutes. At time of writing, there are only two crates with exceptions to this limit: 20 and 25 minutes respectively.

Most of the time, when the docs.rs queue gets backed up it isn't because of a single crate clogging up the queue, but instead because many crates have been released at the same time. Some projects release over 300 crates at the same time, and building all of them can take several hours, delaying other crates in the meantime.

By building only a single target, we can reduce this delay significantly. Additionally, this will reduce delays overall significantly, even for projects that only publish a single crate at a time.

Drawbacks

  • Crates that have different docs on different platforms will be missing those docs if the maintainer takes no action.

Rationale and alternatives

  • We could keep the status quo, building all 5 targets for all crates. Doing so would avoid breaking the documentation for any crate, but keep queue times relatively high, and over time would increase our resource usage a great deal.

Prior art

  • GoDoc has docs for only one platform (by default, Linux).
  • Racket has docs for only a single platform but does not say which platform ('This is an installation-specific listing').

Unresolved questions

  • How long should docs.rs wait before changing the default?
  • How many crates will this affect? Note that 'affect' is ambiguous here since a crate could have different docs on different platforms but not intend to have differences. For example, rustdoc is non-deterministic and generates trait implementations in different orders depending on the platform: futures::mpsc::Receiver does not use #[cfg()], but has different traits orders between x86_64-unknown-linux-gnu and i686-pc-windows-gnu.

Future possibilities

  • docs.rs plans to add build logs for all targets in the future (currently it only has logs for the default target). Building for one target would save generating many of the logs - we'd only generate them for targets that were explicitly requested.
12 Likes

Originally I was going to post this directly as an RFC. But when I gathered the stats, it showed the non-default targets being used a lot (~8 percent of all our visits). So the team wasn't sure whether there was some better alternative than the one proposed here. Opinions welcome!

1 Like

Potential alternate solution: generate alternate tier-1 platform docs on-demand. When someone navigates to the platform-specific docs page, (inform them) and queue up docs for that platform.

3 Likes

Hmm ... A potential issue there is that the build may not succeed. We could queue the build, tell the user to wait for a unknown amount of time, and they come back to see no docs. This is also the case currently, but at least as-is you can look at the docs after they've been published and see all the available targets in a drop-down.

How would you expect the on-demand builds to be prioritized relative to new builds? I think I'd want them to have a lower priority so that newly published crates get their docs up quickly, but that could mean very long queue times if e.g. there was a recent rusoto release.

Alternative : Detect architectures

Build the documentation for x86_64-unknown-linux-gnu first.

If the code build contained #cfg attribute relative to specific OS, build the doc for those OS too with x86_64 for architecture.

If the code contain #cfg attribute for other architecture, build those architecture with unknown-linux-gnu for OS

Drawback

More complex. Probably need to modify rustdoc.

Alternative : Single doc

Make Rustdoc build a single documentation for all triple, mentioning on the item pages if there are not available for all architectures.

Drawback

More complex. Probably need to modify rustdoc.

3 Likes

Another alternative possibility: build 2 very different targets (e.g. x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc), and if the documentation comes out identical for both, assume it'll likely be identical for other targets. If it isn't, then provide the documentation for those two, provide a note somewhere saying that they're different, and suggest that the crate author may wish to either fix that by using cfg(rustdoc) to show all documentation on all platforms, or by giving an explicit target list in Cargo.toml.

That would help gently push people towards using cfg(rustdoc) for target-specific documentation.

3 Likes

Would this proposal still be compelling if it we went from 5 to 3 instead of 5 to 1, cutting the 32 bit variants? While I agree that most crates don't need more than a single platform, I'm not sure I agree the default target should be GNU Linux if nothing is present. On the principle of least surprise, I would expect uploading a Windows or Mac only crate to work out of the box without adding metadata.

1 Like

docs.rs aside, I would love to have a way to specify "supported targets" in Cargo.toml, for crates that only support specific targets. That would provide better machine-readable metadata, rather than just using #[cfg(not(...))] compile_error! in the source.

If we had such a mechanism, docs.rs could build for that target.

2 Likes

Unfortunately it appears that rustdoc is non-reproducible across different targets, e.g. somehow the target influences the order traits render in:

Comparing just hashes of the files that docs.rs has, 231/770 pages of futures v0.3.1 are different cross-target despite futures having no target specific code.

1 Like

Windows or Mac only crates have difficulty already because docs.rs only supports building from Linux. Setting a crate up to cross-compile correctly (even just for docs) is much more work than adding a little metadata of which targets should be supported.

1 Like

That sounds like a bug - is there an issue open?

This may be as simple as a missing sort on those impls in rustdoc. There are a lot of hashmaps, and while rustdoc tries to always sort things before displaying them, there may be a missing sort and these may be coming out in hashmap order.

As far as I can tell, the Vecs in the values of the impls field of Cache are generated in FxHashSet iter order. They're then partitioned, then rendered, then the rendered HTML is sorted before being concatenated. Something in that process seems to result in a non-deterministic order. That should be fixable.

Alternative : Detect architectures

I really like this idea! Does anyone know if there's an existing option to print cfgs seen in the code? Rustc has to detect those for macro expansion anyway so it seems like it should be possible without too much trouble.

The only potential issue is if someone tries to build for too many targets and overloads the server, but we already have a limit of 10 by default so I don't think it will be a problem.

Alternative : Single doc

While I agree this is the ideal solution, this is one of the oldest open rustdoc bugs, filed in 2012: #1998. In fact, not having this affected the design of rustdoc in many other ways; see for example #43348 and #73566. So I don't think this will happen any time soon.

docs.rs already has targets = [...]. I know it's docs.rs specific, but I don't think we need another option for it (at least for this use case).

On the principle of least surprise, I would expect uploading a Windows or Mac only crate to work out of the box without adding metadata.

This is already not the case, because the first target built is (by default) Linux, and if the first build fails docs.rs assumes all the other builds will fail. This is the original reason we introduced default-target = ....

And of course, like @Nemo157 mentioned, we can't really change the default-target to anything else by default because it will require cross-compiling and break many crates for the reasons mentioned in the RFC.

At least libstd seems to have a single documentation that includes OS-dependent APIs (e.g. https://doc.rust-lang.org/std/os/index.html)

Does docs.rs work differently? Why?

If you look at the source code, they have conditional compilation specifically for rustdoc: https://doc.rust-lang.org/src/std/os/mod.rs.html#1-74. So it requires some setup on their end. The way they use rustdoc also means that it only works when documenting, because it doesn't type check if you assume all platforms are valid: https://hastebin.com/uwilafabod.coffeescript. This is what I was talking about before in Pre-RFC: Build only one target by default on docs.rs.

I asked on Zulip how this might be implemented and @ehuss had the idea to use syn + a package to parse cfg expressions, for example https://crates.io/crates/parse_cfg. A potential issue is that you can have #[cfg] mod foo;, and foo.rs might not exist on all platforms, so it would have to gracefully fallback in that case. Maybe we could take @XAMPPRocky's suggestion of only building 3 major platforms by default, then trying to be smarter about it using the cfg idea? That would hit the common case of 'no cfg at all' while still making sure that documentation shows up for most major platforms if the crate uses some cfg pattern that docs.rs can't handle.

Rather than do proper module tree walking, I'd expect a "#[cfg] scraper" just to look at all of the .rs files in ./src/**/*.rs, which is a cheaper superset to find.

Also, no need to pull in syn, even. I hereby license the following example under UNLICENSE and waive any copyright to the code; feel free to use it in any project for any reason.

cfg scraper
use {
    ignore::{types::TypesBuilder, WalkBuilder},
    proc_macro2::{Delimiter, TokenStream, TokenTree},
    std::{fs::File, io::prelude::*, path::Path, str},
};

pub fn scrape_file(
    tts: impl IntoIterator<Item = TokenTree>,
    mut callback: impl FnMut(TokenStream),
) {
    let mut tts = tts.into_iter();
    while let Some(tt) = tts.next() {
        if let TokenTree::Punct(t) = tt {
            if t.as_char() == '#' {
                if let Some(TokenTree::Group(tt)) = tts.next() {
                    if tt.delimiter() == Delimiter::Bracket {
                        let mut tts = tt.stream().into_iter();
                        if let Some(TokenTree::Ident(t)) = tts.next() {
                            if t == "cfg" {
                                if let Some(TokenTree::Group(tt)) = tts.next() {
                                    if tt.delimiter() == Delimiter::Parenthesis {
                                        callback(tt.stream());
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

pub fn scrape_dir(path: impl AsRef<Path>) -> Vec<TokenStream> {
    let mut cfgs = vec![];
    let walk = WalkBuilder::new(path)
        .types(
            TypesBuilder::new()
                .add_defaults()
                .select("rust")
                .build()
                .unwrap(),
        )
        .build();
    let mut buf = vec![];
    for entry in walk {
        // if error processing this entry, just move to the next one
        let _ = (|| -> Result<(), ()> {
            let entry = entry.map_err(drop)?;
            buf.clear();
            File::open(entry.path())
                .map_err(drop)?
                .read_to_end(&mut buf)
                .map_err(drop)?;
            scrape_file(
                str::from_utf8(&buf)
                    .map_err(drop)?
                    .parse::<TokenStream>()
                    .map_err(drop)?,
                |cfg| cfgs.push(cfg),
            );
            Ok(())
        })();
    }
    cfgs
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn self_test() {
        let cfgs = scrape_dir(env!("CARGO_MANIFEST_DIR"));
        let mut cfgs: Vec<_> = cfgs.into_iter().map(|stream| stream.to_string()).collect();
        cfgs.sort();
        assert_eq!(cfgs, &["test"]);
    }
}

This only scrapes for #[cfg], a real solution probably needs to look for #[cfg_attr] as well.

EDIT: couldn't help myself, made a version that recursively handles #[cfg_attr] properly as well:

cfg and cfg_attr scraper
use {
    ignore::{types::TypesBuilder, WalkBuilder},
    proc_macro2::{Delimiter, TokenStream, TokenTree},
    std::{fs::File, io::prelude::*, path::Path, str},
};

fn scrape_attr(tts: impl IntoIterator<Item = TokenTree>, mut callback: impl FnMut(TokenStream)) {
    let mut tts = tts.into_iter();
    if let Some(TokenTree::Ident(t)) = tts.next() {
        if t == "cfg" {
            if let Some(TokenTree::Group(tt)) = tts.next() {
                if tt.delimiter() == Delimiter::Parenthesis {
                    callback(tt.stream());
                }
            }
        }
        if t == "cfg_attr" {
            if let Some(TokenTree::Group(tt)) = tts.next() {
                if tt.delimiter() == Delimiter::Parenthesis {
                    let mut tts = tt.stream().into_iter();
                    // the cfg part
                    callback(
                        tts.by_ref()
                            .take_while(|tt| {
                                if let TokenTree::Punct(t) = tt {
                                    t.as_char() != ','
                                } else {
                                    true
                                }
                            })
                            .collect(),
                    );
                    // the attr part
                    scrape_attr(tts, callback);
                }
            }
        }
    }
}

pub fn scrape_file(
    tts: impl IntoIterator<Item = TokenTree>,
    mut callback: impl FnMut(TokenStream),
) {
    let mut tts = tts.into_iter();
    while let Some(tt) = tts.next() {
        if let TokenTree::Punct(t) = tt {
            if t.as_char() == '#' {
                if let Some(TokenTree::Group(tt)) = tts.next() {
                    if tt.delimiter() == Delimiter::Bracket {
                        scrape_attr(tt.stream(), &mut callback);
                    }
                }
            }
        }
    }
}

pub fn scrape_dir(path: impl AsRef<Path>) -> Vec<TokenStream> {
    // inner fn so we only monomorphize code once
    fn scrape_dir(path: &Path) -> Vec<TokenStream> {
        let mut cfgs = vec![];
        let walk = WalkBuilder::new(path)
            .types(
                TypesBuilder::new()
                    .add_defaults()
                    .select("rust")
                    .build()
                    .unwrap(),
            )
            .build();
        let mut buf = vec![];
        for entry in walk {
            // if error processing this entry, just move to the next one
            let _ = (|| -> Result<(), ()> {
                let entry = entry.map_err(drop)?;
                buf.clear();
                File::open(entry.path())
                    .map_err(drop)?
                    .read_to_end(&mut buf)
                    .map_err(drop)?;
                scrape_file(
                    str::from_utf8(&buf)
                        .map_err(drop)?
                        .parse::<TokenStream>()
                        .map_err(drop)?,
                    |cfg| cfgs.push(cfg),
                );
                Ok(())
            })();
        }
        cfgs
    }
    scrape_dir(path.as_ref())
}

#[cfg(test)]
#[cfg_attr(CFG_ATTR_TEST1, cfg(CFG_ATTR_TEST2))]
mod tests {
    use super::*;

    #[test]
    fn self_test() {
        let cfgs = scrape_dir(env!("CARGO_MANIFEST_DIR"));
        let mut cfgs: Vec<_> = cfgs.into_iter().map(|stream| stream.to_string()).collect();
        cfgs.sort();
        assert_eq!(cfgs, &["CFG_ATTR_TEST1", "CFG_ATTR_TEST2", "test"]);
    }
}

There are probably still edge cases but this should handle >99% of cases correctly.

4 Likes

This looks great! I ran it on ~/src/rust which has probably too much rust code and it came up with the following:

fn main() {
    use std::collections::HashSet;

    let dir = std::env::args().nth(1).unwrap();
    println!("looking in {}", dir);
    let cfgs = scrape_dir(dir);
    for cfg in cfgs.into_iter()
        .map(|stream| stream.to_string())
        .filter(|s| !s.starts_with("feature ") && !s.starts_with("feature="))
        .collect::<HashSet<_>>()
    {
        println!("{}", cfg);
    }
}
any ( target_arch = "x86" , target_arch = "x86_64" )
any ( feature = "std" , feature = "test_logging" )
any ( feature = "singlecore" )
not ( target_os = "android" )
not ( any ( target_os = "macos" , target_os = "ios" , target_os = "freebsd" , target_os = "netbsd" ) )
all ( not ( target_os = "windows" ) , not ( feature = "selinux-fix" ) )
any ( feature = "testing_only_libclang_3_9" , feature = "testing_only_libclang_4" , feature = "testing_only_libclang_5" , feature = "testing_only_libclang_9" )
not ( any ( unix , windows ) )
all ( feature = "diesel_sqlite_pool" , feature = "diesel_postgres_pool" )
fuzzing
docsrs
not ( feature = "logging" )
not ( target_arch = "x86" )
any ( test , feature = "serialize_structs" )
not ( feature = "tls" )
any ( target_os = "freebsd" , target_os = "netbsd" , target_os = "openbsd" , target_os = "solaris" )
any ( feature = "dualcore" )
not ( feature = "wasm" )
not ( feature = "no-std" )
any ( target_os = "android" , target_os = "linux" , target_arch = "wasm32" , windows )
not ( test )
target_arch = "arm"
not ( target_arch = "aarch64" )
not ( feature = "fuzzing" )
any ( target_arch = "x86_64" , target_arch = "x86" )
any ( target_os = "fuchsia" )
not ( debug_assertions )
target_pointer_width = "16"
all ( target_arch = "wasm32" , target_vendor = "unknown" , target_os = "unknown" , target_env = "" , )
atomic_cas
nightly
not ( feature = "a" )
any ( feature = "stm32h753" , )
all ( target_arch = "x86_64" , feature = "simd" )
all ( feature = "std" , atomic_cas )
all ( any ( target_os = "linux" , target_os = "macos" ) , target_pointer_width = "64" )
any ( not ( target_os = "android" ) , not ( target_arch = "arm" ) )
not ( any ( target_os = "freebsd" , target_os = "netbsd" ) )
tests
not ( trace_tokenizer )
target_arch = "x86_64"
all ( not ( feature = "std" ) , not ( test ) )
not ( target_arch = "x86_64" )
target_arch = "x86"
target_arch = "aarch64"
all ( feature = "dualcore" , feature = "cm4" )
any ( feature = "diesel_sqlite_pool" , feature = "diesel_postgres_pool" , feature = "diesel_mysql_pool" )
not ( feature = "transport" )
not ( windows )
not ( any ( feature = "selinux-fix" , windows ) )
not ( any ( target_arch = "x86_64" , target_arch = "x86" ) )
all ( test , target_endian = "little" )
any ( target_os = "android" , target_os = "linux" )
all ( any ( target_os = "android" , target_os = "linux" ) , feature = "dev_urandom_fallback" )
not ( feature = "std" )
any ( all ( any ( target_os = "android" , target_os = "linux" ) , feature = "dev_urandom_fallback" ) , target_os = "freebsd" , target_os = "netbsd" , target_os = "openbsd" , target_os = "solaris" )
debug_assertions
target_pointer_width = "32"
all ( not ( target_os = "ios" ) , target_arch = "arm" )
all ( feature = "color-backtrace" , not ( feature = "cc" ) )
not ( feature = "device-selected" )
test
not ( target_arch = "wasm32" )
any ( feature = "stm32h742v" , feature = "stm32h743v" , feature = "stm32h750v" , )
all ( feature = "databases" , feature = "sqlite_pool" )
trace_tokenizer
not ( any ( target_arch = "x86" , target_arch = "x86_64" ) )
all ( feature = "jemalloc" , not ( target_env = "msvc" ) )
windows
any ( target_arch = "aarch64" , target_arch = "arm" , target_arch = "x86_64" )
not ( feature = "gen-tests" )
not ( target_os = "windows" )
target_os = "linux"
not ( feature = "selinux-fix" )
any ( all ( any ( target_os = "android" , target_os = "linux" ) , not ( feature = "dev_urandom_fallback" ) ) , target_arch = "wasm32" , windows )
not ( unix )
all ( test , any ( unix , windows ) )
rustfmt
all ( feature = "singlecore" , feature = "dualcore" )
not ( feature = "revision_v" )
not ( feature = "klee-analysis" )
unix
target_pointer_width = "64"
not ( feature = "runtime" )
any ( target_os = "freebsd" , target_os = "netbsd" )
not ( feature = "private-cookies" )
not ( any ( debug_assertions , feature = "testing_only_extra_assertions" , ) )
target_arch = "wasm32"
not ( any ( target_arch = "aarch64" , target_arch = "arm" , target_arch = "x86_64" ) )
all ( feature = "dualcore" , feature = "cm7" )
any ( feature = "stm32h753v" , )
any ( feature = "stm32h747cm7" , )
any ( feature = "stm32h742" , feature = "stm32h743" , feature = "stm32h750" , )
all ( feature = "cm7" , feature = "cm4" )
any ( feature = "stm32h757cm7" , )
not ( target_os = "macos" )
target_os = "windows"
target_os = "freebsd"
all ( target_env = "musl" , target_pointer_width = "64" )
any ( target_os = "macos" , target_os = "ios" )

So this definitely looks doable :slight_smile: Thanks so much!