diff --git a/crates/headroom-core/src/transforms/detection.rs b/crates/headroom-core/src/transforms/detection.rs index 386c28b57..3fabe96af 100644 --- a/crates/headroom-core/src/transforms/detection.rs +++ b/crates/headroom-core/src/transforms/detection.rs @@ -28,6 +28,14 @@ //! issue. Loud-on-error stays at the [`magika_detect`] entry point for //! callers who care; the chain swallows the err with a log line. //! +//! # CPU compatibility +//! +//! Precompiled ONNX Runtime binaries from `ort-sys` may contain AVX2-family +//! instructions. On x86/x86_64 CPUs where AVX2 is unavailable, the magika +//! session init returns an init error before touching ONNX. The chain +//! handles this identically to any other tier-1 error — logs it and falls +//! through to Tier 2 / Tier 3. +//! //! # SearchResults / BuildOutput //! //! The retired regex detector recognized grep-style search output @@ -84,6 +92,7 @@ pub fn detect(content: &str) -> ContentType { #[cfg(test)] mod tests { use super::*; + use crate::transforms::magika_detector::magika_onnx_runtime_supported_by_cpu; #[test] fn empty_input_short_circuits_to_plain_text() { @@ -93,19 +102,38 @@ mod tests { #[test] fn json_array_routes_via_tier_1() { let payload = r#"[{"id": 1}, {"id": 2}, {"id": 3}]"#; - assert_eq!(detect(payload), ContentType::JsonArray); + if !magika_onnx_runtime_supported_by_cpu() { + // On no-AVX2 hosts, magika returns Err and the chain + // falls through to Tier 2 (unidiff — no match for JSON) + // then Tier 3 (PlainText). + assert_eq!(detect(payload), ContentType::PlainText); + } else { + assert_eq!(detect(payload), ContentType::JsonArray); + } } #[test] fn source_code_routes_via_tier_1() { let py = "def hello():\n print('world')\n\nclass Foo:\n pass\n"; - assert_eq!(detect(py), ContentType::SourceCode); + if !magika_onnx_runtime_supported_by_cpu() { + // Magika fallthrough — unidiff won't catch Python source, + // so the chain lands on PlainText. + assert_eq!(detect(py), ContentType::PlainText); + } else { + assert_eq!(detect(py), ContentType::SourceCode); + } } #[test] fn html_routes_via_tier_1() { let html = "

x

