mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
CI installed 'onnxruntime>=1.16.0' for the Rust ort runtime. That floor is 8 minor versions too low, and the failure mode below it is a silent hang. Why 1.24: ort-sys computes ORT_API_VERSION = 17 + one per enabled api-N feature, and Cargo features are additive across the graph. fastembed 5.17.3 enables api-24, so the constant resolves to 24 and ort rejects any lower runtime. Why it hangs instead of failing: on rejection ort calls Error::new() from inside load_dylib_from_path, which already runs inside the Once that setup_api() is initialising. Building the error re-enters that Once, and std::sync::Once blocks forever on re-entry. Reproduced in isolation with a bare Session::builder() and onnxruntime 1.21.1 - killed after >1h at 0% CPU, no output; ORT_DYLIB_PATH makes no difference. With 1.24.4 the same call returns in 1.2s and the kompress parity fixtures pass 21/21. Per @RubenAAA this is not limited to old runtimes: a box that resolves no libonnxruntime at all hangs identically (0.0% CPU, threads in futex_wait_queue, nothing onnx-shaped in /proc/<pid>/maps). Any failure inside load_dylib_from_path re-enters the Once, so a pin alone cannot close it. So this adds a pre-flight to the dylib step asserting, before any test runs, that onnxruntime imports, that its minor is >= 24, and that a libonnxruntime object exists under capi/. Each failure exits 1 with an ::error:: annotation naming the cause, instead of burning the 30-minute timeout with an empty log. Verified all three branches locally (absent -> rc=1, 1.21.1 -> rc=1, 1.24.4 -> rc=0) and in CI, where it resolved onnxruntime 1.28.0 and exported the .so path. pyproject.toml is deliberately untouched: bumping the floor there makes headroom-ai[all] unsatisfiable via a pillow chain (onnxruntime>=1.24 forces pillow>=10.3.0,<12.0 while [all] requires pillow>=12.3.0). The user-facing hazard via headroom/_ort.py remains open and needs its own change.
259 lines
10 KiB
YAML
259 lines
10 KiB
YAML
name: rust
|
|
|
|
# Path gating lives in the `rust-changes` job below, NOT in a workflow-level
|
|
# `paths:` filter. The distinction matters for branch protection: a workflow
|
|
# skipped by `paths:` never creates its check runs at all, so a required status
|
|
# check from it sits pending forever on any PR that misses those paths, and the
|
|
# PR can never merge. A job skipped by `if:` still creates a check run, reports
|
|
# `skipped`, and GitHub counts skipped as success for a required check.
|
|
#
|
|
# Same coverage as the old filter — the path list moved verbatim into
|
|
# `rust-changes` — but `parity` is now safe to mark required on `main`.
|
|
on:
|
|
push:
|
|
branches: [ main, rust-rewrite ]
|
|
pull_request:
|
|
schedule:
|
|
# Nightly parity run at 07:17 UTC (weekdays only). Redundant with the
|
|
# per-PR gate below, but catches drift from toolchain/dependency updates
|
|
# that land without touching any filtered path.
|
|
- cron: '17 7 * * 1-5'
|
|
|
|
concurrency:
|
|
group: rust-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
# Default permissions: read-only. Individual jobs override only what they need.
|
|
# Mitigates CodeQL/CWE-275 (missing-workflow-permissions): the GITHUB_TOKEN
|
|
# defaults to whatever the repo policy is, which can be read-write. Pinning
|
|
# this here means even if the repo default changes, this workflow stays safe.
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
# Carries the path list the workflow-level `paths:` filter used to hold. Named
|
|
# `rust-changes` rather than `changes` so it does not collide with ci.yml's
|
|
# `changes` check.
|
|
rust-changes:
|
|
name: rust-changes
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
outputs:
|
|
rust: ${{ steps.decide.outputs.rust }}
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: dorny/paths-filter@v4
|
|
id: filter
|
|
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
|
with:
|
|
filters: |
|
|
rust:
|
|
- 'crates/**'
|
|
- 'Cargo.toml'
|
|
- 'Cargo.lock'
|
|
- 'rust-toolchain.toml'
|
|
- 'tests/parity/**'
|
|
- 'Makefile'
|
|
- '.github/workflows/rust.yml'
|
|
- id: decide
|
|
# `schedule` and `workflow_dispatch` have no diff to filter against, so
|
|
# they run the full suite — that is the point of the nightly job.
|
|
run: |
|
|
case "${{ github.event_name }}" in
|
|
pull_request|push) echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT" ;;
|
|
*) echo "rust=true" >> "$GITHUB_OUTPUT" ;;
|
|
esac
|
|
|
|
test:
|
|
name: test (ubuntu)
|
|
needs: rust-changes
|
|
if: needs.rust-changes.outputs.rust == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- name: Install stable toolchain
|
|
# Pin action code to @stable (latest fixes), toolchain version
|
|
# via input. The @1.95.0 ref shipped action code that errors on
|
|
# ubuntu-latest with `detected conflict: 'bin/cargo-clippy'`
|
|
# because the pre-installed runner Rust collides with the
|
|
# clippy-preview component install.
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
toolchain: 1.95.0
|
|
components: rustfmt, clippy
|
|
- name: Cache cargo registry + build
|
|
uses: Swatinem/rust-cache@v2
|
|
- uses: actions/setup-python@v6
|
|
with:
|
|
python-version: '3.11'
|
|
- name: Provide ONNX Runtime dylib
|
|
# headroom-core is built with `ort-load-dynamic` (see its
|
|
# Cargo.toml): the ONNX Runtime shared library is dlopen'd at
|
|
# runtime instead of statically linked, so the magika/detection
|
|
# tests need a real libonnxruntime.so and ORT_DYLIB_PATH pointing
|
|
# at it — same contract `headroom/_ort.py` fulfills for Python
|
|
# users via the pip `onnxruntime` package.
|
|
run: |
|
|
# >= 1.24, not 1.16: `ort`'s ORT_API_VERSION resolves to 24 because
|
|
# `fastembed` enables its `api-24` feature.
|
|
pip install 'onnxruntime>=1.24'
|
|
# Pre-flight, not just a pin. `ort` deadlocks rather than errors on
|
|
# ANY failure inside `load_dylib_from_path` — a version mismatch and
|
|
# a library that cannot be resolved at all both re-enter the `Once`
|
|
# that `setup_api()` is initialising, and `std::sync::Once` blocks
|
|
# forever on re-entry. Either way the job burns its full 30-minute
|
|
# timeout at 0% CPU with nothing in the log. Assert both conditions
|
|
# here so a bad runner fails in seconds with a readable message.
|
|
python - <<'PY' >> "$GITHUB_ENV"
|
|
import pathlib, sys
|
|
|
|
def die(msg: str) -> None:
|
|
print(f"::error::{msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
try:
|
|
import onnxruntime
|
|
except Exception as exc: # noqa: BLE001 - any import failure is fatal here
|
|
die(f"onnxruntime is not importable: {exc}")
|
|
|
|
version = onnxruntime.__version__
|
|
try:
|
|
major, minor = (int(part) for part in version.split(".")[:2])
|
|
except ValueError:
|
|
die(f"cannot parse onnxruntime version {version!r}")
|
|
if (major, minor) < (1, 24):
|
|
die(
|
|
f"onnxruntime {version} is too old: ort requires >= 1.24 "
|
|
"(ORT_API_VERSION=24, set by fastembed's api-24 feature). "
|
|
"ort DEADLOCKS instead of erroring below this, so the tests "
|
|
"would hang rather than fail."
|
|
)
|
|
|
|
capi = pathlib.Path(onnxruntime.__file__).parent / "capi"
|
|
libs = sorted(capi.glob("libonnxruntime.so*")) or sorted(capi.glob("libonnxruntime*.dylib"))
|
|
if not libs:
|
|
die(f"no libonnxruntime shared library under {capi}")
|
|
|
|
print(f"ORT_DYLIB_PATH={libs[0]}")
|
|
print(f"onnxruntime {version} -> {libs[0]}", file=sys.stderr)
|
|
PY
|
|
- name: cargo fmt --check
|
|
run: cargo fmt --all -- --check
|
|
- name: cargo clippy
|
|
run: cargo clippy --workspace -- -D warnings
|
|
- name: cargo test
|
|
run: cargo test --workspace
|
|
|
|
simulator-e2e:
|
|
name: simulator e2e (${{ matrix.os }})
|
|
needs: rust-changes
|
|
if: needs.rust-changes.outputs.rust == 'true'
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 30
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- name: Install stable toolchain
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
toolchain: 1.95.0
|
|
- name: Cache cargo registry + build
|
|
uses: Swatinem/rust-cache@v2
|
|
- name: cargo test simulator-backed proxy e2e
|
|
run: cargo test -p headroom-proxy --test e2e_simulators
|
|
|
|
wheels:
|
|
name: wheels (${{ matrix.target }})
|
|
needs: rust-changes
|
|
if: needs.rust-changes.outputs.rust == 'true'
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 45
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: ubuntu-latest
|
|
target: x86_64-unknown-linux-gnu
|
|
maturin-target: x86_64
|
|
- os: macos-14
|
|
target: aarch64-apple-darwin
|
|
maturin-target: aarch64-apple-darwin
|
|
- os: macos-15-intel
|
|
target: x86_64-apple-darwin
|
|
maturin-target: x86_64-apple-darwin
|
|
# Intel macOS uses `ort-load-dynamic` (no prebuilt ORT from ort-sys);
|
|
# Apple Silicon bundles ORT via `ort-download-binaries-rustls-tls`.
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: actions/setup-python@v6
|
|
with:
|
|
python-version: '3.11'
|
|
- name: "Build wheel (single-wheel architecture builds headroom-ai)"
|
|
uses: PyO3/maturin-action@v1
|
|
# Maturin reads `[tool.maturin]` from the root `pyproject.toml`
|
|
# which points at `crates/headroom-py/Cargo.toml` for the cdylib.
|
|
# Output is `headroom_ai-<ver>-<py>-<py>-<platform>.whl` containing
|
|
# both Python source and the compiled `headroom/_core.so`.
|
|
with:
|
|
command: build
|
|
args: --release --out dist
|
|
target: ${{ matrix.maturin-target }}
|
|
- name: Upload wheel artifact
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: wheels-${{ matrix.target }}
|
|
path: dist/*.whl
|
|
|
|
audit:
|
|
name: audit
|
|
needs: rust-changes
|
|
if: needs.rust-changes.outputs.rust == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
toolchain: 1.95.0
|
|
- uses: Swatinem/rust-cache@v2
|
|
- name: Install cargo-audit + cargo-deny
|
|
uses: taiki-e/install-action@v2
|
|
with:
|
|
tool: cargo-audit,cargo-deny
|
|
- name: cargo audit (soft-fail)
|
|
continue-on-error: true
|
|
run: cargo audit
|
|
- name: cargo deny check licenses
|
|
continue-on-error: true
|
|
run: cargo deny check licenses
|
|
|
|
# Blocking on every PR that touches Rust. Safe to harden now because the
|
|
# harness fails only on a Diff — `parity-run` sets `any_diffs` inside the
|
|
# diffed loop alone, so the 65 fixtures still served by `stub_comparator!`
|
|
# report as Skipped and cannot turn this red. Measured on main today:
|
|
# 111 matched / 65 skipped / 0 diffed.
|
|
#
|
|
# What it protects: the recorded fixtures are frozen Python output, so this
|
|
# gate catches the Rust side drifting away from that snapshot — exactly the
|
|
# failure mode the ongoing port produces. It cannot detect the Python side
|
|
# drifting away from the fixtures; that needs re-recording, not this job.
|
|
parity:
|
|
name: parity
|
|
needs: rust-changes
|
|
if: needs.rust-changes.outputs.rust == 'true'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
toolchain: 1.95.0
|
|
- uses: Swatinem/rust-cache@v2
|
|
# No Python toolchain: headroom-parity links headroom-core directly and
|
|
# never crosses into the interpreter, so the venv + maturin + `pip
|
|
# install -e .` setup this job used to do was pure overhead.
|
|
- name: Run parity harness
|
|
run: make test-parity
|