Skip to main content

Crate xtask

Crate xtask 

Source
Expand description

English | 简体中文

§xtask

The repository’s task runner, behind the cargo xtask alias. It wraps the commands a contributor runs before a commit, so CI and a local run cannot drift apart, and it builds the example’s web and Android artifacts.

This is a development tool for this repository only, and publish = false. It is a workspace member, so cargo clippy --workspace and cargo fmt --all cover it along with the crates it acts on; .cargo/config.toml wires it up as xtask = "run -p xtask --".

§Tasks

cargo xtask check          # typos, tombi, clippy and the doc build, then `cargo fmt --check`
cargo xtask test           # nextest over unit and integration tests, then the doctests
cargo xtask print-gpu      # print the GPU devices the tests draw with, as JSON
cargo xtask miri           # run the ECS's tests under Miri, which is what checks its unsafe
cargo xtask test-wasm      # run the GPU tests in a browser, through wasm and WebGL2
cargo xtask run-wasm       # build the web examples and serve them for a browser
cargo xtask build-android  # build the Android library and the APK around it
cargo xtask publish        # publish the workspace's crates to crates.io, in dependency order

§cargo xtask check

Runs typos and tombi, then clippy twice, then the doc build, then cargo fmt --all -- --check. The two tools that need no compiler go first, so a misspelling or an unsorted feature table costs a second rather than the minutes clippy takes. Clippy comes next because its diagnostics may leave the tree in a state rustfmt would rewrite, so formatting is the last word; the doc build sits between them because a rustdoc warning is a lint like clippy’s, and a doc comment rustdoc would rewrite is still rustfmt’s to settle. Accepts --release.

typos covers every file in the repository and tombi every TOML one — lint --error-on-warnings and then format --check — which is what CI’s lint job runs, so a misspelling or a misformatted Cargo.toml fails here instead of only there. Both are pointed at the workspace root rather than the working directory, since that is where typos.toml and tombi.toml live. Neither ships with cargo: when one is missing the step says which tool, not how to install it — both are distributed outside cargo.

Clippy runs twice, because each compiles a different set of code:

  1. The library crates with no default features at all, which is the combination that proves their general facilities compile and lint on their own — and the one a false optional feature, whose dependants never really compiled without it, fails to compile. The crates are named one by one rather than picked with --workspace, because --no-default-features reaches only the packages the command names: for every other package the features come from the dependency edges that reach it, and feature unification applies all of them. Every application crate asks unlit3d for ui and winit, so a workspace-wide run would compile the libraries with exactly the backends this pass exists to leave out.
  2. The workspace with every feature, which the doc build and the full test run compile.

§cargo xtask test

Runs cargo nextest run --workspace --lib --bins --tests --all-features, then cargo test --workspace --all-features --doc. Nextest gives every test its own process, so one test’s device, logger or panic cannot reach another’s; it does not run doctests, hence the second pass. The targets are named rather than swept in with --all-targets because the benchmarks are not test binaries.

The nextest pass runs twice: once with the default tier — the WebGPU baseline’s limits — and once with UNLIT3D_DEVICE_TIER=webgl2, which narrows every headless device to WebGL2’s limits and missing downlevel capabilities. The second pass is what exercises the paths a browser takes — the shader’s arrays read through textures instead of storage buffers, and a mesh’s vertex offset baked into its indices — on a machine whose own adapter is Vulkan, Metal or DX12. Set the variable to native to run against the adapter’s own limits instead of the baseline. Accepts --release, which applies to both passes.

A frame that fails its snapshot comparison is written before the test fails, under target/snapshot-mismatches for the default pass and target/snapshot-mismatches-webgl2 for the tier pass — a directory each, so a frame that differs under both tiers can be told apart. CI uploads them when a test fails, so a mismatch can be looked at rather than only scored.

§cargo xtask print-gpu

Prints the devices a test run would draw with — the tier, whether an indexed draw can use base_vertex, and the adapter, features and limits — as JSON. A test run draws through two tiers, the default one and then webgl2, so both are printed, one after the other. --tier reports a single one instead: webgpu, webgl2 or native, spelled the way UNLIT3D_DEVICE_TIER spells them for a test run.

The device is wgpu’s, and so is the description: AdapterInfo, Features and Limits are serialized as wgpu declares them, so the output carries the fields — and the spellings — wgpu-info would print, without a table to keep in step with a wgpu upgrade. The task runner links no graphics code, so it runs this through unlit_wgpu_test_util’s gpu-info binary and prints what that says.

CI runs it once per test job, before the suite: a mismatch against a stored frame is usually one driver disagreeing with another, so the run that reports a score also records what it ran on. cargo xtask test-wasm prints the same record for the browser, which is a device no host process can describe.

§cargo xtask miri

