diff --git a/.github/workflows/qemu-linux-runtime.yml b/.github/workflows/qemu-linux-runtime.yml new file mode 100644 index 00000000..78451e39 --- /dev/null +++ b/.github/workflows/qemu-linux-runtime.yml @@ -0,0 +1,76 @@ +name: QEMU Linux Runtime + +# Proves fbuild can start Espressif QEMU on a stock Linux runner without any +# host library installation. `ubuntu-latest` (24.04) ships none of libslirp, +# libSDL2 or libpixman, which the Espressif QEMU binaries link against — so +# this job is red unless fbuild provisions them itself. +# +# There is deliberately NO apt-get step here. Adding one would make the job +# pass for the wrong reason. + +on: + workflow_dispatch: {} + push: + branches: [main] + paths: + - 'crates/fbuild-toolchain/**' + - 'crates/fbuild-daemon/src/handlers/emulator/**' + - 'ci/build_qemu_linux_runtime.py' + - '.github/workflows/qemu-linux-runtime.yml' + pull_request: + branches: [main] + paths: + - 'crates/fbuild-toolchain/**' + - 'crates/fbuild-daemon/src/handlers/emulator/**' + - 'ci/build_qemu_linux_runtime.py' + - '.github/workflows/qemu-linux-runtime.yml' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + qemu_runtime: + name: QEMU starts with no host libraries (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Confirm the runner really is missing the libraries + # Documents the premise of the test. Informational: a future runner + # image that ships them makes the test exercise the host path, which + # is still a valid pass. + run: | + for lib in libslirp.so.0 libSDL2-2.0.so.0 libpixman-1.so.0; do + if ldconfig -p | grep -q "$lib"; then + echo "present: $lib" + else + echo "absent: $lib <- fbuild must provision this" + fi + done + + - uses: astral-sh/setup-uv@v3 + + - name: Setup soldr + uses: zackees/setup-soldr@v0 + with: + version: 0.8.23 + cache: true + build-cache: true + target-cache: true + prebuild-deps: none + linker: platform-default + + - name: QEMU runtime provisioning test + run: | + soldr cargo test -p fbuild-toolchain --test qemu_linux_runtime -- \ + --ignored --nocapture diff --git a/.github/workflows/qemu-runtime-bundle.yml b/.github/workflows/qemu-runtime-bundle.yml new file mode 100644 index 00000000..e66f8bc2 --- /dev/null +++ b/.github/workflows/qemu-runtime-bundle.yml @@ -0,0 +1,50 @@ +name: Build QEMU Linux Runtime Bundle + +# Manually-dispatched builder for the runtime-library bundles fbuild downloads +# when a Linux host cannot start Espressif QEMU on its own. +# +# The bundle is always built inside an ubuntu:20.04 container, so the libraries +# it carries need at most GLIBC_2.30 — the same floor the Espressif QEMU +# binaries themselves have. The runner only supplies the CPU architecture: +# aarch64 needs a native arm64 runner because dpkg's maintainer scripts fail +# under emulated arm64. +# +# Upload the resulting artifacts to the `qemu-linux-runtime-v1` release and +# paste the printed SHA-256 into +# crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs. + +on: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + bundle: + name: ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v3 + + - name: Build bundle + run: | + uv run --no-project python ci/build_qemu_linux_runtime.py \ + --arch ${{ matrix.arch }} --out dist/qemu-runtime + + - uses: actions/upload-artifact@v4 + with: + name: qemu-esp-linux-runtime-${{ matrix.arch }} + path: dist/qemu-runtime/* + if-no-files-found: error diff --git a/ci/README.md b/ci/README.md index 3c711eee..c4119be7 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,6 +5,7 @@ Python scripts for CI, packaging, and development tooling. All invoked via `uv r ## Contents - **`build_dist.py`** -- Triggers GitHub Actions native builds, downloads artifacts, and assembles `dist/` for PyPI packaging +- **`build_qemu_linux_runtime.py`** -- Builds the Linux runtime-library bundle Espressif QEMU needs (`ldd` closure minus glibc, built on ubuntu:22.04). Published to the `qemu-linux-runtime-v1` release and downloaded on demand by `fbuild-toolchain`'s `esp_qemu_runtime` module; run in CI by `qemu-runtime-bundle.yml` - **`check_workspace_crates.py`** -- Monocrate guard: fails if the root `Cargo.toml` `[workspace] members` list gains a crate outside the approved allowlist (run by `crate-gate.yml`) - **`check_rust_toolchain_pins.py`** -- Prevents fbuild-owned Rust 1.95.0 MSRV, toolchain, workflow, and bootstrap declarations from drifting - **`enforce_platform_boundary.py`** -- Independent whole-tree and manifest checker for the exact host-platform occurrence ledger diff --git a/ci/build_qemu_linux_runtime.py b/ci/build_qemu_linux_runtime.py new file mode 100644 index 00000000..7a7fdbd4 --- /dev/null +++ b/ci/build_qemu_linux_runtime.py @@ -0,0 +1,275 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# /// +"""Build the Linux runtime-library bundle that Espressif QEMU needs. + +Why this exists +--------------- +The Espressif QEMU tarballs (`qemu-{xtensa,riscv32}-softmmu-*.tar.xz`) ship +**only** the emulator binary, its ROM blobs, and a static `libfdt.a`. The +binaries carry no `RPATH`/`RUNPATH` and dynamically link against five +non-glibc libraries: + + libpixman-1.so.0 libgcrypt.so.20 libSDL2-2.0.so.0 libz.so.1 libslirp.so.0 + +`ubuntu-24.04` — what `ubuntu-latest` resolves to on GitHub Actions — carries +none of libslirp/libSDL2/libpixman, so `qemu-system-xtensa` dies at exec with +`error while loading shared libraries: libslirp.so.0`. Requiring every caller +to `apt-get install` the set first makes QEMU emulation depend on an external +bootstrap step, which is exactly what fbuild exists to remove. + +This script produces the bundle fbuild downloads on demand instead. It runs +`ldd` over both real QEMU binaries inside a pinned container and copies the +**full transitive closure minus the glibc family** (libc/libm/libpthread/ +librt/libdl/libutil/libresolv/libgcc_s and the loader itself — shipping those +without the matching `ld-linux` is how you get a segfault, not a fix). + +The closure is walked mechanically rather than hand-listed on purpose: SDL2 on +Ubuntu pulls in X11, Wayland, PulseAudio and friends, and libslirp pulls glib. +A curated list silently rots the first time a dependency is added upstream. + +Build host is **ubuntu:20.04**. The bundled libraries inherit the build image's +glibc floor, and the goal is for the bundle never to be the binding constraint: +the Espressif QEMU binaries themselves require up to `GLIBC_2.30`, and a +20.04-built closure requires exactly the same. Building on 22.04 would push the +floor to `GLIBC_2.34` and lock out hosts that could otherwise run QEMU. + +Usage:: + + uv run python ci/build_qemu_linux_runtime.py --arch x86_64 + uv run python ci/build_qemu_linux_runtime.py --arch aarch64 --out dist/ + +Both arches build in a container, but the aarch64 bundle needs a **native** +arm64 host (an `ubuntu-24.04-arm` runner, via `qemu-runtime-bundle.yml`): +dpkg's maintainer scripts fail under Docker Desktop's arm64 emulation. + +Emits `qemu-esp-linux-runtime--.tar.zst`, a `.manifest.txt` +listing what went in, and prints the SHA-256 to paste into +`crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs`. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import subprocess +import sys +from pathlib import Path + + +# Keep in sync with QEMU_RELEASE_TAG in +# crates/fbuild-toolchain/src/toolchain/esp_qemu.rs. +QEMU_RELEASE_TAG = "esp-develop-9.2.2-20250817" +QEMU_ARCHIVE_VERSION = "esp_develop_9.2.2_20250817" + +BUILD_IMAGE = "ubuntu:20.04" + +# Docker --platform value per target architecture. +DOCKER_PLATFORM = { + "x86_64": "linux/amd64", + "aarch64": "linux/arm64", +} + +# Espressif's archive suffix per target architecture. +QEMU_ARCHIVE_SUFFIX = { + "x86_64": "x86_64-linux-gnu", + "aarch64": "aarch64-linux-gnu", +} + +CONTAINER_SCRIPT = r""" +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +ARCH_SUFFIX="__ARCH_SUFFIX__" +TAG="__TAG__" +VERSION="__VERSION__" +OUT_NAME="__OUT_NAME__" +DROP="__DROP_DIR__" + +# Private staging directory: this payload runs as root, so fixed /tmp names +# would let a local attacker pre-create them as symlinks and redirect +# privileged mkdir/cp/zstd writes. +WORK="$(mktemp -d)" +chmod 700 "$WORK" +trap 'rm -rf "$WORK"' EXIT + +apt-get update -qq +apt-get install -y --no-install-recommends ca-certificates curl xz-utils zstd libsdl2-2.0-0 libslirp0 libpixman-1-0 libgcrypt20 zlib1g >/dev/null + +cd "$WORK" +for a in xtensa riscv32; do + url="https://github.com/espressif/qemu/releases/download/${TAG}/qemu-${a}-softmmu-${VERSION}-${ARCH_SUFFIX}.tar.xz" + echo "downloading ${url}" + curl -sSfL -o "q-${a}.tar.xz" "${url}" + mkdir -p "x-${a}" + tar xf "q-${a}.tar.xz" -C "x-${a}" +done + +XTENSA="$WORK/x-xtensa/qemu/bin/qemu-system-xtensa" +RISCV="$WORK/x-riscv32/qemu/bin/qemu-system-riscv32" +test -x "$XTENSA" +test -x "$RISCV" + +BUNDLE="$WORK/bundle" +mkdir -p "$BUNDLE/lib" + +# Resolved shared-object paths for one ELF, one per line. +deps() { + ldd "$1" 2>/dev/null | awk '{print $3}' | grep '^/' || true +} + +# The glibc family and the dynamic loader are deliberately excluded: they +# must come from the host, and a bundled libc without its matching +# ld-linux is unloadable. +is_glibc_family() { + case "$1" in + libc.so.6|libm.so.6|libpthread.so.0|librt.so.1|libdl.so.2) return 0;; + libutil.so.1|libresolv.so.2|libgcc_s.so.1|ld-linux*) return 0;; + *) return 1;; + esac +} + +QUEUE="$(deps "$XTENSA"; deps "$RISCV")" +for _round in 1 2 3 4 5 6 7 8; do + NEXT="" + for f in $QUEUE; do + b="$(basename "$f")" + if is_glibc_family "$b"; then continue; fi + if [ ! -f "$BUNDLE/lib/$b" ]; then + cp -L "$f" "$BUNDLE/lib/$b" + NEXT="$NEXT $(deps "$f")" + fi + done + [ -z "$(echo $NEXT)" ] && break + QUEUE="$NEXT" +done + +# Fail loudly if the libraries this bundle exists for are absent. +for required in libslirp.so.0 libSDL2-2.0.so.0 libpixman-1.so.0 libgcrypt.so.20 libz.so.1; do + if [ ! -f "$BUNDLE/lib/$required" ]; then + echo "FATAL: closure is missing $required" >&2 + exit 1 + fi +done + +{ + echo "# Espressif QEMU ${TAG} Linux runtime libraries (${ARCH_SUFFIX})" + echo "# glibc family intentionally excluded; comes from the host." + (cd "$BUNDLE/lib" && ls -1 | sort) +} > "$BUNDLE/MANIFEST.txt" + +cd "$BUNDLE" +tar cf - lib MANIFEST.txt | zstd -19 -T0 -q -o "$WORK/${OUT_NAME}" + +echo "=== bundle ===" +cat "$BUNDLE/MANIFEST.txt" +echo "libraries: $(ls -1 "$BUNDLE/lib" | wc -l)" +echo "archive: $(stat -c %s "$WORK/${OUT_NAME}") bytes" + +# Prove the bundle actually satisfies both binaries with no host packages in +# play beyond glibc: strip the apt-installed copies first, so a system library +# left behind cannot mask a gap in the closure. +apt-get remove -y libsdl2-2.0-0 libslirp0 libpixman-1-0 >/dev/null 2>&1 || true +for bin in "$XTENSA" "$RISCV"; do + if ! LD_LIBRARY_PATH="$BUNDLE/lib" "$bin" --version >/dev/null; then + echo "FATAL: $bin still cannot start with the bundle applied" >&2 + exit 1 + fi +done +echo "selftest: both QEMU binaries start with LD_LIBRARY_PATH=/lib" + +# Hand the artifacts to the caller through a fresh, root-owned drop directory. +rm -rf "$DROP" +mkdir -m 700 -p "$DROP" +cp "$WORK/${OUT_NAME}" "$DROP/${OUT_NAME}" +cp "$BUNDLE/MANIFEST.txt" "$DROP/${OUT_NAME}.manifest.txt" +""" + +# Root-owned directory the payload copies finished artifacts into. +DROP_DIR = "/var/tmp/fbuild-qemu-runtime" + + +def out_name(arch: str) -> str: + return f"qemu-esp-linux-runtime-{arch}-{QEMU_RELEASE_TAG}.tar.zst" + + +def render_script(arch: str) -> str: + return ( + CONTAINER_SCRIPT.replace("__ARCH_SUFFIX__", QEMU_ARCHIVE_SUFFIX[arch]) + .replace("__TAG__", QEMU_RELEASE_TAG) + .replace("__VERSION__", QEMU_ARCHIVE_VERSION) + .replace("__OUT_NAME__", out_name(arch)) + .replace("__DROP_DIR__", DROP_DIR) + ) + + +def build(arch: str, out_dir: Path) -> Path: + script = render_script(arch) + payload = base64.b64encode(script.encode()).decode() + container = f"fbuild-qemu-runtime-{arch}" + + subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False) + proc = subprocess.run( + [ + "docker", + "run", + "--name", + container, + "--platform", + DOCKER_PLATFORM[arch], + BUILD_IMAGE, + "bash", + "-c", + f"echo {payload} | base64 -d > /tmp/build.sh && bash /tmp/build.sh", + ], + capture_output=True, + text=True, + check=False, + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + raise SystemExit(f"container build failed with exit code {proc.returncode}") + + out_dir.mkdir(parents=True, exist_ok=True) + archive = out_dir / out_name(arch) + for remote, local in ( + (f"{DROP_DIR}/{out_name(arch)}", archive), + (f"{DROP_DIR}/{out_name(arch)}.manifest.txt", Path(f"{archive}.manifest.txt")), + ): + subprocess.run( + ["docker", "cp", f"{container}:{remote}", str(local)], check=True + ) + subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False) + return archive + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--arch", + choices=sorted(DOCKER_PLATFORM), + default="x86_64", + help="target architecture of the bundle (default: x86_64)", + ) + parser.add_argument( + "--out", + type=Path, + default=Path("dist/qemu-runtime"), + help="output directory (default: dist/qemu-runtime)", + ) + args = parser.parse_args() + + archive = build(args.arch, args.out) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + print() + print(f"archive: {archive}") + print(f"sha256: {digest}") + print(f"size: {archive.stat().st_size} bytes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/platform_boundary_ledger.tsv b/ci/platform_boundary_ledger.tsv index e7ab8af1..18698e97 100644 --- a/ci/platform_boundary_ledger.tsv +++ b/ci/platform_boundary_ledger.tsv @@ -3,11 +3,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg #[cfg(win crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(windows)] 0 host_executable host_artifact_policy crates/fbuild-paths/src/dev_daemon_namespace.rs native_path std::env::current_exe 0 host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(target_os=))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 1 host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(target_os=)] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 1 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 2 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 3 host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg(not(target_os=))] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg(target_os=)] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs attr_cfg #![cfg(target_os=)] 0 host_executable host_artifact_policy diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 2e0d5bae..735e488d 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -68,11 +68,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs 146 attr_cfg #[cfg crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 9 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 21 attr_cfg #[cfg(not(windows))] host_executable host_artifact_policy crates/fbuild-paths/src/dev_daemon_namespace.rs 83 native_path std::env::current_exe host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 220 attr_cfg #[cfg(not(target_os=))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 226 attr_cfg #[cfg(target_os=)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 576 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 582 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 613 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 618 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 776 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 803 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 528 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 534 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 565 attr_cfg #[cfg(not(windows))] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 570 attr_cfg #[cfg(not(windows))] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 728 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 755 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 224 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 246 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 252 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs 18 attr_cfg #![cfg(target_os=)] host_executable host_artifact_policy diff --git a/ci/test_enforce_platform_boundary.py b/ci/test_enforce_platform_boundary.py index ffb5b411..d61d63db 100644 --- a/ci/test_enforce_platform_boundary.py +++ b/ci/test_enforce_platform_boundary.py @@ -17,7 +17,12 @@ def test_committed_exact_occurrence_ledger_matches_whole_tree(self) -> None: # Phase 8b (FastLED/fbuild#1314): the 15 `host` and 6 `process` # namespace rows migrated behind the platform facades are gone; # only `host_executable` work (phase 8c) remains. - self.assertEqual(len(self.expected), 12) + # + # 12 -> 14: the Espressif QEMU Linux runtime bundle adds three + # `target_os = "linux"` gates in `esp_qemu_runtime.rs` plus the + # Linux-only integration test, and moves the two pre-existing + # `target_os` gates out of `esp_qemu.rs`. + self.assertEqual(len(self.expected), 14) self.assertFalse(boundary.validate_ledger(self.expected)) self.assertFalse(boundary.compare(self.expected, self.observed)) @@ -68,12 +73,14 @@ def test_private_platform_implementation_findings_are_not_baselined(self) -> Non manifest_finding, normalized="winapi", ) - self.assertEqual(len(boundary.rows_from_findings([unauthorized_manifest_finding])), 1) + self.assertEqual( + len(boundary.rows_from_findings([unauthorized_manifest_finding])), 1 + ) unauthorized_facade_finding = dataclasses.replace( image_finding, kind="cfg_macro", - normalized='cfg!(windows)', + normalized="cfg!(windows)", ) self.assertEqual( boundary.rows_from_findings([unauthorized_facade_finding]), @@ -102,9 +109,9 @@ def test_no_filesystem_mechanics_remain_outside_the_boundary(self) -> None: self.assertFalse([row for row in self.expected if row.capability == "fs"]) def test_rp2040_filesystem_mechanics_use_the_neutral_facade(self) -> None: - source = ( - boundary.ROOT / "crates/fbuild-deploy/src/rp2040.rs" - ).read_text(encoding="utf-8") + source = (boundary.ROOT / "crates/fbuild-deploy/src/rp2040.rs").read_text( + encoding="utf-8" + ) for forbidden in ("AsRawHandle", "CancelSynchronousIo", ".raw_os_error()"): self.assertNotIn(forbidden, source) @@ -150,7 +157,12 @@ def test_duplicate_and_non_contiguous_ordinal_are_rejected(self) -> None: def test_second_identical_occurrence_in_grandfathered_file_is_new(self) -> None: first = self.expected[0] - same_group = [row for row in self.expected if (row.path, row.kind, row.normalized) == (first.path, first.kind, first.normalized)] + same_group = [ + row + for row in self.expected + if (row.path, row.kind, row.normalized) + == (first.path, first.kind, first.normalized) + ] extra = dataclasses.replace(first, ordinal=len(same_group)) failures = boundary.compare(self.expected, [*self.observed, extra]) @@ -173,7 +185,14 @@ def test_actual_dylint_undercount_is_rejected(self) -> None: source, kind, normalized = next(iter(expected)) process = "123" sources = {(process, source)} - findings = boundary.collections.Counter({(process, source, kind, normalized): expected[(source, kind, normalized)] - 1}) + findings = boundary.collections.Counter( + { + (process, source, kind, normalized): expected[ + (source, kind, normalized) + ] + - 1 + } + ) failures = boundary.compare_dylint_observations(expected, sources, findings) @@ -181,7 +200,10 @@ def test_actual_dylint_undercount_is_rejected(self) -> None: def test_one_selector_and_six_neutral_namespaces(self) -> None: platform = boundary.ROOT / "crates/fbuild-core/src/platform" - all_source = "\n".join(path.read_text(encoding="utf-8") for path in (boundary.ROOT / "crates").rglob("*.rs")) + all_source = "\n".join( + path.read_text(encoding="utf-8") + for path in (boundary.ROOT / "crates").rglob("*.rs") + ) selector = (platform / "mod.rs").read_text(encoding="utf-8") self.assertEqual(all_source.count("std::cfg_select!"), 1) diff --git a/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs b/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs index 17369d27..73ac80c6 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs @@ -277,6 +277,7 @@ pub async fn deploy_qemu( show_timestamp, verbose, process_label: "QEMU", + project_dir: Some(&project_dir), }, ) .await diff --git a/crates/fbuild-daemon/src/handlers/emulator/runners.rs b/crates/fbuild-daemon/src/handlers/emulator/runners.rs index 61c4bc51..a781230a 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/runners.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/runners.rs @@ -132,6 +132,7 @@ impl EmulatorRunner for QemuRunner { show_timestamp: config.show_timestamp, verbose: config.verbose, process_label: "QEMU", + project_dir: Some(&self.project_dir), }, ) .await?; @@ -345,6 +346,8 @@ impl EmulatorRunner for SimavrRunner { show_timestamp: config.show_timestamp, verbose: config.verbose, process_label: "simavr", + // simavr is a host-native binary; no QEMU runtime bundle. + project_dir: None, }, ) .await?; diff --git a/crates/fbuild-daemon/src/handlers/emulator/shared.rs b/crates/fbuild-daemon/src/handlers/emulator/shared.rs index 4bfdac4a..54208114 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/shared.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/shared.rs @@ -42,6 +42,9 @@ pub(crate) struct RunQemuOptions<'a> { pub verbose: bool, /// Label used in user-visible messages (e.g. "QEMU", "simavr"). pub process_label: &'a str, + /// Project directory, used to locate fbuild's Linux QEMU runtime-library + /// bundle. `None` for non-QEMU runners and for tests that spawn a stub. + pub project_dir: Option<&'a Path>, } /// Configuration for an emulator test run (user-facing options). @@ -156,7 +159,11 @@ pub(crate) async fn resolve_esp32_toolchain_gcc_path( Ok(toolchain.get_gcc_path()) } -fn apply_process_environment(cmd: &mut tokio::process::Command, exe_path: &Path) { +fn apply_process_environment( + cmd: &mut tokio::process::Command, + exe_path: &Path, + project_dir: Option<&Path>, +) { if fbuild_core::platform::host::is_windows() { let current_path = std::env::var("PATH").unwrap_or_default(); if let Ok(path_env) = @@ -164,6 +171,22 @@ fn apply_process_environment(cmd: &mut tokio::process::Command, exe_path: &Path) { cmd.env("PATH", path_env); } + return; + } + + // Linux: the Espressif QEMU binaries bundle no shared libraries and + // carry no RPATH. When the host could not start QEMU on its own, + // toolchain resolution installed fbuild's runtime bundle; point the + // emulator at it. Hosts that never needed the bundle get `None` here + // and keep their own libraries. + if let (true, Some(project_dir)) = (fbuild_core::platform::host::is_linux(), project_dir) { + let current = std::env::var("LD_LIBRARY_PATH").ok(); + if let Some(value) = fbuild_packages::toolchain::build_linux_qemu_ld_library_path( + project_dir, + current.as_deref(), + ) { + cmd.env("LD_LIBRARY_PATH", value); + } } } @@ -190,7 +213,7 @@ pub(crate) async fn run_qemu_process( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - apply_process_environment(&mut cmd, qemu_path); + apply_process_environment(&mut cmd, qemu_path, options.project_dir); let label = options.process_label; if options.verbose { diff --git a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs index 2364a49c..a503b36d 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs @@ -45,6 +45,7 @@ async fn run_qemu_process_reports_expected_success_output() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -76,6 +77,7 @@ async fn run_qemu_process_surfaces_crash_decoder_output() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -215,6 +217,9 @@ async fn run_real_esp32s3_fixture_in_qemu() { show_timestamp: false, verbose: true, process_label: "QEMU", + // Real QEMU: on a host that needed fbuild's runtime bundle, + // resolve_executable() installed it and the spawn must export it. + project_dir: Some(&project_dir), }, ) .await @@ -251,6 +256,7 @@ async fn run_avr8js_headless_captures_stdout() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -282,6 +288,7 @@ async fn run_avr8js_headless_halt_on_success() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -311,6 +318,7 @@ async fn run_avr8js_headless_halt_on_error() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -346,6 +354,7 @@ async fn simavr_runner_captures_stdout_via_process_runner() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -374,6 +383,7 @@ async fn simavr_runner_halt_on_success() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -403,6 +413,7 @@ async fn simavr_runner_halt_on_error() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -432,6 +443,7 @@ async fn simavr_runner_timeout() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index 9f6f345a..1e957c6b 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -89,6 +89,7 @@ impl EspQemuArch { pub struct EspQemu { base: PackageBase, arch: EspQemuArch, + project_dir: PathBuf, } impl EspQemu { @@ -111,6 +112,7 @@ impl EspQemu { project_dir, ), arch, + project_dir: project_dir.to_path_buf(), }) } @@ -150,8 +152,11 @@ impl EspQemu { // deps resolve) before handing it to the caller. On Linux, a // missing .so exits with code 127 — much clearer to report here // with the toolchain path than as a bare "exited with code 127" - // from the emulator runner. - preflight_qemu_binary(&resolved)?; + // from the emulator runner. When the host is missing libraries, + // fbuild provisions them itself rather than asking the caller to + // apt-get anything. + crate::toolchain::esp_qemu_runtime::ensure_qemu_can_start(&resolved, &self.project_dir) + .await?; Ok(resolved) } @@ -168,16 +173,25 @@ impl EspQemu { } } -/// Validate that the bundled `lib/` directory shipped by the Espressif QEMU -/// tarball is present alongside the binary. +/// Validate that the `lib/` directory shipped by the Espressif QEMU tarball is +/// present alongside the binary — an extraction-completeness check, nothing +/// more. /// /// The tarball layout is: /// ```text /// qemu/ /// ├── bin/qemu-system-xtensa -/// └── lib/libslirp.so.0 (with rpath $ORIGIN/../lib) +/// ├── share/qemu/*.bin (ROM blobs) +/// └── lib//libfdt.a (static; no shared libraries, no rpath) /// ``` /// +/// **The tarball ships no runtime shared libraries and the binaries carry no +/// `RPATH`/`RUNPATH`.** libslirp/libSDL2/libpixman/libgcrypt/libz come from the +/// host, or — when the host does not have them — from the bundle +/// `esp_qemu_runtime` downloads. An earlier version of this comment claimed +/// `lib/libslirp.so.0` was bundled with an `$ORIGIN/../lib` rpath; it is not, +/// which is why this check passed on hosts where QEMU could not start. +/// /// `staged_install` already extracts-to-staging-then-atomic-rename and writes /// a `.install_complete` sentinel, so a complete tree carries both. This /// check defends against cache restoration from an older fbuild version that @@ -197,9 +211,10 @@ fn qemu_validate_bundled_libs(qemu_binary: &Path) -> Result<()> { } } Err(FbuildError::PackageError(format!( - "Espressif QEMU installation at {} appears incomplete: \ - bundled lib/ directory not found. The cached toolchain may be corrupt. \ - Delete the cache entry and retry:\n rm -rf {}", + "Espressif QEMU extraction at {} is incomplete: the tarball's lib/ \ + directory is missing, so the archive was only partially unpacked. \ + (This is about extraction, not about host shared libraries — those \ + are handled separately.) Delete the cache entry and retry:\n rm -rf {}", qemu_binary.display(), qemu_binary .parent() @@ -209,69 +224,6 @@ fn qemu_validate_bundled_libs(qemu_binary: &Path) -> Result<()> { ))) } -/// Probe the QEMU binary with `--version` to verify its shared library -/// dependencies resolve at runtime. -/// -/// Returns `Ok(())` if the probe succeeds, or a diagnostic `Err` if the -/// binary exits with code 127 (dynamic linker failure — missing .so). -/// Other probe failures are treated as non-fatal (the real run will -/// surface the error with full context). -fn preflight_qemu_binary(qemu_binary: &Path) -> Result<()> { - #[cfg(not(target_os = "linux"))] - { - let _ = qemu_binary; - Ok(()) - } - - #[cfg(target_os = "linux")] - { - // Short synchronous probe: verify the QEMU binary can start before we - // hand it to the async emulator runner. Uses run_command_blocking which - // routes through containment (no console flash on Windows, containment - // group on all platforms) and is ~100 ms. - let probe_result = fbuild_core::subprocess::run_command_blocking( - &[&qemu_binary.to_string_lossy(), "--version"], - None, // cwd - None, // env - Some(std::time::Duration::from_secs(5)), - ); - - match probe_result { - Ok(out) if out.success() => Ok(()), - Ok(out) if out.exit_code == 127 => { - // Try to identify which library is missing from the linker error. - let missing = out - .stderr - .lines() - .find(|l| l.contains("error while loading shared libraries")) - .map(|l| l.trim().to_string()); - - Err(FbuildError::PackageError(format!( - "QEMU at {} cannot start: a required shared library is missing.\n\ - {}\n\ - The cached QEMU toolchain appears incomplete or corrupt.\n\ - To fix, delete the cached toolchain and retry:\n rm -rf {}", - qemu_binary.display(), - missing.as_deref().unwrap_or(&format!( - "The dynamic linker reported: {}", - out.stderr.trim() - )), - qemu_binary - .parent() - .and_then(|p| p.parent()) - .unwrap_or(qemu_binary.parent().unwrap_or(qemu_binary)) - .display(), - ))) - } - Ok(_) | Err(_) => { - // Non-127 exit or spawn failure: non-fatal at this stage. - // The real QEMU run will surface the error with full context. - Ok(()) - } - } - } -} - #[async_trait::async_trait] impl Package for EspQemu { async fn ensure_installed(&self) -> Result { @@ -883,58 +835,4 @@ mod tests { "should reject root binary without lib/" ); } - - // ── preflight_qemu_binary ─────────────────────────────────────── - - #[test] - fn preflight_ok_when_binary_runs_version_successfully() { - // On Linux, a real QEMU binary would pass. On non-Linux, - // preflight is a no-op. We test with a shell script that exits 0 - // so the probe succeeds cross-platform. - let tmp = tempfile::TempDir::new().unwrap(); - let probe = tmp.path().join("probe_qemu"); - if fbuild_core::platform::host::is_windows() { - std::fs::write(&probe, b"@echo off\r\nexit /b 0\r\n").unwrap(); - } else { - std::fs::write(&probe, b"#!/bin/sh\nexit 0\n").unwrap(); - fbuild_core::platform::fs::set_executable(&probe).unwrap(); - }; - // preflight is a no-op on non-Linux, and on Linux with a fake - // script that exits 0 it should pass. - let result = preflight_qemu_binary(&probe); - assert!( - result.is_ok(), - "preflight should pass when binary returns 0" - ); - } - - #[test] - fn preflight_linux_detects_missing_shared_library_exit_127() { - if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux - { - return; - } - let tmp = tempfile::TempDir::new().unwrap(); - // Script that prints the canonical dynamic-linker error to stderr - // and exits 127 — same observable as a missing .so. - let probe = tmp.path().join("fake_qemu_missing_so"); - std::fs::write( - &probe, - b"#!/bin/sh\necho 'error while loading shared libraries: libslirp.so.0: cannot open shared object file' >&2\nexit 127\n", - ) - .unwrap(); - fbuild_core::platform::fs::set_executable(&probe).unwrap(); - - let err = preflight_qemu_binary(&probe).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("shared library"), - "should report missing shared library: {msg}" - ); - assert!( - msg.contains("libslirp.so.0"), - "should name the missing library: {msg}" - ); - assert!(msg.contains("rm -rf"), "should suggest deletion: {msg}"); - } } diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs new file mode 100644 index 00000000..573f436e --- /dev/null +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -0,0 +1,540 @@ +//! Linux runtime shared libraries for Espressif QEMU. +//! +//! The Espressif QEMU tarballs ship the emulator binary, its ROM blobs and a +//! static `libfdt.a` — and nothing else. The binaries carry no `RPATH`/ +//! `RUNPATH` and dynamically link five non-glibc libraries: +//! +//! ```text +//! libpixman-1.so.0 libgcrypt.so.20 libSDL2-2.0.so.0 libz.so.1 libslirp.so.0 +//! ``` +//! +//! On a stock `ubuntu-24.04` image (what `ubuntu-latest` resolves to on GitHub +//! Actions) libslirp, libSDL2 and libpixman are all absent, so +//! `qemu-system-xtensa` dies at exec with +//! `error while loading shared libraries: libslirp.so.0`. +//! +//! Making callers `apt-get install` the set first turns QEMU emulation into +//! something you have to bootstrap from outside fbuild. Instead fbuild +//! downloads a prebuilt bundle of the full non-glibc dependency closure and +//! points the emulator at it with `LD_LIBRARY_PATH`. +//! +//! The bundle is **lazy**: it is only downloaded when a probe proves the host +//! cannot start QEMU on its own. Hosts that already carry the libraries never +//! fetch it and never have their libraries shadowed. +//! +//! Bundle provenance: `ci/build_qemu_linux_runtime.py` walks `ldd` over both +//! real QEMU binaries inside `ubuntu:20.04` and archives the transitive +//! closure minus the glibc family. glibc and the loader deliberately stay on +//! the host — a bundled `libc.so.6` without its matching `ld-linux` is a +//! segfault, not a fix. +//! +//! The 20.04 build image is chosen so the bundle is never the binding +//! portability constraint: its libraries need at most `GLIBC_2.30`, which is +//! exactly what the Espressif QEMU binaries themselves require. Building on +//! 22.04 would raise the floor to `GLIBC_2.34` and lock out hosts that could +//! otherwise run QEMU. + +use std::path::Path; + +use fbuild_core::path::NormalizedPath; +use fbuild_core::platform::host::{self, HostArch, HostPlatform}; +use fbuild_core::{FbuildError, Result}; + +use crate::{CacheSubdir, PackageBase, PackageInfo}; + +/// Release tag of the QEMU build this bundle was closed over. Kept in sync +/// with `QEMU_RELEASE_TAG` in `esp_qemu.rs` and with +/// `ci/build_qemu_linux_runtime.py`. +const RUNTIME_VERSION: &str = "esp-develop-9.2.2-20250817"; + +/// Release that hosts the prebuilt bundles. +const RUNTIME_RELEASE_TAG: &str = "qemu-linux-runtime-v1"; + +/// Subdirectory the archive extracts its libraries into. +const LIB_SUBDIR: &str = "lib"; + +/// Every non-glibc library the Espressif QEMU binaries link directly. All of +/// them must be present for a cached bundle to count as complete: checking one +/// sentinel would let a half-extracted tree pass `is_installed()`, and the +/// install would then never be repaired — the second probe would just fail +/// with "cannot start even with the bundle applied". +/// +/// The bundle carries their transitive closure too, but those are reachable +/// only through these five, so a tree that has all five and nothing else is +/// already impossible from `staged_install`'s atomic rename. +const REQUIRED_LIBS: &[&str] = &[ + "libslirp.so.0", + "libSDL2-2.0.so.0", + "libpixman-1.so.0", + "libgcrypt.so.20", + "libz.so.1", +]; + +/// The bundled Linux runtime libraries for Espressif QEMU. +pub struct QemuLinuxRuntime { + base: PackageBase, +} + +impl QemuLinuxRuntime { + pub fn new(project_dir: &Path) -> Result { + Self::for_host(host::current(), project_dir) + } + + fn for_host(host: HostPlatform, project_dir: &Path) -> Result { + let arch = runtime_arch(host)?; + let url = format!( + "https://github.com/FastLED/fbuild/releases/download/{}/qemu-esp-linux-runtime-{}-{}.tar.zst", + RUNTIME_RELEASE_TAG, arch, RUNTIME_VERSION + ); + Ok(Self { + base: PackageBase::new( + "esp-qemu-linux-runtime", + RUNTIME_VERSION, + &url, + &format!("qemu-linux-runtime-{arch}"), + Some(runtime_sha256(arch)?), + CacheSubdir::Toolchains, + project_dir, + ), + }) + } + + /// Directory to place on `LD_LIBRARY_PATH`. + pub fn lib_dir(&self) -> NormalizedPath { + NormalizedPath::from(self.base.install_path()).join(LIB_SUBDIR) + } + + /// Install if needed and return the directory to put on `LD_LIBRARY_PATH`. + pub async fn ensure_lib_dir(&self) -> Result { + if !self.is_installed() { + self.base.staged_install(validate_runtime_install).await?; + } + Ok(self.lib_dir()) + } + + /// Whether a complete bundle is already unpacked in the cache. + pub fn is_installed(&self) -> bool { + self.base.is_cached() && missing_libs(self.lib_dir().as_path()).is_empty() + } + + pub fn get_info(&self) -> PackageInfo { + self.base.get_info() + } +} + +/// Which of the required libraries are absent from an extracted `lib/` dir. +fn missing_libs(lib_dir: &Path) -> Vec<&'static str> { + REQUIRED_LIBS + .iter() + .copied() + .filter(|lib| !lib_dir.join(lib).is_file()) + .collect() +} + +/// Reject an extracted tree that does not carry the libraries it exists for. +fn validate_runtime_install(install_dir: &Path) -> Result<()> { + let lib_dir = install_dir.join(LIB_SUBDIR); + let missing = missing_libs(&lib_dir); + if missing.is_empty() { + return Ok(()); + } + Err(FbuildError::PackageError(format!( + "QEMU Linux runtime bundle at {} is incomplete: {} missing from {}/", + install_dir.display(), + missing.join(", "), + LIB_SUBDIR, + ))) +} + +/// Architecture token used in the bundle's asset name. +fn runtime_arch(host: HostPlatform) -> Result<&'static str> { + if !host.is_linux() { + return Err(FbuildError::PackageError(format!( + "the QEMU runtime-library bundle is Linux-only; host is {}", + host.os_name() + ))); + } + match host.arch() { + HostArch::X86_64 => Ok("x86_64"), + HostArch::Aarch64 => Ok("aarch64"), + _ => Err(FbuildError::PackageError(format!( + "no QEMU runtime-library bundle is published for linux-{}", + host.arch_name() + ))), + } +} + +/// SHA-256 of each published bundle. An architecture without an entry has no +/// bundle yet: report that plainly instead of downloading something unpinned. +/// +/// aarch64 is built by `.github/workflows/qemu-runtime-bundle.yml` on a native +/// arm64 runner — Docker Desktop's arm64 emulation cannot run dpkg's +/// maintainer scripts, so it cannot be produced from an x86_64 developer +/// workstation the way the x86_64 bundle was. +fn runtime_sha256(arch: &str) -> Result<&'static str> { + match arch { + "x86_64" => Ok("b3318ccf60df8e17a42b5b0f61180440f56337fadb0babe867bff1f3dfecd99f"), + other => Err(FbuildError::PackageError(format!( + "no QEMU runtime-library bundle is published for linux-{other} yet (tracked in the {RUNTIME_RELEASE_TAG} release).\n\ + Install the QEMU runtime libraries from your distribution — on Debian/Ubuntu: libslirp0, libsdl2-2.0-0, libpixman-1-0." + ))), + } +} + +/// Prepend `lib_dir` to an existing `LD_LIBRARY_PATH` value. +/// +/// Returns the combined value. An empty or absent current value yields just +/// the bundle directory. The bundle goes first because the host is, by +/// construction, missing at least one of the libraries it carries. +pub fn ld_library_path_with(lib_dir: &Path, current: Option<&str>) -> String { + let bundle = lib_dir.to_string_lossy().to_string(); + match current { + Some(existing) if !existing.is_empty() => format!("{bundle}:{existing}"), + _ => bundle, + } +} + +/// Spawn-time lookup: the bundle directory, if this host needed it and it is +/// already installed. +/// +/// Stateless on purpose — mirrors `build_windows_qemu_path_env`, so the +/// emulator spawn path does not have to thread a resolution result through +/// every call site. A host that never needed the bundle has nothing cached +/// here and gets `None`. +pub fn installed_lib_dir(project_dir: &Path) -> Option { + let runtime = QemuLinuxRuntime::new(project_dir).ok()?; + if runtime.is_installed() { + Some(runtime.lib_dir()) + } else { + None + } +} + +/// `LD_LIBRARY_PATH` for a QEMU spawn, or `None` when the host does not need +/// the bundle. +pub fn build_linux_qemu_ld_library_path( + project_dir: &Path, + current: Option<&str>, +) -> Option { + let lib_dir = installed_lib_dir(project_dir)?; + Some(ld_library_path_with(&lib_dir, current)) +} + +/// Result of probing a QEMU binary with `--version`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum QemuProbe { + /// The binary started; its shared-library dependencies all resolved. + Started, + /// The dynamic linker could not satisfy a dependency (exit code 127). + /// Carries the linker's own line when it could be recovered. + MissingSharedLibrary(String), + /// Probe could not be interpreted (spawn failure, or some other + /// non-127 exit). Treated as non-fatal: the real run reports it with + /// full context. + Inconclusive, +} + +/// Probe the QEMU binary with `--version` to verify its shared library +/// dependencies resolve at runtime. +/// +/// `lib_dir`, when given, is prepended to `LD_LIBRARY_PATH` for the probe so +/// the caller can ask "does it start *with* the bundled runtime libraries?". +/// +/// Probing is Linux-only. Windows resolves its DLLs through the `PATH` +/// hydration path above, and macOS builds are self-contained. +fn probe_qemu_binary(qemu_binary: &Path, lib_dir: Option<&Path>) -> QemuProbe { + #[cfg(not(target_os = "linux"))] + { + let _ = (qemu_binary, lib_dir); + QemuProbe::Started + } + + #[cfg(target_os = "linux")] + { + // Short synchronous probe: verify the QEMU binary can start before we + // hand it to the async emulator runner. Uses run_command_blocking which + // routes through containment (no console flash on Windows, containment + // group on all platforms) and is ~100 ms. + let ld_library_path = lib_dir + .map(|dir| ld_library_path_with(dir, std::env::var("LD_LIBRARY_PATH").ok().as_deref())); + let env: Option> = ld_library_path + .as_deref() + .map(|value| vec![("LD_LIBRARY_PATH", value)]); + + let probe_result = fbuild_core::subprocess::run_command_blocking( + &[&qemu_binary.to_string_lossy(), "--version"], + None, // cwd + env.as_deref(), + Some(std::time::Duration::from_secs(5)), + ); + + match probe_result { + Ok(out) if out.success() => QemuProbe::Started, + Ok(out) if out.exit_code == 127 => { + let detail = out + .stderr + .lines() + .find(|l| l.contains("error while loading shared libraries")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| out.stderr.trim().to_string()); + QemuProbe::MissingSharedLibrary(detail) + } + Ok(_) | Err(_) => QemuProbe::Inconclusive, + } + } +} + +/// Make sure the resolved QEMU binary can actually start on this host. +/// +/// The Espressif tarballs bundle no shared libraries and carry no `RPATH`, so +/// on Linux the binary needs libslirp/libSDL2/libpixman/libgcrypt/libz from +/// somewhere. A stock `ubuntu-24.04` has none of the first three. Rather than +/// make every caller `apt-get install` them first — an external bootstrap step +/// fbuild exists to remove — fbuild downloads its own runtime bundle and +/// re-probes with it applied. +/// +/// The bundle is fetched lazily: a host that can already start QEMU never +/// downloads it and never has its own libraries shadowed. +pub(crate) async fn ensure_qemu_can_start(qemu_binary: &Path, project_dir: &Path) -> Result<()> { + let missing = match probe_qemu_binary(qemu_binary, None) { + QemuProbe::Started | QemuProbe::Inconclusive => return Ok(()), + QemuProbe::MissingSharedLibrary(detail) => detail, + }; + + tracing::info!( + "QEMU at {} is missing a host shared library ({}); fetching the fbuild runtime bundle", + qemu_binary.display(), + missing + ); + + let runtime = QemuLinuxRuntime::new(project_dir) + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + let lib_dir = runtime + .ensure_lib_dir() + .await + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + + match probe_qemu_binary(qemu_binary, Some(lib_dir.as_path())) { + QemuProbe::Started | QemuProbe::Inconclusive => Ok(()), + QemuProbe::MissingSharedLibrary(still_missing) => Err(FbuildError::PackageError(format!( + "QEMU at {} cannot start even with the fbuild runtime bundle at {} applied.\n\ + {}\n\ + The bundle carries the full non-glibc dependency closure of the Espressif QEMU binaries, so this points at a host glibc older than the bundle's build image (ubuntu 22.04, glibc 2.35), or at a library the closure does not cover.\n\ + Please report it at https://github.com/FastLED/fbuild/issues.", + qemu_binary.display(), + lib_dir.display(), + still_missing, + ))), + } +} + +/// Error for "the host cannot start QEMU and fbuild could not provision the +/// libraries either" — keeps the original linker complaint in view. +fn runtime_unavailable_error(qemu_binary: &Path, missing: &str, cause: &str) -> FbuildError { + FbuildError::PackageError(format!( + "QEMU at {} cannot start: a required shared library is missing.\n\ + {}\n\ + fbuild could not provision its Linux runtime bundle: {}", + qemu_binary.display(), + missing, + cause, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn linux(arch: HostArch) -> HostPlatform { + HostPlatform::new(fbuild_core::platform::host::HostOs::Linux, arch) + } + + #[test] + fn arch_token_for_linux_hosts() { + assert_eq!(runtime_arch(linux(HostArch::X86_64)).unwrap(), "x86_64"); + assert_eq!(runtime_arch(linux(HostArch::Aarch64)).unwrap(), "aarch64"); + } + + #[test] + fn non_linux_hosts_are_rejected() { + let win = HostPlatform::new( + fbuild_core::platform::host::HostOs::Windows, + HostArch::X86_64, + ); + let err = runtime_arch(win).unwrap_err().to_string(); + assert!(err.contains("Linux-only"), "unexpected error: {err}"); + } + + #[test] + fn url_points_at_the_published_asset() { + let tmp = tempfile::TempDir::new().unwrap(); + let rt = QemuLinuxRuntime::for_host(linux(HostArch::X86_64), tmp.path()).unwrap(); + let info = rt.get_info(); + assert!( + info.url.ends_with(&format!( + "qemu-esp-linux-runtime-x86_64-{RUNTIME_VERSION}.tar.zst" + )), + "unexpected url: {}", + info.url + ); + assert!(info.url.contains(RUNTIME_RELEASE_TAG), "url: {}", info.url); + } + + #[test] + fn ld_library_path_puts_bundle_first() { + let combined = ld_library_path_with(Path::new("/cache/rt/lib"), Some("/usr/local/lib")); + assert_eq!(combined, "/cache/rt/lib:/usr/local/lib"); + } + + #[test] + fn ld_library_path_without_existing_value() { + assert_eq!( + ld_library_path_with(Path::new("/cache/rt/lib"), None), + "/cache/rt/lib" + ); + assert_eq!( + ld_library_path_with(Path::new("/cache/rt/lib"), Some("")), + "/cache/rt/lib" + ); + } + + /// Lay down an extracted-bundle tree carrying `libs`. + fn bundle_tree(root: &Path, libs: &[&str]) { + let lib = root.join(LIB_SUBDIR); + std::fs::create_dir_all(&lib).unwrap(); + for name in libs { + std::fs::write(lib.join(name), b"").unwrap(); + } + } + + #[test] + fn validate_rejects_empty_tree_and_names_every_missing_library() { + let tmp = tempfile::TempDir::new().unwrap(); + bundle_tree(tmp.path(), &[]); + let err = validate_runtime_install(tmp.path()) + .unwrap_err() + .to_string(); + for lib in REQUIRED_LIBS { + assert!(err.contains(lib), "error should name {lib}: {err}"); + } + } + + #[test] + fn validate_rejects_tree_missing_one_library() { + // A partially-restored CI cache must be re-installed, not accepted: + // otherwise `is_installed()` short-circuits the repair and the QEMU + // probe fails afterwards with nothing left to fix it. + let tmp = tempfile::TempDir::new().unwrap(); + bundle_tree(tmp.path(), &REQUIRED_LIBS[1..]); + let err = validate_runtime_install(tmp.path()) + .unwrap_err() + .to_string(); + assert!( + err.contains(REQUIRED_LIBS[0]), + "error should name the one missing library: {err}" + ); + } + + #[test] + fn validate_accepts_tree_with_every_required_library() { + let tmp = tempfile::TempDir::new().unwrap(); + bundle_tree(tmp.path(), REQUIRED_LIBS); + validate_runtime_install(tmp.path()).expect("complete tree should validate"); + } + + #[test] + fn uninstalled_bundle_yields_no_ld_library_path() { + let tmp = tempfile::TempDir::new().unwrap(); + // Nothing is cached under a fresh project dir, so the spawn-time + // lookup must stay quiet rather than inventing a path. + assert!(build_linux_qemu_ld_library_path(tmp.path(), Some("/usr/lib")).is_none()); + } + + // ── probe_qemu_binary ─────────────────────────────────────────── + + #[test] + fn probe_reports_started_when_binary_runs_version_successfully() { + // On Linux, a real QEMU binary would pass. On non-Linux, probing is + // a no-op that always reports Started. We use a script that exits 0 + // so the assertion holds cross-platform. + let tmp = tempfile::TempDir::new().unwrap(); + let probe = tmp.path().join("probe_qemu"); + if fbuild_core::platform::host::is_windows() { + std::fs::write(&probe, b"@echo off\r\nexit /b 0\r\n").unwrap(); + } else { + std::fs::write(&probe, b"#!/bin/sh\nexit 0\n").unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + }; + assert!( + matches!(probe_qemu_binary(&probe, None), QemuProbe::Started), + "probe should report Started when the binary returns 0" + ); + } + + #[test] + fn probe_linux_detects_missing_shared_library_exit_127() { + if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux + { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + // Script that prints the canonical dynamic-linker error to stderr + // and exits 127 — same observable as a missing .so. + let probe = tmp.path().join("fake_qemu_missing_so"); + std::fs::write( + &probe, + b"#!/bin/sh\necho 'error while loading shared libraries: libslirp.so.0: cannot open shared object file' >&2\nexit 127\n", + ) + .unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + + match probe_qemu_binary(&probe, None) { + QemuProbe::MissingSharedLibrary(detail) => assert!( + detail.contains("libslirp.so.0"), + "probe should name the missing library: {detail}" + ), + _ => panic!("exit 127 must be reported as a missing shared library"), + } + } + + #[test] + fn probe_linux_exports_the_bundle_on_ld_library_path() { + if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux + { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + // Script that succeeds only when LD_LIBRARY_PATH leads with the + // directory we asked the probe to apply — i.e. the bundle actually + // reaches the QEMU invocation rather than merely being installed. + let probe = tmp.path().join("fake_qemu_needs_lib_dir"); + let lib_dir = tmp.path().join("bundle-lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::write( + &probe, + format!( + "#!/bin/sh\ncase \"$LD_LIBRARY_PATH\" in\n {}:*|{}) exit 0;;\nesac\necho 'error while loading shared libraries: libslirp.so.0' >&2\nexit 127\n", + lib_dir.display(), + lib_dir.display() + ) + .as_bytes(), + ) + .unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + + assert!( + matches!( + probe_qemu_binary(&probe, None), + QemuProbe::MissingSharedLibrary(_) + ), + "without the bundle the stub must fail like a real missing .so" + ); + assert!( + matches!( + probe_qemu_binary(&probe, Some(&lib_dir)), + QemuProbe::Started + ), + "probe must put the bundle directory on LD_LIBRARY_PATH" + ); + } +} diff --git a/crates/fbuild-toolchain/src/toolchain/mod.rs b/crates/fbuild-toolchain/src/toolchain/mod.rs index 2df693aa..b08cf120 100644 --- a/crates/fbuild-toolchain/src/toolchain/mod.rs +++ b/crates/fbuild-toolchain/src/toolchain/mod.rs @@ -8,6 +8,7 @@ pub mod esp32; pub mod esp32_metadata; pub mod esp8266; pub mod esp_qemu; +pub mod esp_qemu_runtime; pub mod riscv; pub mod rp2040_picotool; pub mod rp2040_pqt; @@ -19,6 +20,10 @@ pub use avr::AvrToolchain; pub use clang::{ClangComponent, ClangComponentKind}; pub use esp_qemu::build_windows_qemu_path_env; pub use esp_qemu::{EspQemu, EspQemuArch, EspQemuRiscv32, EspQemuXtensa}; +pub use esp_qemu_runtime::{ + QemuLinuxRuntime, build_linux_qemu_ld_library_path, + installed_lib_dir as qemu_linux_runtime_lib_dir, +}; pub use esp32::Esp32Toolchain; pub use esp8266::Esp8266Toolchain; pub use riscv::RiscvToolchain; diff --git a/crates/fbuild-toolchain/tests/README.md b/crates/fbuild-toolchain/tests/README.md new file mode 100644 index 00000000..0afada34 --- /dev/null +++ b/crates/fbuild-toolchain/tests/README.md @@ -0,0 +1,18 @@ +# fbuild-toolchain integration tests + +Tests that exercise toolchain resolution against the real network and the real +package cache, rather than in-process fixtures. + +- **`qemu_linux_runtime.rs`** — Linux-only. Proves fbuild can start Espressif + QEMU on a host that carries none of libslirp / libSDL2 / libpixman, by + provisioning its own runtime-library bundle. `#[ignore]`d by default because + it downloads QEMU (~15 MB) and, when needed, the bundle (~5 MB). Run in CI by + `.github/workflows/qemu-linux-runtime.yml` on a stock `ubuntu-latest` runner + with **no** apt preinstall step — that missing preinstall is the point of the + test. + +Run locally on Linux: + +```bash +soldr cargo test -p fbuild-toolchain --test qemu_linux_runtime -- --ignored --nocapture +``` diff --git a/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs b/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs new file mode 100644 index 00000000..0420c93f --- /dev/null +++ b/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs @@ -0,0 +1,88 @@ +//! Linux end-to-end check: fbuild can start Espressif QEMU on a host that +//! does **not** carry libslirp / libSDL2 / libpixman. +//! +//! This is the regression test for FastLED/FastLED#3964's root cause. The +//! Espressif QEMU tarballs bundle no shared libraries and carry no `RPATH`, +//! and `ubuntu-24.04` — `ubuntu-latest` on GitHub Actions — ships none of the +//! three libraries above. Before fbuild provisioned them itself, every QEMU +//! emulation lane died at exec with +//! `error while loading shared libraries: libslirp.so.0`, and the only fix +//! was an `apt-get install` step *outside* fbuild. +//! +//! The test is meaningful only on a host missing those libraries, which is +//! exactly what the `qemu-linux-runtime.yml` workflow provides: a stock +//! `ubuntu-latest` runner with no apt preinstall step. On a developer machine +//! that already has the libraries the test still passes — it just proves the +//! host path rather than the bundle path, which is also a behaviour worth +//! keeping (fbuild must not download the bundle it does not need). +#![cfg(target_os = "linux")] + +use std::path::Path; + +use fbuild_toolchain::toolchain::{ + EspQemu, EspQemuArch, build_linux_qemu_ld_library_path, qemu_linux_runtime_lib_dir, +}; + +/// Run ` --version` the way the emulator runner does, and report +/// whether it started. +fn qemu_starts(qemu: &Path, project_dir: &Path) -> (bool, String) { + let ld_library_path = build_linux_qemu_ld_library_path( + project_dir, + std::env::var("LD_LIBRARY_PATH").ok().as_deref(), + ); + let env: Option> = ld_library_path + .as_deref() + .map(|value| vec![("LD_LIBRARY_PATH", value)]); + + let out = fbuild_core::subprocess::run_command_blocking( + &[&qemu.to_string_lossy(), "--version"], + None, + env.as_deref(), + Some(std::time::Duration::from_secs(10)), + ) + .expect("probe should spawn"); + (out.success(), format!("{}{}", out.stdout, out.stderr)) +} + +#[tokio::test] +#[ignore = "downloads Espressif QEMU (~15 MB) and, on a host missing the libraries, the runtime bundle (~5 MB)"] +async fn esp_qemu_starts_without_any_host_library_install() { + let project = tempfile::TempDir::new().expect("temp project dir"); + + for arch in [EspQemuArch::Xtensa, EspQemuArch::Riscv32] { + let qemu = EspQemu::new(project.path(), arch) + .expect("package handle") + .resolve_executable() + .await + .unwrap_or_else(|e| panic!("{arch:?}: fbuild could not provide a usable QEMU: {e}")); + + let (started, output) = qemu_starts(&qemu, project.path()); + assert!( + started, + "{arch:?}: {} could not start during invocation:\n{output}", + qemu.display() + ); + assert!( + output.contains("QEMU emulator version"), + "{arch:?}: unexpected --version output:\n{output}" + ); + } + + // On a host that needed the bundle, it must now be installed and exported + // through LD_LIBRARY_PATH; on a host that did not, fbuild must not have + // downloaded it at all. Both are correct — what would not be correct is a + // bundle that exists but never reaches the QEMU invocation. + if let Some(lib_dir) = qemu_linux_runtime_lib_dir(project.path()) { + assert!( + lib_dir.join("libslirp.so.0").is_file(), + "installed bundle is missing libslirp.so.0: {}", + lib_dir.display() + ); + let ld = build_linux_qemu_ld_library_path(project.path(), None) + .expect("installed bundle must produce an LD_LIBRARY_PATH"); + assert!( + ld.starts_with(&lib_dir.to_string_lossy().to_string()), + "bundle must come first on LD_LIBRARY_PATH, got: {ld}" + ); + } +} diff --git a/dylints/enforce_platform_boundary/src/baseline.txt b/dylints/enforce_platform_boundary/src/baseline.txt index aaeaf7bc..77af85bd 100644 --- a/dylints/enforce_platform_boundary/src/baseline.txt +++ b/dylints/enforce_platform_boundary/src/baseline.txt @@ -3,11 +3,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 1 crates/fbuild-paths/src/dev_daemon_namespace.rs native_import std::env::current_exe 0 -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 0 -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 0 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 2 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 3 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 4 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 5 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 0 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 1 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 2 +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs attr_cfg target_os 0 diff --git a/uv.lock b/uv.lock index a92a052b..65157e05 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.5.17" +version = "2.5.19" source = { editable = "." } [package.dev-dependencies]