diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 597b2d95..a7521cc6 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -210,6 +210,13 @@ jobs: timeout-minutes: 10 run: mise run wasm-cov-branch-check + # The GPU probe page's WASM only runs in a browser, so its tests run in the same headless Chrome as above. + # Their lcov is folded into lcov.info. The page holds no libs/ crate, so the branch check above does not read it. + - name: Merge GPU probe page coverage into lcov.info + if: ${{ !cancelled() && runner.os == 'Linux' && hashFiles('lcov.info') != '' }} + timeout-minutes: 20 + run: mise run gpu-probe-web-cov + # Python coverage does not depend on the Rust result, so a failing Rust test must not skip it. - name: Collect Python coverage if: ${{ !cancelled() }} diff --git a/.mise/cargo-clippy-wasm-check.jq b/.mise/cargo-clippy-wasm-check.jq new file mode 100644 index 00000000..d843da70 --- /dev/null +++ b/.mise/cargo-clippy-wasm-check.jq @@ -0,0 +1,10 @@ +# The workspace crates `cargo-clippy-wasm-check` lints as the browser builds them, as `-p ` words. +# Reads `cargo metadata --no-deps`: a browser crate depends on one of `$names`, and the crates picked are the +# workspace crates a browser crate depends on that are not browser crates themselves. +[.packages[].name] as $ws +| [.packages[] | select(any(.dependencies[].name; . as $dep | any($names[]; . == $dep)))] as $browser +| [$browser[].dependencies[] | select(.kind == null) | .name] +| unique +| .[] +| select(. as $name | any($ws[]; . == $name) and all($browser[].name; . != $name)) +| "-p", . diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 4bb5f537..6fcada8d 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -47,7 +47,7 @@ version = "0.9.1" # only be asserted where that path runs. A crate belongs to exactly one list: naming it in both would let a # gap in one pipeline be answered by the other. native_cov_libs = """ -{{ "actix-web-config core-guest edge-toolkit et-otlp gpu-probe lidar math1 path sanitise test-helpers " ~ +{{ "actix-web-config core-guest edge-toolkit et-otlp gpu-probe https-listener lidar math1 path sanitise test-helpers " ~ "test-nats test-otlp test-recovery ws-runner-common" -}} """ wasm_cov_libs = "wasi-guest web" @@ -595,23 +595,23 @@ rg "ws-wasm-agent/src/lib.rs|Filename|TOTAL" "$rpt" || true """ shell = "{{ vars.task_shell }}" -[tasks.pic-viewer-cov] +[tasks._browser-cov] depends = ["build-wasm-cov-wrapper"] -description = "Coverage for pic-viewer: run its wasm-bindgen display tests headless, emit lcov into lcov.info" -# Coverage for the module's browser-only display path; its native parse tests already ride cargo-llvm-cov. -# show_image's fetch/decode/canvas-draw only runs in a real browser, so this drives the wasm-bindgen browser -# tests (tests/show_image.rs) in headless Chrome, instrumented the same way as wasm-agent-cov -- the -# workspace wrapper's -Cinstrument-coverage + --emit=llvm-ir, wasm-bindgen-test's built-in coverage cfg, and -# et-web/coverage for minicov's profiler runtime -- then converts the .profraw the same way (gut every -# workspace .ll body to `unreachable`, keep the covmap, llc to a fixed x86_64 ELF object, llvm-cov to lcov, -# keep only this module's records). No backend is needed: the display tests are offline. +description = "Coverage for one crate's wasm-bindgen browser tests: run them headless, emit lcov into lcov.info" +# Runs one crate's wasm-bindgen browser tests in headless Chrome and folds what they covered into lcov.info. +# The tests are instrumented the same way as wasm-agent-cov's -- the workspace wrapper's -Cinstrument-coverage + +# --emit=llvm-ir, and wasm-bindgen-test's built-in coverage cfg, with whatever features the crate needs to link +# minicov's profiler runtime -- and the .profraw is converted the same way (gut every workspace .ll body to +# `unreachable`, keep the covmap, llc to a fixed x86_64 ELF object, llvm-cov to lcov, keep only the records under the +# crate's source). The profiles, objects and lcov land in target/-cov. run = """ -covdir=target/pic-viewer-cov +covdir="target/$usage_name-cov" +profdata="$covdir/$usage_name.profdata" coreutils mkdir -p "$covdir" coreutils rm -f "$covdir"/*.profraw "$covdir"/*.o "$covdir"/*.g.ll # Force a recompile so this run emits a fresh instrumented .ll whose covmap matches the captured .profraw. rustup target add wasm32-unknown-unknown -cargo clean -p et-ws-pic-viewer --target wasm32-unknown-unknown +cargo clean -p "$usage_package" --target wasm32-unknown-unknown # Resolve the webdriver, honouring a CHROMEDRIVER already in the environment before the mise-pinned one. driver="${CHROMEDRIVER:-$(mise which chromedriver)}" @@ -621,20 +621,52 @@ export RUSTC_WORKSPACE_WRAPPER="{{ config_root }}/target/debug/int-wasm-cov-wrap export RUSTFLAGS="--cfg wasm_bindgen_unstable_test_coverage" export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="$runner" export CHROMEDRIVER="$driver" -export LLVM_PROFILE_FILE="{{ config_root }}/$covdir/pic-viewer-%p.profraw" -cargo test -p et-ws-pic-viewer --features et-web/coverage --target wasm32-unknown-unknown --test show_image +export LLVM_PROFILE_FILE="{{ config_root }}/$covdir/$usage_name-%p.profraw" +features=() +if [ -n "${usage_features:-}" ]; then + features=(--features "$usage_features") +fi +cargo test -p "$usage_package" "${features[@]}" --target wasm32-unknown-unknown --test "$usage_test" host="$(rustc +{{ vars.rust_nightly }} -vV | goawk '/^host:/ { print $2 }')" llbin="$(rustc +{{ vars.rust_nightly }} --print sysroot)/lib/rustlib/$host/bin" -"$llbin/llvm-profdata" merge -sparse -o "$covdir/pic-viewer.profdata" "$covdir"/pic-viewer-*.profraw -. .mise/wasm-ll-objs.sh "$covdir" "$llbin" pic-viewer-cov -"$llbin/llvm-cov" export --format=lcov --instr-profile "$covdir/pic-viewer.profdata" "${objs[@]}" >"$covdir/all.lcov" +"$llbin/llvm-profdata" merge -sparse -o "$profdata" "$covdir/$usage_name"-*.profraw +. .mise/wasm-ll-objs.sh "$covdir" "$llbin" "$usage_name-cov" +"$llbin/llvm-cov" export --format=lcov --instr-profile "$profdata" "${objs[@]}" >"$covdir/all.lcov" coreutils touch lcov.info -goawk -v want="modules/web/pic-viewer/src/" -f .mise/lcov-keep-want.awk "$covdir/all.lcov" >>lcov.info +goawk -v want="$usage_src" -f .mise/lcov-keep-want.awk "$covdir/all.lcov" >>lcov.info rpt="$covdir/report.txt" -"$llbin/llvm-cov" report --instr-profile "$covdir/pic-viewer.profdata" "${objs[@]}" >"$rpt" 2>/dev/null || true -rg "modules/web/pic-viewer/src/lib.rs|Filename|TOTAL" "$rpt" || true +"$llbin/llvm-cov" report --instr-profile "$profdata" "${objs[@]}" >"$rpt" 2>/dev/null || true +rg "$usage_src|Filename|TOTAL" "$rpt" || true +""" +shell = "{{ vars.task_shell }}" +usage = """ +arg "" help="The run's name, which names target/-cov and the profiles in it" +arg "" help="The workspace package whose browser tests are run" +arg "" help="The test target under the package's tests/ to run" +arg "" help="The source directory whose records are kept, such as modules/web/pic-viewer/src/" +flag "--features " help="Features the test build needs, such as one that links minicov" +""" + +[tasks.gpu-probe-web-cov] +description = "Coverage for the GPU probe page's WASM: run its wasm-bindgen tests headless, emit lcov into lcov.info" +# The probe sets, canvas fingerprints and the JSON they give only run in a browser, so they are tested in one. +# Headless Chrome gives them WebGL 2, WebGPU and a 2-D canvas, and the crate's own `coverage` feature links minicov. +run = """ +src=utilities/gpu-probe/web/src/ +mise run _browser-cov gpu-probe-web et-int-gpu-probe-web probes "$src" --features coverage +""" +shell = "{{ vars.task_shell }}" + +[tasks.pic-viewer-cov] +description = "Coverage for pic-viewer: run its wasm-bindgen display tests headless, emit lcov into lcov.info" +# Coverage for the module's browser-only display path; its native parse tests already ride cargo-llvm-cov. +# show_image's fetch/decode/canvas-draw only runs in a real browser; et-web/coverage links minicov's profiler +# runtime. No backend is needed: the display tests are offline. +run = """ +src=modules/web/pic-viewer/src/ +mise run _browser-cov pic-viewer et-ws-pic-viewer show_image "$src" --features et-web/coverage """ shell = "{{ vars.task_shell }}" diff --git a/.mise/config.linux.toml b/.mise/config.linux.toml index 7363de11..8455e47c 100644 --- a/.mise/config.linux.toml +++ b/.mise/config.linux.toml @@ -7,8 +7,6 @@ # These build the deno web runner's libsqlite3-sys; a mise-only box usually only # has libclang-cpp (the C++ API), not the C one. "conda:clangxx" = "latest" -# gpg for mise's gpg_verify signature checks; _setup_all installs it up front. -"conda:gnupg" = "latest" # mold, the linker a Linux lane that sets ET_LINK_WITH_MOLD links Rust with. "aqua:rui314/mold" = "latest" diff --git a/.mise/config.macos.toml b/.mise/config.macos.toml index fc187d7c..7dd32dbe 100644 --- a/.mise/config.macos.toml +++ b/.mise/config.macos.toml @@ -2,10 +2,6 @@ # Shared vars (rpath_flag, pylib_flag, py3_unix, ...) come from config.toml, which # loads first; PYO3_PYTHON keeps its config.toml default (py3_unix). -[tools] -# gpg for mise's gpg_verify signature checks; _setup_all installs it up front. -"conda:gnupg" = "latest" - [vars] # Xcode's libclang (the lib dir beside `xcrun`'s clang). # clang-sys doesn't always auto-find it on CI. The system libclang knows the SDK, so no bindgen args. diff --git a/.mise/config.toml b/.mise/config.toml index dec7db7f..ade24e57 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -68,6 +68,10 @@ gpg_verify = true # aqua/github ship no Windows build for action-validator, so scope the aqua entry off Windows. # config.windows.toml installs cargo:action-validator (cargo-quickinstall msvc prebuilt) there instead. action-validator = { version = "latest", os = ["linux", "macos"] } +# gpg for mise's gpg_verify signature checks, which _setup_all installs up front. +# conda-forge builds no gnupg for Windows, where config.windows.toml installs conda:m2-gnupg instead, so it is scoped +# off Windows: unscoped, resolving the lockfile's windows-x64 rows fails on it. +"conda:gnupg" = { version = "latest", os = ["linux", "macos"] } # jaq: a Rust jq-compatible alternative implementation with prebuilt binaries for every platform. "aqua:01mf02/jaq" = "latest" "aqua:EmbarkStudios/cargo-deny" = "latest" @@ -686,6 +690,16 @@ wasm_scope = "--scope edge-toolkit " # name defaulted there would be one project's carried by every other. Stated once here because all three # ws-server tasks need it and a copy per task is a copy that drifts. hub_root_module = "@edge-toolkit/et-ws-server-static" +# The address a server binds to for other devices on the LAN to reach it: every IPv4 interface. +lan_bind = "0.0.0.0" +# The command every gpu-probe server task starts the server with, before the arguments it passes. +gpu_probe_server = "cargo run -p et-gpu-probe-server --bin et-gpu-probe-server --" +# The arguments the gpu-probe server tasks share: the registry, the probe page, and the certificate they serve. +# The pair lives under target/ so it is never committed; delete it to have one generated for a new LAN address. +gpu_probe_server_args = """ +{{ "--registry libs/gpu-probe/signatures.yaml --root utilities/gpu-probe/static " ~ + "--cert target/gpu-probe-server-cert.pem --key target/gpu-probe-server-key.pem" -}} +""" # Override-free head of a browser module's wasm-pack build: coverage wrapper prefix plus fixed build args. # Used by the two modules that also run et-cli module-package-json (har1, face-detection). It excludes no_opt / # web_cargo_args -- those are appended inline at the call site because no_opt carries a per-platform override that a @@ -1271,7 +1285,8 @@ run = "cargo clippy {{ vars.clippy_scope }} {{ vars.cargo_ws_excludes }}" # Lints the wasm crates on the targets they actually build for, which cargo-clippy-check's host build never sees. # The crate sets come from `cargo metadata`, by what each crate binds to, so a new module joins without an edit: -# a `wasm-bindgen` dependent is a browser crate, a `wit-bindgen` or `et-wasi-guest` dependent is a WASI guest. +# a `wasm-bindgen` dependent is a browser crate, a `wit-bindgen` or `et-wasi-guest` dependent is a WASI guest, and a +# workspace crate a browser crate depends on is built into the browser too, so its library is linted there as well. [tasks.cargo-clippy-wasm-check] description = "cargo clippy the browser and WASI crates on their wasm targets" run = """ @@ -1280,6 +1295,7 @@ meta="$(cargo metadata --format-version 1 --no-deps)" # shellcheck disable=SC2016 pick='.packages[] | select(any(.dependencies[].name; . as $dep | any($names[]; . == $dep))) | "-p", .name' browser="$(printf '%s' "$meta" | jaq -r --argjson names '["wasm-bindgen"]' "$pick")" +built_on="$(printf '%s' "$meta" | jaq -r --argjson names '["wasm-bindgen"]' -f .mise/cargo-clippy-wasm-check.jq)" wasi="$(printf '%s' "$meta" | jaq -r --argjson names '["wit-bindgen", "et-wasi-guest"]' "$pick")" # The Wasm 1.0 guests, picked by the target their docs build for, as depending on et-core-guest picks the runner too. # shellcheck disable=SC2016 @@ -1289,6 +1305,11 @@ rc=0 # Both package lists are newline-separated `-p` / `` words, and rely on word-splitting. # shellcheck disable=SC2086 cargo clippy --keep-going --all-targets --target wasm32-unknown-unknown $browser || rc=1 +# The crates browser crates build on are linted as libraries alongside them. +# Only their libraries, since their tests run natively; beside the browser crates, so cargo unifies the features +# those turn on in them, which is how their browser-only code is built. +# shellcheck disable=SC2086 +cargo clippy --keep-going --lib --target wasm32-unknown-unknown $browser $built_on || rc=1 # shellcheck disable=SC2086 cargo clippy --keep-going --all-targets --target wasm32-wasip2 $wasi || rc=1 # Only the libraries: a test needs std, which wasm32v1-none does not have. @@ -1423,6 +1444,61 @@ shell = "{{ vars.task_shell }}" description = "Check the jscpd baseline has not grown (conftest)" run = "conftest test --namespace jscpd -p config/conftest/policy config/jscpd-baseline.json" +# The files are found on disk rather than in git's index. +# So one a fresh `bench-gpu-probe` run wrote, or one renamed and not yet staged, is checked as it stands. +[tasks.conftest-check-bench-results] +description = "Check each benchmark results file is named for the machine and OS it records (conftest)" +run = """ +find libs -path '*/benches/results/*.yaml' -print0 | + xargs -0 -r conftest test --combine --namespace bench_results -p config/conftest/policy +""" +shell = "{{ vars.task_shell }}" + +# Each benchmark is recorded in Bencher Metric Format: its mean as `latency`, with its 95% interval, in nanoseconds. +# The bench writes to a criterion home of its own, emptied first, so a benchmark since renamed or removed leaves +# nothing behind to be recorded. criterion's raw samples stay in that home, out of the repository. +[tasks.bench-gpu-probe] +description = "Benchmark et-gpu-probe and record the estimates in libs/gpu-probe/benches/results/-.yaml" +run = """ +machine="$usage_machine" +home=target/criterion-gpu-probe +coreutils rm -rf "$home" +CRITERION_HOME="$PWD/$home" cargo bench -p et-gpu-probe --features native,registry --bench gpu_probe +lines="$home/benchmarks.jsonl" +# Each value is rounded to a hundredth of a nanosecond, finer than any of them is known to, so a rerun's diff is short. +# `$est` is the jaq variable each benchmark's estimates are bound to, so the shell must not expand it. +# shellcheck disable=SC2016 +mean='$est[0].mean' bound='$est[0].mean.confidence_interval' +latency="{ value: $mean.point_estimate, lower_value: $bound.lower_bound, upper_value: $bound.upper_bound }" +measure="{ (.full_id): { latency: ($latency | map_values(. * 100 | round / 100)) } }" +find "$home" -path '*/new/benchmark.json' | coreutils sort | while read -r meta; do + jaq -c --slurpfile est "$(coreutils dirname "$meta")/estimates.json" "$measure" "$meta" +done >"$lines" +host=$(rustc -vV | rg -r '$1' '^host: (.*)$') +# The same machine gives other results under another OS, through its other drivers, so the OS names the file too. +case "$host" in + *-linux-*) os=linux ;; + *-apple-darwin) os=macos ;; + *-windows-*) os=windows ;; + *) + echo "no OS name for host $host" >&2 + exit 1 + ;; +esac +out="libs/gpu-probe/benches/results/$machine-$os.yaml" +coreutils mkdir -p "$(coreutils dirname "$out")" +recorded=(--arg id "$machine" --arg os "$os" --arg host "$host" --arg rustc "$(rustc -V)") +recorded+=(--arg date "$(coreutils date -u +%F)") +# `$id`, `$os`, `$host`, `$rustc` and `$date` are jaq variables `recorded` binds, so the shell must not expand them. +# shellcheck disable=SC2016 +record='{ machine: { id: $id, os: $os, host: $host, rustc: $rustc, recorded: $date }, benchmarks: add }' +jaq -s "${recorded[@]}" "$record" "$lines" >"$home/record.json" +yq -p json -o yaml '.' "$home/record.json" >"$out" +echo "wrote $out" +""" +shell = "{{ vars.task_shell }}" +usage = 'arg "" help="This machine, as lowercase words joined by -: dell-precision-5570, say"' + # Rego unit tests living beside the policies, for invariants the real policy runs cannot reach from one OS. # Named into the conftest-check-* family so the `check` aggregate always picks it up. [tasks.conftest-check-policy-tests] @@ -2947,6 +3023,175 @@ MODULES_ROOT = "{{ vars.hub_root_module }}" OTLP_AUTH_PASSWORD = "Complexpass#123" # skipcq: SCT-A000 -- local dev-only credential, committed deliberately OTLP_AUTH_USERNAME = "root@example.com" +[tasks.build-gpu-probe-page] +description = "Build the GPU probe sets as WASM into utilities/gpu-probe/static/pkg, for the page the server serves" +dir = "utilities/gpu-probe/web" +run = """ +pkg="../static/pkg" +wasm-pack build . --target web --out-dir "$pkg" --no-pack {{ vars.no_opt }} -- --target wasm32-unknown-unknown +""" +shell = "{{ vars.task_shell }}" + +[tasks.gpu-probe-page] +depends = ["build-gpu-probe-page"] +description = "Serve the GPU probe page at http://localhost:8099/, adding what it submits to the registry" +run = "{{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --http --port 8099" + +[tasks.gpu-probe-chrome] +depends = ["build-gpu-probe-page"] +description = "Probe every GPU Google Chrome offers on the GPU probe page, WebGL on the ANGLE backend named" +# Serve the probe page and open Google Chrome on it, for a person to submit from or, with --submit, driven to. +# +# The page is served at localhost:8099 for the length of the task, unless a `gpu-probe-page` already serves it, and +# every job the task starts is stopped when it ends. Chrome is started with WebGPU on Vulkan, which Chrome on Linux +# offers only with Vulkan turned on and the GPU blocklist overridden, and WebGL on the ANGLE backend named. ANGLE takes +# Vulkan from its features rather than `--use-angle=vulkan`, which leaves Chrome with no WebGL 2 context, and Chrome +# honours only one `--enable-features`. The registry's Linux sightings were taken under X11, and each run gets a +# profile of its own under target/, so the person's own Chrome is never touched. +# +# By default Chrome opens for a person to check each row and submit. With --submit, chromedriver drives it through +# WebDriver, whose calls curl makes: the page submits every set itself when opened with `submit`, and says how each +# went in its status line, which the task prints. google-chrome and curl are the host's: the probes measure the +# browser the person has. +run = """ +trap 'coreutils kill $(jobs -p) 2>/dev/null || true' EXIT +# Wait up to $3 seconds for $1 to answer, failing as soon as $2, the process that is to serve it, has exited. +ready() { + for _ in $(coreutils seq "$3"); do + if curl -sf -o /dev/null "$1"; then return 0; fi + if ! kill -0 "$2" 2>/dev/null; then + printf '%s exited before it served %s\\n' "$4" "$1" >&2 + return 1 + fi + coreutils sleep 1 + done + printf '%s did not serve %s within %ss\\n' "$4" "$1" "$3" >&2 + return 1 +} +if ! curl -sf -o /dev/null http://localhost:8099/; then + {{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --http --port 8099 & + ready http://localhost:8099/ $! 600 "the gpu-probe server" +fi +coreutils mkdir -p target/gpu-probe-chrome +# The device goes in the page's query, so it is percent-encoded: a `&` in it would otherwise start another parameter. +device="${usage_device:-}" yq -n 'strenv(device) | @uri' >target/gpu-probe-chrome/device +device=$(coreutils cat target/gpu-probe-chrome/device) +angle="${usage_angle:-vulkan}" +if [ "$angle" = vulkan ]; then + switches=("--enable-features=Vulkan,VulkanFromANGLE,DefaultANGLEVulkan") +else + switches=(--enable-features=Vulkan "--use-angle=$angle" --enable-unsafe-swiftshader) +fi +switches+=(--enable-unsafe-webgpu --ozone-platform=x11 --no-first-run) +if [ "${usage_submit:-false}" != true ]; then + profile="$PWD/target/gpu-probe-chrome/$angle" + google-chrome "${switches[@]}" "--user-data-dir=$profile" "http://localhost:8099/?device=$device" + exit +fi +driver=http://localhost:9515 +chromedriver --port=9515 >target/gpu-probe-chrome/chromedriver.log 2>&1 & +ready "$driver/status" $! 60 chromedriver +profile="$PWD/target/gpu-probe-chrome/$angle-driven" +args=$(printf '"%s",' "${switches[@]}" "--user-data-dir=$profile") +options='{"args":['"${args%,}"']}' +capabilities='{"capabilities":{"alwaysMatch":{"browserName":"chrome","goog:chromeOptions":'"$options"'}}}' +reply=target/gpu-probe-chrome/webdriver-reply.json +curl -sf -XPOST "$driver/session" -d "$capabilities" -o "$reply" +session="$driver/session/$(yq -p json '.value.sessionId' "$reply")" +url="http://localhost:8099/?submit&device=$device" yq -n -o json -I0 '{"url": strenv(url)}' >"$reply" +curl -sf -XPOST "$session/url" -d "@$reply" >/dev/null +read_status='{"script":"return document.getElementById(\\"status\\").textContent","args":[]}' +# The page says "Submitted." only when every set went in or was on record already, and "Not submitted." otherwise. +status="" +for _ in $(coreutils seq 300); do + coreutils sleep 2 + curl -sf -XPOST "$session/execute/sync" -d "$read_status" -o "$reply" + status=$(yq -p json '.value' "$reply") + if [[ $status == Submitted* || $status == "Not submitted"* || $status == "The probes could not run"* ]]; then + break + fi +done +curl -sf -XDELETE "$session" >/dev/null +printf '%s\\n' "${status:-the page said nothing within 600s}" +[[ $status == Submitted* ]] +""" +shell = "{{ vars.task_shell }}" +usage = """ +arg "[angle]" help="The ANGLE backend Chrome runs WebGL on: vulkan (default), gl or swiftshader" +flag "--device " help="The machine the rows name, such as its make and model" +flag "--submit" help="Drive Chrome through chromedriver and submit every probe set, rather than open it for a person" +""" + +[tasks.gpu-probe-firefox] +depends = ["build-gpu-probe-page"] +description = "Probe Firefox on the GPU probe page from a fresh profile, optionally with fingerprinting protection on" +# Serve the probe page and open Firefox on it, for a person to submit from or, with --submit, to submit by itself. +# +# The page is served at localhost:8099 for the length of the task, unless a `gpu-probe-page` already serves it, and +# every job the task starts is stopped when it ends. Each run gets a fresh profile under target/, so the person's own +# Firefox is never touched, and every run is a session of its own: Firefox keys its canvas PNG encoding to the +# session, so two runs are what tells a stable signature from a randomised one. +# +# There is no WebDriver for Firefox among the tools, so with --submit the page is opened with `submit` and read from +# Firefox's own output instead, where the page writes its status lines once every set is submitted. firefox is the +# host's: the probes measure the browser the person has. +run = """ +trap 'coreutils kill $(jobs -p) 2>/dev/null || true' EXIT +if ! curl -sf -o /dev/null http://localhost:8099/; then + {{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --http --port 8099 & + server=$! + for _ in $(coreutils seq 600); do + if curl -sf -o /dev/null http://localhost:8099/; then break; fi + if ! kill -0 "$server" 2>/dev/null; then + printf 'the gpu-probe server exited before it served the page\\n' >&2 + exit 1 + fi + coreutils sleep 1 + done +fi +run=target/gpu-probe-firefox +profile="$PWD/$run/profile" +coreutils rm -rf "$profile" +coreutils mkdir -p "$profile" +coreutils cp .mise/gpu-probe-firefox.user.js "$profile/user.js" +query="device=$(device="${usage_device:-}" yq -n 'strenv(device) | @uri')" +if [ "${usage_fingerprinting_protection:-false}" = true ]; then + printf 'user_pref("privacy.fingerprintingProtection", true);\\n' >>"$profile/user.js" + query="$query&defence=privacy.fingerprintingProtection%20on" +fi +if [ "${usage_submit:-false}" != true ]; then + firefox --no-remote --profile "$profile" --new-window "http://localhost:8099/?$query" + exit +fi +firefox --no-remote --profile "$profile" --new-window "http://localhost:8099/?submit&$query" >"$run/firefox.log" 2>&1 & +browser=$! +# The page says "Submitted." only when every set went in or was on record already, and "Not submitted." otherwise. +verdict='gpu-probe: (Submitted|Not submitted|The probes could not run)' +for _ in $(coreutils seq 300); do + if rg -q "$verdict" "$run/firefox.log" || ! kill -0 "$browser" 2>/dev/null; then break; fi + coreutils sleep 2 +done +# Firefox writes each console line as the call's arguments, quoted; the status lines are what follows the prefix. +rg -o 'gpu-probe: [^"]*' "$run/firefox.log" || printf 'the page said nothing within 600s\\n' +rg -q 'gpu-probe: Submitted' "$run/firefox.log" +""" +shell = "{{ vars.task_shell }}" +usage = """ +flag "--device " help="The machine the rows name, such as its make and model" +flag "--fingerprinting-protection" help="Turn on privacy.fingerprintingProtection, and name it as the rows' defence" +flag "--submit" help="Submit every probe set from the page and print how each went, rather than open it for a person" +""" + +[tasks.gpu-probe-server] +depends = ["build-gpu-probe-page"] +description = "Collect GPU probe signatures into libs/gpu-probe/signatures.yaml over HTTPS at localhost:8443" +run = "{{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }}" + +[tasks.gpu-probe-server-lan] +depends = ["build-gpu-probe-page"] +description = "Collect GPU probe signatures over HTTPS from other devices on the LAN, listening on 0.0.0.0:8443" +run = "{{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --bind {{ vars.lan_bind }}" + [tasks.ws-server-no-otel] description = "Run the WebSocket server without OTLP/o2 observability (OpenTelemetry init is skipped)" # Same as ws-server but sets no OTLP_* env, so Config.otlp deserialises to None and et_otlp::init is skipped. @@ -3527,17 +3772,36 @@ shell = "{{ vars.task_shell_plain }}" # install-all rather than a bare install, so guest-language tools reach their lockfiles too -- MISE_ENV only # loads a subset, and a partial install leaves every unloaded config's lockfile unexamined. # +# Within each lockfile the entries are compared as a set -- each `[[tools.]]` header with its own `version` and +# `backend`, so a value stays tied to its tool -- and their order does not count: a tool locked at two versions, as +# rust is at `stable` and the pinned nightly, has its entries written in either order depending on where mise runs, +# and the order alone read as drift when lines were compared. CI wrote the opposite of the committed order, +# [mise-lock-check] -version = "stable" +# [mise-lock-check] +version = "nightly-2026-08-05" +# at https://github.com/edge-toolkit/core/actions/runs/43384034380/job/131748196639 on commit +# https://github.com/edge-toolkit/core/commit/3682bb426d01268f9d525207ec624e8dd8e441d9, where a local install wrote +# the committed one. Each file is compared on its own, so a tool moved from one lockfile to another is still drift. +# # The .mise/locks/ sidecars -- the npm and PyPI tools' dependency graphs, which the lockfiles reference by path and # digest -- carry no per-platform rows, so they are compared whole: any change, or a graph directory git does not # track yet, is drift. [tasks.mise-lock-check] depends = ["install-all"] description = "Fail if .mise/mise*.lock or .mise/locks/ differ from what a full install resolves" -# `|| true` because rg exits 1 on no matches, which under pipefail would abort the whole task on the clean path. -# `^[-+][-+]` drops git's `+++`/`---` file headers; `@@` hunk headers never match the leading `[-+]`. +# The staged copy of each lockfile, from git's index, and the copy install-all left must list the same entries. +# A lockfile git does not hold yet lists none. Both the sort and comm run in the C locale, so they agree on the order +# comm needs its input in. run = """ -drift=$(git diff -U0 -- .mise/mise*.lock | rg "^[-+]" | rg -v "^[-+][-+]" | - rg "^[-+](version|backend) = |^[-+]\\[\\[tools\\." || true) +entries=.mise/mise-lock-check.awk +drift="" +for lock in .mise/mise*.lock; do + staged=$( (git show ":$lock" 2>/dev/null || true) | goawk -f "$entries" | LC_ALL=C coreutils sort) + resolved=$(goawk -f "$entries" "$lock" | LC_ALL=C coreutils sort) + if [ "$staged" != "$resolved" ]; then + changed=$(LC_ALL=C coreutils comm -3 <(printf '%s\\n' "$staged") <(printf '%s\\n' "$resolved")) + drift=$(printf '%s\\n%s:\\n%s' "$drift" "$lock" "$changed") + fi +done sidecars=$( git diff --name-only -- .mise/locks git ls-files --others --exclude-standard -- .mise/locks diff --git a/.mise/gpu-probe-firefox.user.js b/.mise/gpu-probe-firefox.user.js new file mode 100644 index 00000000..05c42316 --- /dev/null +++ b/.mise/gpu-probe-firefox.user.js @@ -0,0 +1,20 @@ +// The prefs `mise run gpu-probe-firefox` starts each fresh Firefox profile with. +// They skip what a new profile opens with -- the onboarding and pre-onboarding screens, the terms-of-use and +// data-reporting notices, the welcome page and the default-browser question -- so the probe page is the only thing +// shown, and send the page's console to stdout, where the task reads how the run went. +// Firefox defines `user_pref` itself, as it reads this file into the profile. +/* global user_pref */ +user_pref("browser.aboutwelcome.enabled", false); +user_pref("browser.preonboarding.enabled", false); +user_pref("browser.shell.checkDefaultBrowser", false); +user_pref("browser.startup.firstrunSkipsHomepage", true); +user_pref("browser.startup.homepage_override.mstone", "ignore"); +user_pref("browser.uitour.enabled", false); +user_pref("datareporting.policy.dataSubmissionEnabled", false); +user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true); +user_pref("devtools.console.stdout.content", true); +user_pref("startup.homepage_welcome_url", ""); +user_pref("startup.homepage_welcome_url.additional", ""); +user_pref("termsofuse.bypassNotification", true); +user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); +user_pref("trailhead.firstrun.didSeeAboutWelcome", true); diff --git a/.mise/mise-lock-check.awk b/.mise/mise-lock-check.awk new file mode 100644 index 00000000..8751bcb9 --- /dev/null +++ b/.mise/mise-lock-check.awk @@ -0,0 +1,13 @@ +# Lists each entry of a mise lockfile on a line of its own: its `[[tools.]]` header, version and backend. +# mise-lock-check sorts and compares these lists, so each value stays tied to the tool it belongs to while the order +# the entries were written in counts for nothing. Only an entry's own `version` and `backend` are taken, the first of +# each after its header, so a later table of that tool -- its options, a platform's checksum -- adds nothing. +/^\[\[tools\./ { + if (tool != "") print tool " " version " " backend + tool = $0 + version = "" + backend = "" +} +/^version = / && version == "" { version = $0 } +/^backend = / && backend == "" { backend = $0 } +END { if (tool != "") print tool " " version " " backend } diff --git a/.mise/mise.js.lock b/.mise/mise.js.lock index 8b0bf487..b186a176 100644 --- a/.mise/mise.js.lock +++ b/.mise/mise.js.lock @@ -23,9 +23,11 @@ checksum = "blake3:2f2414bd00332f5fbe3b50ef84e3dde7f3ee35c9fa29ad3ca449710e4b171 url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.69.0/oxfmt-aarch64-apple-darwin.tar.gz" [tools."http:oxfmt"."platforms.macos-x64"] +checksum = "sha256:80bf2c7e48f1d02dfe0cdbf510fc8c786df533448e8175fea58c4a6173e1752b" url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.69.0/oxfmt-x86_64-apple-darwin.tar.gz" [tools."http:oxfmt"."platforms.windows-x64"] +checksum = "sha256:70381a422464bf13abd8d8210e0e0cdc2aedc96dfe0a4d76a732de547a3ea7a6" url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.69.0/oxfmt-x86_64-pc-windows-msvc.zip" [[tools."http:oxlint"]] @@ -45,12 +47,15 @@ checksum = "blake3:974e8e6b96e5e21882977efdf829ae4043e5424351f27a245ce22da93670f url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint-x86_64-unknown-linux-gnu.tar.gz" [tools."http:oxlint"."platforms.macos-arm64"] +checksum = "sha256:f27b2959685ae13694a83a7d420154b440d6b4f62fd2942c136666e2ab3cc2cc" url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint-aarch64-apple-darwin.tar.gz" [tools."http:oxlint"."platforms.macos-x64"] +checksum = "sha256:bddfbbdd38cfc7b22def9b6698b9d87e4477730763ce1fa2605afcdcea08ba73" url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint-x86_64-apple-darwin.tar.gz" [tools."http:oxlint"."platforms.windows-x64"] +checksum = "sha256:4705fa429bc730cd4663ab5ca227d2bc73bace2a87e6c7d0882012fb4b947619" url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.73.0/oxlint-x86_64-pc-windows-msvc.zip" [[tools."npm:@asyncapi/generator"]] diff --git a/.mise/mise.linux.lock b/.mise/mise.linux.lock index 6d0b8c80..9383f494 100644 --- a/.mise/mise.linux.lock +++ b/.mise/mise.linux.lock @@ -12,10 +12,6 @@ checksum = "sha256:548d128bc257badd2d9dbdff8173eb693fcbec84ceeb0fa74cbfed141ed42 url = "https://conda.anaconda.org/conda-forge/linux-aarch64/binutils_impl_linux-aarch64-2.46.1-default_h5f4c503_102.conda" checksum = "sha256:eebe159bf600943552e4319ff4ce27b6a2dadf0dcc5443e76dcee9259a408e11" -[conda-packages.linux-arm64."bzip2-1.0.8-h4777abc_10"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_10.conda" -checksum = "sha256:24eacc8a20fd7c4616566178562bef7f9344eb4a8700cfc3180fa75a6ff9d39f" - [conda-packages.linux-arm64."clang-23-23.1.2-default_h473506b_0"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/clang-23-23.1.2-default_h473506b_0.conda" checksum = "sha256:408ffc3dec7d6ac5ef17a11fdb925a94f178f9d0414c4412884c6394b1a89ab1" @@ -64,10 +60,6 @@ checksum = "sha256:5d224bf4df9bac24e69de41897c53756108c5271a0e5d2d2f66fd4e2fbc1d url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.46.1-default_h1979696_102.conda" checksum = "sha256:2c4901f4227b0850328ed0c69f958b30ad2cd18982f7a31c7c1f911827004d08" -[conda-packages.linux-arm64."libassuan-3.0.2-h5ad3122_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libassuan-3.0.2-h5ad3122_0.conda" -checksum = "sha256:042843a7760a9be3e7642528a2667b644f8eda009e1846658726fdca610afb73" - [conda-packages.linux-arm64."libclang-cpp23.1-23.1.2-default_h01b09c7_0"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libclang-cpp23.1-23.1.2-default_h01b09c7_0.conda" checksum = "sha256:9b0ced8d10de3dd1cc8271779dd1603b6168ee2384b0fa79ac7743a1ea0ae47c" @@ -88,42 +80,14 @@ checksum = "sha256:eae7a3449d5a3b5351c5427137c076cc3fc826645c7bfc42dc10772b78382 url = "https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-aarch64-16.2.0-hc0c2482_107.conda" checksum = "sha256:3a0fc46d9ffe0bc98e825bfcc0409564435e74ae0ac094b484a89a296ed777ee" -[conda-packages.linux-arm64."libgcc-ng-16.2.0-he9431aa_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-16.2.0-he9431aa_7.conda" -checksum = "sha256:d7502682cfce6f51aabd40bba63cd6b46db3d3e4b67abdb00f54ae72332523db" - -[conda-packages.linux-arm64."libgcrypt-1.12.2-h8af1aa0_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-1.12.2-h8af1aa0_2.conda" -checksum = "sha256:327d2239319829def075ca2580b810060bc956036819d771d727352029cf6735" - -[conda-packages.linux-arm64."libgcrypt-devel-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-devel-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:2dc2ab9e7a58bb9b774e30a4b5a178113f0adbf76c5298e43b1cf0e2f272e8f5" - -[conda-packages.linux-arm64."libgcrypt-lib-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-lib-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:26f2981255e575a47c1fb4bcd0f869d0fadfa3ab44a0ab005fe3887f75e7a154" - -[conda-packages.linux-arm64."libgcrypt-tools-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-tools-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:3d566ba1641653f7ec835ee1401397e296c77d47c7b979bc6725c33883fe377a" - [conda-packages.linux-arm64."libgomp-16.2.0-h8acb6b2_7"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-16.2.0-h8acb6b2_7.conda" checksum = "sha256:4406fd67c5beeb99b9372f9bf07aa3e40ca141e337eceebac3ff64aa72799494" -[conda-packages.linux-arm64."libgpg-error-1.61-h7ac5ae9_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgpg-error-1.61-h7ac5ae9_2.conda" -checksum = "sha256:df4c43ae4003100feea4d96000fcfff57f05832b2aa5b9385bba05cb33fa776f" - [conda-packages.linux-arm64."libiconv-1.18-h1ea5142_3"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h1ea5142_3.conda" checksum = "sha256:d22c666eb887afd2119aac2110e23a0e817e9391bda99293421f7c46dab1564c" -[conda-packages.linux-arm64."libksba-1.6.7-h0a1ffab_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libksba-1.6.7-h0a1ffab_0.conda" -checksum = "sha256:6bd8c23157e3afa89d56ff876993232d94dc796e1ad190f3cd56148815c7004f" - [conda-packages.linux-arm64."libllvm23-23.1.2-h680871c_0"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libllvm23-23.1.2-h680871c_0.conda" checksum = "sha256:9b7820c041aeebea33d738a917c185ae554b6b89e2491e83e64f9a2fe675f88c" @@ -132,10 +96,6 @@ checksum = "sha256:9b7820c041aeebea33d738a917c185ae554b6b89e2491e83e64f9a2fe675f url = "https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_1.conda" checksum = "sha256:f760669fd1cea27f689f411c0d5488e26ce50e382c9b63e4ad348f959850124a" -[conda-packages.linux-arm64."libsqlite-3.53.4-h399dd60_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.4-h399dd60_1.conda" -checksum = "sha256:fae75e68e9dbc3c90dcf93d4bae6315f1330c95aaf1404a721e8468266c23475" - [conda-packages.linux-arm64."libstdcxx-16.2.0-hef695bb_7"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-16.2.0-hef695bb_7.conda" checksum = "sha256:b241a3acfa9cdb1359112864a1241225533c92642de3000d52ae65ef5d3a9786" @@ -144,10 +104,6 @@ checksum = "sha256:b241a3acfa9cdb1359112864a1241225533c92642de3000d52ae65ef5d3a9 url = "https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-aarch64-16.2.0-h082e5f6_107.conda" checksum = "sha256:c46e0efb196a24e9192f160c124f8a1d927c7d191595a1dc993f30dc93a52497" -[conda-packages.linux-arm64."libstdcxx-ng-16.2.0-hdbbeba8_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-16.2.0-hdbbeba8_7.conda" -checksum = "sha256:cfe644e4cf78302e605f7dc1cfa23ee4edabf5b72f84c92436f5f51c3003542c" - [conda-packages.linux-arm64."libxml2-16-2.15.4-h2e5089f_0"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.4-h2e5089f_0.conda" checksum = "sha256:58bf7fcf64bae7f624a5e2aed860ddea614a8ec13c8c43fe150420a275b8bdb5" @@ -164,26 +120,6 @@ checksum = "sha256:76efa6cc9d7e6f5ee3bbca0939f64054af2bdfb3c3632531f8e633cf6c2ea url = "https://conda.anaconda.org/conda-forge/linux-aarch64/llvm-openmp-23.1.2-hde6636f_0.conda" checksum = "sha256:75a5cba29fd370cf20c6922587bbf4034858e2a46815b2cc45144f009b596df7" -[conda-packages.linux-arm64."ncurses-6.6-h2b6f883_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-h2b6f883_1.conda" -checksum = "sha256:d69a04914139627f0a6bfd19412d6c7f1e37edc896af84a6011e07e8bc1e69fa" - -[conda-packages.linux-arm64."npth-1.8-h5ad3122_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/npth-1.8-h5ad3122_0.conda" -checksum = "sha256:1fcbc982059bc5ed7a544ae43a8be6d9ee8d0d4c3fe8fb30eb9185db0aa6e114" - -[conda-packages.linux-arm64."ntbtls-0.3.2-hd84c7bf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ntbtls-0.3.2-hd84c7bf_0.conda" -checksum = "sha256:d9af7c17e6c452312a9d623eeff588f427242986fcc39ea65a0ef030dbe8cbe1" - -[conda-packages.linux-arm64."readline-8.3-ha7194a6_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-ha7194a6_1.conda" -checksum = "sha256:80167dcf73a96b6d0c1bb2298d7b8b9bc21b27cc5bf56979f9c548b49a4d1722" - -[conda-packages.linux-arm64."sqlite-3.53.4-h6ce2f9a_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/sqlite-3.53.4-h6ce2f9a_1.conda" -checksum = "sha256:551c79a5cf720c1302bc201f8633c27954a5daeb67be3719023a24ac8fd9f818" - [conda-packages.linux-arm64."sysroot_linux-aarch64-2.28-h585391f_9"] url = "https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-aarch64-2.28-h585391f_9.conda" checksum = "sha256:1bd2db6b2e451247bab103e4a0128cf6c7595dd72cb26d70f7fadd9edd1d1bc3" @@ -192,10 +128,6 @@ checksum = "sha256:1bd2db6b2e451247bab103e4a0128cf6c7595dd72cb26d70f7fadd9edd1d1 url = "https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda" checksum = "sha256:b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8" -[conda-packages.linux-arm64."zlib-1.3.2-hdc9db2a_3"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.2-hdc9db2a_3.conda" -checksum = "sha256:3f3c296477cf26474660942f4d8ec104a8eee2cb1abc7c5bfec9ac1102c66451" - [conda-packages.linux-arm64."zstd-1.5.7-h9d15635_7"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h9d15635_7.conda" checksum = "sha256:427fd14bcb3b8659796fecc682716617409350fb5a98e5b7b47558a10d1a2fc7" @@ -212,10 +144,6 @@ checksum = "sha256:659c367ef49df7741749a2ad240b007d7df51b4f210505a78543deafb001e url = "https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda" checksum = "sha256:fb7bf36984a37ce7e4714d1d1da0bd0e3bfc679520f5cdc184afc676fd4b5da2" -[conda-packages.linux-x64."bzip2-1.0.8-hda65f42_10"] -url = "https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda" -checksum = "sha256:1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96" - [conda-packages.linux-x64."clang-23-23.1.2-default_hfab3205_0"] url = "https://conda.anaconda.org/conda-forge/linux-64/clang-23-23.1.2-default_hfab3205_0.conda" checksum = "sha256:86a6c2e8d77a1c07905623c4decaa2304a27a55ea7fa39a2ce338f911104f799" @@ -252,10 +180,6 @@ checksum = "sha256:8ab3d19a6d833a9d98a559d40a2e9f07eea353d5501c2610417f53102391b url = "https://conda.anaconda.org/conda-forge/noarch/compiler-rt_linux-64-23.1.2-ha770c72_0.conda" checksum = "sha256:1365a5fff5c9463b09586ed4cd0b03864083fde070bbbe5320d4ddf70d648ebe" -[conda-packages.linux-x64."icu-78.3-py310h44b86e0_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda" -checksum = "sha256:9f07834f0c546ab14d885ce0366285f61f44e326c0edd1fc63b8294e113ae432" - [conda-packages.linux-x64."kernel-headers_linux-64-4.18.0-he073ed8_9"] url = "https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda" checksum = "sha256:41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a" @@ -264,10 +188,6 @@ checksum = "sha256:41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b3 url = "https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda" checksum = "sha256:27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec" -[conda-packages.linux-x64."libassuan-3.0.2-h5888daf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libassuan-3.0.2-h5888daf_0.conda" -checksum = "sha256:a89ee880e951cc9399606545cb9e6377c8f260fa40942b8486d82d64d3d34491" - [conda-packages.linux-x64."libclang-cpp23.1-23.1.2-default_h0acdd01_0"] url = "https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp23.1-23.1.2-default_h0acdd01_0.conda" checksum = "sha256:49156155d4f0ab4f479bc96d24db2c48c0b6bdb5659135c194f03c289ae2c523" @@ -288,42 +208,14 @@ checksum = "sha256:e031634c3a928f9594eba868bc46ef5230d6571a364a0c63215b06c4563bf url = "https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-16.2.0-he3ce08f_107.conda" checksum = "sha256:f1e7465f6b53f89b325e441c30ad7b19407b051eca556b6d0386ec05513b8e76" -[conda-packages.linux-x64."libgcc-ng-16.2.0-h69a702a_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.2.0-h69a702a_7.conda" -checksum = "sha256:03b54d7008de080a17cc2174b0e4acd6d06e1c981b2eb19846f46d8f59966034" - -[conda-packages.linux-x64."libgcrypt-1.12.2-ha770c72_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-1.12.2-ha770c72_2.conda" -checksum = "sha256:831898d269b74439ef904d5ae237ed08a1c886a3a4f29fd6973eb09d22e59aa6" - -[conda-packages.linux-x64."libgcrypt-devel-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-devel-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:ba6a30372f94eab139a4df5967cfb685e0a1bd6cb2d4d464698594e29c5f2de2" - -[conda-packages.linux-x64."libgcrypt-lib-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:a67e7b569f08459fbd62619cbbc14a0e2da9d9e519feb11ee035bb31c653d1a9" - -[conda-packages.linux-x64."libgcrypt-tools-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-tools-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:781063cd902514ad5c27f30f512d7175f5c51013a1fecb1aa602ca04b583a8f2" - [conda-packages.linux-x64."libgomp-16.2.0-he0feb66_7"] url = "https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda" checksum = "sha256:92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758" -[conda-packages.linux-x64."libgpg-error-1.61-h54a6638_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.61-h54a6638_2.conda" -checksum = "sha256:b69606451dfce168990dd40324e52daf981472511788d412ce78664760d3889f" - [conda-packages.linux-x64."libiconv-1.18-h0cb94f2_3"] url = "https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda" checksum = "sha256:f943117edb9cd4d9c61cc972eee5a34291dc55ea7a6e9e38da104995841cbcb6" -[conda-packages.linux-x64."libksba-1.6.7-hac33072_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libksba-1.6.7-hac33072_0.conda" -checksum = "sha256:289d4f2bf33134c4f5565f772829cb66c996485723bd9c299a21f73ff8aed77a" - [conda-packages.linux-x64."libllvm23-23.1.2-h474f4eb_0"] url = "https://conda.anaconda.org/conda-forge/linux-64/libllvm23-23.1.2-h474f4eb_0.conda" checksum = "sha256:253a11a58756a3584c72c625752fb6ccf0a8885815f3e911f05731cfc24ea0ed" @@ -332,10 +224,6 @@ checksum = "sha256:253a11a58756a3584c72c625752fb6ccf0a8885815f3e911f05731cfc24ea url = "https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda" checksum = "sha256:9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab" -[conda-packages.linux-x64."libsqlite-3.53.4-h13e7031_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h13e7031_1.conda" -checksum = "sha256:f20d70da54e5b31dd4a51fb1efeaafafd5ab6dd8d7ac9d1438eaa2526ac4ed3d" - [conda-packages.linux-x64."libstdcxx-16.2.0-h934c35e_7"] url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda" checksum = "sha256:fa7018298629fc429971fffd6ecba3d27a7b02e4e890d6a93ea01ffebe8cb745" @@ -344,10 +232,6 @@ checksum = "sha256:fa7018298629fc429971fffd6ecba3d27a7b02e4e890d6a93ea01ffebe8cb url = "https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-16.2.0-h86e191b_107.conda" checksum = "sha256:ebbad84bdebd8a9964020d74dcce0f211f1431878c66326f6115dcb59e4c3469" -[conda-packages.linux-x64."libstdcxx-ng-16.2.0-hdf11a46_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda" -checksum = "sha256:337f481db36cc357884f05f404ccd1b3a7e0fc1ee48ec5c884e4281d213bc863" - [conda-packages.linux-x64."libxml2-16-2.15.4-hbdfff7e_0"] url = "https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hbdfff7e_0.conda" checksum = "sha256:b6f96287c408269f5067bb4f7a61693ff5c856152902362964d9807152b9c146" @@ -364,26 +248,6 @@ checksum = "sha256:eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc url = "https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-23.1.2-h7148c6a_0.conda" checksum = "sha256:292606e3a17fcf98db940d3ee6e723f84f4c6fb0104a6a69cd95043a7794eef0" -[conda-packages.linux-x64."ncurses-6.6-hdb14827_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda" -checksum = "sha256:5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3" - -[conda-packages.linux-x64."npth-1.8-h5888daf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/npth-1.8-h5888daf_0.conda" -checksum = "sha256:da3af9708bbf0d459360f7c706d2550859560262c232a5b41a0715806ad7dafc" - -[conda-packages.linux-x64."ntbtls-0.3.2-hfc55251_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/ntbtls-0.3.2-hfc55251_0.conda" -checksum = "sha256:63184386e34caad02ff05ee9ea84428197131c63ae9b424c8247e2488c0b5673" - -[conda-packages.linux-x64."readline-8.3-hd6e31c0_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda" -checksum = "sha256:01b3fe073a66e321970d09e04b388708f8cbdca5cdfbfcb7c9eeb470ad10383d" - -[conda-packages.linux-x64."sqlite-3.53.4-h9ffa6c4_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.4-h9ffa6c4_1.conda" -checksum = "sha256:b01812eff4e950a73933cb3dc14647a97ee377c7fab4858495b80a749ebfbf8b" - [conda-packages.linux-x64."sysroot_linux-64-2.28-h4ee821c_9"] url = "https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda" checksum = "sha256:c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851" @@ -392,18 +256,10 @@ checksum = "sha256:c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b url = "https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda" checksum = "sha256:b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8" -[conda-packages.linux-x64."zlib-1.3.2-h25fd6f3_3"] -url = "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda" -checksum = "sha256:16080a1c7724f7d25727cdc23c7658e0cec2db52448c1dc0c33467ee2c6e1c62" - [conda-packages.linux-x64."zstd-1.5.7-hb78ec9c_7"] url = "https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda" checksum = "sha256:47d682b9f6d6ec9eb1a6e6c3e75ea6273e899e78fb7fc59f81d39745009fbc60" -[conda-packages.macos-arm64."bzip2-1.0.8-h4e30115_10"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda" -checksum = "sha256:8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6" - [conda-packages.macos-arm64."ca-certificates-2026.7.22-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda" checksum = "sha256:0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6" @@ -464,10 +320,6 @@ checksum = "sha256:aa34de810767f7d92721aeab004479672853f2fb9235a3e9c350db41c0c86 url = "https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm23_1_hc8058f9_6.conda" checksum = "sha256:857ab8bf793d29a4d94af0c9eb536d1bb5a56ab9c4b5fb7a4100b24c35e96446" -[conda-packages.macos-arm64."libassuan-3.0.2-h286801f_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libassuan-3.0.2-h286801f_0.conda" -checksum = "sha256:0d8ec81850417d106df2cd4d4cb21e1597c8ce9a930bc86857ea504eb8e08dd4" - [conda-packages.macos-arm64."libclang-cpp23.1-23.1.2-default_h79071a4_0"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp23.1-23.1.2-default_h79071a4_0.conda" checksum = "sha256:7a176b6dda75f39184cc96b548fa436581451547bb185c610627a9b6dca05ca8" @@ -492,38 +344,10 @@ checksum = "sha256:4b125b5cd6e93b96e6dfd4901da800298ce9297d9fbd7d3bd864c76865818 url = "https://conda.anaconda.org/conda-forge/noarch/libcxx-headers-23.1.2-h707e725_0.conda" checksum = "sha256:8f2231b686519f3779b639cc1de0c9a9f4926265056a3c356767bb605c0437a2" -[conda-packages.macos-arm64."libgcrypt-1.12.2-hce30654_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-1.12.2-hce30654_2.conda" -checksum = "sha256:2512a5201146cbe4abccc6e7a7a46bd98424013b5c19363d8204e91c11b0e39a" - -[conda-packages.macos-arm64."libgcrypt-devel-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-devel-1.12.2-hb001647_2.conda" -checksum = "sha256:2fe60bf669bac3f2d36ea70d78418f71b45afe6ba5d5bb3b4a50b6f2e42e97b4" - -[conda-packages.macos-arm64."libgcrypt-lib-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-lib-1.12.2-hb001647_2.conda" -checksum = "sha256:45b1b91ff0b7dcfb2d31ffcdc156be2cb93998d80764f9a1f4bbd6a51e5a318a" - -[conda-packages.macos-arm64."libgcrypt-tools-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-tools-1.12.2-hb001647_2.conda" -checksum = "sha256:6e972ad2159b0f0f8bfec35814b5b736cbf8aec32c666346074d8eb9f7cca8a5" - -[conda-packages.macos-arm64."libgpg-error-1.61-h784d473_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgpg-error-1.61-h784d473_2.conda" -checksum = "sha256:eb84229d2ac791a4241044dc3ad172389beb4a730cc4a2afb1ddbe51e8233d11" - [conda-packages.macos-arm64."libiconv-1.18-he4c29f2_3"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda" checksum = "sha256:689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f" -[conda-packages.macos-arm64."libintl-0.25.1-hae62183_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-hae62183_2.conda" -checksum = "sha256:b43c6645e668f86d04d5c06d6dc17efe921bbd9244be4b77946d56fb950a3fcc" - -[conda-packages.macos-arm64."libksba-1.6.7-h00cdb27_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libksba-1.6.7-h00cdb27_0.conda" -checksum = "sha256:1fb2da81dd151baf940bfb90b9bfe8b14a57ab75d28b95fd5adde89e14b8b288" - [conda-packages.macos-arm64."libllvm23-23.1.2-h759d1ac_0"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/libllvm23-23.1.2-h759d1ac_0.conda" checksum = "sha256:9717b7ae514600cebeb43ea89bf008bef6e96fbbefa1dda40843c394f2b6a46e" @@ -536,10 +360,6 @@ checksum = "sha256:23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4 url = "https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_1.conda" checksum = "sha256:fcfa03afe31f40dfabf011629d99836adbebbc3ed1b38c7e9eee9ffc7581975b" -[conda-packages.macos-arm64."libsqlite-3.53.4-hca69786_1"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-hca69786_1.conda" -checksum = "sha256:839b31d4830e896b4d315b551d27f2bb08026bc946df04bcc360d8627c3ba2cd" - [conda-packages.macos-arm64."libxml2-16-2.15.4-heb56d2d_0"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda" checksum = "sha256:8a26f7c94ab849c1930da36c298f4b82e892382b70ae865cd7274188d9d783e2" @@ -568,46 +388,22 @@ checksum = "sha256:2911fe5dad062b513f572d317fbc7ef1e94f177cac63db01bbb1d4278e707 url = "https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda" checksum = "sha256:7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68" -[conda-packages.macos-arm64."npth-1.8-h286801f_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/npth-1.8-h286801f_0.conda" -checksum = "sha256:e6f12cee635860a2f70dc40ad28d20d80cdabf566dfab2db1ca0489b1fb891a9" - -[conda-packages.macos-arm64."ntbtls-0.3.2-h1059232_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/ntbtls-0.3.2-h1059232_0.conda" -checksum = "sha256:3b2343fb4120dd864ceb050123a84943c4cce3f65c0d010d82950af873d6e3f0" - [conda-packages.macos-arm64."openssl-3.6.4-h55eecbc_0"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda" checksum = "sha256:f23239eacd75c4c50705e68fae1aa3292da473e6a3a4abe2330f1e6afa680704" -[conda-packages.macos-arm64."readline-8.3-h8b90a29_1"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda" -checksum = "sha256:d6782b430e6dce4fe8c7ac118cd988d5a193eb4a7f60741edfaede58016b6110" - [conda-packages.macos-arm64."sigtool-codesign-0.1.3-h98dc951_1"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_1.conda" checksum = "sha256:7efd6d7d18bf9cc5788bfe1c1e1620d9dbbe00a81c646814929a82ff31944cf3" -[conda-packages.macos-arm64."sqlite-3.53.4-ha9f7ffe_1"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.53.4-ha9f7ffe_1.conda" -checksum = "sha256:4e0c8704a29b6c265e7bdb6508e1d4180570adcc6e031daf182578d3777fb8b1" - [conda-packages.macos-arm64."tapi-1600.0.11.8-hb561403_4"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_4.conda" checksum = "sha256:6effea9587f049e702c838e8e57d98ac92b89ca1f3f1edd51c78dd252d24e83b" -[conda-packages.macos-arm64."zlib-1.3.2-h8088a28_3"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda" -checksum = "sha256:ab46d85e4fcffff1b1c5cac517afa40b7ae784cf76fc9da1f55f6a6934291eb6" - [conda-packages.macos-arm64."zstd-1.5.7-hf451053_7"] url = "https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda" checksum = "sha256:da867f5092eb0cb746d353694f0098031fd9817a4ce7d5743121209ae0f406ca" -[conda-packages.macos-x64."bzip2-1.0.8-h374f1ed_10"] -url = "https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda" -checksum = "sha256:4ed83961876dc8844a6f0df49c07b408efbaea275ffb0b37133e24c006990b3a" - [conda-packages.macos-x64."ca-certificates-2026.7.22-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda" checksum = "sha256:0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6" @@ -664,10 +460,6 @@ checksum = "sha256:11a6c36fd6207683a57d5e91533d3ce1756c1fc7e2a30504de8686ac867a8 url = "https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm23_1_h484b24f_6.conda" checksum = "sha256:36c603caf2a7f65ae9f0ce4acbf7afde49357bdcea65b28ca7aefd9cf46c1f5a" -[conda-packages.macos-x64."libassuan-3.0.2-h240833e_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libassuan-3.0.2-h240833e_0.conda" -checksum = "sha256:90af38135168c54a1fd4a3adbb0b0d7fd36d53cf575e263ff5227adeca423011" - [conda-packages.macos-x64."libclang-cpp23.1-23.1.2-default_h6e863b9_0"] url = "https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp23.1-23.1.2-default_h6e863b9_0.conda" checksum = "sha256:491c85fac38f91060802761e59e3a1505ee738ed2849504e8d0f3ecf0c520537" @@ -692,38 +484,10 @@ checksum = "sha256:5e8c3c2ec418e83291d1bc52d055af682f5c66599cbbe222b6b38f2f7d6cb url = "https://conda.anaconda.org/conda-forge/noarch/libcxx-headers-23.1.2-h707e725_0.conda" checksum = "sha256:8f2231b686519f3779b639cc1de0c9a9f4926265056a3c356767bb605c0437a2" -[conda-packages.macos-x64."libgcrypt-1.12.2-h694c41f_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-1.12.2-h694c41f_2.conda" -checksum = "sha256:09c2c02626323c23e6245bafc53cf2b3a8774397f7a7f49e5b615a43993ae6f3" - -[conda-packages.macos-x64."libgcrypt-devel-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-devel-1.12.2-hd115831_2.conda" -checksum = "sha256:55d86f0eb92b21310e5619f1397ef3d24a851181d29fafcaaaabf2aa38a74307" - -[conda-packages.macos-x64."libgcrypt-lib-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-lib-1.12.2-hd115831_2.conda" -checksum = "sha256:234e85de6d138ce426133ac36fbfb220448d7c6b688f3f20504ce1c3c24618ab" - -[conda-packages.macos-x64."libgcrypt-tools-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-tools-1.12.2-hd115831_2.conda" -checksum = "sha256:0128042f831879b66cbc3a1a34a78603c3a9eee8198c6c64bae01114720dd476" - -[conda-packages.macos-x64."libgpg-error-1.61-h2fb4741_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgpg-error-1.61-h2fb4741_2.conda" -checksum = "sha256:bb0352e979fa32a15233382cdfac5d933b7884e74a9487fe82d1b76aa2b4b4c4" - [conda-packages.macos-x64."libiconv-1.18-h4ae439b_3"] url = "https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h4ae439b_3.conda" checksum = "sha256:2389e03a58ed0f5e8f2469eb4d4ba80d0af378b38854ae5eee65e5b641244082" -[conda-packages.macos-x64."libintl-0.25.1-h98e67c8_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libintl-0.25.1-h98e67c8_2.conda" -checksum = "sha256:1fad645b88742ad51dbaf9a6709fa9663edd663a4fed6b27c9c4cbc42fa9c514" - -[conda-packages.macos-x64."libksba-1.6.7-hf036a51_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libksba-1.6.7-hf036a51_0.conda" -checksum = "sha256:cbeb2ae0d4b6e3f2abfcb1ae13c92e84e3cdd87caaad7a7e99e6a80edd35deb1" - [conda-packages.macos-x64."libllvm23-23.1.2-hd11396f_0"] url = "https://conda.anaconda.org/conda-forge/osx-64/libllvm23-23.1.2-hd11396f_0.conda" checksum = "sha256:f6174a6f25f900bbcebaa6461ad7152937ff5769e6dbec500a385c321e5adbc2" @@ -736,10 +500,6 @@ checksum = "sha256:7915dac7c71c208e40e716e2f6d3eff41a8d5584e0e7c2d46f9bf9bd5f9aa url = "https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-h8c25ef7_1.conda" checksum = "sha256:c3d76ff04ebed64d00a7ff5106297a55af20082a3d3df0ea7dfb235f94ba31c3" -[conda-packages.macos-x64."libsqlite-3.53.4-ha5db789_1"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-ha5db789_1.conda" -checksum = "sha256:78afe0bf88da66d7df62d39632aa69cb73eb32d923cd1175230ce2978618d9c8" - [conda-packages.macos-x64."libxml2-16-2.15.4-h341e563_0"] url = "https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.4-h341e563_0.conda" checksum = "sha256:bc284057b85e4a0ecc158598d81c3081fa58e9111969a05d3056ab7a8442c837" @@ -768,38 +528,18 @@ checksum = "sha256:bbf730a23807a089f2c2e9f1d460d03d49919291f97895b3aeb9593f6f0d6 url = "https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda" checksum = "sha256:12c1d676b9a0e8109b576672519312c5428308f3f3d7706f8717e2f536d5d9e7" -[conda-packages.macos-x64."npth-1.8-h240833e_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/npth-1.8-h240833e_0.conda" -checksum = "sha256:d12c2c6d5e6c4447478a451781f8f3da74844873f7ce4d435a22ff95a93ac1b3" - -[conda-packages.macos-x64."ntbtls-0.3.2-h2d185b6_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/ntbtls-0.3.2-h2d185b6_0.conda" -checksum = "sha256:81e664250100203f32a5b74ad8dd41b906b13d8c0e0efbd8aa6c876c4d962fc2" - [conda-packages.macos-x64."openssl-3.6.4-h332eb6d_0"] url = "https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda" checksum = "sha256:73d648d24f0994fd8641972848d74bd57b8d80888a64600643e3fe88a1b50545" -[conda-packages.macos-x64."readline-8.3-h000c2f7_1"] -url = "https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h000c2f7_1.conda" -checksum = "sha256:feb9248a062b7a0738e56339d8c9e87bc9adf8e8c76549fb55b0b2456bec3a57" - [conda-packages.macos-x64."sigtool-codesign-0.1.3-h8c25ef7_1"] url = "https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-h8c25ef7_1.conda" checksum = "sha256:37aac42f05e21b48a3b61b28ae624ed5a3d2acfcbaabf2cd10ef7762c4fb4c45" -[conda-packages.macos-x64."sqlite-3.53.4-hdea7a7b_1"] -url = "https://conda.anaconda.org/conda-forge/osx-64/sqlite-3.53.4-hdea7a7b_1.conda" -checksum = "sha256:eada66c9946b1feaff5c50b63902305836f09532a71ccf6461e73dc75b28018e" - [conda-packages.macos-x64."tapi-1600.0.11.8-h44950e2_4"] url = "https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_4.conda" checksum = "sha256:045e62838ec78ad75974552ebeeccbee5a4a843272da47c7a67e99fd9cc25d8d" -[conda-packages.macos-x64."zlib-1.3.2-hbb4bfdb_3"] -url = "https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.2-hbb4bfdb_3.conda" -checksum = "sha256:d46ba65a5ebcb4f682f9f0f21943c427eb56e7f9d15aeab8b823294c787dbb0b" - [conda-packages.macos-x64."zstd-1.5.7-hbc1a06c_7"] url = "https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda" checksum = "sha256:5277886d9704a624dc9b79ac985861e1e431bdb39a57c082507ab577a138ec6c" @@ -1075,121 +815,3 @@ conda_deps = [ "libiconv-1.18-hc1393d2_3", "liblzma-5.8.3-hfd05255_1", ] - -[[tools."conda:gnupg"]] -version = "2.5.24" -backend = "conda:gnupg" - -[tools."conda:gnupg".options] -channel = "conda-forge" - -[tools."conda:gnupg"."platforms.linux-arm64"] -checksum = "sha256:5317940ba41018dd9e8adbaf81386f670430db3c78b100c16d24bf3f5e46dfc3" -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/gnupg-2.5.24-hf476e02_0.conda" -conda_deps = [ - "npth-1.8-h5ad3122_0", - "libksba-1.6.7-h0a1ffab_0", - "libassuan-3.0.2-h5ad3122_0", - "ntbtls-0.3.2-hd84c7bf_0", - "bzip2-1.0.8-h4777abc_10", - "readline-8.3-ha7194a6_1", - "libiconv-1.18-h1ea5142_3", - "sqlite-3.53.4-h6ce2f9a_1", - "libsqlite-3.53.4-h399dd60_1", - "libstdcxx-16.2.0-hef695bb_7", - "libgcc-16.2.0-h205dda4_7", - "libstdcxx-ng-16.2.0-hdbbeba8_7", - "libgcc-ng-16.2.0-he9431aa_7", - "libgcrypt-lib-1.12.2-h5bc82ec_2", - "libgpg-error-1.61-h7ac5ae9_2", - "ncurses-6.6-h2b6f883_1", - "icu-78.3-py311h3512406_2", - "libzlib-1.3.2-hdc9db2a_3", - "zlib-1.3.2-hdc9db2a_3", - "_openmp_mutex-4.5-20_gnu", - "libgomp-16.2.0-h8acb6b2_7", - "libgcrypt-1.12.2-h8af1aa0_2", - "libgcrypt-tools-1.12.2-h5bc82ec_2", - "libgcrypt-devel-1.12.2-h5bc82ec_2", -] - -[tools."conda:gnupg"."platforms.linux-x64"] -checksum = "sha256:2e6965580eac0911e9ec0630b623e2a3ad30d7c98a3f3dfc05a7dae3d0707e7f" -url = "https://conda.anaconda.org/conda-forge/linux-64/gnupg-2.5.24-h4362557_0.conda" -conda_deps = [ - "libassuan-3.0.2-h5888daf_0", - "libksba-1.6.7-hac33072_0", - "npth-1.8-h5888daf_0", - "ntbtls-0.3.2-hfc55251_0", - "bzip2-1.0.8-hda65f42_10", - "readline-8.3-hd6e31c0_1", - "libiconv-1.18-h0cb94f2_3", - "sqlite-3.53.4-h9ffa6c4_1", - "libsqlite-3.53.4-h13e7031_1", - "libgcc-16.2.0-ha9f2e26_7", - "libgcc-ng-16.2.0-h69a702a_7", - "libstdcxx-16.2.0-h934c35e_7", - "libstdcxx-ng-16.2.0-hdf11a46_7", - "libgcrypt-lib-1.12.2-h7cc23a3_2", - "libgpg-error-1.61-h54a6638_2", - "ncurses-6.6-hdb14827_1", - "libzlib-1.3.2-h25fd6f3_3", - "zlib-1.3.2-h25fd6f3_3", - "icu-78.3-py310h44b86e0_2", - "_openmp_mutex-4.5-20_gnu", - "libgomp-16.2.0-he0feb66_7", - "libgcrypt-1.12.2-ha770c72_2", - "libgcrypt-tools-1.12.2-h7cc23a3_2", - "libgcrypt-devel-1.12.2-h7cc23a3_2", -] - -[tools."conda:gnupg"."platforms.macos-arm64"] -checksum = "sha256:3e1b9115df3d4213b19b1fe10022b0584cbf3134348e60335346c7ee5359438d" -url = "https://conda.anaconda.org/conda-forge/osx-arm64/gnupg-2.5.24-h1775f82_0.conda" -conda_deps = [ - "npth-1.8-h286801f_0", - "libassuan-3.0.2-h286801f_0", - "libksba-1.6.7-h00cdb27_0", - "ntbtls-0.3.2-h1059232_0", - "bzip2-1.0.8-h4e30115_10", - "readline-8.3-h8b90a29_1", - "libiconv-1.18-he4c29f2_3", - "sqlite-3.53.4-ha9f7ffe_1", - "libsqlite-3.53.4-hca69786_1", - "libgpg-error-1.61-h784d473_2", - "libgcrypt-lib-1.12.2-hb001647_2", - "libcxx-23.1.2-h55c6f16_0", - "ncurses-6.6-he64c551_1", - "libzlib-1.3.2-h8088a28_3", - "zlib-1.3.2-h8088a28_3", - "icu-78.3-py310h579977c_2", - "libgcrypt-1.12.2-hce30654_2", - "libgcrypt-tools-1.12.2-hb001647_2", - "libgcrypt-devel-1.12.2-hb001647_2", - "libintl-0.25.1-hae62183_2", -] - -[tools."conda:gnupg"."platforms.macos-x64"] -checksum = "sha256:26faef1a7427846709eeffa6a03e639695d3c13999b873b94b2e073b665f5d15" -url = "https://conda.anaconda.org/conda-forge/osx-64/gnupg-2.5.24-h4ccb0c0_0.conda" -conda_deps = [ - "npth-1.8-h240833e_0", - "ntbtls-0.3.2-h2d185b6_0", - "libksba-1.6.7-hf036a51_0", - "libassuan-3.0.2-h240833e_0", - "bzip2-1.0.8-h374f1ed_10", - "readline-8.3-h000c2f7_1", - "libiconv-1.18-h4ae439b_3", - "sqlite-3.53.4-hdea7a7b_1", - "libsqlite-3.53.4-ha5db789_1", - "libcxx-23.1.2-h19cb2f5_0", - "libgpg-error-1.61-h2fb4741_2", - "libgcrypt-lib-1.12.2-hd115831_2", - "ncurses-6.6-hcc0dc9a_1", - "libzlib-1.3.2-hbb4bfdb_3", - "zlib-1.3.2-hbb4bfdb_3", - "libintl-0.25.1-h98e67c8_2", - "libgcrypt-1.12.2-h694c41f_2", - "libgcrypt-tools-1.12.2-hd115831_2", - "libgcrypt-devel-1.12.2-hd115831_2", -] diff --git a/.mise/mise.lock b/.mise/mise.lock index eecd0c41..8969858f 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -6,42 +6,354 @@ lockfile_version = 3 url = "https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda" checksum = "sha256:a2527b1d81792a0ccd2c05850960df119c2b6d8f5fdec97f2db7d25dc23b1068" +[conda-packages.linux-arm64."bzip2-1.0.8-h4777abc_10"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_10.conda" +checksum = "sha256:24eacc8a20fd7c4616566178562bef7f9344eb4a8700cfc3180fa75a6ff9d39f" + [conda-packages.linux-arm64."ca-certificates-2026.10.23-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda" checksum = "sha256:95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3" +[conda-packages.linux-arm64."libassuan-3.0.2-h5ad3122_0"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libassuan-3.0.2-h5ad3122_0.conda" +checksum = "sha256:042843a7760a9be3e7642528a2667b644f8eda009e1846658726fdca610afb73" + [conda-packages.linux-arm64."libgcc-16.2.0-h205dda4_7"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-16.2.0-h205dda4_7.conda" checksum = "sha256:eae7a3449d5a3b5351c5427137c076cc3fc826645c7bfc42dc10772b783826f1" +[conda-packages.linux-arm64."libgcc-ng-16.2.0-he9431aa_7"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-16.2.0-he9431aa_7.conda" +checksum = "sha256:d7502682cfce6f51aabd40bba63cd6b46db3d3e4b67abdb00f54ae72332523db" + +[conda-packages.linux-arm64."libgcrypt-1.12.2-h8af1aa0_2"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-1.12.2-h8af1aa0_2.conda" +checksum = "sha256:327d2239319829def075ca2580b810060bc956036819d771d727352029cf6735" + +[conda-packages.linux-arm64."libgcrypt-devel-1.12.2-h5bc82ec_2"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-devel-1.12.2-h5bc82ec_2.conda" +checksum = "sha256:2dc2ab9e7a58bb9b774e30a4b5a178113f0adbf76c5298e43b1cf0e2f272e8f5" + +[conda-packages.linux-arm64."libgcrypt-lib-1.12.2-h5bc82ec_2"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-lib-1.12.2-h5bc82ec_2.conda" +checksum = "sha256:26f2981255e575a47c1fb4bcd0f869d0fadfa3ab44a0ab005fe3887f75e7a154" + +[conda-packages.linux-arm64."libgcrypt-tools-1.12.2-h5bc82ec_2"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-tools-1.12.2-h5bc82ec_2.conda" +checksum = "sha256:3d566ba1641653f7ec835ee1401397e296c77d47c7b979bc6725c33883fe377a" + [conda-packages.linux-arm64."libgomp-16.2.0-h8acb6b2_7"] url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-16.2.0-h8acb6b2_7.conda" checksum = "sha256:4406fd67c5beeb99b9372f9bf07aa3e40ca141e337eceebac3ff64aa72799494" +[conda-packages.linux-arm64."libgpg-error-1.61-hfb11796_2"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgpg-error-1.61-hfb11796_2.conda" +checksum = "sha256:9fd3e1ab86e7aa70c81bac763a32cf1ee583e954ec4baaea1363f34239941b7f" + +[conda-packages.linux-arm64."libiconv-1.18-h1ea5142_3"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h1ea5142_3.conda" +checksum = "sha256:d22c666eb887afd2119aac2110e23a0e817e9391bda99293421f7c46dab1564c" + +[conda-packages.linux-arm64."libksba-1.6.7-h0a1ffab_0"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libksba-1.6.7-h0a1ffab_0.conda" +checksum = "sha256:6bd8c23157e3afa89d56ff876993232d94dc796e1ad190f3cd56148815c7004f" + +[conda-packages.linux-arm64."libsqlite-3.54.1-hd26f1b6_101"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.54.1-hd26f1b6_101.conda" +checksum = "sha256:61a67f7085f497fa7622c6e1ae8611b8b2796a2eca70dcecc8d9124ca4f0d46b" + +[conda-packages.linux-arm64."libstdcxx-16.2.0-hef695bb_7"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-16.2.0-hef695bb_7.conda" +checksum = "sha256:b241a3acfa9cdb1359112864a1241225533c92642de3000d52ae65ef5d3a9786" + +[conda-packages.linux-arm64."libstdcxx-ng-16.2.0-hdbbeba8_7"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-16.2.0-hdbbeba8_7.conda" +checksum = "sha256:cfe644e4cf78302e605f7dc1cfa23ee4edabf5b72f84c92436f5f51c3003542c" + +[conda-packages.linux-arm64."libzlib-1.3.2-hdc9db2a_3"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_3.conda" +checksum = "sha256:76efa6cc9d7e6f5ee3bbca0939f64054af2bdfb3c3632531f8e633cf6c2ea41e" + +[conda-packages.linux-arm64."ncurses-6.6-h2b6f883_1"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-h2b6f883_1.conda" +checksum = "sha256:d69a04914139627f0a6bfd19412d6c7f1e37edc896af84a6011e07e8bc1e69fa" + +[conda-packages.linux-arm64."npth-1.8-h5ad3122_0"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/npth-1.8-h5ad3122_0.conda" +checksum = "sha256:1fcbc982059bc5ed7a544ae43a8be6d9ee8d0d4c3fe8fb30eb9185db0aa6e114" + +[conda-packages.linux-arm64."ntbtls-0.3.2-hd84c7bf_0"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ntbtls-0.3.2-hd84c7bf_0.conda" +checksum = "sha256:d9af7c17e6c452312a9d623eeff588f427242986fcc39ea65a0ef030dbe8cbe1" + +[conda-packages.linux-arm64."readline-8.3-ha7194a6_1"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-ha7194a6_1.conda" +checksum = "sha256:80167dcf73a96b6d0c1bb2298d7b8b9bc21b27cc5bf56979f9c548b49a4d1722" + +[conda-packages.linux-arm64."sqlite-3.54.1-h363059a_101"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/sqlite-3.54.1-h363059a_101.conda" +checksum = "sha256:34557a27186bee8224bf6d7d1160f52c307b441e016b049ef59eacde27dc0416" + +[conda-packages.linux-arm64."zlib-1.3.2-hdc9db2a_3"] +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.2-hdc9db2a_3.conda" +checksum = "sha256:3f3c296477cf26474660942f4d8ec104a8eee2cb1abc7c5bfec9ac1102c66451" + [conda-packages.linux-x64."_openmp_mutex-4.5-20_gnu"] url = "https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda" checksum = "sha256:1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9" +[conda-packages.linux-x64."bzip2-1.0.8-hda65f42_10"] +url = "https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda" +checksum = "sha256:1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96" + [conda-packages.linux-x64."ca-certificates-2026.10.23-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda" checksum = "sha256:95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3" +[conda-packages.linux-x64."libassuan-3.0.2-h5888daf_0"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libassuan-3.0.2-h5888daf_0.conda" +checksum = "sha256:a89ee880e951cc9399606545cb9e6377c8f260fa40942b8486d82d64d3d34491" + [conda-packages.linux-x64."libgcc-16.2.0-ha9f2e26_7"] url = "https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda" checksum = "sha256:e031634c3a928f9594eba868bc46ef5230d6571a364a0c63215b06c4563bfcea" +[conda-packages.linux-x64."libgcc-ng-16.2.0-h69a702a_7"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.2.0-h69a702a_7.conda" +checksum = "sha256:03b54d7008de080a17cc2174b0e4acd6d06e1c981b2eb19846f46d8f59966034" + +[conda-packages.linux-x64."libgcrypt-1.12.2-ha770c72_2"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-1.12.2-ha770c72_2.conda" +checksum = "sha256:831898d269b74439ef904d5ae237ed08a1c886a3a4f29fd6973eb09d22e59aa6" + +[conda-packages.linux-x64."libgcrypt-devel-1.12.2-h7cc23a3_2"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-devel-1.12.2-h7cc23a3_2.conda" +checksum = "sha256:ba6a30372f94eab139a4df5967cfb685e0a1bd6cb2d4d464698594e29c5f2de2" + +[conda-packages.linux-x64."libgcrypt-lib-1.12.2-h7cc23a3_2"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.12.2-h7cc23a3_2.conda" +checksum = "sha256:a67e7b569f08459fbd62619cbbc14a0e2da9d9e519feb11ee035bb31c653d1a9" + +[conda-packages.linux-x64."libgcrypt-tools-1.12.2-h7cc23a3_2"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-tools-1.12.2-h7cc23a3_2.conda" +checksum = "sha256:781063cd902514ad5c27f30f512d7175f5c51013a1fecb1aa602ca04b583a8f2" + [conda-packages.linux-x64."libgomp-16.2.0-he0feb66_7"] url = "https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda" checksum = "sha256:92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758" +[conda-packages.linux-x64."libgpg-error-1.61-hee9eb32_2"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.61-hee9eb32_2.conda" +checksum = "sha256:c43d91e1a5104cbea17372964740206e679689d504eb88779b32a556f25179c3" + +[conda-packages.linux-x64."libiconv-1.18-h0cb94f2_3"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda" +checksum = "sha256:f943117edb9cd4d9c61cc972eee5a34291dc55ea7a6e9e38da104995841cbcb6" + +[conda-packages.linux-x64."libksba-1.6.7-hac33072_0"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libksba-1.6.7-hac33072_0.conda" +checksum = "sha256:289d4f2bf33134c4f5565f772829cb66c996485723bd9c299a21f73ff8aed77a" + +[conda-packages.linux-x64."libsqlite-3.54.1-h0737f62_101"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda" +checksum = "sha256:c294029f893d05286f1061bbab85b820f1eea73dd859af4bcc619197bcc34810" + +[conda-packages.linux-x64."libstdcxx-16.2.0-h934c35e_7"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda" +checksum = "sha256:fa7018298629fc429971fffd6ecba3d27a7b02e4e890d6a93ea01ffebe8cb745" + +[conda-packages.linux-x64."libstdcxx-ng-16.2.0-hdf11a46_7"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda" +checksum = "sha256:337f481db36cc357884f05f404ccd1b3a7e0fc1ee48ec5c884e4281d213bc863" + +[conda-packages.linux-x64."libzlib-1.3.2-h25fd6f3_3"] +url = "https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda" +checksum = "sha256:eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736" + +[conda-packages.linux-x64."ncurses-6.6-hdb14827_1"] +url = "https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda" +checksum = "sha256:5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3" + +[conda-packages.linux-x64."npth-1.8-h5888daf_0"] +url = "https://conda.anaconda.org/conda-forge/linux-64/npth-1.8-h5888daf_0.conda" +checksum = "sha256:da3af9708bbf0d459360f7c706d2550859560262c232a5b41a0715806ad7dafc" + +[conda-packages.linux-x64."ntbtls-0.3.2-hfc55251_0"] +url = "https://conda.anaconda.org/conda-forge/linux-64/ntbtls-0.3.2-hfc55251_0.conda" +checksum = "sha256:63184386e34caad02ff05ee9ea84428197131c63ae9b424c8247e2488c0b5673" + +[conda-packages.linux-x64."readline-8.3-hd6e31c0_1"] +url = "https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda" +checksum = "sha256:01b3fe073a66e321970d09e04b388708f8cbdca5cdfbfcb7c9eeb470ad10383d" + +[conda-packages.linux-x64."sqlite-3.54.1-h6e79ffa_101"] +url = "https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.54.1-h6e79ffa_101.conda" +checksum = "sha256:f36a4986a640f89fda0d3b038a80449ee247a879238006cafdd6d38a5c606328" + +[conda-packages.linux-x64."zlib-1.3.2-h25fd6f3_3"] +url = "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda" +checksum = "sha256:16080a1c7724f7d25727cdc23c7658e0cec2db52448c1dc0c33467ee2c6e1c62" + +[conda-packages.macos-arm64."bzip2-1.0.8-h4e30115_10"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda" +checksum = "sha256:8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6" + [conda-packages.macos-arm64."ca-certificates-2026.10.23-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda" checksum = "sha256:95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3" +[conda-packages.macos-arm64."libassuan-3.0.2-h286801f_0"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libassuan-3.0.2-h286801f_0.conda" +checksum = "sha256:0d8ec81850417d106df2cd4d4cb21e1597c8ce9a930bc86857ea504eb8e08dd4" + +[conda-packages.macos-arm64."libcxx-23.1.5-hc645fd0_1"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.5-hc645fd0_1.conda" +checksum = "sha256:1ca51526efc4e58d8a3cb1ebde994c50145da58bc30b29e7c62af96c0612e0c3" + +[conda-packages.macos-arm64."libgcrypt-1.12.2-hce30654_2"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-1.12.2-hce30654_2.conda" +checksum = "sha256:2512a5201146cbe4abccc6e7a7a46bd98424013b5c19363d8204e91c11b0e39a" + +[conda-packages.macos-arm64."libgcrypt-devel-1.12.2-hb001647_2"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-devel-1.12.2-hb001647_2.conda" +checksum = "sha256:2fe60bf669bac3f2d36ea70d78418f71b45afe6ba5d5bb3b4a50b6f2e42e97b4" + +[conda-packages.macos-arm64."libgcrypt-lib-1.12.2-hb001647_2"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-lib-1.12.2-hb001647_2.conda" +checksum = "sha256:45b1b91ff0b7dcfb2d31ffcdc156be2cb93998d80764f9a1f4bbd6a51e5a318a" + +[conda-packages.macos-arm64."libgcrypt-tools-1.12.2-hb001647_2"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-tools-1.12.2-hb001647_2.conda" +checksum = "sha256:6e972ad2159b0f0f8bfec35814b5b736cbf8aec32c666346074d8eb9f7cca8a5" + +[conda-packages.macos-arm64."libgpg-error-1.61-hdca3b7d_2"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgpg-error-1.61-hdca3b7d_2.conda" +checksum = "sha256:75e64458efad17d0315b8aefc30f734eabd7ad97bb3ebf75e14026ccfbd31fb2" + +[conda-packages.macos-arm64."libiconv-1.18-he4c29f2_3"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda" +checksum = "sha256:689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f" + +[conda-packages.macos-arm64."libintl-1.0-hae62183_1"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libintl-1.0-hae62183_1.conda" +checksum = "sha256:50cd23514846dd057c5d3d4f38e29a7948909e51b3b53b99fe4b8a541971edb0" + +[conda-packages.macos-arm64."libksba-1.6.7-h00cdb27_0"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libksba-1.6.7-h00cdb27_0.conda" +checksum = "sha256:1fb2da81dd151baf940bfb90b9bfe8b14a57ab75d28b95fd5adde89e14b8b288" + +[conda-packages.macos-arm64."libsqlite-3.54.1-h34a968b_101"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda" +checksum = "sha256:c49adf700ad17332b938ac627845a3fdc70a0810660d3ec3814de25b156fbf60" + +[conda-packages.macos-arm64."libzlib-1.3.2-h8088a28_3"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda" +checksum = "sha256:a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7" + +[conda-packages.macos-arm64."ncurses-6.6-he64c551_1"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda" +checksum = "sha256:7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68" + +[conda-packages.macos-arm64."npth-1.8-h286801f_0"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/npth-1.8-h286801f_0.conda" +checksum = "sha256:e6f12cee635860a2f70dc40ad28d20d80cdabf566dfab2db1ca0489b1fb891a9" + +[conda-packages.macos-arm64."ntbtls-0.3.2-h1059232_0"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/ntbtls-0.3.2-h1059232_0.conda" +checksum = "sha256:3b2343fb4120dd864ceb050123a84943c4cce3f65c0d010d82950af873d6e3f0" + +[conda-packages.macos-arm64."readline-8.3-h8b90a29_1"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda" +checksum = "sha256:d6782b430e6dce4fe8c7ac118cd988d5a193eb4a7f60741edfaede58016b6110" + +[conda-packages.macos-arm64."sqlite-3.54.1-hdb6324b_101"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.54.1-hdb6324b_101.conda" +checksum = "sha256:9e36fa9989e80b5966345a6e0a4b08787bf35da9836f57077ab0285db2a39192" + +[conda-packages.macos-arm64."zlib-1.3.2-h8088a28_3"] +url = "https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda" +checksum = "sha256:ab46d85e4fcffff1b1c5cac517afa40b7ae784cf76fc9da1f55f6a6934291eb6" + +[conda-packages.macos-x64."bzip2-1.0.8-h374f1ed_10"] +url = "https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda" +checksum = "sha256:4ed83961876dc8844a6f0df49c07b408efbaea275ffb0b37133e24c006990b3a" + [conda-packages.macos-x64."ca-certificates-2026.10.23-hbd8a1cb_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda" checksum = "sha256:95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3" +[conda-packages.macos-x64."libassuan-3.0.2-h240833e_0"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libassuan-3.0.2-h240833e_0.conda" +checksum = "sha256:90af38135168c54a1fd4a3adbb0b0d7fd36d53cf575e263ff5227adeca423011" + +[conda-packages.macos-x64."libcxx-23.1.5-h88b51c2_1"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libcxx-23.1.5-h88b51c2_1.conda" +checksum = "sha256:5af3e11962bd680a67d8ceb05d44b5d0312e6c058392b82030f6cb4f83e1df3a" + +[conda-packages.macos-x64."libgcrypt-1.12.2-h694c41f_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-1.12.2-h694c41f_2.conda" +checksum = "sha256:09c2c02626323c23e6245bafc53cf2b3a8774397f7a7f49e5b615a43993ae6f3" + +[conda-packages.macos-x64."libgcrypt-devel-1.12.2-hd115831_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-devel-1.12.2-hd115831_2.conda" +checksum = "sha256:55d86f0eb92b21310e5619f1397ef3d24a851181d29fafcaaaabf2aa38a74307" + +[conda-packages.macos-x64."libgcrypt-lib-1.12.2-hd115831_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-lib-1.12.2-hd115831_2.conda" +checksum = "sha256:234e85de6d138ce426133ac36fbfb220448d7c6b688f3f20504ce1c3c24618ab" + +[conda-packages.macos-x64."libgcrypt-tools-1.12.2-hd115831_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-tools-1.12.2-hd115831_2.conda" +checksum = "sha256:0128042f831879b66cbc3a1a34a78603c3a9eee8198c6c64bae01114720dd476" + +[conda-packages.macos-x64."libgpg-error-1.61-hc569e58_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libgpg-error-1.61-hc569e58_2.conda" +checksum = "sha256:c6ced72cb9b830d00bea42b17587101fd3c4f31a0374e380915b06fdfd7b7096" + +[conda-packages.macos-x64."libiconv-1.18-h4ae439b_3"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h4ae439b_3.conda" +checksum = "sha256:2389e03a58ed0f5e8f2469eb4d4ba80d0af378b38854ae5eee65e5b641244082" + +[conda-packages.macos-x64."libintl-0.25.1-h98e67c8_2"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libintl-0.25.1-h98e67c8_2.conda" +checksum = "sha256:1fad645b88742ad51dbaf9a6709fa9663edd663a4fed6b27c9c4cbc42fa9c514" + +[conda-packages.macos-x64."libksba-1.6.7-hf036a51_0"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libksba-1.6.7-hf036a51_0.conda" +checksum = "sha256:cbeb2ae0d4b6e3f2abfcb1ae13c92e84e3cdd87caaad7a7e99e6a80edd35deb1" + +[conda-packages.macos-x64."libsqlite-3.54.1-ha5db789_101"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.54.1-ha5db789_101.conda" +checksum = "sha256:45af47119bf30cd41f661349cef1e93ad8e736d04b617560d0ae8d74d2eb8ffa" + +[conda-packages.macos-x64."libzlib-1.3.2-hbb4bfdb_3"] +url = "https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda" +checksum = "sha256:b2dba286dd6632292b12296e761193b5ef9fb0eaeecaa481f5ba9af72c0c18e1" + +[conda-packages.macos-x64."ncurses-6.6-hcc0dc9a_1"] +url = "https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda" +checksum = "sha256:12c1d676b9a0e8109b576672519312c5428308f3f3d7706f8717e2f536d5d9e7" + +[conda-packages.macos-x64."npth-1.8-h240833e_0"] +url = "https://conda.anaconda.org/conda-forge/osx-64/npth-1.8-h240833e_0.conda" +checksum = "sha256:d12c2c6d5e6c4447478a451781f8f3da74844873f7ce4d435a22ff95a93ac1b3" + +[conda-packages.macos-x64."ntbtls-0.3.2-h2d185b6_0"] +url = "https://conda.anaconda.org/conda-forge/osx-64/ntbtls-0.3.2-h2d185b6_0.conda" +checksum = "sha256:81e664250100203f32a5b74ad8dd41b906b13d8c0e0efbd8aa6c876c4d962fc2" + +[conda-packages.macos-x64."readline-8.3-h000c2f7_1"] +url = "https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h000c2f7_1.conda" +checksum = "sha256:feb9248a062b7a0738e56339d8c9e87bc9adf8e8c76549fb55b0b2456bec3a57" + +[conda-packages.macos-x64."sqlite-3.54.1-hdea7a7b_101"] +url = "https://conda.anaconda.org/conda-forge/osx-64/sqlite-3.54.1-hdea7a7b_101.conda" +checksum = "sha256:d7d355559000f26b811daa6caf3489b00d60d48cf4aa3b249556091e983afb41" + +[conda-packages.macos-x64."zlib-1.3.2-hbb4bfdb_3"] +url = "https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.2-hbb4bfdb_3.conda" +checksum = "sha256:d46ba65a5ebcb4f682f9f0f21943c427eb56e7f9d15aeab8b823294c787dbb0b" + [conda-packages.windows-x64."ca-certificates-2026.10.23-h4c7d964_0"] url = "https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-h4c7d964_0.conda" checksum = "sha256:f1d280d2618de987204102090e83733089c092dc2dd45686fd3dcbe809034f6a" @@ -648,6 +960,122 @@ checksum = "sha256:bace36e94b31c68ab6fa295f26dfa11219e0701cf7c94b0284a7d1cb13dac url = "https://github.com/Kitware/CMake/releases/download/v4.4.4/cmake-4.4.4-windows-x86_64.zip" url_api = "https://api.github.com/repos/Kitware/CMake/releases/assets/605998125" +[[tools."conda:gnupg"]] +version = "2.5.24" +backend = "conda:gnupg" +specifiers = ["latest"] + +[tools."conda:gnupg".options] +channel = "conda-forge" + +[tools."conda:gnupg"."platforms.linux-arm64"] +checksum = "sha256:5317940ba41018dd9e8adbaf81386f670430db3c78b100c16d24bf3f5e46dfc3" +url = "https://conda.anaconda.org/conda-forge/linux-aarch64/gnupg-2.5.24-hf476e02_0.conda" +conda_deps = [ + "npth-1.8-h5ad3122_0", + "libksba-1.6.7-h0a1ffab_0", + "libassuan-3.0.2-h5ad3122_0", + "ntbtls-0.3.2-hd84c7bf_0", + "bzip2-1.0.8-h4777abc_10", + "readline-8.3-ha7194a6_1", + "libiconv-1.18-h1ea5142_3", + "sqlite-3.54.1-h363059a_101", + "libsqlite-3.54.1-hd26f1b6_101", + "libstdcxx-16.2.0-hef695bb_7", + "libgcc-16.2.0-h205dda4_7", + "libstdcxx-ng-16.2.0-hdbbeba8_7", + "libgcc-ng-16.2.0-he9431aa_7", + "libgcrypt-lib-1.12.2-h5bc82ec_2", + "libgpg-error-1.61-hfb11796_2", + "ncurses-6.6-h2b6f883_1", + "libzlib-1.3.2-hdc9db2a_3", + "zlib-1.3.2-hdc9db2a_3", + "_openmp_mutex-4.5-20_gnu", + "libgomp-16.2.0-h8acb6b2_7", + "libgcrypt-1.12.2-h8af1aa0_2", + "libgcrypt-tools-1.12.2-h5bc82ec_2", + "libgcrypt-devel-1.12.2-h5bc82ec_2", +] + +[tools."conda:gnupg"."platforms.linux-x64"] +checksum = "sha256:2e6965580eac0911e9ec0630b623e2a3ad30d7c98a3f3dfc05a7dae3d0707e7f" +url = "https://conda.anaconda.org/conda-forge/linux-64/gnupg-2.5.24-h4362557_0.conda" +conda_deps = [ + "libassuan-3.0.2-h5888daf_0", + "libksba-1.6.7-hac33072_0", + "npth-1.8-h5888daf_0", + "ntbtls-0.3.2-hfc55251_0", + "bzip2-1.0.8-hda65f42_10", + "readline-8.3-hd6e31c0_1", + "libiconv-1.18-h0cb94f2_3", + "sqlite-3.54.1-h6e79ffa_101", + "libsqlite-3.54.1-h0737f62_101", + "libgcc-16.2.0-ha9f2e26_7", + "libgcc-ng-16.2.0-h69a702a_7", + "libstdcxx-16.2.0-h934c35e_7", + "libstdcxx-ng-16.2.0-hdf11a46_7", + "libgcrypt-lib-1.12.2-h7cc23a3_2", + "libgpg-error-1.61-hee9eb32_2", + "ncurses-6.6-hdb14827_1", + "libzlib-1.3.2-h25fd6f3_3", + "zlib-1.3.2-h25fd6f3_3", + "_openmp_mutex-4.5-20_gnu", + "libgomp-16.2.0-he0feb66_7", + "libgcrypt-1.12.2-ha770c72_2", + "libgcrypt-tools-1.12.2-h7cc23a3_2", + "libgcrypt-devel-1.12.2-h7cc23a3_2", +] + +[tools."conda:gnupg"."platforms.macos-arm64"] +checksum = "sha256:3e1b9115df3d4213b19b1fe10022b0584cbf3134348e60335346c7ee5359438d" +url = "https://conda.anaconda.org/conda-forge/osx-arm64/gnupg-2.5.24-h1775f82_0.conda" +conda_deps = [ + "npth-1.8-h286801f_0", + "libassuan-3.0.2-h286801f_0", + "libksba-1.6.7-h00cdb27_0", + "ntbtls-0.3.2-h1059232_0", + "bzip2-1.0.8-h4e30115_10", + "readline-8.3-h8b90a29_1", + "libiconv-1.18-he4c29f2_3", + "sqlite-3.54.1-hdb6324b_101", + "libsqlite-3.54.1-h34a968b_101", + "libgpg-error-1.61-hdca3b7d_2", + "libgcrypt-lib-1.12.2-hb001647_2", + "libcxx-23.1.5-hc645fd0_1", + "ncurses-6.6-he64c551_1", + "libzlib-1.3.2-h8088a28_3", + "zlib-1.3.2-h8088a28_3", + "libgcrypt-1.12.2-hce30654_2", + "libgcrypt-tools-1.12.2-hb001647_2", + "libgcrypt-devel-1.12.2-hb001647_2", + "libintl-1.0-hae62183_1", +] + +[tools."conda:gnupg"."platforms.macos-x64"] +checksum = "sha256:26faef1a7427846709eeffa6a03e639695d3c13999b873b94b2e073b665f5d15" +url = "https://conda.anaconda.org/conda-forge/osx-64/gnupg-2.5.24-h4ccb0c0_0.conda" +conda_deps = [ + "npth-1.8-h240833e_0", + "ntbtls-0.3.2-h2d185b6_0", + "libksba-1.6.7-hf036a51_0", + "libassuan-3.0.2-h240833e_0", + "bzip2-1.0.8-h374f1ed_10", + "readline-8.3-h000c2f7_1", + "libiconv-1.18-h4ae439b_3", + "sqlite-3.54.1-hdea7a7b_101", + "libsqlite-3.54.1-ha5db789_101", + "libcxx-23.1.5-h88b51c2_1", + "libgpg-error-1.61-hc569e58_2", + "libgcrypt-lib-1.12.2-hd115831_2", + "ncurses-6.6-hcc0dc9a_1", + "libzlib-1.3.2-hbb4bfdb_3", + "zlib-1.3.2-hbb4bfdb_3", + "libintl-0.25.1-h98e67c8_2", + "libgcrypt-1.12.2-h694c41f_2", + "libgcrypt-tools-1.12.2-hd115831_2", + "libgcrypt-devel-1.12.2-hd115831_2", +] + [[tools."conda:openssl"]] version = "3.6.5" backend = "conda:openssl" @@ -1115,6 +1543,7 @@ url = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0/onnxru url_api = "https://api.github.com/repos/microsoft/onnxruntime/releases/assets/253300383" [tools."github:microsoft/onnxruntime"."platforms.linux-x64"] +checksum = "sha256:8344d55f93d5bc5021ce342db50f62079daf39aaafb5d311a451846228be49b3" url = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0/onnxruntime-linux-x64-1.22.0.tgz" url_api = "https://api.github.com/repos/microsoft/onnxruntime/releases/assets/253300385" @@ -1124,10 +1553,12 @@ url = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0/onnxru url_api = "https://api.github.com/repos/microsoft/onnxruntime/releases/assets/253300410" [tools."github:microsoft/onnxruntime"."platforms.macos-x64"] +checksum = "sha256:e4ec94a7696de74fb1b12846569aa94e499958af6ffa186022cfde16c9d617f0" url = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0/onnxruntime-osx-x86_64-1.22.0.tgz" url_api = "https://api.github.com/repos/microsoft/onnxruntime/releases/assets/253300427" [tools."github:microsoft/onnxruntime"."platforms.windows-x64"] +checksum = "sha256:174c616efc0271194488642a72f1a514e01487da4dfe84c49296d66e40ebe0da" url = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0/onnxruntime-win-x64-1.22.0.zip" url_api = "https://api.github.com/repos/microsoft/onnxruntime/releases/assets/253300435" @@ -1419,16 +1850,6 @@ checksum = "sha256:bb7c603d923beb57481652ac9848d1aa89da674b56ed99f99b6631418fbd9 url = "https://github.com/utkuozdemir/nvidia_gpu_exporter/releases/download/v1.10.0/nvidia_gpu_exporter_1.10.0_linux_x86_64.tar.gz" url_api = "https://api.github.com/repos/utkuozdemir/nvidia_gpu_exporter/releases/assets/467484915" -[tools."github:utkuozdemir/nvidia_gpu_exporter"."platforms.macos-x64"] -checksum = "sha256:91b1faf572e609c7b7a5a5c795730c52cb18fb1682a0c208a0a38230b59e05ea" -url = "https://github.com/utkuozdemir/nvidia_gpu_exporter/releases/download/v1.10.0/nvidia_gpu_exporter_1.10.0_darwin_x86_64.tar.gz" -url_api = "https://api.github.com/repos/utkuozdemir/nvidia_gpu_exporter/releases/assets/467484918" - -[tools."github:utkuozdemir/nvidia_gpu_exporter"."platforms.windows-x64"] -checksum = "sha256:1fe9ecf3513ef7eaf9fdeb84cfdbb9bd4fa31f4c54271183a68c0075457d66b6" -url = "https://github.com/utkuozdemir/nvidia_gpu_exporter/releases/download/v1.10.0/nvidia_gpu_exporter_1.10.0_windows_x86_64.zip" -url_api = "https://api.github.com/repos/utkuozdemir/nvidia_gpu_exporter/releases/assets/467484916" - [[tools."github:uutils/coreutils"]] version = "0.6.0" backend = "github:uutils/coreutils" @@ -1470,11 +1891,6 @@ specifiers = ["0.10.0"] [tools."github:uutils/findutils".options] asset_pattern = '*-{{ x86_64_arch }}-*{{ os(linux="musl.t", macos="darwin.t", windows="msvc.z") }}*' -[tools."github:uutils/findutils"."platforms.linux-arm64"] -checksum = "sha256:c2dad7fbf0cd6b108f833b0a42651f3ad796b6dfa71d303ca1ab51eed3ce3afe" -url = "https://github.com/uutils/findutils/releases/download/0.10.0/findutils-aarch64-unknown-linux-musl.tar.xz" -url_api = "https://api.github.com/repos/uutils/findutils/releases/assets/500572461" - [tools."github:uutils/findutils"."platforms.linux-x64"] checksum = "sha256:e7c10ea19e1dce2549aaf6f35e15bf42a5e8141d6100c499b6d6fc9b25ea9209" url = "https://github.com/uutils/findutils/releases/download/0.10.0/findutils-x86_64-unknown-linux-musl.tar.xz" @@ -1500,16 +1916,6 @@ version = "0.7.2" backend = "github:vladkens/macmon" specifiers = ["0.7.2"] -[tools."github:vladkens/macmon"."platforms.linux-arm64"] -checksum = "sha256:c79fdc7ab02b456b897dcc3ea041678420d7a1d5bd669aaac36fda3885572ad6" -url = "https://github.com/vladkens/macmon/releases/download/v0.7.2/macmon-v0.7.2.tar.gz" -url_api = "https://api.github.com/repos/vladkens/macmon/releases/assets/410521089" - -[tools."github:vladkens/macmon"."platforms.linux-x64"] -checksum = "sha256:c79fdc7ab02b456b897dcc3ea041678420d7a1d5bd669aaac36fda3885572ad6" -url = "https://github.com/vladkens/macmon/releases/download/v0.7.2/macmon-v0.7.2.tar.gz" -url_api = "https://api.github.com/repos/vladkens/macmon/releases/assets/410521089" - [tools."github:vladkens/macmon"."platforms.macos-arm64"] checksum = "sha256:c79fdc7ab02b456b897dcc3ea041678420d7a1d5bd669aaac36fda3885572ad6" url = "https://github.com/vladkens/macmon/releases/download/v0.7.2/macmon-v0.7.2.tar.gz" @@ -1520,11 +1926,6 @@ checksum = "sha256:c79fdc7ab02b456b897dcc3ea041678420d7a1d5bd669aaac36fda3885572 url = "https://github.com/vladkens/macmon/releases/download/v0.7.2/macmon-v0.7.2.tar.gz" url_api = "https://api.github.com/repos/vladkens/macmon/releases/assets/410521089" -[tools."github:vladkens/macmon"."platforms.windows-x64"] -checksum = "sha256:c79fdc7ab02b456b897dcc3ea041678420d7a1d5bd669aaac36fda3885572ad6" -url = "https://github.com/vladkens/macmon/releases/download/v0.7.2/macmon-v0.7.2.tar.gz" -url_api = "https://api.github.com/repos/vladkens/macmon/releases/assets/410521089" - [[tools."github:wasm-bindgen/wasm-bindgen"]] version = "0.2.130" backend = "github:wasm-bindgen/wasm-bindgen" @@ -1676,6 +2077,7 @@ backend = "http:chromedriver" specifiers = ["latest"] [tools."http:chromedriver"."platforms.linux-x64"] +checksum = "sha256:2a5024610c53f340a1aaa5d9a3e2a4fa448be7d71cca5238bd8c3c7f16181df4" url = "https://storage.googleapis.com/chrome-for-testing-public/156.0.8078.47/linux64/chromedriver-linux64.zip" [tools."http:chromedriver"."platforms.macos-arm64"] @@ -1683,6 +2085,7 @@ checksum = "blake3:427a34f0ac4999786f9c2e6d21a4bed3288894f2df353d9667853957e4833 url = "https://storage.googleapis.com/chrome-for-testing-public/156.0.8078.47/mac-arm64/chromedriver-mac-arm64.zip" [tools."http:chromedriver"."platforms.macos-x64"] +checksum = "sha256:a2cc341313d8b14861b30a0aa1133bf8c975df7dc850b2cb50a052a1fcb4f1e9" url = "https://storage.googleapis.com/chrome-for-testing-public/156.0.8078.47/mac-x64/chromedriver-mac-x64.zip" [[tools."http:et-rp"]] @@ -1716,9 +2119,11 @@ backend = "http:openobserve" specifiers = ["1.1.2"] [tools."http:openobserve"."platforms.linux-arm64"] +checksum = "sha256:b315e81fadd67fb63df0218605f1d18137a89f22b43a0a8e2d72aa61ffe6f600" url = "https://downloads.openobserve.ai/releases/openobserve/v1.1.2/openobserve-v1.1.2-linux-arm64-musl.tar.gz" [tools."http:openobserve"."platforms.linux-x64"] +checksum = "sha256:5695c06b6fb23ca42bf2ce7160b0bb18b19c60aa6add5eaae307fa7250ff4ffb" url = "https://downloads.openobserve.ai/releases/openobserve/v1.1.2/openobserve-v1.1.2-linux-amd64-musl.tar.gz" [tools."http:openobserve"."platforms.macos-arm64"] @@ -1726,9 +2131,11 @@ checksum = "blake3:b05adaf54df5b4012e6e69a6d339584128f7b5da97cba2b2bc7874035289e url = "https://downloads.openobserve.ai/releases/openobserve/v1.1.2/openobserve-v1.1.2-darwin-arm64.tar.gz" [tools."http:openobserve"."platforms.macos-x64"] +checksum = "sha256:196861f7beadb533be782f2d98e5458a9a8c383e802f0d108115eddb296cc2f4" url = "https://downloads.openobserve.ai/releases/openobserve/v1.1.2/openobserve-v1.1.2-darwin-amd64.tar.gz" [tools."http:openobserve"."platforms.windows-x64"] +checksum = "sha256:6beb3e4d0325319206b2b8cd7f291d081fe68487fb50a2c46bbfedf462c4fdbb" url = "https://downloads.openobserve.ai/releases/openobserve/v1.1.2/openobserve-v1.1.2-windows-amd64.zip" [[tools."http:rp-wasm"]] @@ -2055,24 +2462,24 @@ url = "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15 url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/478119643" [[tools.rust]] -version = "nightly-2026-08-05" +version = "stable" backend = "core:rust" -specifiers = ["nightly-2026-08-05"] +specifiers = ["stable"] [tools.rust.options] -components = "llvm-tools,rust-src,rustfmt" +components = "clippy,rust-analyzer" profile = "minimal" -targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" +targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" [[tools.rust]] -version = "stable" +version = "nightly-2026-08-05" backend = "core:rust" -specifiers = ["stable"] +specifiers = ["nightly-2026-08-05"] [tools.rust.options] -components = "clippy,rust-analyzer" +components = "llvm-tools,rust-src,rustfmt" profile = "minimal" -targets = "riscv32imac-unknown-none-elf,thumbv8m.main-none-eabihf,wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" +targets = "wasm32-unknown-unknown,wasm32-wasip2,wasm32v1-none" [[tools.taplo]] version = "0.10.0" @@ -2085,18 +2492,22 @@ url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-aar url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257322597" [tools.taplo."platforms.linux-x64"] +checksum = "sha256:8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156" url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz" url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257322600" [tools.taplo."platforms.macos-arm64"] +checksum = "sha256:713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d" url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-darwin-aarch64.gz" url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257323110" [tools.taplo."platforms.macos-x64"] +checksum = "sha256:898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2" url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-darwin-x86_64.gz" url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257323116" [tools.taplo."platforms.windows-x64"] +checksum = "sha256:1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9" url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-windows-x86_64.zip" url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257323062" diff --git a/.mise/mise.macos.lock b/.mise/mise.macos.lock index 1c5b714f..73e22c32 100644 --- a/.mise/mise.macos.lock +++ b/.mise/mise.macos.lock @@ -1,455 +1 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html - -lockfile_version = 3 - -[conda-packages.linux-arm64."_openmp_mutex-4.5-20_gnu"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda" -checksum = "sha256:a2527b1d81792a0ccd2c05850960df119c2b6d8f5fdec97f2db7d25dc23b1068" - -[conda-packages.linux-arm64."bzip2-1.0.8-h4777abc_10"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_10.conda" -checksum = "sha256:24eacc8a20fd7c4616566178562bef7f9344eb4a8700cfc3180fa75a6ff9d39f" - -[conda-packages.linux-arm64."libassuan-3.0.2-h5ad3122_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libassuan-3.0.2-h5ad3122_0.conda" -checksum = "sha256:042843a7760a9be3e7642528a2667b644f8eda009e1846658726fdca610afb73" - -[conda-packages.linux-arm64."libgcc-16.2.0-h205dda4_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-16.2.0-h205dda4_7.conda" -checksum = "sha256:eae7a3449d5a3b5351c5427137c076cc3fc826645c7bfc42dc10772b783826f1" - -[conda-packages.linux-arm64."libgcc-ng-16.2.0-he9431aa_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-16.2.0-he9431aa_7.conda" -checksum = "sha256:d7502682cfce6f51aabd40bba63cd6b46db3d3e4b67abdb00f54ae72332523db" - -[conda-packages.linux-arm64."libgcrypt-1.12.2-h8af1aa0_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-1.12.2-h8af1aa0_2.conda" -checksum = "sha256:327d2239319829def075ca2580b810060bc956036819d771d727352029cf6735" - -[conda-packages.linux-arm64."libgcrypt-devel-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-devel-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:2dc2ab9e7a58bb9b774e30a4b5a178113f0adbf76c5298e43b1cf0e2f272e8f5" - -[conda-packages.linux-arm64."libgcrypt-lib-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-lib-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:26f2981255e575a47c1fb4bcd0f869d0fadfa3ab44a0ab005fe3887f75e7a154" - -[conda-packages.linux-arm64."libgcrypt-tools-1.12.2-h5bc82ec_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgcrypt-tools-1.12.2-h5bc82ec_2.conda" -checksum = "sha256:3d566ba1641653f7ec835ee1401397e296c77d47c7b979bc6725c33883fe377a" - -[conda-packages.linux-arm64."libgomp-16.2.0-h8acb6b2_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-16.2.0-h8acb6b2_7.conda" -checksum = "sha256:4406fd67c5beeb99b9372f9bf07aa3e40ca141e337eceebac3ff64aa72799494" - -[conda-packages.linux-arm64."libgpg-error-1.61-hfb11796_2"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libgpg-error-1.61-hfb11796_2.conda" -checksum = "sha256:9fd3e1ab86e7aa70c81bac763a32cf1ee583e954ec4baaea1363f34239941b7f" - -[conda-packages.linux-arm64."libiconv-1.18-h1ea5142_3"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h1ea5142_3.conda" -checksum = "sha256:d22c666eb887afd2119aac2110e23a0e817e9391bda99293421f7c46dab1564c" - -[conda-packages.linux-arm64."libksba-1.6.7-h0a1ffab_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libksba-1.6.7-h0a1ffab_0.conda" -checksum = "sha256:6bd8c23157e3afa89d56ff876993232d94dc796e1ad190f3cd56148815c7004f" - -[conda-packages.linux-arm64."libsqlite-3.54.1-hd26f1b6_101"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.54.1-hd26f1b6_101.conda" -checksum = "sha256:61a67f7085f497fa7622c6e1ae8611b8b2796a2eca70dcecc8d9124ca4f0d46b" - -[conda-packages.linux-arm64."libstdcxx-16.2.0-hef695bb_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-16.2.0-hef695bb_7.conda" -checksum = "sha256:b241a3acfa9cdb1359112864a1241225533c92642de3000d52ae65ef5d3a9786" - -[conda-packages.linux-arm64."libstdcxx-ng-16.2.0-hdbbeba8_7"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-16.2.0-hdbbeba8_7.conda" -checksum = "sha256:cfe644e4cf78302e605f7dc1cfa23ee4edabf5b72f84c92436f5f51c3003542c" - -[conda-packages.linux-arm64."libzlib-1.3.2-hdc9db2a_3"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_3.conda" -checksum = "sha256:76efa6cc9d7e6f5ee3bbca0939f64054af2bdfb3c3632531f8e633cf6c2ea41e" - -[conda-packages.linux-arm64."ncurses-6.6-h2b6f883_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-h2b6f883_1.conda" -checksum = "sha256:d69a04914139627f0a6bfd19412d6c7f1e37edc896af84a6011e07e8bc1e69fa" - -[conda-packages.linux-arm64."npth-1.8-h5ad3122_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/npth-1.8-h5ad3122_0.conda" -checksum = "sha256:1fcbc982059bc5ed7a544ae43a8be6d9ee8d0d4c3fe8fb30eb9185db0aa6e114" - -[conda-packages.linux-arm64."ntbtls-0.3.2-hd84c7bf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/ntbtls-0.3.2-hd84c7bf_0.conda" -checksum = "sha256:d9af7c17e6c452312a9d623eeff588f427242986fcc39ea65a0ef030dbe8cbe1" - -[conda-packages.linux-arm64."readline-8.3-ha7194a6_1"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-ha7194a6_1.conda" -checksum = "sha256:80167dcf73a96b6d0c1bb2298d7b8b9bc21b27cc5bf56979f9c548b49a4d1722" - -[conda-packages.linux-arm64."sqlite-3.54.1-h363059a_101"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/sqlite-3.54.1-h363059a_101.conda" -checksum = "sha256:34557a27186bee8224bf6d7d1160f52c307b441e016b049ef59eacde27dc0416" - -[conda-packages.linux-arm64."zlib-1.3.2-hdc9db2a_3"] -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.2-hdc9db2a_3.conda" -checksum = "sha256:3f3c296477cf26474660942f4d8ec104a8eee2cb1abc7c5bfec9ac1102c66451" - -[conda-packages.linux-x64."_openmp_mutex-4.5-20_gnu"] -url = "https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda" -checksum = "sha256:1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9" - -[conda-packages.linux-x64."bzip2-1.0.8-hda65f42_10"] -url = "https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda" -checksum = "sha256:1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96" - -[conda-packages.linux-x64."libassuan-3.0.2-h5888daf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libassuan-3.0.2-h5888daf_0.conda" -checksum = "sha256:a89ee880e951cc9399606545cb9e6377c8f260fa40942b8486d82d64d3d34491" - -[conda-packages.linux-x64."libgcc-16.2.0-ha9f2e26_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda" -checksum = "sha256:e031634c3a928f9594eba868bc46ef5230d6571a364a0c63215b06c4563bfcea" - -[conda-packages.linux-x64."libgcc-ng-16.2.0-h69a702a_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.2.0-h69a702a_7.conda" -checksum = "sha256:03b54d7008de080a17cc2174b0e4acd6d06e1c981b2eb19846f46d8f59966034" - -[conda-packages.linux-x64."libgcrypt-1.12.2-ha770c72_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-1.12.2-ha770c72_2.conda" -checksum = "sha256:831898d269b74439ef904d5ae237ed08a1c886a3a4f29fd6973eb09d22e59aa6" - -[conda-packages.linux-x64."libgcrypt-devel-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-devel-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:ba6a30372f94eab139a4df5967cfb685e0a1bd6cb2d4d464698594e29c5f2de2" - -[conda-packages.linux-x64."libgcrypt-lib-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:a67e7b569f08459fbd62619cbbc14a0e2da9d9e519feb11ee035bb31c653d1a9" - -[conda-packages.linux-x64."libgcrypt-tools-1.12.2-h7cc23a3_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-tools-1.12.2-h7cc23a3_2.conda" -checksum = "sha256:781063cd902514ad5c27f30f512d7175f5c51013a1fecb1aa602ca04b583a8f2" - -[conda-packages.linux-x64."libgomp-16.2.0-he0feb66_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda" -checksum = "sha256:92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758" - -[conda-packages.linux-x64."libgpg-error-1.61-hee9eb32_2"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.61-hee9eb32_2.conda" -checksum = "sha256:c43d91e1a5104cbea17372964740206e679689d504eb88779b32a556f25179c3" - -[conda-packages.linux-x64."libiconv-1.18-h0cb94f2_3"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda" -checksum = "sha256:f943117edb9cd4d9c61cc972eee5a34291dc55ea7a6e9e38da104995841cbcb6" - -[conda-packages.linux-x64."libksba-1.6.7-hac33072_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libksba-1.6.7-hac33072_0.conda" -checksum = "sha256:289d4f2bf33134c4f5565f772829cb66c996485723bd9c299a21f73ff8aed77a" - -[conda-packages.linux-x64."libsqlite-3.54.1-h0737f62_101"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda" -checksum = "sha256:c294029f893d05286f1061bbab85b820f1eea73dd859af4bcc619197bcc34810" - -[conda-packages.linux-x64."libstdcxx-16.2.0-h934c35e_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda" -checksum = "sha256:fa7018298629fc429971fffd6ecba3d27a7b02e4e890d6a93ea01ffebe8cb745" - -[conda-packages.linux-x64."libstdcxx-ng-16.2.0-hdf11a46_7"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda" -checksum = "sha256:337f481db36cc357884f05f404ccd1b3a7e0fc1ee48ec5c884e4281d213bc863" - -[conda-packages.linux-x64."libzlib-1.3.2-h25fd6f3_3"] -url = "https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda" -checksum = "sha256:eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736" - -[conda-packages.linux-x64."ncurses-6.6-hdb14827_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda" -checksum = "sha256:5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3" - -[conda-packages.linux-x64."npth-1.8-h5888daf_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/npth-1.8-h5888daf_0.conda" -checksum = "sha256:da3af9708bbf0d459360f7c706d2550859560262c232a5b41a0715806ad7dafc" - -[conda-packages.linux-x64."ntbtls-0.3.2-hfc55251_0"] -url = "https://conda.anaconda.org/conda-forge/linux-64/ntbtls-0.3.2-hfc55251_0.conda" -checksum = "sha256:63184386e34caad02ff05ee9ea84428197131c63ae9b424c8247e2488c0b5673" - -[conda-packages.linux-x64."readline-8.3-hd6e31c0_1"] -url = "https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda" -checksum = "sha256:01b3fe073a66e321970d09e04b388708f8cbdca5cdfbfcb7c9eeb470ad10383d" - -[conda-packages.linux-x64."sqlite-3.54.1-h6e79ffa_101"] -url = "https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.54.1-h6e79ffa_101.conda" -checksum = "sha256:f36a4986a640f89fda0d3b038a80449ee247a879238006cafdd6d38a5c606328" - -[conda-packages.linux-x64."zlib-1.3.2-h25fd6f3_3"] -url = "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda" -checksum = "sha256:16080a1c7724f7d25727cdc23c7658e0cec2db52448c1dc0c33467ee2c6e1c62" - -[conda-packages.macos-arm64."bzip2-1.0.8-h4e30115_10"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda" -checksum = "sha256:8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6" - -[conda-packages.macos-arm64."libassuan-3.0.2-h286801f_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libassuan-3.0.2-h286801f_0.conda" -checksum = "sha256:0d8ec81850417d106df2cd4d4cb21e1597c8ce9a930bc86857ea504eb8e08dd4" - -[conda-packages.macos-arm64."libcxx-23.1.4-h55c6f16_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.4-h55c6f16_2.conda" -checksum = "sha256:9c390b4f403db32346754ccef6b84b7e5d9b70cb6d76258fd23b05f2db2ab216" - -[conda-packages.macos-arm64."libgcrypt-1.12.2-hce30654_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-1.12.2-hce30654_2.conda" -checksum = "sha256:2512a5201146cbe4abccc6e7a7a46bd98424013b5c19363d8204e91c11b0e39a" - -[conda-packages.macos-arm64."libgcrypt-devel-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-devel-1.12.2-hb001647_2.conda" -checksum = "sha256:2fe60bf669bac3f2d36ea70d78418f71b45afe6ba5d5bb3b4a50b6f2e42e97b4" - -[conda-packages.macos-arm64."libgcrypt-lib-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-lib-1.12.2-hb001647_2.conda" -checksum = "sha256:45b1b91ff0b7dcfb2d31ffcdc156be2cb93998d80764f9a1f4bbd6a51e5a318a" - -[conda-packages.macos-arm64."libgcrypt-tools-1.12.2-hb001647_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgcrypt-tools-1.12.2-hb001647_2.conda" -checksum = "sha256:6e972ad2159b0f0f8bfec35814b5b736cbf8aec32c666346074d8eb9f7cca8a5" - -[conda-packages.macos-arm64."libgpg-error-1.61-hdca3b7d_2"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libgpg-error-1.61-hdca3b7d_2.conda" -checksum = "sha256:75e64458efad17d0315b8aefc30f734eabd7ad97bb3ebf75e14026ccfbd31fb2" - -[conda-packages.macos-arm64."libiconv-1.18-he4c29f2_3"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda" -checksum = "sha256:689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f" - -[conda-packages.macos-arm64."libintl-1.0-hae62183_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libintl-1.0-hae62183_0.conda" -checksum = "sha256:0c38524361d5dd9e1aff4c2c5e95dc70a8c2c7a092f808a0dc591fc7737d8f38" - -[conda-packages.macos-arm64."libksba-1.6.7-h00cdb27_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libksba-1.6.7-h00cdb27_0.conda" -checksum = "sha256:1fb2da81dd151baf940bfb90b9bfe8b14a57ab75d28b95fd5adde89e14b8b288" - -[conda-packages.macos-arm64."libsqlite-3.54.1-h34a968b_101"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda" -checksum = "sha256:c49adf700ad17332b938ac627845a3fdc70a0810660d3ec3814de25b156fbf60" - -[conda-packages.macos-arm64."libzlib-1.3.2-h8088a28_3"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda" -checksum = "sha256:a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7" - -[conda-packages.macos-arm64."ncurses-6.6-he64c551_1"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda" -checksum = "sha256:7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68" - -[conda-packages.macos-arm64."npth-1.8-h286801f_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/npth-1.8-h286801f_0.conda" -checksum = "sha256:e6f12cee635860a2f70dc40ad28d20d80cdabf566dfab2db1ca0489b1fb891a9" - -[conda-packages.macos-arm64."ntbtls-0.3.2-h1059232_0"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/ntbtls-0.3.2-h1059232_0.conda" -checksum = "sha256:3b2343fb4120dd864ceb050123a84943c4cce3f65c0d010d82950af873d6e3f0" - -[conda-packages.macos-arm64."readline-8.3-h8b90a29_1"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda" -checksum = "sha256:d6782b430e6dce4fe8c7ac118cd988d5a193eb4a7f60741edfaede58016b6110" - -[conda-packages.macos-arm64."sqlite-3.54.1-hdb6324b_101"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.54.1-hdb6324b_101.conda" -checksum = "sha256:9e36fa9989e80b5966345a6e0a4b08787bf35da9836f57077ab0285db2a39192" - -[conda-packages.macos-arm64."zlib-1.3.2-h8088a28_3"] -url = "https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda" -checksum = "sha256:ab46d85e4fcffff1b1c5cac517afa40b7ae784cf76fc9da1f55f6a6934291eb6" - -[conda-packages.macos-x64."bzip2-1.0.8-h374f1ed_10"] -url = "https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda" -checksum = "sha256:4ed83961876dc8844a6f0df49c07b408efbaea275ffb0b37133e24c006990b3a" - -[conda-packages.macos-x64."libassuan-3.0.2-h240833e_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libassuan-3.0.2-h240833e_0.conda" -checksum = "sha256:90af38135168c54a1fd4a3adbb0b0d7fd36d53cf575e263ff5227adeca423011" - -[conda-packages.macos-x64."libcxx-23.1.4-h19cb2f5_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libcxx-23.1.4-h19cb2f5_2.conda" -checksum = "sha256:5d66e121f30c4b3792480de92230ec05d024106df5c031452bba356153df8c65" - -[conda-packages.macos-x64."libgcrypt-1.12.2-h694c41f_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-1.12.2-h694c41f_2.conda" -checksum = "sha256:09c2c02626323c23e6245bafc53cf2b3a8774397f7a7f49e5b615a43993ae6f3" - -[conda-packages.macos-x64."libgcrypt-devel-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-devel-1.12.2-hd115831_2.conda" -checksum = "sha256:55d86f0eb92b21310e5619f1397ef3d24a851181d29fafcaaaabf2aa38a74307" - -[conda-packages.macos-x64."libgcrypt-lib-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-lib-1.12.2-hd115831_2.conda" -checksum = "sha256:234e85de6d138ce426133ac36fbfb220448d7c6b688f3f20504ce1c3c24618ab" - -[conda-packages.macos-x64."libgcrypt-tools-1.12.2-hd115831_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgcrypt-tools-1.12.2-hd115831_2.conda" -checksum = "sha256:0128042f831879b66cbc3a1a34a78603c3a9eee8198c6c64bae01114720dd476" - -[conda-packages.macos-x64."libgpg-error-1.61-hc569e58_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libgpg-error-1.61-hc569e58_2.conda" -checksum = "sha256:c6ced72cb9b830d00bea42b17587101fd3c4f31a0374e380915b06fdfd7b7096" - -[conda-packages.macos-x64."libiconv-1.18-h4ae439b_3"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h4ae439b_3.conda" -checksum = "sha256:2389e03a58ed0f5e8f2469eb4d4ba80d0af378b38854ae5eee65e5b641244082" - -[conda-packages.macos-x64."libintl-0.25.1-h98e67c8_2"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libintl-0.25.1-h98e67c8_2.conda" -checksum = "sha256:1fad645b88742ad51dbaf9a6709fa9663edd663a4fed6b27c9c4cbc42fa9c514" - -[conda-packages.macos-x64."libksba-1.6.7-hf036a51_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libksba-1.6.7-hf036a51_0.conda" -checksum = "sha256:cbeb2ae0d4b6e3f2abfcb1ae13c92e84e3cdd87caaad7a7e99e6a80edd35deb1" - -[conda-packages.macos-x64."libsqlite-3.54.1-ha5db789_101"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.54.1-ha5db789_101.conda" -checksum = "sha256:45af47119bf30cd41f661349cef1e93ad8e736d04b617560d0ae8d74d2eb8ffa" - -[conda-packages.macos-x64."libzlib-1.3.2-hbb4bfdb_3"] -url = "https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda" -checksum = "sha256:b2dba286dd6632292b12296e761193b5ef9fb0eaeecaa481f5ba9af72c0c18e1" - -[conda-packages.macos-x64."ncurses-6.6-hcc0dc9a_1"] -url = "https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda" -checksum = "sha256:12c1d676b9a0e8109b576672519312c5428308f3f3d7706f8717e2f536d5d9e7" - -[conda-packages.macos-x64."npth-1.8-h240833e_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/npth-1.8-h240833e_0.conda" -checksum = "sha256:d12c2c6d5e6c4447478a451781f8f3da74844873f7ce4d435a22ff95a93ac1b3" - -[conda-packages.macos-x64."ntbtls-0.3.2-h2d185b6_0"] -url = "https://conda.anaconda.org/conda-forge/osx-64/ntbtls-0.3.2-h2d185b6_0.conda" -checksum = "sha256:81e664250100203f32a5b74ad8dd41b906b13d8c0e0efbd8aa6c876c4d962fc2" - -[conda-packages.macos-x64."readline-8.3-h000c2f7_1"] -url = "https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h000c2f7_1.conda" -checksum = "sha256:feb9248a062b7a0738e56339d8c9e87bc9adf8e8c76549fb55b0b2456bec3a57" - -[conda-packages.macos-x64."sqlite-3.54.1-hdea7a7b_101"] -url = "https://conda.anaconda.org/conda-forge/osx-64/sqlite-3.54.1-hdea7a7b_101.conda" -checksum = "sha256:d7d355559000f26b811daa6caf3489b00d60d48cf4aa3b249556091e983afb41" - -[conda-packages.macos-x64."zlib-1.3.2-hbb4bfdb_3"] -url = "https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.2-hbb4bfdb_3.conda" -checksum = "sha256:d46ba65a5ebcb4f682f9f0f21943c427eb56e7f9d15aeab8b823294c787dbb0b" - -[[tools."conda:gnupg"]] -version = "2.5.24" -backend = "conda:gnupg" -specifiers = ["latest"] - -[tools."conda:gnupg".options] -channel = "conda-forge" - -[tools."conda:gnupg"."platforms.linux-arm64"] -checksum = "sha256:5317940ba41018dd9e8adbaf81386f670430db3c78b100c16d24bf3f5e46dfc3" -url = "https://conda.anaconda.org/conda-forge/linux-aarch64/gnupg-2.5.24-hf476e02_0.conda" -conda_deps = [ - "npth-1.8-h5ad3122_0", - "libksba-1.6.7-h0a1ffab_0", - "libassuan-3.0.2-h5ad3122_0", - "ntbtls-0.3.2-hd84c7bf_0", - "bzip2-1.0.8-h4777abc_10", - "readline-8.3-ha7194a6_1", - "libiconv-1.18-h1ea5142_3", - "sqlite-3.54.1-h363059a_101", - "libsqlite-3.54.1-hd26f1b6_101", - "libstdcxx-16.2.0-hef695bb_7", - "libgcc-16.2.0-h205dda4_7", - "libstdcxx-ng-16.2.0-hdbbeba8_7", - "libgcc-ng-16.2.0-he9431aa_7", - "libgcrypt-lib-1.12.2-h5bc82ec_2", - "libgpg-error-1.61-hfb11796_2", - "ncurses-6.6-h2b6f883_1", - "libzlib-1.3.2-hdc9db2a_3", - "zlib-1.3.2-hdc9db2a_3", - "_openmp_mutex-4.5-20_gnu", - "libgomp-16.2.0-h8acb6b2_7", - "libgcrypt-1.12.2-h8af1aa0_2", - "libgcrypt-tools-1.12.2-h5bc82ec_2", - "libgcrypt-devel-1.12.2-h5bc82ec_2", -] - -[tools."conda:gnupg"."platforms.linux-x64"] -checksum = "sha256:2e6965580eac0911e9ec0630b623e2a3ad30d7c98a3f3dfc05a7dae3d0707e7f" -url = "https://conda.anaconda.org/conda-forge/linux-64/gnupg-2.5.24-h4362557_0.conda" -conda_deps = [ - "libassuan-3.0.2-h5888daf_0", - "libksba-1.6.7-hac33072_0", - "npth-1.8-h5888daf_0", - "ntbtls-0.3.2-hfc55251_0", - "bzip2-1.0.8-hda65f42_10", - "readline-8.3-hd6e31c0_1", - "libiconv-1.18-h0cb94f2_3", - "sqlite-3.54.1-h6e79ffa_101", - "libsqlite-3.54.1-h0737f62_101", - "libgcc-16.2.0-ha9f2e26_7", - "libgcc-ng-16.2.0-h69a702a_7", - "libstdcxx-16.2.0-h934c35e_7", - "libstdcxx-ng-16.2.0-hdf11a46_7", - "libgcrypt-lib-1.12.2-h7cc23a3_2", - "libgpg-error-1.61-hee9eb32_2", - "ncurses-6.6-hdb14827_1", - "libzlib-1.3.2-h25fd6f3_3", - "zlib-1.3.2-h25fd6f3_3", - "_openmp_mutex-4.5-20_gnu", - "libgomp-16.2.0-he0feb66_7", - "libgcrypt-1.12.2-ha770c72_2", - "libgcrypt-tools-1.12.2-h7cc23a3_2", - "libgcrypt-devel-1.12.2-h7cc23a3_2", -] - -[tools."conda:gnupg"."platforms.macos-arm64"] -checksum = "sha256:3e1b9115df3d4213b19b1fe10022b0584cbf3134348e60335346c7ee5359438d" -url = "https://conda.anaconda.org/conda-forge/osx-arm64/gnupg-2.5.24-h1775f82_0.conda" -conda_deps = [ - "npth-1.8-h286801f_0", - "libassuan-3.0.2-h286801f_0", - "libksba-1.6.7-h00cdb27_0", - "ntbtls-0.3.2-h1059232_0", - "bzip2-1.0.8-h4e30115_10", - "readline-8.3-h8b90a29_1", - "libiconv-1.18-he4c29f2_3", - "sqlite-3.54.1-hdb6324b_101", - "libsqlite-3.54.1-h34a968b_101", - "libgpg-error-1.61-hdca3b7d_2", - "libintl-1.0-hae62183_0", - "libgcrypt-lib-1.12.2-hb001647_2", - "libcxx-23.1.4-h55c6f16_2", - "ncurses-6.6-he64c551_1", - "libzlib-1.3.2-h8088a28_3", - "zlib-1.3.2-h8088a28_3", - "libgcrypt-1.12.2-hce30654_2", - "libgcrypt-tools-1.12.2-hb001647_2", - "libgcrypt-devel-1.12.2-hb001647_2", -] - -[tools."conda:gnupg"."platforms.macos-x64"] -checksum = "sha256:26faef1a7427846709eeffa6a03e639695d3c13999b873b94b2e073b665f5d15" -url = "https://conda.anaconda.org/conda-forge/osx-64/gnupg-2.5.24-h4ccb0c0_0.conda" -conda_deps = [ - "npth-1.8-h240833e_0", - "ntbtls-0.3.2-h2d185b6_0", - "libksba-1.6.7-hf036a51_0", - "libassuan-3.0.2-h240833e_0", - "bzip2-1.0.8-h374f1ed_10", - "readline-8.3-h000c2f7_1", - "libiconv-1.18-h4ae439b_3", - "sqlite-3.54.1-hdea7a7b_101", - "libsqlite-3.54.1-ha5db789_101", - "libcxx-23.1.4-h19cb2f5_2", - "libgpg-error-1.61-hc569e58_2", - "libgcrypt-lib-1.12.2-hd115831_2", - "ncurses-6.6-hcc0dc9a_1", - "libzlib-1.3.2-hbb4bfdb_3", - "zlib-1.3.2-hbb4bfdb_3", - "libintl-0.25.1-h98e67c8_2", - "libgcrypt-1.12.2-h694c41f_2", - "libgcrypt-tools-1.12.2-hd115831_2", - "libgcrypt-devel-1.12.2-hd115831_2", -] diff --git a/.mise/mise.python.lock b/.mise/mise.python.lock index 911bbd18..bf6abc99 100644 --- a/.mise/mise.python.lock +++ b/.mise/mise.python.lock @@ -11,18 +11,23 @@ specifiers = ["0.29.3"] strip_components = "1" [tools."http:pyodide"."platforms.linux-arm64"] +checksum = "sha256:458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4" url = "https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-0.29.3.tar.bz2" [tools."http:pyodide"."platforms.linux-x64"] +checksum = "sha256:458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4" url = "https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-0.29.3.tar.bz2" [tools."http:pyodide"."platforms.macos-arm64"] +checksum = "sha256:458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4" url = "https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-0.29.3.tar.bz2" [tools."http:pyodide"."platforms.macos-x64"] +checksum = "sha256:458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4" url = "https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-0.29.3.tar.bz2" [tools."http:pyodide"."platforms.windows-x64"] +checksum = "sha256:458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4" url = "https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-0.29.3.tar.bz2" [[tools."npm:pyodide"]] diff --git a/.mise/mise.r.lock b/.mise/mise.r.lock index c5470675..4bae6232 100644 --- a/.mise/mise.r.lock +++ b/.mise/mise.r.lock @@ -13,9 +13,11 @@ checksum = "blake3:764c56176d67d64b858325fcc245d653ef5993dc01df3e7f8929448439d97 url = "https://github.com/r-wasm/webr/releases/download/v0.6.0/webr-0.6.0.tar.gz" [tools."http:webr"."platforms.macos-arm64"] +checksum = "sha256:04c56d16c3ad29265a2c4ffcb800ee0b72ab6dd64dd678c9c8fec81a832cb8cc" url = "https://github.com/r-wasm/webr/releases/download/v0.6.0/webr-0.6.0.tar.gz" [tools."http:webr"."platforms.macos-x64"] +checksum = "sha256:04c56d16c3ad29265a2c4ffcb800ee0b72ab6dd64dd678c9c8fec81a832cb8cc" url = "https://github.com/r-wasm/webr/releases/download/v0.6.0/webr-0.6.0.tar.gz" [tools."http:webr"."platforms.windows-x64"] diff --git a/AGENTS.md b/AGENTS.md index 063e3fc3..98287598 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,16 @@ and on every `main` commit alike -- it carries no information about the change u green. Read `gh pr checks` as clean once everything except that check is passing. Every other `DeepSource: *` check (Rust, Python, Secrets, ...) is a real signal and must stay green. +**A failing `DeepSource: *` check is read on its run page, which an agent can open.** The findings are not on +GitHub: the check carries no annotations, and the review comment posts inline only what lands inside the diff, +saying the rest is on the dashboard. Each analyzer's run page is the status's `target_url`, from +`gh api repos/{owner}/{repo}/commits//status --jq '.statuses[] | select(.context | startswith("DeepSource"))'`. +The page renders client-side, so `curl` and WebFetch get an empty shell; open it in a browser the agent drives and +read its text. In the Claude desktop app that is the built-in browser pane: a `navigate` from a tab already open on +another site may be refused, so open the URL as a tab of its own with `preview_start` and a `url`, which asks the +operator to allow the site once, then read it with `get_page_text`. No DeepSource sign-in is needed for this +public repository. The page lists each issue with its code (`DeepSource RS-E1015`, ...), severity, and the lines. + When an agent is watching a PR (e.g. via `/loop`), the next-poll delay follows three tiers -- tight at first (catch fail-fast errors), loose in the middle (long compile/test phases run on a 30-90 min scale), then loose again once everything's settled (waiting for the user to push): @@ -629,9 +639,9 @@ scenario deployments do not use this password -- `et-cli` derives a per-scenario ## Testing -Tests must live in a `/tests/` directory or in source files prefixed `test_`. Do not use inline -`#[cfg(test)]` modules. If a function is private but needs testing, add a `[lib]` target to the crate and export it -so `/tests/` can reach it. +Rust tests must live as flat files directly in a `/tests/` directory -- never in a subdirectory of it, and never +in a `test_*.rs` source file. Do not use inline `#[cfg(test)]` modules. If a function is private but needs testing, add +a `[lib]` target to the crate and export it so `/tests/` can reach it. Every file under `/tests/` carries `#![cfg(test)]`, and the preamble order is fixed: the `//!` module doc comes **first**, then the attribute. Both are inner attributes, so either order compiles -- this is a house style, not @@ -654,6 +664,10 @@ a `//!` block above it is required, not a violation. As of this writing every on a module doc puts it first, so a review comment claiming the attribute must lead is reading a rule this repo does not have. +Benchmarks live in `/benches/`, run by criterion with `harness = false`, and are held to the same latitude as +tests: a bench file may carry `#[expect(...)]` lint attributes, file-wide or on an item, where the harness's shape +makes a lint fire -- criterion calls each group function once, for one -- each with its `reason`, as tests do. + Shared, **low-dependency** test helper functions -- free-port reservation, port-readiness waits, and the like -- belong in the `et-test-helpers` crate (`libs/test-helpers`); reuse and extend it rather than re-implementing the same helper per test. Keep its dependency footprint small (currently just `port_check` + `retry`): anything heavier or diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5968cf..dedd5602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ Changelog 1.1.0. which drives no GPU itself. Its `host` answers the probes on the CPU, and its `HOST` and `WASI_NN` name the interfaces a signature is measured through with no browser. The module's `gpu.renderer` now falls back to WebGL's renderer string where a browser blanks the WebGPU adapter's. +- `et-gpu-probe` probes the GPU where no shader compiler decides the answer. `CLEARS` clears a target of a given + `Format` to a value with no shader at all, so the driver or output stage converts it, and the fixed-function set -- + `BLENDS`, `FILTERS` and `RASTERS` -- reads back what the blend unit, texture unit and rasterizer computed: rounding, + filter-weight bits and sub-pixel snapping. Each has its WGSL, its exact answer, and its own interface, + `WEBGPU_CLEAR` and `WEBGPU_FIXED_FUNCTION`, under which `Signature` reads it. A registry row may now record the + `driver_version` and `api_version` the graphics API itself reports. +- `et-gpu-probe-server` collects GPU probe signatures over HTTPS into `libs/gpu-probe/signatures.yaml`: a paste form + at `/submit`, JSON submissions at `POST /register`, each added as a new entry or as another place a known one was + seen. `mise run gpu-probe-server` serves this machine and `mise run gpu-probe-server-lan` the LAN. Its TLS and + LAN-address code is the new `et-https-listener` crate, which `et-ws-server` now re-exports as its `tls` and `net` + modules; its new `generate_tls_certs_covering` puts extra names, such as LAN addresses, in a self-signed pair. + Beside it, `gpu-probe` probes every GPU adapter of this machine, software renderers included, and its CPU, and + adds whatever sightings the registry is missing, driving the GPUs through `et-gpu-probe`'s new `native` feature. + The same feature builds for `wasm32`, opening the browser's WebGPU, and every `native::Gpu` call is async. The + server serves a probe page that runs the probe sets as WASM, through WebGL 2 and WebGPU, and submits each signature; + `mise run gpu-probe-page` builds and serves it on plain HTTP at localhost, which `--http` allows on a loopback only. - A WASI module can read a lidar. `et-ws-wasi-runner`'s new `lidar` feature, on by default, hosts `et:ws-wasi/serial`, lending a guest the serial ports its `SERIAL_PORTS_` variables name: a device, or `tcp://:` for one a bridge such as ser2net serves. The interface's own `serial-host` world, which the @@ -177,6 +193,11 @@ Changelog 1.1.0. ### Changed +- **Breaking:** `edge-toolkit` built without `std` holds the agent registry, `ws_server`, only with its new `board` + feature, which brings in the spin lock it is kept under; `board` also adds `board::BoardLock`, that lock, for the + rest of a board's shared state. Likewise `et-ws-service` without `std` holds its `hub` routing core only with + `board`, which now turns on edge-toolkit's, and `et-storage-service`'s `board` does the same for its routes. A + `no_std` user of any of them adds `board`; with `std` the registry keeps `std::sync::Mutex` and nothing changes. - A board hosts `wasi:clocks/wall-clock`, which every guest built with Rust 1.100 imports for its standard library's locks. A board keeps no calendar, so the time it gives is how long its clock has run. The interface moves from `edge_toolkit::board::COVERAGE_IMPORTS` to `HOSTED_IMPORTS`. diff --git a/CHECKS.md b/CHECKS.md index 817f2056..d6525bef 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,11 +1,11 @@ # Checks -- **Generated:** 2026-11-12 -- **Checks:** 85 +- **Generated:** 2026-11-13 +- **Checks:** 86 - **Tools:** 49 - **Shell-script checks:** 11 - **Checks with a repair pass:** 22 -- **Rules:** 381 custom local rules, or non-default strict linter settings +- **Rules:** 388 custom local rules, or non-default strict linter settings - **Meta rules:** 22 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -377,6 +377,7 @@ Rules, from `config/ast-grep/rules`: Rules, from `Cargo.toml`: +- `cargo::unused_dependencies` -- deny - [`clippy::absolute_paths`][absolute_paths] -- allow, priority 4 - `clippy::all` -- deny, priority -1 - [`clippy::allow_attributes`][allow_attributes] -- deny, priority 4 @@ -544,7 +545,7 @@ Rules, from `Cargo.toml`: ## `cargo metadata` - `cargo-clippy-wasm-check` (`default` env; run by `check:rust`) -- cargo clippy the browser and WASI crates on their - wasm targets. + wasm targets. Config: `.mise/cargo-clippy-wasm-check.jq`. ## `cargo shear` @@ -574,6 +575,8 @@ Rules, from `Cargo.toml`: (run `mise run gen:checks`) (conftest). Config: `config/conftest/policy`. - `conftest-check-ast-grep-rules` (`default` env; run by `conftest-check`) -- Check every ast-grep rule declares severity error (conftest). Config: `config/ast-grep/rules`, `config/conftest/policy`. +- `conftest-check-bench-results` (`default` env; run by `conftest-check`) -- Check each benchmark results file is named + for the machine and OS it records (conftest). Config: `config/conftest/policy`. - `conftest-check-branch-size` (`default` env; run by `conftest-check`) -- Fail when the branch modifies more files than CodeRabbit reviews in one pull request (conftest). Config: `config/conftest/policy`. - `conftest-check-checks-badge` (`default` env; run by `conftest-check`) -- Check the README checks badge keeps step @@ -610,6 +613,13 @@ Rules, from `config/conftest/policy`: - Every advisory ignore expires on one date, the next milestone review, which the operator sets. - `ast_grep_rules` (meta) (`config/conftest/policy/ast_grep_rules/ast_grep_rules.rego`): - A rule whose severity is anything but `error`, or is missing, is not a gate. +- `bench_results` (`config/conftest/policy/bench_results/bench_results.rego`): + - A results file sits in a crate's benches/results directory. + - A results file is named for the machine and OS it records. + - A machine id is lowercase words joined by hyphens. + - A results file records the OS it ran, as one a host build runs on. + - A results file's benchmarks are a mapping, each benchmark's name to its measures. + - A results file records at least one benchmark. - `branch_size` (`config/conftest/policy/branch_size/branch_size.rego`): - A branch may modify at most `max_files` files, or CodeRabbit skips its pull request's review. - `cargo` (`config/conftest/policy/cargo/cargo.rego`): @@ -1310,7 +1320,7 @@ Rules, from `config/taplo`: - `gpu-probe-signatures-check` (`default` env; run by `check:rust`) -- Fail if libs/gpu-probe/signatures.json is stale (run `mise run gen:gpu-probe-signatures`). - `mise-lock-check` (`default` env; run by `check:rust`) -- Fail if .mise/mise*.lock or .mise/locks/ differ from what a - full install resolves. + full install resolves. Config: `.mise/mise-lock-check.awk`. - `readme-mise-version-check` (`default` env; run by `check:rust`) -- Fail if README.md's mise version differs from min_version in .mise/config.toml. - `verification-check` (`default` env; run by `check:rust`) -- Fail if generated scenario output files are stale. diff --git a/Cargo.lock b/Cargo.lock index d91b6bb1..61812673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,15 @@ dependencies = [ "alloc-no-stdlib 3.0.0", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -444,6 +453,23 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "annotate-snippets" +version = "0.12.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0258346f92c78dbc5b336b9a5b27c64447aa10643bc64e651db2edb145361564" +dependencies = [ + "anstyle", + "memchr", + "unicode-width 0.2.3", +] + [[package]] name = "anstream" version = "1.0.0" @@ -536,6 +562,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.8" @@ -1552,6 +1584,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.3.0" @@ -1699,6 +1758,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width 0.2.3", ] @@ -2311,6 +2372,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -5144,6 +5238,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -5796,15 +5899,59 @@ dependencies = [ name = "et-gpu-probe" version = "0.1.0" dependencies = [ + "criterion", "document-features", "et-gpu-probe", + "futures-channel", "futures-executor", + "objc2-metal", + "platforms", "serde_json", "target-lexicon", "thiserror 2.0.21", "webbrowser", "wgpu", "wgpu-hal", + "windows 0.62.2", +] + +[[package]] +name = "et-gpu-probe-server" +version = "0.1.0" +dependencies = [ + "actix-files", + "actix-http", + "actix-rt", + "actix-web", + "clap", + "edge-toolkit", + "et-gpu-probe", + "et-https-listener", + "fs-err", + "futures-executor", + "serde", + "serde-saphyr", + "serde_json", + "tempfile", + "thiserror 2.0.21", + "tracing", + "tracing-subscriber", + "wgpu", +] + +[[package]] +name = "et-https-listener" +version = "0.1.0" +dependencies = [ + "document-features", + "fs-err", + "local-ip-address", + "rcgen", + "rustls", + "rustls-webpki", + "tempfile", + "thiserror 2.0.21", + "tracing", ] [[package]] @@ -5837,6 +5984,7 @@ dependencies = [ "command-error", "edge-toolkit", "et-cli", + "et-gpu-probe-server", "et-modules-service", "et-onnx", "et-recovery-supervisor", @@ -5874,6 +6022,26 @@ dependencies = [ "wit-parser 0.258.3", ] +[[package]] +name = "et-int-gpu-probe-web" +version = "0.1.0" +dependencies = [ + "base64 0.23.1", + "document-features", + "et-gpu-probe", + "et-web", + "js-sys", + "md-5 0.11.0", + "png", + "serde_json", + "thiserror 2.0.21", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "wgpu", +] + [[package]] name = "et-int-repo-check" version = "0.1.0" @@ -6496,23 +6664,19 @@ dependencies = [ "document-features", "edge-toolkit", "et-actix-web-config", + "et-https-listener", "et-modules-service", "et-otlp", "et-storage-service", "et-websockify-service", "et-ws-service", - "fs-err", - "local-ip-address", "qr2term", - "rcgen", - "rustls", "schemars 1.2.3", "serde", "serde-env", "serde-inline-default", "serde_default", "tempfile", - "thiserror 2.0.21", "tracing", "tracing-actix-web", "tracing-subscriber", @@ -6545,7 +6709,6 @@ dependencies = [ "serde-inline-default", "serde_default", "serde_json", - "spin 0.12.3", "subtle", "tempfile", "thiserror 2.0.21", @@ -7421,6 +7584,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "granit-parser" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f05586d4d4df3d47123207826c40b252728b438a1a07bad3c01ee6bea401df" +dependencies = [ + "arraydeque", + "smallvec 1.16.4", +] + [[package]] name = "group" version = "0.13.0" @@ -10210,6 +10383,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "panic-probe" version = "1.0.0" @@ -10627,6 +10810,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "platforms" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9245c6e7c5a6bcdd7977fdf6d1e1c67f4cc2d0d58c041df0ea5940953033e6ca" + [[package]] name = "png" version = "0.18.1" @@ -12365,10 +12554,14 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43fb82d550b07e6f929a58d7d0935182602f48ddead875f5ee313c41c8f07fe" dependencies = [ + "annotate-snippets", "base64 0.23.1", + "encoding_rs_io", + "granit-parser", "nohash-hasher", "num-traits", "serde_core", + "smallvec 1.16.4", "zmij", ] @@ -13905,6 +14098,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -15496,6 +15699,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", + "naga", "portable-atomic", "profiling", "raw-window-handle", diff --git a/Cargo.toml b/Cargo.toml index 64dc22aa..21f381cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ edition = "2024" license = "Apache-2.0 or MIT" repository = "https://github.com/edge-toolkit/core" +# Held at 1.99 by the coverage toolchain, which no 1.100 nightly can be. +# Every 1.100.0-nightly carries LLVM 23, whose profraw format minicov 0.3.8 cannot write, and wasm-bindgen-test +# 0.3.80 pins minicov at exactly 0.3.8 (see the minicov entry). A 1.100 requirement made cargo refuse every crate +# on `rust_nightly`, with "rustc 1.99.0-nightly is not supported" on +# https://github.com/edge-toolkit/core/actions/runs/43355799367/job/131657587566. Raise it once a +# wasm-bindgen-test release accepts minicov 0.3.9, moving `rust_nightly` to a 1.100 nightly in the same change. rust-version = "1.99.0" [workspace] @@ -11,6 +17,7 @@ members = [ "libs/edge-toolkit", "libs/et-otlp", "libs/gpu-probe", + "libs/https-listener", "libs/lidar", "libs/math1", "libs/org", @@ -62,6 +69,8 @@ members = [ "services/ws-test-server", "utilities/int-gen", "utilities/cli", + "utilities/gpu-probe", + "utilities/gpu-probe/web", "utilities/onnx", "utilities/repo-check", "utilities/wasm-cov-wrapper", @@ -90,12 +99,14 @@ bytes = { version = "1", default-features = false } bytesize = { version = "2", features = ["serde"] } cc = "1" chrono = { version = "0.4", default-features = false, features = ["serde"] } +# Benchmarks; rayon and the plotters charts are left out, since a run's numbers are what a README records. clap = { version = "4.4", features = ["derive"] } clap-markdown = "0.1" cloudevents-sdk = "0.9" command-error = "0.8" const-hex = "1.19" cortex-m-rt = "0.7" +criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } critical-section = "1" croner = "4" cyw43 = "0.7" @@ -134,6 +145,8 @@ et-cli = { path = "utilities/cli", version = "0.2.0" } et-core-guest = { path = "libs/core-guest", version = "0.1.0" } et-firmware-modules = { path = "services/firmware-modules", version = "0.1.0" } et-gpu-probe = { path = "libs/gpu-probe", version = "0.1.0" } +et-gpu-probe-server = { path = "utilities/gpu-probe", version = "0.1.0" } +et-https-listener = { path = "libs/https-listener", version = "0.1.0" } et-hub-backplane = { path = "services/hub-backplane", version = "0.2.0" } et-lidar = { path = "libs/lidar", version = "0.1.0" } et-math1 = { path = "libs/math1", version = "0.1.0" } @@ -161,6 +174,7 @@ et-ws-wasi-runner = { path = "services/ws-wasi-runner", version = "0.3.0", defau et-ws-wasm-agent = { path = "services/ws-wasm-agent", version = "0.3.0" } fake = "5" fs-err = "3" +futures-channel = { version = "0.3", default-features = false } futures-executor = "0.3" futures-util = "0.3" heck = "0.5" @@ -185,6 +199,7 @@ kdl = { version = "6", features = ["v1"] } libc = "0.2" local-ip-address = "0.6" log = "0.4" +md-5 = { version = "0.11", default-features = false } # minicov's profiler runtime must match the LLVM of the toolchain that instruments the guests. # Held at exactly 0.3.8 (profraw format 10, LLVM 22) because wasm-bindgen-test 0.3.80 -- the only release that # pairs with wasm-bindgen 0.2.130 -- pins `minicov = "=0.3.8"` under its coverage cfg (wasm-bindgen PR 5283), @@ -225,6 +240,8 @@ onnx-extractor = "0.5" # test runs against). Only `aws` is enabled -- adding `gcp`/`azure`/`http` is a one-word change if a consumer # ever needs them, and each drags its own SDK surface, so they stay off until asked for. object_store = { version = "0.14", default-features = false, features = ["aws", "fs"] } +# Held to the release wgpu-hal's Metal backend is built on, so a device it hands out is one of these types. +objc2-metal = { version = "0.3", default-features = false } openapiv3 = "2" opentelemetry = "0.32" opentelemetry-appender-tracing = "0.32" @@ -266,6 +283,9 @@ opentelemetry_sdk = "0.32" ort = { version = "=2.0.0-rc.10", default-features = false, features = ["load-dynamic"] } panic-probe = "1" picoserve = { version = "0.20", default-features = false, features = ["ws"] } +# Names each operating system as `std::env::consts::OS` spells it, Android included. +platforms = { version = "3", default-features = false } +png = { version = "0.18", default-features = false } port_check = "0.3" pretty_yaml = "0.6" prettyplease = "0.3" @@ -295,6 +315,7 @@ retry = { version = "2", default-features = false } retry-policies = "0.5" rstest = "0.28" rustls = "0.23" +rustls-webpki = { version = "0.103", default-features = false, features = ["alloc"] } sanitize-filename = "0.8.0-beta" schemars = { version = "1.2", features = ["derive"] } secrecy = { version = "0.10", features = ["serde"] } @@ -390,6 +411,8 @@ wgpu-hal = { version = "29", default-features = false } wgpu-types = "29" # Windows-only feature-unification shim: forces `winapi/std` on for deno_io. winapi = { version = "0.3", features = ["std"] } +# Held to the release wgpu-hal's Direct3D 12 backend is built on, so an adapter it hands out is one of these types. +windows = { version = "0.62", default-features = false } wit-bindgen = "0.63" wit-encoder = "0.263" # Held to the release wasmtime-internal-wit-bindgen builds against. @@ -429,6 +452,11 @@ unsafe_op_in_unsafe_fn = "deny" # unused_crate_dependencies = "deny" unused_results = "deny" +[workspace.lints.cargo] +# A dependency the manifest declares and no source uses fails the build, not just warns as cargo's default does. +# Unlike rustc's unused_crate_dependencies above, cargo's check sees a package's targets together, so it can be denied. +unused_dependencies = "deny" + [workspace.lints.rustdoc] private_intra_doc_links = { level = "deny", priority = 8 } diff --git a/config/cargo-features.toml b/config/cargo-features.toml index af500cfb..824da387 100644 --- a/config/cargo-features.toml +++ b/config/cargo-features.toml @@ -15,6 +15,7 @@ docs = "Internal: renders the crate's feature list into its documentation, as do env-schema = "Internal: derives JSON Schema for the crate's environment config, which et-int-gen renders into HELP.md." lidar = "Lends WASI guests the serial ports `SERIAL_PORTS_` names, which they read a lidar through." modules = "Serves ws-module packages from disk under `/modules`, and the root module as the browser UI." +native = "Puts the GPU probe sets to a GPU through wgpu: the machine's own GPUs, or in a page the browser's WebGPU." nats = "Shares agents between hubs through the NATS JetStream backplane." nn = "Hosts `wasi:nn` for guests, running their models through ONNX Runtime." openapi-spec = "Adds OpenAPI metadata to the crate's HTTP handlers, for et-int-gen to harvest into the published spec." diff --git a/config/conftest/policy/bench_results/bench_results.rego b/config/conftest/policy/bench_results/bench_results.rego new file mode 100644 index 00000000..8f9f169e --- /dev/null +++ b/config/conftest/policy/bench_results/bench_results.rego @@ -0,0 +1,72 @@ +# Each machine's benchmark results, under each OS it ran, sit in a file named for that machine and OS. +# Run with `--combine --namespace bench_results` over `libs/*/benches/results/*.yaml`, so `input` is an array of +# {path, contents}, one per results file. +# +# A results file is what `mise run bench-gpu-probe ` writes: the machine it was measured on under `machine`, +# with its `id` and the `os` it ran, and every benchmark's estimates under `benchmarks`. The same machine gives other +# results under another OS, through its other drivers, so a file is found by both, and its name must agree with what +# it records: a file renamed by hand, or one recorded under a mistyped id, would sit beside the real one as a second +# machine nobody has. The id is lowercase words joined by `-`, so it is the same file name on every OS. +package bench_results + +import data.paths + +# A machine id: lowercase letters and digits, in words joined by single hyphens. +id_pattern := `^[a-z0-9]+(-[a-z0-9]+)*$` + +# The directory a results file sits in, and its suffix; what is between them is the machine id and the OS. +results_path := `^libs/[^/]+/benches/results/([^/]+)\.yaml$` + +# Every OS a host build runs the benchmarks on, as the registry's rows name it. +oses := {"linux", "macos", "windows"} + +# A results file sits in a crate's benches/results directory. +deny contains msg if { + some file in paths.files + not regex.match(results_path, file.path) + msg := sprintf("%s: a benchmark results file belongs in libs//benches/results/-.yaml", [file.path]) +} + +# A results file is named for the machine and OS it records. +deny contains msg if { + some file in paths.files + stem := regex.find_all_string_submatch_n(results_path, file.path, 1)[0][1] + id := object.get(file.contents, ["machine", "id"], "") + os := object.get(file.contents, ["machine", "os"], "") + expected := $"{id}-{os}" + stem != expected + msg := sprintf("%s: records machine %v under %v, so it must be named %v.yaml", [file.path, id, os, expected]) +} + +# A machine id is lowercase words joined by hyphens. +deny contains msg if { + some file in paths.files + id := file.contents.machine.id + not regex.match(id_pattern, id) + msg := sprintf("%s: machine id %q must be lowercase words joined by -", [file.path, id]) +} + +# A results file records the OS it ran, as one a host build runs on. +deny contains msg if { + some file in paths.files + os := object.get(file.contents, ["machine", "os"], "") + not os in oses + msg := sprintf("%s: machine os %q must be one of %v", [file.path, os, sort(oses)]) +} + +# A results file's benchmarks are a mapping, each benchmark's name to its measures. +deny contains msg if { + some file in paths.files + benchmarks := object.get(file.contents, "benchmarks", {}) + not is_object(benchmarks) + msg := sprintf("%s: benchmarks must map each benchmark's name to its measures, not be %v", [file.path, benchmarks]) +} + +# A results file records at least one benchmark. +deny contains msg if { + some file in paths.files + benchmarks := object.get(file.contents, "benchmarks", {}) + is_object(benchmarks) + count(benchmarks) == 0 + msg := sprintf("%s: records no benchmarks", [file.path]) +} diff --git a/config/conftest/policy/bench_results/bench_results_test.rego b/config/conftest/policy/bench_results/bench_results_test.rego new file mode 100644 index 00000000..1220a8cd --- /dev/null +++ b/config/conftest/policy/bench_results/bench_results_test.rego @@ -0,0 +1,95 @@ +# Unit tests for the benchmark results file names, run by `conftest verify`. +package bench_results_test + +import data.bench_results + +# A results file at `path` recording machine `id` under `os`, and one benchmark. +results(path, id, os) := { + "path": path, + "contents": {"machine": {"id": id, "os": os}, "benchmarks": {"probe_sets/shaders": {"latency": {"value": 1.0}}}}, +} + +test_a_file_named_for_its_machine_and_os_passes if { + named := results("libs/gpu-probe/benches/results/dell-precision-5570-linux.yaml", "dell-precision-5570", "linux") + count(bench_results.deny) == 0 with input as [named] +} + +test_the_same_machine_under_two_oses_is_two_files if { + linux := results("libs/gpu-probe/benches/results/dell-linux.yaml", "dell", "linux") + windows := results("libs/gpu-probe/benches/results/dell-windows.yaml", "dell", "windows") + count(bench_results.deny) == 0 with input as [linux, windows] +} + +test_a_file_named_for_another_machine_is_refused if { + renamed := results("libs/gpu-probe/benches/results/old-name-linux.yaml", "dell-precision-5570", "linux") + msgs := bench_results.deny with input as [renamed] + count(msgs) == 1 + some msg in msgs + contains(msg, "must be named dell-precision-5570-linux.yaml") +} + +test_a_file_named_without_its_os_is_refused if { + unnamed := results("libs/gpu-probe/benches/results/dell.yaml", "dell", "linux") + msgs := bench_results.deny with input as [unnamed] + count(msgs) == 1 + some msg in msgs + contains(msg, "must be named dell-linux.yaml") +} + +test_a_file_with_no_machine_is_refused if { + no_machine := {"path": "libs/gpu-probe/benches/results/laptop-linux.yaml", "contents": {"benchmarks": {"a": {}}}} + msgs := bench_results.deny with input as [no_machine] + some msg in msgs + contains(msg, "records machine under ") +} + +test_an_id_that_is_not_lowercase_words_is_refused if { + capitalised := results("libs/gpu-probe/benches/results/Dell_5570-linux.yaml", "Dell_5570", "linux") + msgs := bench_results.deny with input as [capitalised] + count(msgs) == 1 + some msg in msgs + contains(msg, "must be lowercase words joined by -") +} + +test_an_os_a_host_build_does_not_run_on_is_refused if { + msgs := bench_results.deny with input as [results("libs/gpu-probe/benches/results/dell-beos.yaml", "dell", "beos")] + count(msgs) == 1 + some msg in msgs + contains(msg, `machine os "beos" must be one of`) +} + +test_a_file_outside_a_results_directory_is_refused if { + msgs := bench_results.deny with input as [results("libs/gpu-probe/benches/dell-linux.yaml", "dell", "linux")] + count(msgs) == 1 + some msg in msgs + contains(msg, "belongs in libs//benches/results/-.yaml") +} + +# A results file named for its machine and OS, whose `benchmarks` is `value`. +benchmarks_file(value) := { + "path": "libs/gpu-probe/benches/results/dell-linux.yaml", + "contents": {"machine": {"id": "dell", "os": "linux"}, "benchmarks": value}, +} + +test_benchmarks_given_as_a_string_are_refused if { + msgs := bench_results.deny with input as [benchmarks_file("lost")] + count(msgs) == 1 + some msg in msgs + contains(msg, "benchmarks must map each benchmark's name to its measures") +} + +test_benchmarks_given_as_a_list_are_refused if { + msgs := bench_results.deny with input as [benchmarks_file(["a", "b"])] + count(msgs) == 1 + some msg in msgs + contains(msg, "benchmarks must map each benchmark's name to its measures") +} + +test_a_file_recording_no_benchmarks_is_refused if { + contents := {"machine": {"id": "dell", "os": "linux"}, "benchmarks": {}} + empty := {"path": "libs/gpu-probe/benches/results/dell-linux.yaml", "contents": contents} + msgs := bench_results.deny with input as [empty] + count(msgs) == 1 + some msg in msgs + contains(msg, "records no benchmarks") +} diff --git a/config/conftest/policy/mise/mise.rego b/config/conftest/policy/mise/mise.rego index 43743fb6..76ed6fde 100644 --- a/config/conftest/policy/mise/mise.rego +++ b/config/conftest/policy/mise/mise.rego @@ -208,6 +208,8 @@ allowed_os_scoped_tool := { # action-validator (aqua) has no Windows build, so it is os-scoped off Windows. # config.windows.toml installs cargo:action-validator there instead (cargo-quickinstall msvc prebuilt). "action-validator", + # conda-forge builds no gnupg for Windows, where config.windows.toml installs conda:m2-gnupg instead. + "conda:gnupg", "http:chromedriver", "pipx", "pipx:torch", diff --git a/config/gitleaks.toml b/config/gitleaks.toml index 86109dcc..e98e773c 100644 --- a/config/gitleaks.toml +++ b/config/gitleaks.toml @@ -39,6 +39,7 @@ paths = [ '''(^|/)__pycache__/''', '''(^|/)node_modules/''', '''(^|/)obj/''', + '''^libs/gpu-probe/signatures\.yaml\.(lock|partial)$''', '''^modules/web/dotnet-data1/bin/''', '''^modules/web/js-data1/pkg/et_ws_js_data1\.js$''', '''^modules/web/kotlin-data1/build/''', @@ -51,6 +52,7 @@ paths = [ '''^services/ws-server/storage/''', '''^services/ws-wasm-agent/pkg/''', '''^target/''', + '''^utilities/gpu-probe/static/pkg/''', '''^verification/.*/\.mise/locks/''', '''^zig-out/''', ] diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index b1bd6c83..1bd078c4 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -261,6 +261,7 @@ "cc9e2428ec6b5759": 1, "ccd7a1a480b66ccf": 2, "cd216052533fd84d": 1, + "cdc5caaeebaa58dd": 1, "ce17c249a87973d7": 1, "ce8437d3ac124830": 1, "ce990318bfc4aa30": 1, @@ -284,7 +285,6 @@ "dd162080de996936": 1, "ddca47a4caded0fd": 1, "de1a3233f93e1695": 1, - "de556507e92474d3": 1, "de6fdf79d6510892": 1, "e152738c60f43b0e": 1, "e25456ad9cc7f9f9": 1, diff --git a/config/jscpd.json b/config/jscpd.json index 4a765486..6728b2fc 100644 --- a/config/jscpd.json +++ b/config/jscpd.json @@ -9,6 +9,7 @@ "verification/**", "**/HELP.md", "**/pkg/package.json", + "libs/*/benches/results/**", "libs/gpu-probe/signatures.json", "libs/gpu-probe/signatures.yaml", "modules/**/Cargo.toml", diff --git a/config/ls-lint.yaml b/config/ls-lint.yaml index 486d2b0d..cefdaa4a 100644 --- a/config/ls-lint.yaml +++ b/config/ls-lint.yaml @@ -6,7 +6,10 @@ ls: # The regex alternative permits leading-dot tool dirs (.github, .mise). .dir: kebab-case | snake_case | regex:\.[a-z_]+ - .rs: snake_case | kebab-case + # Rust files are snake_case or kebab-case, and none starts with `test_`. + # A Rust test is a flat file in its crate's `tests/`, never a `test_*.rs` module under `src/`; Python's `test_*.py` + # stays, as pytest finds tests by that prefix. RE2 has no lookahead, so the regex spells out every start but `test_`. + .rs: regex:(?:[a-su-z0-9]|t$|t[a-df-z0-9_-]|te$|te[a-rt-z0-9_-]|tes$|tes[a-su-z0-9_-]|test$|test[a-z0-9])[a-z0-9_-]* .py: snake_case | regex:__[a-z]+__ .js: snake_case .zig: snake_case @@ -26,6 +29,15 @@ ls: # `regex:^$` matches no basename, so every `*.conf` file fails the check. .conf: regex:^$ + # A Rust integration test is one flat file directly in its crate's `tests/`. + # No `tests//main.rs` binary split across a directory, and no helper module tucked below `tests/`: shared + # helpers go in a test-support crate. The first key covers a directory directly under `tests/`, the second every + # one below that. + "**/tests/*": + .rs: exists:0 + "**/tests/*/**": + .rs: exists:0 + ignore: # Every entry is a literal path -- no glob or wildcard syntax of any kind. # ls-lint snapshots glob ignores once at startup and drops any key matching nothing then (internal/glob/glob.go @@ -93,6 +105,10 @@ ignore: - modules/web/zig-except1/pkg - modules/web/zig-math1/pkg - services/ws-wasm-agent/pkg + - utilities/gpu-probe/static/pkg + # The GPU probe registry's lock file and staged copy, which its server and the gpu-probe binary write beside it. + - libs/gpu-probe/signatures.yaml.lock + - libs/gpu-probe/signatures.yaml.partial # Python venvs plus the pytest and bytecode caches each Python module's test run leaves behind. - .venv - .pytest_cache @@ -164,3 +180,9 @@ ignore: - modules/web/kotlin-math1/.kotlin - modules/web/kotlin-math1/build - modules/web/kotlin-math1/src/wasmJsMain + # A private unit-test module that src/policy.rs includes through #[path], not a test binary of its own. + - services/recovery-supervisor/tests/unit/policy_guards.rs + # `test_*.rs` modules that test private items, written before the prefix was banned, until each moves to `tests/`. + - modules/web/face-detection/src/test_face_detection.rs + - modules/web/har1/src/test_har1.rs + - services/ws-wasi-runner/src/board/test_host.rs diff --git a/config/lychee.toml b/config/lychee.toml index 4af5c762..3c881b33 100644 --- a/config/lychee.toml +++ b/config/lychee.toml @@ -26,7 +26,14 @@ exclude_private = true # those paths). # Each entry is an unanchored regex over the path, so it is anchored to a whole directory name: a bare "data" also # dropped every file under the `*-data1` modules. -exclude_path = ['(^|/)node_modules/', '(^|/)target/', '\.local\.[^/]+$', '^(\./)?data/', '^(\./)?generated/'] +exclude_path = [ + '(^|/)node_modules/', + '(^|/)target/', + '\.local\.[^/]+$', + '^(\./)?data/', + '^(\./)?generated/', + '^(\./)?utilities/gpu-probe/static/pkg/', +] # URL patterns (regex) to skip, none of which are real links. # doc/test placeholder hosts (e.g. http://host:8080/) and `{...}` format-string templates diff --git a/config/oxlintrc.jsonc b/config/oxlintrc.jsonc index 21e5a9da..6fd8bb66 100644 --- a/config/oxlintrc.jsonc +++ b/config/oxlintrc.jsonc @@ -4,6 +4,9 @@ // docs hinting otherwise. { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + // Every script here runs in a browser page or worker, or in a host that gives it the same globals. + // eslint/no-undef needs them declared to tell a real undefined name from one the host provides. + "env": { "browser": true, "worker": true, "es2024": true }, "categories": { "correctness": "error", "suspicious": "warn", @@ -39,8 +42,16 @@ // which leaves `undefined` standing in for "not set yet" with nothing to say it was meant. "eslint/id-length": ["error", { "exceptions": ["x", "y"] }], "eslint/init-declarations": "error", + // The local twin of `DeepSource JS-0125`: a name used but never declared throws a ReferenceError at run time. + // oxlint ignores `/* global */` comments, so a host-provided name is declared in an override's `globals`. + "eslint/no-undef": "error", }, "overrides": [ + { + // A Firefox profile's prefs file, which Firefox reads with `user_pref` defined. + "files": ["**/.mise/gpu-probe-firefox.user.js"], + "globals": { "user_pref": "readonly" }, + }, { // shim.js installs DOM stubs so Deno-hosted wasm-bindgen modules see a browser-shaped global. // The stubs are Window / HTMLElement / HTMLCanvasElement / Image classes plus `obj.onload = null` property diff --git a/config/ryl.toml b/config/ryl.toml index dd4ca405..7cfc5223 100644 --- a/config/ryl.toml +++ b/config/ryl.toml @@ -17,6 +17,9 @@ strict = true "../.github/workflows/*.yaml" = ["empty-values"] # The generated Kubernetes manifests carry the hub's module list as one env scalar, which has no way to wrap. "../verification/**/k3s.yaml" = ["line-length"] +# Benchmark results, every machine's and OS's, record each time as measured, in nanoseconds. +# yq writes the millisecond ones as floats in scientific notation, and rounding them to dodge that would discard data. +"../libs/*/benches/results/*.yaml" = ["float-values"] [lint.rules] brackets = "enable" diff --git a/config/typos.toml b/config/typos.toml index 8664d5a6..062ab71f 100644 --- a/config/typos.toml +++ b/config/typos.toml @@ -23,3 +23,5 @@ extend-ignore-re = ["Functionality is equivelant to", "MIS Quarterly", "lod-(min # ser2net is the serial-to-network bridge daemon, whose "ser" is short for serial rather than a misspelling of "set". [default.extend-identifiers] ser2net = "ser2net" +# textureLod is GLSL's sampling function that names its level of detail, not a misspelling of "load". +textureLod = "textureLod" diff --git a/libs/edge-toolkit/Cargo.toml b/libs/edge-toolkit/Cargo.toml index 963cfd67..8bb35534 100644 --- a/libs/edge-toolkit/Cargo.toml +++ b/libs/edge-toolkit/Cargo.toml @@ -67,8 +67,8 @@ required-features = ["std"] default = ["std"] ## Everything that needs an operating system. ## Configuration loading, the registry file, process arguments and the mise lookups. Without it the crate is -## `no_std` + `alloc`: the WebSocket protocol types, the port allocation, the `BasicAuth` config, and the hub's agent -## registry held in memory only. +## `no_std` + `alloc`: the WebSocket protocol types, the port allocation and the `BasicAuth` config, with the hub's +## agent registry, held in memory only, when `board` is on too. std = [ "base64/std", "chrono/std", @@ -94,6 +94,9 @@ schema-export = ["dep:asyncapi-rust", "dep:schemars", "schemars/chrono04", "std" ## Derives JSON Schema for the configuration types the binaries read from the environment. ## et-int-gen renders each binary's environment variables into its HELP.md from that schema. env-schema = ["dep:schemars", "std"] +## Runs on a microcontroller board: the hub's agent registry, held in memory under a spin lock. +## A board has no `std::sync::Mutex`, so this is what gives a `no_std` build its registry. +board = ["dep:spin"] ## Internal: a board runner built for coverage also hosts the `/cov` interfaces an instrumented guest writes through. coverage = [] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. @@ -116,8 +119,8 @@ serde_default = { workspace = true, optional = true } serde_json = { workspace = true, features = ["alloc"] } serde_path_to_error.workspace = true serde_yaml = { workspace = true, optional = true } -# The registry's lock when there is no `std::sync::Mutex`. -spin.workspace = true +# The registry's lock on a board, which has no `std::sync::Mutex`. +spin = { workspace = true, optional = true } strum = { workspace = true, optional = true } thiserror.workspace = true tracing = { workspace = true, features = ["std"], optional = true } diff --git a/libs/edge-toolkit/src/board.rs b/libs/edge-toolkit/src/board.rs index 9d6caa64..b376fb49 100644 --- a/libs/edge-toolkit/src/board.rs +++ b/libs/edge-toolkit/src/board.rs @@ -175,3 +175,10 @@ impl Guest { self.imports.iter().map(String::as_str).filter(|import| !hosts(import)) } } + +/// The lock a board keeps shared state under: a spin lock, since a board has no `std::sync::Mutex`. +/// +/// Nothing on a board can poison it, so its `lock()` gives the guard itself. It is the same lock in a host build of a +/// board's code, so that code behaves there as it will on the board. +#[cfg(feature = "board")] +pub type BoardLock = spin::Mutex; diff --git a/libs/edge-toolkit/src/lib.rs b/libs/edge-toolkit/src/lib.rs index 39039ab1..f54d9ed7 100644 --- a/libs/edge-toolkit/src/lib.rs +++ b/libs/edge-toolkit/src/lib.rs @@ -13,4 +13,6 @@ pub mod board; pub mod config; pub mod ports; pub mod ws; +// The agent registry needs a lock: `std`'s, or on a board a spin lock. A `no_std` build for neither has no registry. +#[cfg(any(feature = "board", feature = "std"))] pub mod ws_server; diff --git a/libs/edge-toolkit/src/ws_server.rs b/libs/edge-toolkit/src/ws_server.rs index 4fd3a97c..daf96414 100644 --- a/libs/edge-toolkit/src/ws_server.rs +++ b/libs/edge-toolkit/src/ws_server.rs @@ -25,8 +25,8 @@ pub const MAX_AGENT_ID_LEN: usize = 64; /// The lock the registry's shared state sits behind. /// -/// `std::sync::Mutex` wherever there is one, so a `std` caller's `lock()` reports poisoning as it always has; a spin -/// lock without `std`, where nothing can poison it. +/// `std::sync::Mutex` wherever there is one, so a `std` caller's `lock()` reports poisoning as it always has; without +/// `std`, the board's spin lock, where nothing can poison it. pub type Lock = LockImpl; #[derive(Debug, Error)] diff --git a/libs/edge-toolkit/tests/registry.rs b/libs/edge-toolkit/tests/registry.rs index fc0ee406..9d541983 100644 --- a/libs/edge-toolkit/tests/registry.rs +++ b/libs/edge-toolkit/tests/registry.rs @@ -1,8 +1,9 @@ //! Covers the in-memory `AgentRegistry` paths the ws-server integration tests skip: reconnect, disconnect, //! `agent_session`, the blocklist, message queueing and acknowledgement under each retention, and the -//! `with_pending_direct_messages` builder. None of it needs `std`. `S = String` stands in for the runtime session -//! handle. +//! `with_pending_direct_messages` builder. None of it needs `std`; it runs wherever the registry exists, with `std` or +//! on a board. `S = String` stands in for the runtime session handle. #![cfg(test)] +#![cfg(any(feature = "board", feature = "std"))] use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr}; diff --git a/libs/gpu-probe/.gitignore b/libs/gpu-probe/.gitignore new file mode 100644 index 00000000..85ec2606 --- /dev/null +++ b/libs/gpu-probe/.gitignore @@ -0,0 +1,3 @@ +# The lock file the registry's writers share, and the staged copy each renames over the registry. +/signatures.yaml.lock +/signatures.yaml.partial diff --git a/libs/gpu-probe/Cargo.toml b/libs/gpu-probe/Cargo.toml index 8480ed45..696cf04f 100644 --- a/libs/gpu-probe/Cargo.toml +++ b/libs/gpu-probe/Cargo.toml @@ -13,29 +13,58 @@ features = ["docs"] [lib] doctest = false +[[test]] +name = "native" +required-features = ["native"] + +[[bench]] +harness = false +name = "gpu_probe" +required-features = ["native", "registry"] + [features] ## Embeds the registry of every signature the probes have been seen to give. ## `Signature::identify` then names the hardware a signature was seen on, and `Signature::seen` lists each place. -registry = ["dep:serde_json", "dep:target-lexicon", "dep:thiserror", "dep:webbrowser", "dep:wgpu-hal"] +registry = ["dep:platforms", "dep:serde_json", "dep:target-lexicon", "dep:thiserror", "dep:webbrowser", "dep:wgpu-hal"] +## Puts the probe sets to a GPU through wgpu, and reads what the device says it is. +## Natively it opens the backends `et-ws-wasi-runner` hosts and reads the API beneath wgpu; on wasm32, WebGPU. +native = ["dep:futures-channel", "dep:objc2-metal", "dep:thiserror", "dep:wgpu", "dep:windows"] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] [dependencies] document-features = { workspace = true, optional = true } +futures-channel = { workspace = true, features = ["std"], optional = true } +platforms = { workspace = true, optional = true } serde_json = { workspace = true, features = ["std"], optional = true } -# The three below each name a kind of thing a registry row records: a CPU architecture, a browser, a GPU vendor. +# platforms, target-lexicon, webbrowser and wgpu-hal each name a kind of thing a registry row records. +# Those are an operating system, a CPU architecture, a browser and a GPU vendor. target-lexicon = { workspace = true, optional = true } -thiserror = { workspace = true, optional = true } +thiserror = { workspace = true, features = ["std"], optional = true } webbrowser = { workspace = true, optional = true } wgpu-hal = { workspace = true, optional = true } -# What the native test puts the probes to this machine's GPU with, on the backends `et-ws-wasi-runner` hosts. +# The backends the native feature opens a GPU through: the machine's own APIs, or in a page the browser's WebGPU. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +wgpu = { workspace = true, features = ["dx12", "metal", "std", "vulkan", "wgsl"], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wgpu = { workspace = true, features = ["std", "webgpu", "wgsl"], optional = true } + +# What the native feature reads the device's own description from, under the API it was opened through. +[target.'cfg(target_vendor = "apple")'.dependencies] +objc2-metal = { workspace = true, features = ["MTLDevice", "std"], optional = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Graphics_Dxgi", "std"], optional = true } + # The crate's dependency on itself turns the registry on for every test. [dev-dependencies] +criterion.workspace = true et-gpu-probe = { workspace = true, features = ["registry"] } futures-executor.workspace = true serde_json = { workspace = true, features = ["std"] } -wgpu = { workspace = true, features = ["dx12", "metal", "std", "vulkan", "wgsl"] } +wgpu = { workspace = true, features = ["noop"] } [lints] workspace = true diff --git a/libs/gpu-probe/README.md b/libs/gpu-probe/README.md new file mode 100644 index 00000000..8321e0cc --- /dev/null +++ b/libs/gpu-probe/README.md @@ -0,0 +1,61 @@ +# et-gpu-probe + +Numeric and fixed-function probes that tell one GPU's arithmetic from another's, the registry of every signature they +have been seen to give, and, with the `native` feature, the driver that puts them to a GPU through wgpu. + +## Benchmarks + +`benches/gpu_probe.rs` measures every operation the crate offers: building each probe set and its exact answers, the +texture formats' conversions, reading a signature through each interface, the registry, and opening the GPUs and +putting every probe set to each adapter the machine has. Record a machine's results with + +```bash +mise run bench-gpu-probe dell-precision-5570 +``` + +naming the machine as lowercase words joined by `-`. That runs the bench and writes +`benches/results/-.yaml`, the OS read from the compiler's host, since the same machine gives other +results under another OS through its other drivers. It records the machine, OS, host triple, compiler and date, and +each benchmark's mean time with its 95% confidence interval, in nanoseconds, in Bencher Metric Format. criterion's raw +samples stay under `target/`. + +### Dell Precision 5570, Linux + +An Intel Core i7-12800H (14 cores, 20 threads) with 32 GB, on Pop!_OS 24.04 and Linux 7.2.15, built by rustc 1.100.0. +Its GPUs are an NVIDIA RTX A1000 Laptop GPU on driver 595.104.02 and the Iris Xe (Alder Lake GT2) integrated in the +CPU on Mesa 26.1.6, each through Vulkan, beside Mesa's llvmpipe software renderer. Recorded on 2026-11-13, on an +otherwise idle machine. + +Putting each probe set to each adapter, through Vulkan: the mean time per call, with the 95% interval's width. + +| Probe set | NVIDIA RTX A1000 | Intel Iris Xe (ADL GT2) | llvmpipe (LLVM 20.1.2) | +| -------------------------- | ------------------ | ----------------------- | ---------------------- | +| `signature` | 1.1 ms +/- 0.13 ms | 968 us +/- 50 us | 1.5 ms +/- 0.03 ms | +| `combined_signature` | 88 us +/- 8 us | 169 us +/- 34 us | 135 us +/- 9 us | +| `clear_signature` | 313 us +/- 24 us | 612 us +/- 104 us | 1.2 ms +/- 0.04 ms | +| `fixed_function_signature` | 2.4 ms +/- 0.10 ms | 2.1 ms +/- 0.07 ms | 21.5 ms +/- 25.8 ms | +| `report` | 6.8 us +/- 0.06 us | 6.5 us +/- 0.04 us | 51.7 us +/- 0.3 us | + +Opening the default adapter takes 93 ms, and opening every adapter 824 ms; each compiles the probe set's shaders for +the device. The combined shader is the fastest way to take the numeric signature on every adapter, since it is one +pipeline rather than sixteen. llvmpipe's fixed-function set is the one wide interval: a software renderer drawing on +the CPU stalls now and then, so its mean is less telling than the rest. + +The rest runs on the CPU alone: + +| Operation | Mean per call | +| ---------------------------------------------------------------- | ------------------------------ | +| `shaders` (the sixteen WGSL probe shaders) | 4.2 us | +| `combined_shader` | 1.2 us | +| `fragment_shaders` (the GLSL ES set) | 482 ns | +| a fixed-function probe's WGSL or GLSL | 0.9 to 1.4 us | +| `reference`, `host` | 7 ns, 41 ns | +| `clear_reference`, `fixed_function_reference` | 78 ns, 147 ns | +| `Format::encode`, `decode` (the sRGB format slowest) | 4 to 37 ns | +| `Format::texel`, `red` | 18 to 20 ns, 1.3 ns | +| `Signature::fingerprint`, `results`, `traits` | 0.46 to 0.87 us | +| `Signature::describe`, `class` | 1.4 to 2.3 us, 0.43 to 0.71 us | +| `Signature::identify` (looking the signature up in the registry) | 1.8 to 2.8 us | +| `Signature::seen` (every row the registry holds for it) | 1.8 to 3.8 us | +| `every_sighting` (every row of the registry) | 31 us | +| `Sighting::read`, `read_entry` (three rows) | 152 ns, 488 ns | diff --git a/libs/gpu-probe/benches/gpu_probe.rs b/libs/gpu-probe/benches/gpu_probe.rs new file mode 100644 index 00000000..c8f8bfba --- /dev/null +++ b/libs/gpu-probe/benches/gpu_probe.rs @@ -0,0 +1,193 @@ +//! Benchmarks every operation `et-gpu-probe` offers: building the probe sets and their reference answers, the texture +//! formats, reading a signature, the registry, and putting each probe set to every GPU adapter the machine has. +//! +//! Run with `cargo bench -p et-gpu-probe --features native,registry`. The GPU group names each adapter as wgpu does, +//! so its results are this machine's own; the rest measure the CPU alone. +#![expect( + clippy::single_call_fn, + reason = "criterion calls each benchmark group once, through criterion_group!, and each helper serves one group" +)] + +use std::hint::black_box; + +use criterion::measurement::WallTime; +use criterion::{BenchmarkGroup, Criterion, criterion_group, criterion_main}; +use et_gpu_probe::native::Gpu; +use et_gpu_probe::{ + Answer, BLENDS, FILTERS, Format, GpuVendor, HOST, RASTER_FORMAT, RASTER_WIDTH, RASTERS, Sighting, Signature, + WEBGL2, WEBGPU, WEBGPU_CLEAR, WEBGPU_FIXED_FUNCTION, clear_reference, combined_shader, coverage, every_sighting, + fixed_function_reference, fragment_shaders, host, reference, shaders, +}; +use futures_executor::block_on; +use serde_json::json; + +/// Every texture format the probe sets use. +const FORMATS: [Format; 5] = [ + Format::Rgba8Unorm, + Format::Rgba8UnormSrgb, + Format::Rgb10a2Unorm, + Format::Rg11b10Ufloat, + Format::Rgba16Float, +]; + +/// Every interface a signature of the probe sets can be read through, beside the answers a device would give. +const INTERFACES: [&str; 5] = [HOST, WEBGPU, WEBGL2, WEBGPU_CLEAR, WEBGPU_FIXED_FUNCTION]; + +/// Measure `routine` as `id` in `group`; criterion keeps what it returns from being optimised away. +/// +/// An `id` of the form `function/parameter` is reported as criterion reports a parameterised benchmark. +fn measure(group: &mut BenchmarkGroup<'_, WallTime>, id: &str, mut routine: Routine) +where + Routine: FnMut() -> Output, +{ + let _chained: &mut BenchmarkGroup<'_, WallTime> = group.bench_function(id, |bench| bench.iter(&mut routine)); +} + +/// Building the shader probe set, the GPU-free answers to it, and the shaders of the sets no compiler decides. +fn probe_sets(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("probe_sets"); + measure(&mut group, "shaders", shaders); + measure(&mut group, "combined_shader", combined_shader); + measure(&mut group, "fragment_shaders", fragment_shaders); + measure(&mut group, "reference", reference); + measure(&mut group, "host", host); + measure(&mut group, "clear_reference", clear_reference); + measure(&mut group, "fixed_function_reference", fixed_function_reference); + for blend in BLENDS { + measure(&mut group, &format!("wgsl/{}", blend.name), || blend.shader()); + measure(&mut group, &format!("glsl/{}", blend.name), || blend.glsl()); + measure(&mut group, &format!("reference/{}", blend.name), || blend.reference()); + } + for filter in FILTERS { + measure(&mut group, &format!("wgsl/{}", filter.name), || filter.shader()); + measure(&mut group, &format!("glsl/{}", filter.name), || filter.glsl()); + measure(&mut group, &format!("reference/{}", filter.name), || filter.reference()); + } + for raster in RASTERS { + measure(&mut group, &format!("wgsl/{}", raster.name), || raster.shader()); + measure(&mut group, &format!("glsl/{}", raster.name), || raster.glsl()); + measure(&mut group, &format!("reference/{}", raster.name), || raster.reference()); + } + let target_bytes = RASTER_WIDTH.saturating_mul(RASTER_FORMAT.texel_bytes()); + let texels = vec![0xff_u8; usize::try_from(target_bytes).unwrap_or(0)]; + measure(&mut group, "coverage", || coverage(black_box(&texels))); + group.finish(); +} + +/// Converting a value to and from each format's code, and reading a code out of a texel. +fn formats(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("formats"); + for format in FORMATS { + let name = format.name(); + let code = format.encode(0.5_f64); + let first_word: [u8; 4] = format.texel(code).first_chunk().copied().unwrap_or_default(); + measure(&mut group, &format!("encode/{name}"), || { + format.encode(black_box(0.3_f64)) + }); + measure(&mut group, &format!("decode/{name}"), || format.decode(black_box(code))); + measure(&mut group, &format!("texel/{name}"), || format.texel(black_box(code))); + measure(&mut group, &format!("red/{name}"), || format.red(black_box(first_word))); + } + group.finish(); +} + +/// A signature through `interface` answering each probe as a device computing it exactly would. +fn answered(interface: &'static str) -> Signature { + let answers = match interface { + HOST => host().to_vec(), + WEBGPU_CLEAR => clear_reference(), + WEBGPU_FIXED_FUNCTION => fixed_function_reference(), + _ => reference().to_vec(), + }; + Signature::new(interface, answers.into_iter().map(Answer::Bits).collect()) +} + +/// Reading a signature through each interface, and finding it in the registry. +/// +/// That is its words, fingerprint, traits and description, then the registry entries and rows that hold it. +fn signatures(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("signatures"); + for interface in INTERFACES { + let signature = answered(interface); + measure(&mut group, &format!("results/{interface}"), || signature.results()); + measure(&mut group, &format!("fingerprint/{interface}"), || { + signature.fingerprint() + }); + measure(&mut group, &format!("completed/{interface}"), || signature.completed()); + measure(&mut group, &format!("deviations/{interface}"), || { + signature.deviations() + }); + measure(&mut group, &format!("traits/{interface}"), || signature.traits()); + measure(&mut group, &format!("describe/{interface}"), || signature.describe()); + measure(&mut group, &format!("class/{interface}"), || signature.class()); + measure(&mut group, &format!("identify/{interface}"), || signature.identify()); + measure(&mut group, &format!("seen/{interface}"), || signature.seen()); + } + group.finish(); +} + +/// Reading the registry's rows, and naming GPU vendors. +/// +/// The row read is laid out as the registry's own are, with the free-text fields a row carries beside the fixed ones, +/// and the entry holds three of it, as an entry seen in a few places does. +fn registry(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("registry"); + let row = json!({ + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal", + }); + let entry = json!({ "interface": WEBGPU, "label": "A GPU", "seen": [row, row, row] }); + measure(&mut group, "every_sighting", every_sighting); + measure(&mut group, "sighting_read", || Sighting::read(black_box(&row))); + measure(&mut group, "sighting_read_entry", || { + Sighting::read_entry(black_box(&entry)) + }); + if let Ok(sighting) = Sighting::read(&row) { + measure(&mut group, "sighting_is_here", || sighting.is_here()); + } + measure(&mut group, "gpu_vendor_from_id", || { + GpuVendor::from_id(black_box(0x10de)) + }); + measure(&mut group, "gpu_vendor_from_device_name", || { + GpuVendor::from_device_name(black_box("Apple M5 Pro")) + }); + measure(&mut group, "gpu_vendor_name", || black_box(GpuVendor::Nvidia).name()); + group.finish(); +} + +/// Opening the GPUs, and putting every probe set to each adapter this machine has. +/// +/// A GPU answers in milliseconds where the CPU groups take nanoseconds, so this group takes fewer samples. Each adapter +/// is named as wgpu names it, beside the API it was opened through; one whose device does not open is left out, as +/// `Gpu::open_all` reports it. +fn gpus(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("gpus"); + let _sampled: &mut BenchmarkGroup<'_, WallTime> = group.sample_size(10); + measure(&mut group, "open", || block_on(Gpu::open())); + measure(&mut group, "open_all", || block_on(Gpu::open_all())); + for gpu in block_on(Gpu::open_all()).into_iter().flatten() { + let name = format!("{} ({})", gpu.info().name, gpu.backend()); + measure(&mut group, &format!("signature/{name}"), || block_on(gpu.signature())); + measure(&mut group, &format!("combined_signature/{name}"), || { + block_on(gpu.combined_signature()) + }); + measure(&mut group, &format!("clear_signature/{name}"), || { + block_on(gpu.clear_signature()) + }); + measure(&mut group, &format!("fixed_function_signature/{name}"), || { + block_on(gpu.fixed_function_signature()) + }); + measure(&mut group, &format!("report/{name}"), || gpu.report()); + } + group.finish(); +} + +criterion_group!(benches, probe_sets, formats, signatures, registry, gpus); +criterion_main!(benches); diff --git a/libs/gpu-probe/benches/results/dell-precision-5570-linux.yaml b/libs/gpu-probe/benches/results/dell-precision-5570-linux.yaml new file mode 100644 index 00000000..42950be9 --- /dev/null +++ b/libs/gpu-probe/benches/results/dell-precision-5570-linux.yaml @@ -0,0 +1,627 @@ +machine: + id: dell-precision-5570 + os: linux + host: x86_64-unknown-linux-gnu + rustc: rustc 1.100.0 (0ef1bc386 2026-11-09) + recorded: "2026-11-13" +benchmarks: + formats/decode/rg11b10ufloat: + latency: + value: 6.11 + lower_value: 6.05 + upper_value: 6.17 + formats/decode/rgb10a2unorm: + latency: + value: 4.81 + lower_value: 4.78 + upper_value: 4.85 + formats/decode/rgba16float: + latency: + value: 7.83 + lower_value: 7.78 + upper_value: 7.87 + formats/decode/rgba8unorm: + latency: + value: 4.67 + lower_value: 4.66 + upper_value: 4.69 + formats/decode/rgba8unorm-srgb: + latency: + value: 37.03 + lower_value: 36.04 + upper_value: 38.09 + formats/encode/rg11b10ufloat: + latency: + value: 10.09 + lower_value: 10 + upper_value: 10.18 + formats/encode/rgb10a2unorm: + latency: + value: 4.5 + lower_value: 4.5 + upper_value: 4.51 + formats/encode/rgba16float: + latency: + value: 9.24 + lower_value: 9.17 + upper_value: 9.3 + formats/encode/rgba8unorm: + latency: + value: 4.06 + lower_value: 4.06 + upper_value: 4.07 + formats/encode/rgba8unorm-srgb: + latency: + value: 16.93 + lower_value: 16.81 + upper_value: 17.05 + formats/red/rg11b10ufloat: + latency: + value: 1.33 + lower_value: 1.32 + upper_value: 1.33 + formats/red/rgb10a2unorm: + latency: + value: 1.33 + lower_value: 1.33 + upper_value: 1.34 + formats/red/rgba16float: + latency: + value: 1.31 + lower_value: 1.3 + upper_value: 1.32 + formats/red/rgba8unorm: + latency: + value: 1.27 + lower_value: 1.27 + upper_value: 1.28 + formats/red/rgba8unorm-srgb: + latency: + value: 1.32 + lower_value: 1.32 + upper_value: 1.32 + formats/texel/rg11b10ufloat: + latency: + value: 18.59 + lower_value: 18.56 + upper_value: 18.63 + formats/texel/rgb10a2unorm: + latency: + value: 20.21 + lower_value: 19.79 + upper_value: 20.69 + formats/texel/rgba16float: + latency: + value: 18.8 + lower_value: 18.61 + upper_value: 19.05 + formats/texel/rgba8unorm: + latency: + value: 17.52 + lower_value: 17.46 + upper_value: 17.57 + formats/texel/rgba8unorm-srgb: + latency: + value: 17.86 + lower_value: 17.77 + upper_value: 17.95 + gpus/clear_signature/Intel(R) Iris(R) Xe Graphics (ADL GT2) (vulkan): + latency: + value: 611850.74 + lower_value: 561449.54 + upper_value: 665372.52 + gpus/clear_signature/llvmpipe (LLVM 20.1.2, 256 bits) (vulkan): + latency: + value: 1.2123983e+06 + lower_value: 1.19332079e+06 + upper_value: 1.23699303e+06 + gpus/clear_signature/NVIDIA RTX A1000 Laptop GPU (vulkan): + latency: + value: 312699.55 + lower_value: 301200.73 + upper_value: 324996.36 + gpus/combined_signature/Intel(R) Iris(R) Xe Graphics (ADL GT2) (vulkan): + latency: + value: 169467.85 + lower_value: 153441.91 + upper_value: 187604.16 + gpus/combined_signature/llvmpipe (LLVM 20.1.2, 256 bits) (vulkan): + latency: + value: 134640.07 + lower_value: 130513.16 + upper_value: 139264.9 + gpus/combined_signature/NVIDIA RTX A1000 Laptop GPU (vulkan): + latency: + value: 88129.87 + lower_value: 84353.1 + upper_value: 92109.93 + gpus/fixed_function_signature/Intel(R) Iris(R) Xe Graphics (ADL GT2) (vulkan): + latency: + value: 2.12343517e+06 + lower_value: 2.08548311e+06 + upper_value: 2.15816112e+06 + gpus/fixed_function_signature/llvmpipe (LLVM 20.1.2, 256 bits) (vulkan): + latency: + value: 2.154024817e+07 + lower_value: 9.07872458e+06 + upper_value: 3.484113619e+07 + gpus/fixed_function_signature/NVIDIA RTX A1000 Laptop GPU (vulkan): + latency: + value: 2.37320995e+06 + lower_value: 2.32320906e+06 + upper_value: 2.42194937e+06 + gpus/open_all: + latency: + value: 8.243174755e+08 + lower_value: 8.0455427656e+08 + upper_value: 8.3770853467e+08 + gpus/open: + latency: + value: 9.339192468e+07 + lower_value: 9.316124771e+07 + upper_value: 9.365202365e+07 + gpus/report/Intel(R) Iris(R) Xe Graphics (ADL GT2) (vulkan): + latency: + value: 6504.05 + lower_value: 6483.14 + upper_value: 6527.59 + gpus/report/llvmpipe (LLVM 20.1.2, 256 bits) (vulkan): + latency: + value: 51735.61 + lower_value: 51609.05 + upper_value: 51903.19 + gpus/report/NVIDIA RTX A1000 Laptop GPU (vulkan): + latency: + value: 6776.8 + lower_value: 6751.31 + upper_value: 6807.92 + gpus/signature/Intel(R) Iris(R) Xe Graphics (ADL GT2) (vulkan): + latency: + value: 968113.92 + lower_value: 944212.38 + upper_value: 994103.05 + gpus/signature/llvmpipe (LLVM 20.1.2, 256 bits) (vulkan): + latency: + value: 1.45783961e+06 + lower_value: 1.44304575e+06 + upper_value: 1.47757824e+06 + gpus/signature/NVIDIA RTX A1000 Laptop GPU (vulkan): + latency: + value: 1.06159489e+06 + lower_value: 999723.75 + upper_value: 1.12493136e+06 + probe_sets/clear_reference: + latency: + value: 77.59 + lower_value: 77.47 + upper_value: 77.72 + probe_sets/combined_shader: + latency: + value: 1169.36 + lower_value: 1167.76 + upper_value: 1171.12 + probe_sets/coverage: + latency: + value: 4.04 + lower_value: 4.02 + upper_value: 4.06 + probe_sets/fixed_function_reference: + latency: + value: 146.48 + lower_value: 146.32 + upper_value: 146.65 + probe_sets/fragment_shaders: + latency: + value: 481.66 + lower_value: 480.98 + upper_value: 482.38 + probe_sets/glsl/blend_half_float: + latency: + value: 941.72 + lower_value: 939.41 + upper_value: 944.23 + probe_sets/glsl/blend_multiply: + latency: + value: 1048.49 + lower_value: 1046.52 + upper_value: 1050.62 + probe_sets/glsl/blend_srgb: + latency: + value: 1198.01 + lower_value: 1193.16 + upper_value: 1202.85 + probe_sets/glsl/filter_fine: + latency: + value: 1190.12 + lower_value: 1183.71 + upper_value: 1196.79 + probe_sets/glsl/filter_srgb: + latency: + value: 1088.05 + lower_value: 1085.33 + upper_value: 1090.69 + probe_sets/glsl/filter_third: + latency: + value: 1168.02 + lower_value: 1142.82 + upper_value: 1197.63 + probe_sets/glsl/raster_left_edge_fine: + latency: + value: 1059.13 + lower_value: 1039.45 + upper_value: 1080.13 + probe_sets/glsl/raster_right_edge_coarse: + latency: + value: 1017.06 + lower_value: 1014.5 + upper_value: 1019.72 + probe_sets/glsl/raster_right_edge_fine: + latency: + value: 984.89 + lower_value: 982.8 + upper_value: 986.91 + probe_sets/host: + latency: + value: 40.8 + lower_value: 40.51 + upper_value: 41.13 + probe_sets/reference/blend_half_float: + latency: + value: 15.01 + lower_value: 14.89 + upper_value: 15.14 + probe_sets/reference/blend_multiply: + latency: + value: 11.43 + lower_value: 11.38 + upper_value: 11.49 + probe_sets/reference/blend_srgb: + latency: + value: 68.8 + lower_value: 68.48 + upper_value: 69.13 + probe_sets/reference/filter_fine: + latency: + value: 12.69 + lower_value: 12.64 + upper_value: 12.74 + probe_sets/reference/filter_srgb: + latency: + value: 21.87 + lower_value: 21.77 + upper_value: 21.99 + probe_sets/reference/filter_third: + latency: + value: 12.65 + lower_value: 12.44 + upper_value: 12.88 + probe_sets/reference: + latency: + value: 7.1 + lower_value: 7.06 + upper_value: 7.14 + probe_sets/reference/raster_left_edge_fine: + latency: + value: 1.92 + lower_value: 1.92 + upper_value: 1.93 + probe_sets/reference/raster_right_edge_coarse: + latency: + value: 1.93 + lower_value: 1.92 + upper_value: 1.93 + probe_sets/reference/raster_right_edge_fine: + latency: + value: 1.92 + lower_value: 1.92 + upper_value: 1.93 + probe_sets/shaders: + latency: + value: 4178.99 + lower_value: 4172.79 + upper_value: 4185.45 + probe_sets/wgsl/blend_half_float: + latency: + value: 1056.83 + lower_value: 1046.41 + upper_value: 1067.64 + probe_sets/wgsl/blend_multiply: + latency: + value: 1077.43 + lower_value: 1074.6 + upper_value: 1080.55 + probe_sets/wgsl/blend_srgb: + latency: + value: 1384.65 + lower_value: 1354.61 + upper_value: 1418.85 + probe_sets/wgsl/filter_fine: + latency: + value: 1395.03 + lower_value: 1379.58 + upper_value: 1411.39 + probe_sets/wgsl/filter_srgb: + latency: + value: 1311.16 + lower_value: 1307.42 + upper_value: 1314.81 + probe_sets/wgsl/filter_third: + latency: + value: 1312.18 + lower_value: 1302.35 + upper_value: 1323.04 + probe_sets/wgsl/raster_left_edge_fine: + latency: + value: 919.03 + lower_value: 916.66 + upper_value: 921.35 + probe_sets/wgsl/raster_right_edge_coarse: + latency: + value: 935.2 + lower_value: 932.62 + upper_value: 937.91 + probe_sets/wgsl/raster_right_edge_fine: + latency: + value: 914.52 + lower_value: 911.52 + upper_value: 917.5 + registry/every_sighting: + latency: + value: 31173.59 + lower_value: 31093.43 + upper_value: 31256.51 + registry/gpu_vendor_from_device_name: + latency: + value: 41.17 + lower_value: 41.07 + upper_value: 41.27 + registry/gpu_vendor_from_id: + latency: + value: 1.93 + lower_value: 1.92 + upper_value: 1.95 + registry/gpu_vendor_name: + latency: + value: 1.52 + lower_value: 1.51 + upper_value: 1.52 + registry/sighting_is_here: + latency: + value: 25.56 + lower_value: 25.44 + upper_value: 25.69 + registry/sighting_read_entry: + latency: + value: 488.15 + lower_value: 486.46 + upper_value: 489.95 + registry/sighting_read: + latency: + value: 151.93 + lower_value: 151.73 + upper_value: 152.13 + signatures/class/host: + latency: + value: 702.9 + lower_value: 701.15 + upper_value: 704.7 + signatures/class/webgl2: + latency: + value: 700.29 + lower_value: 698.84 + upper_value: 701.81 + signatures/class/webgpu-clear: + latency: + value: 553.34 + lower_value: 552.12 + upper_value: 554.58 + signatures/class/webgpu-fixed-function: + latency: + value: 430.17 + lower_value: 427.86 + upper_value: 433.44 + signatures/class/webgpu: + latency: + value: 709.1 + lower_value: 707.48 + upper_value: 710.78 + signatures/completed/host: + latency: + value: 5.7 + lower_value: 5.68 + upper_value: 5.72 + signatures/completed/webgl2: + latency: + value: 5.81 + lower_value: 5.74 + upper_value: 5.89 + signatures/completed/webgpu-clear: + latency: + value: 4.11 + lower_value: 4.11 + upper_value: 4.12 + signatures/completed/webgpu-fixed-function: + latency: + value: 3.48 + lower_value: 3.47 + upper_value: 3.48 + signatures/completed/webgpu: + latency: + value: 5.66 + lower_value: 5.64 + upper_value: 5.69 + signatures/describe/host: + latency: + value: 2262.2 + lower_value: 2211.61 + upper_value: 2315.77 + signatures/describe/webgl2: + latency: + value: 1952.16 + lower_value: 1947 + upper_value: 1957.8 + signatures/describe/webgpu-clear: + latency: + value: 1575.35 + lower_value: 1565.05 + upper_value: 1584.49 + signatures/describe/webgpu-fixed-function: + latency: + value: 1402.17 + lower_value: 1398.14 + upper_value: 1406.5 + signatures/describe/webgpu: + latency: + value: 2088.71 + lower_value: 2084.39 + upper_value: 2093.41 + signatures/deviations/host: + latency: + value: 49.08 + lower_value: 48.32 + upper_value: 49.98 + signatures/deviations/webgl2: + latency: + value: 48.18 + lower_value: 47.62 + upper_value: 48.77 + signatures/deviations/webgpu-clear: + latency: + value: 106.72 + lower_value: 106.46 + upper_value: 106.98 + signatures/deviations/webgpu-fixed-function: + latency: + value: 192.41 + lower_value: 190.77 + upper_value: 194.04 + signatures/deviations/webgpu: + latency: + value: 45.46 + lower_value: 45.37 + upper_value: 45.55 + signatures/fingerprint/host: + latency: + value: 790.7 + lower_value: 787.23 + upper_value: 794.51 + signatures/fingerprint/webgl2: + latency: + value: 874.23 + lower_value: 849.1 + upper_value: 901.12 + signatures/fingerprint/webgpu-clear: + latency: + value: 601.52 + lower_value: 600.14 + upper_value: 603.12 + signatures/fingerprint/webgpu-fixed-function: + latency: + value: 467.1 + lower_value: 463.76 + upper_value: 470.57 + signatures/fingerprint/webgpu: + latency: + value: 721.25 + lower_value: 714.3 + upper_value: 728.45 + signatures/identify/host: + latency: + value: 2140.13 + lower_value: 2132.6 + upper_value: 2148.1 + signatures/identify/webgl2: + latency: + value: 2298.33 + lower_value: 2278.26 + upper_value: 2319.44 + signatures/identify/webgpu-clear: + latency: + value: 2373.18 + lower_value: 2369.24 + upper_value: 2377.32 + signatures/identify/webgpu-fixed-function: + latency: + value: 1792.66 + lower_value: 1788.77 + upper_value: 1796.85 + signatures/identify/webgpu: + latency: + value: 2821.47 + lower_value: 2804.81 + upper_value: 2840.61 + signatures/results/host: + latency: + value: 820.26 + lower_value: 815.21 + upper_value: 825.54 + signatures/results/webgl2: + latency: + value: 743.57 + lower_value: 740.59 + upper_value: 746.52 + signatures/results/webgpu-clear: + latency: + value: 729.58 + lower_value: 725 + upper_value: 734.68 + signatures/results/webgpu-fixed-function: + latency: + value: 678.78 + lower_value: 674.62 + upper_value: 682.42 + signatures/results/webgpu: + latency: + value: 780.74 + lower_value: 778.82 + upper_value: 782.84 + signatures/seen/host: + latency: + value: 3842.81 + lower_value: 3787.12 + upper_value: 3906.34 + signatures/seen/webgl2: + latency: + value: 2598.8 + lower_value: 2571.28 + upper_value: 2627.98 + signatures/seen/webgpu-clear: + latency: + value: 2397.27 + lower_value: 2392.66 + upper_value: 2403.18 + signatures/seen/webgpu-fixed-function: + latency: + value: 1810.25 + lower_value: 1806.07 + upper_value: 1814.78 + signatures/seen/webgpu: + latency: + value: 2829.43 + lower_value: 2821.97 + upper_value: 2838.58 + signatures/traits/host: + latency: + value: 474.51 + lower_value: 472.97 + upper_value: 476.11 + signatures/traits/webgl2: + latency: + value: 479.19 + lower_value: 473.39 + upper_value: 486.04 + signatures/traits/webgpu-clear: + latency: + value: 618.35 + lower_value: 616.93 + upper_value: 620.07 + signatures/traits/webgpu-fixed-function: + latency: + value: 532.56 + lower_value: 530.18 + upper_value: 535.06 + signatures/traits/webgpu: + latency: + value: 462.41 + lower_value: 461.55 + upper_value: 463.3 diff --git a/libs/gpu-probe/signatures.json b/libs/gpu-probe/signatures.json index 35c69b4d..53958d65 100644 --- a/libs/gpu-probe/signatures.json +++ b/libs/gpu-probe/signatures.json @@ -197,6 +197,51 @@ "backend": "opengl", "device": "Dell Precision 5570", "window": "normal" + }, + { + "browser": "chromium", + "browser_version": "Claude 5.11492.0 (Chromium 156.0.8078.75)", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl", + "device": "Dell Precision 5570", + "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" } ], "results": { @@ -230,7 +275,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "brave", @@ -240,7 +285,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" } ], "results": { @@ -274,7 +319,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "safari", @@ -284,7 +329,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "brave", @@ -294,7 +339,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "firefox", @@ -304,7 +349,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" } ], "results": { @@ -372,7 +417,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "deno", @@ -384,7 +429,7 @@ "gpu_version": "Apple M5 Pro GPU", "backend": "metal", "runtime": "et-ws-web-runner 0.3.0", - "window": "none", + "window": "normal", "date": "2026-11-12" }, { @@ -786,6 +831,17 @@ "backend": "vulkan", "device": "Dell Precision 5570", "window": "normal" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "google", + "gpu_version": "SwiftShader (software renderer)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { @@ -955,7 +1011,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "unknown", - "window": "normal" + "window": "both" } ], "results": { @@ -1001,7 +1057,7 @@ }, { "interface": "canvas", - "label": "Firefox 149.0 on Linux -- canvas 2D", + "label": "Firefox 149.0 and 160.0 on Linux -- canvas 2D, unchanged by privacy.fingerprintingProtection", "seen": [ { "browser": "firefox", @@ -1011,6 +1067,27 @@ "gpu": "unknown", "backend": "unknown", "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" } ], "results": { @@ -1487,6 +1564,17 @@ "backend": "vulkan", "device": "Dell Precision 5570", "window": "normal" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "google", + "gpu_version": "SwiftShader (software renderer)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { @@ -1520,7 +1608,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" } ], "results": { @@ -1554,7 +1642,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" } ], "results": { @@ -1589,7 +1677,7 @@ "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "normal" + "window": "both" }, { "browser": "none", @@ -1793,160 +1881,205 @@ } }, { - "interface": "webgpu-split", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Chromium and Brave", + "interface": "canvas", + "label": "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)", "seen": [ { - "browser": "chromium", + "browser": "firefox", + "browser_version": "160", "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" - }, + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.fingerprintingProtection on", + "window": "both" + } + ], + "results": { + "lane0": "a60e6953", + "lane1": "e2f45f75", + "lane2": "956014b5", + "lane3": "1b2515f8" + } + }, + { + "interface": "canvas-browserleaks", + "label": "Firefox 160 on macOS, fingerprintingProtection on -- BrowserLeaks test (one session's value; see next row)", + "seen": [ { - "browser": "brave", + "browser": "firefox", + "browser_version": "160", "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" } ], "results": { - "multiply_add": "33800000", - "fma": "33800000", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00000000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "3dcc1ebd", - "dot": "00000000" + "md5": "67456dde9d4089a8cea2e53d4ecf4b8b" } }, { - "interface": "webgpu-combined", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Chromium", + "interface": "canvas-browserleaks", + "label": "Firefox 160 on macOS, privacy.fingerprintingProtection on", "seen": [ { - "browser": "chromium", + "browser": "firefox", + "browser_version": "160", "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" + }, + { + "interface": "canvas-browserleaks", + "label": "Brave 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test (noise added to reads)", + "seen": [ + { + "browser": "brave", + "browser_version": "157.0.0", + "os": "macos", + "os_version": "26.7.1", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "Shields farbling: noise added to reads", + "window": "normal" } ], "results": { - "multiply_add": "33800000", - "fma": "33800000", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00000000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "3dcc1ebd", - "dot": "00000000" + "md5": "ead7c33e84beeb434b1d384c6e45268b" } }, { - "interface": "webgl2-split", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGL 2 on Chromium, Safari, Brave and Firefox", + "interface": "canvas-browserleaks", + "label": "Firefox 160.0.0 on macOS -- canvas 2D, BrowserLeaks test", "seen": [ { - "browser": "chromium", + "browser": "firefox", + "browser_version": "160.0.0", "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", "window": "private" - }, + } + ], + "results": { + "md5": "4a05912a83d4fdbab55e5ddfaef2d3b0" + } + }, + { + "interface": "webgpu", + "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "seen": [ { - "browser": "safari", + "browser": "librewolf", + "browser_version": "reports itself as Firefox 160.0.0", "os": "macos", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" - }, + "backend": "unknown", + "window": "normal" + } + ], + "blocked": true, + "reason": "WebGPU is unavailable (navigator.gpu is absent; the browser does not expose it)" + }, + { + "interface": "webgl2", + "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "seen": [ { - "browser": "brave", + "browser": "librewolf", + "browser_version": "reports itself as Firefox 160.0.0", "os": "macos", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" - }, + "backend": "unknown", + "window": "normal" + } + ], + "blocked": true, + "reason": "WebGL 2 is unavailable (getContext returned null; no WebGL context at all, so WebGL is turned off)" + }, + { + "interface": "canvas", + "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "seen": [ { - "browser": "firefox", + "browser": "librewolf", + "browser_version": "reports itself as Firefox 160.0.0", "os": "macos", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" + "backend": "unknown", + "window": "normal" } ], - "results": { - "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00000000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "3dcc1ebc", - "dot": "00000000" - } + "blocked": true, + "reason": "randomised: two reads of the same scene differed (per-read noise)" }, { - "interface": "webgl2-combined", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGL 2 on Chromium", + "interface": "canvas-browserleaks", + "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", "seen": [ { - "browser": "chromium", + "browser": "librewolf", + "browser_version": "reports itself as Firefox 160.0.0", + "os": "macos", + "cpu_arch": "aarch64", + "cpu_version": "Apple M5 Pro", + "gpu": "apple", + "gpu_version": "Apple M5 Pro GPU", + "backend": "unknown", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same scene differed (per-read noise)" + }, + { + "interface": "webgl1-split", + "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)", + "seen": [ + { + "browser": "chrome", + "browser_version": "157.0.0", "os": "macos", + "os_version": "26.7.1", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "private" + "window": "both" + }, + { + "browser": "safari", + "browser_version": "27.0", + "os": "macos", + "os_version": "26.7.1", + "cpu_arch": "aarch64", + "cpu_version": "Apple M5 Pro", + "gpu": "apple", + "gpu_version": "Apple M5 Pro GPU", + "backend": "metal", + "window": "normal" } ], "results": { @@ -1964,73 +2097,62 @@ "log": "3f8c9f54", "pow": "3f8edd1f", "atan2": "3d087b99", - "tanh": "3dcc1ebc", + "tanh": "????????", "dot": "00000000" } }, { - "interface": "canvas", - "label": "Apple M5 Pro, macOS -- canvas 2D on Chromium", - "seen": [ - { - "browser": "chromium", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "unknown", - "window": "private" - } - ], - "results": { - "lane0": "4e31fa9b", - "lane1": "76b62595", - "lane2": "5debc5c0", - "lane3": "52ef3664" - } - }, - { - "interface": "canvas", - "label": "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)", + "interface": "canvas-browserleaks", + "label": "Chrome 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test", "seen": [ { - "browser": "firefox", - "browser_version": "160", + "browser": "chrome", + "browser_version": "157.0.0", "os": "macos", + "os_version": "26.7.1", "cpu_arch": "unknown", "gpu": "unknown", "backend": "unknown", - "defence": "privacy.fingerprintingProtection on", "window": "normal" } ], "results": { - "lane0": "a60e6953", - "lane1": "e2f45f75", - "lane2": "956014b5", - "lane3": "1b2515f8" + "md5": "1dc44a8e2846f0a156399977bf5ca67d" } }, { - "interface": "webgpu-split", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Firefox", + "interface": "webgl1-combined", + "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)", "seen": [ { - "browser": "firefox", + "browser": "chrome", + "browser_version": "157.0.0", "os": "macos", + "os_version": "26.7.1", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "private" + "window": "both" + }, + { + "browser": "safari", + "browser_version": "27.0", + "os": "macos", + "os_version": "26.7.1", + "cpu_arch": "aarch64", + "cpu_version": "Apple M5 Pro", + "gpu": "apple", + "gpu_version": "Apple M5 Pro GPU", + "backend": "metal", + "window": "normal" } ], "results": { "multiply_add": "33800000", - "fma": "33800000", - "swamped_sum": "00000000", + "fma": "????????", + "swamped_sum": "40800000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", @@ -2042,46 +2164,40 @@ "log": "3f8c9f54", "pow": "3f8edd1f", "atan2": "3d087b99", - "tanh": "3dcc1ebd", + "tanh": "????????", "dot": "00000000" } }, { - "interface": "canvas", - "label": "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)", + "interface": "webgl1", + "label": "Firefox 160.0.0 on macOS", "seen": [ { "browser": "firefox", - "browser_version": "160", + "browser_version": "160.0.0", "os": "macos", "cpu_arch": "unknown", "gpu": "unknown", "backend": "unknown", - "defence": "privacy.fingerprintingProtection on", - "window": "private" + "window": "normal" } ], - "results": { - "lane0": "a60e6953", - "lane1": "e2f45f75", - "lane2": "956014b5", - "lane3": "1b2515f8" - } + "blocked": true, + "reason": "WebGL 1 is unavailable (getContext returned null, so WebGL is turned off)" }, { "interface": "webgpu-combined", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Firefox 160", + "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Safari", "seen": [ { - "browser": "firefox", - "browser_version": "160", + "browser": "safari", "os": "macos", "cpu_arch": "aarch64", "cpu_version": "Apple M5 Pro", "gpu": "apple", "gpu_version": "Apple M5 Pro GPU", "backend": "metal", - "window": "private" + "window": "normal" } ], "results": { @@ -2100,469 +2216,596 @@ "pow": "3f8edd1f", "atan2": "3d087b99", "tanh": "3dcc1ebd", - "dot": "00000000" + "dot": "40000000" } }, { "interface": "canvas-browserleaks", - "label": "Firefox 160 on macOS, fingerprintingProtection on -- BrowserLeaks test (one session's value; see next row)", + "label": "Safari 27.0.0 on macOS -- canvas 2D, BrowserLeaks test", "seen": [ { - "browser": "firefox", - "browser_version": "160", + "browser": "safari", + "browser_version": "27.0.0", "os": "macos", "cpu_arch": "unknown", "gpu": "unknown", "backend": "unknown", - "defence": "privacy.fingerprintingProtection on", "window": "normal" } ], "results": { - "md5": "67456dde9d4089a8cea2e53d4ecf4b8b" + "md5": "f7316747f9adf8387510e46790f09d66" } }, { - "interface": "canvas-browserleaks", - "label": "Firefox 160 on macOS, privacy.fingerprintingProtection on", + "interface": "webgpu-combined", + "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Edge 153", "seen": [ { - "browser": "firefox", - "browser_version": "160", - "os": "macos", - "cpu_arch": "unknown", - "gpu": "unknown", + "browser": "edge", + "browser_version": "153", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Xe-LPG", "backend": "unknown", - "defence": "privacy.fingerprintingProtection on", + "device": "HP EliteBook (HSN-I6IC)", "window": "normal" } ], - "blocked": true, - "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" + "results": { + "multiply_add": "00000000", + "fma": "00000000", + "swamped_sum": "00000000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087708", + "tanh": "3dcc1eb6", + "dot": "00000000" + } }, { - "interface": "canvas-browserleaks", - "label": "Brave 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test (noise added to reads)", + "interface": "webgl2-combined", + "label": "Intel Xe-LPG through Direct3D 11 on Windows -- WebGL 2 on Chrome", "seen": [ { - "browser": "brave", - "browser_version": "157.0.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "unknown", - "gpu": "unknown", - "backend": "unknown", - "defence": "Shields farbling: noise added to reads", + "browser": "chrome", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Xe-LPG", + "backend": "d3d11", "window": "normal" } ], "results": { - "md5": "ead7c33e84beeb434b1d384c6e45268b" + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087708", + "tanh": "3dcc1eb6", + "dot": "00000000" } }, { - "interface": "canvas-browserleaks", - "label": "Firefox 160.0.0 on macOS -- canvas 2D, BrowserLeaks test", + "interface": "webgl1-split", + "label": "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153", "seen": [ { - "browser": "firefox", - "browser_version": "160.0.0", - "os": "macos", - "cpu_arch": "unknown", - "gpu": "unknown", - "backend": "unknown", - "window": "private" + "browser": "edge", + "browser_version": "153", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Arc Graphics", + "backend": "d3d11", + "device": "HP EliteBook (HSN-I6IC)", + "window": "normal" } ], "results": { - "md5": "4a05912a83d4fdbab55e5ddfaef2d3b0" + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087708", + "tanh": "????????", + "dot": "00000000" } }, { - "interface": "webgpu", - "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "interface": "webgl1-combined", + "label": "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153", "seen": [ { - "browser": "librewolf", - "browser_version": "reports itself as Firefox 160.0.0", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", + "browser": "edge", + "browser_version": "153", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Arc Graphics", + "backend": "d3d11", + "device": "HP EliteBook (HSN-I6IC)", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087708", + "tanh": "????????", + "dot": "00000000" + } + }, + { + "interface": "canvas-browserleaks", + "label": "HP EliteBook (HSN-I6IC), Windows -- canvas 2D, BrowserLeaks test on Edge 153", + "seen": [ + { + "browser": "edge", + "browser_version": "153", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", "backend": "unknown", + "device": "HP EliteBook (HSN-I6IC)", "window": "normal" } ], - "blocked": true, - "reason": "WebGPU is unavailable (navigator.gpu is absent; the browser does not expose it)" + "results": { + "md5": "8c781e6c79d33a68ef6ae90ae071c989" + } }, { - "interface": "webgl2", - "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "interface": "webgpu-split", + "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)", "seen": [ { - "browser": "librewolf", - "browser_version": "reports itself as Firefox 160.0.0", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", + "browser": "firefox", + "browser_version": "155", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Xe-LPG", "backend": "unknown", + "device": "HP EliteBook (HSN-I6IC)", + "reported": "adapter info blanked", "window": "normal" } ], - "blocked": true, - "reason": "WebGL 2 is unavailable (getContext returned null; no WebGL context at all, so WebGL is turned off)" + "results": { + "multiply_add": "33800000", + "fma": "33800000", + "swamped_sum": "00000000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087708", + "tanh": "3dcc1eb6", + "dot": "00000000" + } }, { "interface": "canvas", - "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "label": "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)", "seen": [ { - "browser": "librewolf", - "browser_version": "reports itself as Firefox 160.0.0", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", + "browser": "firefox", + "browser_version": "155.0.0", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", "backend": "unknown", + "device": "HP EliteBook (HSN-I6IC)", "window": "normal" } ], "blocked": true, - "reason": "randomised: two reads of the same scene differed (per-read noise)" + "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" }, { "interface": "canvas-browserleaks", - "label": "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro", + "label": "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)", "seen": [ { - "browser": "librewolf", - "browser_version": "reports itself as Firefox 160.0.0", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", + "browser": "firefox", + "browser_version": "155.0.0", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", "backend": "unknown", + "device": "HP EliteBook (HSN-I6IC)", "window": "normal" } ], "blocked": true, - "reason": "randomised: two reads of the same scene differed (per-read noise)" + "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" }, { - "interface": "webgl1-split", - "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)", + "interface": "webgpu-combined", + "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)", "seen": [ { - "browser": "chrome", - "browser_version": "157.0.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "normal" - }, - { - "browser": "safari", - "browser_version": "27.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", + "browser": "firefox", + "browser_version": "155", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Xe-LPG", + "backend": "unknown", + "device": "HP EliteBook (HSN-I6IC)", + "reported": "adapter info blanked", "window": "normal" } ], "results": { "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", + "fma": "33800000", + "swamped_sum": "00000000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", - "exp": "3f8d763e", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "????????", + "atan2": "3d087708", + "tanh": "3dcc1eb6", "dot": "00000000" } }, { - "interface": "canvas-browserleaks", - "label": "Chrome 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test", + "interface": "webgpu-split", + "label": "Apple Paravirtual device through Metal on macOS arm64, GitHub macos-latest runner -- wgpu 29, no browser", "seen": [ { - "browser": "chrome", - "browser_version": "157.0.0", + "browser": "none", "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "unknown", - "gpu": "unknown", - "backend": "unknown", - "window": "normal" + "cpu_arch": "aarch64", + "gpu": "apple", + "gpu_version": "Apple Paravirtual device", + "backend": "metal", + "device": "GitHub macos-latest runner", + "runtime": "wgpu 29", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" } ], "results": { - "md5": "1dc44a8e2846f0a156399977bf5ca67d" + "multiply_add": "33800000", + "fma": "33800000", + "swamped_sum": "00000000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc7576", + "cos": "3f7eb896", + "tan": "3dcd7c46", + "exp": "3f8d763d", + "log": "3f8c9f53", + "pow": "3f8edd1f", + "atan2": "3d087b99", + "tanh": "3dcc1eb5", + "dot": "00000000" } }, { - "interface": "webgl1-combined", - "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)", + "interface": "webgpu-split", + "label": "llvmpipe (LLVM 20.1.2, Mesa 25.2.8) through Vulkan on Linux arm64, GitHub runner -- wgpu 29, no browser", "seen": [ { - "browser": "chrome", - "browser_version": "157.0.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "normal" - }, - { - "browser": "safari", - "browser_version": "27.0", - "os": "macos", - "os_version": "26.7.1", + "browser": "none", + "os": "linux", + "os_version": "Ubuntu 24.04", "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "normal" + "gpu": "mesa", + "gpu_version": "llvmpipe (LLVM 20.1.2, 128 bits), Mesa 25.2.8-0ubuntu0.24.04.4", + "backend": "vulkan", + "device": "GitHub ubuntu-24.04-arm runner", + "runtime": "wgpu 29", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" } ], "results": { - "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", + "multiply_add": "00000000", + "fma": "00000000", + "swamped_sum": "00000000", "divide": "3eaaaaab", - "subnormal_product": "00000000", + "subnormal_product": "00400000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", + "sin": "3dcc7577", + "cos": "3f7eb898", + "tan": "3dcd7c44", "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "????????", - "dot": "00000000" + "pow": "3f8edd20", + "atan2": "3d087ae6", + "tanh": "3dcc1ebe", + "dot": "40000000" } }, { - "interface": "webgl1", - "label": "Firefox 160.0.0 on macOS", + "interface": "webgpu-split", + "label": "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for msvc", "seen": [ { - "browser": "firefox", - "browser_version": "160.0.0", - "os": "macos", - "cpu_arch": "unknown", - "gpu": "unknown", - "backend": "unknown", - "window": "normal" + "browser": "none", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "microsoft", + "gpu_version": "Basic Render Driver 10.0.26100.33648", + "backend": "d3d12", + "device": "GitHub windows-latest runner", + "runtime": "wgpu 29, built for x86_64-pc-windows-msvc", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" } ], - "blocked": true, - "reason": "WebGL 1 is unavailable (getContext returned null, so WebGL is turned off)" + "results": { + "multiply_add": "00000000", + "fma": "00000000", + "swamped_sum": "00000000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756b", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763e", + "log": "3f8c9f54", + "pow": "3f8edd20", + "atan2": "3d087708", + "tanh": "3dcc1ebc", + "dot": "00000000" + } }, { - "interface": "webgl1-split", - "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0)", + "interface": "webgpu-split", + "label": "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for GNU", "seen": [ { - "browser": "chrome", - "browser_version": "157.0.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" + "browser": "none", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "microsoft", + "gpu_version": "Basic Render Driver 10.0.26100.33648", + "backend": "d3d12", + "device": "GitHub windows-latest runner", + "runtime": "wgpu 29, built for x86_64-pc-windows-gnullvm", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" + }, + { + "browser": "none", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "microsoft", + "gpu_version": "Basic Render Driver 10.0.26100.33648", + "backend": "d3d12", + "device": "GitHub windows-latest runner", + "runtime": "wgpu 29, built for x86_64-pc-windows-gnu", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" } ], "results": { - "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", + "multiply_add": "00000000", + "fma": "00000000", + "swamped_sum": "00000000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", + "sin": "3dcc756b", + "cos": "3f7eb898", + "tan": "3dcd7c38", "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "????????", + "pow": "3f8edd20", + "atan2": "3d087708", + "tanh": "3dcc1ebe", "dot": "00000000" } }, { - "interface": "webgl1-combined", - "label": "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0)", + "interface": "webgpu-split", + "label": "llvmpipe (LLVM 21.1.8, Mesa 26.0.8) through Vulkan on Linux x86_64, GitHub runner -- wgpu 29, no browser", "seen": [ { - "browser": "chrome", - "browser_version": "157.0.0", - "os": "macos", - "os_version": "26.7.1", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", - "window": "private" + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "mesa", + "gpu_version": "llvmpipe (LLVM 21.1.8, 256 bits), Mesa 26.0.8-1ubuntu0.3", + "backend": "vulkan", + "device": "GitHub ubuntu-latest runner", + "runtime": "wgpu 29", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" } ], "results": { - "multiply_add": "33800000", - "fma": "????????", + "multiply_add": "00000000", + "fma": "00000000", "swamped_sum": "40800000", "divide": "3eaaaaab", - "subnormal_product": "00000000", + "subnormal_product": "00400000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", + "sin": "3dcc7577", + "cos": "3f7eb898", + "tan": "3dcd7c44", "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "????????", - "dot": "00000000" + "pow": "3f8edd20", + "atan2": "3d087ae6", + "tanh": "3dcc1ebe", + "dot": "40000000" } }, { - "interface": "webgpu-combined", - "label": "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Safari", + "interface": "webgpu-split", + "label": "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29", "seen": [ { - "browser": "safari", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "apple", - "gpu_version": "Apple M5 Pro GPU", - "backend": "metal", + "browser": "none", + "os": "linux", + "os_version": "Pop!_OS 24.04", + "cpu_arch": "x86_64", + "cpu_version": "12th Gen Intel Core i7-12800H", + "gpu": "intel", + "gpu_version": "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea", + "backend": "vulkan", + "device": "Dell Precision 5570", + "runtime": "wgpu 29", + "window": "none", + "date": "2026-11-13" + }, + { + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "gen-12lp", + "backend": "vulkan", + "device": "Dell Precision 5570", "window": "normal" } ], "results": { "multiply_add": "33800000", "fma": "33800000", - "swamped_sum": "00000000", + "swamped_sum": "40800000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb899", - "tan": "3dcd7c43", - "exp": "3f8d763e", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087b99", - "tanh": "3dcc1ebd", - "dot": "40000000" + "atan2": "3d087ae6", + "tanh": "3dcc1eb6", + "dot": "3f800000" } }, { - "interface": "canvas-browserleaks", - "label": "Safari 27.0.0 on macOS -- canvas 2D, BrowserLeaks test", + "interface": "webgpu-combined", + "label": "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29", "seen": [ { - "browser": "safari", - "browser_version": "27.0.0", - "os": "macos", - "cpu_arch": "unknown", - "gpu": "unknown", - "backend": "unknown", - "window": "normal" - } - ], - "results": { - "md5": "f7316747f9adf8387510e46790f09d66" - } - }, - { - "interface": "webgpu-combined", - "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Edge 153", - "seen": [ + "browser": "none", + "os": "linux", + "os_version": "Pop!_OS 24.04", + "cpu_arch": "x86_64", + "cpu_version": "12th Gen Intel Core i7-12800H", + "gpu": "intel", + "gpu_version": "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea", + "backend": "vulkan", + "device": "Dell Precision 5570", + "runtime": "wgpu 29", + "window": "none", + "date": "2026-11-13" + }, { - "browser": "edge", - "browser_version": "153", - "os": "windows", + "browser": "none", + "os": "linux", "cpu_arch": "x86_64", "gpu": "intel", - "gpu_version": "Xe-LPG", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", - "window": "normal" - } - ], - "results": { - "multiply_add": "00000000", - "fma": "00000000", - "swamped_sum": "00000000", - "divide": "3eaaaaab", - "subnormal_product": "00000000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", - "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", - "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087708", - "tanh": "3dcc1eb6", - "dot": "00000000" - } - }, - { - "interface": "webgl2-combined", - "label": "Intel Xe-LPG through Direct3D 11 on Windows -- WebGL 2 on Chrome", - "seen": [ + "gpu_version": "Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" + }, { "browser": "chrome", - "os": "windows", + "browser_version": "157.0.8103.78", + "os": "linux", "cpu_arch": "x86_64", "gpu": "intel", - "gpu_version": "Xe-LPG", - "backend": "d3d11", + "gpu_version": "gen-12lp", + "backend": "vulkan", + "device": "Dell Precision 5570", "window": "normal" } ], "results": { "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", + "fma": "33800000", + "swamped_sum": "40000000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", @@ -2573,187 +2816,321 @@ "exp": "3f8d763d", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087708", + "atan2": "3d087ae6", "tanh": "3dcc1eb6", - "dot": "00000000" + "dot": "3f800000" } }, { - "interface": "webgl1-split", - "label": "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153", + "interface": "host", + "label": "Rust f32 on the CPU, every probe correctly rounded: macOS arm64, Linux arm64 and x86_64, Windows x64", "seen": [ { - "browser": "edge", - "browser_version": "153", + "browser": "none", + "os": "macos", + "cpu_arch": "aarch64", + "cpu_version": "Apple M5 Pro", + "gpu": "none", + "backend": "none", + "runtime": "Rust f32 and Apple's math library", + "window": "none", + "date": "2026-11-12" + }, + { + "browser": "none", + "os": "macos", + "cpu_arch": "aarch64", + "gpu": "none", + "backend": "none", + "device": "GitHub macos-latest runner", + "runtime": "Rust f32 and Apple's math library", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" + }, + { + "browser": "none", + "os": "linux", + "os_version": "Ubuntu 24.04", + "cpu_arch": "aarch64", + "gpu": "none", + "backend": "none", + "device": "GitHub ubuntu-24.04-arm runner", + "runtime": "Rust f32 and glibc", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" + }, + { + "browser": "none", "os": "windows", "cpu_arch": "x86_64", - "gpu": "intel", - "gpu_version": "Arc Graphics", - "backend": "d3d11", - "device": "HP EliteBook (HSN-I6IC)", - "window": "normal" + "gpu": "none", + "backend": "none", + "device": "GitHub windows-latest runner", + "runtime": "Rust f32 and the MSVC runtime", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" + }, + { + "browser": "none", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "none", + "backend": "none", + "device": "GitHub windows-latest runner", + "runtime": "Rust f32, built for x86_64-pc-windows-gnullvm", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" + }, + { + "browser": "none", + "os": "windows", + "cpu_arch": "x86_64", + "gpu": "none", + "backend": "none", + "device": "GitHub windows-latest runner", + "runtime": "Rust f32, built for x86_64-pc-windows-gnu", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" + }, + { + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "none", + "backend": "none", + "device": "GitHub ubuntu-latest runner", + "runtime": "Rust f32 and glibc", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" + }, + { + "browser": "none", + "os": "linux", + "os_version": "Pop!_OS 24.04", + "cpu_arch": "x86_64", + "cpu_version": "12th Gen Intel Core i7-12800H", + "gpu": "none", + "backend": "none", + "device": "Dell Precision 5570", + "runtime": "Rust f32 and glibc", + "window": "none", + "date": "2026-11-13" } ], "results": { - "multiply_add": "33800000", - "fma": "????????", - "swamped_sum": "40800000", + "multiply_add": "00000000", + "fma": "33800000", + "swamped_sum": "00000000", "divide": "3eaaaaab", - "subnormal_product": "00000000", + "subnormal_product": "00400000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", + "sin": "3dcc7577", "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "tan": "3dcd7c44", + "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087708", - "tanh": "????????", + "pow": "3f8edd20", + "atan2": "3d087b99", + "tanh": "3dcc1ebc", "dot": "00000000" } }, { - "interface": "webgl1-combined", - "label": "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153", + "interface": "wasi-nn", + "label": "ONNX Runtime 1.22 on the CPU behind wasi:nn: Linux x86_64, where its multiply-add is not fused", "seen": [ { - "browser": "edge", - "browser_version": "153", - "os": "windows", + "browser": "none", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "intel", - "gpu_version": "Arc Graphics", - "backend": "d3d11", - "device": "HP EliteBook (HSN-I6IC)", - "window": "normal" + "gpu": "none", + "backend": "none", + "device": "GitHub ubuntu-latest runner", + "runtime": "ONNX Runtime 1.22", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" } ], "results": { - "multiply_add": "33800000", - "fma": "????????", + "multiply_add": "00000000", + "fma": "00000000", "swamped_sum": "40800000", "divide": "3eaaaaab", - "subnormal_product": "00000000", + "subnormal_product": "00400000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", + "sin": "3dcc7577", "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "tan": "3dcd7c44", + "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087708", - "tanh": "????????", + "pow": "3f8edd20", + "atan2": "3d087b99", + "tanh": "3dcc1ebb", "dot": "00000000" } }, { - "interface": "canvas-browserleaks", - "label": "HP EliteBook (HSN-I6IC), Windows -- canvas 2D, BrowserLeaks test on Edge 153", + "interface": "wasi-nn", + "label": "ONNX Runtime 1.22 on the CPU behind wasi:nn: macOS arm64 and Linux arm64", "seen": [ { - "browser": "edge", - "browser_version": "153", - "os": "windows", - "cpu_arch": "x86_64", - "gpu": "intel", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", - "window": "normal" - } - ], - "results": { - "md5": "8c781e6c79d33a68ef6ae90ae071c989" - } - }, - { - "interface": "webgpu-split", - "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)", - "seen": [ + "browser": "none", + "os": "macos", + "cpu_arch": "aarch64", + "cpu_version": "Apple M5 Pro", + "gpu": "none", + "backend": "none", + "runtime": "ONNX Runtime 1.22", + "window": "none", + "date": "2026-11-12" + }, { - "browser": "firefox", - "browser_version": "155", - "os": "windows", - "cpu_arch": "x86_64", - "gpu": "intel", - "gpu_version": "Xe-LPG", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", - "reported": "adapter info blanked", - "window": "normal" + "browser": "none", + "os": "macos", + "cpu_arch": "aarch64", + "gpu": "none", + "backend": "none", + "device": "GitHub macos-latest runner", + "runtime": "ONNX Runtime 1.22", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174374" + }, + { + "browser": "none", + "os": "linux", + "os_version": "Ubuntu 24.04", + "cpu_arch": "aarch64", + "gpu": "none", + "backend": "none", + "device": "GitHub ubuntu-24.04-arm runner", + "runtime": "ONNX Runtime 1.22", + "window": "none", + "date": "2026-11-12", + "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" } ], "results": { - "multiply_add": "33800000", + "multiply_add": "00000000", "fma": "33800000", - "swamped_sum": "00000000", + "swamped_sum": "40800000", "divide": "3eaaaaab", - "subnormal_product": "00000000", + "subnormal_product": "00400000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", + "sin": "3dcc7577", "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "tan": "3dcd7c44", + "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd1f", - "atan2": "3d087708", - "tanh": "3dcc1eb6", + "pow": "3f8edd20", + "atan2": "3d087b99", + "tanh": "3dcc1ebb", "dot": "00000000" } }, { - "interface": "canvas", - "label": "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)", + "interface": "webgpu-clear", + "label": "Intel(R) Iris(R) Xe Graphics (ADL GT2) through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { - "browser": "firefox", - "browser_version": "155.0.0", - "os": "windows", + "browser": "none", + "os": "linux", "cpu_arch": "x86_64", "gpu": "intel", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", + "gpu_version": "Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "gen-12lp", + "backend": "vulkan", + "device": "Dell Precision 5570", "window": "normal" } ], - "blocked": true, - "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" + "results": { + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000a", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bb", + "srgb8_dark": "00000006", + "ufloat11_rounding": "000003c0", + "half_rounding": "00003c01", + "half_subnormal": "00000010", + "half_overflow": "00007c00" + } }, { - "interface": "canvas-browserleaks", - "label": "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)", + "interface": "webgpu-fixed-function", + "label": "Intel(R) Iris(R) Xe Graphics (ADL GT2) through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { - "browser": "firefox", - "browser_version": "155.0.0", - "os": "windows", + "browser": "none", + "os": "linux", "cpu_arch": "x86_64", "gpu": "intel", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", + "gpu_version": "Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "gen-12lp", + "backend": "vulkan", + "device": "Dell Precision 5570", "window": "normal" } ], - "blocked": true, - "reason": "randomised: hash differs between visits in one browser (noise keyed to the session)" + "results": { + "blend_multiply": "00000033", + "blend_half_float": "00003c00", + "blend_srgb": "000000e1", + "filter_third": "3eaa0000", + "filter_fine": "00000000", + "filter_srgb": "3ddd80de", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "0000000f" + } }, { - "interface": "webgpu-combined", - "label": "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)", + "interface": "webgpu-split", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { - "browser": "firefox", - "browser_version": "155", - "os": "windows", + "browser": "none", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "intel", - "gpu_version": "Xe-LPG", - "backend": "unknown", - "device": "HP EliteBook (HSN-I6IC)", - "reported": "adapter info blanked", - "window": "normal" + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "driver_version": "595.104.2.0", + "api_version": "Vulkan 1.4.329", + "backend": "vulkan", + "window": "none" } ], "results": { @@ -2764,33 +3141,31 @@ "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", - "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "sin": "3dcc7566", + "cos": "3f7eb897", + "tan": "3dcd7c34", + "exp": "3f8d763e", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087708", - "tanh": "3dcc1eb6", - "dot": "00000000" + "atan2": "3d087b99", + "tanh": "3dcc1ebd", + "dot": "40000000" } }, { - "interface": "webgpu-split", - "label": "Apple Paravirtual device through Metal on macOS arm64, GitHub macos-latest runner -- wgpu 29, no browser", + "interface": "webgpu-combined", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { "browser": "none", - "os": "macos", - "cpu_arch": "aarch64", - "gpu": "apple", - "gpu_version": "Apple Paravirtual device", - "backend": "metal", - "device": "GitHub macos-latest runner", - "runtime": "wgpu 29", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "driver_version": "595.104.2.0", + "api_version": "Vulkan 1.4.329", + "backend": "vulkan", + "window": "none" } ], "results": { @@ -2801,42 +3176,118 @@ "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7576", - "cos": "3f7eb896", - "tan": "3dcd7c46", - "exp": "3f8d763d", - "log": "3f8c9f53", + "sin": "3dcc7566", + "cos": "3f7eb897", + "tan": "3dcd7c34", + "exp": "3f8d763e", + "log": "3f8c9f54", "pow": "3f8edd1f", "atan2": "3d087b99", - "tanh": "3dcc1eb5", - "dot": "00000000" + "tanh": "3dcc1ebd", + "dot": "40000000" + } + }, + { + "interface": "webgpu-clear", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser", + "seen": [ + { + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "driver_version": "595.104.2.0", + "api_version": "Vulkan 1.4.329", + "backend": "vulkan", + "window": "none" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "ampere", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000a", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c0", + "half_rounding": "00003c00", + "half_subnormal": "00000010", + "half_overflow": "00007bff" + } + }, + { + "interface": "webgpu-fixed-function", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser", + "seen": [ + { + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "driver_version": "595.104.2.0", + "api_version": "Vulkan 1.4.329", + "backend": "vulkan", + "window": "none" + }, + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "ampere", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "blend_multiply": "00000032", + "blend_half_float": "00003c01", + "blend_srgb": "000000e1", + "filter_third": "3eaa00aa", + "filter_fine": "00000000", + "filter_srgb": "3ddd0000", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "0000000f" } }, { "interface": "webgpu-split", - "label": "llvmpipe (LLVM 20.1.2, Mesa 25.2.8) through Vulkan on Linux arm64, GitHub runner -- wgpu 29, no browser", + "label": "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { "browser": "none", "os": "linux", - "os_version": "Ubuntu 24.04", - "cpu_arch": "aarch64", + "cpu_arch": "x86_64", "gpu": "mesa", - "gpu_version": "llvmpipe (LLVM 20.1.2, 128 bits), Mesa 25.2.8-0ubuntu0.24.04.4", + "gpu_version": "llvmpipe (LLVM 20.1.2, 256 bits)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", "backend": "vulkan", - "device": "GitHub ubuntu-24.04-arm runner", - "runtime": "wgpu 29", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" + "window": "none" } ], "results": { "multiply_add": "00000000", "fma": "00000000", - "swamped_sum": "00000000", + "swamped_sum": "40800000", "divide": "3eaaaaab", - "subnormal_product": "00400000", + "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", "sin": "3dcc7577", @@ -2851,146 +3302,203 @@ } }, { - "interface": "webgpu-split", - "label": "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for msvc", + "interface": "webgpu-combined", + "label": "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { "browser": "none", - "os": "windows", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "microsoft", - "gpu_version": "Basic Render Driver 10.0.26100.33648", - "backend": "d3d12", - "device": "GitHub windows-latest runner", - "runtime": "wgpu 29, built for x86_64-pc-windows-msvc", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" + "gpu": "mesa", + "gpu_version": "llvmpipe (LLVM 20.1.2, 256 bits)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" } ], "results": { "multiply_add": "00000000", "fma": "00000000", - "swamped_sum": "00000000", + "swamped_sum": "40800000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756b", + "sin": "3dcc7577", "cos": "3f7eb898", - "tan": "3dcd7c38", + "tan": "3dcd7c44", "exp": "3f8d763e", "log": "3f8c9f54", "pow": "3f8edd20", - "atan2": "3d087708", - "tanh": "3dcc1ebc", - "dot": "00000000" + "atan2": "3d087ae6", + "tanh": "3dcc1ebe", + "dot": "40000000" } }, { - "interface": "webgpu-split", - "label": "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for GNU", + "interface": "webgpu-clear", + "label": "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser", "seen": [ { "browser": "none", - "os": "windows", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "microsoft", - "gpu_version": "Basic Render Driver 10.0.26100.33648", - "backend": "d3d12", - "device": "GitHub windows-latest runner", - "runtime": "wgpu 29, built for x86_64-pc-windows-gnullvm", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" - }, + "gpu": "mesa", + "gpu_version": "llvmpipe (LLVM 20.1.2, 256 bits)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" + } + ], + "results": { + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000a", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c1", + "half_rounding": "00003c00", + "half_subnormal": "00000010", + "half_overflow": "00007bff" + } + }, + { + "interface": "webgpu-fixed-function", + "label": "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser", + "seen": [ { "browser": "none", - "os": "windows", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "microsoft", - "gpu_version": "Basic Render Driver 10.0.26100.33648", - "backend": "d3d12", - "device": "GitHub windows-latest runner", - "runtime": "wgpu 29, built for x86_64-pc-windows-gnu", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" + "gpu": "mesa", + "gpu_version": "llvmpipe (LLVM 20.1.2, 256 bits)", + "driver_version": "26.1.6", + "api_version": "Vulkan 1.4.354", + "backend": "vulkan", + "window": "none" } ], "results": { - "multiply_add": "00000000", - "fma": "00000000", - "swamped_sum": "00000000", + "blend_multiply": "00000033", + "blend_half_float": "00003c00", + "blend_srgb": "000000e1", + "filter_third": "3eaaaaab", + "filter_fine": "00000000", + "filter_srgb": "3dddd65c", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "0000000f" + } + }, + { + "interface": "webgl2-split", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", + "seen": [ + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756b", - "cos": "3f7eb898", - "tan": "3dcd7c38", + "sin": "3dcc7566", + "cos": "3f7eb897", + "tan": "3dcd7c34", "exp": "3f8d763e", "log": "3f8c9f54", - "pow": "3f8edd20", - "atan2": "3d087708", - "tanh": "3dcc1ebe", + "pow": "3f8edd1f", + "atan2": "3d087b99", + "tanh": "3dcc1ebd", "dot": "00000000" } }, { - "interface": "webgpu-split", - "label": "llvmpipe (LLVM 21.1.8, Mesa 26.0.8) through Vulkan on Linux x86_64, GitHub runner -- wgpu 29, no browser", + "interface": "webgl2-clear", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", "seen": [ { - "browser": "none", + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", "cpu_arch": "x86_64", - "gpu": "mesa", - "gpu_version": "llvmpipe (LLVM 21.1.8, 256 bits), Mesa 26.0.8-1ubuntu0.3", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", "backend": "vulkan", - "device": "GitHub ubuntu-latest runner", - "runtime": "wgpu 29", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { - "multiply_add": "00000000", - "fma": "00000000", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00400000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7577", - "cos": "3f7eb898", - "tan": "3dcd7c44", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd20", - "atan2": "3d087ae6", - "tanh": "3dcc1ebe", - "dot": "40000000" + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000a", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c0", + "half_rounding": "00003c00", + "half_subnormal": "00000010", + "half_overflow": "00007bff" + } + }, + { + "interface": "webgl2-fixed-function", + "label": "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", + "seen": [ + { + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "nvidia", + "gpu_version": "NVIDIA RTX A1000 Laptop GPU", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "blend_multiply": "00000032", + "blend_half_float": "00003c01", + "blend_srgb": "000000e1", + "filter_third": "3eaa00aa", + "filter_fine": "00000000", + "filter_srgb": "3ddd0000", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "0000000f" } }, { "interface": "webgpu-split", - "label": "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29", + "label": "ampere through vulkan on linux -- WebGPU on chrome 157.0.8103.78", "seen": [ { - "browser": "none", + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", - "os_version": "Pop!_OS 24.04", "cpu_arch": "x86_64", - "cpu_version": "12th Gen Intel Core i7-12800H", - "gpu": "intel", - "gpu_version": "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea", + "gpu": "nvidia", + "gpu_version": "ampere", "backend": "vulkan", "device": "Dell Precision 5570", - "runtime": "wgpu 29", - "window": "none", - "date": "2026-11-13" + "window": "normal" } ], "results": { @@ -3001,271 +3509,354 @@ "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", - "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "sin": "3dcc7566", + "cos": "3f7eb897", + "tan": "3dcd7c34", + "exp": "3f8d763e", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087ae6", - "tanh": "3dcc1eb6", - "dot": "3f800000" + "atan2": "3d087b99", + "tanh": "3dcc1ebd", + "dot": "40000000" } }, { "interface": "webgpu-combined", - "label": "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29", + "label": "ampere through vulkan on linux -- WebGPU on chrome 157.0.8103.78", "seen": [ { - "browser": "none", + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", - "os_version": "Pop!_OS 24.04", "cpu_arch": "x86_64", - "cpu_version": "12th Gen Intel Core i7-12800H", - "gpu": "intel", - "gpu_version": "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea", + "gpu": "nvidia", + "gpu_version": "ampere", "backend": "vulkan", "device": "Dell Precision 5570", - "runtime": "wgpu 29", - "window": "none", - "date": "2026-11-13" + "window": "normal" } ], "results": { "multiply_add": "33800000", "fma": "33800000", - "swamped_sum": "40000000", + "swamped_sum": "40800000", "divide": "3eaaaaab", "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc756a", - "cos": "3f7eb898", - "tan": "3dcd7c38", - "exp": "3f8d763d", + "sin": "3dcc7566", + "cos": "3f7eb897", + "tan": "3dcd7c34", + "exp": "3f8d763e", "log": "3f8c9f54", "pow": "3f8edd1f", - "atan2": "3d087ae6", - "tanh": "3dcc1eb6", - "dot": "3f800000" + "atan2": "3d087b99", + "tanh": "3dcc1ebd", + "dot": "40000000" } }, { - "interface": "host", - "label": "Rust f32 on the CPU, every probe correctly rounded: macOS arm64, Linux arm64 and x86_64, Windows x64", + "interface": "webgpu-clear", + "label": "SwiftShader (software renderer) through vulkan on linux -- WebGPU on chrome 157.0.8103.78", "seen": [ { - "browser": "none", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "none", - "backend": "none", - "runtime": "Rust f32 and Apple's math library", - "window": "none", - "date": "2026-11-12" - }, + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "google", + "gpu_version": "SwiftShader (software renderer)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000b", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c1", + "half_rounding": "00003c01", + "half_subnormal": "00000010", + "half_overflow": "00007c46" + } + }, + { + "interface": "webgpu-fixed-function", + "label": "SwiftShader (software renderer) through vulkan on linux -- WebGPU on chrome 157.0.8103.78", + "seen": [ { - "browser": "none", - "os": "macos", - "cpu_arch": "aarch64", - "gpu": "none", - "backend": "none", - "device": "GitHub macos-latest runner", - "runtime": "Rust f32 and Apple's math library", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" - }, + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "google", + "gpu_version": "SwiftShader (software renderer)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "blend_multiply": "00000032", + "blend_half_float": "00003c01", + "blend_srgb": "000000e1", + "filter_third": "3eaaaaac", + "filter_fine": "3a831201", + "filter_srgb": "3ddd090a", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "00000007" + } + }, + { + "interface": "webgl2-clear", + "label": "Intel Iris Xe (Alder Lake GT2) through OpenGL (ES) on Linux, Dell Precision 5570 -- WebGL 2", + "seen": [ { - "browser": "none", + "browser": "chromium", + "browser_version": "Claude 5.11492.0 (Chromium 156.0.8078.75)", "os": "linux", - "os_version": "Ubuntu 24.04", - "cpu_arch": "aarch64", - "gpu": "none", - "backend": "none", - "device": "GitHub ubuntu-24.04-arm runner", - "runtime": "Rust f32 and glibc", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", - "os": "windows", + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "none", - "backend": "none", - "device": "GitHub windows-latest runner", - "runtime": "Rust f32 and the MSVC runtime", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", - "os": "windows", - "cpu_arch": "x86_64", - "gpu": "none", - "backend": "none", - "device": "GitHub windows-latest runner", - "runtime": "Rust f32, built for x86_64-pc-windows-gnullvm", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", - "os": "windows", + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" + } + ], + "results": { + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000a", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c1", + "half_rounding": "00003c00", + "half_subnormal": "00000010", + "half_overflow": "00007bff" + } + }, + { + "interface": "webgl2-fixed-function", + "label": "Intel Iris Xe (Alder Lake GT2) through OpenGL (ES) on Linux, Dell Precision 5570 -- WebGL 2", + "seen": [ + { + "browser": "chromium", + "browser_version": "Claude 5.11492.0 (Chromium 156.0.8078.75)", + "os": "linux", "cpu_arch": "x86_64", - "gpu": "none", - "backend": "none", - "device": "GitHub windows-latest runner", - "runtime": "Rust f32, built for x86_64-pc-windows-gnu", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", "cpu_arch": "x86_64", - "gpu": "none", - "backend": "none", - "device": "GitHub ubuntu-latest runner", - "runtime": "Rust f32 and glibc", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" + } + ], + "results": { + "blend_multiply": "00000033", + "blend_half_float": "00003c00", + "blend_srgb": "000000e1", + "filter_third": "3eaa0000", + "filter_fine": "00000000", + "filter_srgb": "3ddd80de", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "0000000f" + } + }, + { + "interface": "webgl2-split", + "label": "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", + "seen": [ + { + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", - "os_version": "Pop!_OS 24.04", "cpu_arch": "x86_64", - "cpu_version": "12th Gen Intel Core i7-12800H", - "gpu": "none", - "backend": "none", + "gpu": "google", + "gpu_version": "SwiftShader Device (Subzero)", + "backend": "vulkan", "device": "Dell Precision 5570", - "runtime": "Rust f32 and glibc", - "window": "none", - "date": "2026-11-13" + "window": "normal" } ], "results": { "multiply_add": "00000000", - "fma": "33800000", + "fma": "????????", "swamped_sum": "00000000", "divide": "3eaaaaab", - "subnormal_product": "00400000", + "subnormal_product": "00000000", "sqrt": "3fddb3d7", "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7577", - "cos": "3f7eb898", - "tan": "3dcd7c44", + "sin": "3dcc6e85", + "cos": "3f7eadd1", + "tan": "3dcd7dfb", "exp": "3f8d763e", "log": "3f8c9f54", "pow": "3f8edd20", "atan2": "3d087b99", - "tanh": "3dcc1ebc", + "tanh": "3dcc1eba", "dot": "00000000" } }, { - "interface": "wasi-nn", - "label": "ONNX Runtime 1.22 on the CPU behind wasi:nn: Linux x86_64, where its multiply-add is not fused", + "interface": "webgl2-clear", + "label": "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", "seen": [ { - "browser": "none", + "browser": "chrome", + "browser_version": "157.0.8103.78", "os": "linux", "cpu_arch": "x86_64", - "gpu": "none", - "backend": "none", - "device": "GitHub ubuntu-latest runner", - "runtime": "ONNX Runtime 1.22", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" + "gpu": "google", + "gpu_version": "SwiftShader Device (Subzero)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { - "multiply_add": "00000000", - "fma": "00000000", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00400000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7577", - "cos": "3f7eb898", - "tan": "3dcd7c44", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd20", - "atan2": "3d087b99", - "tanh": "3dcc1ebb", - "dot": "00000000" + "unorm8_quarter": "0000000a", + "unorm8_half": "0000000b", + "unorm8_three_quarters": "0000000b", + "unorm10_three_quarters": "00000065", + "srgb8_mid": "000000bc", + "srgb8_dark": "00000007", + "ufloat11_rounding": "000003c1", + "half_rounding": "00003c01", + "half_subnormal": "00000010", + "half_overflow": "00007c00" } }, { - "interface": "wasi-nn", - "label": "ONNX Runtime 1.22 on the CPU behind wasi:nn: macOS arm64 and Linux arm64", + "interface": "webgl2-fixed-function", + "label": "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78", "seen": [ { - "browser": "none", - "os": "macos", - "cpu_arch": "aarch64", - "cpu_version": "Apple M5 Pro", - "gpu": "none", - "backend": "none", - "runtime": "ONNX Runtime 1.22", - "window": "none", - "date": "2026-11-12" - }, + "browser": "chrome", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "google", + "gpu_version": "SwiftShader Device (Subzero)", + "backend": "vulkan", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "blend_multiply": "00000032", + "blend_half_float": "00003c01", + "blend_srgb": "000000e1", + "filter_third": "3eaaaaac", + "filter_fine": "3a831201", + "filter_srgb": "3ddd090a", + "raster_right_edge_fine": "00000007", + "raster_left_edge_fine": "000000f8", + "raster_right_edge_coarse": "00000007" + } + }, + { + "interface": "canvas-browserleaks", + "label": "Firefox 160.0 on Linux -- canvas 2D, PNG encoding keyed to the session", + "seen": [ { - "browser": "none", - "os": "macos", - "cpu_arch": "aarch64", - "gpu": "none", - "backend": "none", - "device": "GitHub macos-latest runner", - "runtime": "ONNX Runtime 1.22", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174374" + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" }, { - "browser": "none", + "browser": "firefox", + "browser_version": "160.0", "os": "linux", - "os_version": "Ubuntu 24.04", - "cpu_arch": "aarch64", - "gpu": "none", - "backend": "none", - "device": "GitHub ubuntu-24.04-arm runner", - "runtime": "ONNX Runtime 1.22", - "window": "none", - "date": "2026-11-12", - "source": "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.fingerprintingProtection on", + "window": "normal" } ], - "results": { - "multiply_add": "00000000", - "fma": "33800000", - "swamped_sum": "40800000", - "divide": "3eaaaaab", - "subnormal_product": "00400000", - "sqrt": "3fddb3d7", - "inverse_sqrt": "3f13cd3a", - "sin": "3dcc7577", - "cos": "3f7eb898", - "tan": "3dcd7c44", - "exp": "3f8d763e", - "log": "3f8c9f54", - "pow": "3f8edd20", - "atan2": "3d087b99", - "tanh": "3dcc1ebb", - "dot": "00000000" - } + "blocked": true, + "reason": "randomised: the PNG's bytes differ between visits in one browser while the pixels they decode to do not" } ] } diff --git a/libs/gpu-probe/signatures.yaml b/libs/gpu-probe/signatures.yaml index 817df647..dc43b531 100644 --- a/libs/gpu-probe/signatures.yaml +++ b/libs/gpu-probe/signatures.yaml @@ -7,22 +7,27 @@ # CPU answering as f32 arithmetic and its math library, and the probes as ONNX operators behind wasi:nn. canvas is a # classic 2-D canvas fingerprint kept for comparison: a page's own scene, read with getImageData from a canvas the # page made. canvas-browserleaks is the BrowserLeaks canvas test, read with toDataURL from a canvas made in a -# sandboxed frame; its md5 result is the signature that site shows. A new mechanism brings its own interface name. +# sandboxed frame; its md5 result is the signature that site shows. webgpu-clear and webgpu-fixed-function are the +# probe sets no shader compiler decides: render passes clearing a target to a value, and draws whose answer comes +# from the blend unit, texture unit or rasterizer; webgl2-clear and webgl2-fixed-function are the same sets on WebGL +# 2, a clearBufferfv taking the place of a render pass's clear. A new mechanism brings its own interface name. # A signature can be shared by several devices, so each entry lists every place it has been seen, one row each, under # "seen"; when a new sighting matches an existing entry, add a row rather than a duplicate entry. The label is only a # summary of the rows. A row names, from fixed sets of values: # browser chrome, firefox, safari, opera (and whatever else the webbrowser crate names), brave, chromium, deno, # edge, librewolf, samsung-internet, tor-browser, or none where no browser was involved -# os android, linux, macos, windows +# os android, ios, linux, macos, windows # cpu_arch an architecture as a target triple spells it (aarch64, x86_64), or unknown # gpu the GPU's vendor as wgpu names it (amd, apple, arm, broadcom, imgtec, intel, mesa, nvidia, qualcomm), # google or microsoft for their software renderers, none where no GPU was involved, or unknown # backend metal, d3d11, d3d12, vulkan, opengl, opengl-es, none, or unknown -# window normal, private (an incognito window), or none where no browser was involved +# window normal, private (an incognito window), both (each gave the signature, so one row stands for the two), +# or none where no browser was involved # and may add, as free text: browser_version, os_version, cpu_version (the chip), gpu_version (the model and driver), -# device (the machine), runtime (what ran the probes where no browser did), defence (a privacy setting in force), -# reported (what the interface said the GPU was, where that is not what it is), date and source (where the sighting -# is on record). +# driver_version and api_version (the versions the graphics API itself reports for the driver and the device, as the +# native test prints them, not as a browser or wgpu describes them), device (the machine), runtime (what ran the +# probes where no browser did), defence (a privacy setting in force), reported (what the interface said the GPU was, +# where that is not what it is), date and source (where the sighting is on record). # Each result is keyed by a name its mechanism gives it (a probe, a hash lane) so fields can be added. # An interface a browser blocks or does not offer is recorded as an entry with "blocked: true" and a "reason" # instead of results, so the registry also shows which browsers withhold which interface, and why. @@ -192,6 +197,43 @@ signatures: backend: opengl device: "Dell Precision 5570" window: normal + - browser: chromium + browser_version: "Claude 5.11492.0 (Chromium 156.0.8078.75)" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.fingerprintingProtection on" + window: normal results: multiply_add: "33800000" fma: "????????" @@ -219,7 +261,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: brave os: macos cpu_arch: aarch64 @@ -227,7 +269,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both results: multiply_add: "33800000" fma: "33800000" @@ -255,7 +297,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: safari os: macos cpu_arch: aarch64 @@ -263,7 +305,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: brave os: macos cpu_arch: aarch64 @@ -271,7 +313,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: firefox os: macos cpu_arch: aarch64 @@ -279,7 +321,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both results: multiply_add: "33800000" fma: "????????" @@ -335,7 +377,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: deno browser_version: "deno_runtime 0.268.0, deno_webgpu 0.227.0" os: macos @@ -345,7 +387,7 @@ signatures: gpu_version: "Apple M5 Pro GPU" backend: metal runtime: "et-ws-web-runner 0.3.0" - window: none + window: normal date: "2026-11-12" - browser: none os: macos @@ -677,6 +719,15 @@ signatures: backend: vulkan device: "Dell Precision 5570" window: normal + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader (software renderer)" + backend: vulkan + device: "Dell Precision 5570" + window: normal results: multiply_add: "00000000" fma: "00000000" @@ -816,7 +867,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: unknown - window: normal + window: both results: lane0: "4e31fa9b" lane1: "76b62595" @@ -851,7 +902,7 @@ signatures: tanh: "3dcc1eb6" dot: "3f800000" - interface: canvas - label: "Firefox 149.0 on Linux -- canvas 2D" + label: "Firefox 149.0 and 160.0 on Linux -- canvas 2D, unchanged by privacy.fingerprintingProtection" seen: - browser: firefox browser_version: "149.0" @@ -860,6 +911,23 @@ signatures: gpu: unknown backend: unknown window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.fingerprintingProtection on" + window: normal results: lane0: "b11cf4d5" lane1: "9f52fa33" @@ -1228,6 +1296,15 @@ signatures: backend: vulkan device: "Dell Precision 5570" window: normal + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader (software renderer)" + backend: vulkan + device: "Dell Precision 5570" + window: normal results: multiply_add: "00000000" fma: "00000000" @@ -1255,7 +1332,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both results: multiply_add: "33800000" fma: "33800000" @@ -1283,7 +1360,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both results: multiply_add: "33800000" fma: "????????" @@ -1312,7 +1389,7 @@ signatures: gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: normal + window: both - browser: none os: macos cpu_arch: aarch64 @@ -1474,105 +1551,153 @@ signatures: lane1: "f467a8a2" lane2: "bf84affe" lane3: "84e85fe3" - - interface: webgpu-split - label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Chromium and Brave" + - interface: canvas + label: "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)" seen: - - browser: chromium + - browser: firefox + browser_version: "160" os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.fingerprintingProtection on" + window: both + results: + lane0: "a60e6953" + lane1: "e2f45f75" + lane2: "956014b5" + lane3: "1b2515f8" + - interface: canvas-browserleaks + label: "Firefox 160 on macOS, fingerprintingProtection on -- BrowserLeaks test (one session's value; see next row)" + seen: + - browser: firefox + browser_version: "160" + os: macos + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.fingerprintingProtection on" + window: normal + results: + md5: "67456dde9d4089a8cea2e53d4ecf4b8b" + - interface: canvas-browserleaks + label: "Firefox 160 on macOS, privacy.fingerprintingProtection on" + seen: + - browser: firefox + browser_version: "160" + os: macos + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.fingerprintingProtection on" + window: normal + blocked: true + reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" + - interface: canvas-browserleaks + label: "Brave 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test (noise added to reads)" + seen: - browser: brave + browser_version: "157.0.0" + os: macos + os_version: "26.7.1" + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "Shields farbling: noise added to reads" + window: normal + results: + md5: "ead7c33e84beeb434b1d384c6e45268b" + - interface: canvas-browserleaks + label: "Firefox 160.0.0 on macOS -- canvas 2D, BrowserLeaks test" + seen: + - browser: firefox + browser_version: "160.0.0" + os: macos + cpu_arch: unknown + gpu: unknown + backend: unknown + window: private + results: + md5: "4a05912a83d4fdbab55e5ddfaef2d3b0" + - interface: webgpu + label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" + seen: + - browser: librewolf + browser_version: "reports itself as Firefox 160.0.0" os: macos cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private - results: - multiply_add: "33800000" - fma: "33800000" - swamped_sum: "40800000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" - log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "3dcc1ebd" - dot: "00000000" - - interface: webgpu-combined - label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Chromium" + backend: unknown + window: normal + blocked: true + reason: "WebGPU is unavailable (navigator.gpu is absent; the browser does not expose it)" + - interface: webgl2 + label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" seen: - - browser: chromium + - browser: librewolf + browser_version: "reports itself as Firefox 160.0.0" os: macos cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private - results: - multiply_add: "33800000" - fma: "33800000" - swamped_sum: "40800000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" - log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "3dcc1ebd" - dot: "00000000" - - interface: webgl2-split - label: "Apple M5 Pro GPU through Metal on macOS -- WebGL 2 on Chromium, Safari, Brave and Firefox" + backend: unknown + window: normal + blocked: true + reason: "WebGL 2 is unavailable (getContext returned null; no WebGL context at all, so WebGL is turned off)" + - interface: canvas + label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" seen: - - browser: chromium + - browser: librewolf + browser_version: "reports itself as Firefox 160.0.0" os: macos cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private - - browser: safari + backend: unknown + window: normal + blocked: true + reason: "randomised: two reads of the same scene differed (per-read noise)" + - interface: canvas-browserleaks + label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" + seen: + - browser: librewolf + browser_version: "reports itself as Firefox 160.0.0" os: macos cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private - - browser: brave + backend: unknown + window: normal + blocked: true + reason: "randomised: two reads of the same scene differed (per-read noise)" + - interface: webgl1-split + label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)" + seen: + - browser: chrome + browser_version: "157.0.0" os: macos + os_version: "26.7.1" cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: private - - browser: firefox + window: both + - browser: safari + browser_version: "27.0" os: macos + os_version: "26.7.1" cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: private + window: normal results: multiply_add: "33800000" fma: "????????" @@ -1588,19 +1713,44 @@ signatures: log: "3f8c9f54" pow: "3f8edd1f" atan2: "3d087b99" - tanh: "3dcc1ebc" + tanh: "????????" dot: "00000000" - - interface: webgl2-combined - label: "Apple M5 Pro GPU through Metal on macOS -- WebGL 2 on Chromium" + - interface: canvas-browserleaks + label: "Chrome 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test" seen: - - browser: chromium + - browser: chrome + browser_version: "157.0.0" os: macos + os_version: "26.7.1" + cpu_arch: unknown + gpu: unknown + backend: unknown + window: normal + results: + md5: "1dc44a8e2846f0a156399977bf5ca67d" + - interface: webgl1-combined + label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)" + seen: + - browser: chrome + browser_version: "157.0.0" + os: macos + os_version: "26.7.1" cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: private + window: both + - browser: safari + browser_version: "27.0" + os: macos + os_version: "26.7.1" + cpu_arch: aarch64 + cpu_version: "Apple M5 Pro" + gpu: apple + gpu_version: "Apple M5 Pro GPU" + backend: metal + window: normal results: multiply_add: "33800000" fma: "????????" @@ -1616,51 +1766,31 @@ signatures: log: "3f8c9f54" pow: "3f8edd1f" atan2: "3d087b99" - tanh: "3dcc1ebc" + tanh: "????????" dot: "00000000" - - interface: canvas - label: "Apple M5 Pro, macOS -- canvas 2D on Chromium" - seen: - - browser: chromium - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: unknown - window: private - results: - lane0: "4e31fa9b" - lane1: "76b62595" - lane2: "5debc5c0" - lane3: "52ef3664" - - interface: canvas - label: "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)" + - interface: webgl1 + label: "Firefox 160.0.0 on macOS" seen: - browser: firefox - browser_version: "160" + browser_version: "160.0.0" os: macos cpu_arch: unknown gpu: unknown backend: unknown - defence: "privacy.fingerprintingProtection on" window: normal - results: - lane0: "a60e6953" - lane1: "e2f45f75" - lane2: "956014b5" - lane3: "1b2515f8" - - interface: webgpu-split - label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Firefox" + blocked: true + reason: "WebGL 1 is unavailable (getContext returned null, so WebGL is turned off)" + - interface: webgpu-combined + label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Safari" seen: - - browser: firefox + - browser: safari os: macos cpu_arch: aarch64 cpu_version: "Apple M5 Pro" gpu: apple gpu_version: "Apple M5 Pro GPU" backend: metal - window: private + window: normal results: multiply_add: "33800000" fma: "33800000" @@ -1677,377 +1807,309 @@ signatures: pow: "3f8edd1f" atan2: "3d087b99" tanh: "3dcc1ebd" - dot: "00000000" - - interface: canvas - label: "Firefox 160 on macOS, privacy.fingerprintingProtection on -- canvas 2D (differs from the unprotected hash)" + dot: "40000000" + - interface: canvas-browserleaks + label: "Safari 27.0.0 on macOS -- canvas 2D, BrowserLeaks test" seen: - - browser: firefox - browser_version: "160" + - browser: safari + browser_version: "27.0.0" os: macos cpu_arch: unknown gpu: unknown backend: unknown - defence: "privacy.fingerprintingProtection on" - window: private + window: normal results: - lane0: "a60e6953" - lane1: "e2f45f75" - lane2: "956014b5" - lane3: "1b2515f8" + md5: "f7316747f9adf8387510e46790f09d66" - interface: webgpu-combined - label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Firefox 160" + label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Edge 153" seen: - - browser: firefox - browser_version: "160" - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private + - browser: edge + browser_version: "153" + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Xe-LPG" + backend: unknown + device: "HP EliteBook (HSN-I6IC)" + window: normal results: - multiply_add: "33800000" - fma: "33800000" + multiply_add: "00000000" + fma: "00000000" swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "3dcc1ebd" + atan2: "3d087708" + tanh: "3dcc1eb6" dot: "00000000" - - interface: canvas-browserleaks - label: "Firefox 160 on macOS, fingerprintingProtection on -- BrowserLeaks test (one session's value; see next row)" + - interface: webgl2-combined + label: "Intel Xe-LPG through Direct3D 11 on Windows -- WebGL 2 on Chrome" seen: - - browser: firefox - browser_version: "160" - os: macos - cpu_arch: unknown - gpu: unknown - backend: unknown - defence: "privacy.fingerprintingProtection on" + - browser: chrome + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Xe-LPG" + backend: d3d11 window: normal results: - md5: "67456dde9d4089a8cea2e53d4ecf4b8b" - - interface: canvas-browserleaks - label: "Firefox 160 on macOS, privacy.fingerprintingProtection on" + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087708" + tanh: "3dcc1eb6" + dot: "00000000" + - interface: webgl1-split + label: "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153" seen: - - browser: firefox - browser_version: "160" - os: macos - cpu_arch: unknown - gpu: unknown - backend: unknown - defence: "privacy.fingerprintingProtection on" + - browser: edge + browser_version: "153" + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Arc Graphics" + backend: d3d11 + device: "HP EliteBook (HSN-I6IC)" window: normal - blocked: true - reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087708" + tanh: "????????" + dot: "00000000" + - interface: webgl1-combined + label: "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153" + seen: + - browser: edge + browser_version: "153" + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Arc Graphics" + backend: d3d11 + device: "HP EliteBook (HSN-I6IC)" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087708" + tanh: "????????" + dot: "00000000" - interface: canvas-browserleaks - label: "Brave 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test (noise added to reads)" + label: "HP EliteBook (HSN-I6IC), Windows -- canvas 2D, BrowserLeaks test on Edge 153" seen: - - browser: brave - browser_version: "157.0.0" - os: macos - os_version: "26.7.1" - cpu_arch: unknown - gpu: unknown + - browser: edge + browser_version: "153" + os: windows + cpu_arch: x86_64 + gpu: intel backend: unknown - defence: "Shields farbling: noise added to reads" + device: "HP EliteBook (HSN-I6IC)" window: normal results: - md5: "ead7c33e84beeb434b1d384c6e45268b" - - interface: canvas-browserleaks - label: "Firefox 160.0.0 on macOS -- canvas 2D, BrowserLeaks test" + md5: "8c781e6c79d33a68ef6ae90ae071c989" + - interface: webgpu-split + label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)" seen: - browser: firefox - browser_version: "160.0.0" - os: macos - cpu_arch: unknown - gpu: unknown + browser_version: "155" + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Xe-LPG" backend: unknown - window: private - results: - md5: "4a05912a83d4fdbab55e5ddfaef2d3b0" - - interface: webgpu - label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" - seen: - - browser: librewolf - browser_version: "reports itself as Firefox 160.0.0" - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: unknown - window: normal - blocked: true - reason: "WebGPU is unavailable (navigator.gpu is absent; the browser does not expose it)" - - interface: webgl2 - label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" - seen: - - browser: librewolf - browser_version: "reports itself as Firefox 160.0.0" - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: unknown - window: normal - blocked: true - reason: "WebGL 2 is unavailable (getContext returned null; no WebGL context at all, so WebGL is turned off)" - - interface: canvas - label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" - seen: - - browser: librewolf - browser_version: "reports itself as Firefox 160.0.0" - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: unknown - window: normal - blocked: true - reason: "randomised: two reads of the same scene differed (per-read noise)" - - interface: canvas-browserleaks - label: "LibreWolf (reports itself as Firefox 160.0.0) on macOS, Apple M5 Pro" - seen: - - browser: librewolf - browser_version: "reports itself as Firefox 160.0.0" - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: unknown - window: normal - blocked: true - reason: "randomised: two reads of the same scene differed (per-read noise)" - - interface: webgl1-split - label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)" - seen: - - browser: chrome - browser_version: "157.0.0" - os: macos - os_version: "26.7.1" - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: normal - - browser: safari - browser_version: "27.0" - os: macos - os_version: "26.7.1" - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: normal + device: "HP EliteBook (HSN-I6IC)" + reported: "adapter info blanked" + window: normal results: multiply_add: "33800000" - fma: "????????" - swamped_sum: "40800000" + fma: "33800000" + swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "????????" + atan2: "3d087708" + tanh: "3dcc1eb6" dot: "00000000" - - interface: canvas-browserleaks - label: "Chrome 157.0.0 on macOS 26.7.1 -- canvas 2D, BrowserLeaks test" + - interface: canvas + label: "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)" seen: - - browser: chrome - browser_version: "157.0.0" - os: macos - os_version: "26.7.1" - cpu_arch: unknown - gpu: unknown + - browser: firefox + browser_version: "155.0.0" + os: windows + cpu_arch: x86_64 + gpu: intel backend: unknown + device: "HP EliteBook (HSN-I6IC)" window: normal - results: - md5: "1dc44a8e2846f0a156399977bf5ca67d" - - interface: webgl1-combined - label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0 and Safari 27.0)" + blocked: true + reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" + - interface: canvas-browserleaks + label: "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)" seen: - - browser: chrome - browser_version: "157.0.0" - os: macos - os_version: "26.7.1" - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal + - browser: firefox + browser_version: "155.0.0" + os: windows + cpu_arch: x86_64 + gpu: intel + backend: unknown + device: "HP EliteBook (HSN-I6IC)" window: normal - - browser: safari - browser_version: "27.0" - os: macos - os_version: "26.7.1" - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal + blocked: true + reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" + - interface: webgpu-combined + label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)" + seen: + - browser: firefox + browser_version: "155" + os: windows + cpu_arch: x86_64 + gpu: intel + gpu_version: "Xe-LPG" + backend: unknown + device: "HP EliteBook (HSN-I6IC)" + reported: "adapter info blanked" window: normal results: multiply_add: "33800000" - fma: "????????" - swamped_sum: "40800000" + fma: "33800000" + swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "????????" + atan2: "3d087708" + tanh: "3dcc1eb6" dot: "00000000" - - interface: webgl1 - label: "Firefox 160.0.0 on macOS" - seen: - - browser: firefox - browser_version: "160.0.0" - os: macos - cpu_arch: unknown - gpu: unknown - backend: unknown - window: normal - blocked: true - reason: "WebGL 1 is unavailable (getContext returned null, so WebGL is turned off)" - - interface: webgl1-split - label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0)" + - interface: webgpu-split + label: "Apple Paravirtual device through Metal on macOS arm64, GitHub macos-latest runner -- wgpu 29, no browser" seen: - - browser: chrome - browser_version: "157.0.0" + - browser: none os: macos - os_version: "26.7.1" cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" gpu: apple - gpu_version: "Apple M5 Pro GPU" + gpu_version: "Apple Paravirtual device" backend: metal - window: private + device: "GitHub macos-latest runner" + runtime: "wgpu 29" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" results: multiply_add: "33800000" - fma: "????????" - swamped_sum: "40800000" + fma: "33800000" + swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" - log: "3f8c9f54" + cos: "3f7eb896" + tan: "3dcd7c46" + exp: "3f8d763d" + log: "3f8c9f53" pow: "3f8edd1f" atan2: "3d087b99" - tanh: "????????" + tanh: "3dcc1eb5" dot: "00000000" - - interface: webgl1-combined - label: "Apple M5 Pro through Metal on macOS 26.7.1 (Chrome 157.0.0)" + - interface: webgpu-split + label: "llvmpipe (LLVM 20.1.2, Mesa 25.2.8) through Vulkan on Linux arm64, GitHub runner -- wgpu 29, no browser" seen: - - browser: chrome - browser_version: "157.0.0" - os: macos - os_version: "26.7.1" + - browser: none + os: linux + os_version: "Ubuntu 24.04" cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: private + gpu: mesa + gpu_version: "llvmpipe (LLVM 20.1.2, 128 bits), Mesa 25.2.8-0ubuntu0.24.04.4" + backend: vulkan + device: "GitHub ubuntu-24.04-arm runner" + runtime: "wgpu 29" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" results: - multiply_add: "33800000" - fma: "????????" - swamped_sum: "40800000" + multiply_add: "00000000" + fma: "00000000" + swamped_sum: "00000000" divide: "3eaaaaab" - subnormal_product: "00000000" + subnormal_product: "00400000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" + sin: "3dcc7577" + cos: "3f7eb898" + tan: "3dcd7c44" exp: "3f8d763e" log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "????????" - dot: "00000000" - - interface: webgpu-combined - label: "Apple M5 Pro GPU through Metal on macOS -- WebGPU on Safari" - seen: - - browser: safari - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: apple - gpu_version: "Apple M5 Pro GPU" - backend: metal - window: normal - results: - multiply_add: "33800000" - fma: "33800000" - swamped_sum: "00000000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb899" - tan: "3dcd7c43" - exp: "3f8d763e" - log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "3dcc1ebd" + pow: "3f8edd20" + atan2: "3d087ae6" + tanh: "3dcc1ebe" dot: "40000000" - - interface: canvas-browserleaks - label: "Safari 27.0.0 on macOS -- canvas 2D, BrowserLeaks test" - seen: - - browser: safari - browser_version: "27.0.0" - os: macos - cpu_arch: unknown - gpu: unknown - backend: unknown - window: normal - results: - md5: "f7316747f9adf8387510e46790f09d66" - - interface: webgpu-combined - label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Edge 153" + - interface: webgpu-split + label: "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for msvc" seen: - - browser: edge - browser_version: "153" + - browser: none os: windows cpu_arch: x86_64 - gpu: intel - gpu_version: "Xe-LPG" - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - window: normal + gpu: microsoft + gpu_version: "Basic Render Driver 10.0.26100.33648" + backend: d3d12 + device: "GitHub windows-latest runner" + runtime: "wgpu 29, built for x86_64-pc-windows-msvc" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" results: multiply_add: "00000000" fma: "00000000" @@ -2056,86 +2118,124 @@ signatures: subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" + sin: "3dcc756b" cos: "3f7eb898" tan: "3dcd7c38" - exp: "3f8d763d" + exp: "3f8d763e" log: "3f8c9f54" - pow: "3f8edd1f" + pow: "3f8edd20" atan2: "3d087708" - tanh: "3dcc1eb6" + tanh: "3dcc1ebc" dot: "00000000" - - interface: webgl2-combined - label: "Intel Xe-LPG through Direct3D 11 on Windows -- WebGL 2 on Chrome" + - interface: webgpu-split + label: "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for GNU" seen: - - browser: chrome + - browser: none os: windows cpu_arch: x86_64 - gpu: intel - gpu_version: "Xe-LPG" - backend: d3d11 - window: normal + gpu: microsoft + gpu_version: "Basic Render Driver 10.0.26100.33648" + backend: d3d12 + device: "GitHub windows-latest runner" + runtime: "wgpu 29, built for x86_64-pc-windows-gnullvm" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" + - browser: none + os: windows + cpu_arch: x86_64 + gpu: microsoft + gpu_version: "Basic Render Driver 10.0.26100.33648" + backend: d3d12 + device: "GitHub windows-latest runner" + runtime: "wgpu 29, built for x86_64-pc-windows-gnu" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" results: - multiply_add: "33800000" - fma: "????????" - swamped_sum: "40800000" + multiply_add: "00000000" + fma: "00000000" + swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" + sin: "3dcc756b" cos: "3f7eb898" tan: "3dcd7c38" - exp: "3f8d763d" + exp: "3f8d763e" log: "3f8c9f54" - pow: "3f8edd1f" + pow: "3f8edd20" atan2: "3d087708" - tanh: "3dcc1eb6" + tanh: "3dcc1ebe" dot: "00000000" - - interface: webgl1-split - label: "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153" + - interface: webgpu-split + label: "llvmpipe (LLVM 21.1.8, Mesa 26.0.8) through Vulkan on Linux x86_64, GitHub runner -- wgpu 29, no browser" seen: - - browser: edge - browser_version: "153" - os: windows + - browser: none + os: linux cpu_arch: x86_64 - gpu: intel - gpu_version: "Arc Graphics" - backend: d3d11 - device: "HP EliteBook (HSN-I6IC)" - window: normal + gpu: mesa + gpu_version: "llvmpipe (LLVM 21.1.8, 256 bits), Mesa 26.0.8-1ubuntu0.3" + backend: vulkan + device: "GitHub ubuntu-latest runner" + runtime: "wgpu 29" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" results: - multiply_add: "33800000" - fma: "????????" + multiply_add: "00000000" + fma: "00000000" swamped_sum: "40800000" divide: "3eaaaaab" - subnormal_product: "00000000" + subnormal_product: "00400000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" + sin: "3dcc7577" cos: "3f7eb898" - tan: "3dcd7c38" - exp: "3f8d763d" + tan: "3dcd7c44" + exp: "3f8d763e" log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087708" - tanh: "????????" - dot: "00000000" - - interface: webgl1-combined - label: "Intel Arc Graphics through Direct3D 11 on Windows, HP EliteBook (HSN-I6IC) -- WebGL 1 on Edge 153" + pow: "3f8edd20" + atan2: "3d087ae6" + tanh: "3dcc1ebe" + dot: "40000000" + - interface: webgpu-split + label: "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29" seen: - - browser: edge - browser_version: "153" - os: windows + - browser: none + os: linux + os_version: "Pop!_OS 24.04" cpu_arch: x86_64 + cpu_version: "12th Gen Intel Core i7-12800H" gpu: intel - gpu_version: "Arc Graphics" - backend: d3d11 - device: "HP EliteBook (HSN-I6IC)" + gpu_version: "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea" + backend: vulkan + device: "Dell Precision 5570" + runtime: "wgpu 29" + window: none + date: "2026-11-13" + - browser: none + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Intel(R) Iris(R) Xe Graphics (ADL GT2)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan + window: none + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "gen-12lp" + backend: vulkan + device: "Dell Precision 5570" window: normal results: multiply_add: "33800000" - fma: "????????" + fma: "33800000" swamped_sum: "40800000" divide: "3eaaaaab" subnormal_product: "00000000" @@ -2147,39 +2247,46 @@ signatures: exp: "3f8d763d" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087708" - tanh: "????????" - dot: "00000000" - - interface: canvas-browserleaks - label: "HP EliteBook (HSN-I6IC), Windows -- canvas 2D, BrowserLeaks test on Edge 153" + atan2: "3d087ae6" + tanh: "3dcc1eb6" + dot: "3f800000" + - interface: webgpu-combined + label: "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29" seen: - - browser: edge - browser_version: "153" - os: windows + - browser: none + os: linux + os_version: "Pop!_OS 24.04" cpu_arch: x86_64 + cpu_version: "12th Gen Intel Core i7-12800H" gpu: intel - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - window: normal - results: - md5: "8c781e6c79d33a68ef6ae90ae071c989" - - interface: webgpu-split - label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)" - seen: - - browser: firefox - browser_version: "155" - os: windows + gpu_version: "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea" + backend: vulkan + device: "Dell Precision 5570" + runtime: "wgpu 29" + window: none + date: "2026-11-13" + - browser: none + os: linux cpu_arch: x86_64 gpu: intel - gpu_version: "Xe-LPG" - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - reported: "adapter info blanked" + gpu_version: "Intel(R) Iris(R) Xe Graphics (ADL GT2)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan + window: none + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "gen-12lp" + backend: vulkan + device: "Dell Precision 5570" window: normal results: multiply_add: "33800000" fma: "33800000" - swamped_sum: "00000000" + swamped_sum: "40000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" @@ -2190,212 +2297,120 @@ signatures: exp: "3f8d763d" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087708" + atan2: "3d087ae6" tanh: "3dcc1eb6" - dot: "00000000" - - interface: canvas - label: "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)" - seen: - - browser: firefox - browser_version: "155.0.0" - os: windows - cpu_arch: x86_64 - gpu: intel - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - window: normal - blocked: true - reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" - - interface: canvas-browserleaks - label: "Firefox 155.0.0 on Windows, HP EliteBook (HSN-I6IC)" - seen: - - browser: firefox - browser_version: "155.0.0" - os: windows - cpu_arch: x86_64 - gpu: intel - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - window: normal - blocked: true - reason: "randomised: hash differs between visits in one browser (noise keyed to the session)" - - interface: webgpu-combined - label: "Intel Xe-LPG on Windows, HP EliteBook (HSN-I6IC) -- WebGPU on Firefox 155 (adapter info blanked)" - seen: - - browser: firefox - browser_version: "155" - os: windows - cpu_arch: x86_64 - gpu: intel - gpu_version: "Xe-LPG" - backend: unknown - device: "HP EliteBook (HSN-I6IC)" - reported: "adapter info blanked" - window: normal - results: - multiply_add: "33800000" - fma: "33800000" - swamped_sum: "00000000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" - cos: "3f7eb898" - tan: "3dcd7c38" - exp: "3f8d763d" - log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087708" - tanh: "3dcc1eb6" - dot: "00000000" - - interface: webgpu-split - label: "Apple Paravirtual device through Metal on macOS arm64, GitHub macos-latest runner -- wgpu 29, no browser" + dot: "3f800000" + - interface: host + label: "Rust f32 on the CPU, every probe correctly rounded: macOS arm64, Linux arm64 and x86_64, Windows x64" seen: - browser: none os: macos cpu_arch: aarch64 - gpu: apple - gpu_version: "Apple Paravirtual device" - backend: metal + cpu_version: "Apple M5 Pro" + gpu: none + backend: none + runtime: "Rust f32 and Apple's math library" + window: none + date: "2026-11-12" + - browser: none + os: macos + cpu_arch: aarch64 + gpu: none + backend: none device: "GitHub macos-latest runner" - runtime: "wgpu 29" + runtime: "Rust f32 and Apple's math library" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" - results: - multiply_add: "33800000" - fma: "33800000" - swamped_sum: "00000000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc7576" - cos: "3f7eb896" - tan: "3dcd7c46" - exp: "3f8d763d" - log: "3f8c9f53" - pow: "3f8edd1f" - atan2: "3d087b99" - tanh: "3dcc1eb5" - dot: "00000000" - - interface: webgpu-split - label: "llvmpipe (LLVM 20.1.2, Mesa 25.2.8) through Vulkan on Linux arm64, GitHub runner -- wgpu 29, no browser" - seen: - browser: none os: linux os_version: "Ubuntu 24.04" cpu_arch: aarch64 - gpu: mesa - gpu_version: "llvmpipe (LLVM 20.1.2, 128 bits), Mesa 25.2.8-0ubuntu0.24.04.4" - backend: vulkan + gpu: none + backend: none device: "GitHub ubuntu-24.04-arm runner" - runtime: "wgpu 29" + runtime: "Rust f32 and glibc" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" - results: - multiply_add: "00000000" - fma: "00000000" - swamped_sum: "00000000" - divide: "3eaaaaab" - subnormal_product: "00400000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc7577" - cos: "3f7eb898" - tan: "3dcd7c44" - exp: "3f8d763e" - log: "3f8c9f54" - pow: "3f8edd20" - atan2: "3d087ae6" - tanh: "3dcc1ebe" - dot: "40000000" - - interface: webgpu-split - label: "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for msvc" - seen: - browser: none os: windows cpu_arch: x86_64 - gpu: microsoft - gpu_version: "Basic Render Driver 10.0.26100.33648" - backend: d3d12 + gpu: none + backend: none device: "GitHub windows-latest runner" - runtime: "wgpu 29, built for x86_64-pc-windows-msvc" + runtime: "Rust f32 and the MSVC runtime" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" - results: - multiply_add: "00000000" - fma: "00000000" - swamped_sum: "00000000" - divide: "3eaaaaab" - subnormal_product: "00000000" - sqrt: "3fddb3d7" - inverse_sqrt: "3f13cd3a" - sin: "3dcc756b" - cos: "3f7eb898" - tan: "3dcd7c38" - exp: "3f8d763e" - log: "3f8c9f54" - pow: "3f8edd20" - atan2: "3d087708" - tanh: "3dcc1ebc" - dot: "00000000" - - interface: webgpu-split - label: "Microsoft Basic Render Driver through Direct3D 12 on Windows x64, GitHub runner -- wgpu 29 built for GNU" - seen: - browser: none os: windows cpu_arch: x86_64 - gpu: microsoft - gpu_version: "Basic Render Driver 10.0.26100.33648" - backend: d3d12 + gpu: none + backend: none device: "GitHub windows-latest runner" - runtime: "wgpu 29, built for x86_64-pc-windows-gnullvm" + runtime: "Rust f32, built for x86_64-pc-windows-gnullvm" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" - browser: none os: windows cpu_arch: x86_64 - gpu: microsoft - gpu_version: "Basic Render Driver 10.0.26100.33648" - backend: d3d12 + gpu: none + backend: none device: "GitHub windows-latest runner" - runtime: "wgpu 29, built for x86_64-pc-windows-gnu" + runtime: "Rust f32, built for x86_64-pc-windows-gnu" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" + - browser: none + os: linux + cpu_arch: x86_64 + gpu: none + backend: none + device: "GitHub ubuntu-latest runner" + runtime: "Rust f32 and glibc" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" + - browser: none + os: linux + os_version: "Pop!_OS 24.04" + cpu_arch: x86_64 + cpu_version: "12th Gen Intel Core i7-12800H" + gpu: none + backend: none + device: "Dell Precision 5570" + runtime: "Rust f32 and glibc" + window: none + date: "2026-11-13" results: multiply_add: "00000000" - fma: "00000000" + fma: "33800000" swamped_sum: "00000000" divide: "3eaaaaab" - subnormal_product: "00000000" + subnormal_product: "00400000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756b" + sin: "3dcc7577" cos: "3f7eb898" - tan: "3dcd7c38" + tan: "3dcd7c44" exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd20" - atan2: "3d087708" - tanh: "3dcc1ebe" + atan2: "3d087b99" + tanh: "3dcc1ebc" dot: "00000000" - - interface: webgpu-split - label: "llvmpipe (LLVM 21.1.8, Mesa 26.0.8) through Vulkan on Linux x86_64, GitHub runner -- wgpu 29, no browser" + - interface: wasi-nn + label: "ONNX Runtime 1.22 on the CPU behind wasi:nn: Linux x86_64, where its multiply-add is not fused" seen: - browser: none os: linux cpu_arch: x86_64 - gpu: mesa - gpu_version: "llvmpipe (LLVM 21.1.8, 256 bits), Mesa 26.0.8-1ubuntu0.3" - backend: vulkan + gpu: none + backend: none device: "GitHub ubuntu-latest runner" - runtime: "wgpu 29" + runtime: "ONNX Runtime 1.22" window: none date: "2026-11-12" source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" @@ -2413,163 +2428,261 @@ signatures: exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd20" - atan2: "3d087ae6" - tanh: "3dcc1ebe" - dot: "40000000" - - interface: webgpu-split - label: "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29" + atan2: "3d087b99" + tanh: "3dcc1ebb" + dot: "00000000" + - interface: wasi-nn + label: "ONNX Runtime 1.22 on the CPU behind wasi:nn: macOS arm64 and Linux arm64" seen: + - browser: none + os: macos + cpu_arch: aarch64 + cpu_version: "Apple M5 Pro" + gpu: none + backend: none + runtime: "ONNX Runtime 1.22" + window: none + date: "2026-11-12" + - browser: none + os: macos + cpu_arch: aarch64 + gpu: none + backend: none + device: "GitHub macos-latest runner" + runtime: "ONNX Runtime 1.22" + window: none + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174374" - browser: none os: linux - os_version: "Pop!_OS 24.04" - cpu_arch: x86_64 - cpu_version: "12th Gen Intel Core i7-12800H" - gpu: intel - gpu_version: "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea" - backend: vulkan - device: "Dell Precision 5570" - runtime: "wgpu 29" + os_version: "Ubuntu 24.04" + cpu_arch: aarch64 + gpu: none + backend: none + device: "GitHub ubuntu-24.04-arm runner" + runtime: "ONNX Runtime 1.22" window: none - date: "2026-11-13" + date: "2026-11-12" + source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" results: - multiply_add: "33800000" + multiply_add: "00000000" fma: "33800000" swamped_sum: "40800000" divide: "3eaaaaab" - subnormal_product: "00000000" + subnormal_product: "00400000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" + sin: "3dcc7577" cos: "3f7eb898" - tan: "3dcd7c38" - exp: "3f8d763d" + tan: "3dcd7c44" + exp: "3f8d763e" log: "3f8c9f54" - pow: "3f8edd1f" - atan2: "3d087ae6" - tanh: "3dcc1eb6" - dot: "3f800000" - - interface: webgpu-combined - label: "Intel Iris Xe (Alder Lake GT2) through Mesa's Vulkan driver on Linux, Dell Precision 5570 -- wgpu 29" + pow: "3f8edd20" + atan2: "3d087b99" + tanh: "3dcc1ebb" + dot: "00000000" + - interface: webgpu-clear + label: "Intel(R) Iris(R) Xe Graphics (ADL GT2) through vulkan on linux x86_64 -- wgpu, no browser" seen: - browser: none os: linux - os_version: "Pop!_OS 24.04" cpu_arch: x86_64 - cpu_version: "12th Gen Intel Core i7-12800H" gpu: intel - gpu_version: "Iris Xe (Alder Lake GT2), Mesa 26.1.6-1pop0~1787580452~24.04~a5619ea" + gpu_version: "Intel(R) Iris(R) Xe Graphics (ADL GT2)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan + window: none + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "gen-12lp" backend: vulkan device: "Dell Precision 5570" - runtime: "wgpu 29" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000a" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bb" + srgb8_dark: "00000006" + ufloat11_rounding: "000003c0" + half_rounding: "00003c01" + half_subnormal: "00000010" + half_overflow: "00007c00" + - interface: webgpu-fixed-function + label: "Intel(R) Iris(R) Xe Graphics (ADL GT2) through vulkan on linux x86_64 -- wgpu, no browser" + seen: + - browser: none + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Intel(R) Iris(R) Xe Graphics (ADL GT2)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan + window: none + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "gen-12lp" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + blend_multiply: "00000033" + blend_half_float: "00003c00" + blend_srgb: "000000e1" + filter_third: "3eaa0000" + filter_fine: "00000000" + filter_srgb: "3ddd80de" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "0000000f" + - interface: webgpu-split + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser" + seen: + - browser: none + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + driver_version: "595.104.2.0" + api_version: "Vulkan 1.4.329" + backend: vulkan window: none - date: "2026-11-13" results: multiply_add: "33800000" fma: "33800000" - swamped_sum: "40000000" + swamped_sum: "00000000" divide: "3eaaaaab" subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc756a" - cos: "3f7eb898" - tan: "3dcd7c38" - exp: "3f8d763d" + sin: "3dcc7566" + cos: "3f7eb897" + tan: "3dcd7c34" + exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd1f" - atan2: "3d087ae6" - tanh: "3dcc1eb6" - dot: "3f800000" - - interface: host - label: "Rust f32 on the CPU, every probe correctly rounded: macOS arm64, Linux arm64 and x86_64, Windows x64" + atan2: "3d087b99" + tanh: "3dcc1ebd" + dot: "40000000" + - interface: webgpu-combined + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser" seen: - - browser: none - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: none - backend: none - runtime: "Rust f32 and Apple's math library" - window: none - date: "2026-11-12" - - browser: none - os: macos - cpu_arch: aarch64 - gpu: none - backend: none - device: "GitHub macos-latest runner" - runtime: "Rust f32 and Apple's math library" - window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836477" - browser: none os: linux - os_version: "Ubuntu 24.04" - cpu_arch: aarch64 - gpu: none - backend: none - device: "GitHub ubuntu-24.04-arm runner" - runtime: "Rust f32 and glibc" - window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" - - browser: none - os: windows cpu_arch: x86_64 - gpu: none - backend: none - device: "GitHub windows-latest runner" - runtime: "Rust f32 and the MSVC runtime" + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + driver_version: "595.104.2.0" + api_version: "Vulkan 1.4.329" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836624" + results: + multiply_add: "33800000" + fma: "33800000" + swamped_sum: "00000000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc7566" + cos: "3f7eb897" + tan: "3dcd7c34" + exp: "3f8d763e" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087b99" + tanh: "3dcc1ebd" + dot: "40000000" + - interface: webgpu-clear + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser" + seen: - browser: none - os: windows + os: linux cpu_arch: x86_64 - gpu: none - backend: none - device: "GitHub windows-latest runner" - runtime: "Rust f32, built for x86_64-pc-windows-gnullvm" + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + driver_version: "595.104.2.0" + api_version: "Vulkan 1.4.329" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836502" - - browser: none - os: windows + - browser: chrome + browser_version: "157.0.8103.78" + os: linux cpu_arch: x86_64 - gpu: none - backend: none - device: "GitHub windows-latest runner" - runtime: "Rust f32, built for x86_64-pc-windows-gnu" - window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836594" + gpu: nvidia + gpu_version: "ampere" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000a" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c0" + half_rounding: "00003c00" + half_subnormal: "00000010" + half_overflow: "00007bff" + - interface: webgpu-fixed-function + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux x86_64 -- wgpu, no browser" + seen: - browser: none os: linux cpu_arch: x86_64 - gpu: none - backend: none - device: "GitHub ubuntu-latest runner" - runtime: "Rust f32 and glibc" + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + driver_version: "595.104.2.0" + api_version: "Vulkan 1.4.329" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" - - browser: none + - browser: chrome + browser_version: "157.0.8103.78" os: linux - os_version: "Pop!_OS 24.04" cpu_arch: x86_64 - cpu_version: "12th Gen Intel Core i7-12800H" - gpu: none - backend: none + gpu: nvidia + gpu_version: "ampere" + backend: vulkan device: "Dell Precision 5570" - runtime: "Rust f32 and glibc" + window: normal + results: + blend_multiply: "00000032" + blend_half_float: "00003c01" + blend_srgb: "000000e1" + filter_third: "3eaa00aa" + filter_fine: "00000000" + filter_srgb: "3ddd0000" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "0000000f" + - interface: webgpu-split + label: "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser" + seen: + - browser: none + os: linux + cpu_arch: x86_64 + gpu: mesa + gpu_version: "llvmpipe (LLVM 20.1.2, 256 bits)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan window: none - date: "2026-11-13" results: multiply_add: "00000000" - fma: "33800000" - swamped_sum: "00000000" + fma: "00000000" + swamped_sum: "40800000" divide: "3eaaaaab" - subnormal_product: "00400000" + subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" sin: "3dcc7577" @@ -2578,28 +2691,27 @@ signatures: exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd20" - atan2: "3d087b99" - tanh: "3dcc1ebc" - dot: "00000000" - - interface: wasi-nn - label: "ONNX Runtime 1.22 on the CPU behind wasi:nn: Linux x86_64, where its multiply-add is not fused" + atan2: "3d087ae6" + tanh: "3dcc1ebe" + dot: "40000000" + - interface: webgpu-combined + label: "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser" seen: - browser: none os: linux cpu_arch: x86_64 - gpu: none - backend: none - device: "GitHub ubuntu-latest runner" - runtime: "ONNX Runtime 1.22" + gpu: mesa + gpu_version: "llvmpipe (LLVM 20.1.2, 256 bits)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174690" results: multiply_add: "00000000" fma: "00000000" swamped_sum: "40800000" divide: "3eaaaaab" - subnormal_product: "00400000" + subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" sin: "3dcc7577" @@ -2608,56 +2720,425 @@ signatures: exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd20" - atan2: "3d087b99" - tanh: "3dcc1ebb" - dot: "00000000" - - interface: wasi-nn - label: "ONNX Runtime 1.22 on the CPU behind wasi:nn: macOS arm64 and Linux arm64" + atan2: "3d087ae6" + tanh: "3dcc1ebe" + dot: "40000000" + - interface: webgpu-clear + label: "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser" seen: - browser: none - os: macos - cpu_arch: aarch64 - cpu_version: "Apple M5 Pro" - gpu: none - backend: none - runtime: "ONNX Runtime 1.22" - window: none - date: "2026-11-12" - - browser: none - os: macos - cpu_arch: aarch64 - gpu: none - backend: none - device: "GitHub macos-latest runner" - runtime: "ONNX Runtime 1.22" + os: linux + cpu_arch: x86_64 + gpu: mesa + gpu_version: "llvmpipe (LLVM 20.1.2, 256 bits)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303201/job/131012174374" + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000a" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c1" + half_rounding: "00003c00" + half_subnormal: "00000010" + half_overflow: "00007bff" + - interface: webgpu-fixed-function + label: "llvmpipe (LLVM 20.1.2, 256 bits) through vulkan on linux x86_64 -- wgpu, no browser" + seen: - browser: none os: linux - os_version: "Ubuntu 24.04" - cpu_arch: aarch64 - gpu: none - backend: none - device: "GitHub ubuntu-24.04-arm runner" - runtime: "ONNX Runtime 1.22" + cpu_arch: x86_64 + gpu: mesa + gpu_version: "llvmpipe (LLVM 20.1.2, 256 bits)" + driver_version: "26.1.6" + api_version: "Vulkan 1.4.354" + backend: vulkan window: none - date: "2026-11-12" - source: "https://github.com/edge-toolkit/core/actions/runs/43167303165/job/131012836545" results: - multiply_add: "00000000" + blend_multiply: "00000033" + blend_half_float: "00003c00" + blend_srgb: "000000e1" + filter_third: "3eaaaaab" + filter_fine: "00000000" + filter_srgb: "3dddd65c" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "0000000f" + - interface: webgl2-split + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc7566" + cos: "3f7eb897" + tan: "3dcd7c34" + exp: "3f8d763e" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087b99" + tanh: "3dcc1ebd" + dot: "00000000" + - interface: webgl2-clear + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000a" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c0" + half_rounding: "00003c00" + half_subnormal: "00000010" + half_overflow: "00007bff" + - interface: webgl2-fixed-function + label: "NVIDIA RTX A1000 Laptop GPU through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "NVIDIA RTX A1000 Laptop GPU" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + blend_multiply: "00000032" + blend_half_float: "00003c01" + blend_srgb: "000000e1" + filter_third: "3eaa00aa" + filter_fine: "00000000" + filter_srgb: "3ddd0000" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "0000000f" + - interface: webgpu-split + label: "ampere through vulkan on linux -- WebGPU on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "ampere" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + multiply_add: "33800000" fma: "33800000" swamped_sum: "40800000" divide: "3eaaaaab" - subnormal_product: "00400000" + subnormal_product: "00000000" sqrt: "3fddb3d7" inverse_sqrt: "3f13cd3a" - sin: "3dcc7577" - cos: "3f7eb898" - tan: "3dcd7c44" + sin: "3dcc7566" + cos: "3f7eb897" + tan: "3dcd7c34" + exp: "3f8d763e" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087b99" + tanh: "3dcc1ebd" + dot: "40000000" + - interface: webgpu-combined + label: "ampere through vulkan on linux -- WebGPU on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: nvidia + gpu_version: "ampere" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + multiply_add: "33800000" + fma: "33800000" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc7566" + cos: "3f7eb897" + tan: "3dcd7c34" + exp: "3f8d763e" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087b99" + tanh: "3dcc1ebd" + dot: "40000000" + - interface: webgpu-clear + label: "SwiftShader (software renderer) through vulkan on linux -- WebGPU on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader (software renderer)" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000b" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c1" + half_rounding: "00003c01" + half_subnormal: "00000010" + half_overflow: "00007c46" + - interface: webgpu-fixed-function + label: "SwiftShader (software renderer) through vulkan on linux -- WebGPU on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader (software renderer)" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + blend_multiply: "00000032" + blend_half_float: "00003c01" + blend_srgb: "000000e1" + filter_third: "3eaaaaac" + filter_fine: "3a831201" + filter_srgb: "3ddd090a" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "00000007" + - interface: webgl2-clear + label: "Intel Iris Xe (Alder Lake GT2) through OpenGL (ES) on Linux, Dell Precision 5570 -- WebGL 2" + seen: + - browser: chromium + browser_version: "Claude 5.11492.0 (Chromium 156.0.8078.75)" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.fingerprintingProtection on" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000a" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c1" + half_rounding: "00003c00" + half_subnormal: "00000010" + half_overflow: "00007bff" + - interface: webgl2-fixed-function + label: "Intel Iris Xe (Alder Lake GT2) through OpenGL (ES) on Linux, Dell Precision 5570 -- WebGL 2" + seen: + - browser: chromium + browser_version: "Claude 5.11492.0 (Chromium 156.0.8078.75)" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.fingerprintingProtection on" + window: normal + results: + blend_multiply: "00000033" + blend_half_float: "00003c00" + blend_srgb: "000000e1" + filter_third: "3eaa0000" + filter_fine: "00000000" + filter_srgb: "3ddd80de" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "0000000f" + - interface: webgl2-split + label: "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader Device (Subzero)" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + multiply_add: "00000000" + fma: "????????" + swamped_sum: "00000000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc6e85" + cos: "3f7eadd1" + tan: "3dcd7dfb" exp: "3f8d763e" log: "3f8c9f54" pow: "3f8edd20" atan2: "3d087b99" - tanh: "3dcc1ebb" + tanh: "3dcc1eba" dot: "00000000" + - interface: webgl2-clear + label: "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader Device (Subzero)" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + unorm8_quarter: "0000000a" + unorm8_half: "0000000b" + unorm8_three_quarters: "0000000b" + unorm10_three_quarters: "00000065" + srgb8_mid: "000000bc" + srgb8_dark: "00000007" + ufloat11_rounding: "000003c1" + half_rounding: "00003c01" + half_subnormal: "00000010" + half_overflow: "00007c00" + - interface: webgl2-fixed-function + label: "SwiftShader Device (Subzero) through vulkan on linux -- WebGL 2 on chrome 157.0.8103.78" + seen: + - browser: chrome + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: google + gpu_version: "SwiftShader Device (Subzero)" + backend: vulkan + device: "Dell Precision 5570" + window: normal + results: + blend_multiply: "00000032" + blend_half_float: "00003c01" + blend_srgb: "000000e1" + filter_third: "3eaaaaac" + filter_fine: "3a831201" + filter_srgb: "3ddd090a" + raster_right_edge_fine: "00000007" + raster_left_edge_fine: "000000f8" + raster_right_edge_coarse: "00000007" + - interface: canvas-browserleaks + label: "Firefox 160.0 on Linux -- canvas 2D, PNG encoding keyed to the session" + seen: + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.fingerprintingProtection on" + window: normal + blocked: true + reason: "randomised: the PNG's bytes differ between visits in one browser while the pixels they decode to do not" diff --git a/libs/gpu-probe/src/canvas.rs b/libs/gpu-probe/src/canvas.rs new file mode 100644 index 00000000..342b9cfc --- /dev/null +++ b/libs/gpu-probe/src/canvas.rs @@ -0,0 +1,151 @@ +//! The canvas fingerprints, taken beside the probe sets because a browser's defences treat a canvas read apart. +//! +//! Two are taken, since a defence can cover one way of making and reading a canvas and not another. [`CANVAS`] is +//! a fixed scene on a canvas the page made, read with `getImageData` and hashed with [`pixel_hash`]; it records the +//! hash's four words as `lane0` to `lane3`. [`CANVAS_BROWSERLEAKS`] is the canvas fingerprint browserleaks.com takes, +//! drawn on a canvas a sandboxed frame made and read with `toDataURL`; it records the MD5 of that PNG as `md5`, the +//! value that site shows as its signature in the same browser. + +/// The interface a page's own canvas scene, read with `getImageData`, is filed under. +pub const CANVAS: &str = "canvas"; + +/// The interface the canvas fingerprint browserleaks.com takes is filed under, read with `toDataURL` in a frame. +pub const CANVAS_BROWSERLEAKS: &str = "canvas-browserleaks"; + +/// The offset each of the hash's four lanes starts from. +const LANE_SEEDS: [u32; 4] = [0x811c_9dc5, 0x1b87_3593, 0x9e37_79b1, 0x85eb_ca77]; + +/// The odd multiplier each lane takes a byte in with. +const LANE_PRIMES: [u32; 4] = [16_777_619, 2_246_822_519, 3_266_489_917, 668_265_263]; + +/// How many bytes the lanes take between folds. +const FOLD_EVERY: usize = 64; + +/// The width and height of the known scene, in pixels. +pub const KNOWN_SIZE: u8 = 64; + +/// The width and height of each of the known scene's blocks, in pixels. +pub const KNOWN_BLOCK: u8 = 8; + +/// How many blocks the known scene has across and down, which [`KNOWN_BLOCK`] pixels each make [`KNOWN_SIZE`]. +const KNOWN_BLOCKS: u8 = 8; + +/// The opaque colour the known scene's block `across` from the left and `down` from the top is filled with. +/// +/// The known scene is blocks of flat opaque colour aligned to the pixel grid, which every renderer draws exactly, so a +/// read of it that gives back any other value was changed on the way out: noise is seen within a single visit, with no +/// unprotected browser to compare against. +#[must_use] +pub const fn known_colour(across: u8, down: u8) -> [u8; 3] { + [ + across + .wrapping_mul(37) + .wrapping_add(down.wrapping_mul(11)) + .wrapping_add(20), + across + .wrapping_mul(59) + .wrapping_add(down.wrapping_mul(23)) + .wrapping_add(70), + across + .wrapping_mul(17) + .wrapping_add(down.wrapping_mul(101)) + .wrapping_add(130), + ] +} + +/// The RGBA values a faithful read of the whole known scene gives back, row by row. +#[must_use] +pub fn known_scene() -> Vec { + let block = usize::from(KNOWN_BLOCK); + (0..KNOWN_BLOCKS) + .flat_map(|down| { + let row: Vec = (0..KNOWN_BLOCKS) + .flat_map(|across| { + let [red, green, blue] = known_colour(across, down); + [red, green, blue, u8::MAX].repeat(block) + }) + .collect(); + row.repeat(block) + }) + .collect() +} + +/// How a read of the known scene differed from what was drawn. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct Alteration { + /// How many of the values read back differ from the ones drawn. + pub changed: usize, + /// How many values were read back. + pub total: usize, + /// The [`pixel_hash`] of where and how the values differ, or nothing where none do. + /// + /// It tells one noise from another. + pub pattern: String, +} + +/// A read whose length is not that of the scene it read. +#[derive(Debug)] +#[non_exhaustive] +pub struct ReadLength { + /// How many values were read back. + pub read: usize, + /// How many the scene holds. + pub expected: usize, +} + +impl core::fmt::Display for ReadLength { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "read {} values, not {}", self.read, self.expected) + } +} + +impl core::error::Error for ReadLength {} + +/// How `read` differs from `expected`, value by value. +/// +/// # Errors +/// +/// [`ReadLength`] when `read` holds a different number of values from `expected`. +pub fn alteration(read: &[u8], expected: &[u8]) -> Result { + if read.len() != expected.len() { + return Err(ReadLength { + read: read.len(), + expected: expected.len(), + }); + } + let difference: Vec = read.iter().zip(expected).map(|(got, drawn)| got ^ drawn).collect(); + let changed = difference.iter().filter(|value| **value != 0).count(); + let pattern = if changed == 0 { + String::default() + } else { + pixel_hash(&difference).map(|lane| format!("{lane:08x}")).join("-") + }; + Ok(Alteration { + changed, + total: read.len(), + pattern, + }) +} + +/// A 128-bit hash of a canvas's pixels, as four 32-bit words. +/// +/// Four independent multiply-xor lanes each take every byte, and after each 64 bytes the first two are folded into the +/// last two. It is the hash the registry's [`CANVAS`] entries were taken with, so a signature taken here is compared +/// with them as it is. +#[must_use] +pub fn pixel_hash(bytes: &[u8]) -> [u32; 4] { + let mut lanes = LANE_SEEDS; + for block in bytes.chunks(FOLD_EVERY) { + for byte in block { + for (lane, prime) in lanes.iter_mut().zip(LANE_PRIMES) { + *lane = (*lane ^ u32::from(*byte)).wrapping_mul(prime); + } + } + if block.len() == FOLD_EVERY { + let [first, second, third, fourth] = lanes; + lanes = [first, second, third ^ (first >> 13_u32), fourth ^ (second >> 7_u32)]; + } + } + lanes +} diff --git a/libs/gpu-probe/src/clear.rs b/libs/gpu-probe/src/clear.rs new file mode 100644 index 00000000..04ac6122 --- /dev/null +++ b/libs/gpu-probe/src/clear.rs @@ -0,0 +1,101 @@ +//! Probes that run no shader: a render pass clears a one-texel target to a value, and the texel is read back. +//! +//! The clear value is given as a float, and the driver or the GPU's output stage converts it to the target's format. +//! That conversion is left loose: rounding to the nearest code is preferred and not required, how closely the sRGB +//! curve is followed is not pinned down, and a half-precision subnormal or overflow may be kept or not. No shader +//! compiler stands between the value and the texel, so what this set tells apart is the hardware and the driver alone. + +use crate::Format; + +/// How many clears the set makes. +pub const CLEAR_COUNT: usize = 10; + +/// Every clear, in output order. +/// +/// The first three put the same 8-bit code a quarter, a half and three quarters of the way to the next, and the 10-bit +/// one three quarters: rounding gives the next code where truncating keeps the lower. The half-way one is only half-way +/// as near as an f64 holds it, so which way it goes says how the conversion was computed. The two sRGB ones take the +/// curve's curved and straight parts. The float ones put a value three quarters of the way between two codes, below the +/// smallest normal half, and above the largest finite one. +pub const CLEARS: [Clear; CLEAR_COUNT] = [ + Clear { + name: "unorm8_quarter", + format: Format::Rgba8Unorm, + value: 10.25 / 255.0, + }, + Clear { + name: "unorm8_half", + format: Format::Rgba8Unorm, + value: 10.5 / 255.0, + }, + Clear { + name: "unorm8_three_quarters", + format: Format::Rgba8Unorm, + value: 10.75 / 255.0, + }, + Clear { + name: "unorm10_three_quarters", + format: Format::Rgb10a2Unorm, + value: 100.75 / 1023.0, + }, + Clear { + name: "srgb8_mid", + format: Format::Rgba8UnormSrgb, + value: 0.5, + }, + Clear { + name: "srgb8_dark", + format: Format::Rgba8UnormSrgb, + value: 0.002, + }, + Clear { + name: "ufloat11_rounding", + format: Format::Rg11b10Ufloat, + value: 1.0 + 0.75 / 64.0, + }, + Clear { + name: "half_rounding", + format: Format::Rgba16Float, + value: 1.0 + 0.75 / 1024.0, + }, + Clear { + name: "half_subnormal", + format: Format::Rgba16Float, + value: 1.0 / 1_048_576.0, + }, + Clear { + name: "half_overflow", + format: Format::Rgba16Float, + value: 70_000.0, + }, +]; + +/// For each clear with more than one answer worth naming: its name, then each answer's code beside what it means. +pub(crate) const CHOICES: [(&str, &[(u32, &str)]); 6] = [ + ("unorm8_three_quarters", &[(11, "rounded"), (10, "truncated")]), + ("unorm10_three_quarters", &[(101, "rounded"), (100, "truncated")]), + ("ufloat11_rounding", &[(0x3c1, "rounded"), (0x3c0, "truncated")]), + ("half_rounding", &[(0x3c01, "rounded"), (0x3c00, "truncated")]), + ("half_subnormal", &[(0x0010, "kept"), (0, "flushed")]), + ("half_overflow", &[(0x7c00, "infinity"), (0x7bff, "clamped")]), +]; + +/// One clear: the target's format, and the value its red channel is cleared to. +/// +/// The target's green and blue channels are cleared to zero and its alpha to one, and only red is read back. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Clear { + /// The probe's name. + pub name: &'static str, + /// The format of the target cleared. + pub format: Format, + /// The value the target's red channel is cleared to. + pub value: f64, +} + +/// The code each clear's red channel holds when the value is converted correctly, in output order. +#[must_use] +pub fn clear_reference() -> Vec { + CLEARS.iter().map(|clear| clear.format.encode(clear.value)).collect() +} diff --git a/libs/gpu-probe/src/fixed.rs b/libs/gpu-probe/src/fixed.rs new file mode 100644 index 00000000..051536c0 --- /dev/null +++ b/libs/gpu-probe/src/fixed.rs @@ -0,0 +1,404 @@ +//! Probes of the GPU's fixed-function stages: blending, texture filtering and rasterization. +//! +//! Each still draws with a shader, but one that only hands the stage a constant or a coordinate: the arithmetic whose +//! result is read back is done by the blend unit, the texture unit or the rasterizer, not by anything a shader compiler +//! emits. Each draws one triangle into a target one texel high and reads the target back. + +use crate::Format; + +/// How many fixed-function probes the set holds. +pub const FIXED_FUNCTION_COUNT: usize = 9; + +/// How many texels wide the target a raster probe draws into is. +pub const RASTER_WIDTH: u32 = 8; + +/// The format of the target a raster probe draws into, whose red channel is set wherever the triangle covers. +pub const RASTER_FORMAT: Format = Format::Rgba8Unorm; + +/// Every blend probe, in output order. +/// +/// The first multiplies 101 by one half, a tie between two 8-bit codes. The second adds to one a value three quarters +/// of the way to the next half-precision float. The third adds to an sRGB code, which the blend has to decode and +/// encode again. +pub const BLENDS: [Blend; 3] = [ + Blend { + name: "blend_multiply", + format: Format::Rgba8Unorm, + destination: 101, + source: 0.5, + operation: Operation::Multiply, + }, + Blend { + name: "blend_half_float", + format: Format::Rgba16Float, + destination: 0x3c00, + source: 1.0 / 2048.0 + 1.0 / 8192.0, + operation: Operation::Add, + }, + Blend { + name: "blend_srgb", + format: Format::Rgba8UnormSrgb, + destination: 188, + source: 0.25, + operation: Operation::Add, + }, +]; + +/// Every filter probe, in output order. +/// +/// The first two sample between black and white a third and a thousandth of the way: a texture unit keeps only so many +/// bits of the weight, and the thousandth is below what 8 bits hold. The third samples halfway between two sRGB codes, +/// which is a different value when the texels are filtered before they are decoded rather than after. Each coordinate +/// is midway between 0.5 and the weight, since the two texel centres sit at a quarter and three quarters. +pub const FILTERS: [Filter; 3] = [ + Filter { + name: "filter_third", + format: Format::Rgba8Unorm, + texels: [0, 255], + coordinate: f32::midpoint(0.5, 1.0 / 3.0), + }, + Filter { + name: "filter_fine", + format: Format::Rgba8Unorm, + texels: [0, 255], + coordinate: f32::midpoint(0.5, 1.0 / 1000.0), + }, + Filter { + name: "filter_srgb", + format: Format::Rgba8UnormSrgb, + texels: [0, 128], + coordinate: 0.5, + }, +]; + +/// Every raster probe, in output order. +/// +/// Each puts its edge a little right of the centre of texel 3, at 3.5: by 2^-10 of a texel, which a rasterizer that +/// snaps vertices to 8 fractional bits moves onto the centre, and by 2^-6, which one with only 4 bits does. A centre on +/// the edge is covered when it is the triangle's left edge and not when it is its right, so snapping shows. +pub const RASTERS: [Raster; 3] = [ + Raster { + name: "raster_right_edge_fine", + edge: 3.5 + 1.0 / 1024.0, + side: Side::Left, + }, + Raster { + name: "raster_left_edge_fine", + edge: 3.5 + 1.0 / 1024.0, + side: Side::Right, + }, + Raster { + name: "raster_right_edge_coarse", + edge: 3.5 + 1.0 / 64.0, + side: Side::Left, + }, +]; + +/// For each fixed-function probe with more than one answer worth naming: its name, then each answer beside its meaning. +pub(crate) const CHOICES: [(&str, &[(u32, &str)]); 4] = [ + ("blend_half_float", &[(0x3c01, "rounded"), (0x3c00, "truncated")]), + ("raster_right_edge_fine", &[(0x0f, "exact"), (0x07, "snapped")]), + ("raster_left_edge_fine", &[(0xf0, "exact"), (0xf8, "snapped")]), + ("raster_right_edge_coarse", &[(0x0f, "exact"), (0x07, "snapped")]), +]; + +/// The vertices of a triangle covering the whole of any target, in clip space. +const FULL_SCREEN: [[f32; 2]; 3] = [[-1.0, -1.0], [3.0, -1.0], [-1.0, 3.0]]; + +/// What a blend probe's blend unit does with the colour the shader gives and the colour the target holds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Operation { + /// The product of the two: source factor the destination colour, destination factor zero, added. + Multiply, + /// The sum of the two: both factors one, added. + Add, +} + +/// A blend probe: a constant colour blended into a one-texel target that holds a code to start with. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Blend { + /// The probe's name. + pub name: &'static str, + /// The format of the target. + pub format: Format, + /// The code the target's red channel holds before the draw, uploaded as a texel of [`Format::texel`]. + pub destination: u32, + /// The value the shader gives every channel of the colour it blends in. + pub source: f32, + /// What the blend unit does with the two. + pub operation: Operation, +} + +/// A filter probe: a two-texel texture sampled with linear filtering between its texels, at one coordinate. +/// +/// The texture is two texels wide and one high, and is sampled at level 0 with a linear minification and magnification +/// filter and clamp-to-edge addressing, at ([`Filter::coordinate`], 0.5). Its red channels hold [`Filter::texels`]. The +/// sample's red channel is drawn into a one-texel `r32float` target and read back as bits. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Filter { + /// The probe's name. + pub name: &'static str, + /// The format of the sampled texture. + pub format: Format, + /// The codes the texture's two texels hold in their red channels, left first. + pub texels: [u32; 2], + /// The horizontal coordinate sampled at, from 0 to 1 across the texture. + pub coordinate: f32, +} + +/// Which side of its vertical edge a raster probe's triangle lies on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Side { + /// The triangle lies left of the edge, which is then its right edge. + Left, + /// The triangle lies right of the edge, which is then its left edge. + Right, +} + +/// A raster probe: a triangle with one vertical edge, drawn across a target [`RASTER_WIDTH`] texels wide. +/// +/// The triangle covers the whole height of the target, so which texels it covers depends only on where its edge falls +/// against their centres, and is read back as a mask: bit `i` set when texel `i` was covered. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Raster { + /// The probe's name. + pub name: &'static str, + /// Where the vertical edge falls, in texels from the target's left side. + pub edge: f32, + /// Which side of the edge the triangle lies on. + pub side: Side, +} + +/// Every fixed-function probe's name, in output order: the blends, then the filters, then the rasters. +#[expect( + clippy::single_call_fn, + reason = "kept beside the three lists it reads, which the probe set is built from elsewhere" +)] +pub(crate) fn names() -> Vec<&'static str> { + BLENDS + .iter() + .map(|blend| blend.name) + .chain(FILTERS.iter().map(|filter| filter.name)) + .chain(RASTERS.iter().map(|raster| raster.name)) + .collect() +} + +impl Blend { + /// The WGSL that draws the probe: a triangle over the whole target, in [`Blend::source`] on every channel. + /// + /// Its entry points are `vertex` and `fragment`, and it binds nothing. + #[must_use] + pub fn shader(&self) -> String { + let source = literal(self.source); + let colour = format!("vec4({source}, {source}, {source}, {source})"); + render_shader(FULL_SCREEN, "", &colour) + } + + /// The GLSL ES 3.00 vertex and fragment shaders that draw the probe on WebGL 2, as [`Blend::shader`] draws it. + /// + /// They bind nothing, and draw three vertices as triangles with no attributes. + #[must_use] + pub fn glsl(&self) -> [String; 2] { + let source = glsl_literal(self.source); + glsl_program(FULL_SCREEN, "", &format!("vec4({source})")) + } + + /// The code the target's red channel holds after the draw, computed exactly and rounded once. + #[must_use] + #[expect(clippy::float_arithmetic, reason = "a blend is float arithmetic on the two colours")] + pub fn reference(&self) -> u32 { + let destination = self.format.decode(self.destination); + let source = f64::from(self.source); + self.format.encode(match self.operation { + Operation::Multiply => source * destination, + Operation::Add => source + destination, + }) + } +} + +impl Filter { + /// The WGSL that draws the probe: a triangle over the one-texel target, in the sample's red channel. + /// + /// Its entry points are `vertex` and `fragment`, and it binds the texture at binding 0 and the sampler at 1. + #[must_use] + pub fn shader(&self) -> String { + let bindings = concat!( + "@group(0) @binding(0) var probeTexture : texture_2d;\n", + "@group(0) @binding(1) var probeSampler : sampler;\n" + ); + let point = format!("vec2({}, 0.5)", literal(self.coordinate)); + let sample = format!("textureSampleLevel(probeTexture, probeSampler, {point}, 0.0).r"); + let colour = format!("vec4({sample}, 0.0, 0.0, 1.0)"); + render_shader(FULL_SCREEN, bindings, &colour) + } + + /// The GLSL ES 3.00 vertex and fragment shaders that draw the probe on WebGL 2, as [`Filter::shader`] draws it. + /// + /// The fragment shader samples the texture bound to its `probeTexture` uniform, whose own parameters give the + /// filtering and addressing the probe calls for. + #[must_use] + pub fn glsl(&self) -> [String; 2] { + let point = format!("vec2({}, 0.5)", glsl_literal(self.coordinate)); + let colour = format!("vec4(textureLod(probeTexture, {point}, 0.0).r, 0.0, 0.0, 1.0)"); + glsl_program(FULL_SCREEN, "uniform highp sampler2D probeTexture;\n", &colour) + } + + /// The bits of the f32 the sample's red channel is, filtered exactly between the decoded texels. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "linear filtering is float arithmetic on the two texels" + )] + pub fn reference(&self) -> u32 { + let [left, right] = self.texels.map(|code| self.format.decode(code)); + let weight = f64::from(self.coordinate).mul_add(2.0_f64, -0.5_f64); + crate::nearest((right - left).mul_add(weight, left)).to_bits() + } +} + +impl Raster { + /// The WGSL that draws the probe: its triangle, in white. + /// + /// Its entry points are `vertex` and `fragment`, and it binds nothing. + #[must_use] + pub fn shader(&self) -> String { + render_shader(self.corners(), "", "vec4(1.0)") + } + + /// The GLSL ES 3.00 vertex and fragment shaders that draw the probe on WebGL 2, as [`Raster::shader`] draws it. + /// + /// They bind nothing, and draw three vertices as triangles with no attributes. + #[must_use] + pub fn glsl(&self) -> [String; 2] { + glsl_program(self.corners(), "", "vec4(1.0)") + } + + /// The triangle's corners in clip space: two on the edge, above and below the target, and one far to its side. + #[expect( + clippy::float_arithmetic, + reason = "the edge's clip-space position is its texel position scaled by a power of two, and exact" + )] + fn corners(&self) -> [[f32; 2]; 3] { + let across = self.edge / 4.0 - 1.0; + let far = match self.side { + Side::Left => -5.0, + Side::Right => 5.0, + }; + [[across, -3.0], [across, 3.0], [far, 0.0]] + } + + /// The mask of the texels the triangle covers, with no snapping. + /// + /// A centre on the edge is counted as a rasterizer counts it: covered for a left edge, not for a right one. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "a texel's centre is half a texel past its left side" + )] + pub fn reference(&self) -> u32 { + (0..RASTER_WIDTH) + .filter(|texel| { + let centre = f64::from(*texel) + 0.5_f64; + let edge = f64::from(self.edge); + match self.side { + Side::Left => centre < edge, + Side::Right => centre >= edge, + } + }) + .fold(0, |mask, texel| mask | (1_u32 << texel)) + } +} + +/// The mask of the texels of a raster probe's target that the triangle covered, from the target's bytes. +/// +/// Bit `i` is set when the red channel of texel `i` is not zero. +#[must_use] +pub fn coverage(texels: &[u8]) -> u32 { + let texel_bytes = usize::try_from(RASTER_FORMAT.texel_bytes()).unwrap_or(usize::MAX); + texels + .chunks(texel_bytes) + .zip(0..RASTER_WIDTH) + .filter(|(texel, _)| texel.first().is_some_and(|red| *red != 0)) + .fold(0, |mask, (_, index)| mask | (1_u32 << index)) +} + +/// The answer each fixed-function probe gives when computed exactly, in output order. +#[must_use] +pub fn fixed_function_reference() -> Vec { + BLENDS + .iter() + .map(Blend::reference) + .chain(FILTERS.iter().map(Filter::reference)) + .chain(RASTERS.iter().map(Raster::reference)) + .collect() +} + +/// `value` as a WGSL expression for exactly that f32, which a decimal literal can only round to. +fn literal(value: f32) -> String { + format!("bitcast({:#010x}u)", value.to_bits()) +} + +/// A WGSL vertex and fragment shader drawing the triangle `corners`, in clip space, in the colour `colour`. +/// +/// `bindings` declares whatever `colour` reads. The entry points are `vertex` and `fragment`. +fn render_shader(corners: [[f32; 2]; 3], bindings: &str, colour: &str) -> String { + let corners = corner_list(corners, "vec2", literal); + format!( + "{bindings} +@vertex +fn vertex(@builtin(vertex_index) index : u32) -> @builtin(position) vec4 {{ + var corners = array, 3>({corners}); + return vec4(corners[index], 0.0, 1.0); +}} + +@fragment +fn fragment() -> @location(0) vec4 {{ + return {colour}; +}} +" + ) +} + +/// `value` as a GLSL ES 3.00 expression for exactly that f32, which a decimal literal can only round to. +fn glsl_literal(value: f32) -> String { + format!("uintBitsToFloat({:#010x}u)", value.to_bits()) +} + +/// GLSL ES 3.00 vertex and fragment shaders drawing the triangle `corners`, in clip space, in the colour `colour`. +/// +/// `declarations` declares whatever `colour` reads. The vertex shader takes each corner from `gl_VertexID`. +fn glsl_program(corners: [[f32; 2]; 3], declarations: &str, colour: &str) -> [String; 2] { + let corners = corner_list(corners, "vec2", glsl_literal); + let vertex = format!( + "#version 300 es +void main() {{ + vec2 corners[3] = vec2[3]({corners}); + gl_Position = vec4(corners[gl_VertexID], 0.0, 1.0); +}} +" + ); + let fragment = format!( + "#version 300 es +precision highp float; +{declarations}out highp vec4 probeOut; +void main() {{ + probeOut = {colour}; +}} +" + ); + [vertex, fragment] +} + +/// `corners` as a comma-separated list of `constructor` calls, each coordinate written exactly by `literal`. +fn corner_list(corners: [[f32; 2]; 3], constructor: &str, literal: fn(f32) -> String) -> String { + corners + .iter() + .map(|[across, upward]| format!("{constructor}({}, {})", literal(*across), literal(*upward))) + .collect::>() + .join(", ") +} diff --git a/libs/gpu-probe/src/format.rs b/libs/gpu-probe/src/format.rs new file mode 100644 index 00000000..cf1c9051 --- /dev/null +++ b/libs/gpu-probe/src/format.rs @@ -0,0 +1,244 @@ +//! The texture formats the probes with no shader of their own write and sample, and how each stores a value. + +/// The largest code an 8-bit normalized channel holds. +const UNORM8_MAX: f64 = 255.0; + +/// The largest code a 10-bit normalized channel holds. +const UNORM10_MAX: f64 = 1023.0; + +/// The mantissa bits of an IEEE half, which a 16-bit float channel holds. +const HALF_MANTISSA: u32 = 10; + +/// The mantissa bits of the unsigned 11-bit float a red or green channel of `rg11b10ufloat` holds. +const UFLOAT11_MANTISSA: u32 = 6; + +/// The exponent field of a small float that marks infinity: every bit of its five set. +const SMALL_FLOAT_INFINITE_EXPONENT: u32 = 31; + +/// The exponent a small float's smallest normal value has, which its subnormal values are scaled by too. +const SMALL_FLOAT_MIN_EXPONENT: i32 = -14; + +/// The offset a small float's five-bit exponent field holds its exponent at. +const SMALL_FLOAT_BIAS: i32 = 15; + +/// The offset an f64's exponent field holds its exponent at. +const DOUBLE_BIAS: i32 = 1023; + +/// The linear value below which the sRGB curve is a straight line. +const SRGB_LINEAR_LIMIT: f64 = 0.003_130_8; + +/// The slope of the straight part of the sRGB curve. +const SRGB_SLOPE: f64 = 12.92; + +/// The encoded value below which the sRGB curve is a straight line. +const SRGB_ENCODED_LIMIT: f64 = 0.040_45; + +/// The exponent of the curved part of the sRGB curve. +const SRGB_GAMMA: f64 = 2.4; + +/// The offset the curved part of the sRGB curve is shifted by. +const SRGB_OFFSET: f64 = 0.055; + +/// A texture format a probe writes or samples, named as WebGPU names it. +/// +/// Every format's texel holds its red channel in its lowest bits, so the first four bytes of a texel read in little- +/// endian order give the red channel under a mask, which is how [`Format::red`] reads it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Format { + /// Four 8-bit normalized channels. + Rgba8Unorm, + /// Four 8-bit normalized channels, the colour ones holding an sRGB encoding of the value. + Rgba8UnormSrgb, + /// Three 10-bit normalized colour channels and a 2-bit normalized alpha. + Rgb10a2Unorm, + /// Unsigned floats: 11 bits for red and green, 10 for blue, and no alpha. + Rg11b10Ufloat, + /// Four IEEE half-precision floats. + Rgba16Float, +} + +impl Format { + /// The name WebGPU gives the format. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Rgba8Unorm => "rgba8unorm", + Self::Rgba8UnormSrgb => "rgba8unorm-srgb", + Self::Rgb10a2Unorm => "rgb10a2unorm", + Self::Rg11b10Ufloat => "rg11b10ufloat", + Self::Rgba16Float => "rgba16float", + } + } + + /// How many bytes one texel of the format takes. + #[must_use] + pub const fn texel_bytes(self) -> u32 { + match self { + Self::Rgba16Float => 8, + Self::Rgba8Unorm | Self::Rgba8UnormSrgb | Self::Rgb10a2Unorm | Self::Rg11b10Ufloat => 4, + } + } + + /// How many bits of the texel the red channel takes, starting from the lowest. + const fn red_bits(self) -> u32 { + match self { + Self::Rgba8Unorm | Self::Rgba8UnormSrgb => 8, + Self::Rgb10a2Unorm => 10, + Self::Rg11b10Ufloat => 11, + Self::Rgba16Float => 16, + } + } + + /// The code the red channel holds, read from the first four bytes of a texel as the GPU laid them out. + #[must_use] + #[expect( + clippy::little_endian_bytes, + reason = "WebGPU lays a texel's channels out in little-endian order on every platform" + )] + pub fn red(self, first_word: [u8; 4]) -> u32 { + u32::from_le_bytes(first_word) & low_bits(self.red_bits()) + } + + /// The code the format holds `value` as, rounded correctly: to the nearest code, a tie to the even one. + /// + /// A normalized format holds the value clamped to [0, 1], and an sRGB one its sRGB encoding. A float format's value + /// is taken to be zero or more; one past the format's largest is infinity. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "the code a value is held as is a product of the value and the format's scale" + )] + pub fn encode(self, value: f64) -> u32 { + match self { + Self::Rgba8Unorm => whole(value.clamp(0.0, 1.0) * UNORM8_MAX), + Self::Rgba8UnormSrgb => whole(srgb_encode(value.clamp(0.0, 1.0)) * UNORM8_MAX), + Self::Rgb10a2Unorm => whole(value.clamp(0.0, 1.0) * UNORM10_MAX), + Self::Rg11b10Ufloat => small_float(value, UFLOAT11_MANTISSA), + Self::Rgba16Float => small_float(value, HALF_MANTISSA), + } + } + + /// The value the red channel's `code` stands for, as a sampler or a blend reads it. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "a normalized code stands for its quotient by the format's scale" + )] + pub fn decode(self, code: u32) -> f64 { + match self { + Self::Rgba8Unorm => f64::from(code) / UNORM8_MAX, + Self::Rgba8UnormSrgb => srgb_decode(f64::from(code) / UNORM8_MAX), + Self::Rgb10a2Unorm => f64::from(code) / UNORM10_MAX, + Self::Rg11b10Ufloat => small_float_value(code, UFLOAT11_MANTISSA), + Self::Rgba16Float => small_float_value(code, HALF_MANTISSA), + } + } + + /// The bytes of one texel whose red channel holds `code`, its green and blue zero, and its alpha, if any, one. + #[must_use] + #[expect( + clippy::little_endian_bytes, + reason = "WebGPU lays a texel's channels out in little-endian order on every platform" + )] + pub fn texel(self, code: u32) -> Vec { + let red = code & low_bits(self.red_bits()); + let word = match self { + Self::Rgba8Unorm | Self::Rgba8UnormSrgb => red | 0xff00_0000, + Self::Rgb10a2Unorm => red | 0xc000_0000, + Self::Rg11b10Ufloat => red, + Self::Rgba16Float => { + let one_alpha: u64 = 0x3c00_0000_0000_0000; + return (u64::from(red) | one_alpha).to_le_bytes().to_vec(); + } + }; + word.to_le_bytes().to_vec() + } +} + +/// A word with its lowest `count` bits set, `count` being 16 or fewer. +fn low_bits(count: u32) -> u32 { + 1_u32.checked_shl(count).map_or(u32::MAX, |bit| bit.saturating_sub(1)) +} + +/// `value`, a whole number from zero to `u32::MAX` once rounded, rounded to the nearest one, a tie to the even one. +#[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the value is rounded to a whole number in range first, and std has no other conversion from f64" +)] +const fn whole(value: f64) -> u32 { + value.round_ties_even() as u32 +} + +/// The sRGB encoding of `linear`, a value from zero to one. +#[expect( + clippy::float_arithmetic, + clippy::single_call_fn, + reason = "the sRGB curve is float arithmetic, kept beside its inverse" +)] +fn srgb_encode(linear: f64) -> f64 { + if linear <= SRGB_LINEAR_LIMIT { + linear * SRGB_SLOPE + } else { + (1.0 + SRGB_OFFSET).mul_add(linear.powf(SRGB_GAMMA.recip()), -SRGB_OFFSET) + } +} + +/// The linear value whose sRGB encoding is `encoded`, a value from zero to one. +#[expect( + clippy::float_arithmetic, + clippy::single_call_fn, + reason = "the sRGB curve is float arithmetic, kept beside its inverse" +)] +fn srgb_decode(encoded: f64) -> f64 { + if encoded <= SRGB_ENCODED_LIMIT { + encoded / SRGB_SLOPE + } else { + ((encoded + SRGB_OFFSET) / (1.0 + SRGB_OFFSET)).powf(SRGB_GAMMA) + } +} + +/// The bits of `value`, zero or more, as a float with a five-bit exponent, `mantissa` mantissa bits and no sign. +/// +/// The value is scaled by a power of two to put its last kept bit at one, which is exact, and rounded once. The rounded +/// mantissa carries its leading one into the exponent field, so a value that rounds up to the next power of two, and a +/// subnormal one that rounds up to the smallest normal, each land on the right bits with no case of its own. Past the +/// largest finite value the bits are infinity's. +#[expect( + clippy::float_arithmetic, + reason = "scaling by a power of two is exact, and it puts the rounding where the format rounds" +)] +fn small_float(value: f64, mantissa: u32) -> u32 { + let field = (value.to_bits() >> 52_u32) & 0x7ff; + let exponent = i32::try_from(field) + .unwrap_or(i32::MAX) + .saturating_sub(DOUBLE_BIAS) + .max(SMALL_FLOAT_MIN_EXPONENT); + let shift = i32::try_from(mantissa).unwrap_or(0_i32).saturating_sub(exponent); + let quantum = whole(value * 2.0_f64.powi(shift)); + let biased = u32::try_from(exponent.saturating_add(SMALL_FLOAT_BIAS).saturating_sub(1)).unwrap_or(0_u32); + let bits = (biased << mantissa).saturating_add(quantum); + bits.min(SMALL_FLOAT_INFINITE_EXPONENT << mantissa) +} + +/// The value the bits of a float with a five-bit exponent, `mantissa` mantissa bits and no sign stand for. +#[expect( + clippy::float_arithmetic, + reason = "a float's value is its mantissa scaled by a power of two" +)] +fn small_float_value(bits: u32, mantissa: u32) -> f64 { + let fraction = bits & low_bits(mantissa); + let field = bits >> mantissa; + let width = i32::try_from(mantissa).unwrap_or(0_i32); + if field == 0 { + return f64::from(fraction) * 2.0_f64.powi(SMALL_FLOAT_MIN_EXPONENT.saturating_sub(width)); + } + if field >= SMALL_FLOAT_INFINITE_EXPONENT { + return f64::INFINITY; + } + let exponent = i32::try_from(field).unwrap_or(0_i32).saturating_sub(SMALL_FLOAT_BIAS); + let significand = f64::from(fraction.saturating_add(1_u32 << mantissa)); + significand * 2.0_f64.powi(exponent.saturating_sub(width)) +} diff --git a/libs/gpu-probe/src/lib.rs b/libs/gpu-probe/src/lib.rs index cca5e4ec..3dd6b451 100644 --- a/libs/gpu-probe/src/lib.rs +++ b/libs/gpu-probe/src/lib.rs @@ -10,18 +10,42 @@ //! It drives no GPU itself, so the same probes serve a browser's WebGPU, a native host's, and a caller with none. Each //! probe is a shader of its own: a device that refuses one expression still answers the rest, and a signature with gaps //! in it identifies a device better than no signature. +//! +//! Two more probe sets reach the GPU where no shader compiler decides the answer. [`CLEARS`] clears a target to a +//! value with no shader at all, so the driver or the output stage converts the value to the target's format. The fixed- +//! function set -- [`BLENDS`], [`FILTERS`] and [`RASTERS`] -- draws with a shader that only hands the blend unit, the +//! texture unit or the rasterizer a constant, so the arithmetic read back is that stage's. #![cfg_attr(feature = "docs", doc = "## Feature flags")] #![cfg_attr(feature = "docs", doc = document_features::document_features!())] +#[cfg(feature = "registry")] +pub use platforms::OS; + +mod canvas; +mod clear; +mod fixed; +mod format; +#[cfg(feature = "native")] +pub mod native; #[cfg(feature = "registry")] mod registry; #[cfg(feature = "registry")] mod sighting; +pub use self::canvas::{ + Alteration, CANVAS, CANVAS_BROWSERLEAKS, KNOWN_BLOCK, KNOWN_SIZE, ReadLength, alteration, known_colour, + known_scene, pixel_hash, +}; +pub use self::clear::{CLEAR_COUNT, CLEARS, Clear, clear_reference}; +pub use self::fixed::{ + BLENDS, Blend, FILTERS, FIXED_FUNCTION_COUNT, Filter, Operation, RASTER_FORMAT, RASTER_WIDTH, RASTERS, Raster, + Side, coverage, fixed_function_reference, +}; +pub use self::format::Format; #[cfg(feature = "registry")] pub use self::registry::every_sighting; #[cfg(feature = "registry")] -pub use self::sighting::{Backend, Browser, GpuVendor, OperatingSystem, RowError, Sighting, Window}; +pub use self::sighting::{Backend, Browser, GpuVendor, RowError, Sighting, Window}; /// How many expressions the probe set evaluates. pub const PROBE_COUNT: usize = 16; @@ -62,6 +86,18 @@ pub const HOST: &str = "host"; /// The interface a signature was measured through, where the probes ran as ONNX operators behind `wasi:nn`. pub const WASI_NN: &str = "wasi-nn"; +/// The interface a signature of [`CLEARS`] was measured through, each a render pass clearing a target on WebGPU. +pub const WEBGPU_CLEAR: &str = "webgpu-clear"; + +/// The interface a signature of the fixed-function probes was measured through, each drawn on WebGPU. +pub const WEBGPU_FIXED_FUNCTION: &str = "webgpu-fixed-function"; + +/// The interface a signature of [`CLEARS`] was measured through, each a `clearBufferfv` of a target on WebGL 2. +pub const WEBGL2_CLEAR: &str = "webgl2-clear"; + +/// The interface a signature of the fixed-function probes was measured through, each drawn on WebGL 2. +pub const WEBGL2_FIXED_FUNCTION: &str = "webgl2-fixed-function"; + /// Each probe's name, the WGSL expression it evaluates, and the same expression in GLSL ES 3.00, in output order. /// /// The first five and the last have an exact answer under strict evaluation and at least one other, equally legal one, @@ -265,6 +301,42 @@ const KNOWN: [(&str, [&str; PROBE_COUNT], &str); 7] = [ /// What a device is classed as when no known GPU answers the probe set as it did. pub const UNRECOGNISED: &str = "unrecognised"; +/// The answers a probe set names: a probe's name, then each answer beside what giving it means. +type Choices = [(&'static str, &'static [(u32, &'static str)])]; + +/// A probe set: each probe's name and correct answer, in output order, and the answers it names. +struct ProbeSet { + /// Each probe's name, in output order. + names: Vec<&'static str>, + /// Each probe's correct answer, in output order. + reference: Vec, + /// The answers the set names. + choices: &'static Choices, +} + +impl ProbeSet { + /// The probe set a signature measured through `interface` answers. + fn measured_through(interface: &str) -> Self { + match interface { + WEBGPU_CLEAR | WEBGL2_CLEAR => Self { + names: CLEARS.iter().map(|probe| probe.name).collect(), + reference: clear_reference(), + choices: &clear::CHOICES, + }, + WEBGPU_FIXED_FUNCTION | WEBGL2_FIXED_FUNCTION => Self { + names: fixed::names(), + reference: fixed_function_reference(), + choices: &fixed::CHOICES, + }, + _ => Self { + names: PROBES.iter().map(|probe| probe.0).collect(), + reference: reference().to_vec(), + choices: &CHOICES, + }, + } + } +} + /// Each probe's name beside the WGSL compute shader that evaluates it, in output order. /// /// Every shader reads [`INPUTS`] from an 8-element `f32` storage buffer at binding 0 and writes its result's bits to a @@ -318,7 +390,7 @@ pub fn fragment_shaders() -> Vec<(&'static str, String)> { clippy::cast_possible_truncation, reason = "rounding an f64 to the nearest f32 is the conversion wanted, and std offers no other spelling of it" )] -const fn nearest(value: f64) -> f32 { +pub(crate) const fn nearest(value: f64) -> f32 { value as f32 } @@ -438,16 +510,28 @@ impl Signature { .count() } - /// How many probes the device answered with bits other than the correctly rounded ones. + /// How many probes the device answered with bits other than the correct ones. #[must_use] pub fn deviations(&self) -> usize { self.observed .iter() - .zip(reference()) + .zip(ProbeSet::measured_through(self.interface).reference) .filter(|pair| matches!(pair.0, Answer::Bits(bits) if *bits != pair.1)) .count() } + /// Each probe's name beside its result as a hex word, `????????` where the device gave none, in output order. + /// + /// These are an entry's `results` in the registry. + #[must_use] + pub fn results(&self) -> Vec<(&'static str, String)> { + ProbeSet::measured_through(self.interface) + .names + .into_iter() + .zip(self.words()) + .collect() + } + /// Every result as a hex word, `????????` where the device gave none, joined by `-`. /// /// Two devices have the same fingerprint exactly when they answered every probe the same way. @@ -471,8 +555,7 @@ impl Signature { /// The label of every registry entry holding the answers this device gave, through the same interface. /// /// A label names every device, browser and backend the registry has seen the signature on. No label means the - /// registry has not seen it, and more than one that it is filed more than once, as a private window's is beside a - /// normal one's. + /// registry has not seen it, and more than one that it is filed more than once. /// /// # Stability /// @@ -499,18 +582,21 @@ impl Signature { registry::seen(self.interface, &self.words()) } - /// Which legal answer the device gave to each probe that has two, as `name=choice` joined by spaces. + /// Which named answer the device gave to each probe of its set that has more than one. /// - /// A probe the device did not answer reads `failed`, and an answer that is neither of the two reads `other`. + /// Each is `name=choice`, joined by spaces. A probe the device did not answer reads `failed`, and an answer that is + /// none of those named reads `other`. #[must_use] pub fn traits(&self) -> String { - CHOICES + let set = ProbeSet::measured_through(self.interface); + set.choices .iter() .map(|choice| { - let chosen = PROBES + let chosen = set + .names .iter() .zip(&self.observed) - .find(|pair| pair.0.0 == choice.0) + .find(|pair| *pair.0 == choice.0) .map_or("failed", |pair| match pair.1 { Answer::Bits(bits) => choice .1 @@ -527,20 +613,21 @@ impl Signature { /// One line per probe, saying what the device did with it. /// - /// An answered probe gives its name, the device's bits, the correctly rounded bits, and the distance between them - /// in units in the last place. A refused one gives its name and the reason. + /// An answered probe gives its name, the device's bits, the correct bits, and the distance between them in units + /// in the last place -- of the f32, or of the format a clear or a blend wrote. A refused one gives its name and + /// the reason. #[must_use] pub fn describe(&self) -> Vec { - PROBES + let set = ProbeSet::measured_through(self.interface); + set.names .iter() - .zip(self.observed.iter().zip(reference())) - .map(|(probe, (answer, correct))| match answer { + .zip(self.observed.iter().zip(set.reference)) + .map(|(name, (answer, correct))| match answer { Answer::Bits(bits) => format!( - "{} gpu={bits:08x} correct={correct:08x} ulps={:+}", - probe.0, + "{name} gpu={bits:08x} correct={correct:08x} ulps={:+}", i64::from(*bits).saturating_sub(i64::from(correct)) ), - Answer::Refused(reason) => format!("{} failed: {reason}", probe.0), + Answer::Refused(reason) => format!("{name} failed: {reason}"), }) .collect() } diff --git a/libs/gpu-probe/src/native.rs b/libs/gpu-probe/src/native.rs new file mode 100644 index 00000000..3eb85d8a --- /dev/null +++ b/libs/gpu-probe/src/native.rs @@ -0,0 +1,892 @@ +//! Puts the probe sets to a GPU through wgpu, and reads what the device says it is. +//! +//! Built natively, wgpu opens the machine's GPUs itself and the device is described from the API it was opened through. +//! Built for `wasm32`, wgpu opens the browser's WebGPU, so the same probes run in a page. Every call that waits on the +//! GPU is async, since a page cannot block for it; a native caller blocks on each with an executor. +//! +//! The device is asked for the one optional feature a clear needs, rendering to `rg11b10ufloat`, when the adapter has +//! it; without it that clear is refused, which its signature records like any other refusal. +#![expect( + clippy::single_call_fn, + reason = "each step of opening the device, running a probe set and reading the device is a named function" +)] +#![cfg_attr( + target_arch = "wasm32", + expect( + clippy::future_not_send, + reason = "a page's WebGPU objects are JavaScript's, which stay on the one thread a page has" + ) +)] + +#[cfg(target_vendor = "apple")] +use objc2_metal::MTLDevice as _; +#[cfg(windows)] +use windows::core::Interface as _; + +use crate::{ + Answer, BLENDS, Blend, CLEARS, Clear, FILTERS, Filter, Format, INPUTS, Operation, PROBE_COUNT, RASTER_FORMAT, + RASTER_WIDTH, RASTERS, Raster, Signature, WEBGPU, WEBGPU_CLEAR, WEBGPU_COMBINED, WEBGPU_FIXED_FUNCTION, + combined_shader, coverage, shaders, +}; + +/// How many bytes the storage buffer every probe shader reads [`INPUTS`] from holds. +#[expect( + clippy::as_conversions, + reason = "a few dozen bytes fit any u64, and a const has no fallible conversion from usize" +)] +const INPUT_BYTES: u64 = size_of_val(&INPUTS) as u64; + +/// How many bytes the one `u32` every probe shader writes takes. +const OUTPUT_BYTES: u64 = 4; + +/// How many bytes the readback of a signature's answers holds: one word a probe. +#[expect( + clippy::as_conversions, + reason = "a few dozen probes fit any u64, and a const has no fallible conversion from usize" +)] +const SIGNATURE_BYTES: u64 = PROBE_COUNT as u64 * OUTPUT_BYTES; + +/// How many bytes of the readback buffer each probe drawn into a target has. +/// +/// A texture copy's offset into the buffer is aligned to this. +const SLOT_BYTES: u64 = 256; + +/// The format a filter probe draws its sample into, read back as the f32 it is. +const FILTER_TARGET: wgpu::TextureFormat = wgpu::TextureFormat::R32Float; + +/// Each graphics API wgpu opens a device through, beside the name the registry gives it. +const BACKENDS: [(wgpu::Backend, &str); 4] = [ + (wgpu::Backend::Vulkan, "vulkan"), + (wgpu::Backend::Metal, "metal"), + (wgpu::Backend::Dx12, "d3d12"), + (wgpu::Backend::Gl, "opengl"), +]; + +/// Every Metal GPU family, beside the name Apple gives it. +#[cfg(target_vendor = "apple")] +const METAL_FAMILIES: [(objc2_metal::MTLGPUFamily, &str); 16] = [ + (objc2_metal::MTLGPUFamily::Apple1, "apple1"), + (objc2_metal::MTLGPUFamily::Apple2, "apple2"), + (objc2_metal::MTLGPUFamily::Apple3, "apple3"), + (objc2_metal::MTLGPUFamily::Apple4, "apple4"), + (objc2_metal::MTLGPUFamily::Apple5, "apple5"), + (objc2_metal::MTLGPUFamily::Apple6, "apple6"), + (objc2_metal::MTLGPUFamily::Apple7, "apple7"), + (objc2_metal::MTLGPUFamily::Apple8, "apple8"), + (objc2_metal::MTLGPUFamily::Apple9, "apple9"), + (objc2_metal::MTLGPUFamily::Apple10, "apple10"), + (objc2_metal::MTLGPUFamily::Mac2, "mac2"), + (objc2_metal::MTLGPUFamily::Common1, "common1"), + (objc2_metal::MTLGPUFamily::Common2, "common2"), + (objc2_metal::MTLGPUFamily::Common3, "common3"), + (objc2_metal::MTLGPUFamily::Metal3, "metal3"), + (objc2_metal::MTLGPUFamily::Metal4, "metal4"), +]; + +/// What stopped the probes being put to the GPU. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum NativeError { + /// wgpu found no adapter to open. + #[error("no GPU adapter could be opened: {0}")] + Adapter(#[from] wgpu::RequestAdapterError), + /// The adapter would not open a device. + #[error("the GPU adapter would not open a device: {0}")] + Device(#[from] wgpu::RequestDeviceError), + /// Waiting for the device to finish the probes failed. + #[error("the GPU did not finish the probes: {0}")] + Poll(#[from] wgpu::PollError), + /// The buffer the answers were copied to could not be read. + #[error("the GPU's answers could not be read back: {0}")] + Readback(#[from] wgpu::BufferAsyncError), + /// wgpu dropped the buffer's mapping callback without ever calling it. + #[error("the GPU never said whether its answers could be read back: {0}")] + Unmapped(#[from] futures_channel::oneshot::Canceled), +} + +/// A probe as a device holds it: the pipeline that evaluates it, or the device's reason for refusing to build one. +type Probe = Result; + +/// What a device says it is, read from wgpu and from the API beneath it. +#[derive(Debug)] +#[non_exhaustive] +pub struct Report { + /// Every value, one `name: value` line each. + pub lines: Vec, + /// The values a registry row has a field for: `driver_version` and `api_version`, as far as the API gives them. + pub versions: Vec<(&'static str, String)>, +} + +/// This machine's GPU, opened through wgpu, with the probe set compiled for it. +pub struct Gpu { + /// The adapter the device was opened on. + adapter: wgpu::Adapter, + /// The whole probe set as one shader. + combined: Probe, + /// The device the probes run on. + device: wgpu::Device, + /// The probe set, in output order. + probes: Vec, + /// Where the device's work is submitted. + queue: wgpu::Queue, +} + +/// How a probe drawn into a target reads its answer from the bytes of the target it copied back. +#[derive(Clone, Copy)] +enum Read { + /// The red channel of the first texel, in the format given. + Red(Format), + /// The first four bytes, as the f32 an `r32float` texel holds. + Word, + /// The mask of the texels a raster probe's triangle covered. + Coverage, +} + +/// A probe drawn into a target: the target, how its pass starts, what the pass draws, and how its answer is read. +struct Pass { + /// What the pass draws: a pipeline, and the bind group it reads, if it reads one. A clear draws nothing. + draw: Option<(wgpu::RenderPipeline, Option)>, + /// How the pass starts: clearing the target to a value, or keeping what was uploaded to it. + load: wgpu::LoadOp, + /// How the answer is read from the target's bytes. + read: Read, + /// The target, one texel high. + target: wgpu::Texture, +} + +impl Gpu { + /// Open the adapter wgpu picks by default, and compile the probe set for it. + pub async fn open() -> Result { + Self::open_with(&wgpu::RequestAdapterOptions::default()).await + } + + /// Open the adapter wgpu, or the browser behind it, chooses for `options`, with the probe set compiled for it. + /// + /// A browser offers its GPUs only this way, one for each power preference and a fallback adapter when asked for + /// one, so asking with each is how a page reaches every GPU it can. + pub async fn open_with(options: &wgpu::RequestAdapterOptions<'_, '_>) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = instance.request_adapter(options).await?; + Self::open_adapter(adapter).await + } + + /// Open every adapter wgpu finds through Vulkan, Metal and Direct3D 12, with the probe set compiled for each. + /// + /// They come in the order wgpu lists them. A software renderer, such as llvmpipe or WARP, is opened beside the + /// GPUs: it answers as the CPU's own arithmetic does through that renderer, which is a signature of its own. So is + /// a GPU reached through two of those APIs, which is opened once through each. + /// + /// Each adapter gives its own outcome, so one whose device will not open -- a broken driver, or a software renderer + /// that rejects the default limits -- stops none of the others being measured. + #[cfg(not(target_arch = "wasm32"))] + pub async fn open_all() -> Vec> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let mut gpus = Vec::new(); + for adapter in instance.enumerate_adapters(wgpu::Backends::PRIMARY).await { + gpus.push(Self::open_adapter(adapter).await); + } + gpus + } + + /// Open a device on `adapter`, and compile the probe set for it. + /// + /// For a caller that chose the adapter itself, from an instance of its own: one limited to a backend, say. + pub async fn open_adapter(adapter: wgpu::Adapter) -> Result { + let descriptor = wgpu::DeviceDescriptor { + required_features: adapter.features() & wgpu::Features::RG11B10UFLOAT_RENDERABLE, + ..wgpu::DeviceDescriptor::default() + }; + let (device, queue) = adapter.request_device(&descriptor).await?; + let mut probes = Vec::new(); + for shader in shaders() { + probes.push(compile(&device, &shader.1).await); + } + Ok(Self { + combined: compile(&device, &combined_shader()).await, + probes, + adapter, + device, + queue, + }) + } + + /// What wgpu reports the adapter is. + #[must_use] + pub fn info(&self) -> wgpu::AdapterInfo { + self.adapter.get_info() + } + + /// Whether the device may render to `rg11b10ufloat`, without which that clear is refused. + #[must_use] + pub fn renders_ufloat11(&self) -> bool { + self.device + .features() + .contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) + } + + /// The name the registry gives the graphics API the device was opened through, or `unknown`. + #[must_use] + pub fn backend(&self) -> &'static str { + let backend = self.info().backend; + BACKENDS + .iter() + .find(|known| known.0 == backend) + .map_or("unknown", |known| known.1) + } + + /// What the probe set answers on this GPU, each probe in a shader of its own. + pub async fn signature(&self) -> Result { + let stages: Vec<(&Probe, u64)> = self.probes.iter().map(|probe| (probe, 1)).collect(); + Ok(Signature::new(WEBGPU, self.answers(&stages).await?)) + } + + /// What the probe set answers on this GPU, every probe in the one combined shader. + pub async fn combined_signature(&self) -> Result { + let words = u64::try_from(PROBE_COUNT).unwrap_or(u64::MAX); + let answers = self.answers(&[(&self.combined, words)]).await?; + Ok(Signature::new(WEBGPU_COMBINED, answers)) + } + + /// What this GPU answers to the clears. + pub async fn clear_signature(&self) -> Result { + let mut passes = Vec::new(); + for clear in &CLEARS { + passes.push(self.attempt(|| self.clear_pass(clear)).await); + } + Ok(Signature::new(WEBGPU_CLEAR, self.pass_answers(&passes).await?)) + } + + /// What this GPU answers to the fixed-function probes. + pub async fn fixed_function_signature(&self) -> Result { + let mut passes = Vec::new(); + for blend in &BLENDS { + passes.push(self.attempt(|| self.blend_pass(blend)).await); + } + for filter in &FILTERS { + passes.push(self.attempt(|| self.filter_pass(filter)).await); + } + for raster in &RASTERS { + passes.push(self.attempt(|| self.raster_pass(raster)).await); + } + Ok(Signature::new(WEBGPU_FIXED_FUNCTION, self.pass_answers(&passes).await?)) + } + + /// What the adapter and device say the GPU is. + /// + /// The first lines are what wgpu reports for every backend. The rest are read from the API the device was opened + /// through, for whichever of Vulkan, Metal or Direct3D 12 it is: the version numbers there are the device's own, + /// not anything wgpu infers. Each backend's reader finds nothing when the device was not opened through it. + #[must_use] + pub fn report(&self) -> Report { + let adapter = &self.adapter; + let info = adapter.get_info(); + #[cfg_attr( + target_arch = "wasm32", + expect( + unused_mut, + reason = "in a page there is no API beneath wgpu to add to the report from" + ) + )] + let mut report = Report { + lines: vec![ + format!("name: {}", info.name), + format!("vendor id: {:#06x}", info.vendor), + format!("device id: {:#06x}", info.device), + format!("device type: {:?}", info.device_type), + format!("pci bus id: {}", info.device_pci_bus_id), + format!("driver: {}", info.driver), + format!("driver info: {}", info.driver_info), + format!("backend: {}", info.backend), + format!( + "subgroup sizes: {}..={}", + info.subgroup_min_size, info.subgroup_max_size + ), + format!("features: {:?}", adapter.features()), + format!("downlevel: {:?}", adapter.get_downlevel_capabilities()), + format!("limits: {:?}", adapter.limits()), + ], + versions: Vec::new(), + }; + #[cfg(not(any(target_vendor = "apple", target_arch = "wasm32")))] + vulkan_report(adapter, &mut report); + #[cfg(target_vendor = "apple")] + metal_report(&self.device, &mut report); + #[cfg(windows)] + dx12_report(adapter, &mut report); + report + } + + /// The pass `build` makes, or the device's reason for refusing a texture, pipeline or upload it asked for. + async fn attempt(&self, build: Build) -> Result + where + Build: FnOnce() -> Pass, + { + let scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation); + let pass = build(); + scope.pop().await.map_or(Ok(pass), Err) + } + + /// The pass that clears a target of the clear's format to its value, with nothing drawn. + fn clear_pass(&self, clear: &Clear) -> Pass { + Pass { + draw: None, + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear.value, + g: 0.0, + b: 0.0, + a: 1.0, + }), + read: Read::Red(clear.format), + target: self.target(texture_format(clear.format), 1), + } + } + + /// The pass that blends the probe's source into a target already holding its destination. + fn blend_pass(&self, blend: &Blend) -> Pass { + let format = texture_format(blend.format); + let target = self.target(format, 1); + self.upload(&target, &blend.format.texel(blend.destination)); + let component = blend_component(blend.operation); + let state = wgpu::BlendState { + color: component, + alpha: component, + }; + Pass { + draw: Some((self.render_pipeline(&blend.shader(), format, Some(state)), None)), + load: wgpu::LoadOp::Load, + read: Read::Red(blend.format), + target, + } + } + + /// The pass that samples the probe's two-texel texture into a one-texel `r32float` target. + fn filter_pass(&self, filter: &Filter) -> Pass { + let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let source = self.texture(texture_format(filter.format), 2, usage); + let texels: Vec = filter + .texels + .iter() + .flat_map(|code| filter.format.texel(*code)) + .collect(); + self.upload(&source, &texels); + let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..wgpu::SamplerDescriptor::default() + }); + let pipeline = self.render_pipeline(&filter.shader(), FILTER_TARGET, None); + let view = source.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = self.bind_two( + &pipeline.get_bind_group_layout(0), + wgpu::BindingResource::TextureView(&view), + wgpu::BindingResource::Sampler(&sampler), + ); + Pass { + draw: Some((pipeline, Some(bind_group))), + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + read: Read::Word, + target: self.target(FILTER_TARGET, 1), + } + } + + /// The pass that draws the probe's triangle across a cleared target [`RASTER_WIDTH`] texels wide. + fn raster_pass(&self, raster: &Raster) -> Pass { + let format = texture_format(RASTER_FORMAT); + Pass { + draw: Some((self.render_pipeline(&raster.shader(), format, None), None)), + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + read: Read::Coverage, + target: self.target(format, RASTER_WIDTH), + } + } + + /// A texture of `format`, `width` texels by one, for the uses `usage` names. + fn texture(&self, format: wgpu::TextureFormat, width: u32, usage: wgpu::TextureUsages) -> wgpu::Texture { + self.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + } + + /// A target of `format`, `width` texels by one, that a pass draws into and is copied back from. + fn target(&self, format: wgpu::TextureFormat, width: u32) -> wgpu::Texture { + let usage = + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST; + self.texture(format, width, usage) + } + + /// Upload `texels`, one row of them, to the whole of `texture`. + fn upload(&self, texture: &wgpu::Texture, texels: &[u8]) { + self.queue.write_texture( + texture.as_image_copy(), + texels, + wgpu::TexelCopyBufferLayout::default(), + texture.size(), + ); + } + + /// The render pipeline drawing the shader `wgsl` into a target of `format`, blending with `blend`. + fn render_pipeline( + &self, + wgsl: &str, + format: wgpu::TextureFormat, + blend: Option, + ) -> wgpu::RenderPipeline { + let module = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(wgsl.into()), + }); + self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vertex"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fragment"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) + } + + /// A buffer of `size` bytes, for the uses `usage` names. + fn buffer(&self, size: u64, usage: wgpu::BufferUsages) -> wgpu::Buffer { + self.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage, + mapped_at_creation: false, + }) + } + + /// A bind group of `layout` holding `first` at binding 0 and `second` at binding 1. + fn bind_two( + &self, + layout: &wgpu::BindGroupLayout, + first: wgpu::BindingResource<'_>, + second: wgpu::BindingResource<'_>, + ) -> wgpu::BindGroup { + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: first, + }, + wgpu::BindGroupEntry { + binding: 1, + resource: second, + }, + ], + }) + } + + /// Submit `encoder`'s commands, wait for them, and give back the bytes they left in `readback`. + /// + /// Natively the poll runs the mapping callback before it returns; in a page the browser runs it later, and the wait + /// is on the channel the callback answers through. + async fn submit_and_read( + &self, + encoder: wgpu::CommandEncoder, + readback: &wgpu::Buffer, + ) -> Result, NativeError> { + let _submission: wgpu::SubmissionIndex = self.queue.submit([encoder.finish()]); + let (mapped, outcome) = futures_channel::oneshot::channel::>(); + readback.map_async(wgpu::MapMode::Read, .., move |result| { + // The receiver is awaited below, so it is still there to take this unless the whole read was abandoned. + let _sent: Result<(), Result<(), wgpu::BufferAsyncError>> = mapped.send(result); + }); + let _status: wgpu::PollStatus = self.device.poll(wgpu::PollType::wait_indefinitely())?; + outcome.await??; + Ok(readback.get_mapped_range(..).to_vec()) + } + + /// What this GPU answers to `passes`, all of them run in one submission, each copied to a slot of its own. + /// + /// A pass the device refused answers with the refusal. + #[expect( + clippy::little_endian_bytes, + reason = "WebGPU lays a texel out in little-endian order on every platform" + )] + async fn pass_answers(&self, passes: &[Result]) -> Result, NativeError> { + let slots = u64::try_from(passes.len()).unwrap_or(u64::MAX); + let readback = self.buffer( + slots.saturating_mul(SLOT_BYTES), + wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + ); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + // A refused pass keeps its slot but encodes nothing; it answers with its refusal below. Every pass is accepted + // on the software renderer the coverage lane runs on, so the refusal is filtered out rather than branched on. + let accepted = (0_u64..) + .zip(passes) + .filter_map(|(slot, pass)| Some(slot).zip(pass.as_ref().ok())); + for (slot, pass) in accepted { + encode(&mut encoder, pass, &readback, slot.saturating_mul(SLOT_BYTES)); + } + let held = self.submit_and_read(encoder, &readback).await?; + let slot_bytes = usize::try_from(SLOT_BYTES).unwrap_or(usize::MAX); + Ok(passes + .iter() + .zip(held.chunks(slot_bytes)) + .map(|(pass, bytes)| { + let word = bytes.first_chunk::<4>().copied().unwrap_or_default(); + pass.as_ref().map_or_else( + |refusal| Answer::Refused(refusal.to_string()), + |pass| { + Answer::Bits(match pass.read { + Read::Red(format) => format.red(word), + Read::Word => u32::from_le_bytes(word), + Read::Coverage => coverage(bytes), + }) + }, + ) + }) + .collect()) + } + + /// What this GPU answers to `stages`, all of them run in one submission. + /// + /// A stage is a shader as the device holds it, beside how many probes it evaluates: each of the probe set's shaders + /// evaluates one, and the combined shader all of them. A stage the device refused answers each of its probes with + /// the refusal. + #[expect( + clippy::host_endian_bytes, + reason = "a GPU buffer holds its values in the machine's own byte order" + )] + async fn answers(&self, stages: &[(&Probe, u64)]) -> Result, NativeError> { + let input = self.buffer(INPUT_BYTES, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST); + let input_bytes: Vec = INPUTS.iter().flat_map(|value| value.to_ne_bytes()).collect(); + self.queue.write_buffer(&input, 0, &input_bytes); + let readback = self.buffer( + SIGNATURE_BYTES, + wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + ); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + // Each stage's answers sit at an offset of their own, a refused stage's left unwritten; it answers with its + // refusal below. Every stage compiles on the software renderer the coverage lane runs on, so the refusal is + // filtered out rather than branched on. + let mut offset = 0_u64; + let placed: Vec<_> = stages + .iter() + .map(|(probe, probes)| { + let (bytes, start) = (probes.saturating_mul(OUTPUT_BYTES), offset); + offset = offset.saturating_add(bytes); + (probe, bytes, start) + }) + .collect(); + let compiled = placed + .into_iter() + .filter_map(|(probe, bytes, start)| probe.as_ref().ok().map(|pipeline| (pipeline, bytes, start))); + for (pipeline, bytes, start) in compiled { + let output = self.buffer(bytes, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); + self.run(&mut encoder, pipeline, &input, &output); + encoder.copy_buffer_to_buffer(&output, 0, &readback, start, bytes); + } + let held = self.submit_and_read(encoder, &readback).await?; + let mut words = held.as_chunks::<4>().0.iter(); + let mut answers = Vec::new(); + for (probe, probes) in stages { + for _probe in 0..*probes { + let word = words.next().copied().unwrap_or_default(); + answers.push(probe.as_ref().map_or_else( + |refusal| Answer::Refused(refusal.to_string()), + |_pipeline| Answer::Bits(u32::from_ne_bytes(word)), + )); + } + } + Ok(answers) + } + + /// Add to `encoder` a run of `pipeline`, reading `input` and leaving what it writes in `output`. + fn run( + &self, + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::ComputePipeline, + input: &wgpu::Buffer, + output: &wgpu::Buffer, + ) { + let bind_group = self.bind_two( + &pipeline.get_bind_group_layout(0), + input.as_entire_binding(), + output.as_entire_binding(), + ); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(1, 1, 1); + } +} + +/// The wgpu format `format` is. +const fn texture_format(format: Format) -> wgpu::TextureFormat { + match format { + Format::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, + Format::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb, + Format::Rgb10a2Unorm => wgpu::TextureFormat::Rgb10a2Unorm, + Format::Rg11b10Ufloat => wgpu::TextureFormat::Rg11b10Ufloat, + Format::Rgba16Float => wgpu::TextureFormat::Rgba16Float, + } +} + +/// The factors and operation that make the blend `operation`, the same for colour and alpha. +const fn blend_component(operation: Operation) -> wgpu::BlendComponent { + let (src_factor, dst_factor) = match operation { + Operation::Multiply => (wgpu::BlendFactor::Dst, wgpu::BlendFactor::Zero), + Operation::Add => (wgpu::BlendFactor::One, wgpu::BlendFactor::One), + }; + wgpu::BlendComponent { + src_factor, + dst_factor, + operation: wgpu::BlendOperation::Add, + } +} + +/// The pipeline on `device` that runs the shader `wgsl` holds. +/// +/// A shader the device will not compile, or a pipeline it will not build, is a refusal, which the error scope catches. +async fn compile(device: &wgpu::Device, wgsl: &str) -> Probe { + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(wgsl.into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: None, + layout: None, + module: &module, + entry_point: Some("main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + cache: None, + }); + scope.pop().await.map_or(Ok(pipeline), Err) +} + +/// Add to `encoder` `pass`, and a copy of its target to `readback` at `offset`. +fn encode(encoder: &mut wgpu::CommandEncoder, pass: &Pass, readback: &wgpu::Buffer, offset: u64) { + let view = pass.target.create_view(&wgpu::TextureViewDescriptor::default()); + { + let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: pass.load, + store: wgpu::StoreOp::Store, + }, + })], + ..wgpu::RenderPassDescriptor::default() + }); + if let Some((pipeline, bind_group)) = &pass.draw { + render.set_pipeline(pipeline); + if let Some(bind_group) = bind_group { + render.set_bind_group(0, bind_group, &[]); + } + render.draw(0..3, 0..1); + } + } + encoder.copy_texture_to_buffer( + pass.target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: readback, + layout: wgpu::TexelCopyBufferLayout { + offset, + bytes_per_row: None, + rows_per_image: None, + }, + }, + pass.target.size(), + ); +} + +/// `version`, a Vulkan driver version reported by the vendor with the PCI ID `vendor`, in that vendor's encoding. +/// +/// Vulkan leaves the encoding to the vendor. NVIDIA's packs four fields, Intel's on Windows -- which `on_windows` says +/// the driver is running on -- two, and every other driver on record uses the API version's own layout. +#[cfg(not(target_vendor = "apple"))] +#[must_use] +pub fn vulkan_driver_version(vendor: u32, version: u32, on_windows: bool) -> String { + if vendor == 0x10de { + return format!( + "{}.{}.{}.{}", + version >> 22_u32, + (version >> 14_u32) & 0xff, + (version >> 6_u32) & 0xff, + version & 0x3f + ); + } + if vendor == 0x8086 && on_windows { + return format!("{}.{}", version >> 14_u32, version & 0x3fff); + } + vulkan_api_version(version) +} + +/// `version`, a version in the layout Vulkan gives its API versions: variant, major, minor and patch. +#[cfg(not(target_vendor = "apple"))] +#[must_use] +pub fn vulkan_api_version(version: u32) -> String { + let semantic = format!( + "{}.{}.{}", + (version >> 22_u32) & 0x7f, + (version >> 12_u32) & 0x3ff, + version & 0xfff + ); + match version >> 29_u32 { + 0 => semantic, + variant => format!("{semantic} (variant {variant})"), + } +} + +/// Add to `report` what Vulkan says the GPU is, when `adapter` was opened through Vulkan. +/// +/// wgpu builds its Vulkan backend everywhere but on Apple's platforms and in a page. +#[cfg(not(any(target_vendor = "apple", target_arch = "wasm32")))] +#[expect( + unsafe_code, + reason = "wgpu hands out the Vulkan adapter beneath it only through an unsafe call" +)] +fn vulkan_report(adapter: &wgpu::Adapter, report: &mut Report) { + // SAFETY: the Vulkan adapter is only read from, and is not kept past this function. + let hal = unsafe { adapter.as_hal::() }; + // An adapter opened through another API has no Vulkan side, and adds nothing here: a Direct3D 12 one on Windows, + // and on every platform one on wgpu's no-op backend. + let Some(vulkan) = hal else { return }; + let properties = vulkan.physical_device_capabilities().properties(); + let limits = properties.limits; + let uuid = properties + .pipeline_cache_uuid + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .concat(); + let name = properties + .device_name_as_c_str() + .map_or_else(|error| error.to_string(), |name| name.to_string_lossy().into_owned()); + let api = vulkan_api_version(properties.api_version); + let driver = vulkan_driver_version(properties.vendor_id, properties.driver_version, cfg!(windows)); + report.lines.extend([ + format!("vulkan device name: {name}"), + format!("vulkan device type: {:?}", properties.device_type), + format!("vulkan vendor id: {:#06x}", properties.vendor_id), + format!("vulkan device id: {:#06x}", properties.device_id), + format!("vulkan api version: {api}"), + format!( + "vulkan driver version: {driver} (raw {:#010x})", + properties.driver_version + ), + format!("vulkan pipeline cache uuid: {uuid}"), + format!("vulkan sub-pixel precision bits: {}", limits.sub_pixel_precision_bits), + format!("vulkan sub-texel precision bits: {}", limits.sub_texel_precision_bits), + format!("vulkan mipmap precision bits: {}", limits.mipmap_precision_bits), + format!("vulkan viewport sub-pixel bits: {}", limits.viewport_sub_pixel_bits), + ]); + report + .versions + .extend([("driver_version", driver), ("api_version", format!("Vulkan {api}"))]); +} + +/// Add to `report` what Metal says the GPU is, when `device` was opened through Metal. +#[cfg(target_vendor = "apple")] +#[expect( + unsafe_code, + reason = "wgpu hands out the Metal device beneath it only through an unsafe call" +)] +fn metal_report(device: &wgpu::Device, report: &mut Report) { + // SAFETY: the Metal device is only read from, and is not kept past this function. + let hal = unsafe { device.as_hal::() }; + // A device opened through another API has no Metal side: on Apple's platforms, only one on wgpu's no-op backend. + let Some(metal) = hal else { return }; + let raw = metal.raw_device(); + let families: Vec<&str> = METAL_FAMILIES + .iter() + .filter(|family| raw.supportsFamily(family.0)) + .map(|family| family.1) + .collect(); + report.lines.extend([ + format!("metal device name: {}", raw.name()), + format!("metal architecture: {}", raw.architecture().name()), + format!("metal registry id: {:#x}", raw.registryID()), + format!("metal location: {:?}", raw.location()), + format!("metal recommended working set: {}", raw.recommendedMaxWorkingSetSize()), + format!("metal families: {}", families.join(" ")), + ]); +} + +/// Add to `report` what DXGI says the GPU is, when `adapter` was opened through Direct3D 12. +/// +/// The user-mode driver version is the one Windows shows for the display driver, four fields of 16 bits each. +#[cfg(windows)] +#[expect( + unsafe_code, + reason = "wgpu hands out the DXGI adapter beneath it only through an unsafe call, and DXGI's calls are unsafe" +)] +fn dx12_report(adapter: &wgpu::Adapter, report: &mut Report) { + // SAFETY: the DXGI adapter is only read from, and is not kept past this function. + let hal = unsafe { adapter.as_hal::() }; + let Some(dx12) = hal else { return }; + let raw = dx12.raw_adapter(); + // SAFETY: the call only fills in the description it returns, from an adapter that outlives it. + let description = unsafe { raw.GetDesc2() }; + // SAFETY: the call only fills in the version it returns, from an adapter that outlives it. + let driver = unsafe { raw.CheckInterfaceSupport(&windows::Win32::Graphics::Dxgi::IDXGIDevice::IID) }; + // A version DXGI would not give is left out of `versions`, whose values a registry row records as fact. + match driver { + Ok(version) => { + // DXGI hands the four 16-bit fields over packed in a signed 64-bit integer; its bits are the version. + let packed = version.cast_unsigned(); + let driver = format!( + "{}.{}.{}.{}", + packed >> 48_u32, + (packed >> 32_u32) & 0xffff, + (packed >> 16_u32) & 0xffff, + packed & 0xffff + ); + report.lines.push(format!("dxgi driver version: {driver}")); + report.versions.push(("driver_version", driver)); + } + Err(error) => report.lines.push(format!("dxgi driver version: {error}")), + } + match description { + Ok(description) => { + let name = String::from_utf16_lossy(&description.Description); + report.lines.extend([ + format!("dxgi description: {}", name.trim_end_matches('\0')), + format!("dxgi vendor id: {:#06x}", description.VendorId), + format!("dxgi device id: {:#06x}", description.DeviceId), + format!("dxgi subsystem id: {:#010x}", description.SubSysId), + format!("dxgi revision: {:#x}", description.Revision), + format!("dxgi dedicated video memory: {}", description.DedicatedVideoMemory), + ]); + } + Err(error) => report.lines.push(format!("dxgi description: {error}")), + } +} diff --git a/libs/gpu-probe/src/registry.rs b/libs/gpu-probe/src/registry.rs index 98b51c7a..06ee3f54 100644 --- a/libs/gpu-probe/src/registry.rs +++ b/libs/gpu-probe/src/registry.rs @@ -4,7 +4,7 @@ use std::sync::LazyLock; use serde_json::Value; -use crate::PROBES; +use crate::ProbeSet; use crate::sighting::{RowError, Sighting}; /// The registry: the JSON written from `signatures.yaml`, embedded as text when the crate is compiled. @@ -41,6 +41,7 @@ fn entries() -> impl Iterator { /// results, kept for an interface a browser withheld, holds none. fn holding<'words>(interface: &'words str, words: &'words [String]) -> impl Iterator + 'words { let split = format!("{interface}-split"); + let names = ProbeSet::measured_through(interface).names; entries() .filter(move |entry| { let filed = entry.get("interface").and_then(Value::as_str); @@ -48,9 +49,9 @@ fn holding<'words>(interface: &'words str, words: &'words [String]) -> impl Iter }) .filter(move |entry| { let results = entry.get("results"); - words.len() == PROBES.len() - && PROBES.iter().zip(words).all(|(probe, word)| { - results.and_then(|held| held.get(probe.0)).and_then(Value::as_str) == Some(word.as_str()) + words.len() == names.len() + && names.iter().zip(words).all(|(name, word)| { + results.and_then(|held| held.get(*name)).and_then(Value::as_str) == Some(word.as_str()) }) }) } diff --git a/libs/gpu-probe/src/sighting.rs b/libs/gpu-probe/src/sighting.rs index 05ec8e71..d76a8f5b 100644 --- a/libs/gpu-probe/src/sighting.rs +++ b/libs/gpu-probe/src/sighting.rs @@ -2,6 +2,7 @@ use core::str::FromStr; +use platforms::OS; use serde_json::Value; use target_lexicon::Architecture; use wgpu_hal::auxil::db; @@ -18,14 +19,6 @@ const BROWSERS: [(&str, Browser); 8] = [ ("none", Browser::Absent), ]; -/// The operating systems a row can name, each beside the name a row gives it. -const SYSTEMS: [(&str, OperatingSystem); 4] = [ - ("android", OperatingSystem::Android), - ("linux", OperatingSystem::Linux), - ("macos", OperatingSystem::MacOs), - ("windows", OperatingSystem::Windows), -]; - /// The GPU vendors a row can name, each beside the name a row gives it. const GPU_VENDORS: [(&str, GpuVendor); 13] = [ ("amd", GpuVendor::Amd), @@ -74,14 +67,15 @@ const BACKENDS: [(&str, Backend); 8] = [ ]; /// The windows a row can name, each beside the name a row gives it. -const WINDOWS: [(&str, Window); 3] = [ +const WINDOWS: [(&str, Window); 4] = [ ("normal", Window::Normal), ("private", Window::Private), + ("both", Window::Both), ("none", Window::Absent), ]; /// Every field a row may carry: the six it must, then its free text. -const FIELDS: [&str; 16] = [ +const FIELDS: [&str; 18] = [ "browser", "os", "cpu_arch", @@ -92,6 +86,8 @@ const FIELDS: [&str; 16] = [ "os_version", "cpu_version", "gpu_version", + "driver_version", + "api_version", "device", "runtime", "defence", @@ -151,28 +147,6 @@ impl FromStr for Browser { } } -/// The operating system a signature was seen on. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum OperatingSystem { - /// Android. - Android, - /// Linux. - Linux, - /// macOS. - MacOs, - /// Windows. - Windows, -} - -impl FromStr for OperatingSystem { - type Err = RowError; - - fn from_str(name: &str) -> Result { - named(&SYSTEMS, "os", name) - } -} - /// Who makes the GPU a signature was seen on, as wgpu names GPU vendors, with those it has no name for. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -216,6 +190,32 @@ impl GpuVendor { .find(|vendor| vendor.0 == id) .map_or(Self::Unknown, |vendor| vendor.1) } + + /// The vendor whose name the GPU's own name opens with, or [`Self::Unknown`], for a backend that gives no ID. + /// + /// Metal names its GPUs this way -- "Apple M5 Pro", "AMD Radeon Pro 5500M", "Intel(R) UHD Graphics 630" -- so the + /// name says whose GPU it is where the ID cannot, an Apple GPU and one in an Intel Mac alike. + #[must_use] + pub fn from_device_name(device: &str) -> Self { + let first = device + .chars() + .take_while(char::is_ascii_alphabetic) + .collect::() + .to_ascii_lowercase(); + GPU_VENDORS + .iter() + .find(|vendor| vendor.0 == first) + .map_or(Self::Unknown, |vendor| vendor.1) + } + + /// The name a registry row gives the vendor, which reads back as it. + #[must_use] + pub fn name(self) -> &'static str { + GPU_VENDORS + .iter() + .find(|vendor| vendor.1 == self) + .map_or("unknown", |vendor| vendor.0) + } } impl FromStr for GpuVendor { @@ -264,6 +264,8 @@ pub enum Window { Normal, /// A private, or incognito, window. Private, + /// An ordinary window and a private one alike, each of which gave the same signature. + Both, /// No browser was involved, so no window was. Absent, } @@ -290,8 +292,8 @@ pub struct Sighting { pub cpu_arch: Architecture, /// Who makes the GPU. pub gpu: GpuVendor, - /// The operating system. - pub system: OperatingSystem, + /// The operating system, as `std::env::consts::OS` names it. + pub system: OS, /// The kind of browser window. pub window: Window, } @@ -315,6 +317,7 @@ impl Sighting { }); } let cpu_arch = required(row, "cpu_arch")?; + let system = required(row, "os")?; Ok(Self { backend: required(row, "backend")?.parse()?, browser: required(row, "browser")?.parse()?, @@ -323,7 +326,10 @@ impl Sighting { value: cpu_arch.to_owned(), })?, gpu: required(row, "gpu")?.parse()?, - system: required(row, "os")?.parse()?, + system: system.parse().ok().ok_or_else(|| RowError::Unknown { + field: "os", + value: system.to_owned(), + })?, window: required(row, "window")?.parse()?, }) } @@ -346,7 +352,7 @@ impl Sighting { pub fn is_here(&self) -> bool { std::env::consts::OS .parse() - .is_ok_and(|system: OperatingSystem| system == self.system) + .is_ok_and(|system: OS| system == self.system) && self.cpu_arch.to_string() == std::env::consts::ARCH } } diff --git a/libs/gpu-probe/tests/canvas.rs b/libs/gpu-probe/tests/canvas.rs new file mode 100644 index 00000000..94d448e0 --- /dev/null +++ b/libs/gpu-probe/tests/canvas.rs @@ -0,0 +1,101 @@ +//! Covers the canvas fingerprints' pixel hash, against vectors the hash the registry's canvas entries were taken with +//! gave for the same bytes. +#![cfg(test)] + +use et_gpu_probe::{ + CANVAS, CANVAS_BROWSERLEAKS, KNOWN_BLOCK, KNOWN_SIZE, alteration, known_colour, known_scene, pixel_hash, +}; + +#[test] +fn known_colours_wrap_at_a_byte() { + assert_eq!(known_colour(0, 0), [20, 70, 130]); + assert_eq!(known_colour(1, 0), [57, 129, 147]); + assert_eq!(known_colour(7, 7), [100, 132, 188]); +} + +#[test] +fn the_known_scene_is_opaque_blocks() { + let scene = known_scene(); + let side = usize::from(KNOWN_SIZE); + assert_eq!(scene.len(), side * side * 4); + let pixel = |column: usize, row: usize| scene[(row * side + column) * 4..][..4].to_vec(); + let block = usize::from(KNOWN_BLOCK); + assert_eq!(pixel(0, 0), [20, 70, 130, 255]); + assert_eq!(pixel(block - 1, block - 1), [20, 70, 130, 255]); + assert_eq!(pixel(block, 0), [57, 129, 147, 255]); + assert_eq!(pixel(side - 1, side - 1), [100, 132, 188, 255]); +} + +#[test] +fn a_faithful_read_is_unaltered() { + let scene = known_scene(); + let unaltered = alteration(&scene, &scene).unwrap(); + assert_eq!( + (unaltered.changed, unaltered.total, unaltered.pattern.as_str()), + (0, scene.len(), "") + ); +} + +#[test] +fn noise_is_counted_and_told_apart() { + let scene = known_scene(); + let mut once = scene.clone(); + once[5] ^= 1; + let mut twice = once.clone(); + twice[900] ^= 2; + let first = alteration(&once, &scene).unwrap(); + let second = alteration(&twice, &scene).unwrap(); + assert_eq!((first.changed, second.changed), (1, 2)); + assert_eq!(first.pattern.len(), 35); + assert_ne!(first.pattern, second.pattern); +} + +#[test] +fn a_read_of_the_wrong_length_is_refused() { + let refused = alteration(&[0; 3], &[0; 4]).unwrap_err(); + assert_eq!((refused.read, refused.expected), (3, 4)); + assert_eq!(refused.to_string(), "read 3 values, not 4"); +} + +/// `length` bytes counting up in sevens from three, wrapping at a byte, so every lane sees every byte value. +fn sequence(length: usize) -> Vec { + core::iter::successors(Some(3_u8), |byte| Some(byte.wrapping_add(7))) + .take(length) + .collect() +} + +#[test] +fn nothing_hashes_to_the_seeds() { + assert_eq!(pixel_hash(&[]), [0x811c_9dc5, 0x1b87_3593, 0x9e37_79b1, 0x85eb_ca77]); +} + +#[test] +fn one_block_folds_once() { + assert_eq!( + pixel_hash(&sequence(64)), + [0x71b1_e1c5, 0x09e0_6c13, 0xb3d5_03be, 0x9a5a_42af] + ); +} + +#[test] +fn a_partial_block_after_several_whole_ones_is_taken_in() { + assert_eq!( + pixel_hash(&sequence(300)), + [0xe5e7_e389, 0x4e56_3baf, 0x7e05_84bb, 0x34e9_227b] + ); +} + +#[test] +fn one_changed_byte_changes_every_lane() { + let mut bytes = sequence(300); + let before = pixel_hash(&bytes); + bytes[150] ^= 1; + let after = pixel_hash(&bytes); + assert!(before.iter().zip(after).all(|(was, now)| *was != now)); +} + +#[test] +fn the_interfaces_are_named_as_the_registry_files_them() { + assert_eq!(CANVAS, "canvas"); + assert_eq!(CANVAS_BROWSERLEAKS, "canvas-browserleaks"); +} diff --git a/libs/gpu-probe/tests/native.rs b/libs/gpu-probe/tests/native.rs index 8c04edfb..4b89ee72 100644 --- a/libs/gpu-probe/tests/native.rs +++ b/libs/gpu-probe/tests/native.rs @@ -1,66 +1,20 @@ -//! Puts the probe set to this machine with no browser involved, to its GPU through wgpu and to its CPU. +//! Puts the probe sets to this machine with no browser involved, to its GPU through wgpu and to its CPU. //! -//! Each signature is checked against the one recorded for that hardware. A machine nobody has recorded still has to -//! answer every probe legally, and the same way twice; what it answered is printed, which is how its signature comes to -//! be recorded. +//! Each shader signature is checked against the one recorded for that hardware. A machine nobody has recorded still has +//! to answer every probe legally, and the same way twice; what it answered is printed, which is how its signature comes +//! to be recorded. The clears and fixed-function probes, which no shader compiler decides, have to be answered the same +//! way twice. Beside the signatures the test prints what the device says it is, read from the API it was opened through +//! rather than from what wgpu makes of it. #![cfg(test)] use std::sync::LazyLock; -use et_gpu_probe::{ - Answer, HOST, INPUTS, PROBE_COUNT, Signature, WEBGPU, WEBGPU_COMBINED, combined_shader, host, shaders, -}; +use et_gpu_probe::native::{Gpu, NativeError}; +use et_gpu_probe::{Answer, BLENDS, CLEARS, FILTERS, HOST, PROBE_COUNT, RASTERS, Signature, host}; use futures_executor::block_on; -/// How many bytes the storage buffer every probe shader reads [`INPUTS`] from holds. -const INPUT_BYTES: u64 = 32; - -/// How many bytes the one `u32` every probe shader writes takes. -const OUTPUT_BYTES: u64 = 4; - -/// How many bytes the answers to the whole probe set take. -const SIGNATURE_BYTES: u64 = 64; - -/// How many words the answers to the whole probe set take, one a probe. -const PROBE_WORDS: u64 = 16; - /// This machine's GPU, opened once and with the probe set compiled for it. -static GPU: LazyLock = LazyLock::new(|| { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); - let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap(); - let info = adapter.get_info(); - Gpu { - hardware: format!( - "{} | {} {} | {}", - info.name, info.driver, info.driver_info, info.backend - ), - combined: compile(&device, &combined_shader()), - probes: shaders().iter().map(|shader| compile(&device, &shader.1)).collect(), - vendor: info.vendor, - device, - queue, - } -}); - -/// A probe as a device holds it: the pipeline that evaluates it, or the device's reason for refusing to build one. -type Probe = Result; - -/// A GPU the probes are put to. -struct Gpu { - /// The whole probe set as one shader. - combined: Probe, - /// The device the probes run on. - device: wgpu::Device, - /// What the adapter behind the device reports itself as: its name, driver and backend. - hardware: String, - /// The probe set, in output order. - probes: Vec, - /// Where the device's work is submitted. - queue: wgpu::Queue, - /// The PCI vendor ID the adapter reports, which is none on some backends. - vendor: u32, -} +static GPU: LazyLock = LazyLock::new(|| block_on(Gpu::open()).unwrap()); /// Print `signature` beside the `hardware` it was measured on through `interface`. /// @@ -75,164 +29,79 @@ fn report(interface: &str, hardware: &str, signature: &Signature) { } } -/// A buffer of `size` bytes on `device`, for the uses `usage` names. -fn buffer(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { - label: None, - size, - usage, - mapped_at_creation: false, - }) -} - -/// The pipeline on `device` that runs the shader `wgsl` holds. -/// -/// A shader the device will not compile, or a pipeline it will not build, is a refusal, which the error scope catches. -fn compile(device: &wgpu::Device, wgsl: &str) -> Probe { - let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: None, - source: wgpu::ShaderSource::Wgsl(wgsl.into()), - }); - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: None, - layout: None, - module: &module, - entry_point: Some("main"), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }); - block_on(scope.pop()).map_or(Ok(pipeline), Err) +/// What this machine's CPU answers to the probe set. +fn cpu_signature() -> Signature { + Signature::new(HOST, host().into_iter().map(Answer::Bits).collect()) } -/// Add to `encoder` a run of `pipeline` on `device`, reading `input` and leaving what it writes in `output`. -#[expect( - clippy::single_call_fn, - reason = "one stage's run, kept apart from the loop over the stages" -)] -fn run( - device: &wgpu::Device, - encoder: &mut wgpu::CommandEncoder, - pipeline: &wgpu::ComputePipeline, - input: &wgpu::Buffer, - output: &wgpu::Buffer, -) { - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: input.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: output.as_entire_binding(), - }, - ], - }); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(1, 1, 1); +#[test] +fn this_device_reports_itself_through_the_api_it_was_opened_through() { + let device = GPU.report(); + for line in &device.lines { + println!("device {line}"); + } + // The line each backend's reader gives that only the API itself could have: a version, or Metal's architecture. + let readers = [ + ("vulkan", "vulkan driver version: "), + ("metal", "metal architecture: "), + ("d3d12", "dxgi driver version: "), + ]; + let reader = readers.iter().find(|reader| reader.0 == GPU.backend()).unwrap(); + assert!(device.lines.iter().any(|line| line.starts_with(reader.1))); } -/// What this machine's GPU answers to `stages`, all of them run in one submission. +/// What `measure` answers on this machine's GPU, printed as measured through `interface`. /// -/// A stage is a shader as the device holds it, beside how many probes it evaluates: each of the probe set's shaders -/// evaluates one, and the combined shader all of them. A stage the device refused answers each of its probes with -/// the refusal. -#[expect( - clippy::host_endian_bytes, - reason = "a GPU buffer holds its values in the machine's own byte order" -)] -fn gpu_answers(stages: &[(&Probe, u64)]) -> Vec { - let Gpu { device, queue, .. } = &*GPU; - let input = buffer( - device, - INPUT_BYTES, - wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - ); - let input_bytes: Vec = INPUTS.iter().flat_map(|value| value.to_ne_bytes()).collect(); - queue.write_buffer(&input, 0, &input_bytes); - let readback = buffer( - device, - SIGNATURE_BYTES, - wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, +/// A second measurement has to answer the same way. +fn measured_twice(interface: &str, measure: Measure) -> Signature +where + Measure: Fn() -> Pending, + Pending: Future>, +{ + let signature = block_on(measure()).unwrap(); + // What the adapter behind the device reports itself as: its name, driver and backend. + let info = GPU.info(); + let hardware = format!( + "{} | {} {} | {}", + info.name, info.driver, info.driver_info, info.backend ); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); - let mut offset = 0_u64; - for (probe, probes) in stages { - let bytes = probes.saturating_mul(OUTPUT_BYTES); - if let Ok(pipeline) = probe { - let output = buffer( - device, - bytes, - wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - ); - run(device, &mut encoder, pipeline, &input, &output); - encoder.copy_buffer_to_buffer(&output, 0, &readback, offset, bytes); - } - offset = offset.saturating_add(bytes); - } - let _submission: wgpu::SubmissionIndex = queue.submit([encoder.finish()]); - readback.map_async(wgpu::MapMode::Read, .., |mapped| mapped.unwrap()); - let _status: wgpu::PollStatus = device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); - let held = readback.get_mapped_range(..); - let mut words = held.as_chunks::<4>().0.iter(); - let mut answers = Vec::new(); - for (probe, probes) in stages { - for _probe in 0..*probes { - let word = words.next().unwrap(); - answers.push(match probe { - Ok(_) => Answer::Bits(u32::from_ne_bytes(*word)), - Err(refusal) => Answer::Refused(refusal.to_string()), - }); - } - } - answers + report(interface, &hardware, &signature); + assert_eq!(signature.fingerprint(), block_on(measure()).unwrap().fingerprint()); + signature } -/// What this machine's GPU answers to the probe set, each probe in a shader of its own. -fn gpu_signature() -> Signature { - let stages: Vec<(&Probe, u64)> = GPU.probes.iter().map(|probe| (probe, 1)).collect(); - Signature::new(WEBGPU, gpu_answers(&stages)) -} - -/// What this machine's GPU answers to the probe set, every probe in the one combined shader. -fn combined_signature() -> Signature { - Signature::new(WEBGPU_COMBINED, gpu_answers(&[(&GPU.combined, PROBE_WORDS)])) +#[test] +fn this_gpu_answers_every_clear_the_same_way_twice() { + let signature = measured_twice("wgpu clear", || GPU.clear_signature()); + // Only rendering to rg11b10ufloat is optional, so only that clear may be refused. + let refusable = usize::from(!GPU.renders_ufloat11()); + assert_eq!(signature.completed() + refusable, CLEARS.len()); } -/// What this machine's CPU answers to the probe set. -fn cpu_signature() -> Signature { - Signature::new(HOST, host().into_iter().map(Answer::Bits).collect()) +#[test] +fn this_gpu_answers_every_fixed_function_probe_the_same_way_twice() { + let signature = measured_twice("wgpu fixed-function", || GPU.fixed_function_signature()); + assert_eq!(signature.completed(), BLENDS.len() + FILTERS.len() + RASTERS.len()); } #[test] fn this_gpu_answers_every_probe_legally_and_the_same_way_twice() { - let signature = gpu_signature(); - report("wgpu", &GPU.hardware, &signature); - println!("wgpu vendor id: {:#06x}", GPU.vendor); - + let signature = measured_twice("wgpu", || GPU.signature()); + println!("wgpu vendor id: {:#06x}", GPU.info().vendor); assert_eq!(signature.completed(), PROBE_COUNT); assert!(!signature.traits().contains("=other")); - assert_eq!(signature.fingerprint(), gpu_signature().fingerprint()); } #[test] fn this_gpu_answers_the_combined_shader_legally_and_the_same_way_twice() { - let signature = combined_signature(); - report("wgpu combined", &GPU.hardware, &signature); - + let signature = measured_twice("wgpu combined", || GPU.combined_signature()); assert_eq!(signature.completed(), PROBE_COUNT); assert!(!signature.traits().contains("=other")); - assert_eq!(signature.fingerprint(), combined_signature().fingerprint()); } -// The platforms whose GPU and CPU each have a signature on record: an Apple GPU through Metal, llvmpipe through -// Vulkan and the Basic Render Driver through Direct3D 12. A machine of one of these platforms with hardware not yet -// on record fails here, and the fingerprint the tests beside this one print is what gets it recorded. +// The platforms whose GPU and CPU each have a signature on record: an Apple GPU through Metal, llvmpipe through Vulkan +// and the Basic Render Driver through Direct3D 12. A machine of one of these platforms with hardware not yet on record +// fails here, and the fingerprint the tests beside this one print is what gets it recorded. #[cfg(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "linux", target_arch = "aarch64"), @@ -242,8 +111,8 @@ fn this_gpu_answers_the_combined_shader_legally_and_the_same_way_twice() { #[test] fn the_registry_has_seen_this_gpu_and_this_cpu_on_this_platform() { // An adapter that reports no vendor ID, as Metal's does, is held to its platform alone. - let vendor = et_gpu_probe::GpuVendor::from_id(GPU.vendor); - let gpu_seen = gpu_signature().seen().unwrap(); + let vendor = et_gpu_probe::GpuVendor::from_id(GPU.info().vendor); + let gpu_seen = block_on(GPU.signature()).unwrap().seen().unwrap(); assert!(gpu_seen.iter().any(|sighting| { sighting.is_here() && (vendor == et_gpu_probe::GpuVendor::Unknown || sighting.gpu == vendor) })); @@ -259,3 +128,77 @@ fn this_cpu_answers_every_probe_the_same_way_twice() { assert_eq!(signature.fingerprint(), cpu_signature().fingerprint()); } + +#[test] +fn every_adapter_opens_and_answers_the_probe_set() { + let gpus = block_on(Gpu::open_all()); + assert!(!gpus.is_empty()); + for opened in &gpus { + let gpu = opened.as_ref().unwrap(); + let info = gpu.info(); + println!( + "adapter: {} ({:?}) through {}", + info.name, + info.device_type, + gpu.backend() + ); + assert_eq!(block_on(gpu.signature()).unwrap().completed(), PROBE_COUNT); + } +} + +#[test] +fn an_adapter_no_graphics_api_stands_behind_reports_only_what_wgpu_says() { + // wgpu's no-op backend is on every platform and has no Vulkan, Metal or Direct3D 12 side, so the report reads + // none of those APIs: what each reader does for an adapter opened through another API than its own. + let descriptor = wgpu::InstanceDescriptor { + backends: wgpu::Backends::NOOP, + backend_options: wgpu::BackendOptions { + noop: wgpu::NoopBackendOptions { enable: true }, + ..wgpu::BackendOptions::default() + }, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }; + let instance = wgpu::Instance::new(descriptor); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let gpu = block_on(Gpu::open_adapter(adapter)).unwrap(); + assert_eq!(gpu.info().backend, wgpu::Backend::Noop); + let report = gpu.report(); + assert_ne!(report.lines.len(), 0); + for api in ["vulkan", "metal", "dxgi"] { + assert!( + !report.lines.iter().any(|line| line.starts_with(api)), + "{api} in {:?}", + report.lines + ); + } + assert_eq!(report.versions.len(), 0); +} + +#[test] +fn the_adapter_preferred_for_each_power_preference_opens_and_answers_the_probe_set() { + for power_preference in [wgpu::PowerPreference::HighPerformance, wgpu::PowerPreference::LowPower] { + let options = wgpu::RequestAdapterOptions { + power_preference, + ..wgpu::RequestAdapterOptions::default() + }; + let gpu = block_on(Gpu::open_with(&options)).unwrap(); + println!("{power_preference:?}: {} through {}", gpu.info().name, gpu.backend()); + assert_eq!(block_on(gpu.signature()).unwrap().completed(), PROBE_COUNT); + } +} + +#[cfg(not(target_vendor = "apple"))] +#[test] +fn a_vulkan_driver_version_is_read_in_its_vendors_encoding() { + use et_gpu_probe::native::{vulkan_api_version, vulkan_driver_version}; + + // NVIDIA 580.95.5.0, packed 10.8.8.6 bits. + assert_eq!(vulkan_driver_version(0x10de, 0x9117_c140, false), "580.95.5.0"); + // Intel's Windows driver 101.130, packed 18.14 bits, but the API layout on Linux, where Mesa drives it. + assert_eq!(vulkan_driver_version(0x8086, 0x0019_4082, true), "101.130"); + assert_eq!(vulkan_driver_version(0x8086, 0x0680_1006, false), "26.1.6"); + // AMD packs its driver version as the API version is, on every platform. + assert_eq!(vulkan_driver_version(0x1002, 0x0080_2000, true), "2.2.0"); + assert_eq!(vulkan_api_version(0x0040_6162), "1.6.354"); + assert_eq!(vulkan_api_version(0x2040_6162), "1.6.354 (variant 1)"); +} diff --git a/libs/gpu-probe/tests/no_shader.rs b/libs/gpu-probe/tests/no_shader.rs new file mode 100644 index 00000000..e0d25bee --- /dev/null +++ b/libs/gpu-probe/tests/no_shader.rs @@ -0,0 +1,346 @@ +//! Covers the probe sets no shader compiler decides: the clears, and the blend, filter and raster probes -- their +//! formats, correct answers and shaders, and what a signature reads out of a device's answers to them. +#![cfg(test)] + +use et_gpu_probe::{ + Answer, BLENDS, CLEAR_COUNT, CLEARS, FILTERS, FIXED_FUNCTION_COUNT, Format, Operation, RASTER_FORMAT, RASTER_WIDTH, + RASTERS, Side, Signature, WEBGL2_CLEAR, WEBGL2_FIXED_FUNCTION, WEBGPU_CLEAR, WEBGPU_FIXED_FUNCTION, + clear_reference, coverage, fixed_function_reference, +}; + +/// Every format the probes use, each beside its WebGPU name, the bytes a texel of it takes and its red channel's mask. +const FORMATS: [(Format, &str, u32, u32); 5] = [ + (Format::Rgba8Unorm, "rgba8unorm", 4, 0xff), + (Format::Rgba8UnormSrgb, "rgba8unorm-srgb", 4, 0xff), + (Format::Rgb10a2Unorm, "rgb10a2unorm", 4, 0x3ff), + (Format::Rg11b10Ufloat, "rg11b10ufloat", 4, 0x7ff), + (Format::Rgba16Float, "rgba16float", 8, 0xffff), +]; + +/// The codes each clear's red channel holds when its value is converted correctly. +const CLEAR_CODES: [u32; CLEAR_COUNT] = [10, 10, 11, 101, 188, 7, 0x3c1, 0x3c01, 0x10, 0x7c00]; + +/// The answer each fixed-function probe gives when computed exactly. +const FIXED_FUNCTION_ANSWERS: [u32; FIXED_FUNCTION_COUNT] = + [50, 0x3c01, 225, 0x3eaa_aaac, 0x3a83_1200, 0x3ddd_0a89, 0x0f, 0xf0, 0x0f]; + +/// A signature through `interface` answering every probe with `answers`. +fn answered(interface: &'static str, answers: &[u32]) -> Signature { + Signature::new(interface, answers.iter().copied().map(Answer::Bits).collect()) +} + +#[test] +fn every_format_has_its_webgpu_name_and_texel_size() { + for (format, name, bytes, _) in FORMATS { + assert_eq!(format.name(), name); + assert_eq!(format.texel_bytes(), bytes); + assert_eq!(usize::try_from(bytes).unwrap(), format.texel(1).len()); + } +} + +#[test] +fn a_texel_holds_its_code_in_red_and_reads_back_the_same() { + for (format, .., mask) in FORMATS { + let texel = format.texel(0xabcd); + // Each format keeps as many of the code's bits as its red channel has. + assert_eq!(format.red(*texel.first_chunk::<4>().unwrap()), 0xabcd & mask); + } +} + +#[test] +#[expect( + clippy::little_endian_bytes, + reason = "WebGPU lays a texel out in little-endian order on every platform" +)] +fn a_texel_has_zero_green_and_blue_and_an_opaque_alpha() { + assert_eq!(Format::Rgba8Unorm.texel(7), [7, 0, 0, 0xff]); + assert_eq!(Format::Rgba8UnormSrgb.texel(7), [7, 0, 0, 0xff]); + assert_eq!(Format::Rgb10a2Unorm.texel(7), 0xc000_0007_u32.to_le_bytes()); + assert_eq!(Format::Rg11b10Ufloat.texel(7), 7_u32.to_le_bytes()); + assert_eq!(Format::Rgba16Float.texel(7), 0x3c00_0000_0000_0007_u64.to_le_bytes()); +} + +#[test] +fn a_normalized_code_decodes_to_its_share_of_the_scale_and_encodes_back() { + for (format, scale) in [(Format::Rgba8Unorm, 255), (Format::Rgb10a2Unorm, 1023)] { + assert_eq!(format.encode(format.decode(scale)), scale); + assert_eq!(format.encode(format.decode(3)), 3); + // Out of range values are clamped. + assert_eq!(format.encode(2.0), scale); + assert_eq!(format.encode(-1.0), 0); + } +} + +#[test] +fn an_srgb_code_decodes_through_both_parts_of_the_curve_and_encodes_back() { + for code in [0, 7, 10, 11, 128, 188, 255] { + assert_eq!(Format::Rgba8UnormSrgb.encode(Format::Rgba8UnormSrgb.decode(code)), code); + } +} + +#[test] +fn a_small_float_decodes_its_subnormals_normals_and_infinity() { + assert_eq!( + Format::Rgba16Float.decode(0x0010).to_bits(), + (1.0_f64 / 1_048_576.0).to_bits() + ); + assert_eq!(Format::Rgba16Float.decode(0x3c00).to_bits(), 1.0_f64.to_bits()); + assert_eq!(Format::Rgba16Float.decode(0x7bff).to_bits(), 65_504.0_f64.to_bits()); + assert!(Format::Rgba16Float.decode(0x7c00).is_infinite()); + assert_eq!( + Format::Rg11b10Ufloat.decode(0x3c1).to_bits(), + (1.0_f64 + 1.0 / 64.0).to_bits() + ); + assert!(Format::Rg11b10Ufloat.decode(0x7c0).is_infinite()); +} + +#[test] +fn a_small_float_encodes_zero_ties_and_carries_correctly() { + assert_eq!(Format::Rgba16Float.encode(0.0), 0); + assert_eq!(Format::Rgba16Float.encode(65_504.0), 0x7bff); + // Halfway between 1 and the next half is a tie that goes to the even mantissa, 1. + assert_eq!(Format::Rgba16Float.encode(1.0 + 1.0 / 2048.0), 0x3c00); + // A mantissa that rounds up past its last value carries into the exponent. + assert_eq!(Format::Rgba16Float.encode(2.0 - 1.0 / 8192.0), 0x4000); + assert_eq!(Format::Rg11b10Ufloat.encode(1.0), 0x3c0); +} + +#[test] +fn every_clear_converts_correctly_to_the_code_it_was_aimed_at() { + assert_eq!(clear_reference(), CLEAR_CODES); + assert_eq!(CLEARS.len(), CLEAR_COUNT); + for clear in CLEARS { + assert_ne!(clear.name, ""); + assert!(clear.value > 0.0_f64); + } +} + +#[test] +fn a_device_converting_every_clear_correctly_names_its_choices() { + let signature = answered(WEBGPU_CLEAR, &CLEAR_CODES); + assert_eq!(signature.completed(), CLEAR_COUNT); + assert_eq!(signature.deviations(), 0); + assert_eq!( + signature.traits(), + concat!( + "unorm8_three_quarters=rounded unorm10_three_quarters=rounded ufloat11_rounding=rounded ", + "half_rounding=rounded half_subnormal=kept half_overflow=infinity" + ) + ); + assert_eq!(signature.class(), et_gpu_probe::UNRECOGNISED); +} + +#[test] +fn a_device_that_truncates_and_flushes_is_told_apart() { + let mut answers = CLEAR_CODES; + answers[2] = 10; + answers[3] = 100; + answers[6] = 0x3c0; + answers[7] = 0x3c00; + answers[8] = 0; + answers[9] = 0x7bff; + let signature = answered(WEBGPU_CLEAR, &answers); + assert_eq!(signature.deviations(), 6); + assert_eq!( + signature.traits(), + concat!( + "unorm8_three_quarters=truncated unorm10_three_quarters=truncated ufloat11_rounding=truncated ", + "half_rounding=truncated half_subnormal=flushed half_overflow=clamped" + ) + ); + assert!( + signature + .describe() + .iter() + .any(|line| line == "half_overflow gpu=00007bff correct=00007c00 ulps=-1") + ); +} + +#[test] +fn a_clear_the_device_refused_reads_failed() { + let mut observed: Vec = CLEAR_CODES.iter().copied().map(Answer::Bits).collect(); + observed[6] = Answer::Refused("not renderable".to_owned()); + let signature = Signature::new(WEBGPU_CLEAR, observed); + assert_eq!(signature.completed(), CLEAR_COUNT - 1); + assert!(signature.traits().contains(" ufloat11_rounding=failed ")); + assert!( + signature + .describe() + .contains(&"ufloat11_rounding failed: not renderable".to_owned()) + ); +} + +#[test] +fn every_fixed_function_probe_has_its_exact_answer() { + assert_eq!(fixed_function_reference(), FIXED_FUNCTION_ANSWERS); + assert_eq!(BLENDS.len() + FILTERS.len() + RASTERS.len(), FIXED_FUNCTION_COUNT); +} + +#[test] +fn a_device_computing_every_fixed_function_probe_exactly_names_its_choices() { + let signature = answered(WEBGPU_FIXED_FUNCTION, &FIXED_FUNCTION_ANSWERS); + assert_eq!(signature.deviations(), 0); + assert_eq!( + signature.traits(), + concat!( + "blend_half_float=rounded raster_right_edge_fine=exact raster_left_edge_fine=exact ", + "raster_right_edge_coarse=exact" + ) + ); + let lines = signature.describe(); + assert_eq!(lines.len(), FIXED_FUNCTION_COUNT); + assert_eq!( + lines.first().unwrap(), + "blend_multiply gpu=00000032 correct=00000032 ulps=+0" + ); +} + +#[test] +fn a_rasterizer_that_snaps_to_eight_bits_is_told_apart() { + let mut answers = FIXED_FUNCTION_ANSWERS; + answers[6] = 0x07; + answers[7] = 0xf8; + let signature = answered(WEBGPU_FIXED_FUNCTION, &answers); + assert_eq!( + signature.traits(), + concat!( + "blend_half_float=rounded raster_right_edge_fine=snapped raster_left_edge_fine=snapped ", + "raster_right_edge_coarse=exact" + ) + ); +} + +#[test] +fn every_blend_shader_draws_its_source_over_the_whole_target() { + for blend in BLENDS { + let shader = blend.shader(); + let source = format!("bitcast({:#010x}u)", blend.source.to_bits()); + assert_eq!(shader.matches(&source).count(), 4); + assert!(shader.contains("fn vertex(") && shader.contains("fn fragment(")); + assert!(!shader.contains("@binding")); + } + assert_eq!( + BLENDS.map(|blend| blend.operation), + [Operation::Multiply, Operation::Add, Operation::Add] + ); +} + +#[test] +fn every_filter_shader_samples_its_coordinate_from_two_bindings() { + for filter in FILTERS { + let shader = filter.shader(); + assert!(shader.contains(&format!("bitcast({:#010x}u)", filter.coordinate.to_bits()))); + assert!(shader.contains("@binding(0) var probeTexture") && shader.contains("@binding(1) var probeSampler")); + assert!(shader.contains("textureSampleLevel(")); + } +} + +#[test] +fn every_raster_shader_puts_its_edge_in_clip_space_and_its_far_corner_on_its_side() { + for raster in RASTERS { + let shader = raster.shader(); + let across = raster.edge / 4.0 - 1.0; + assert_eq!(shader.matches(&format!("{:#010x}u", across.to_bits())).count(), 2); + let far: f32 = if raster.side == Side::Left { -5.0 } else { 5.0 }; + assert!(shader.contains(&format!("{:#010x}u", far.to_bits()))); + } +} + +/// The GLSL ES 3.00 expression for exactly `value`. +fn glsl_literal(value: f32) -> String { + format!("uintBitsToFloat({:#010x}u)", value.to_bits()) +} + +/// Check `[vertex, fragment]` is a GLSL ES 3.00 pair drawing three corners from `gl_VertexID` into `probeOut`. +fn assert_glsl_draw(program: &[String; 2]) { + let [vertex, fragment] = program; + assert!(vertex.starts_with("#version 300 es\n") && fragment.starts_with("#version 300 es\n")); + assert!(vertex.contains("vec2 corners[3] = vec2[3](") && vertex.contains("corners[gl_VertexID]")); + assert!(fragment.contains("precision highp float;") && fragment.contains("out highp vec4 probeOut;")); +} + +#[test] +fn every_blend_draws_the_same_source_in_glsl() { + for blend in BLENDS { + let program = blend.glsl(); + assert_glsl_draw(&program); + assert!(program[1].contains(&format!("probeOut = vec4({});", glsl_literal(blend.source)))); + assert!(!program[1].contains("uniform")); + } +} + +#[test] +fn every_filter_samples_the_same_coordinate_in_glsl() { + for filter in FILTERS { + let program = filter.glsl(); + assert_glsl_draw(&program); + let point = format!("vec2({}, 0.5)", glsl_literal(filter.coordinate)); + assert!(program[1].contains("uniform highp sampler2D probeTexture;\n")); + assert!(program[1].contains(&format!("textureLod(probeTexture, {point}, 0.0).r"))); + } +} + +#[test] +fn every_raster_puts_the_same_corners_in_glsl() { + for raster in RASTERS { + let program = raster.glsl(); + assert_glsl_draw(&program); + let across = glsl_literal(raster.edge / 4.0 - 1.0); + let far = glsl_literal(if raster.side == Side::Left { -5.0 } else { 5.0 }); + let corners = format!( + "vec2({across}, {}), vec2({across}, {}), vec2({far}, {})", + glsl_literal(-3.0), + glsl_literal(3.0), + glsl_literal(0.0) + ); + assert!(program[0].contains(&corners)); + assert!( + raster + .shader() + .contains(&format!("{:#010x}u", (raster.edge / 4.0 - 1.0).to_bits())) + ); + assert!(program[1].contains("probeOut = vec4(1.0);")); + } +} + +#[test] +fn webgl2_signatures_read_with_the_same_sets_as_webgpu_ones() { + for (webgl2, webgpu, answers) in [ + (WEBGL2_CLEAR, WEBGPU_CLEAR, &CLEAR_CODES[..]), + ( + WEBGL2_FIXED_FUNCTION, + WEBGPU_FIXED_FUNCTION, + &FIXED_FUNCTION_ANSWERS[..], + ), + ] { + let (through_webgl2, through_webgpu) = (answered(webgl2, answers), answered(webgpu, answers)); + assert_eq!(through_webgl2.results(), through_webgpu.results()); + assert_eq!(through_webgl2.traits(), through_webgpu.traits()); + assert_eq!(through_webgl2.deviations(), 0); + } +} + +#[test] +fn coverage_sets_a_bit_for_each_texel_with_red() { + let texel_bytes = usize::try_from(RASTER_FORMAT.texel_bytes()).unwrap(); + let width = usize::try_from(RASTER_WIDTH).unwrap(); + let mut texels = vec![0_u8; texel_bytes * width]; + for texel in [0, 2, 7] { + texels[texel * texel_bytes] = 0xff; + } + // Alpha and the other channels say nothing about coverage. + texels[texel_bytes + 3] = 0xff; + assert_eq!(coverage(&texels), 0b1000_0101); + assert_eq!(coverage(&[]), 0); +} + +#[cfg(feature = "registry")] +#[test] +fn the_registry_holds_no_signature_of_the_new_sets_yet() { + assert_eq!(answered(WEBGPU_CLEAR, &CLEAR_CODES).identify(), Vec::<&str>::new()); + assert_eq!( + answered(WEBGPU_FIXED_FUNCTION, &FIXED_FUNCTION_ANSWERS).identify(), + Vec::<&str>::new() + ); + assert_eq!(answered(WEBGL2_CLEAR, &CLEAR_CODES).identify(), Vec::<&str>::new()); +} diff --git a/libs/gpu-probe/tests/sighting.rs b/libs/gpu-probe/tests/sighting.rs index 4f1fcd22..55ab2282 100644 --- a/libs/gpu-probe/tests/sighting.rs +++ b/libs/gpu-probe/tests/sighting.rs @@ -3,8 +3,8 @@ #![cfg(test)] use et_gpu_probe::{ - Answer, Backend, Browser, GpuVendor, HOST, OperatingSystem, RowError, Sighting, Signature, WEBGPU, Window, - every_sighting, reference, + Answer, Backend, Browser, GpuVendor, HOST, OS, RowError, Sighting, Signature, WEBGPU, Window, every_sighting, + reference, }; use serde_json::{Value, json}; @@ -41,13 +41,21 @@ fn refusal(row: &Value) -> String { fn a_row_reads_as_what_it_names() { let sighting = Sighting::read(&row()).unwrap(); assert_eq!(sighting.browser, Browser::Brave); - assert_eq!(sighting.system, OperatingSystem::Android); + assert_eq!(sighting.system, OS::Android); assert_eq!(sighting.cpu_arch.to_string(), "aarch64"); assert_eq!(sighting.gpu, GpuVendor::Arm); assert_eq!(sighting.backend, Backend::Vulkan); assert_eq!(sighting.window, Window::Private); } +#[test] +fn a_row_seen_in_both_kinds_of_window_reads_as_both() { + assert_eq!( + Sighting::read(&row_with("window", "both")).unwrap().window, + Window::Both + ); +} + #[test] fn a_browser_the_webbrowser_crate_names_is_one_it_lists() { assert!(matches!("chrome".parse::(), Ok(Browser::Listed(_)))); @@ -64,6 +72,43 @@ fn a_gpu_vendor_is_known_by_the_id_its_adapters_report() { assert_eq!(GpuVendor::from_id(0), GpuVendor::Unknown); } +#[test] +fn a_gpu_vendor_is_known_by_the_name_metal_gives_its_gpu() { + assert_eq!(GpuVendor::from_device_name("Apple M5 Pro"), GpuVendor::Apple); + assert_eq!(GpuVendor::from_device_name("AMD Radeon Pro 5500M"), GpuVendor::Amd); + assert_eq!( + GpuVendor::from_device_name("Intel(R) UHD Graphics 630"), + GpuVendor::Intel + ); + assert_eq!(GpuVendor::from_device_name("Radeon"), GpuVendor::Unknown); + assert_eq!(GpuVendor::from_device_name(""), GpuVendor::Unknown); +} + +#[test] +fn every_gpu_vendor_has_a_name_that_reads_back_as_it() { + for name in [ + "amd", + "apple", + "intel", + "mesa", + "microsoft", + "nvidia", + "unknown", + "none", + ] { + let vendor: GpuVendor = name.parse().unwrap(); + assert_eq!(vendor.name(), name); + } +} + +#[test] +fn a_signature_gives_its_results_by_probe_name() { + let signature = Signature::new(HOST, reference().into_iter().map(Answer::Bits).collect()); + let results = signature.results(); + assert_eq!(results.len(), et_gpu_probe::PROBE_COUNT); + assert_eq!(results.first().unwrap(), &("multiply_add", "00000000".to_owned())); +} + #[test] fn a_value_the_registry_does_not_know_is_refused_by_name() { for field in ["browser", "os", "cpu_arch", "gpu", "backend", "window"] { diff --git a/libs/https-listener/Cargo.toml b/libs/https-listener/Cargo.toml new file mode 100644 index 00000000..047fea7e --- /dev/null +++ b/libs/https-listener/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "et-https-listener" +publish = true +description = "TLS pairs and advertised LAN addresses for the HTTPS listeners edge-toolkit servers open" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +all-features = true + +[lib] +doctest = false + +[[test]] +name = "tls" +required-features = ["tls-self-signed"] + +[features] +## Loads a PEM certificate and key, and builds the rustls server config that serves them. +tls = ["dep:rustls"] +## Generates a self-signed pair, for the loopback names and any others given, when none is on disk yet. +tls-self-signed = ["dep:fs-err", "dep:rcgen", "dep:rustls-webpki", "tls"] +## Internal: renders this feature list into the crate documentation, as docs.rs builds it. +docs = ["dep:document-features"] + +[dependencies] +document-features = { workspace = true, optional = true } +fs-err = { workspace = true, optional = true } +local-ip-address.workspace = true +rcgen = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +rustls-webpki = { workspace = true, optional = true } +thiserror = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } + +[dev-dependencies] +fs-err.workspace = true +rustls.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/libs/https-listener/src/lib.rs b/libs/https-listener/src/lib.rs new file mode 100644 index 00000000..e4e9eb40 --- /dev/null +++ b/libs/https-listener/src/lib.rs @@ -0,0 +1,10 @@ +//! What an edge-toolkit server needs to open an HTTPS listener a browser on another device can reach. +//! +//! [`net`] finds the host's LAN addresses worth advertising, and [`tls`] loads or generates the certificate a listener +//! serves, a self-signed one covering those addresses as well as the loopback names. +#![cfg_attr(feature = "docs", doc = "## Feature flags")] +#![cfg_attr(feature = "docs", doc = document_features::document_features!())] + +pub mod net; +#[cfg(feature = "tls")] +pub mod tls; diff --git a/services/ws-server/src/net.rs b/libs/https-listener/src/net.rs similarity index 68% rename from services/ws-server/src/net.rs rename to libs/https-listener/src/net.rs index a73f0117..3db38d27 100644 --- a/services/ws-server/src/net.rs +++ b/libs/https-listener/src/net.rs @@ -18,11 +18,11 @@ pub enum NetError { /// Filters an interface list to reachable IPv4 addresses and orders them by likely client reachability. /// -/// Keeps only non-loopback, non-link-local, non-unspecified IPv4 addresses, then sorts them by interface -/// class, preserving enumeration order within each class: the `log_interface` name (if given) first, -/// then `en*` (Wi-Fi / Ethernet), then `bridge*`, then everything else (VPN tunnels and the like). The -/// `bridge*` class still outranks the tunnels because when macOS Internet Sharing is active and the Wi-Fi -/// NIC holds no address, its `bridge100` gateway is the best guess even without an explicit preference. +/// Keeps only non-loopback, non-link-local, non-unspecified IPv4 addresses, then sorts them by interface class, +/// preserving enumeration order within each class: the `log_interface` name (if given) first, then `en*` (Wi-Fi / +/// Ethernet), then `bridge*`, then everything else (VPN tunnels and the like). The `bridge*` class still outranks the +/// tunnels because when macOS Internet Sharing is active and the Wi-Fi NIC holds no address, its `bridge100` gateway is +/// the best guess even without an explicit preference. #[must_use] pub fn rank_candidates(ifas: Vec<(String, IpAddr)>, log_interface: Option<&str>) -> Vec<(String, IpAddr)> { let mut candidates: Vec<(String, IpAddr)> = ifas @@ -56,13 +56,25 @@ fn format_ifas(ifas: &[(String, IpAddr)]) -> String { /// Enumerates the host's network interfaces and returns the ranked IPv4 candidates for the startup banner. /// -/// Logs the complete raw enumeration (every interface and address, before any filtering) so a machine -/// with no usable candidate can be diagnosed from the startup log alone -- the offline-hotspot demo is -/// exactly the situation where no second machine is available to poke around from. An explicitly -/// configured `log_interface` is a hard requirement: if it is absent or holds no usable IPv4 address, -/// this returns an error (aborting startup) rather than silently advertising some other address. +/// Logs the complete raw enumeration (every interface and address, before any filtering) so a machine with no usable +/// candidate can be diagnosed from the startup log alone -- the offline-hotspot demo is exactly the situation where +/// no second machine is available to poke around from. An explicitly configured `log_interface` is a hard requirement: +/// if it is absent or holds no usable IPv4 address, this returns an error (aborting startup) rather than silently +/// advertising some other address. pub fn candidate_ipv4s(log_interface: Option<&str>) -> Result, NetError> { - let ifas = match local_ip_address::list_afinet_netifas() { + select_candidates(local_ip_address::list_afinet_netifas(), log_interface) +} + +/// The ranked IPv4 candidates in `enumerated`, the operating system's answer when asked for its interfaces. +/// +/// It is [`candidate_ipv4s`] once the operating system has answered, taking the answer as a value, so that every way +/// the answer can go -- a failure included, which an operating system gives only when something is badly wrong -- can +/// be handed to it directly. +pub fn select_candidates( + enumerated: Result, local_ip_address::Error>, + log_interface: Option<&str>, +) -> Result, NetError> { + let ifas = match enumerated { Ok(ifas) => ifas, Err(e) => { if log_interface.is_some() { diff --git a/libs/https-listener/src/tls.rs b/libs/https-listener/src/tls.rs new file mode 100644 index 00000000..df45e09b --- /dev/null +++ b/libs/https-listener/src/tls.rs @@ -0,0 +1,147 @@ +//! The certificate an HTTPS listener serves: loaded from PEM files, or generated self-signed and written to them. +#![expect( + clippy::unwrap_used, + reason = "TLS bootstrap helpers panic on invalid PEM / cert-gen failure intentionally; callers are main + tests" +)] + +use std::path::Path; + +use rustls::pki_types::pem::PemObject as _; +#[cfg(feature = "tls-self-signed")] +use tracing::{info, warn}; + +type CertKeyPair = ( + rustls::pki_types::CertificateDer<'static>, + rustls::pki_types::PrivateKeyDer<'static>, +); + +#[must_use] +pub fn load_tls_certs(cert_filename: &Path, key_filename: &Path) -> CertKeyPair { + let cert_der = rustls::pki_types::CertificateDer::from_pem_file(cert_filename).unwrap(); + let key_der = rustls::pki_types::PrivateKeyDer::from_pem_file(key_filename).unwrap(); + (cert_der, key_der) +} + +/// Generate a self-signed pair for `localhost`, `127.0.0.1` and `::1`, writing it to the two files. +#[cfg(feature = "tls-self-signed")] +#[must_use] +pub fn generate_tls_certs(cert_filename: &Path, key_filename: &Path) -> CertKeyPair { + generate_tls_certs_covering(cert_filename, key_filename, &[]) +} + +/// Generate a self-signed pair for the loopback names and `names`, writing it to the two files. +/// +/// Each of `names` is a DNS name or an IP address, such as a LAN address a phone reaches the server at, which a browser +/// then finds in the certificate rather than refusing it as issued for another host. +#[cfg(feature = "tls-self-signed")] +#[must_use] +pub fn generate_tls_certs_covering(cert_filename: &Path, key_filename: &Path, names: &[String]) -> CertKeyPair { + let loopback = ["localhost", "127.0.0.1", "::1"].map(str::to_owned); + let certified = rcgen::generate_simple_self_signed([loopback.as_slice(), names].concat()).unwrap(); + fs_err::write(cert_filename, certified.cert.pem()).unwrap(); + write_private(key_filename, certified.signing_key.serialize_pem().as_bytes()); + let cert_der = certified.cert.der().clone(); + let key_der = rustls::pki_types::PrivateKeyDer::from(rustls::pki_types::PrivatePkcs8KeyDer::from( + certified.signing_key.serialize_der(), + )); + (cert_der, key_der) +} + +/// Write `contents` to a new file at `path` that only its owner can read, replacing any file there. +/// +/// On Unix the file is created with mode `0600`, so the key is never readable by anyone else, not even for the moment +/// between its creation and a later change of mode. Windows gives a new file the access list of the directory it is +/// made in, which in a user's own tree already admits only that user and the administrators. +#[cfg(feature = "tls-self-signed")] +#[expect( + clippy::single_call_fn, + reason = "writing a secret file owner-only is a step of its own, apart from generating what goes in it" +)] +fn write_private(path: &Path, contents: &[u8]) { + use std::io::Write as _; + + // A file already there keeps its own mode when written over, so it is removed rather than truncated; it may well + // not exist, and the open below reports any real problem. + let _absent: Result<(), std::io::Error> = fs_err::remove_file(path); + let mut new_file = fs_err::OpenOptions::new(); + let options = new_file.write(true).create_new(true); + #[cfg(unix)] + let options = { + use fs_err::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600) + }; + options.open(path).unwrap().write_all(contents).unwrap(); +} + +/// Each of `names` the certificate `cert` is not valid for, in the order given. +/// +/// A name that is neither a DNS name nor an IP address cannot be covered by any certificate, and is counted with them. +#[cfg(feature = "tls-self-signed")] +#[must_use] +pub fn uncovered_names(cert: &rustls::pki_types::CertificateDer<'_>, names: &[String]) -> Vec { + let parsed = webpki::EndEntityCert::try_from(cert); + names + .iter() + .filter(|name| { + let server = rustls::pki_types::ServerName::try_from(name.as_str()); + !matches!((&parsed, server), (Ok(end), Ok(server)) if end.verify_is_valid_for_subject_name(&server).is_ok()) + }) + .cloned() + .collect() +} + +/// Warn of each of `names` the loaded certificate `cert` does not cover, and how to have a pair made that does. +#[cfg(feature = "tls-self-signed")] +#[expect( + clippy::single_call_fn, + reason = "the stale-certificate warning is a step of loading a pair, kept apart from the choice to load one" +)] +fn warn_if_stale(cert: &rustls::pki_types::CertificateDer<'_>, names: &[String], cert_filename: &Path, key: &Path) { + let missing = uncovered_names(cert, names); + if !missing.is_empty() { + warn!( + "TLS certificate {} does not cover {}; delete it and {} to have one generated that does", + cert_filename.display(), + missing.join(", "), + key.display() + ); + } +} + +/// The pair in the two files when both exist, or else a newly generated one. +/// +/// A generated pair is self-signed for the loopback names and `names`, and written to the two files. A loaded pair is +/// served as it is, since it may be one an operator gave; each of `names` it does not cover -- a LAN address that has +/// changed since it was generated -- is warned of, with how to have a new one made. +#[cfg(feature = "tls-self-signed")] +#[must_use] +pub fn load_or_generate_tls_certs(cert_filename: &Path, key_filename: &Path, names: &[String]) -> CertKeyPair { + let (done, pair) = if cert_filename.exists() && key_filename.exists() { + let pair = load_tls_certs(cert_filename, key_filename); + warn_if_stale(&pair.0, names, cert_filename, key_filename); + ("Loaded", pair) + } else { + ( + "Generated a self-signed", + generate_tls_certs_covering(cert_filename, key_filename, names), + ) + }; + let report = format!( + "{done} TLS certificate {} and key {}", + cert_filename.display(), + key_filename.display() + ); + info!("{report}"); + pair +} + +#[must_use] +pub fn build_tls_server_config( + cert_der: rustls::pki_types::CertificateDer<'static>, + key_der: rustls::pki_types::PrivateKeyDer<'static>, +) -> rustls::ServerConfig { + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap() +} diff --git a/services/ws-server/tests/net.rs b/libs/https-listener/tests/net.rs similarity index 66% rename from services/ws-server/tests/net.rs rename to libs/https-listener/tests/net.rs index 4d01bb91..1b1f28c0 100644 --- a/services/ws-server/tests/net.rs +++ b/libs/https-listener/tests/net.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use et_ws_server::net::{NetError, candidate_ipv4s, rank_candidates}; +use et_https_listener::net::{NetError, candidate_ipv4s, rank_candidates, select_candidates}; fn ifa(name: &str, octets: [u8; 4]) -> (String, IpAddr) { (name.to_string(), IpAddr::V4(Ipv4Addr::from(octets))) @@ -32,8 +32,8 @@ fn filters_out_unusable_addresses() { #[test] fn log_interface_ranks_first() { - // Internet Sharing with a wired uplink: the phone scanning the QR code sits on the bridge100 - // network, so the explicitly preferred hotspot gateway must beat the uplink NIC's LAN address. + // Internet Sharing with a wired uplink: the phone scanning the QR code sits on the bridge100 network, so the + // explicitly preferred hotspot gateway must beat the uplink NIC's LAN address. let ranked = rank_candidates( vec![ifa("en8", [10, 128, 116, 231]), ifa("bridge100", [192, 168, 2, 1])], Some("bridge100"), @@ -94,3 +94,31 @@ fn missing_log_interface_is_a_startup_error() { fn no_preference_never_errors() { let _ranked = candidate_ipv4s(None).unwrap(); } + +#[test] +fn a_failed_enumeration_is_an_error_only_when_an_interface_was_asked_for() { + let failed = || Err(local_ip_address::Error::PlatformNotSupported("test".to_owned())); + let err = select_candidates(failed(), Some("en0")).unwrap_err(); + assert!(matches!(err, NetError::Enumeration(_))); + assert!(err.to_string().starts_with("failed to enumerate network interfaces")); + assert_eq!(select_candidates(failed(), None).unwrap(), Vec::new()); +} + +#[test] +fn nothing_usable_is_no_candidate_and_no_error_without_a_preference() { + let loopback_only = vec![ifa("lo", [127, 0, 0, 1])]; + assert_eq!(select_candidates(Ok(loopback_only), None).unwrap(), Vec::new()); +} + +#[test] +fn an_asked_for_interface_among_nothing_usable_says_so() { + let err = select_candidates(Ok(Vec::new()), Some("en0")).unwrap_err(); + assert!(err.to_string().ends_with("usable candidates: (none)")); +} + +#[test] +fn an_asked_for_interface_that_is_usable_is_ranked_first() { + let enumerated = vec![ifa("en0", [192, 168, 1, 2]), ifa("wg0", [10, 0, 0, 2])]; + let ranked = select_candidates(Ok(enumerated), Some("wg0")).unwrap(); + assert_eq!(names(&ranked), ["wg0", "en0"]); +} diff --git a/libs/https-listener/tests/tls.rs b/libs/https-listener/tests/tls.rs new file mode 100644 index 00000000..5f9efa26 --- /dev/null +++ b/libs/https-listener/tests/tls.rs @@ -0,0 +1,158 @@ +//! Covers the TLS bootstrap helpers: self-signed generation, PEM round-trip load, and rustls config build. +#![cfg(test)] + +use et_https_listener::tls::{ + build_tls_server_config, generate_tls_certs, generate_tls_certs_covering, load_or_generate_tls_certs, + load_tls_certs, uncovered_names, +}; +use rustls::pki_types::CertificateDer; +use tempfile::tempdir; + +#[test] +fn generate_write_load_and_build_server_config() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + + // Generate a self-signed pair, which also writes both PEM files to disk. + let (gen_cert, gen_key) = generate_tls_certs(&cert, &key); + assert!( + cert.exists() && key.exists(), + "generate_tls_certs must write both PEM files" + ); + + // The freshly-written PEMs must load back into the exact same der bytes that were generated. + let (loaded_cert, loaded_key) = load_tls_certs(&cert, &key); + assert_eq!(gen_cert, loaded_cert, "reloaded cert der must match the generated der"); + assert_eq!( + gen_key.secret_der(), + loaded_key.secret_der(), + "reloaded key der must match the generated der" + ); + + let from_generated = build_tls_server_config(gen_cert, gen_key); + assert!( + from_generated.alpn_protocols.is_empty(), + "no ALPN protocols are configured by default" + ); + + let from_loaded = build_tls_server_config(loaded_cert, loaded_key); + assert!( + from_loaded.alpn_protocols.is_empty(), + "reloaded config also has no ALPN protocols" + ); +} + +#[test] +#[should_panic(expected = "NotFound")] +fn load_tls_certs_missing_cert_file_panics() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("missing-cert.pem"); + let key = dir.path().join("missing-key.pem"); + drop(load_tls_certs(&cert, &key)); +} + +#[test] +#[should_panic(expected = "NotFound")] +fn load_tls_certs_missing_key_file_panics() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + drop(generate_tls_certs(&cert, &key)); + fs_err::remove_file(&key).unwrap(); + + drop(load_tls_certs(&cert, &key)); +} + +/// Whether `needle` appears anywhere in `der`, as a subject alternative name's bytes do in a certificate. +fn holds(der: &[u8], needle: &[u8]) -> bool { + der.windows(needle.len()).any(|window| window == needle) +} + +#[test] +fn a_certificate_covers_the_names_it_is_generated_for_beside_the_loopback_ones() { + let dir = tempdir().unwrap(); + let names = ["10-1-2-3.nip.io".to_owned(), "10.1.2.3".to_owned()]; + let (cert, _key) = generate_tls_certs_covering(&dir.path().join("cert.pem"), &dir.path().join("key.pem"), &names); + assert!(holds(cert.as_ref(), b"10-1-2-3.nip.io")); + assert!(holds(cert.as_ref(), &[10, 1, 2, 3])); + assert!(holds(cert.as_ref(), b"localhost")); +} + +#[test] +fn a_pair_on_disk_is_loaded_and_a_missing_one_generated_once() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + let (generated, _key) = load_or_generate_tls_certs(&cert, &key, &[]); + assert!(cert.exists() && key.exists()); + let (loaded, _key) = load_or_generate_tls_certs(&cert, &key, &["other.example".to_owned()]); + assert_eq!(generated, loaded); + // A name the pair covers loads it just the same, with nothing to warn of. + let (covered, _key) = load_or_generate_tls_certs(&cert, &key, &["localhost".to_owned()]); + assert_eq!(generated, covered); +} + +#[test] +fn a_certificate_without_its_key_is_replaced() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + let (first, _key) = load_or_generate_tls_certs(&cert, &key, &[]); + fs_err::remove_file(&key).unwrap(); + let (second, _key) = load_or_generate_tls_certs(&cert, &key, &[]); + assert_ne!(first, second); + assert!(key.exists()); +} + +#[test] +fn a_certificate_names_each_advertised_name_it_does_not_cover() { + let dir = tempdir().unwrap(); + let names = ["10-1-2-3.nip.io".to_owned(), "10.1.2.3".to_owned()]; + let (cert, _key) = generate_tls_certs_covering(&dir.path().join("cert.pem"), &dir.path().join("key.pem"), &names); + assert_eq!(uncovered_names(&cert, &names), Vec::::new()); + let moved = ["10.1.2.3".to_owned(), "10.9.9.9".to_owned(), "not a name!".to_owned()]; + assert_eq!(uncovered_names(&cert, &moved), ["10.9.9.9", "not a name!"]); + // A certificate that will not parse covers nothing. + let garbled = CertificateDer::from(vec![1_u8, 2, 3]); + assert_eq!(uncovered_names(&garbled, &names), names); +} + +/// Check the key at `key` is a PEM private key that, on Unix, only its owner can read or write. +fn assert_private(key: &std::path::Path) { + assert!(fs_err::read_to_string(key).unwrap().contains("PRIVATE KEY")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!(fs_err::metadata(key).unwrap().permissions().mode() & 0o777, 0o600); + } +} + +#[test] +fn a_generated_key_is_private_even_over_a_readable_one() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + drop(generate_tls_certs(&cert, &key)); + assert_private(&key); + // A key left behind without its certificate, readable by all, is replaced rather than written through. + fs_err::remove_file(&cert).unwrap(); + fs_err::write(&key, "old").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs_err::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + drop(load_or_generate_tls_certs(&cert, &key, &[])); + assert_private(&key); +} + +#[test] +#[should_panic(expected = "InconsistentKeys")] +fn build_tls_server_config_with_mismatched_key_panics() { + let dir = tempdir().unwrap(); + let (cert_a, _key_a) = generate_tls_certs(&dir.path().join("a-cert.pem"), &dir.path().join("a-key.pem")); + let (_cert_b, key_b) = generate_tls_certs(&dir.path().join("b-cert.pem"), &dir.path().join("b-key.pem")); + + drop(build_tls_server_config(cert_a, key_b)); +} diff --git a/services/modules/Cargo.toml b/services/modules/Cargo.toml index 276871f3..19f71d0b 100644 --- a/services/modules/Cargo.toml +++ b/services/modules/Cargo.toml @@ -65,7 +65,8 @@ docs = ["dep:document-features"] actix-files = { workspace = true, optional = true } actix-web = { workspace = true, optional = true } document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +# Its module-folder configuration needs an operating system, so `std` turns it on; a board build has no use for it. +edge-toolkit = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } picoserve = { workspace = true, features = ["json"], optional = true } schemars = { workspace = true, optional = true } diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index f75bc332..b6b3fd54 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -64,7 +64,7 @@ std = [ "edge-toolkit/std", ] ## Serves agent storage through picoserve, kept wherever the embedder keeps it, on a board with no filesystem. -board = ["dep:picoserve"] +board = ["dep:picoserve", "edge-toolkit/board"] ## Adds OpenAPI metadata to the `/storage` handlers for `et-int-gen` to harvest into the published spec. openapi-spec = ["dep:utoipa", "std"] ## Renders each stored image as a thumbnail on the server's terminal, e.g. what pic-viewer uploads. @@ -79,7 +79,8 @@ docs = ["dep:document-features"] actix-web = { workspace = true, optional = true } actix-web-thiserror = { workspace = true, optional = true } document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +# The agent registry the routes check callers against: `std` and `board` each turn it on, with the lock that build has. +edge-toolkit = { workspace = true, optional = true } et-hub-backplane = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index ef755630..3ee50a03 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -23,10 +23,6 @@ path = "src/main.rs" name = "configure_app" required-features = ["modules", "storage"] -[[test]] -name = "tls" -required-features = ["tls-self-signed"] - [features] #! Every service the hub can mount beyond `/ws` and `/health` is a feature, all on by default. #! A deployment that needs only the hub sets `default-features = false` and opts back into the services it serves. @@ -43,10 +39,10 @@ qr = ["dep:qr2term"] storage = ["dep:et-storage-service"] ## Adds the HTTPS/WSS listener, serving the PEM pair named by `TLS_CERT_FILE` and `TLS_KEY_FILE`. ## Without it the hub serves plain HTTP only, which browsers refuse for camera, sensors and other secure-context APIs. -tls = ["actix-web/rustls-0_23", "dep:rustls", "dep:serde-inline-default", "et-actix-web-config/rustls-0_23"] +tls = ["actix-web/rustls-0_23", "dep:serde-inline-default", "et-actix-web-config/rustls-0_23", "et-https-listener/tls"] ## Generates a self-signed `localhost` pair into `TLS_CERT_FILE` and `TLS_KEY_FILE` when they do not exist yet. ## Without it a missing pair is a startup error, for deployments that provision their own certificates. -tls-self-signed = ["dep:fs-err", "dep:rcgen", "tls"] +tls-self-signed = ["et-https-listener/tls-self-signed", "tls"] ## Renders each image an agent stores as a thumbnail on the hub's terminal, e.g. what pic-viewer uploads. ## It implies `storage`, and is what pulls the `image` decoder into the build. tty-image = ["et-storage-service/tty-image", "storage"] @@ -79,22 +75,18 @@ actix-web.workspace = true document-features = { workspace = true, optional = true } edge-toolkit = { workspace = true, features = ["std"] } et-actix-web-config.workspace = true +et-https-listener.workspace = true et-modules-service = { workspace = true, features = ["std"], optional = true } et-otlp = { workspace = true, optional = true } et-storage-service = { workspace = true, features = ["std"], optional = true } et-websockify-service = { workspace = true, optional = true } et-ws-service = { workspace = true, features = ["nats", "std"] } -fs-err = { workspace = true, optional = true } -local-ip-address.workspace = true qr2term = { workspace = true, optional = true } -rcgen = { workspace = true, optional = true } -rustls = { workspace = true, optional = true } schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["std"] } serde-env.workspace = true serde-inline-default = { workspace = true, optional = true } serde_default.workspace = true -thiserror = { workspace = true, features = ["std"] } tracing = { workspace = true, features = ["std"] } tracing-actix-web.workspace = true tracing-subscriber.workspace = true diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index 07d6083d..168587e5 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -7,13 +7,13 @@ use std::net::{Ipv4Addr, SocketAddr}; use actix_web::{HttpResponse, web}; +pub use et_https_listener::net; +#[cfg(feature = "tls")] +pub use et_https_listener::tls; pub use et_ws_service::{AgentSession, WsAgentRegistry}; pub mod config; -pub mod net; pub mod routes; -#[cfg(feature = "tls")] -pub mod tls; pub use self::routes::health; use crate::config::Config; @@ -35,9 +35,9 @@ pub fn configure_app(cfg: &mut web::ServiceConfig, agent_registry: web::Data(cfg, &config.storage); } - // Relay for browser WASM runtimes (webR) that can only open a WebSocket: bridge /websockify to this - // server's own plain-HTTP loopback port so their libcurl/httr2 can reach the storage API. Loopback-only - // and server-fixed, so it is not an open proxy. Registered before the modules catch-all below. + // Relay for browser WASM runtimes (webR) that can only open a WebSocket: bridge /websockify to this server's own + // plain-HTTP loopback port so their libcurl/httr2 can reach the storage API. Loopback-only and server-fixed, so it + // is not an open proxy. Registered before the modules catch-all below. #[cfg(feature = "websockify")] { let relay_target = SocketAddr::from(( diff --git a/services/ws-server/src/main.rs b/services/ws-server/src/main.rs index f2fb16df..04bc546c 100644 --- a/services/ws-server/src/main.rs +++ b/services/ws-server/src/main.rs @@ -51,19 +51,13 @@ async fn main() -> Result<(), Box> { let rustls_config = { let cert_filename = &env.tls.cert_file; let key_filename = &env.tls.key_file; + #[cfg(feature = "tls-self-signed")] + let (cert_der, key_der) = tls::load_or_generate_tls_certs(cert_filename, key_filename, &[]); + #[cfg(not(feature = "tls-self-signed"))] let (cert_der, key_der) = if cert_filename.exists() && key_filename.exists() { info!("Loading TLS certificate from {:?}", cert_filename); tls::load_tls_certs(cert_filename, key_filename) } else { - #[cfg(feature = "tls-self-signed")] - { - info!( - "Generated self-signed localhost certificate to {:?} and key to {:?}", - cert_filename, key_filename - ); - tls::generate_tls_certs(cert_filename, key_filename) - } - #[cfg(not(feature = "tls-self-signed"))] return Err(format!( "TLS certificate {} or key {} is missing; this build cannot generate one", cert_filename.display(), @@ -121,19 +115,14 @@ async fn main() -> Result<(), Box> { let registry = agent_registry.clone(); let backplane = backplane_data.clone(); let config = env.clone(); - // `TracingLogger` extracts the W3C `traceparent` header from - // incoming requests (via the `opentelemetry_0_31` feature) and uses - // it as the parent context of the per-request span -- that's how - // traces propagate from the wasi-runner (or any client that injects - // `traceparent`) into the server. + // `TracingLogger` extracts the W3C `traceparent` header from incoming requests (via the `opentelemetry_0_31` + // feature) and uses it as the parent context of the per-request span -- that's how traces propagate from the + // wasi-runner (or any client that injects `traceparent`) into the server. App::new() - // `Logger::default()` emits one `actix_web` INFO log line per - // request (method, path, status, duration). The - // tracing-subscriber default has `tracing-log` enabled, so the - // `log` records show up in the same console as tracing events - // -- invaluable when an actix-files 404 would otherwise be - // silent (TracingLogger only creates the span, it doesn't emit - // events on success). + // `Logger::default()` emits one `actix_web` INFO log line per request (method, path, status, duration). The + // tracing-subscriber default has `tracing-log` enabled, so the `log` records show up in the same console + // as tracing events -- invaluable when an actix-files 404 would otherwise be silent (TracingLogger only + // creates the span, it doesn't emit events on success). .wrap(Logger::default()) .wrap(TracingLogger::default()) .wrap( @@ -162,8 +151,7 @@ async fn main() -> Result<(), Box> { withdraw_local_agents(&backplane, ®istry_clone).await; } delivery.stop().await; - // Flush batched spans/logs before exit; otherwise short-lived runs lose - // the tail of the trace. + // Flush batched spans/logs before exit; otherwise short-lived runs lose the tail of the trace. #[cfg(feature = "otlp")] if let Some(handles) = otel_handles { handles.shutdown(); diff --git a/services/ws-server/src/tls.rs b/services/ws-server/src/tls.rs deleted file mode 100644 index a7532b77..00000000 --- a/services/ws-server/src/tls.rs +++ /dev/null @@ -1,49 +0,0 @@ -#![expect( - clippy::unwrap_used, - reason = "TLS bootstrap helpers panic on invalid PEM / cert-gen failure intentionally; callers are main + tests" -)] - -use std::path::Path; - -use rustls::pki_types::pem::PemObject as _; - -type CertKeyPair = ( - rustls::pki_types::CertificateDer<'static>, - rustls::pki_types::PrivateKeyDer<'static>, -); - -#[must_use] -pub fn load_tls_certs(cert_filename: &Path, key_filename: &Path) -> CertKeyPair { - let cert_der = rustls::pki_types::CertificateDer::from_pem_file(cert_filename).unwrap(); - let key_der = rustls::pki_types::PrivateKeyDer::from_pem_file(key_filename).unwrap(); - (cert_der, key_der) -} - -#[cfg(feature = "tls-self-signed")] -#[must_use] -pub fn generate_tls_certs(cert_filename: &Path, key_filename: &Path) -> CertKeyPair { - let certified = rcgen::generate_simple_self_signed(vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "::1".to_string(), - ]) - .unwrap(); - fs_err::write(cert_filename, certified.cert.pem()).unwrap(); - fs_err::write(key_filename, certified.signing_key.serialize_pem()).unwrap(); - let cert_der = certified.cert.der().clone(); - let key_der = rustls::pki_types::PrivateKeyDer::from(rustls::pki_types::PrivatePkcs8KeyDer::from( - certified.signing_key.serialize_der(), - )); - (cert_der, key_der) -} - -#[must_use] -pub fn build_tls_server_config( - cert_der: rustls::pki_types::CertificateDer<'static>, - key_der: rustls::pki_types::PrivateKeyDer<'static>, -) -> rustls::ServerConfig { - rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert_der], key_der) - .unwrap() -} diff --git a/services/ws-server/tests/tls.rs b/services/ws-server/tests/tls.rs deleted file mode 100644 index 21f5b23a..00000000 --- a/services/ws-server/tests/tls.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Covers the TLS bootstrap helpers: self-signed generation, PEM round-trip load, and rustls config build. -#![cfg(test)] - -use et_ws_server::tls::{build_tls_server_config, generate_tls_certs, load_tls_certs}; -use tempfile::tempdir; - -#[test] -fn generate_write_load_and_build_server_config() { - let dir = tempdir().unwrap(); - let cert = dir.path().join("cert.pem"); - let key = dir.path().join("key.pem"); - - // Generate a self-signed pair, which also writes both PEM files to disk. - let (gen_cert, gen_key) = generate_tls_certs(&cert, &key); - assert!( - cert.exists() && key.exists(), - "generate_tls_certs must write both PEM files" - ); - - // The freshly-written PEMs must load back into the exact same der bytes that were generated. - let (loaded_cert, loaded_key) = load_tls_certs(&cert, &key); - assert_eq!(gen_cert, loaded_cert, "reloaded cert der must match the generated der"); - assert_eq!( - gen_key.secret_der(), - loaded_key.secret_der(), - "reloaded key der must match the generated der" - ); - - let from_generated = build_tls_server_config(gen_cert, gen_key); - assert!( - from_generated.alpn_protocols.is_empty(), - "no ALPN protocols are configured by default" - ); - - let from_loaded = build_tls_server_config(loaded_cert, loaded_key); - assert!( - from_loaded.alpn_protocols.is_empty(), - "reloaded config also has no ALPN protocols" - ); -} - -#[test] -#[should_panic(expected = "NotFound")] -fn load_tls_certs_missing_cert_file_panics() { - let dir = tempdir().unwrap(); - let cert = dir.path().join("missing-cert.pem"); - let key = dir.path().join("missing-key.pem"); - drop(load_tls_certs(&cert, &key)); -} - -#[test] -#[should_panic(expected = "NotFound")] -fn load_tls_certs_missing_key_file_panics() { - let dir = tempdir().unwrap(); - let cert = dir.path().join("cert.pem"); - let key = dir.path().join("key.pem"); - drop(generate_tls_certs(&cert, &key)); - fs_err::remove_file(&key).unwrap(); - - drop(load_tls_certs(&cert, &key)); -} - -#[test] -#[should_panic(expected = "InconsistentKeys")] -fn build_tls_server_config_with_mismatched_key_panics() { - let dir = tempdir().unwrap(); - let (cert_a, _key_a) = generate_tls_certs(&dir.path().join("a-cert.pem"), &dir.path().join("a-key.pem")); - let (_cert_b, key_b) = generate_tls_certs(&dir.path().join("b-cert.pem"), &dir.path().join("b-key.pem")); - - drop(build_tls_server_config(cert_a, key_b)); -} diff --git a/services/ws-web-runner/Cargo.toml b/services/ws-web-runner/Cargo.toml index c93a8e5b..d3c0b577 100644 --- a/services/ws-web-runner/Cargo.toml +++ b/services/ws-web-runner/Cargo.toml @@ -68,11 +68,9 @@ rstest.workspace = true serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt", "time"] } -[build-dependencies] -# Compiles mingw-shim/ for the x86_64-pc-windows-gnu build (see build.rs). -# Used only in build.rs's `#[cfg(windows)]` branch, so these go unreferenced on non-Windows hosts -- harmless, -# since that branch (and the functions it calls) don't compile there, which also keeps them out of the Linux -# coverage report. +# What build.rs compiles mingw-shim/ with, for the x86_64-pc-windows-gnu build. +# Its `#[cfg(windows)]` branch is all that uses them, so they are declared for Windows alone. +[target.'cfg(windows)'.build-dependencies] cc.workspace = true command-error.workspace = true fs-err.workspace = true diff --git a/services/ws/Cargo.toml b/services/ws/Cargo.toml index 003bac11..3b2b76b1 100644 --- a/services/ws/Cargo.toml +++ b/services/ws/Cargo.toml @@ -37,8 +37,8 @@ required-features = ["board"] [features] default = ["board", "nats", "otel"] ## The actix-web hub: the `/ws` endpoint, the admin API, idle timeouts and the registry file. -## Without it the crate is `no_std` + `alloc` and holds only `hub`, the routing an embedder drives over its own -## transport. +## Without it the crate is `no_std` + `alloc`, and holds `hub`, the routing an embedder drives over its own +## transport, only with `board`, which gives the hub's registry its lock. std = [ "bytes/std", "dep:actix-web", @@ -59,7 +59,7 @@ std = [ "tracing/std", ] ## A `no_std` hub serving `/ws` over HTTP and WebSocket through picoserve, on any socket the embedder accepts. -board = ["dep:picoserve", "dep:spin"] +board = ["dep:bytes", "dep:picoserve", "dep:serde_json", "dep:tracing", "edge-toolkit/board"] ## Shares agents between hubs through the NATS JetStream backplane `WS_BACKPLANE` names. ## Without it every hub is local, keeping its agents in its own registry. nats = ["dep:et-hub-backplane", "std"] @@ -75,11 +75,12 @@ docs = ["dep:document-features"] [dependencies] actix-web = { workspace = true, optional = true } actix-ws = { workspace = true, optional = true } -bytes.workspace = true +bytes = { workspace = true, optional = true } bytesize = { workspace = true, optional = true } chrono = { workspace = true, features = ["clock"], optional = true } document-features = { workspace = true, optional = true } -edge-toolkit.workspace = true +# Turned on, as bytes, serde_json and tracing are, by `std` and by `board`, the two builds that hold the hub. +edge-toolkit = { workspace = true, optional = true } et-hub-backplane = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } opentelemetry = { workspace = true, optional = true } @@ -89,12 +90,11 @@ secrecy = { workspace = true, optional = true } serde.workspace = true serde-inline-default = { workspace = true, optional = true } serde_default = { workspace = true, optional = true } -serde_json = { workspace = true, features = ["alloc"] } -spin = { workspace = true, optional = true } +serde_json = { workspace = true, features = ["alloc"], optional = true } subtle = { workspace = true, optional = true } thiserror = { workspace = true, features = ["std"], optional = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"], optional = true } -tracing.workspace = true +tracing = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } uuid = { workspace = true, optional = true } diff --git a/services/ws/src/lib.rs b/services/ws/src/lib.rs index e43dea25..b7181270 100644 --- a/services/ws/src/lib.rs +++ b/services/ws/src/lib.rs @@ -13,6 +13,9 @@ mod backplane; #[cfg(feature = "std")] mod connection; pub mod health; +// The `no_std` routing core both transports share: the `std` server and a board's picoserve hub. It holds +// edge-toolkit's agent registry, which a build has with either; with neither, there is nothing for it to serve over. +#[cfg(any(feature = "board", feature = "std"))] pub mod hub; #[cfg(feature = "board")] pub mod picoserve_hub; @@ -25,6 +28,7 @@ mod telemetry; pub use self::backplane::{ Backplane, BackplaneConfig, BackplaneDelivery, BackplaneError, start_backplane, withdraw_local_agents, }; +#[cfg(any(feature = "board", feature = "std"))] pub use self::hub::SessionMessage; #[cfg(feature = "std")] pub use self::server::{ diff --git a/services/ws/src/picoserve_hub.rs b/services/ws/src/picoserve_hub.rs index 08543c78..d9de9b20 100644 --- a/services/ws/src/picoserve_hub.rs +++ b/services/ws/src/picoserve_hub.rs @@ -17,6 +17,7 @@ use core::net::IpAddr; use core::task::{Poll, Waker}; use bytes::Bytes; +use edge_toolkit::board::BoardLock; use edge_toolkit::ws::ClientMessage; use edge_toolkit::ws_server::{AgentRegistry, MessageRetention}; use picoserve::futures::Either; @@ -57,7 +58,7 @@ struct Mailbox { /// The handle the registry holds for one connection: other agents' messages to it are queued here. #[derive(Clone, Debug)] -pub struct Outbox(Arc>); +pub struct Outbox(Arc>); impl Outbox { /// An empty outbox holding at most `max_queued` messages, though always the newest. @@ -66,7 +67,7 @@ impl Outbox { /// message is dropped to make room. #[must_use] pub fn new(max_queued: usize) -> Self { - Self(Arc::new(spin::Mutex::new(Mailbox { + Self(Arc::new(BoardLock::new(Mailbox { max_queued, queue: VecDeque::new(), waker: None, @@ -116,7 +117,7 @@ impl Outbox { /// The hub every connection shares: its registry, the source of its ids and times, and its limits. pub struct Hub { registry: AgentRegistry, - stamps: spin::Mutex, + stamps: BoardLock, retention: MessageRetention, max_frame_size: usize, max_queued: usize, @@ -136,7 +137,7 @@ where max_frame_size, max_queued, retention, - stamps: spin::Mutex::new(stamps), + stamps: BoardLock::new(stamps), registry: AgentRegistry::default(), } } diff --git a/services/ws/tests/hub.rs b/services/ws/tests/hub.rs index b5c70b80..6846a52e 100644 --- a/services/ws/tests/hub.rs +++ b/services/ws/tests/hub.rs @@ -1,6 +1,8 @@ //! The `no_std` routing core, `hub::Link`, driven directly with no transport: each frame's replies, deliveries and -//! shared steps. `S = String` stands in for the session handle, named after the agent that holds it. +//! shared steps. `S = String` stands in for the session handle, named after the agent that holds it. It runs wherever +//! the hub exists, with `std` or on a board. #![cfg(test)] +#![cfg(any(feature = "board", feature = "std"))] use std::net::{IpAddr, Ipv4Addr}; diff --git a/utilities/gpu-probe/.gitignore b/utilities/gpu-probe/.gitignore new file mode 100644 index 00000000..929805bf --- /dev/null +++ b/utilities/gpu-probe/.gitignore @@ -0,0 +1,2 @@ +# The probe page's WASM, which `mise run build-gpu-probe-page` builds. +/static/pkg/ diff --git a/utilities/gpu-probe/Cargo.toml b/utilities/gpu-probe/Cargo.toml new file mode 100644 index 00000000..5481f80d --- /dev/null +++ b/utilities/gpu-probe/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "et-gpu-probe-server" +publish = true +description = "HTTPS server that collects GPU probe signatures into the et-gpu-probe registry" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +doctest = false + +[[bin]] +doctest = false +name = "et-gpu-probe-server" +path = "src/bin/et-gpu-probe-server.rs" +test = false + +[[bin]] +doctest = false +name = "gpu-probe" +path = "src/bin/gpu-probe.rs" +test = false + +[dependencies] +actix-files.workspace = true +actix-web = { workspace = true, features = ["macros", "rustls-0_23"] } +clap.workspace = true +edge-toolkit = { workspace = true, features = ["std"] } +et-gpu-probe = { workspace = true, features = ["native", "registry"] } +et-https-listener = { workspace = true, features = ["tls-self-signed"] } +fs-err.workspace = true +futures-executor.workspace = true +serde = { workspace = true, features = ["std"] } +serde-saphyr = { workspace = true, features = ["deserialize"] } +serde_json = { workspace = true, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } +tracing-subscriber.workspace = true + +[dev-dependencies] +actix-http.workspace = true +actix-rt.workspace = true +tempfile.workspace = true +wgpu = { workspace = true, features = ["noop"] } + +[lints] +workspace = true diff --git a/utilities/gpu-probe/HELP.md b/utilities/gpu-probe/HELP.md new file mode 100644 index 00000000..fa51485b --- /dev/null +++ b/utilities/gpu-probe/HELP.md @@ -0,0 +1,34 @@ +# Command-Line Help for `et-gpu-probe-server` + +This document contains the help content for the `et-gpu-probe-server` command-line program. + +**Command Overview:** + +- [`et-gpu-probe-server`](#et-gpu-probe-server) + +## `et-gpu-probe-server` + +Collect GPU probe signatures into a registry over HTTPS + +**Usage:** `et-gpu-probe-server [OPTIONS] --registry --cert --key ` + +### Options + +- `--registry ` -- The registry file submissions are added to, such as `libs/gpu-probe/signatures.yaml` +- `--root ` -- A directory of static files, such as a probe page, to serve at `/`. + + Absent, the server serves only the form, the endpoint and the registry: there is no page to serve. +- `--bind ` -- The address to listen on: `0.0.0.0` for other devices on the LAN to reach it + + Default value: `127.0.0.1` +- `--port ` -- The port to listen on + + Default value: `8443` +- `--cert ` -- The PEM certificate to serve, generated self-signed with the key when either is missing +- `--key ` -- The PEM private key of the certificate +- `--public-host ` -- Another name to advertise the server at, which a generated certificate covers too +- `--http` -- Serve plain HTTP instead of HTTPS, which only a loopback `--bind` may. + + A browser treats a loopback origin as secure, so WebGPU still runs there. + + `--cert` and `--key` are not read. diff --git a/utilities/gpu-probe/README.md b/utilities/gpu-probe/README.md new file mode 100644 index 00000000..0802bb76 --- /dev/null +++ b/utilities/gpu-probe/README.md @@ -0,0 +1,91 @@ +# et-gpu-probe-server + +A server that collects GPU probe signatures into the `et-gpu-probe` registry, `libs/gpu-probe/signatures.yaml`, and +the probe page it serves. It serves HTTPS, since WebGPU needs a secure context and browsers treat every origin but a +loopback one as insecure over plain HTTP; `--http` serves plain HTTP on a loopback address alone. + +## The probe page + +`static/index.html` loads the probe sets built as WASM from `web/` and runs every one the browser can: the shader, +clear and fixed-function sets through WebGL 2, and all four WebGPU sets on each adapter WebGPU gives -- the +high-performance one, the low-power one and the software fallback. It shows each signature with a button that submits +it to the registry, under the GPU it ran on. The row it is seen at is filled in from what the browser reports, and can +be corrected before submitting; `?device=` fills in the device, and `?submit` submits every set once probed. +Serve it on this machine, which builds the WASM first: + +```bash +mise run gpu-probe-page +``` + +then open `http://localhost:8099/`. Chrome on Linux gives WebGPU only behind switches, and runs WebGL on whichever +ANGLE backend it is told to, so this opens it with WebGPU on and WebGL on `vulkan`, `gl` or `swiftshader`, serving the +page itself if nothing does yet; `--submit` drives it through chromedriver instead, submitting every set: + +```bash +mise run gpu-probe-chrome vulkan --device "Dell Precision 5570" --submit +``` + +For phones and other machines, serve it over HTTPS on this machine or on the LAN: + +```bash +mise run gpu-probe-server +``` + +```bash +mise run gpu-probe-server-lan +``` + +Then open one of the addresses it prints, and accept the self-signed certificate once in each browser. The +certificate covers localhost, each LAN address, and each address's `nip.io` name, which is a real domain for trying a +browser defence that treats a bare IP differently. `mise run build-gpu-probe-page` builds the WASM alone, into +`static/pkg`. + +## Submitting + +`POST /register` takes one JSON object: the `interface` the signature was measured through, a `label`, the +`results` keyed by name in output order -- or `blocked: true` and a `reason` for an interface a browser withheld -- +and the `seen` row of where it was measured, with the fields the registry's header lists: + +```json +{ + "interface": "webgpu-clear", + "label": "Intel Iris Xe (ADL GT2) through Mesa's Vulkan driver on Linux -- wgpu 29", + "results": { "unorm8_quarter": "0000000a", "unorm8_half": "0000000a" }, + "seen": { + "browser": "none", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "backend": "vulkan", + "window": "none", + "driver_version": "26.1.6" + } +} +``` + +A signature the registry has through that interface gains the row, unless it holds the same row already; anything +else becomes a new entry at the end. The file is edited as text, so its comments and every other entry are left as +they were. `/submit` posts each line pasted into it, which is how a result copied from a browser that cannot reach +the server gets registered. `/signatures.yaml` serves the registry as it stands, and `--root` serves a probe page's +files at `/`. + +A row the registry already holds with every field the same, perhaps among more written by hand, records the sighting +already, so nothing is added for it. + +## Recording this machine + +`gpu-probe` takes no arguments. It puts every probe set to each GPU adapter wgpu finds on this machine -- every GPU, +and any software renderer such as llvmpipe -- and to its CPU, and adds each sighting the repository's registry does +not yet hold, in a row built from what the device reports: its +vendor, name and backend, and the driver and API versions the graphics API beneath wgpu gives. Running it again adds +nothing. + +```bash +cargo run -p et-gpu-probe-server --bin gpu-probe +``` + +After collecting, regenerate the JSON the crate embeds: + +```bash +mise run gen:gpu-probe-signatures +``` diff --git a/utilities/gpu-probe/src/app.rs b/utilities/gpu-probe/src/app.rs new file mode 100644 index 00000000..1b8acf95 --- /dev/null +++ b/utilities/gpu-probe/src/app.rs @@ -0,0 +1,107 @@ +//! The routes the server answers: the submission form, the endpoint it posts to, the registry, and any static files. +#![expect( + clippy::single_call_fn, + reason = "each route's handler is a named function, registered once by configure" +)] + +use std::path::PathBuf; + +use actix_web::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use actix_web::middleware::DefaultHeaders; +use actix_web::{HttpMessage as _, HttpRequest, HttpResponse, web}; +use serde_json::json; +use tracing::info; + +use crate::registry::{Registry, Submission}; + +/// The paste-and-register page served at `/submit`. +const SUBMIT_PAGE: &str = include_str!("submit.html"); + +/// The styles the server's pages share, served at `/probe.css`. +const STYLES: &str = include_str!("probe.css"); + +/// The module the server's pages post a submission with, served at `/register.js`. +const REGISTER_SCRIPT: &str = include_str!("register.mjs"); + +/// Add the server's routes to `config`, adding to `registry` and serving the files under `root`, if given, at `/`. +/// +/// Nothing is cached: the registry changes while signatures are being collected, and a probe page being worked on +/// changes between visits. +pub fn configure(config: &mut web::ServiceConfig, registry: web::Data, root: Option) { + let mut scope = web::scope("") + .wrap(DefaultHeaders::new().add((CACHE_CONTROL, "no-store"))) + .app_data(registry) + .route("/submit", embedded("text/html; charset=utf-8", SUBMIT_PAGE)) + .route("/probe.css", embedded("text/css; charset=utf-8", STYLES)) + .route( + "/register.js", + embedded("text/javascript; charset=utf-8", REGISTER_SCRIPT), + ) + .route("/register", web::post().to(register)) + .route("/signatures.yaml", web::get().to(signatures)); + if let Some(root) = root { + scope = scope.service(actix_files::Files::new("/", root).index_file("index.html")); + } + let _configured = config.service(scope); +} + +/// A route answering a GET with `body`, a file built into the server, as `content_type`. +fn embedded(content_type: &'static str, body: &'static str) -> actix_web::Route { + web::get().to(move || async move { + HttpResponse::Ok() + .insert_header((CONTENT_TYPE, content_type)) + .body(body) + }) +} + +/// The registry as it stands, for a probe page to say whether a device is in it already. +async fn signatures(registry: web::Data) -> HttpResponse { + match fs_err::read_to_string(registry.path()) { + Ok(text) => HttpResponse::Ok() + .insert_header((CONTENT_TYPE, "application/yaml; charset=utf-8")) + .body(text), + Err(error) => HttpResponse::InternalServerError().body(error.to_string()), + } +} + +/// Add the submission in `body` to the registry, answering `{"ok": ..., "message": ...}`. +/// +/// A body that is not a submission answers 400, a submission the registry cannot hold 409, and a registry that could +/// not be read or written 500. Each submission is logged with what became of it, the access log showing only the URL. +/// +/// A body not sent as `application/json` answers 415 before it is read. A browser sends that type from another +/// origin's page only after a CORS preflight, which this server never grants, so a page elsewhere cannot write to the +/// registry by posting it a form or `text/plain`, the kinds of request a browser sends with no preflight. +#[expect( + clippy::future_not_send, + reason = "actix runs each handler on its worker's own thread, and the request it takes is not Send" +)] +async fn register(request: HttpRequest, body: web::Bytes, registry: web::Data) -> HttpResponse { + if request.content_type() != "application/json" { + let message = "a submission must be sent as application/json"; + return reply(HttpResponse::UnsupportedMediaType(), false, message); + } + let submission: Submission = match serde_json::from_slice(&body) { + Ok(submission) => submission, + Err(error) => return reply(HttpResponse::BadRequest(), false, &format!("bad submission: {error}")), + }; + let peer = request + .peer_addr() + .map_or_else(|| "unknown peer".to_owned(), |address| address.ip().to_string()); + let outcome = registry.register(&submission); + let message = outcome.as_ref().map_or_else(ToString::to_string, ToString::to_string); + info!( + "{peer} submitted {} {:?}: {message}", + submission.interface, submission.label + ); + match outcome { + Ok(_) => reply(HttpResponse::Ok(), true, &message), + Err(error) if error.is_rejection() => reply(HttpResponse::Conflict(), false, &message), + Err(_) => reply(HttpResponse::InternalServerError(), false, &message), + } +} + +/// The JSON answer to a submission. +fn reply(mut status: actix_web::HttpResponseBuilder, accepted: bool, message: &str) -> HttpResponse { + status.json(json!({ "ok": accepted, "message": message })) +} diff --git a/utilities/gpu-probe/src/bin/et-gpu-probe-server.rs b/utilities/gpu-probe/src/bin/et-gpu-probe-server.rs new file mode 100644 index 00000000..ec4e54ea --- /dev/null +++ b/utilities/gpu-probe/src/bin/et-gpu-probe-server.rs @@ -0,0 +1,44 @@ +//! Run the GPU probe collection server: parse the arguments, settle the certificate, and serve until stopped. + +use std::net::IpAddr; + +use actix_web::{App, HttpServer, web}; +use clap::Parser as _; +use et_gpu_probe_server::registry::Registry; +use et_gpu_probe_server::{Args, advertised_names, app}; +use et_https_listener::{net, tls}; +use tracing::info; + +#[actix_web::main] +async fn main() -> Result<(), Box> { + let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_unset| "info".into()); + tracing_subscriber::fmt().with_env_filter(filter).init(); + let args = Args::parse(); + args.check()?; + let registry = web::Data::new(Registry::new(args.registry.clone())); + let root = args.root.clone(); + let server = HttpServer::new(move || { + App::new().configure(|services| app::configure(services, registry.clone(), root.clone())) + }); + info!("adding submissions to {}", args.registry.display()); + if args.http { + info!("open http://localhost:{}/", args.port); + server.bind((args.bind, args.port))?.run().await?; + return Ok(()); + } + let lan: Vec = net::candidate_ipv4s(None)? + .into_iter() + .map(|(_, address)| address) + .collect(); + let names = advertised_names(&args.public_host, &lan); + let (cert, key) = tls::load_or_generate_tls_certs(&args.cert, &args.key, &names); + let server = server.bind_rustls_0_23((args.bind, args.port), tls::build_tls_server_config(cert, key))?; + info!("open https://localhost:{}/", args.port); + if args.bind.is_unspecified() { + for name in &names { + info!("from another device: https://{name}:{}/", args.port); + } + } + server.run().await?; + Ok(()) +} diff --git a/utilities/gpu-probe/src/bin/gpu-probe.rs b/utilities/gpu-probe/src/bin/gpu-probe.rs new file mode 100644 index 00000000..3b75b749 --- /dev/null +++ b/utilities/gpu-probe/src/bin/gpu-probe.rs @@ -0,0 +1,19 @@ +//! Put the probe sets to every GPU adapter of this machine and to its CPU, and add to the registry each sighting it +//! does not yet hold. +//! +//! It takes no arguments. The registry is the repository's own `libs/gpu-probe/signatures.yaml`, found from the working +//! directory. The adapters are every one wgpu finds through Vulkan, Metal and Direct3D 12, software renderers too. + +use et_gpu_probe::native::Gpu; +use et_gpu_probe_server::measure::measure; +use et_gpu_probe_server::registry::Registry; +use futures_executor::block_on; + +/// The registry file, from the repository's root. +const REGISTRY: &str = "libs/gpu-probe/signatures.yaml"; + +fn main() -> Result<(), Box> { + let registry = Registry::new(edge_toolkit::config::get_project_root().join(REGISTRY)); + measure(®istry, block_on(Gpu::open_all()), &mut std::io::stdout().lock())?; + Ok(()) +} diff --git a/utilities/gpu-probe/src/lib.rs b/utilities/gpu-probe/src/lib.rs new file mode 100644 index 00000000..294374cf --- /dev/null +++ b/utilities/gpu-probe/src/lib.rs @@ -0,0 +1,84 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] + +use std::net::IpAddr; +use std::path::PathBuf; + +pub mod app; +pub mod measure; +pub mod registry; + +/// Collect GPU probe signatures into a registry over HTTPS. +#[derive(Debug, clap::Parser)] +#[command(name = "et-gpu-probe-server", version)] +#[non_exhaustive] +pub struct Args { + /// The registry file submissions are added to, such as `libs/gpu-probe/signatures.yaml`. + #[arg(long)] + pub registry: PathBuf, + /// A directory of static files, such as a probe page, to serve at `/`. + /// + /// Absent, the server serves only the form, the endpoint and the registry: there is no page to serve. + #[arg(long)] + pub root: Option, + /// The address to listen on: `0.0.0.0` for other devices on the LAN to reach it. + #[arg(long, default_value = "127.0.0.1")] + pub bind: IpAddr, + /// The port to listen on. + #[arg(long, default_value_t = 8443)] + pub port: u16, + /// The PEM certificate to serve, generated self-signed with the key when either is missing. + #[arg(long)] + pub cert: PathBuf, + /// The PEM private key of the certificate. + #[arg(long)] + pub key: PathBuf, + /// Another name to advertise the server at, which a generated certificate covers too. + #[arg(long)] + pub public_host: Vec, + /// Serve plain HTTP instead of HTTPS, which only a loopback `--bind` may. + /// + /// A browser treats a loopback origin as secure, so WebGPU still runs there. + /// + /// `--cert` and `--key` are not read. + #[arg(long)] + pub http: bool, +} + +/// Why the server would not start. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum StartError { + /// Plain HTTP was asked for on an address other devices could reach, where a browser would not run WebGPU. + #[error("--http serves only a loopback --bind, not {0}: a browser treats only a loopback origin as secure")] + NotLoopback(IpAddr), +} + +impl Args { + /// Check the arguments can be served as given. + pub const fn check(&self) -> Result<(), StartError> { + if self.http && !self.bind.is_loopback() { + return Err(StartError::NotLoopback(self.bind)); + } + Ok(()) + } +} + +/// The `nip.io` name that resolves to `address`, such as `10-1-2-3.nip.io` for `10.1.2.3`. +/// +/// It is a real domain for a LAN address, for trying a browser defence that treats a bare IP differently. +#[must_use] +pub fn nip_io(address: IpAddr) -> String { + format!("{}.nip.io", address.to_string().replace(['.', ':'], "-")) +} + +/// Every name a generated certificate covers beyond the loopback ones, and the server is advertised at. +/// +/// That is each of `public_hosts`, then each of the `lan` addresses and its [`nip_io`] name. +#[must_use] +pub fn advertised_names(public_hosts: &[String], lan: &[IpAddr]) -> Vec { + public_hosts + .iter() + .cloned() + .chain(lan.iter().flat_map(|address| [address.to_string(), nip_io(*address)])) + .collect() +} diff --git a/utilities/gpu-probe/src/measure.rs b/utilities/gpu-probe/src/measure.rs new file mode 100644 index 00000000..e7ec8bb1 --- /dev/null +++ b/utilities/gpu-probe/src/measure.rs @@ -0,0 +1,182 @@ +//! Put the probe sets to GPU adapters and to this machine's CPU, and add to a registry each sighting it does not yet +//! hold. +//! +//! Each signature is filed under the interface the registry gives a probe set run with no browser, and seen at a row +//! built from what the device says it is: its vendor, name and backend from wgpu, and its driver and API versions from +//! the API beneath. A row the registry already records, with every field the same, is left alone, so measuring twice +//! adds nothing the second time. +#![expect( + clippy::single_call_fn, + reason = "each step of a measurement is named, and called once" +)] + +use std::collections::BTreeMap; +use std::io::Write; + +use et_gpu_probe::native::{Gpu, NativeError}; +use et_gpu_probe::{Answer, GpuVendor, HOST, Signature, host}; +use futures_executor::block_on; + +use crate::registry::{Outcome, RegisterError, Registry, Submission}; + +/// The operating system and CPU architecture this runs on, as a registry row names them. +const HERE: (&str, &str) = (std::env::consts::OS, std::env::consts::ARCH); + +/// A signature measured through one interface, beside the label it is filed under if new and the row it was seen at. +type Measured = (&'static str, Signature, String, BTreeMap); + +/// What stopped a measurement being recorded in full. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum MeasureError { + /// The registry refused a signature. + #[error(transparent)] + Register(#[from] RegisterError), + /// What became of a signature could not be written out. + #[error("the output could not be written: {0}")] + Write(#[from] std::io::Error), + /// Some adapters could not be measured, by how many; every other one was recorded all the same. + #[error("{0} adapter(s) could not be measured")] + Skipped(usize), +} + +/// Measure every adapter in `opened` and this machine's CPU, and add what they answer to `registry`. +/// +/// What became of each signature is written to `out`. An adapter that did not open, or would not answer, is said so +/// and passed over: the others are measured all the same, and the count of those passed over is the error returned +/// once every other one is recorded. +pub fn measure( + registry: &Registry, + opened: Vec>, + out: &mut Out, +) -> Result<(), MeasureError> +where + Out: Write, +{ + let mut measured = Vec::new(); + let mut skipped = 0_usize; + for gpu in opened { + match gpu.and_then(|gpu| gpu_signatures(&gpu)) { + Ok(signatures) => measured.extend(signatures), + Err(error) => { + writeln!(out, "an adapter was not measured: {error}")?; + skipped = skipped.saturating_add(1); + } + } + } + measured.push(cpu_signature()); + let mut added = false; + for (interface, signature, label, seen) in measured { + let outcome = record(registry, interface, &signature, &label, seen)?; + writeln!(out, "{interface} on {}: {outcome}", signature_label(&label))?; + added |= !matches!(outcome, Outcome::Present(_)); + } + if added { + writeln!( + out, + "run `mise run gen:gpu-probe-signatures` to refresh the JSON the et-gpu-probe crate embeds" + )?; + } + if skipped > 0 { + return Err(MeasureError::Skipped(skipped)); + } + Ok(()) +} + +/// Every probe set `gpu` answers, each beside the label and row a sighting of it is recorded with. +fn gpu_signatures(gpu: &Gpu) -> Result<[Measured; 4], NativeError> { + let info = gpu.info(); + let (system, arch) = HERE; + let mut seen = row(&[ + ("browser", "none"), + ("os", system), + ("cpu_arch", arch), + ("gpu", vendor_of(info.vendor, &info.name).name()), + ("gpu_version", &info.name), + ("backend", gpu.backend()), + ("window", "none"), + ]); + for (field, value) in gpu.report().versions { + let _previous: Option = seen.insert(field.to_owned(), value); + } + let label = format!( + "{} through {} on {system} {arch} -- wgpu, no browser", + info.name, + gpu.backend() + ); + Ok([ + ("webgpu-split", block_on(gpu.signature())?, label.clone(), seen.clone()), + ( + "webgpu-combined", + block_on(gpu.combined_signature())?, + label.clone(), + seen.clone(), + ), + ( + "webgpu-clear", + block_on(gpu.clear_signature())?, + label.clone(), + seen.clone(), + ), + ( + "webgpu-fixed-function", + block_on(gpu.fixed_function_signature())?, + label, + seen, + ), + ]) +} + +/// Whose GPU an adapter is: by the vendor `id` it reports, or by its `name` where the backend, like Metal, gives none. +fn vendor_of(id: u32, name: &str) -> GpuVendor { + let by_id = GpuVendor::from_id(id); + if by_id == GpuVendor::Unknown { + GpuVendor::from_device_name(name) + } else { + by_id + } +} + +/// What this machine's CPU answers, beside the label and row a sighting of it is recorded with. +fn cpu_signature() -> Measured { + let (system, arch) = HERE; + let seen = row(&[ + ("browser", "none"), + ("os", system), + ("cpu_arch", arch), + ("gpu", "none"), + ("backend", "none"), + ("window", "none"), + ]); + let signature = Signature::new(HOST, host().into_iter().map(Answer::Bits).collect()); + (HOST, signature, format!("Rust f32 on the CPU: {system} {arch}"), seen) +} + +/// Add `signature`, measured through `interface` and seen at `seen`, to `registry`. +fn record( + registry: &Registry, + interface: &str, + signature: &Signature, + label: &str, + seen: BTreeMap, +) -> Result { + let results = signature + .results() + .into_iter() + .map(|(name, word)| (name.to_owned(), word)) + .collect(); + registry.register(&Submission::signature(interface, label, results, seen)) +} + +/// The GPU or CPU part of `label`, which is what tells one device's lines of output from another's. +fn signature_label(label: &str) -> &str { + label.split(" through ").next().unwrap_or(label) +} + +/// A registry row from its `fields`. +fn row(fields: &[(&str, &str)]) -> BTreeMap { + fields + .iter() + .map(|(field, value)| ((*field).to_owned(), (*value).to_owned())) + .collect() +} diff --git a/utilities/gpu-probe/src/probe.css b/utilities/gpu-probe/src/probe.css new file mode 100644 index 00000000..64c3ead2 --- /dev/null +++ b/utilities/gpu-probe/src/probe.css @@ -0,0 +1,24 @@ +body { + font: 15px/1.5 system-ui, sans-serif; + max-width: 64rem; + margin: 2rem auto; + padding: 0 1rem; +} + +button { + font: inherit; + padding: 0.4rem 0.9rem; + margin: 0.4rem 0.4rem 0.4rem 0; +} + +.ok { + color: #2a7; +} + +.dup { + color: #888; +} + +.bad { + color: #b23; +} diff --git a/utilities/gpu-probe/src/register.mjs b/utilities/gpu-probe/src/register.mjs new file mode 100644 index 00000000..068d60eb --- /dev/null +++ b/utilities/gpu-probe/src/register.mjs @@ -0,0 +1,14 @@ +// Post one submission, given as JSON text, to the server's /register, and say what became of it. +// The answer holds the server's message, and how to show it: "ok", "dup" for one already present, or "bad". +// skipcq: JS-0833 -- committed ES module; the analyzer's script-mode parse is a false positive +export const register = async (body) => { + const options = { method: "POST", headers: { "Content-Type": "application/json" }, body }; + try { + const response = await fetch("/register", options); + const reply = await response.json(); + const present = reply.message.startsWith("already"); + return { kind: response.ok ? (present ? "dup" : "ok") : "bad", message: reply.message }; + } catch (error) { + return { kind: "bad", message: `request failed: ${String(error)}` }; + } +}; diff --git a/utilities/gpu-probe/src/registry.rs b/utilities/gpu-probe/src/registry.rs new file mode 100644 index 00000000..8aabe456 --- /dev/null +++ b/utilities/gpu-probe/src/registry.rs @@ -0,0 +1,516 @@ +//! The registry a submission is added to: `libs/gpu-probe/signatures.yaml`, or a file of the same shape. +//! +//! A submission is one signature, or one interface a browser withheld, and the one place it was seen. When the registry +//! already holds that signature through that interface, the place is added to the entry's `seen` rows, unless an +//! identical row is there already; otherwise the submission becomes a new entry at the end of the file. The file is +//! edited as text, a row inserted or an entry appended in the layout the file already has, so its comments, its header +//! and every other entry stay byte for byte as they were. +#![expect( + clippy::single_call_fn, + reason = "each step of Registry::register is a named function for readability; most are called from one place" +)] + +use std::collections::BTreeMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use et_gpu_probe::{RowError, Sighting}; +use serde::Deserialize; +use serde_json::Value; + +/// The most results one signature may hold. +const MAX_RESULTS: usize = 256; + +/// The longest a name may be: an interface's, or a result's. +const MAX_NAME: usize = 64; + +/// The longest a result's value may be. +const MAX_VALUE: usize = 128; + +/// The longest free text may be: a label, a reason, or a row's free-text field. +const MAX_TEXT: usize = 200; + +/// The line each entry of the registry starts with, before the interface it was measured through. +const ENTRY_START: &str = " - interface:"; + +/// Every field a row may carry, in the order the registry writes them. +const ROW_ORDER: [&str; 18] = [ + "browser", + "browser_version", + "os", + "os_version", + "cpu_arch", + "cpu_version", + "gpu", + "gpu_version", + "driver_version", + "api_version", + "backend", + "device", + "runtime", + "defence", + "reported", + "window", + "date", + "source", +]; + +/// The fields of a row whose values come from fixed sets, which the registry writes unquoted. +const ENUMERATED: [&str; 6] = ["browser", "os", "cpu_arch", "gpu", "backend", "window"]; + +/// What is wrong with a submission, or what stopped it being added. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RegisterError { + /// The interface is not a name. + #[error("interface must be a name of letters, digits, '_', '.' and '-', starting with a letter")] + Interface, + /// A free-text field is empty, too long, or holds a control character. + #[error("{0} must be 1 to {MAX_TEXT} characters with no control characters")] + Text(String), + /// A signature's results are missing, too many, or not names beside plain values. + #[error("results must map 1 to {MAX_RESULTS} names to values of letters, digits and '?_.+-'")] + Results, + /// A withheld interface carries results, or a signature a reason. + #[error("a blocked interface takes a reason and no results, and a signature results and no reason")] + Blocked, + /// The row of where the submission was seen is not one the registry can read. + #[error("seen: {0}")] + Row(#[from] RowError), + /// The registry file could not be read or written. + #[error("the registry could not be read or written: {0}")] + Io(#[from] std::io::Error), + /// The registry file is not the YAML it should be. + #[error("the registry is not valid YAML: {0}")] + Parse(#[from] serde_saphyr::Error), + /// The registry's text is not laid out as the YAML says it is, so there is nowhere to insert a row. + #[error("the registry's entry {0} has no `results:` or `blocked:` line to insert a row before")] + Layout(usize), + /// Adding to entry `0` (a new one, if past the last) in the registry's text would not read back as that addition. + #[error("the registry's layout around entry {0} is not one a sighting can be added to; it was left as it was")] + Unwritable(usize), + /// Another request panicked while it held the registry. + #[error("the registry lock was poisoned by a failed request")] + Poisoned, +} + +impl RegisterError { + /// Whether the submission itself is at fault, as against the registry it was to be added to. + #[must_use] + pub const fn is_rejection(&self) -> bool { + matches!( + self, + Self::Interface | Self::Text(_) | Self::Results | Self::Blocked | Self::Row(_) + ) + } +} + +impl From> for RegisterError { + fn from(_poisoned: std::sync::PoisonError) -> Self { + Self::Poisoned + } +} + +/// One signature, or one interface a browser withheld, beside the one place it was seen. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct Submission { + /// The interface the signature was measured through, as the registry files it. + pub interface: String, + /// What the entry is, in a line. + pub label: String, + /// Each result's name beside its value, in the order the mechanism gives them; empty for a withheld interface. + #[serde(default)] + pub results: Results, + /// Whether the interface was withheld, so that there are no results. + #[serde(default)] + pub blocked: bool, + /// Why the interface was withheld; empty for a signature. + #[serde(default)] + pub reason: String, + /// Where it was seen, as a row of the registry. + pub seen: BTreeMap, +} + +impl Submission { + /// A signature measured through `interface`, filed as `label` if it is new, with `results`, seen at `seen`. + #[must_use] + pub fn signature( + interface: &str, + label: &str, + results: Vec<(String, String)>, + seen: BTreeMap, + ) -> Self { + Self { + interface: interface.to_owned(), + label: label.to_owned(), + results: Results(results), + blocked: false, + reason: String::default(), + seen, + } + } +} + +/// A signature's results, each name beside its value, kept in the order they were given. +/// +/// The registry lists an entry's results in the order its mechanism gives them -- the probes' output order -- so a new +/// entry is written in the order the submission held them, where a sorted map would lose it. +#[derive(Debug, Default)] +pub struct Results(Vec<(String, String)>); + +impl Results { + /// The results keyed by name, which is how two signatures are compared: order does not make them different. + fn by_name(&self) -> BTreeMap { + self.0.iter().cloned().collect() + } +} + +impl<'de> Deserialize<'de> for Results { + fn deserialize(deserializer: Input) -> Result + where + Input: serde::Deserializer<'de>, + { + deserializer.deserialize_map(InOrder) + } +} + +/// Reads a map into [`Results`] one pair at a time, which keeps the pairs in order. +struct InOrder; + +impl<'de> serde::de::Visitor<'de> for InOrder { + type Value = Results; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a map of result names to values") + } + + fn visit_map(self, mut map: Pairs) -> Result + where + Pairs: serde::de::MapAccess<'de>, + { + let mut results = Vec::new(); + while let Some(pair) = map.next_entry()? { + results.push(pair); + } + Ok(Results(results)) + } +} + +/// What became of a submission that was accepted. +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Outcome { + /// It became a new entry, and the registry now holds this many. + Added(usize), + /// Its row was added to the entry with this label. + Sighted(String), + /// The entry with this label already held the same row, so nothing changed. + Present(String), +} + +impl fmt::Display for Outcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Added(entries) => write!(f, "added; the registry now holds {entries} entries"), + Self::Sighted(label) => write!(f, "added a sighting to: {label}"), + Self::Present(label) => write!(f, "already present: {label}"), + } + } +} + +/// The registry, as far as matching a submission needs to read it. +#[derive(Deserialize)] +struct File { + /// Every entry, in file order. + #[serde(default)] + signatures: Vec, +} + +/// One entry of the registry, as far as matching a submission needs to read it. +#[derive(Deserialize, PartialEq)] +struct Entry { + /// The interface it was measured through. + interface: String, + /// What it is, in a line. + label: String, + /// Each result's name beside its value. + #[serde(default)] + results: BTreeMap, + /// Whether it records a withheld interface. + #[serde(default)] + blocked: bool, + /// Every place it was seen. + #[serde(default)] + seen: Vec>, +} + +impl Entry { + /// Whether `submission` is a sighting of this entry: the same signature, or the same withheld interface. + fn holds(&self, submission: &Submission) -> bool { + self.interface == submission.interface + && self.blocked == submission.blocked + && if submission.blocked { + self.label == submission.label + } else { + self.results == submission.results.by_name() + } + } +} + +/// A registry file, which one request at a time adds to. +#[derive(Debug)] +pub struct Registry { + /// The file. + path: PathBuf, + /// Held for the whole of each read, change and write, so that two submissions never interleave. + lock: Mutex<()>, +} + +impl Registry { + /// The registry held in the file at `path`. + #[must_use] + pub const fn new(path: PathBuf) -> Self { + Self { + path, + lock: Mutex::new(()), + } + } + + /// The file the registry is held in. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Add `submission` to the registry, once it is valid. + /// + /// The whole read, change and write is held against every other writer: another request through this `Registry`, + /// and another process -- a `gpu-probe` run beside a running server -- through a lock file beside the registry, + /// which is left in place for the next writer to lock. An update that would not read back as the registry with + /// just this one sighting added is refused, and the registry left as it was. + pub fn register(&self, submission: &Submission) -> Result { + validate(submission)?; + let _held = self.lock.lock()?; + let lock_file = fs_err::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(self.path.with_extension("yaml.lock"))?; + lock_file.file().lock()?; + let text = fs_err::read_to_string(&self.path)?; + let file: File = serde_saphyr::from_str(&text)?; + let row = row_text(&submission.seen); + let held = file.signatures.iter().position(|entry| entry.holds(submission)); + let (updated, outcome, index) = if let Some(index) = held { + let entry = file.signatures.get(index).ok_or(RegisterError::Layout(index))?; + if entry.seen.iter().any(|row| covers(row, &submission.seen)) { + return Ok(Outcome::Present(entry.label.clone())); + } + let sighted = Outcome::Sighted(entry.label.clone()); + (insert_row(&text, index, &row)?, sighted, index) + } else { + let added = Outcome::Added(file.signatures.len().saturating_add(1)); + (append_entry(&text, submission, &row), added, file.signatures.len()) + }; + reads_back(&updated, &file, index)?; + // The update is written beside the registry and renamed over it, so the registry is never seen half written. + // A staged file the rename did not take is removed; that removal is best effort, as a write that failed may + // have left nothing to remove. + let staged = self.path.with_extension("yaml.partial"); + let replaced = fs_err::write(&staged, updated).and_then(|()| fs_err::rename(&staged, &self.path)); + if replaced.is_err() { + let _removed: Result<(), std::io::Error> = fs_err::remove_file(&staged); + } + replaced?; + Ok(outcome) + } +} + +/// Check `updated` reads back as `before` with only the one row added. +/// +/// That row is in entry `index`, a new last entry if past the end, and every other entry reads back as it was. +/// +/// The update is made by editing text, which assumes the layout this server writes. A registry edited by hand into +/// another one -- an empty `signatures: []` or `seen: []` list, say -- would otherwise be written over with YAML that +/// no longer parses, or parses as something else. +fn reads_back(updated: &str, before: &File, index: usize) -> Result<(), RegisterError> { + let rows = |file: &File| file.signatures.get(index).map_or(0, |entry| entry.seen.len()); + let entries = before + .signatures + .len() + .saturating_add(usize::from(index == before.signatures.len())); + let Ok(after) = serde_saphyr::from_str::(updated) else { + return Err(RegisterError::Unwritable(index)); + }; + // Every other entry must read back as it did: one whose `seen` list is a YAML alias of the one written to would + // otherwise gain the row too, a sighting of a signature nobody submitted. + let others_unchanged = after + .signatures + .iter() + .zip(&before.signatures) + .enumerate() + .all(|(position, (now, was))| position == index || now == was); + if after.signatures.len() == entries && rows(&after) == rows(before).saturating_add(1) && others_unchanged { + return Ok(()); + } + Err(RegisterError::Unwritable(index)) +} + +/// Whether `row`, already in the registry, records `seen`: it gives every field `seen` gives, with the same value. +/// +/// A row written by hand often carries more than a submission can know -- the machine's name, the OS release -- and a +/// submission that tells it nothing new is a sighting already on record, not one to add beside it. +fn covers(row: &BTreeMap, seen: &BTreeMap) -> bool { + seen.iter().all(|(field, value)| row.get(field) == Some(value)) +} + +/// Check every field of `submission` is one the registry can hold, as the layout the text is written in needs. +fn validate(submission: &Submission) -> Result<(), RegisterError> { + if !is_name(&submission.interface) { + return Err(RegisterError::Interface); + } + check_text("label", &submission.label)?; + let results = &submission.results.0; + if submission.blocked { + if !results.is_empty() { + return Err(RegisterError::Blocked); + } + check_text("reason", &submission.reason)?; + } else { + if !submission.reason.is_empty() { + return Err(RegisterError::Blocked); + } + let plain = results.iter().all(|(name, value)| is_name(name) && is_value(value)); + let distinct = submission.results.by_name().len() == results.len(); + if results.is_empty() || results.len() > MAX_RESULTS || !plain || !distinct { + return Err(RegisterError::Results); + } + } + let row: serde_json::Map = submission + .seen + .iter() + .map(|(field, value)| (field.clone(), Value::String(value.clone()))) + .collect(); + let _sighting: Sighting = Sighting::read(&Value::Object(row))?; + for (field, value) in &submission.seen { + check_text(field, value)?; + } + Ok(()) +} + +/// Whether `text` is a name: a letter, then up to [`MAX_NAME`] letters, digits, `_`, `.` and `-` in all. +fn is_name(text: &str) -> bool { + text.len() <= MAX_NAME + && text.chars().next().is_some_and(|first| first.is_ascii_alphabetic()) + && text + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)) +} + +/// Whether `text` is a plain value: 1 to [`MAX_VALUE`] letters, digits, `?`, `_`, `.`, `+` and `-`. +fn is_value(text: &str) -> bool { + (1..=MAX_VALUE).contains(&text.len()) + && text + .chars() + .all(|character| character.is_ascii_alphanumeric() || "?_.+-".contains(character)) +} + +/// Check `text`, the value of `field`, is free text the registry can quote. +fn check_text(field: &str, text: &str) -> Result<(), RegisterError> { + let length = text.chars().count(); + if length == 0 || length > MAX_TEXT || text.chars().any(char::is_control) { + return Err(RegisterError::Text(field.to_owned())); + } + Ok(()) +} + +/// `text` as a YAML double-quoted scalar, in ASCII alone. +/// +/// The registry is a tracked file, which holds ASCII only, so a character past ASCII -- an accent in a label, say -- is +/// written as YAML's `\u` or `\U` escape for it, which reads back as the same character. +fn quoted(text: &str) -> String { + let body: String = text + .chars() + .map(|character| match character { + '\\' => "\\\\".to_owned(), + '"' => "\\\"".to_owned(), + ascii if ascii.is_ascii() => ascii.to_string(), + wide if u32::from(wide) > 0xffff => format!("\\U{:08x}", u32::from(wide)), + other => format!("\\u{:04x}", u32::from(other)), + }) + .collect(); + format!("\"{body}\"") +} + +/// The lines of `row` as an item of an entry's `seen` list, its fields in the registry's order. +fn row_text(row: &BTreeMap) -> String { + let mut lines = String::default(); + for field in ROW_ORDER { + if let Some(value) = row.get(field) { + let lead = if lines.is_empty() { " - " } else { " " }; + let value = if ENUMERATED.contains(&field) { + value.clone() + } else { + quoted(value) + }; + lines.push_str(&[lead, field, ": ", &value, "\n"].concat()); + } + } + lines +} + +/// `text` with `row` inserted at the end of the `seen` list of its entry numbered `index`. +/// +/// An entry's `seen` list ends where its `results:` or, for a withheld interface, its `blocked:` line starts. +fn insert_row(text: &str, index: usize, row: &str) -> Result { + let mut offset = 0_usize; + let mut entries = 0_usize; + let mut inside = false; + for line in text.split_inclusive('\n') { + let starts_entry = line.starts_with(ENTRY_START); + if starts_entry { + inside = entries == index; + entries = entries.saturating_add(1); + } + let ends_seen = line.starts_with(" results:") || line.starts_with(" blocked:"); + if inside && !starts_entry && ends_seen { + let (before, after) = text.split_at(offset); + return Ok([before, row, after].concat()); + } + offset = offset.saturating_add(line.len()); + } + Err(RegisterError::Layout(index)) +} + +/// `text` with `submission`, seen at `row`, appended as a new entry. +fn append_entry(text: &str, submission: &Submission, row: &str) -> String { + let mut updated = text.to_owned(); + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + let label = quoted(&submission.label); + updated.push_str( + &[ + ENTRY_START, + " ", + &submission.interface, + "\n label: ", + &label, + "\n seen:\n", + row, + ] + .concat(), + ); + if submission.blocked { + updated.push_str(&[" blocked: true\n reason: ", "ed(&submission.reason), "\n"].concat()); + } else { + updated.push_str(" results:\n"); + for (name, value) in &submission.results.0 { + updated.push_str(&[" ", name, ": ", "ed(value), "\n"].concat()); + } + } + updated +} diff --git a/utilities/gpu-probe/src/submit.html b/utilities/gpu-probe/src/submit.html new file mode 100644 index 00000000..fe45a044 --- /dev/null +++ b/utilities/gpu-probe/src/submit.html @@ -0,0 +1,53 @@ + + + + + + Submit GPU Signatures + + + + +

Submit GPU Signatures

+

+ Paste one or more submissions, one JSON object per line, then register + them. Each names its + interface, a label, its results (or + blocked and a + reason), and the seen row of where it was + measured. +

+ +
+
+ + + diff --git a/utilities/gpu-probe/static/index.html b/utilities/gpu-probe/static/index.html new file mode 100644 index 00000000..928ae343 --- /dev/null +++ b/utilities/gpu-probe/static/index.html @@ -0,0 +1,530 @@ + + + + + + GPU Probe + + + + +

GPU Probe

+

Loading the probes...

+
+
+ + Where this was measured: the part of a registry row every GPU below + shares + + + + + + + + + + +
+
+

+
+ + + diff --git a/utilities/gpu-probe/tests/measure.rs b/utilities/gpu-probe/tests/measure.rs new file mode 100644 index 00000000..80e36d62 --- /dev/null +++ b/utilities/gpu-probe/tests/measure.rs @@ -0,0 +1,124 @@ +//! Covers measuring adapters and the CPU into a registry: what is added, what a second run leaves alone, and how an +//! adapter that cannot be measured, a registry that cannot be read and an output that cannot be written are reported. +#![cfg(test)] + +use std::io::{self, Write}; +use std::path::PathBuf; + +use et_gpu_probe::native::{Gpu, NativeError}; +use et_gpu_probe_server::measure::{MeasureError, measure}; +use et_gpu_probe_server::registry::Registry; +use futures_executor::block_on; +use tempfile::TempDir; + +/// A registry holding one entry, which no measurement here matches, laid out as `libs/gpu-probe/signatures.yaml` is. +const REGISTRY: &str = r#"# The header, kept as it is. +probe_set: 1 +signatures: + - interface: webgpu + label: "Tor Browser" + seen: + - browser: tor-browser + os: macos + cpu_arch: aarch64 + gpu: apple + backend: unknown + window: normal + blocked: true + reason: "WebGPU is unavailable" +"#; + +/// A registry file holding [`REGISTRY`], in a directory removed when the returned guard drops. +fn registry() -> (TempDir, Registry) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("signatures.yaml"); + fs_err::write(&path, REGISTRY).unwrap(); + (dir, Registry::new(path)) +} + +/// What measuring `opened` into `registry` wrote, beside how it ended. +fn measured(registry: &Registry, opened: Vec>) -> (String, Result<(), MeasureError>) { + let mut out = Vec::new(); + let outcome = measure(registry, opened, &mut out); + (String::from_utf8(out).unwrap(), outcome) +} + +/// A wgpu instance with only `backends` in it, the no-op backend switched on. +fn instance(backends: wgpu::Backends) -> wgpu::Instance { + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = backends; + descriptor.backend_options.noop.enable = true; + wgpu::Instance::new(descriptor) +} + +/// An adapter that did not open: an instance with no backend has none to give. +fn unopened() -> Result { + let refused = block_on(instance(wgpu::Backends::empty()).request_adapter(&wgpu::RequestAdapterOptions::default())); + Err(NativeError::Adapter(refused.unwrap_err())) +} + +/// An output every write to fails. +struct Broken; + +impl Write for Broken { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("the output is closed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn the_cpu_is_recorded_once_and_left_alone_after() { + let (_dir, registry) = registry(); + let (first, outcome) = measured(®istry, Vec::new()); + outcome.unwrap(); + assert!(first.contains("host on Rust f32 on the CPU"), "{first}"); + assert!(first.contains("gen:gpu-probe-signatures"), "{first}"); + let (second, outcome) = measured(®istry, Vec::new()); + outcome.unwrap(); + assert!(!second.contains("gen:gpu-probe-signatures"), "{second}"); +} + +#[test] +fn an_adapter_that_did_not_open_is_passed_over_and_counted() { + let (_dir, registry) = registry(); + let (out, outcome) = measured(®istry, vec![unopened()]); + assert!(out.contains("an adapter was not measured"), "{out}"); + assert!(out.contains("host on Rust f32 on the CPU"), "{out}"); + assert_eq!(outcome.unwrap_err().to_string(), "1 adapter(s) could not be measured"); +} + +#[test] +fn the_noop_adapter_is_measured_or_passed_over_and_the_cpu_still_recorded() { + let (_dir, registry) = registry(); + let adapter = block_on(instance(wgpu::Backends::NOOP).request_adapter(&wgpu::RequestAdapterOptions::default())); + let gpu = block_on(Gpu::open_adapter(adapter.unwrap())); + let (out, outcome) = measured(®istry, vec![gpu]); + assert!(out.contains("host on Rust f32 on the CPU"), "{out}"); + match outcome { + Ok(()) => assert!(out.contains("webgpu-split on "), "{out}"), + Err(skipped) => assert!(matches!(skipped, MeasureError::Skipped(1)), "{skipped}"), + } +} + +#[test] +fn a_registry_that_cannot_be_read_is_reported() { + let registry = Registry::new(PathBuf::from("no-such-directory/signatures.yaml")); + let (_out, outcome) = measured(®istry, Vec::new()); + assert!(matches!(outcome, Err(MeasureError::Register(_))), "{outcome:?}"); +} + +#[test] +fn an_output_that_cannot_be_written_is_reported() { + let (_dir, registry) = registry(); + let outcome = measure(®istry, vec![unopened()], &mut Broken); + let error = outcome.unwrap_err(); + assert!(matches!(error, MeasureError::Write(_)), "{error:?}"); + assert_eq!( + error.to_string(), + "the output could not be written: the output is closed" + ); +} diff --git a/utilities/gpu-probe/tests/registry.rs b/utilities/gpu-probe/tests/registry.rs new file mode 100644 index 00000000..4a99af51 --- /dev/null +++ b/utilities/gpu-probe/tests/registry.rs @@ -0,0 +1,417 @@ +//! Covers adding submissions to a registry file: new entries, new sightings of known ones, repeats, and every way a +//! submission or the file it is added to is refused. +#![cfg(test)] + +use std::path::PathBuf; + +use et_gpu_probe_server::registry::{Outcome, RegisterError, Registry, Submission}; +use serde_json::{Value, json}; +use tempfile::TempDir; + +/// A registry with one signature and one withheld interface, laid out as `libs/gpu-probe/signatures.yaml` is. +const REGISTRY: &str = r#"# The header, kept as it is. +probe_set: 1 +signatures: + - interface: webgpu-split + label: "Known GPU" + seen: + - browser: chrome + os: macos + cpu_arch: aarch64 + gpu: apple + backend: metal + window: normal + results: + multiply_add: "33800000" + fma: "33800000" + - interface: webgpu + label: "Tor Browser" + seen: + - browser: tor-browser + os: macos + cpu_arch: aarch64 + gpu: apple + backend: unknown + window: normal + blocked: true + reason: "WebGPU is unavailable" +"#; + +/// A registry file holding `text`, in a directory removed when the returned guard drops. +fn registry(text: &str) -> (TempDir, Registry) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signatures.yaml"); + fs_err::write(&path, text).unwrap(); + (dir, Registry::new(path)) +} + +/// A row of where a submission was seen. +fn row(browser: &str) -> Value { + json!({ + "browser": browser, + "browser_version": "157", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "backend": "vulkan", + "window": "normal", + }) +} + +/// The submission `value` reads as. +fn submission(value: Value) -> Submission { + serde_json::from_value(value).unwrap() +} + +/// A signature submission through `interface`, with the results the JSON object `results` holds, seen in `browser`. +/// +/// It is read from JSON text, as the server reads a request, since a `json!` value sorts its keys and so would lose the +/// order the results were given in, and any name given twice. +fn signature(interface: &str, results: &str, browser: &str) -> Submission { + let seen = row(browser); + let text = format!(r#"{{"interface": "{interface}", "label": "New GPU", "results": {results}, "seen": {seen}}}"#); + serde_json::from_str(&text).unwrap() +} + +/// The registry file's text. +fn text(registry: &Registry) -> String { + fs_err::read_to_string(registry.path()).unwrap() +} + +/// What adding `submission` to [`REGISTRY`] fails with, as the error words it. +fn refusal(submission: &Submission) -> RegisterError { + let (_dir, registry) = registry(REGISTRY); + registry.register(submission).unwrap_err() +} + +#[test] +fn a_new_signature_is_appended_with_its_results_in_the_order_given() { + let (_dir, registry) = registry(REGISTRY); + let outcome = registry.register(&signature("webgpu-clear", r#"{"zeta": "1", "alpha": "2"}"#, "firefox")); + assert_eq!(outcome.unwrap(), Outcome::Added(3)); + let expected = concat!( + " - interface: webgpu-clear\n", + " label: \"New GPU\"\n", + " seen:\n", + " - browser: firefox\n", + " browser_version: \"157\"\n", + " os: linux\n", + " cpu_arch: x86_64\n", + " gpu: intel\n", + " backend: vulkan\n", + " window: normal\n", + " results:\n", + " zeta: \"1\"\n", + " alpha: \"2\"\n", + ); + assert_eq!(text(®istry), [REGISTRY, expected].concat()); +} + +#[test] +fn a_known_signature_seen_somewhere_new_gains_a_row_before_its_results() { + let (_dir, registry) = registry(REGISTRY); + let results = r#"{"fma": "33800000", "multiply_add": "33800000"}"#; + let outcome = registry.register(&signature("webgpu-split", results, "firefox")); + assert_eq!(outcome.unwrap(), Outcome::Sighted("Known GPU".to_owned())); + let updated = text(®istry); + let row = updated.find(" - browser: firefox\n").unwrap(); + let results = updated.find(" results:\n").unwrap(); + let blocked = updated.find(" - interface: webgpu\n").unwrap(); + assert!(row < results && results < blocked); + assert!(updated.starts_with("# The header, kept as it is.\n")); +} + +#[test] +fn a_row_the_registry_holds_with_more_fields_already_records_a_sighting() { + let (_dir, registry) = registry(REGISTRY); + let seen = [ + ("browser", "chrome"), + ("os", "macos"), + ("cpu_arch", "aarch64"), + ("gpu", "apple"), + ("backend", "metal"), + ("window", "normal"), + ]; + let results = vec![ + ("multiply_add".to_owned(), "33800000".to_owned()), + ("fma".to_owned(), "33800000".to_owned()), + ]; + let row = |extra: &[(&str, &str)]| { + seen.iter() + .chain(extra) + .map(|(field, value)| ((*field).to_owned(), (*value).to_owned())) + .collect() + }; + + // Fields the registry's row does not give make a sighting of its own. + let richer = row(&[("device", "a laptop"), ("browser_version", "157")]); + let outcome = registry.register(&Submission::signature( + "webgpu-split", + "ignored", + results.clone(), + richer, + )); + assert_eq!(outcome.unwrap(), Outcome::Sighted("Known GPU".to_owned())); + let after = text(®istry); + assert!(after.contains(" device: \"a laptop\"\n")); + + // A submission giving only some of that row's fields, all the same, is already on record. + let partial = Submission::signature("webgpu-split", "ignored", results, row(&[("device", "a laptop")])); + assert_eq!( + registry.register(&partial).unwrap(), + Outcome::Present("Known GPU".to_owned()) + ); + assert_eq!(text(®istry), after); +} + +#[test] +fn a_repeat_of_a_known_row_changes_nothing() { + let (_dir, registry) = registry(REGISTRY); + let first = signature( + "webgpu-split", + r#"{"multiply_add": "33800000", "fma": "33800000"}"#, + "firefox", + ); + assert_eq!( + registry.register(&first).unwrap(), + Outcome::Sighted("Known GPU".to_owned()) + ); + let after_first = text(®istry); + assert_eq!( + registry.register(&first).unwrap(), + Outcome::Present("Known GPU".to_owned()) + ); + assert_eq!(text(®istry), after_first); +} + +#[test] +fn a_known_withheld_interface_seen_somewhere_new_gains_a_row_before_blocked() { + let (_dir, registry) = registry(REGISTRY); + let blocked = submission(json!({ + "interface": "webgpu", "label": "Tor Browser", "blocked": true, "reason": "absent", "seen": row("firefox") + })); + assert_eq!( + registry.register(&blocked).unwrap(), + Outcome::Sighted("Tor Browser".to_owned()) + ); + let updated = text(®istry); + assert!(updated.find(" - browser: firefox\n").unwrap() < updated.find(" blocked: true\n").unwrap()); +} + +#[test] +fn a_new_withheld_interface_is_appended_with_its_reason() { + let (_dir, registry) = registry(REGISTRY.trim_end()); + let blocked = submission(json!({ + "interface": "webgl2", "label": "Say \"no\"", "blocked": true, "reason": "a \\ b", "seen": row("firefox") + })); + assert_eq!(registry.register(&blocked).unwrap(), Outcome::Added(3)); + let updated = text(®istry); + assert!(updated.contains(" reason: \"WebGPU is unavailable\"\n - interface: webgl2\n")); + assert!(updated.contains(" label: \"Say \\\"no\\\"\"\n")); + assert!(updated.ends_with(" blocked: true\n reason: \"a \\\\ b\"\n")); +} + +#[test] +fn each_outcome_says_what_became_of_the_submission() { + assert_eq!(Outcome::Added(3).to_string(), "added; the registry now holds 3 entries"); + assert_eq!(Outcome::Sighted("G".to_owned()).to_string(), "added a sighting to: G"); + assert_eq!(Outcome::Present("G".to_owned()).to_string(), "already present: G"); +} + +#[test] +fn a_submission_with_a_bad_interface_or_label_is_refused() { + let bad_interface = signature("9webgpu", r#"{"a": "1"}"#, "firefox"); + assert!(matches!(refusal(&bad_interface), RegisterError::Interface)); + let long = signature(&"w".repeat(65), r#"{"a": "1"}"#, "firefox"); + assert!(matches!(refusal(&long), RegisterError::Interface)); + let unlabelled = submission(json!({"interface": "webgpu", "label": "", "results": {"a": "1"}, "seen": row("x")})); + assert!(matches!(refusal(&unlabelled), RegisterError::Text(field) if field == "label")); +} + +#[test] +fn a_signature_with_missing_or_unplain_results_is_refused() { + for results in [ + "{}", + r#"{"a": "has space"}"#, + r#"{"a": ""}"#, + r#"{"_a": "1"}"#, + r#"{"a": "1", "a": "2"}"#, + ] { + let refused = refusal(&signature("webgpu", results, "firefox")); + assert!(matches!(refused, RegisterError::Results)); + assert!(refused.is_rejection()); + } + let many: Vec = (0_u32..257).map(|index| format!(r#""r{index}": "1""#)).collect(); + let refused = refusal(&signature("webgpu", &format!("{{{}}}", many.join(", ")), "firefox")); + assert!(matches!(refused, RegisterError::Results)); +} + +#[test] +fn a_withheld_interface_takes_a_reason_and_a_signature_none() { + let with_results = submission(json!({ + "interface": "webgpu", "label": "L", "blocked": true, "reason": "r", "results": {"a": "1"}, "seen": row("x") + })); + assert!(matches!(refusal(&with_results), RegisterError::Blocked)); + let with_reason = submission(json!({ + "interface": "webgpu", "label": "L", "reason": "r", "results": {"a": "1"}, "seen": row("x") + })); + assert!(matches!(refusal(&with_reason), RegisterError::Blocked)); + let without_reason = submission(json!({ + "interface": "webgpu", "label": "L", "blocked": true, "seen": row("firefox") + })); + assert!(matches!(refusal(&without_reason), RegisterError::Text(field) if field == "reason")); +} + +#[test] +fn a_row_the_registry_cannot_read_or_quote_is_refused() { + let unknown = signature("webgpu", r#"{"a": "1"}"#, "netscape"); + assert!(matches!(refusal(&unknown), RegisterError::Row(_))); + let mut multiline = row("firefox"); + multiline["device"] = json!("one\ntwo"); + let refused = refusal(&submission(json!({ + "interface": "webgpu", "label": "L", "results": {"a": "1"}, "seen": multiline + }))); + assert!(matches!(&refused, RegisterError::Text(field) if field == "device")); + assert_eq!( + refused.to_string(), + "device must be 1 to 200 characters with no control characters" + ); +} + +#[test] +fn an_added_submission_leaves_no_staged_file_behind() { + let (dir, registry) = registry(REGISTRY); + let new = signature("webgpu-clear", r#"{"a": "1"}"#, "firefox"); + assert_eq!(registry.register(&new).unwrap(), Outcome::Added(3)); + assert!(!dir.path().join("signatures.yaml.partial").exists()); +} + +#[test] +fn a_staged_file_that_cannot_be_written_leaves_the_registry_as_it_was() { + let (dir, registry) = registry(REGISTRY); + // A directory where the staged file goes makes writing it fail, after the registry has been read. + fs_err::create_dir(dir.path().join("signatures.yaml.partial")).unwrap(); + let new = signature("webgpu-clear", r#"{"a": "1"}"#, "firefox"); + assert!(matches!(registry.register(&new).unwrap_err(), RegisterError::Io(_))); + assert_eq!(text(®istry), REGISTRY); +} + +#[test] +fn a_registry_that_cannot_be_read_parsed_or_laid_out_is_not_the_submissions_fault() { + let new = signature("webgpu-clear", r#"{"a": "1"}"#, "firefox"); + + let missing = Registry::new(PathBuf::from("no-such-dir-for-et-gpu-probe-server/signatures.yaml")); + let refused = missing.register(&new).unwrap_err(); + assert!(matches!(refused, RegisterError::Io(_)) && !refused.is_rejection()); + + let (_dir, unparsable) = registry("signatures: [\n"); + assert!(matches!( + unparsable.register(&new).unwrap_err(), + RegisterError::Parse(_) + )); + + let flow = "signatures:\n - {interface: webgpu-clear, label: F, results: {a: \"1\"}, seen: []}\n"; + let (_dir, flowing) = registry(flow); + let refused = flowing.register(&new).unwrap_err(); + assert!(matches!(refused, RegisterError::Layout(0))); + assert!(!refused.is_rejection()); +} + +#[test] +fn a_seen_list_another_entry_aliases_is_not_added_to() { + // The second entry's `seen` is an alias of the first's, so a row added to the first would read back in both. + let shared = concat!( + "signatures:\n", + " - interface: webgpu-clear\n label: \"A\"\n seen: &places\n", + " - browser: chrome\n os: linux\n cpu_arch: x86_64\n gpu: intel\n", + " backend: vulkan\n window: normal\n", + " results:\n a: \"1\"\n", + " - interface: webgpu-clear\n label: \"B\"\n seen: *places\n results:\n a: \"2\"\n", + ); + let (_dir, aliased) = registry(shared); + let new = signature("webgpu-clear", r#"{"a": "1"}"#, "firefox"); + assert!(matches!( + aliased.register(&new).unwrap_err(), + RegisterError::Unwritable(0) + )); + assert_eq!(text(&aliased), shared); +} + +#[test] +fn an_empty_list_the_text_cannot_be_added_to_leaves_the_registry_as_it_was() { + let new = signature("webgpu-clear", r#"{"a": "1"}"#, "firefox"); + + let (_dir, empty) = registry("signatures: []\n"); + assert!(matches!( + empty.register(&new).unwrap_err(), + RegisterError::Unwritable(0) + )); + assert_eq!(text(&empty), "signatures: []\n"); + + let unseen = + "signatures:\n - interface: webgpu-clear\n label: \"F\"\n seen: []\n results:\n a: \"1\"\n"; + let (_dir, never_seen) = registry(unseen); + let refused = never_seen.register(&new).unwrap_err(); + assert!(matches!(refused, RegisterError::Unwritable(0)) && !refused.is_rejection()); + assert_eq!(text(&never_seen), unseen); +} + +#[test] +fn text_past_ascii_is_written_escaped_and_reads_back_the_same() { + let (_dir, registry) = registry(REGISTRY); + let label = "accent \u{e9} \u{1d11e} \"quoted\" back\\slash"; + let new = |browser: &str| { + submission(json!({"interface": "webgpu-clear", "label": label, "results": {"a": "1"}, "seen": row(browser)})) + }; + assert_eq!(registry.register(&new("firefox")).unwrap(), Outcome::Added(3)); + let written = text(®istry); + assert!(written.is_ascii()); + // The label as the file holds it: each character past ASCII as its escape, then the quote and backslash escaped. + let escaped = "label: \"accent \\u00e9 \\U0001d11e \\\"quoted\\\" back\\\\slash\""; + assert!(written.contains(escaped), "{written}"); + // A second sighting finds the entry and names it by its label as read back from the file. + assert_eq!( + registry.register(&new("chrome")).unwrap(), + Outcome::Sighted(label.to_owned()) + ); +} + +#[test] +fn writers_that_share_no_registry_value_lose_none_of_each_others_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signatures.yaml"); + fs_err::write(&path, REGISTRY).unwrap(); + // Each thread holds a registry of its own, as a second process would, so only the lock file keeps them apart. + let writers: Vec<_> = (0_u32..8) + .map(|writer| { + let registry = Registry::new(path.clone()); + std::thread::spawn(move || { + let mut seen = row("firefox"); + seen["browser_version"] = json!(format!("{writer}")); + let results = json!({"multiply_add": "33800000", "fma": "33800000"}); + let sighting = json!({"interface": "webgpu-split", "label": "L", "results": results, "seen": seen}); + registry.register(&submission(sighting)).unwrap() + }) + }) + .collect(); + for writer in writers { + assert!(matches!(writer.join().unwrap(), Outcome::Sighted(_))); + } + let after = fs_err::read_to_string(&path).unwrap(); + for writer in 0_u32..8 { + assert!( + after.contains(&format!("browser_version: \"{writer}\"")), + "row {writer} was lost" + ); + } +} + +#[test] +fn a_submission_carrying_a_field_the_registry_does_not_know_does_not_read() { + let stray = json!({"interface": "webgpu", "label": "L", "results": {}, "seen": {}, "colour": "red"}); + let refused = serde_json::from_value::(stray).unwrap_err(); + assert!(refused.to_string().contains("colour")); + let not_a_map = json!({"interface": "webgpu", "label": "L", "results": ["a"], "seen": {}}); + let refused = serde_json::from_value::(not_a_map).unwrap_err(); + assert!(refused.to_string().contains("a map of result names to values")); +} diff --git a/utilities/gpu-probe/tests/server.rs b/utilities/gpu-probe/tests/server.rs new file mode 100644 index 00000000..c5bb86e7 --- /dev/null +++ b/utilities/gpu-probe/tests/server.rs @@ -0,0 +1,234 @@ +//! Covers the server's routes, served in process: the form, the registry, static files, and each answer `/register` +//! gives -- and the names a generated certificate covers. +#![cfg(test)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::path::PathBuf; + +use actix_web::dev::{Service, ServiceResponse}; +use actix_web::http::StatusCode; +use actix_web::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use actix_web::test as served; +use actix_web::{App, web}; +use et_gpu_probe_server::registry::Registry; +use et_gpu_probe_server::{advertised_names, app, nip_io}; +use serde_json::Value; + +/// A registry holding one known signature. +const REGISTRY: &str = r#"signatures: + - interface: webgpu-split + label: "Known GPU" + seen: + - browser: chrome + os: macos + cpu_arch: aarch64 + gpu: apple + backend: metal + window: normal + results: + fma: "33800000" +"#; + +/// A submission of a new signature, as a page posts it. +const NEW: &str = r#"{"interface": "webgpu-clear", "label": "New", "results": {"a": "1"}, + "seen": {"browser": "firefox", "os": "linux", "cpu_arch": "x86_64", "gpu": "intel", "backend": "vulkan", + "window": "normal"}}"#; + +/// The server's routes, adding to a registry at `path` and serving the files under `root`. +#[expect( + clippy::future_not_send, + reason = "actix serves in process on the test's own thread, and its service is not Send" +)] +async fn service( + path: PathBuf, + root: Option, +) -> impl Service { + let registry = web::Data::new(Registry::new(path)); + served::init_service(App::new().configure(|config| app::configure(config, registry, root))).await +} + +/// A directory holding a registry file of [`REGISTRY`] and a probe page, removed when the guard drops. +fn workspace() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs_err::write(dir.path().join("signatures.yaml"), REGISTRY).unwrap(); + fs_err::write(dir.path().join("index.html"), "

probe page

").unwrap(); + dir +} + +/// What `/register` answers `body`, sent as JSON, with: the status, and the JSON reply's `ok` and `message`. +#[expect( + clippy::future_not_send, + reason = "actix serves in process on the test's own thread, and its service is not Send" +)] +async fn register(path: PathBuf, body: &str) -> (StatusCode, bool, String) { + register_as(path, body, "application/json").await +} + +/// What `/register` answers `body`, sent as `content_type`, with: the status, and the reply's `ok` and `message`. +#[expect( + clippy::future_not_send, + reason = "actix serves in process on the test's own thread, and its service is not Send" +)] +async fn register_as(path: PathBuf, body: &str, content_type: &str) -> (StatusCode, bool, String) { + let app = service(path, None).await; + let request = served::TestRequest::post() + .uri("/register") + .insert_header(("content-type", content_type)) + .set_payload(body.to_owned()) + .to_request(); + let response = served::call_service(&app, request).await; + let status = response.status(); + let reply: Value = served::read_body_json(response).await; + ( + status, + reply["ok"].as_bool().unwrap(), + reply["message"].as_str().unwrap().to_owned(), + ) +} + +#[actix_rt::test] +async fn the_form_and_what_the_pages_share_are_served_and_never_cached() { + let dir = workspace(); + let app = service(dir.path().join("signatures.yaml"), None).await; + let built_in = [ + ("/submit", "text/html", "import { register } from \"/register.js\""), + ("/probe.css", "text/css", ".bad {"), + ("/register.js", "text/javascript", "fetch(\"/register\""), + ]; + for (path, kind, holds) in built_in { + let response = served::call_service(&app, served::TestRequest::get().uri(path).to_request()).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + assert!( + response + .headers() + .get(CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + .starts_with(kind) + ); + let body = served::read_body(response).await; + assert!(String::from_utf8_lossy(&body).contains(holds), "{path} holds {holds}"); + } +} + +#[actix_rt::test] +async fn the_registry_is_served_as_it_stands() { + let dir = workspace(); + let app = service(dir.path().join("signatures.yaml"), None).await; + let response = served::call_service(&app, served::TestRequest::get().uri("/signatures.yaml").to_request()).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(served::read_body(response).await, REGISTRY.as_bytes()); + + let missing = service(dir.path().join("missing.yaml"), None).await; + let response = served::call_service( + &missing, + served::TestRequest::get().uri("/signatures.yaml").to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[actix_rt::test] +async fn a_probe_page_is_served_from_the_root_given() { + let dir = workspace(); + let app = service(dir.path().join("signatures.yaml"), Some(dir.path().to_owned())).await; + let response = served::call_service(&app, served::TestRequest::get().uri("/").to_request()).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(served::read_body(response).await, b"

probe page

".as_slice()); + + let without = service(dir.path().join("signatures.yaml"), None).await; + let response = served::call_service(&without, served::TestRequest::get().uri("/").to_request()).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[actix_rt::test] +async fn a_submission_is_added_then_found_present() { + let dir = workspace(); + let path = dir.path().join("signatures.yaml"); + assert_eq!( + register(path.clone(), NEW).await, + ( + StatusCode::OK, + true, + "added; the registry now holds 2 entries".to_owned() + ) + ); + assert_eq!( + register(path, NEW).await, + (StatusCode::OK, true, "already present: New".to_owned()) + ); +} + +#[actix_rt::test] +async fn a_body_that_is_not_a_submission_is_a_bad_request() { + let dir = workspace(); + let (status, accepted, message) = register(dir.path().join("signatures.yaml"), "not json").await; + assert_eq!((status, accepted), (StatusCode::BAD_REQUEST, false)); + assert!(message.starts_with("bad submission: ")); +} + +#[actix_rt::test] +async fn a_submission_not_sent_as_json_is_refused_unread() { + let dir = workspace(); + let path = dir.path().join("signatures.yaml"); + let before = fs_err::read_to_string(&path).unwrap(); + // text/plain is what a page on another origin can post with no preflight. + let (status, accepted, message) = register_as(path.clone(), NEW, "text/plain").await; + assert_eq!((status, accepted), (StatusCode::UNSUPPORTED_MEDIA_TYPE, false)); + assert!(message.contains("application/json")); + assert_eq!(fs_err::read_to_string(&path).unwrap(), before); +} + +#[actix_rt::test] +async fn a_submission_the_registry_cannot_hold_is_a_conflict() { + let dir = workspace(); + let unknown = NEW.replace("firefox", "netscape"); + let (status, accepted, message) = register(dir.path().join("signatures.yaml"), &unknown).await; + assert_eq!((status, accepted), (StatusCode::CONFLICT, false)); + assert!(message.contains("netscape")); +} + +#[actix_rt::test] +async fn a_registry_that_cannot_be_read_is_a_server_error() { + let dir = workspace(); + let (status, accepted, _message) = register(dir.path().join("missing.yaml"), NEW).await; + assert_eq!((status, accepted), (StatusCode::INTERNAL_SERVER_ERROR, false)); +} + +#[test] +fn plain_http_is_served_only_on_a_loopback_address() { + use clap::Parser as _; + use et_gpu_probe_server::{Args, StartError}; + + let args = |extra: &[&str]| { + let base = [ + "et-gpu-probe-server", + "--registry", + "r.yaml", + "--cert", + "c.pem", + "--key", + "k.pem", + ]; + Args::parse_from(base.iter().chain(extra)) + }; + args(&["--http"]).check().unwrap(); + args(&["--http", "--bind", "::1"]).check().unwrap(); + args(&["--bind", "0.0.0.0"]).check().unwrap(); + let refused = args(&["--http", "--bind", "0.0.0.0"]).check().unwrap_err(); + assert!(matches!(refused, StartError::NotLoopback(_))); + assert!(refused.to_string().contains("0.0.0.0")); +} + +#[test] +fn a_certificate_covers_each_public_host_and_each_lan_address_with_its_nip_io_name() { + let lan = [IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))]; + assert_eq!(nip_io(lan[0]), "10-1-2-3.nip.io"); + assert_eq!( + advertised_names(&["probe.example".to_owned()], &lan), + ["probe.example", "10.1.2.3", "10-1-2-3.nip.io"] + ); + assert_eq!(advertised_names(&[], &[]), Vec::::new()); +} diff --git a/utilities/gpu-probe/tests/server_bin.rs b/utilities/gpu-probe/tests/server_bin.rs new file mode 100644 index 00000000..22d2020f --- /dev/null +++ b/utilities/gpu-probe/tests/server_bin.rs @@ -0,0 +1,63 @@ +//! Covers starting the `et-gpu-probe-server` binary on the paths that end by themselves: a refused `--http` address, +//! and a port another listener already holds, over plain HTTP and over HTTPS with a certificate generated first. +#![cfg(test)] + +use std::net::TcpListener; +use std::process::Output; + +use tempfile::TempDir; + +/// The server's output when started with `args` and a registry, certificate and key in `dir`. +/// +/// Every start here is meant to fail, so the raw exit status is what is asserted on. +fn started(dir: &TempDir, args: &[&str]) -> Output { + let path = |name: &str| dir.path().join(name); + std::process::Command::new(env!("CARGO_BIN_EXE_et-gpu-probe-server")) + .arg("--registry") + .arg(path("signatures.yaml")) + .arg("--cert") + .arg(path("cert.pem")) + .arg("--key") + .arg(path("key.pem")) + .args(args) + // The log line a test looks for is at info, which a filter inherited from the caller could hide. + .env("RUST_LOG", "info") + .output() + .unwrap() +} + +/// A port on the loopback address a listener holds for as long as the returned listener lives. +fn taken() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port().to_string(); + (listener, port) +} + +#[test] +fn plain_http_off_the_loopback_address_is_refused() { + let dir = TempDir::new().unwrap(); + let output = started(&dir, &["--http", "--bind", "0.0.0.0"]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("0.0.0.0"), "{stderr}"); +} + +#[test] +fn plain_http_on_a_taken_port_fails_to_bind() { + let dir = TempDir::new().unwrap(); + let (_held, port) = taken(); + let output = started(&dir, &["--http", "--port", &port]); + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("adding submissions to"), "{stdout}"); +} + +#[test] +fn https_on_a_taken_port_generates_the_certificate_then_fails_to_bind() { + let dir = TempDir::new().unwrap(); + let (_held, port) = taken(); + let output = started(&dir, &["--port", &port]); + assert!(!output.status.success()); + assert!(dir.path().join("cert.pem").exists()); + assert!(dir.path().join("key.pem").exists()); +} diff --git a/utilities/gpu-probe/web/Cargo.toml b/utilities/gpu-probe/web/Cargo.toml new file mode 100644 index 00000000..65549ad6 --- /dev/null +++ b/utilities/gpu-probe/web/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "et-int-gpu-probe-web" +publish = false +description = "The GPU probe sets as WASM, for a page to put to the browser's WebGPU" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +features = ["docs"] + +# `#[wasm_bindgen] pub async fn probe_webgpu` expands to `wasm_bindgen_futures` paths, which cargo-shear cannot see. +[package.metadata.cargo-shear] +ignored = ["wasm-bindgen-futures"] + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +## Internal: links et-web's minicov profiler runtime, for the coverage build of the page's browser tests. +coverage = ["dep:et-web", "et-web/coverage"] +## Internal: renders this feature list into the crate documentation, as docs.rs builds it. +docs = ["dep:document-features"] + +[dependencies] +base64 = { workspace = true, features = ["std"] } +document-features = { workspace = true, optional = true } +et-gpu-probe = { workspace = true, features = ["native"] } +js-sys.workspace = true +md-5.workspace = true +png.workspace = true +serde_json = { workspace = true, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +web-sys = { workspace = true, features = [ + "CanvasGradient", + "CanvasRenderingContext2d", + "ContextAttributes2d", + "Document", + "Element", + "HtmlCanvasElement", + "HtmlElement", + "HtmlIFrameElement", + "ImageData", + "Node", + "WebGl2RenderingContext", + "WebGlContextAttributes", + "WebGlFramebuffer", + "WebGlProgram", + "WebGlShader", + "WebGlTexture", + "WebGlUniformLocation", + "Window", +] } +wgpu.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +et-web = { workspace = true, optional = true } + +[dev-dependencies] +wasm-bindgen-test.workspace = true + +[lints] +workspace = true diff --git a/utilities/gpu-probe/web/src/canvas.rs b/utilities/gpu-probe/web/src/canvas.rs new file mode 100644 index 00000000..f49eb965 --- /dev/null +++ b/utilities/gpu-probe/web/src/canvas.rs @@ -0,0 +1,542 @@ +//! The two canvas fingerprints, drawn and read in the page the way the registry's canvas entries were taken. +//! +//! Each is taken more than once, because a browser defending against canvas fingerprinting answers with noise: a +//! fingerprint whose reads disagree is withheld, with the reason, rather than given as a signature. +#![expect( + clippy::single_call_fn, + reason = "each step of drawing and reading a canvas is a named function" +)] + +use core::f64::consts::TAU; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use et_gpu_probe::{ + Alteration, CANVAS, CANVAS_BROWSERLEAKS, KNOWN_BLOCK, KNOWN_SIZE, alteration, known_colour, known_scene, pixel_hash, +}; +use md5::{Digest as _, Md5}; +use serde_json::{Value, json}; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::{CanvasRenderingContext2d, ContextAttributes2d, Document, HtmlCanvasElement, HtmlIFrameElement}; + +/// The width of the page's own scene. +const SCENE_WIDTH: u32 = 300; + +/// The height of the page's own scene. +const SCENE_HEIGHT: u32 = 70; + +/// Where the scene's two circles are centred, beside the colour each is drawn in. +/// +/// The circles sit at 0.35 and 0.6 of the scene's width and half its height, with a radius of 0.4 of its height, and +/// the text at 0.55 and 0.85 of its height. Each of those products is exact in `f64`, so they are written out here +/// rather than computed, and the scene is drawn at the very coordinates the registry's entries were drawn at. +const CIRCLES: [(f64, &str); 2] = [(105.0, "#e33"), (180.0, "#39f")]; + +/// The height the scene's circles are centred at. +const CIRCLE_MIDDLE: f64 = 35.0; + +/// The radius of the scene's circles. +const CIRCLE_RADIUS: f64 = 28.0; + +/// The baseline of the scene's filled text. +const FILLED_BASELINE: f64 = 38.5; + +/// The baseline of the scene's stroked text. +const STROKED_BASELINE: f64 = 59.5; + +/// The text the canvas fingerprint browserleaks.com takes writes, twice. +const BROWSERLEAKS_TEXT: &str = "BrowserLeaks,com 1.0"; + +/// The width of the canvas the fingerprint browserleaks.com takes draws on. +const BROWSERLEAKS_WIDTH: f64 = 220.0; + +/// The height of the canvas the fingerprint browserleaks.com takes draws on. +const BROWSERLEAKS_HEIGHT: f64 = 30.0; + +/// The read path the page's own scene is taken through. +const SCENE_PATH: Path = Path { + frame: false, + will_read_frequently: true, + png: false, +}; + +/// The read path the canvas fingerprint browserleaks.com takes is taken through. +const BROWSERLEAKS_PATH: Path = Path { + frame: true, + will_read_frequently: false, + png: true, +}; + +/// What stopped a canvas being drawn or read. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CanvasError { + /// The page has no document, or the sandboxed frame none of its own. + #[error("the page has no document to make a canvas in")] + Document, + /// The browser made no object of the kind named. + #[error("the browser made no {0}")] + Object(&'static str), + /// A canvas call threw, with what it threw. + #[error("the canvas refused: {0}")] + Refused(String), + /// The PNG `toDataURL` gave was not a base64 data URL. + #[error("toDataURL gave no base64 PNG")] + DataUrl, + /// The PNG `toDataURL` gave decodes to something other than 8-bit colour. + #[error("toDataURL gave a PNG that does not decode to 8-bit colour")] + Png, + /// The data URL `toDataURL` gave did not hold valid base64. + #[error("toDataURL gave a data URL that is not valid base64: {0}")] + Base64(#[from] base64::DecodeError), + /// The PNG `toDataURL` gave could not be decoded. + #[error("toDataURL gave a PNG that could not be decoded: {0}")] + Decode(#[from] png::DecodingError), + /// A read of a canvas gave a different number of values from another read of it. + #[error("two reads of one canvas disagree in length: {0}")] + Length(#[from] et_gpu_probe::ReadLength), +} + +impl From for CanvasError { + fn from(thrown: JsValue) -> Self { + Self::Refused(thrown.as_string().unwrap_or_else(|| format!("{thrown:?}"))) + } +} + +/// A fingerprint every read agreed on, as the page shows the probe sets' signatures. +/// +/// It is given by its `interface`, `results` as `[name, word]` pairs, and its `fingerprint`, `traits` and `describe` +/// lines. +fn signature(interface: &str, results: &[(String, String)], describe: &[String]) -> Value { + let words: Vec<&str> = results.iter().map(|(_, word)| word.as_str()).collect(); + json!({ + "interface": interface, + "results": results, + "fingerprint": words.join("-"), + "traits": "", + "describe": describe, + }) +} + +/// A fingerprint the browser answered with noise. +/// +/// It is given by its `interface`, the `reason` it is withheld, and its `describe` lines. +fn withheld(interface: &str, reason: &str, describe: &[String]) -> Value { + json!({ "interface": interface, "reason": reason, "describe": describe }) +} + +/// One way of making and reading a canvas. +#[derive(Clone, Copy, Eq, PartialEq)] +struct Path { + /// Whether a sandboxed frame's document made the canvas, rather than the page's. + frame: bool, + /// Whether the canvas's context was asked for as one read back often. + will_read_frequently: bool, + /// Whether the canvas is read as the PNG `toDataURL` gives, rather than with `getImageData`. + png: bool, +} + +impl Path { + /// Every way this page makes and reads a canvas. + const ALL: [Self; 8] = [ + Self { + frame: false, + will_read_frequently: true, + png: false, + }, + Self { + frame: false, + will_read_frequently: true, + png: true, + }, + Self { + frame: false, + will_read_frequently: false, + png: false, + }, + Self { + frame: false, + will_read_frequently: false, + png: true, + }, + Self { + frame: true, + will_read_frequently: true, + png: false, + }, + Self { + frame: true, + will_read_frequently: true, + png: true, + }, + Self { + frame: true, + will_read_frequently: false, + png: false, + }, + Self { + frame: true, + will_read_frequently: false, + png: true, + }, + ]; + + /// The path as a page shows it: where the canvas was made, its context, and how it was read. + fn name(self) -> String { + let home = if self.frame { "sandboxed frame" } else { "page" }; + let context = if self.will_read_frequently { + "willReadFrequently" + } else { + "default" + }; + let read = if self.png { "toDataURL" } else { "getImageData" }; + format!("{home}, {context}, {read}") + } +} + +/// The known scene read back through every way the page makes and reads a canvas. +/// +/// A defence that adds noise to some of them alters the values read back on those, and not the others, which is seen +/// within a single visit: the known scene's every value is known before it is drawn. +pub struct ReadPaths { + /// One line a path, saying how many values it altered, or why it could not be read. + lines: Vec, + /// The paths that altered a value. + altered: Vec, +} + +impl ReadPaths { + /// Whether `path` altered a value of the known scene. + fn altered(&self, path: Path) -> bool { + self.altered.contains(&path) + } + + /// One line a path, saying how many values it altered, or why it could not be read. + pub fn into_lines(self) -> Vec { + self.lines + } +} + +/// Read the known scene back through every [`Path`]. +pub fn read_paths() -> Result { + let document = document()?; + let expected = known_scene(); + let mut paths = ReadPaths { + lines: Vec::new(), + altered: Vec::new(), + }; + for path in Path::ALL { + let read = if path.frame { + in_frame(&document, |inner| read_known(inner, path)) + } else { + read_known(&document, path) + }; + let line = match read.map(|values| alteration(&values, &expected)) { + Ok(Ok(found)) if found.changed == 0 => format!("{}: no value altered", path.name()), + Ok(Ok(found)) => { + paths.altered.push(path); + format!( + "{}: {} of {} values altered, pattern {}", + path.name(), + found.changed, + found.total, + found.pattern + ) + } + Ok(Err(problem)) => format!("{}: {problem}", path.name()), + Err(problem) => format!("{}: {problem}", path.name()), + }; + paths.lines.push(line); + } + Ok(paths) +} + +/// The known scene, drawn on a canvas `document` made and read back through `path`, as RGBA values. +fn read_known(document: &Document, path: Path) -> Result, CanvasError> { + let canvas = canvas(document)?; + canvas.set_width(u32::from(KNOWN_SIZE)); + canvas.set_height(u32::from(KNOWN_SIZE)); + let context = context_2d(&canvas, path.will_read_frequently)?; + let block = f64::from(KNOWN_BLOCK); + let step = usize::from(KNOWN_BLOCK); + for (down, top) in (0_u8..).zip((0..KNOWN_SIZE).step_by(step)) { + for (across, left) in (0_u8..).zip((0..KNOWN_SIZE).step_by(step)) { + let [red, green, blue] = known_colour(across, down); + context.set_fill_style_str(&format!("rgb({red},{green},{blue})")); + context.fill_rect(f64::from(left), f64::from(top), block, block); + } + } + if path.png { + return decode_png(&png_bytes(&canvas)?); + } + let side = f64::from(KNOWN_SIZE); + Ok(context.get_image_data(0.0, 0.0, side, side)?.data().0) +} + +/// The RGBA values of `png`, an opaque PNG given back with or without its alpha channel. +fn decode_png(png: &[u8]) -> Result, CanvasError> { + let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); + decoder.set_transformations(png::Transformations::EXPAND); + let mut reader = decoder.read_info()?; + let mut buffer = vec![0; reader.output_buffer_size().ok_or(CanvasError::Png)?]; + let frame = reader.next_frame(&mut buffer)?; + let bytes = buffer.get(..frame.buffer_size()).ok_or(CanvasError::Png)?; + match frame.color_type { + png::ColorType::Rgba => Ok(bytes.to_vec()), + png::ColorType::Rgb => { + let (pixels, _rest) = bytes.as_chunks::<3>(); + Ok(pixels + .iter() + .flat_map(|&[red, green, blue]| [red, green, blue, u8::MAX]) + .collect()) + } + png::ColorType::Grayscale | png::ColorType::Indexed | png::ColorType::GrayscaleAlpha => Err(CanvasError::Png), + } +} + +/// The page's own scene, on a canvas the page made, hashed from `getImageData` twice. +/// +/// It is withheld when the same read of the known scene in `paths` was altered. +pub fn scene(paths: &ReadPaths) -> Result { + let document = document()?; + let canvas = canvas(&document)?; + canvas.set_width(SCENE_WIDTH); + canvas.set_height(SCENE_HEIGHT); + let context = context_2d(&canvas, true)?; + let read = || -> Result<[String; 4], CanvasError> { + draw_scene(&context, f64::from(SCENE_WIDTH), f64::from(SCENE_HEIGHT))?; + let pixels = context.get_image_data(0.0, 0.0, f64::from(SCENE_WIDTH), f64::from(SCENE_HEIGHT))?; + Ok(pixel_hash(&pixels.data()).map(|lane| format!("{lane:08x}"))) + }; + let first = read()?; + let second = read()?; + let describe = [ + format!("scene read 1: {}", first.join("-")), + format!("scene read 2: {}", second.join("-")), + ]; + if first != second { + return Ok(withheld( + CANVAS, + "randomised: two reads of the same scene differed (per-read noise)", + &describe, + )); + } + if paths.altered(SCENE_PATH) { + let reason = "randomised: getImageData altered a scene whose every value is known (noise added to reads)"; + return Ok(withheld(CANVAS, reason, &describe)); + } + let results: Vec<_> = first + .into_iter() + .enumerate() + .map(|(lane, word)| (format!("lane{lane}"), word)) + .collect(); + Ok(signature(CANVAS, &results, &describe)) +} + +/// The scene the registry's canvas entries hashed: a gradient, two circles multiplied over it, and two lines of text. +fn draw_scene(context: &CanvasRenderingContext2d, width: f64, height: f64) -> Result<(), CanvasError> { + context.clear_rect(0.0, 0.0, width, height); + let gradient = context.create_linear_gradient(0.0, 0.0, width, height); + gradient.add_color_stop(0.0, "#f09")?; + gradient.add_color_stop(0.5, "#0cf")?; + gradient.add_color_stop(1.0, "#fc0")?; + context.set_fill_style_canvas_gradient(&gradient); + context.fill_rect(0.0, 0.0, width, height); + context.set_global_composite_operation("multiply")?; + for (across, colour) in CIRCLES { + context.set_fill_style_str(colour); + context.begin_path(); + context.arc(across, CIRCLE_MIDDLE, CIRCLE_RADIUS, 0.0, TAU)?; + context.fill(); + } + context.set_global_composite_operation("source-over")?; + context.set_text_baseline("alphabetic"); + context.set_fill_style_str("#222"); + context.set_font("600 22px 'Times New Roman', Georgia, serif"); + context.fill_text("GPU fp \u{2728} 1234567890", 8.0, FILLED_BASELINE)?; + context.set_stroke_style_str("rgba(0,0,0,0.5)"); + context.set_font("italic 16px system-ui, sans-serif"); + context.stroke_text("the quick brown fox", 10.0, STROKED_BASELINE)?; + Ok(()) +} + +/// The canvas fingerprint browserleaks.com takes, in two sandboxed frames and on a canvas the page made. +/// +/// The two frames stand for two loads of that site. +/// Its signature is the first frame's; a read in either frame that disagrees with it withholds the signature. The +/// page's own canvas is only shown, to say whether the frame changes the answer. It is withheld too when the same read +/// of the known scene in `paths` was altered. +pub fn browserleaks(paths: &ReadPaths) -> Result { + let document = document()?; + let first = in_frame(&document, browserleaks_reads)?; + let second = in_frame(&document, browserleaks_reads)?; + // The page's own canvas is only shown, so one the browser will not read leaves the frames' answer standing. + let page = "canvas made by the page"; + let page_line = browserleaks_reads(&document).map_or_else( + |problem| format!("{page}: not read, {problem}"), + |reads| reads.line(page), + ); + let describe = [first.line("frame 1"), second.line("frame 2"), page_line]; + if first.hash != first.again { + let reason = "randomised: two reads of the same canvas differed (per-read noise)"; + return Ok(withheld(CANVAS_BROWSERLEAKS, reason, &describe)); + } + if [&second.hash, &second.again].iter().any(|other| **other != first.hash) { + let reason = "randomised: two sandboxed frames in one page gave different hashes"; + return Ok(withheld(CANVAS_BROWSERLEAKS, reason, &describe)); + } + // Noise keyed to the session gives the same hash on every read of one visit, so only a read it leaves alone, or a + // scene whose values are known, shows it. + if first.against_pixels.changed != 0 { + let reason = "randomised: toDataURL differed from getImageData of the same canvas (noise keyed to the session)"; + return Ok(withheld(CANVAS_BROWSERLEAKS, reason, &describe)); + } + if paths.altered(BROWSERLEAKS_PATH) { + let reason = "randomised: toDataURL altered a scene whose every value is known (noise keyed to the session)"; + return Ok(withheld(CANVAS_BROWSERLEAKS, reason, &describe)); + } + Ok(signature( + CANVAS_BROWSERLEAKS, + &[("md5".to_owned(), first.hash)], + &describe, + )) +} + +/// What `take` gives on the document of a fresh sandboxed frame in `document`, the frame removed after. +fn in_frame( + document: &Document, + take: impl FnOnce(&Document) -> Result, +) -> Result { + // An `iframe` element is an `HTMLIFrameElement`, so the cast needs no check. + let frame = document.create_element("iframe")?.unchecked_into::(); + frame.set_attribute("sandbox", "allow-same-origin")?; + frame.set_attribute("aria-hidden", "true")?; + frame.set_attribute("style", "position:absolute;width:0;height:0;border:0")?; + document + .body() + .ok_or(CanvasError::Document)? + .append_with_node_1(&frame)?; + let reads = frame + .content_document() + .ok_or(CanvasError::Document) + .and_then(|inner| take(&inner)); + frame.remove(); + reads +} + +/// What one canvas carrying the fingerprint's drawing gave back. +struct Reads { + /// The MD5 of the PNG `toDataURL` gave. + hash: String, + /// The MD5 of the PNG a second `toDataURL` of the same canvas gave. + again: String, + /// How that PNG's values differ from those `getImageData` gives for the same canvas. + against_pixels: Alteration, + /// The [`pixel_hash`] of the values `getImageData` gives for the canvas. + /// + /// It says whether the image itself changed where the PNG's hash did. + pixels: String, +} + +impl Reads { + /// The reads as a page shows them, opening with `place`. + fn line(&self, place: &str) -> String { + let Self { + hash, + again, + against_pixels, + pixels, + } = self; + let changed = against_pixels.changed; + format!("{place}: {hash} then {again} on a second read; pixels {pixels}, the PNG differing in {changed} values") + } +} + +/// The fingerprint's drawing on a canvas `document` made, its PNG read twice and checked against its pixels. +/// +/// A browser draws one canvas once, so its PNG and its `getImageData` hold the same values, and a defence that adds +/// noise to one read and not the other shows as a difference between them within a single visit. +fn browserleaks_reads(document: &Document) -> Result { + let canvas = canvas(document)?; + let context = context_2d(&canvas, false)?; + draw_browserleaks(&canvas, &context)?; + let png = png_bytes(&canvas)?; + let pixels = context + .get_image_data(0.0, 0.0, BROWSERLEAKS_WIDTH, BROWSERLEAKS_HEIGHT)? + .data() + .0; + let against_pixels = alteration(&decode_png(&png)?, &pixels)?; + Ok(Reads { + hash: md5_hex(&png), + again: md5_hex(&png_bytes(&canvas)?), + against_pixels, + pixels: pixel_hash(&pixels).map(|lane| format!("{lane:08x}")).join("-"), + }) +} + +/// The drawing the fingerprint browserleaks.com takes, call for call. +/// +/// The canvas is sized after its context is taken, as that site does. +fn draw_browserleaks(canvas: &HtmlCanvasElement, context: &CanvasRenderingContext2d) -> Result<(), CanvasError> { + canvas.set_attribute("width", &BROWSERLEAKS_WIDTH.to_string())?; + canvas.set_attribute("height", &BROWSERLEAKS_HEIGHT.to_string())?; + context.set_text_baseline("top"); + context.set_font("14px 'Arial'"); + context.set_text_baseline("alphabetic"); + context.set_fill_style_str("#f60"); + context.fill_rect(125.0, 1.0, 62.0, 20.0); + context.set_fill_style_str("#069"); + context.fill_text(BROWSERLEAKS_TEXT, 2.0, 15.0)?; + context.set_fill_style_str("rgba(102, 204, 0, 0.7)"); + context.fill_text(BROWSERLEAKS_TEXT, 4.0, 17.0)?; + Ok(()) +} + +/// The MD5 of `bytes`, as 32 lowercase hex digits. +#[expect( + clippy::big_endian_bytes, + reason = "an MD5 digest is written out first byte first, which is big-endian" +)] +fn md5_hex(bytes: &[u8]) -> String { + let digest: [u8; 16] = Md5::digest(bytes).into(); + format!("{:032x}", u128::from_be_bytes(digest)) +} + +/// The PNG `toDataURL` gives for `canvas`. +fn png_bytes(canvas: &HtmlCanvasElement) -> Result, CanvasError> { + let url = canvas.to_data_url_with_type("image/png")?; + let encoded = url.strip_prefix("data:image/png;base64,").ok_or(CanvasError::DataUrl)?; + Ok(STANDARD.decode(encoded)?) +} + +/// The page's document. +fn document() -> Result { + web_sys::window() + .and_then(|window| window.document()) + .ok_or(CanvasError::Document) +} + +/// A canvas made by `document`, which the page does not show. +/// +/// A frame's document makes its elements in the frame's own realm, where an `instanceof` check against the page's +/// `HTMLCanvasElement` fails, so the element is cast unchecked: a `canvas` element is an `HTMLCanvasElement` whichever +/// realm made it. +fn canvas(document: &Document) -> Result { + Ok(document.create_element("canvas")?.unchecked_into::()) +} + +/// The 2-D context of `canvas`, cast unchecked for the reason [`canvas`] gives. +/// +/// With `will_read_frequently` the context is asked for as one the page reads back often, which a browser may keep in +/// memory rather than on the GPU, and so draw and read differently. +fn context_2d(canvas: &HtmlCanvasElement, will_read_frequently: bool) -> Result { + let context = if will_read_frequently { + let attributes = ContextAttributes2d::new(); + attributes.set_will_read_frequently(true); + canvas.get_context_with_context_options("2d", &attributes)? + } else { + canvas.get_context("2d")? + }; + let context = context.ok_or(CanvasError::Object("2-D context"))?; + Ok(context.unchecked_into::()) +} diff --git a/utilities/gpu-probe/web/src/lib.rs b/utilities/gpu-probe/web/src/lib.rs new file mode 100644 index 00000000..b5777a8f --- /dev/null +++ b/utilities/gpu-probe/web/src/lib.rs @@ -0,0 +1,130 @@ +//! The GPU probe sets and canvas fingerprints as WASM, for a page to put to the browser's WebGL 2 with +//! [`probe_webgl2`], to each of its WebGPU adapters with [`probe_webgpu`], and to its 2-D canvas with [`probe_canvas`]. +#![cfg_attr(feature = "docs", doc = document_features::document_features!())] +#![cfg_attr( + target_arch = "wasm32", + expect( + clippy::future_not_send, + reason = "a page's WebGPU objects are JavaScript's, which stay on the one thread a page has" + ) +)] + +use et_gpu_probe::Signature; +use et_gpu_probe::native::Gpu; +// rustc links only the crates a crate names, and the coverage build needs et-web's profiler runtime linked. +#[cfg(all(feature = "coverage", target_arch = "wasm32"))] +use et_web as _; +use serde_json::{Value, json}; +use wasm_bindgen::prelude::{JsError, wasm_bindgen}; + +mod canvas; +mod targets; +mod webgl2; + +/// Take both canvas fingerprints, and give back what each answered. +/// +/// The answer is JSON text: an object whose `signatures` hold each fingerprint the browser answered the same way +/// every time it was read, as [`probe_webgl2`] gives them, whose `withheld` hold each it answered with noise, by its +/// `interface`, `reason` and `describe` lines, whose `report` says, a line each, how every way the page makes and reads +/// a canvas gave back a scene whose values are all known, and whose `problems` say why one could not be taken at all. +#[wasm_bindgen] +pub fn probe_canvas() -> Result { + let mut signatures = Vec::new(); + let mut withheld = Vec::new(); + let mut problems = Vec::new(); + let paths = canvas::read_paths()?; + for (interface, outcome) in [ + (et_gpu_probe::CANVAS, canvas::scene(&paths)), + (et_gpu_probe::CANVAS_BROWSERLEAKS, canvas::browserleaks(&paths)), + ] { + match outcome { + Ok(answer) if answer.get("reason").is_some() => withheld.push(answer), + Ok(answer) => signatures.push(answer), + Err(problem) => problems.push(format!("{interface}: {problem}")), + } + } + let answer = json!({ + "signatures": signatures, + "withheld": withheld, + "report": paths.into_lines(), + "problems": problems, + }); + Ok(serde_json::to_string(&answer)?) +} + +/// Put every probe set WebGL 2 can run to the browser's WebGL 2, and give back what each answered. +/// +/// The answer is JSON text: an object whose `signatures` hold one object a probe set, and whose `problems` say why a +/// set gave nothing. Each signature gives the `interface` the registry files it under, its `results` as `[name, word]` +/// pairs in output order, and its `fingerprint`, `traits` and `describe` lines. +#[wasm_bindgen] +pub fn probe_webgl2() -> Result { + let (signatures, problems) = gather([ + ("webgl2-split", webgl2::signature()), + ("webgl2-clear", targets::clear_signature()), + ("webgl2-fixed-function", targets::fixed_function_signature()), + ]); + let answer = json!({ "signatures": signatures, "problems": problems }); + Ok(serde_json::to_string(&answer)?) +} + +/// Put every probe set to the WebGPU adapter the browser gives for `power` and `fallback`, and give back the answers. +/// +/// `power` is a `GPUPowerPreference`, `high-performance` or `low-power`, and `fallback` asks for the browser's fallback +/// adapter, a software renderer, instead. The answer is JSON text as [`probe_webgl2`] gives it, with a `report` beside +/// the signatures: the adapter's description, one `name: value` line each. +#[wasm_bindgen] +pub async fn probe_webgpu(power: &str, fallback: bool) -> Result { + let power_preference = match power { + "high-performance" => wgpu::PowerPreference::HighPerformance, + "low-power" => wgpu::PowerPreference::LowPower, + _ => wgpu::PowerPreference::None, + }; + let options = wgpu::RequestAdapterOptions { + power_preference, + force_fallback_adapter: fallback, + compatible_surface: None, + }; + let (signatures, report, problems) = match Gpu::open_with(&options).await { + Ok(gpu) => { + // Each set is measured on its own, so one that fails -- the device lost at its readback, say -- leaves the + // sets before it answered and submittable, and is reported beside them. + let (signatures, problems) = gather([ + ("webgpu-split", gpu.signature().await), + ("webgpu-combined", gpu.combined_signature().await), + ("webgpu-clear", gpu.clear_signature().await), + ("webgpu-fixed-function", gpu.fixed_function_signature().await), + ]); + (signatures, gpu.report().lines, problems) + } + Err(problem) => (Vec::new(), Vec::new(), vec![format!("WebGPU: {problem}")]), + }; + let answer = json!({ "signatures": signatures, "report": report, "problems": problems }); + Ok(serde_json::to_string(&answer)?) +} + +/// What a page shows of each set in `measured` that answered, and why each that did not gave nothing. +/// +/// A set that answered is given by the `interface` it was measured through, its `results` as `[name, word]` pairs, and +/// its `fingerprint`, `traits` and `describe` lines. +fn gather(measured: Sets) -> (Vec, Vec) +where + Problem: core::fmt::Display, + Sets: IntoIterator)>, +{ + let mut signatures = Vec::new(); + let mut problems = Vec::new(); + for (interface, outcome) in measured { + match outcome { + Ok(signature) => signatures.push(json!({ + "interface": interface, + "results": signature.results(), + "fingerprint": signature.fingerprint(), + "traits": signature.traits(), + "describe": signature.describe(), + })), + Err(problem) => problems.push(format!("{interface}: {problem}")), + } + } + (signatures, problems) +} diff --git a/utilities/gpu-probe/web/src/targets.rs b/utilities/gpu-probe/web/src/targets.rs new file mode 100644 index 00000000..56028f2f --- /dev/null +++ b/utilities/gpu-probe/web/src/targets.rs @@ -0,0 +1,260 @@ +//! The probe sets no shader compiler decides, put to the page's WebGL 2: clears, blends, filters and rasterization. +//! +//! Each probe draws into, or clears, a texture of its own attached to a framebuffer, and reads back the code its red +//! channel holds. A texel is read back in the type WebGL 2 reads its format in exactly: bytes for the 8-bit formats, +//! the packed word for the 10-bit one, and floats for the float formats, whose values each convert to f32 with no +//! rounding and so name the code they came from. +#![expect( + clippy::single_call_fn, + reason = "each step of drawing a probe is a named function of its own" +)] + +use et_gpu_probe::{ + Answer, BLENDS, Blend, CLEARS, Clear, FILTERS, Filter, Format, Operation, RASTER_FORMAT, RASTER_WIDTH, RASTERS, + Raster, Signature, WEBGL2_CLEAR, WEBGL2_FIXED_FUNCTION, coverage, +}; +use js_sys::{Float32Array, Object, Uint8Array, Uint16Array, Uint32Array}; +use web_sys::{WebGl2RenderingContext as Context, WebGlTexture}; + +use crate::webgl2::{WebGl2Error, context, link, settled}; + +/// The layout of the target a filter probe draws its sample into, which reads back as the sample's own bits. +const SAMPLE_LAYOUT: Layout = [Context::R32F, Context::RED, Context::FLOAT]; + +/// How a texture's texels are stored and handed over: its sized internal format, then its texels' format and type. +type Layout = [u32; 3]; + +/// What the page's WebGL 2 answers to the clears. +pub fn clear_signature() -> Result { + let context = float_context()?; + let answers = CLEARS + .iter() + .map(|probe| answer(&context, || clear(&context, probe))) + .collect(); + Ok(Signature::new(WEBGL2_CLEAR, answers)) +} + +/// What the page's WebGL 2 answers to the fixed-function probes: the blends, then the filters, then the rasters. +pub fn fixed_function_signature() -> Result { + let context = float_context()?; + let blends = BLENDS.iter().map(|probe| answer(&context, || blend(&context, probe))); + let filters = FILTERS.iter().map(|probe| answer(&context, || filter(&context, probe))); + let rasters = RASTERS.iter().map(|probe| answer(&context, || raster(&context, probe))); + let answers = blends.chain(filters).chain(rasters).collect(); + Ok(Signature::new(WEBGL2_FIXED_FUNCTION, answers)) +} + +/// A WebGL 2 context with float targets enabled where the browser offers them. +/// +/// Without `EXT_color_buffer_float` a float texture cannot be attached to a framebuffer, so the probes that need one +/// are refused one by one rather than the whole set. +fn float_context() -> Result { + let context = context()?; + let _float_targets = context.get_extension("EXT_color_buffer_float"); + Ok(context) +} + +/// What the device did with the probe `measure` runs: its code, or why it gave none. +/// +/// A WebGL error the probe left behind is a refusal too, since a code read back after one means nothing. +fn answer(context: &Context, measure: impl FnOnce() -> Result) -> Answer { + let outcome = measure().and_then(|code| settled(context).map(|()| code)); + // A probe refused partway may have left errors behind, which would otherwise be charged to the next one. + let _cleared: Result<(), WebGl2Error> = settled(context); + outcome.map_or_else(|refusal| Answer::Refused(refusal.to_string()), Answer::Bits) +} + +/// Clear a one-texel target of the probe's format to its value, and read back the code it holds. +/// +/// WebGL 2 takes a clear value as an f32, so the value is rounded to one before the driver converts it. +fn clear(context: &Context, probe: &Clear) -> Result { + target(context, probe.format, 1, None)?; + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "clearBufferfv takes f32 values, and std has no other conversion from f64" + )] + let value = probe.value as f32; + context.clear_bufferfv_with_f32_array(Context::COLOR, 0, &[value, 0.0, 0.0, 1.0]); + read_red(context, probe.format) +} + +/// Blend the probe's colour into a one-texel target holding its destination code, and read back the code it holds. +fn blend(context: &Context, probe: &Blend) -> Result { + target(context, probe.format, 1, Some(&probe.format.texel(probe.destination)))?; + let [vertex, fragment] = probe.glsl(); + context.use_program(Some(&link(context, &vertex, &fragment)?)); + let (source, destination) = match probe.operation { + Operation::Multiply => (Context::DST_COLOR, Context::ZERO), + Operation::Add => (Context::ONE, Context::ONE), + _ => return Err(WebGl2Error::Unsupported(probe.name)), + }; + context.enable(Context::BLEND); + context.blend_equation(Context::FUNC_ADD); + context.blend_func(source, destination); + context.draw_arrays(Context::TRIANGLES, 0, 3); + context.disable(Context::BLEND); + read_red(context, probe.format) +} + +/// Sample the probe's two-texel texture into a one-texel `r32float` target, and read back the sample's bits. +fn filter(context: &Context, probe: &Filter) -> Result { + let sample = texture(context, SAMPLE_LAYOUT, "r32float", 1, None)?; + attach(context, &sample, "r32float", 1)?; + let [left, right] = probe.texels.map(|code| probe.format.texel(code)); + let name = probe.format.name(); + let _sampled: WebGlTexture = texture(context, layout(probe.format)?, name, 2, Some(&[left, right].concat()))?; + for (parameter, value) in [ + (Context::TEXTURE_MIN_FILTER, Context::LINEAR), + (Context::TEXTURE_MAG_FILTER, Context::LINEAR), + (Context::TEXTURE_WRAP_S, Context::CLAMP_TO_EDGE), + (Context::TEXTURE_WRAP_T, Context::CLAMP_TO_EDGE), + ] { + context.tex_parameteri(Context::TEXTURE_2D, parameter, gl_int(value)); + } + let [vertex, fragment] = probe.glsl(); + let program = link(context, &vertex, &fragment)?; + context.use_program(Some(&program)); + context.uniform1i(context.get_uniform_location(&program, "probeTexture").as_ref(), 0); + context.draw_arrays(Context::TRIANGLES, 0, 3); + let values = Float32Array::new_with_length(4); + read(context, 1, Context::FLOAT, &values)?; + Ok(values.get_index(0).to_bits()) +} + +/// Draw the probe's triangle across a target [`RASTER_WIDTH`] texels wide, and read back which texels it covered. +fn raster(context: &Context, probe: &Raster) -> Result { + target(context, RASTER_FORMAT, RASTER_WIDTH, None)?; + let [vertex, fragment] = probe.glsl(); + context.use_program(Some(&link(context, &vertex, &fragment)?)); + context.draw_arrays(Context::TRIANGLES, 0, 3); + let texels = Uint8Array::new_with_length(RASTER_WIDTH.saturating_mul(RASTER_FORMAT.texel_bytes())); + read(context, RASTER_WIDTH, Context::UNSIGNED_BYTE, &texels)?; + Ok(coverage(&texels.to_vec())) +} + +/// Draw into a new texture of `format`, `width` texels wide and one high, holding `texels` if given. +fn target(context: &Context, format: Format, width: u32, texels: Option<&[u8]>) -> Result<(), WebGl2Error> { + let drawn = texture(context, layout(format)?, format.name(), width, texels)?; + attach(context, &drawn, format.name(), width) +} + +/// A new texture stored as `layout`, `width` texels wide and one high, holding `texels` if given, left bound. +/// +/// The texels are laid out as [`Format::texel`] lays them, and handed over as the typed array the layout's type calls +/// for, which views the same bytes. `name` names the format in a refusal. +fn texture( + context: &Context, + layout: Layout, + name: &'static str, + width: u32, + texels: Option<&[u8]>, +) -> Result { + let [internal, format, kind] = layout; + let texture = context.create_texture().ok_or(WebGl2Error::Object("texture"))?; + context.bind_texture(Context::TEXTURE_2D, Some(&texture)); + let data = texels.map(|bytes| typed(bytes, kind)); + let (target, level, border) = (Context::TEXTURE_2D, 0_i32, 0_i32); + let uploaded = context.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_array_buffer_view( + target, + level, + gl_int(internal), + gl_int(width), + 1, + border, + format, + kind, + data.as_ref(), + ); + if uploaded.is_err() { + return Err(WebGl2Error::Target(name)); + } + Ok(texture) +} + +/// Attach `texture` to a new framebuffer, and draw into its `width` texels from then on. +fn attach(context: &Context, texture: &WebGlTexture, name: &'static str, width: u32) -> Result<(), WebGl2Error> { + let framebuffer = context.create_framebuffer().ok_or(WebGl2Error::Object("framebuffer"))?; + context.bind_framebuffer(Context::FRAMEBUFFER, Some(&framebuffer)); + let (attachment, level) = (Context::COLOR_ATTACHMENT0, 0_i32); + context.framebuffer_texture_2d( + Context::FRAMEBUFFER, + attachment, + Context::TEXTURE_2D, + Some(texture), + level, + ); + if context.check_framebuffer_status(Context::FRAMEBUFFER) != Context::FRAMEBUFFER_COMPLETE { + return Err(WebGl2Error::Target(name)); + } + context.viewport(0, 0, gl_int(width), 1); + Ok(()) +} + +/// How WebGL 2 stores a texture of `format`, and the format and type a texel of it uploads as. +const fn layout(format: Format) -> Result { + Ok(match format { + Format::Rgba8Unorm => [Context::RGBA8, Context::RGBA, Context::UNSIGNED_BYTE], + Format::Rgba8UnormSrgb => [Context::SRGB8_ALPHA8, Context::RGBA, Context::UNSIGNED_BYTE], + Format::Rgb10a2Unorm => [Context::RGB10_A2, Context::RGBA, Context::UNSIGNED_INT_2_10_10_10_REV], + Format::Rg11b10Ufloat => [ + Context::R11F_G11F_B10F, + Context::RGB, + Context::UNSIGNED_INT_10F_11F_11F_REV, + ], + Format::Rgba16Float => [Context::RGBA16F, Context::RGBA, Context::HALF_FLOAT], + other => return Err(WebGl2Error::Target(other.name())), + }) +} + +/// `bytes` as the typed array WebGL 2 takes texels of the type `kind` in, viewing the same memory. +fn typed(bytes: &[u8], kind: u32) -> Object { + let buffer = Uint8Array::from(bytes).buffer(); + match kind { + Context::UNSIGNED_BYTE => Uint8Array::new(&buffer).into(), + Context::HALF_FLOAT => Uint16Array::new(&buffer).into(), + _ => Uint32Array::new(&buffer).into(), + } +} + +/// The code the red channel of the target's first texel holds, the target being of `format`. +fn read_red(context: &Context, format: Format) -> Result { + match format { + Format::Rgba8Unorm | Format::Rgba8UnormSrgb => { + let texel = Uint8Array::new_with_length(4); + read(context, 1, Context::UNSIGNED_BYTE, &texel)?; + Ok(u32::from(texel.get_index(0))) + } + Format::Rgb10a2Unorm => { + let word = Uint32Array::new_with_length(1); + read(context, 1, Context::UNSIGNED_INT_2_10_10_10_REV, &word)?; + #[expect( + clippy::little_endian_bytes, + reason = "Format::red reads a texel lowest bits first, as WebGPU lays it out and this word packs it" + )] + let texel = word.get_index(0).to_le_bytes(); + Ok(format.red(texel)) + } + Format::Rg11b10Ufloat | Format::Rgba16Float => { + let texel = Float32Array::new_with_length(4); + read(context, 1, Context::FLOAT, &texel)?; + Ok(format.encode(f64::from(texel.get_index(0)))) + } + other => Err(WebGl2Error::Target(other.name())), + } +} + +/// Read the first `width` texels of the target as RGBA of the type `kind` into `into`. +fn read(context: &Context, width: u32, kind: u32, into: &Object) -> Result<(), WebGl2Error> { + let pixels = + context.read_pixels_with_opt_array_buffer_view(0, 0, gl_int(width), 1, Context::RGBA, kind, Some(into)); + if pixels.is_err() { + return Err(WebGl2Error::Read); + } + Ok(()) +} + +/// `value`, a WebGL enum or a size of a few texels, as the `GLint` WebGL takes it in. +fn gl_int(value: u32) -> i32 { + i32::try_from(value).unwrap_or(i32::MAX) +} diff --git a/utilities/gpu-probe/web/src/webgl2.rs b/utilities/gpu-probe/web/src/webgl2.rs new file mode 100644 index 00000000..e0177d48 --- /dev/null +++ b/utilities/gpu-probe/web/src/webgl2.rs @@ -0,0 +1,155 @@ +//! The probe set as GLSL ES 3.00 fragment shaders, each drawn to one pixel with the page's WebGL 2. +//! +//! A browser that gives no WebGPU adapter -- Chromium on Linux, by default -- still gives WebGL 2, so this is the +//! signature such a page can take. Each probe's shader writes its result's four bytes to the pixel's red, green, blue +//! and alpha, lowest first, so the pixel read back is the result in little-endian order. +#![expect( + clippy::little_endian_bytes, + clippy::single_call_fn, + reason = "the pixel holds the result lowest byte first; each step of drawing a probe is a named function" +)] + +use et_gpu_probe::{Answer, INPUTS, Signature, VERTEX_SHADER, WEBGL2, fragment_shaders}; +use wasm_bindgen::JsCast as _; +use web_sys::{ + HtmlCanvasElement, WebGl2RenderingContext as Context, WebGlContextAttributes, WebGlProgram, WebGlShader, +}; + +/// What stopped the page drawing with WebGL 2, or one probe being drawn. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WebGl2Error { + /// The page has no document to make a canvas in. + #[error("the page could not make a canvas")] + Canvas, + /// The browser gives no WebGL 2 context on the canvas. + #[error("the browser gives no WebGL 2 context")] + Context, + /// The browser made no object of the kind named. + #[error("the browser made no {0}")] + Object(&'static str), + /// A shader did not compile, with the compiler's log. + #[error("the shader did not compile: {0}")] + Compile(String), + /// The probe's shaders did not link, with the linker's log. + #[error("the probe did not link: {0}")] + Link(String), + /// The pixel a probe drew could not be read back. + #[error("the browser would not read the pixel back")] + Read, + /// A target of the format named cannot be drawn into, or read back exactly. + #[error("the browser cannot draw into and read back {0} exactly")] + Target(&'static str), + /// The browser lost the WebGL context while the probe ran, so nothing it read back was drawn. + #[error("the browser lost the WebGL context")] + Lost, + /// The probe named is of a kind this page has no WebGL 2 driver for. + #[error("the page has no WebGL 2 driver for {0}")] + Unsupported(&'static str), + /// WebGL reported an error, by its code, while the probe ran. + #[error("WebGL reported error {0:#06x}")] + WebGl(u32), +} + +/// What the page's WebGL 2 answers to the probe set, each probe in a shader of its own. +/// +/// A probe the browser will not compile or link is a refusal, given as the compiler's or linker's log. +pub fn signature() -> Result { + let context = context()?; + let answers = fragment_shaders() + .iter() + .map(|(_, source)| { + draw(&context, source).map_or_else(|refusal| Answer::Refused(refusal.to_string()), Answer::Bits) + }) + .collect(); + Ok(Signature::new(WEBGL2, answers)) +} + +/// A WebGL 2 context on a one-pixel canvas the page does not show. +/// +/// Its drawing buffer is not antialiased and keeps alpha unpremultiplied, so the pixel a probe draws reads back as +/// exactly the bytes its shader wrote. +pub(crate) fn context() -> Result { + let canvas = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.create_element("canvas").ok()) + .and_then(|element| element.dyn_into::().ok()) + .ok_or(WebGl2Error::Canvas)?; + canvas.set_width(1); + canvas.set_height(1); + let attributes = WebGlContextAttributes::new(); + attributes.set_antialias(false); + attributes.set_premultiplied_alpha(false); + attributes.set_preserve_drawing_buffer(true); + canvas + .get_context_with_context_options("webgl2", &attributes) + .ok() + .flatten() + .and_then(|context| context.dyn_into::().ok()) + .ok_or(WebGl2Error::Context) +} + +/// Draw the probe whose fragment shader is `source` to the context's one pixel, and read back the bits it wrote. +fn draw(context: &Context, source: &str) -> Result { + let program = link(context, VERTEX_SHADER, source)?; + context.use_program(Some(&program)); + context.disable(Context::DITHER); + let inputs = context.get_uniform_location(&program, "probeIn"); + context.uniform1fv_with_f32_array(inputs.as_ref(), &INPUTS); + context.draw_arrays(Context::POINTS, 0, 1); + let mut pixel = [0_u8; 4]; + let (rgba, bytes) = (Context::RGBA, Context::UNSIGNED_BYTE); + if context + .read_pixels_with_opt_u8_array(0, 0, 1, 1, rgba, bytes, Some(&mut pixel)) + .is_err() + { + return Err(WebGl2Error::Read); + } + settled(context)?; + Ok(u32::from_le_bytes(pixel)) +} + +/// Check the context drew what it was asked to: it is not lost, and reported no error while the probe ran. +/// +/// A lost context, which WebGL allows at any moment, carries on taking calls and doing nothing, so a readback after it +/// gives the zeroes it started with rather than failing; its error clears once read, so the rest are read away too. +pub(crate) fn settled(context: &Context) -> Result<(), WebGl2Error> { + if context.is_context_lost() { + return Err(WebGl2Error::Lost); + } + let error = context.get_error(); + while context.get_error() != Context::NO_ERROR {} + match error { + Context::NO_ERROR => Ok(()), + reported => Err(WebGl2Error::WebGl(reported)), + } +} + +/// The vertex shader `vertex` linked with the fragment shader `fragment`. +pub(crate) fn link(context: &Context, vertex: &str, fragment: &str) -> Result { + let vertex = compile(context, Context::VERTEX_SHADER, vertex)?; + let fragment = compile(context, Context::FRAGMENT_SHADER, fragment)?; + let program = context.create_program().ok_or(WebGl2Error::Object("program"))?; + context.attach_shader(&program, &vertex); + context.attach_shader(&program, &fragment); + context.link_program(&program); + if context.get_program_parameter(&program, Context::LINK_STATUS).as_bool() == Some(true) { + return Ok(program); + } + Err(WebGl2Error::Link( + context.get_program_info_log(&program).unwrap_or_default(), + )) +} + +/// `source` compiled as a shader of the kind `kind`. +fn compile(context: &Context, kind: u32, source: &str) -> Result { + let shader = context.create_shader(kind).ok_or(WebGl2Error::Object("shader"))?; + context.shader_source(&shader, source); + context.compile_shader(&shader); + if context.get_shader_parameter(&shader, Context::COMPILE_STATUS).as_bool() == Some(true) { + return Ok(shader); + } + Err(WebGl2Error::Compile( + context.get_shader_info_log(&shader).unwrap_or_default(), + )) +} diff --git a/utilities/gpu-probe/web/tests/probes.rs b/utilities/gpu-probe/web/tests/probes.rs new file mode 100644 index 00000000..0763b646 --- /dev/null +++ b/utilities/gpu-probe/web/tests/probes.rs @@ -0,0 +1,86 @@ +//! Puts every probe set and both canvas fingerprints to a real browser's WebGL 2, WebGPU and 2-D canvas, and checks the +//! page gets back the JSON it reads: signatures with their results, and a reason for whatever was not probed. +#![cfg(test)] +#![cfg(target_arch = "wasm32")] +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "each test is called once, by the wrapper #[wasm_bindgen_test] makes; the WebGPU probe holds page objects" +)] +#![cfg_attr(wasm_bindgen_unstable_test_coverage, feature(coverage_attribute))] + +use et_int_gpu_probe_web::{probe_canvas, probe_webgl2, probe_webgpu}; +use serde_json::Value; +use wasm_bindgen_test::wasm_bindgen_test; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +/// The JSON a probe gave, parsed. +fn parsed(answer: &str) -> Value { + serde_json::from_str(answer).unwrap() +} + +/// The `interface` of each signature in `answer`. +fn interfaces(answer: &Value) -> Vec<&str> { + answer["signatures"] + .as_array() + .unwrap() + .iter() + .map(|signature| signature["interface"].as_str().unwrap()) + .collect() +} + +#[wasm_bindgen_test] +fn webgl2_gives_every_set_or_says_why_not() { + let answer = parsed(&probe_webgl2().unwrap()); + let given = interfaces(&answer); + let problems = answer["problems"].as_array().unwrap(); + assert_eq!(given.len().saturating_add(problems.len()), 3, "{answer}"); + for signature in answer["signatures"].as_array().unwrap() { + assert!(!signature["results"].as_array().unwrap().is_empty(), "{signature}"); + assert!(signature["describe"].is_array(), "{signature}"); + } +} + +#[wasm_bindgen_test] +async fn webgpu_gives_every_set_or_says_why_not() { + for (power, fallback) in [("high-performance", false), ("low-power", false), ("", true)] { + let answer = parsed(&probe_webgpu(power, fallback).await.unwrap()); + let given = interfaces(&answer); + let problems = answer["problems"].as_array().unwrap(); + assert!(given.len() == 4 || !problems.is_empty(), "{answer}"); + assert!(answer["report"].is_array(), "{answer}"); + } +} + +#[wasm_bindgen_test] +fn both_canvas_fingerprints_are_taken() { + let answer = parsed(&probe_canvas().unwrap()); + assert_eq!(answer["problems"].as_array().unwrap().len(), 0, "{answer}"); + let mut taken: Vec<&str> = interfaces(&answer); + for withheld in answer["withheld"].as_array().unwrap() { + assert!( + withheld["reason"].as_str().unwrap().starts_with("randomised: "), + "{withheld}" + ); + taken.push(withheld["interface"].as_str().unwrap()); + } + taken.sort_unstable(); + assert_eq!(taken, ["canvas", "canvas-browserleaks"]); + let report = answer["report"].as_array().unwrap(); + assert_eq!(report.len(), 8, "{answer}"); +} + +#[wasm_bindgen_test] +fn an_unaltered_browser_gives_the_hashes_its_reads_agree_on() { + let answer = parsed(&probe_canvas().unwrap()); + for signature in answer["signatures"].as_array().unwrap() { + let describe = signature["describe"].as_array().unwrap(); + assert!(describe.len() >= 2, "{signature}"); + let fingerprint = signature["fingerprint"].as_str().unwrap(); + assert!( + describe.iter().all(|line| line.as_str().unwrap().contains(fingerprint)), + "{signature}" + ); + } +} diff --git a/utilities/int-gen/Cargo.toml b/utilities/int-gen/Cargo.toml index cf0015ee..eaeedb6e 100644 --- a/utilities/int-gen/Cargo.toml +++ b/utilities/int-gen/Cargo.toml @@ -22,6 +22,7 @@ clap-markdown.workspace = true command-error.workspace = true edge-toolkit = { workspace = true, features = ["schema-export"] } et-cli.workspace = true +et-gpu-probe-server.workspace = true et-modules-service = { workspace = true, features = ["openapi-spec"] } et-onnx.workspace = true et-recovery-supervisor = { workspace = true, features = ["env-schema"] } diff --git a/utilities/int-gen/src/help.rs b/utilities/int-gen/src/help.rs index 7f2c8504..7bbdd1d9 100644 --- a/utilities/int-gen/src/help.rs +++ b/utilities/int-gen/src/help.rs @@ -59,6 +59,7 @@ pub struct BinaryCrate { pub fn clap_commands() -> BTreeMap<&'static str, clap::Command> { BTreeMap::from([ ("et-cli", et_cli::cli::Cli::command()), + ("et-gpu-probe-server", et_gpu_probe_server::Args::command()), ("et-int-gen", crate::cli::Cli::command()), ("et-onnx", et_onnx::Args::command()), ]) diff --git a/utilities/int-gen/tests/help_md.rs b/utilities/int-gen/tests/help_md.rs index 0d11cae7..b41a4adf 100644 --- a/utilities/int-gen/tests/help_md.rs +++ b/utilities/int-gen/tests/help_md.rs @@ -56,6 +56,7 @@ fn detects_every_binary_using_clap_or_serde_env() { documented, [ ("et-cli", true, false), + ("et-gpu-probe-server", true, false), ("et-int-gen", true, false), ("et-onnx", true, false), ("et-recovery-supervisor", false, true),