Runs unlit_ecs’s tests under Miri, the interpreter that checks the aliasing the compiler cannot. The world keeps component values in UnsafeCells and hands out plain references through a shared &World, so a mistake in the borrow ledger is undefined behaviour rather than a panic, and no ordinary test can see it — a release build reads the same bytes as a debug one. cargo xtask test therefore cannot stand in for this, and Miri is the gate for that code.

It interprets every test, which is one to two orders of magnitude slower, so it names the one crate whose storage is unsafe rather than the workspace. The toolchain is nightly’s, since Miri is not on stable and its sysroot is built against the nightly it ships with; rustup component add miri --toolchain nightly installs it. CI runs this as its own job, keyed on the nightly’s commit hash so a toolchain bump rebuilds the sysroot instead of reusing a stale one.

§cargo xtask test-wasm

Runs the same GPU tests cargo xtask test does, but on wasm32-unknown-unknown in a real browser, and then starts the example itself. A wasm binary has no process to start and no exit code to hand back, so the pass is assembled rather than run:

  1. The workspace is built for wasm — under the default features, which is what the browser and the APK ship, and under the wasm-release cargo profile — so a change that breaks the example or the library for this target fails here rather than only in its own job.
  2. cargo nextest list --list-type binaries-only builds the wasm test binaries and reports where they landed — asked of nextest so the target list, features and names stay the ones the host pass uses.
  3. wasm-bindgen turns each into a JS module exporting run_test.
  4. The test page, which loads one module and calls that export, is copied beside them, together with a wasm_paths.json mapping a module name to its script.
  5. A Node runner serves that directory and opens the page once per test in a fresh browser context. A frame that fails its comparison is encoded in the browser — the page has no filesystem — and handed to this runner, which writes it under target/snapshot-mismatches-wasm for CI to upload.
  6. The same native test binaries, with UNLIT3D_WASM_TEST set, become a proxy instead of a test suite: cargo nextest drives them, and each trial asks the runner to run that test in the browser. Nextest’s listing, filtering, reporting and exit code then all work as they do for an ordinary suite.

The verdict travels through sessionStorage, because a browser has no exit code and a panicking test traps its wasm instance rather than unwinding — which is also why #[should_panic] is judged by a panic hook rather than by catching the panic.

Before the tests, the runner also loads each example — the programs a browser actually runs, which the GPU tests never start — and checks that it boots, finds a backend and leaves a shaded image on its canvas rather than a blank one. They are the same pages run-wasm serves, built by the same code, so the two cannot drift.

The runner’s JS dependencies and a browser are installed on the first run; CHROME_PATH names an existing browser to reuse instead of downloading one, and --show opens a visible window to watch a failure happen. The profile the nextest pass uses is wasm, defined in .config/nextest.toml; that is a nextest profile, and separate from the wasm-release cargo profile the builds themselves use.

Everything this task compiles for wasm32-unknown-unknown goes through wasm-release, as run-wasm does. Every build for the target also enables the simd128 target feature — set in .cargo/config.toml, which the workflows extend through the per-target CARGO_TARGET_*_RUSTFLAGS variables rather than a RUSTFLAGS that would override it wholesale — under which rapier’s physics and rustysynth’s mixing both vectorize. A debug wasm is the slow thing here: it takes minutes to link, several times the size to fetch, and then runs the tests on a code path no browser ever ships.

§cargo xtask run-wasm

The standard pipeline for a wasm32-unknown-unknown binary a browser runs: build the examples for the target, hand each wasm to wasm-bindgen so it gets a JS loader, then serve the output behind a built-in static file server — WebGPU is only available in a secure context, and localhost is one while a file:// URL is not. The server is built into the task runner, so nothing needs to be installed.

--app picks what to build: unlit3d, unlit3dx or all, the default. The build lays the site out the way the Pages workflow publishes it — a landing page at the root, one directory per example beside it — and the landing page links whichever examples were built, so --app unlit3dx serves a one-card page rather than a broken link.

It binds to loopback on port 8000, or the ports after it if that one is taken, and prints the URL to open. --no-serve builds and runs bindgen without serving; trailing positional arguments are passed through to the cargo build.

The build always uses the workspace’s wasm-release cargo profile — release’s settings at a size-oriented opt-level — and enables the simd128 target feature, as every wasm build does; there is no profile switch to pass: the output lands under target/generated/wasm-release either way, and a browser is never handed the debug binary.

The binaries are named explicitly rather than left to the default target selection, which would also build the *_android crates’ cdylibs — a library kind only Android loads. The binary is what a browser runs, and wasm-bindgen is handed its .wasm.

§cargo xtask build-android

Two tools, split along the language boundary. cargo ndk cross-compiles the unlit3d_examples_android and unlit3dx_examples_android crates for Android and drops each .so into the app’s jniLibs directory, which is where Gradle looks for native libraries and packages whatever it finds; Gradle then compiles the activities and assembles the APK around them. The libraries are built first because an APK without them is an activity that cannot start.

