I've got a little Tauri app that reads YouTube videos as text instead of watching them, and alongside the desktop app there's a plain terminal client that shares the same SQLite database — mostly so I can run it somewhere the desktop app can't go, like iSH on my phone. Cross-compiling it for iSH sounded like a five-minute job: pick the right target triple, cargo build --release, copy the binary over. It was not a five-minute job. It took three separate, unrelated bugs stacked on top of each other, and the error message the whole time was just Illegal instruction, no stack trace, no panic message, nothing.
Writing this down because every one of the three problems had a completely different fix, and I want to remember the order I found them in next time this happens.
iSH runs a real Alpine Linux userland on an emulated x86 CPU, so getting a binary onto it is normally just a matter of cross-compiling for i686-unknown-linux-musl and copying the file over — python3 -m http.server on the Mac, wget from iSH. That part worked exactly like it's supposed to. The binary ran, the menus showed up, reading list stuff worked fine since it's all local SQLite.
Then I searched for a channel, which needs an actual HTTPS request to YouTube, and the whole thing just died:
Search channels: being honest
Illegal instruction
No Rust panic, no backtrace. Just gone. On the Mac, the exact same code searching the exact same thing worked instantly.
Before touching any Rust code I wanted to know if this was even a Rust problem. iSH ships busybox, and busybox has wget, so:
wget -O- https://www.youtube.com 2>&1 | head -c 300
That worked fine — full page back, no errors. So the network path was healthy and iSH's own TLS stack could talk to the same host with no trouble. Whatever was crashing was specific to my binary.
iSH emulates a 32-bit x86 CPU, and — this is apparently a well-known limitation, it's the same reason Node.js doesn't run there — it doesn't implement SSE2 at all. Just doesn't have it.
The problem is that Rust's regular i686-unknown-linux-musl target assumes SSE2 is available, because that's what the target's calling convention is built on:
$ rustc --target i686-unknown-linux-musl --print cfg | grep feature
target_feature="crt-static"
target_feature="fxsr"
target_feature="sse"
target_feature="sse2"
So every i686 binary you build with the normal target has SSE2 instructions baked into it just from the standard library alone, and iSH has no way to run those. That right there explains the crash, or so I thought.
The fix for this part is targeting i586-unknown-linux-musl instead, which has no SSE baseline at all — it falls back to old-style x87 floating point:
$ rustc --target i586-unknown-linux-musl --print cfg | grep feature
target_feature="crt-static"
Nothing else listed. That's the target I actually wanted.
Getting the linker set up for a target nobody normally uses took its own detour. Homebrew's musl-cross formula can build an i486/i686 cross-compiler, but it compiles from source, and it turns out my Mac is on a macOS version new enough that Homebrew won't even attempt a from-source build on it. So instead of fighting that, I used the messense/rust-musl-cross Docker image, which ships a working i686-unknown-linux-musl-gcc I could point the i586 target at:
docker run --rm -v "$PWD":/home/rust/src messense/rust-musl-cross:i686-musl \
bash -c "rustup target add i586-unknown-linux-musl && \
cargo build --release --target i586-unknown-linux-musl -p textube-cli"
Rebuilt, copied it over, ran the same search. Same crash. Exact same Illegal instruction, no better.
So the SSE2 baseline wasn't the only problem. The actual TLS library I was using — reqwest with the default rustls crypto backend, which is aws-lc-rs — ships its own hand-written assembly for things like AES and does its own CPU feature checks at runtime, completely independent of whatever target the Rust compiler thinks it's building for. iSH's CPU emulation doesn't handle whatever that runtime check was doing, and it just faulted.
Swapped the crypto backend to ring instead, same idea (fast asm-based crypto), same problem class. This one didn't crash outright, but it failed differently — every single TLS connection died with:
Search failed: Network error contacting YouTube: error sending request for url (...) -> client error (Connect) -> cannot decrypt peer's message
I turned on rustls's own logging to see exactly where it was dying:
RUST_LOG=rustls=debug ./textube-cli
and the log showed the handshake completing all the way through — ClientHello sent, ServerHello parsed, cipher suite picked — and then just stopping, right where it should decrypt the server's first encrypted message. Every time. Deterministically, not flaky.
The actual fix was switching away from asm-optimized crypto entirely and using rustls-rustcrypto, a crypto provider written in plain Rust with no hand-tuned assembly:
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "logging"] }
rustls-rustcrypto = "0.0.2-alpha"
rustls_rustcrypto::provider()
.install_default()
.expect("failed to install rustls crypto provider");
Rebuilt, ran it again, and the handshake actually completed this time — "TLS1.3 encrypted extensions" showed up in the log, which is the exact line that never printed before. Real progress. And then it crashed again, a little further down the road, right after:
DEBUG rustls_platform_verifier::verification::others] Loaded 141 CA root certificates from the system
Illegal instruction
At this point the pattern was obvious: it's not any single library, it's that a bunch of Rust crypto crates each independently check the CPU at runtime and jump into hand-written SIMD code paths if they think the hardware supports it — completely ignoring what target features the compiler was actually told to use. Compiling for i586 stops the compiler from emitting SSE2, but it does nothing to stop a crate from checking is_x86_feature_detected!("sse2") at runtime and using it anyway if the check comes back true (which, on iSH's imperfect CPU emulation, it apparently does).
I confirmed this with objdump, checking the actual compiled binary for SSE/AVX register usage:
objdump -d target/i586-unknown-linux-musl/release/textube-cli | grep -icE '%xmm|%ymm'
First run: 1609 hits. Way more than zero, despite the "no SSE" target. So I went hunting, crate by crate, using the binary's own symbol table to find out who was responsible each time:
objdump -d textube-cli | awk '/^[0-9a-f]+ </{sym=$0} /xmm|ymm/{print sym}' | sort -u | c++filt
Each hit pointed at a different RustCrypto crate, and each one turned out to have its own escape hatch — a --cfg flag, or a Cargo feature — to force the plain, portable implementation instead of the hardware-dispatched one. None of them use the same name for it, which made this slower than it should've been:
| Crate | How to force software-only |
|---|---|
aes | --cfg aes_force_soft |
polyval | --cfg polyval_force_soft |
poly1305 | --cfg poly1305_force_soft |
chacha20 v0.9 | --cfg chacha20_force_soft |
chacha20 v0.10 | --cfg chacha20_backend="soft" |
sha2 | Cargo feature force-soft |
ppv-lite86 | Cargo feature no_simd |
httparse | env var CARGO_CFG_HTTPARSE_DISABLE_SIMD=1 |
A couple of those are worth a special mention. chacha20 shows up twice in the dependency tree at two different major versions — one pulled in by chacha20poly1305, a newer one pulled in directly — and they use two completely different cfg names for the exact same "please don't use SIMD" request, so you need both at once or you'll fix half the crashes and not understand why the other half are still there.
And httparse's flag isn't really a Cargo convention at all — its build script just checks for a literal environment variable that happens to be named like a Cargo-internal one (CARGO_CFG_...), which had me convinced for a while that I'd already set it correctly when I hadn't.
All of that went into .cargo/config.toml, scoped to just the one target so nothing else is affected:
[env]
CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1"
[target.i586-unknown-linux-musl]
linker = "i686-unknown-linux-musl-gcc"
rustflags = [
"--cfg", "aes_force_soft",
"--cfg", "polyval_force_soft",
"--cfg", "poly1305_force_soft",
"--cfg", "chacha20_force_soft",
"--cfg", "chacha20_backend=\"soft\"",
]
plus the matching Cargo features for sha2 and ppv-lite86 added straight to Cargo.toml as direct dependencies (Cargo unifies features across the whole build, so pulling them in directly just to flip a feature on works fine even though the actual code path is used transitively).
Rebuilt, checked the instruction count again — 984, then 561 after the next fix, then 76, then finally:
$ objdump -d target/i586-unknown-linux-musl/release/textube-cli | grep -icE '%xmm|%ymm'
0
Zero. Copied that binary over to iSH and the search actually worked — real results back from YouTube, no crash.
If you're cross-compiling something with a TLS stack for an old or emulated 32-bit x86 target and it dies with Illegal instruction the moment it touches the network:
rustc --target <triple> --print cfg) — don't assume i686 is safe just because it "sounds old."objdump, because plenty of crates do their own runtime CPU dispatch that a "no SIMD" compile target alone won't stop.The error message never changes through any of this — it's Illegal instruction the whole way down, for three completely different reasons in a row. objdump and rustls's own debug logging were the only things that actually told me where in the process it was dying each time, rather than just that it was dying.