Getting a Rust CLI to actually run on iSH (chasing down 'Illegal instruction')

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.

First: it built fine, then died on the first network call

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.

Ruling out the network itself

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.

Cause #1: iSH has no SSE2, and Rust's i686 target assumes it does

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.

Cause #2: the TLS crypto backend crashes even harder

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

Cause #3: it's not just the TLS crypto, it's everything doing runtime CPU dispatch

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:

CrateHow 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"
sha2Cargo feature force-soft
ppv-lite86Cargo feature no_simd
httparseenv 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.

What I'd check first next time

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:

  1. Check whether your target's baseline CPU features actually match the hardware/emulator you're running on (rustc --target <triple> --print cfg) — don't assume i686 is safe just because it "sounds old."
  2. Swap out any asm-optimized crypto backend for a pure-Rust one first, since that's usually the biggest and earliest offender.
  3. Then check the actual compiled binary for SIMD instructions with 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.