Those two crates hold the exported android_main and nothing else. The examples themselves build as plain libraries, because crate-type is the same for every target: a cdylib in an example would link a shared library on the desktop and in the browser too, where nothing loads one. Each *_android crate keeps the example’s library name, which is what the activities ask for.

The APK carries one activity per example, each loading its own library through its own android.app.lib_name manifest entry — GameActivity reads that name once per activity, which is why switching examples starts the other activity rather than reusing the running one. A floating tab bar, drawn by TabbedExampleActivity, switches between them and finishes the activity it leaves.

Both tools configure themselves from the environment: cargo ndk reads the NDK out of ANDROID_NDK_HOME (or the newest one under ANDROID_HOME/ndk) and Gradle reads the SDK out of ANDROID_HOME (or android/local.properties). A JDK 17 or newer must be on PATH, which is what AGP requires.

One ABI, arm64-v8a, and one API level, 26, are compiled, matching abiFilters and minSdk in android/app/build.gradle.kts — those two files are the whole contract between the Rust and Gradle halves of the build. The default build type is debug; --release builds a release APK, which is left unsigned because the project ships no signing configuration.

§cargo xtask publish

Publishes the crates that reach crates.io — unlit_ecs, unlit_wgpu, unlit3d and unlit3dx — one at a time and in that order. The order is a requirement rather than a preference: a crate cannot be packaged until the crates it depends on are in the registry, so publishing unlit3d first fails with no matching package named unlit_ecs found. cargo publish waits for each uploaded crate to appear in the index, which is what makes the next one resolve.

cargo publish --workspace would order the crates itself, but its --dry-run cannot verify the packages (rust-lang/cargo#16525); publishing crate by crate keeps the verification pass a release is worth.

Every argument after -- is passed through to each cargo publish, so cargo xtask publish -- --dry-run runs every check without uploading and -- --allow-dirty publishes an uncommitted worktree. --dry-run is also recognised when it decides whether a run that stopped partway can be re-run.

The test harness, the example and the task runner are publish = false, so they are never uploaded. A real publish is not repeatable — cargo refuses a version that is already on the registry — so a run that stops partway must be resumed at the crate that failed.

§Layout

src/main.rs           argument parsing (argh) and task dispatch
src/check.rs          `cargo xtask check`
src/test.rs           `cargo xtask test`
src/print_gpu.rs      `cargo xtask print-gpu`: the device, read by `gpu-info`
src/miri.rs           `cargo xtask miri`: the ECS's tests under Miri
src/test_wasm.rs      `cargo xtask test-wasm`: the browser pass and its runner
src/run_wasm.rs       `cargo xtask run-wasm`: the wasm build, bindgen and page
src/build_android.rs  `cargo xtask build-android`: the cross-build and the APK
src/publish.rs        `cargo xtask publish`: the crates and their publish order
src/http.rs           the static file server `run-wasm` serves with
src/step.rs           running one child command and reporting which step failed

§Building it

cargo run -p xtask -- check

Being a workspace member, it shares the workspace’s Cargo.lock and target/. cargo xtask <task> from anywhere in the repository is the normal entry point.

§License

Dual-licensed under MIT or Apache-2.0, at your option.

Re-exports§

pub(crate) use argh::FromArgs; 🔒
pub(crate) use argh::FromArgs; 🔒

Modules§

build_android 🔒
cargo xtask build-android: build the examples’ shared libraries, then the APK that packages them.
check 🔒
cargo xtask check: the typo and TOML checks, clippy, the doc build, then a formatting check.
http 🔒
A minimal static file server, so serving the example needs nothing installed.
miri 🔒
cargo xtask miri: the ECS’s tests under Miri, which is what checks the unsafe in its storage.
print_gpu 🔒
cargo xtask print-gpu: describe the device the tests draw with.
publish 🔒
cargo xtask publish: publish the workspace’s crates in dependency order.
run_wasm 🔒
cargo xtask run-wasm: build and serve the web examples.
step 🔒
Running one step of a task as a child command.
test 🔒
cargo xtask test: nextest over the workspace, then the doctests.
test_wasm 🔒
cargo xtask test-wasm: run the GPU tests in a real browser.

Structs§

Args 🔒
The repository task runner.
BuildAndroidArgs 🔒
Arguments of cargo xtask build-android.
CheckArgs 🔒
Arguments of cargo xtask check.
MiriArgs 🔒
Arguments of cargo xtask miri.
PrintGpuArgs 🔒
Arguments of cargo xtask print-gpu.
PublishArgs 🔒
Arguments of cargo xtask publish.
RunWasmArgs 🔒
Arguments of cargo xtask run-wasm.
TestArgs 🔒
Arguments of cargo xtask test.
TestWasmArgs 🔒
Arguments of cargo xtask test-wasm.

Enums§

Task 🔒
The tasks cargo xtask offers.
WebApp 🔒
Which web examples a wasm task covers.

Functions§

main 🔒
run 🔒