"; - assert_eq!(detect(html), ContentType::Html); + if !magika_onnx_runtime_supported_by_cpu() { + // Magika fallthrough — unidiff won't catch HTML, + // so the chain lands on PlainText. + assert_eq!(detect(html), ContentType::PlainText); + } else { + assert_eq!(detect(html), ContentType::Html); + } } #[test] @@ -118,7 +146,8 @@ mod tests { + print(\"new\")\n"; // Either magika tags it `diff` (Tier 1 hit) or magika // mis-classifies as text and unidiff catches it (Tier 2). - // Both paths produce GitDiff. + // On no-AVX2 hosts, magika is unavailable so Tier 2 still + // catches the diff. Both paths produce GitDiff. assert_eq!(detect(diff), ContentType::GitDiff); } @@ -127,7 +156,7 @@ mod tests { // Magika often mis-classifies naked hunks (no `diff --git` // wrapper) because the visible bytes look like ordinary // patch lines mixed with code. Tier 2 (unidiff parser) - // catches these. + // catches these — even when magika is unavailable. let diff = "--- a/foo.py\n\ +++ b/foo.py\n\ @@ -1,2 +1,2 @@\n\ @@ -195,7 +224,13 @@ mod tests { // YAML lives in magika's `code` group; the chain returns it // as SourceCode so the router picks the code-aware compressor. let yaml = "name: my-app\nversion: 1.0\ndependencies:\n - foo\n"; - assert_eq!(detect(yaml), ContentType::SourceCode); + if !magika_onnx_runtime_supported_by_cpu() { + // Magika fallthrough — unidiff won't catch YAML, + // so the chain lands on PlainText. + assert_eq!(detect(yaml), ContentType::PlainText); + } else { + assert_eq!(detect(yaml), ContentType::SourceCode); + } } #[test] @@ -205,13 +240,21 @@ mod tests { impl Counter {\n \ pub fn new() -> Self { Self { counts: HashMap::new() } }\n\ }\n"; - assert_eq!(detect(rs), ContentType::SourceCode); + if !magika_onnx_runtime_supported_by_cpu() { + // Magika fallthrough — unidiff won't catch Rust source, + // so the chain lands on PlainText. + assert_eq!(detect(rs), ContentType::PlainText); + } else { + assert_eq!(detect(rs), ContentType::SourceCode); + } } #[test] fn chain_is_deterministic_across_repeated_calls() { // Magika returns the same label for identical input on // repeated calls; the chain wraps that determinism. + // On no-AVX2 hosts, the chain always falls through to + // PlainText — which is equally deterministic. let payload = r#"{"users": [{"id": 1}, {"id": 2}]}"#; let a = detect(payload); let b = detect(payload); diff --git a/crates/headroom-core/src/transforms/magika_detector.rs b/crates/headroom-core/src/transforms/magika_detector.rs index 7e0cea970..90c71d9c7 100644 --- a/crates/headroom-core/src/transforms/magika_detector.rs +++ b/crates/headroom-core/src/transforms/magika_detector.rs @@ -28,6 +28,12 @@ //! - **No router rewiring here.** PR3 lands the detector + tests //! only. PR5 flips the ContentRouter to call us instead of the //! regex-based [`crate::transforms::content_detector`]. +//! +//! - **CPU compatibility.** The precompiled ONNX Runtime binary shipped by +//! `ort-sys` may contain AVX2-family instructions on x86/x86_64. Where +//! AVX2 is unavailable on those targets, the session init returns an +//! error early instead of crashing with SIGILL; the detection chain then +//! falls through to Tier 2 and Tier 3 normally. use std::sync::mpsc; use std::sync::{Mutex, OnceLock}; @@ -39,6 +45,25 @@ use tracing; use crate::transforms::content_detector::ContentType; +/// Check whether the CPU can run the precompiled ONNX Runtime binary +/// that magika depends on. +/// +/// On x86/x86_64 without AVX2, the `onnxruntime` shared library shipped +/// by `ort-sys` can contain AVX2-family instructions that will SIGILL. +/// We detect this up front so the magika session init can fail gracefully +/// instead of crashing. +/// +/// On non-x86 targets, this x86-specific AVX2 gate is not applied. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +pub(crate) fn magika_onnx_runtime_supported_by_cpu() -> bool { + std::is_x86_feature_detected!("avx2") +} + +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub(crate) fn magika_onnx_runtime_supported_by_cpu() -> bool { + true +} + /// Errors from the magika detector. Wraps the underlying `magika::Error` /// so callers can match on whether init or inference broke without /// pulling magika types into their imports. @@ -102,6 +127,17 @@ fn magika_init_timeout() -> Duration { fn session() -> &'static Mutex> { MAGIKA_SESSION.get_or_init(|| { + // Early-out if the CPU can't run the precompiled ONNX Runtime. + // Without this check the `onnxruntime` shared library will crash + // with SIGILL on x86 CPUs lacking AVX2. + if !magika_onnx_runtime_supported_by_cpu() { + return Mutex::new(Err( + "Magika ONNX Runtime backend requires AVX2 on this platform; \ + falling back to non-Magika detection" + .to_string(), + )); + } + let timeout = magika_init_timeout(); let (tx, rx) = mpsc::channel(); // Run the (potentially hanging) ONNX init on a side thread so we @@ -234,9 +270,26 @@ mod tests { use super::*; fn assert_detect(content: &str, expected: ContentType, hint: &str) { - match magika_detect(content) { - Ok(got) => assert_eq!(got, expected, "{hint}: expected {expected:?}, got {got:?}"), - Err(e) => panic!("{hint}: detection failed: {e}"), + if !magika_onnx_runtime_supported_by_cpu() { + // On x86 hosts without AVX2 the magika session returns Err + // before any ONNX init — assert graceful degradation + // rather than panicking. + match magika_detect(content) { + Err(MagikaDetectorError::Init(msg)) => { + assert!( + msg.contains("AVX2"), + "{hint}: expected AVX2 error, got: {msg}" + ); + } + other => panic!("{hint}: on no-AVX2 host expected Init(AVX2) error, got {other:?}"), + } + } else { + match magika_detect(content) { + Ok(got) => { + assert_eq!(got, expected, "{hint}: expected {expected:?}, got {got:?}") + } + Err(e) => panic!("{hint}: detection failed: {e}"), + } } } @@ -368,15 +421,32 @@ index abc123..def456 100644 #[test] fn singleton_session_is_reused_across_calls() { - // Two back-to-back calls should both succeed without re-initing - // the model (no panic, no error). We can't directly observe - // the singleton hit-rate without instrumenting the test, but - // wall-clock asymmetry between the first and second call is - // strong evidence (cold ~50 ms, warm <1 ms). For the unit - // suite, just prove neither call errors. - magika_detect("hello world").unwrap(); - magika_detect("def f(): pass").unwrap(); - magika_detect(r#"{"a":1}"#).unwrap(); + // Two back-to-back calls should reuse the same session + // (or same cached error). On AVX2 hosts the session is + // Ok and repeated calls succeed; on no-AVX2 hosts the + // session is Err and repeated calls return the same Err. + if !magika_onnx_runtime_supported_by_cpu() { + // On no-AVX2 the singleton caches the init error; + // repeated calls all return the same Init error. + let r1 = magika_detect("hello world"); + let r2 = magika_detect("def f(): pass"); + let r3 = magika_detect(r#"{"a":1}"#); + for r in [&r1, &r2, &r3] { + match r { + Err(MagikaDetectorError::Init(msg)) => { + assert!(msg.contains("AVX2"), "expected AVX2 error, got: {msg}"); + } + other => panic!("on no-AVX2 host expected Init(AVX2) error, got {other:?}"), + } + } + } else { + // On AVX2 hosts the session loads once and all calls + // succeed. Wall-clock asymmetry (cold ~50 ms, warm + // <1 ms) confirms reuse. + magika_detect("hello world").unwrap(); + magika_detect("def f(): pass").unwrap(); + magika_detect(r#"{"a":1}"#).unwrap(); + } } #[test] diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index c91583aff..35907f057 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -441,6 +441,21 @@ print(f"Compressed content: {result.messages[0]['content'][:200]}...") --- +### "Native detector crashes with illegal instruction" + +On some older or virtualized x86_64 CPUs, AVX2 may be unavailable. The +Magika/ONNX Runtime detector can require AVX2 through its precompiled runtime +binary. Headroom skips that detector tier on x86/x86_64 hosts without AVX2 and +falls back to non-Magika detection tiers instead of crashing. + +If native startup still fails on an older CPU, set: + +```bash +export HEADROOM_REQUIRE_RUST_CORE=false +``` + +--- + ## Error Reference | Exception | Meaning | Solution |