diff --git a/.codacy.yaml b/.codacy.yaml index 4fc7f6ad..3762055c 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -13,6 +13,17 @@ # - CLAUDE.md: Codacy's prose rules (undefined-acronym, absolute-rule-without-escape-hatch) fight this # agent-instructions doc, which is imperative and full of deliberate absolutes ("NEVER ...", "ALWAYS ...") by # design; dprint + typos + editorconfig already lint it, so Codacy adds only false positives here. +# - services/ws-web-runner/mingw-shim/msvc_crt_alloc.c: MISRA-21.3-unavoidable heap-allocation code Codacy cannot +# suppress per line; isolated into that one file so this exclude stays minimal. Full rationale lives in the +# file's own header comment (its canonical home, since more than one analyzer config may exclude the same file). +# - services/ws-modules/wasi-{comm1,data1}/src/coverage.rs: each guest's minicov coverage dump, whose unsafe +# Codacy flags for audit and cannot suppress per line. Isolated into these one-function files so the exclude +# stays minimal; rationale in each file's header. The rest of each guest stays analyzed. +# - utilities/wasm-cov-wrapper/src/main.rs: a RUSTC_WORKSPACE_WRAPPER that reads its own argv (args_os) and forwards +# it to rustc; Codacy's Rust security rule flags args_os-into-a-subprocess as a command-injection shape and cannot +# suppress per line. It is a false positive -- the args are cargo's own trusted rustc invocation -- and forwarding +# argv to rustc is the whole file's purpose, so no code change removes it. The crate is already this one minimal +# file; rationale lives in its header. Still covered by clippy + DeepSource Rust. exclude_paths: - ".github/actions/**" - ".github/workflows/**" @@ -20,3 +31,7 @@ exclude_paths: - "Dockerfile" - "config/semgrep/**" - "services/ws-modules/dotnet-data1/Program.cs" + - "services/ws-modules/wasi-comm1/src/coverage.rs" + - "services/ws-modules/wasi-data1/src/coverage.rs" + - "services/ws-web-runner/mingw-shim/msvc_crt_alloc.c" + - "utilities/wasm-cov-wrapper/src/main.rs" diff --git a/.deepsource.toml b/.deepsource.toml index 07841032..b77a5238 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -2,10 +2,8 @@ version = 1 # Trees that are generated or vendored rather than hand-written source. # Built module artifacts under any `pkg/`, the int-gen outputs under `generated/`, and the scenario `verification/` -# fixtures. The mingw-shim is MSVC-CRT ABI glue (non-const ABI symbols like `__security_cookie`, lazy-init `static` -# function-pointer caches, naked asm) where the general C best-practice rules only ever produce false positives; -# clang-tidy/cpplint already cover that tree. -exclude_patterns = ["**/pkg/**", "generated/**", "services/ws-web-runner/mingw-shim/**", "verification/**"] +# fixtures. +exclude_patterns = ["**/pkg/**", "generated/**", "verification/**"] # Repo convention: tests live in a `tests/` directory or in source files prefixed `test_`. test_patterns = ["**/test_*.py", "**/tests/**"] diff --git a/.dockerignore b/.dockerignore index 97a14539..770d69e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ # Docker reads only this file; patterns are **/-prefixed to match at any depth # like .gitignore. Edit .gitignore and regenerate. +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. **/.claude/ **/*.wasm **/*.onnx @@ -22,13 +24,20 @@ services/ws-server/static/models/ **/node_modules/ **/pnpm-lock.yaml **/.venv/ -# .NET build output. `obj/` is safe globally (nothing tracked is named obj/), -# but `bin/` is scoped to the module so it never matches a Rust crate's -# `src/bin/` (e.g. utilities/int-gen/src/bin/). +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). **/obj/ services/ws-modules/dotnet-data1/bin/ -# Editor dir (but keep the shared recommended-extensions list), tool caches, and -# the ws-server's runtime file storage. +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +lcov.info +**/coverage-python.xml +**/.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +**/*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). .vscode/* !.vscode/extensions.json **/.ruff_cache/ diff --git a/.dprint.jsonc b/.dprint.jsonc index 41d6eee1..31bb24d8 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -1,6 +1,6 @@ -// dprint anchors its base directory (the tree it formats) to the directory of -// the config file it discovers. This stub keeps that base at the repo root while -// the real config lives in config/ alongside the other linter configs. +// Stub config at the repo root so dprint anchors its base directory (the tree it formats) here, not in config/. +// dprint fixes its base to the directory of the config file it discovers, so the real config lives in config/ +// alongside the other linter configs, and this root stub just re-exports it via "extends". { "extends": "config/dprint.jsonc", } diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 17651d6e..09afbf2d 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -52,6 +52,10 @@ jobs: fetch-depth: 1 persist-credentials: false + - name: Free disk space on Linux + if: runner.os == 'Linux' + uses: jlumbroso/free-disk-space@v1.3.1 + - name: Free disk space on Windows if: runner.os == 'Windows' uses: ./.github/actions/free-disk-space-windows diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 65447c81..37ee302d 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -21,9 +21,6 @@ defaults: shell: bash --noprofile --norc -euo pipefail {0} env: - # Exact full guest-language set -- gha.rego pins this literal value at the workflow level. - # (It's what lets `build-modules` build every ws-module so the Rust tests run.) The coverage job appends the - # `coverage` env at job level below, mirroring test.yaml's override job's `,${{ matrix.compiler }}`. MISE_ENV: dart,dotnet,java,js,python,rust,zig jobs: @@ -32,6 +29,7 @@ jobs: timeout-minutes: 150 env: MISE_ENV: dart,dotnet,java,js,python,rust,zig,coverage + ET_TEST_COVERAGE: "true" steps: - name: Checkout uses: actions/checkout@v4 @@ -52,7 +50,7 @@ jobs: timeout-minutes: 20 with: github-token: ${{ github.token }} - install-action-tools: cargo-llvm-cov,cargo-nextest + install-action-tools: cargo-llvm-cov,deepsource - name: Prefetch dependencies timeout-minutes: 15 @@ -70,13 +68,50 @@ jobs: timeout-minutes: 75 run: mise run cargo-llvm-cov - - name: Upload coverage to Codecov (OIDC) + # Fold the instrumented wasm guest modules' coverage into lcov.info so it rides the same `rust` flag. + # The WASI + browser modules dumped their .profraw to target/wasi-cov during the runner tests above. + - name: Merge wasm guest coverage into lcov.info + timeout-minutes: 10 + run: mise run wasm-cov + + - name: Collect Python coverage + timeout-minutes: 15 + env: + GITHUB_TOKEN: ${{ github.token }} + run: mise run pytest-cov + + - name: Upload coverage reports as artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: | + lcov.info + coverage-python.xml + if-no-files-found: error + + - name: Upload Rust coverage to Codecov (OIDC) uses: codecov/codecov-action@v5 with: use_oidc: true files: lcov.info + flags: rust fail_ci_if_error: true + - name: Upload Python coverage to Codecov (OIDC) + uses: codecov/codecov-action@v5 + with: + use_oidc: true + files: coverage-python.xml + flags: python + fail_ci_if_error: true + + - name: Upload Rust coverage to DeepSource (OIDC) + run: deepsource report --analyzer test-coverage --key rust --value-file lcov.info --use-oidc + + - name: Upload Python coverage to DeepSource (OIDC) + run: deepsource report --analyzer test-coverage --key python --value-file coverage-python.xml --use-oidc + # Upload test results even when tests failed. # nextest still wrote the JUnit report, and a failing run is exactly when the per-test results on Codecov # matter most; `report_type: test_results` selects the JUnit upload path. diff --git a/.github/workflows/docker-linux.yaml b/.github/workflows/docker-linux.yaml index de1dd30c..0635cc62 100644 --- a/.github/workflows/docker-linux.yaml +++ b/.github/workflows/docker-linux.yaml @@ -146,17 +146,8 @@ jobs: docker run --rm et-test sh -c "find / -xdev -type f -size +50M -exec du -h {} + | sort -h | tail -50" echo "::endgroup::" - # Run the test phases as two separate `docker run` invocations. - # Each starts a fresh container with an empty writable layer, so the per-phase compile target/ doesn't share - # disk with the other phase. Splits cargo-test's two halves (workspace minus et-ws-web-runner, then - # et-ws-web-runner serialized) for clearer attribution when a tight-disk runner fails. test-ws-web-runner - # already skips itself on hosts that can't satisfy its Deno-runtime requirements, so this step is a no-op on - # those platforms. - - name: Run cargo-test-other - run: docker run --rm et-test mise run cargo-test-other - - - name: Run test-ws-web-runner - run: docker run --rm et-test mise run test-ws-web-runner + - name: Run cargo-test + run: docker run --rm et-test mise run cargo-test - name: Run mise check run: docker run --rm et-check diff --git a/.github/workflows/docker-windows.yaml b/.github/workflows/docker-windows.yaml index 6d36c8c8..f1bfc750 100644 --- a/.github/workflows/docker-windows.yaml +++ b/.github/workflows/docker-windows.yaml @@ -156,14 +156,7 @@ jobs: # shellcheck disable=SC2086 docker build $args . - # Run the test suite split the same way docker-linux does. - # cargo-test-other (workspace minus et-ws-web-runner) and test-ws-web-runner (Deno-runtime gated, self-skips - # on hosts that can't satisfy it). Each runs in a fresh container so the per-phase compile target/ doesn't - # share the writable layer with the other phase. servercore-only -- Nano has no test stage. - - name: Run cargo-test-other + # Run the Rust tests, excluding et-ws-web-runner on the gnullvm target. + - name: Run cargo-test if: matrix.base == 'servercore' - run: docker run --rm et-windows-test mise run cargo-test-other - - - name: Run test-ws-web-runner - if: matrix.base == 'servercore' - run: docker run --rm et-windows-test mise run test-ws-web-runner + run: docker run --rm et-windows-test mise run cargo-test diff --git a/.gitignore b/.gitignore index c94bd3d0..22bd7ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# `.dockerignore` is generated from `.gitignore` by `mise run gen:dockerignore`. +# After editing `.gitignore`, regenerate it and commit both, or the dockerignore-check task fails on drift. .claude/ *.wasm *.onnx @@ -18,13 +20,20 @@ uv.lock node_modules/ pnpm-lock.yaml .venv/ -# .NET build output. `obj/` is safe globally (nothing tracked is named obj/), -# but `bin/` is scoped to the module so it never matches a Rust crate's -# `src/bin/` (e.g. utilities/int-gen/src/bin/). +# .NET build output. +# `obj/` is safe globally (nothing tracked is named obj/), but `bin/` is scoped to the module so it never +# matches a Rust crate's `src/bin/` (e.g. utilities/int-gen/src/bin/). obj/ services/ws-modules/dotnet-data1/bin/ -# Editor dir (but keep the shared recommended-extensions list), tool caches, and -# the ws-server's runtime file storage. +# Coverage-report artifacts produced by the coverage workflow, uploaded to Codecov and never committed. +# Covers cargo-llvm-cov's lcov, pytest-cov's Cobertura xml, and pytest-cov's .coverage data file. +/lcov.info +coverage-python.xml +.coverage +# minicov/llvm raw profiles dropped by instrumented binaries run during coverage builds. +*.profraw +# Editor dir, tool caches, and the ws-server's runtime file storage. +# The .vscode dir is ignored except its shared recommended-extensions list (negated below). .vscode/* !.vscode/extensions.json .ruff_cache/ diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 54286c0b..f2be82d7 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -3,24 +3,128 @@ # and the *-all tasks never touch it. Selected only by the coverage workflow, stacked on the language envs it also # loads (rust + the guest langs whose ws-modules the coverage build exercises, so the module-loading tests run # rather than self-skip). Houses the cargo-llvm-cov task so it stays out of the always-loaded config.toml. -# cargo-llvm-cov and cargo-nextest are not mise [tools] -- the workflow co-installs them via -# taiki-e/install-action -- so there is no [tools] table here. +# cargo-llvm-cov is not a mise [tool] -- the workflow co-installs it via taiki-e/install-action. (cargo-nextest IS +# a mise tool, from config.toml's `github:nextest-rs/nextest` entry.) + +# conda-forge clang is a full LLVM clang with the wasm32 backend that the host clang (Apple's) lacks. +# The wasm coverage builds need it: minicov's build.rs hardcodes bare `clang` to compile its C profiler runtime +# for the guest target, so this wasm-capable clang must be first on PATH (the wasm build tasks prepend it). LLVM +# 22 here matches rustc's LLVM, keeping the covmap format aligned with the llvm-tools llc/llvm-cov used downstream. +[tools] +"conda:clang" = { version = "latest", os = ["linux", "macos"] } + +# The wasm coverage builds run on nightly (-Zno-profiler-runtime) with the wasm-capable conda clang on PATH. +# It is set env-wide here because this env loads only under the coverage workflow; per-command RUSTFLAGS and +# the minicov feature come from the wasm_cov / *_cov_feat vars so uninstrumented builds stay clean. +[env] +RUSTUP_TOOLCHAIN = "nightly" +_.path = "{{ env.HOME }}/.local/share/mise/installs/conda-clang/latest/bin" [tasks.cargo-llvm-cov] description = "Rust coverage (cargo-llvm-cov) + JUnit test results (nextest); emits lcov.info + junit.xml" -# cargo-llvm-cov and cargo-nextest are deliberately NOT mise [tools] (like cargo-unmaintained). -# The coverage workflow installs both via taiki-e/install-action; this task just runs them. cargo-llvm-cov needs -# the `llvm-tools-preview` rustup component, which it adds on demand against the mise-managed toolchain. -# Running the suite through nextest (NEXTEST_PROFILE=ci) emits target/nextest/ci/junit.xml for the Codecov -# test-results upload; et-ws-web-runner is serialized by the `web-runner` test-group in config/nextest.toml -# rather than RUST_TEST_THREADS, which nextest ignores; `--config-file config/nextest.toml` selects that config. -# No `--doc` pass: the workspace sets `[lib] doctest = false` on essentially every crate (doc comments are prose, -# not runnable examples), and `cargo llvm-cov --doc` ignores that setting -- it drives rustdoc directly and chokes -# on the generated et-rest-client's backtick prose. So coverage stays on stable, tests-only. `--no-report` defers -# the report so `report` writes lcov.info from the nextest run's profiles. +# cargo-llvm-cov is deliberately NOT a mise [tool] (like cargo-unmaintained). +# The coverage workflow installs it via taiki-e/install-action; cargo-nextest comes from config.toml's mise tool. +# This task just runs them. cargo-llvm-cov needs the `llvm-tools-preview` rustup component, which it adds on demand +# against the mise-managed toolchain. +# `show-env` exports the coverage instrumentation env (rustc wrapper, LLVM_PROFILE_FILE, target dir) so BOTH the +# nextest run and the mise regen tasks build + run instrumented into one profile set, then a single `report` +# merges them. The regens -- `gen-specs` (et-int-gen) and `regen-verification` (et-cli) -- exercise the code +# generators, which otherwise get only their unit-test coverage. nextest (NEXTEST_PROFILE=ci) also writes +# target/nextest/ci/junit.xml for the Codecov test-results upload, and serializes et-ws-web-runner via the +# `web-runner` test-group in config/nextest.toml. No `--doc` pass: the workspace sets `[lib] doctest = false` +# almost everywhere (doc comments are prose), and cargo-llvm-cov's `--doc` ignores that and chokes on the +# generated et-rest-client's backtick prose, so coverage stays tests-only. env = { NEXTEST_PROFILE = "ci" } run = """ -cargo llvm-cov --no-report nextest --config-file config/nextest.toml -cargo llvm-cov report --lcov --output-path lcov.info +cargo llvm-cov clean --workspace +# Drop the native build dirs so every native crate rebuilds under cargo-llvm-cov's rustc wrapper. +# The prior `build-modules` step compiled et-cli + its native dep tree (edge-toolkit, et-path, int-gen, ...) +# NON-instrumented into target/{debug,release}, and `cargo llvm-cov clean` leaves those in place; without this the +# coverage build reuses them uninstrumented so those crates report zero coverage (et-path was the symptom). The +# wasm guest .ll that wasm-cov consumes live under target/wasm32-wasip2 and are left untouched. +coreutils rm -rf target/debug target/release +# Apply cargo-llvm-cov's instrumentation env via `eval` of its `show-env` exports. +# `--sh` is the current flag (the old `--export-prefix` alias is deprecated). This sets the rustc wrapper + +# LLVM_PROFILE_FILE + profile dir so nextest and the regens instrument into one profile set that `report` merges. +eval "$(cargo llvm-cov show-env --sh)" +# The --features flags compile each runner's coverage code, off by default so non-coverage builds omit it. +# et-ws-web-runner/coverage adds the browser-module capture (shims + the __et_capture_coverage PUT); +# et-ws-wasi-runner/coverage adds the guest `/cov` preopen. ET_TEST_COVERAGE (set by the coverage job) still +# gates capture at runtime within each build. +cargo nextest run --config-file config/nextest.toml --features et-ws-web-runner/coverage,et-ws-wasi-runner/coverage +mise run gen-specs +mise run regen-verification +cargo llvm-cov report --lcov --include-build-script --output-path lcov.info +""" +shell = "bash -euo pipefail -c" + +[tasks.wasm-cov] +description = "Turn instrumented wasm guest .profraw into lcov (llc + llvm-cov) and merge into the workspace lcov.info" +# The instrumented wasm builds drop their minicov .profraw into target/wasi-cov during the runner tests. +# WASI guests (wasi-comm1/wasi-data1) dump via the runner's /cov preopen; browser modules (data1, comm1, ...) go +# through et-web's __et_capture_coverage -> the module's ws-server storage bucket -> the web-runner test (see +# config.rust.toml's coverage wrapper). Each instrumented build also emits an .ll next to its .wasm (release/deps). +# llvm-cov cannot read a coverage map from a .wasm, so (per taiki-e/cargo-llvm-cov#337 + hknio/wasmcov) we gut +# every function body in the .ll to `unreachable` -- keeping only the signatures and the __llvm_covmap records -- +# then compile that to an ELF object with llc. The gut also strips each function's "target-cpu"/"target-features" +# attributes: browser .ll carries wasm `mvp` cpu + wasm features that make llc reject the x86_64 target +# ("64-bit code requested on a subtarget that doesn't support it"); the WASI guest .ll lacks them. The hit counts +# come from the .profraw, so the object needs no code; the fixed x86_64 ELF target avoids the macOS Mach-O +# AsmPrinter aborting on the covmap globals, and llvm-cov reads the covmap from any object format regardless of +# host. llc/llvm-cov/llvm-profdata are the version-matched nightly llvm-tools that built the guests. Path remap of +# the emitted SF: lines to repo-relative may need a CI follow-up. +run = """ +covdir=target/wasi-cov +if [ -z "$(find "$covdir" -maxdepth 1 -name '*.profraw' -print -quit 2>/dev/null)" ]; then + echo "wasi-cov: no $covdir/*.profraw -- coverage build/run produced no guest data; skipping" + exit 0 +fi +host="$(rustc -vV | goawk '/^host:/ { print $2 }')" +bin="$(rustc +nightly --print sysroot)/lib/rustlib/$host/bin" +gut="$covdir/gut.awk" +coreutils cat > "$gut" <<'AWK' +/^target datalayout/ { next } +/^target triple/ { next } +/^define/ { print; print "start:"; print " unreachable"; print "}"; skip = 1; next } +skip && /^}/ { skip = 0; next } +skip { next } +{ gsub(/"target-cpu"="[^"]*"/, ""); gsub(/"target-features"="[^"]*"/, ""); print } +AWK +: > "$covdir/wasi.lcov" +for profraw in "$covdir"/*.profraw; do + name="$(coreutils basename "$profraw" .profraw)" + ll="$(find target -path '*/release/deps/*' -name "$name*.ll" 2>/dev/null | coreutils head -n 1)" + if [ -z "$ll" ]; then echo "wasi-cov: no .ll found for $name"; exit 1; fi + pd="$covdir/$name.profdata" + obj="$covdir/$name.o" + goawk -f "$gut" "$ll" > "$covdir/$name.gutted.ll" + "$bin/llc" -filetype=obj -mtriple=x86_64-unknown-linux-gnu -o "$obj" "$covdir/$name.gutted.ll" + "$bin/llvm-profdata" merge -sparse -o "$pd" "$profraw" + "$bin/llvm-cov" export --format=lcov --instr-profile "$pd" "$obj" >> "$covdir/wasi.lcov" +done +coreutils cat "$covdir/wasi.lcov" >> lcov.info """ shell = "bash -euo pipefail -c" + +[tasks.pytest-cov] +description = "Combined Python coverage (pyface1 pytest + web-runner Pyodide runs) -> Cobertura for Codecov/DeepSource" +# One Python report is combined from three data sources: pyface1's pytest run plus the web-runner Pyodide runs. +# The web-runner integration test drops pydata1's and pyface1's .coverage files into target/pycov/ when +# ET_TEST_COVERAGE is set; `coverage combine` then merges all three via config/coverage.toml's `[paths]` remap into +# a single Cobertura file. pytest-cov is a plugin, so it rides in pyface1's uv `dev` group -- the same venv as +# pytest -- and `uv run` (a mise-managed tool) installs both into the one project venv. UV_PYTHON mirrors +# test-pyface1's mise-CPython pin. +run = """ +pyface=services/ws-modules/pyface1 +pycov=target/pycov +mkdir -p "$pycov" +uv run --directory "$pyface" pytest --cov=pyface1 --cov-report= +cp "$pyface/.coverage" "$pycov/.coverage.pyface1_pytest" +uv run --project "$pyface" coverage combine "$pycov" +uv run --project "$pyface" coverage xml -o coverage-python.xml +""" +shell = "bash -euo pipefail -c" + +[tasks.pytest-cov.env] +COVERAGE_RCFILE = "{{ config_root }}/config/coverage.toml" +UV_PYTHON = "{% if os() == 'windows' %}{{ vars.py3_win }}{% else %}{{ vars.py3_unix }}{% endif %}" diff --git a/.mise/config.dart.toml b/.mise/config.dart.toml index 9cd35e8c..e8ee4c0b 100644 --- a/.mise/config.dart.toml +++ b/.mise/config.dart.toml @@ -1,10 +1,6 @@ # Dart toolchain + tasks. Loaded when MISE_ENV includes `dart`. [vars] -# Google dart-archive base URLs, split out so [tools.dart] below stays one line. -# The release tree and the bucket listing are referenced by its url/version_list_url. -dart_bucket = "https://storage.googleapis.com/storage/v1/b/dart-archive/o" -dart_release = "https://storage.googleapis.com/dart-archive/channels/stable/release" # Every tracked Dart package: the two generated clients plus the two modules. Shared by analyze/format. dart_paths = "generated/dart-ws generated/dart-rest services/ws-modules/dart-comm1 services/ws-modules/dart-data1" diff --git a/.mise/config.dotnet.toml b/.mise/config.dotnet.toml index bff0c219..eb3d789a 100644 --- a/.mise/config.dotnet.toml +++ b/.mise/config.dotnet.toml @@ -7,9 +7,12 @@ dotnet = "10.0.300" [vars] # MSBuild dir inside the mise dotnet SDK, for the roslynator `-m` flags below. -# `$DOTNET_ROOT` stays literal here; the task shell expands it. The version segment is the [tools] dotnet +# `$$DOTNET_ROOT` escapes mise's config-load `$VAR` expansion (`$$` -> literal `$`), so the value stays +# `$DOTNET_ROOT/...` for the task shell to expand at run time. DOTNET_ROOT is a tool-plugin env var that mise +# only injects once the dotnet tool is active; at config-parse time (e.g. mid `mise install`) it isn't set, so +# a bare `$DOTNET_ROOT` would emit a spurious "not defined" warning. The version segment is the [tools] dotnet # pin above -- bump together. -dotnet_msbuild = "$DOTNET_ROOT/sdk/10.0.300" +dotnet_msbuild = "$$DOTNET_ROOT/sdk/10.0.300" [tasks.dotnet-fmt] description = "Format dotnet C# code" diff --git a/.mise/config.js.toml b/.mise/config.js.toml index b53bcf04..15157c76 100644 --- a/.mise/config.js.toml +++ b/.mise/config.js.toml @@ -58,20 +58,19 @@ url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.69.0/oxfmt-x url = "https://github.com/oxc-project/oxc/releases/download/apps_v1.69.0/oxfmt-x86_64-pc-windows-msvc.zip" [tasks.build-ws-har1-module] -depends = ["build-et-cli"] +depends = ["build-et-cli", "build-wasm-cov-wrapper"] description = "Build the har1 workflow WASM module" dir = "services/ws-modules/har1" # Chain with `&&` on one line rather than a multi-line `bash` block. -# This is needed because wasm-pack shells out to `cargo metadata`, and that subprocess can't find `cargo` -# under Git Bash on Windows. The default shell (cmd there) resolves cargo like the single-line wasm-pack -# modules do. -run = "wasm-pack build . --target web {{ vars.no_opt }} && {{ vars.et_cli }} module-package-json" +# wasm-pack shells out to `cargo metadata`, which can't find `cargo` under Git Bash on Windows; the default +# shell (cmd there) resolves it. web_pack_cov folds in the coverage wrapper prefix so this line stays under 120. +run = "{{ vars.web_pack_cov }} {{ vars.no_opt }}{{ vars.web_cov_feat }} && {{ vars.et_cli }} module-package-json" [tasks.build-ws-face-detection-module] -depends = ["build-et-cli"] +depends = ["build-et-cli", "build-wasm-cov-wrapper"] description = "Build the face detection workflow WASM module" dir = "services/ws-modules/face-detection" -run = "wasm-pack build . --target web {{ vars.no_opt }} && {{ vars.et_cli }} module-package-json" +run = "{{ vars.web_pack_cov }} {{ vars.no_opt }}{{ vars.web_cov_feat }} && {{ vars.et_cli }} module-package-json" [tasks.oxlint-check] description = "Lint JavaScript/TypeScript sources (oxlint)" diff --git a/.mise/config.mingw.toml b/.mise/config.mingw.toml index 7846106b..0cdb9f24 100644 --- a/.mise/config.mingw.toml +++ b/.mise/config.mingw.toml @@ -130,9 +130,3 @@ if [ ! -f "$a" ]; then fi """ shell = "bash -euo pipefail -c" - -[tasks.test-ws-web-runner] -# Override config.windows.toml's skip: the gnu target links rusty_v8's msvc prebuilt, so the suite runs. -description = "Run et-ws-web-runner integration tests serialized" -env = { RUST_TEST_THREADS = "1" } -run = "cargo test -p et-ws-web-runner" diff --git a/.mise/config.msvc.toml b/.mise/config.msvc.toml index 68466a26..88ea5d62 100644 --- a/.mise/config.msvc.toml +++ b/.mise/config.msvc.toml @@ -114,9 +114,3 @@ print("rendered", sys.argv[2], "for MSVC", msvcv, "SDK", sdkv) PY """ shell = "bash -euo pipefail -c" - -[tasks.test-ws-web-runner] -# Override config.windows.toml's skip -- the msvc target links rusty_v8's native prebuilt, so it runs. -description = "Run et-ws-web-runner integration tests serialized" -env = { RUST_TEST_THREADS = "1" } -run = "cargo test -p et-ws-web-runner" diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index 3eb89911..c0680776 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -8,55 +8,75 @@ # The rest of the Rust toolchain + tasks (the `rust` tool itself, cargo-*, the universal repo-wide checks, # ws-server, the e2e tests) still live in the default config; moving those out is a later pass. +# Browser wasm-pack builds are coverage-instrumented when ET_TEST_COVERAGE is set. +# The web_cov_wrapper RUSTC_WORKSPACE_WRAPPER (built by build-wasm-cov-wrapper) instruments only workspace +# crates -- never the dependency cdylibs (tracing-wasm, wasm-streams) that don't link minicov's +# __llvm_profile_runtime -- while web_cov_feat enables et-web/coverage to supply minicov's runtime. et-web's +# __et_capture_coverage export (folded by wasm-bindgen into every module's glue) lets the web-runner pull each +# module's .profraw for wasm-cov. +[tasks.build-wasm-cov-wrapper] +description = "Build the RUSTC_WORKSPACE_WRAPPER that instruments browser modules (our crates only) for coverage" +run = "cargo build -p int-wasm-cov-wrapper" + [tasks.build-ws-data1-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the data1 workflow WASM module" dir = "services/ws-modules/data1" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-comm1-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the comm1 workflow WASM module" dir = "services/ws-modules/comm1" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-sensor1-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the sensor1 workflow WASM module" dir = "services/ws-modules/sensor1" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-audio1-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the audio1 workflow WASM module" dir = "services/ws-modules/audio1" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-video1-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the video1 workflow WASM module" dir = "services/ws-modules/video1" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-bluetooth-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the bluetooth workflow WASM module" dir = "services/ws-modules/bluetooth" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-geolocation-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the geolocation workflow WASM module" dir = "services/ws-modules/geolocation" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-graphics-info-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the graphics info workflow WASM module" dir = "services/ws-modules/graphics-info" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-speech-recognition-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the speech recognition workflow WASM module" dir = "services/ws-modules/speech-recognition" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-nfc-module] +depends = ["build-wasm-cov-wrapper"] description = "Build the nfc workflow WASM module" dir = "services/ws-modules/nfc" -run = "wasm-pack build . --target web {{ vars.no_opt }}" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" [tasks.build-ws-wasi-data1-module] depends = ["build-et-cli"] @@ -68,7 +88,7 @@ description = "Build the Rust WASI data1 module as a WASI Preview 2 component" # .wasm artifact lands in the workspace-shared `target/` and is then # copied into the module's pkg/ for et-modules-service to serve. run = """ -cargo build --release -p et-ws-wasi-data1 --target wasm32-wasip2 +{{ vars.wasm_cov }} cargo build --release -p et-ws-wasi-data1 --target wasm32-wasip2{{ vars.wasi_cov_feat }} mkdir -p services/ws-modules/wasi-data1/pkg cp target/wasm32-wasip2/release/et_ws_wasi_data1.wasm services/ws-modules/wasi-data1/pkg/et_ws_wasi_data1.wasm {{ vars.et_cli }} module-package-json --module-dir services/ws-modules/wasi-data1 @@ -79,7 +99,7 @@ shell = "bash -euo pipefail -c" depends = ["build-et-cli"] description = "Build the Rust WASI comm1 module as a WASI Preview 2 component" run = """ -cargo build --release -p et-ws-wasi-comm1 --target wasm32-wasip2 +{{ vars.wasm_cov }} cargo build --release -p et-ws-wasi-comm1 --target wasm32-wasip2{{ vars.wasi_cov_feat }} mkdir -p services/ws-modules/wasi-comm1/pkg cp target/wasm32-wasip2/release/et_ws_wasi_comm1.wasm services/ws-modules/wasi-comm1/pkg/et_ws_wasi_comm1.wasm {{ vars.et_cli }} module-package-json --module-dir services/ws-modules/wasi-comm1 diff --git a/.mise/config.toml b/.mise/config.toml index c35cd2d5..46664993 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -64,6 +64,7 @@ ast-grep = "latest" cargo-binstall = "latest" "cargo:cargo-expand" = { version = "latest", os = ["linux", "macos"] } "cargo:wasm-opt" = { version = "latest", os = ["linux", "macos"] } +"github:nextest-rs/nextest" = "latest" taplo = "latest" watchexec = "latest" # Chromedriver disabled (commented out below). @@ -151,10 +152,17 @@ components = "clippy,rust-analyzer" os = ["linux", "macos"] profile = "minimal" targets = "wasm32-unknown-unknown,wasm32-wasip2" -version = "latest" +# Use `stable`, not `latest`, so every lane resolves through the same rustup channel. +# mise's `latest` picks the max of rust-lang/rust's GitHub Releases, which are published by separate +# automation that lags the `stable` rustup channel the Windows [[tools.rust]] entries resolve through. That +# skew once shipped a newer clippy (with a new restriction lint) to Windows than Linux/macOS, breaking only +# that lane. +version = "stable" [[tools.rust]] -components = "rust-src,rustfmt" +# llvm-tools ships the version-matched llc + llvm-cov + llvm-profdata the wasm-coverage pipeline drives. +# The instrumented guests build on nightly for -Zno-profiler-runtime, so the matching LLVM tools are nightly's. +components = "rust-src,rustfmt,llvm-tools" os = ["linux", "macos"] profile = "minimal" targets = "wasm32-unknown-unknown,wasm32-wasip2" @@ -274,6 +282,11 @@ conda_openssl = "{{ vars.a_home }}/.local/share/mise/installs/conda-openssl/3" # like `D:\a\core\core/target/...` become `D:acorecore/target/...`). Forward slashes Just Work on both # shells. Used as the root of every workspace-relative var below. config_root_fwd = '{{ config_root | replace(from="\\", to="/") }}' +# Google dart-archive base URLs for config.dart.toml's `[tools.dart]` url/version_list_url. +# They live here (always-loaded), not in the dart config, because mise resolves tool URLs during +# `mise upgrade`/`install` without that env config's own [vars] in scope -- the same reason augeas_* assets are here. +dart_bucket = "https://storage.googleapis.com/storage/v1/b/dart-archive/o" +dart_release = "https://storage.googleapis.com/dart-archive/channels/stable/release" # Absolute path to the workspace-built et-cli binary. # The parallel build-ws-*-module tasks each invoke `et-cli module-package-json`; using this var (rather than # `cargo run -p et-cli`) means every invocation execs the same pre-built binary instead of re-entering cargo, @@ -284,6 +297,10 @@ config_root_fwd = '{{ config_root | replace(from="\\", to="/") }}' # on it. `.exe` suffix on Windows matches cargo's output naming. a_et_cli_exe = "et-cli{% if os() == 'windows' %}.exe{% endif %}" a_et_cli_target_dir = "target{% if os() == 'windows' %}/x86_64-pc-windows-gnullvm{% endif %}/debug" +# RUSTC_WORKSPACE_WRAPPER assignment for the coverage build; leaf var referenced by web_cov_wrapper below. +# `a_` prefix so mise's alphabetical [vars] render defines it before web_cov_wrapper uses it. Split out from +# web_cov_wrapper (rather than inlined) to keep that line under the 120-char limit. +a_web_cov_wrapper_env = 'RUSTC_WORKSPACE_WRAPPER={{ config_root }}/target/debug/int-wasm-cov-wrapper ' et_cli = "{{ vars.config_root_fwd }}/{{ vars.a_et_cli_target_dir }}/{{ vars.a_et_cli_exe }}" # mise-managed CPython (Linux/macOS); the PYO3_PYTHON default below. # Windows overrides PYO3_PYTHON with its own py3_win in config.windows.toml. The version @@ -297,6 +314,25 @@ rpath_flag = "-C link-arg=-Wl,-rpath,{{ vars.conda_openssl }}/lib" # `python` pin), since the minor-version alias dir isn't created everywhere. pylib_flag = "-C link-arg=-Wl,-rpath,{{ vars.a_home }}/.local/share/mise/installs/python/3.13.14/lib" wasm_rustflags = "-C target-cpu=mvp -C target-feature=+mutable-globals,+sign-ext,+nontrapping-fptoint,+reference-types" +# RUSTFLAGS for the wasm coverage builds: instrument + emit LLVM IR, no profiler runtime (minicov provides it). +# Single codegen unit + LTO off so each crate emits one .ll the wasm-cov task can gut and feed to llc/llvm-cov. +wasm_cov_rustflags = "--emit=llvm-ir -Cinstrument-coverage -Ccodegen-units=1 -Clto=off -Zno-profiler-runtime" +# Coverage fragments the wasm module build commands append unconditionally (empty unless ET_TEST_COVERAGE is set). +# wasm_cov is an inline RUSTFLAGS env prefix; the nightly toolchain + conda clang come from the coverage [env]. +# The *_feat vars turn on minicov: cargo `--features` for the WASI cdylibs, wasm-pack `-- --features` for browsers +# (comm1 pulls et-web only under this feature). +wasi_cov_feat = '{% if env?.ET_TEST_COVERAGE == "true" %} --features coverage{% endif %}' +wasm_cov = '{% if env?.ET_TEST_COVERAGE == "true" %}RUSTFLAGS="{{ vars.wasm_cov_rustflags }}"{% endif %}' +web_cov_feat = '{% if env?.ET_TEST_COVERAGE == "true" %} -- --features et-web/coverage{% endif %}' +# Command prefix routing a browser module's wasm-pack build through the workspace-only coverage wrapper. +# Instruments our crates, never dependency cdylibs. Empty unless ET_TEST_COVERAGE is set, so normal builds get +# no wrapper (no fingerprint churn). The wrapper binary is built by the build-wasm-cov-wrapper task dependency. +web_cov_wrapper = '{% if env?.ET_TEST_COVERAGE == "true" %}{{ vars.a_web_cov_wrapper_env }}{% endif %}' +# Override-free head of a browser module's wasm-pack build: coverage wrapper prefix plus fixed build args. +# Only the two modules that then run et-cli module-package-json (har1, face-detection) use it, so their +# single-line `&&` run stays under 120. It excludes no_opt / web_cov_feat -- those are appended inline at the +# call site because no_opt carries a per-platform override that a nested var would freeze at this file's value. +web_pack_cov = '{{ vars.web_cov_wrapper }}wasm-pack build . --target web' # Extra flag for the wasm-pack module builds; empty so wasm-opt runs. # config.windows.toml overrides it to --no-opt where wasm-opt can't execute. no_opt = "" @@ -395,8 +431,10 @@ OPENSSL_DIR = "{{ vars.conda_openssl }}" # Always-loaded so `cargo check --workspace` builds the pyo3 runner without MISE_ENV=python. This is the # Linux/macOS path; config.windows.toml overrides it with py3_win. PYO3_PYTHON = "{{ vars.py3_unix }}" -RCLONE_CONFIG = "{{ config_root }}/config/rclone.conf" +RCLONE_CONFIG = "{{ config_root }}/config/rclone.ini" RCLONE_RETRIES = "1" +# Make a bare `rg` search the tracked hidden dirs (esp. `.mise/`) instead of skipping them. See the config file. +RIPGREP_CONFIG_PATH = "{{ config_root }}/config/ripgrep.rc" # Scope to the wasm32 target only. # The env value is honoured by every `wasm-pack`/`cargo` invocation that targets wasm32-unknown-unknown but @@ -468,7 +506,7 @@ depends = [ "cargo-doc-check", "cargo-fmt-check", "conftest-check", - "docker-check", + "dockerignore-check", "dprint-check", "editorconfig-check", "gen-help-all", @@ -477,6 +515,7 @@ depends = [ "hadolint-check", "link-check", "ls-lint-check", + "readme-mise-version-check", "regal-check", "ryl-check", "semgrep-check", @@ -650,7 +689,7 @@ shell = "bash -euo pipefail -c" [tasks.conftest-check-dockerfile] description = "Cross-check Dockerfiles against mise [tools] pins + heredoc body-first-line invariants" -# Two passes over each Dockerfile: +# Two passes over each Dockerfile. # 1. `dockerfile` parser (default) for AST-shape rules: version drift, pipx-disable, RUN heredoc must invoke bash. # Combined with .mise so the AST rules can compare against [tools] pins. # 2. `ignore` parser, which preserves every line of the file as a separate entry -- conftest's dockerfile parser @@ -770,6 +809,28 @@ taplo lint --schema "file:///${PWD#/}/config/taplo/mise-cargo-backend-allowlist. """ shell = "bash -euo pipefail -c" +[tasks.readme-mise-version-check] +# Keep the README's stated minimum mise version in sync with the enforced `min_version` in .mise/config.toml. +# No structural linter spans a markdown prose value and a TOML value, so this is a small goawk/coreutils compare. +description = "Fail if README.md's mise version differs from min_version in .mise/config.toml" +run = """ +# README.md is excluded from the docker build context, so it is absent when `check` runs inside an image. +# gen:dockerignore adds /README.md. Skip there -- the native check lanes (which have README.md) enforce the sync. +if [ ! -f README.md ]; then + echo "readme-mise-version-check: README.md absent (docker build context); skipping -- native lanes enforce it" + exit 0 +fi +config_version="$(goawk -F'"' '/^min_version/{print $2; exit}' .mise/config.toml)" +# shellcheck disable=SC2016 # $0/RSTART/RLENGTH are awk fields, not shell vars -- single quotes are intended +readme_prog='/ or later/ && match($0,/[0-9]{4}\\.[0-9]+\\.[0-9]+/){print substr($0,RSTART,RLENGTH);exit}' +readme_version="$(goawk "$readme_prog" README.md)" +if [ "$config_version" != "$readme_version" ]; then + echo "README mise version ($readme_version) != .mise/config.toml min_version ($config_version)" >&2 + exit 1 +fi +""" +shell = "bash -euo pipefail -c" + [tasks.typos-check] alias = "typos" run = "typos --config config/typos.toml" @@ -925,7 +986,7 @@ run = ''' ''' shell = "bash -euo pipefail -c" -[tasks.docker-check] +[tasks.dockerignore-check] depends = ["gen:dockerignore"] description = "Fail if .dockerignore is stale vs .gitignore (run `mise run gen:dockerignore`)" run = "git diff --exit-code -- .dockerignore" @@ -1173,29 +1234,9 @@ shell = "bash -euo pipefail -c" [tasks.download-models] depends = ["fetch-face1-rclone", "fetch-har-motion1-rclone", "fetch-mnist-rclone"] -[tasks.cargo-test-other] -# Excludes `et-ws-web-runner`, whose tests need single-threaded execution. -# See `test-ws-web-runner` below; running the rest of the workspace in parallel keeps the total -# wall-clock down. Split out from `cargo-test` (which aggregates both halves) so CI can run each phase as -# its own step -- helps isolate which phase consumes which disk / memory budget on tight runners. -run = "cargo test --workspace --exclude et-ws-web-runner" - -[tasks.test-ws-web-runner] -description = "Run et-ws-web-runner integration tests serialized" -# Each test case boots a full `MainWorker` (deno_runtime). -# That is ~10x heavier than the `JsRuntime` it replaced. 8 in parallel exhausts local fd/port budgets on -# macOS, surfacing as "error sending request for url" when the runner's wrapper module dynamic-`import()`s -# its module JS. Pin to one thread so the suite is deterministic. -env = { RUST_TEST_THREADS = "1" } -run = "cargo test -p et-ws-web-runner" - [tasks.cargo-test] -# Composite of two halves. -# cargo-test-other (workspace minus et-ws-web-runner) + test-ws-web-runner (serialized). Local devs / -# hosts where disk and memory aren't tight invoke this single task; CI splits the two halves into -# separate workflow steps for debuggability. -depends = ["cargo-test-other", "test-ws-web-runner"] -description = "Run every Rust test (cargo-test-other + test-ws-web-runner)" +description = "Run every Rust test via nextest (web-runner excluded on gnullvm)" +run = "cargo nextest run --workspace {{ vars.cargo_ws_excludes }} --config-file config/nextest.toml" [tasks."test:rust"] # Rust tests. Always-loaded, so `test`'s glob always matches it. diff --git a/.mise/config.windows.toml b/.mise/config.windows.toml index 974db927..987a9b1e 100644 --- a/.mise/config.windows.toml +++ b/.mise/config.windows.toml @@ -150,8 +150,8 @@ tsgolint_pkg = '{{ vars.mise_installs }}\npm-oxlint-tsgolint\0.24.0\node_modules # So the module + wasm-agent builds pass --no-opt; they reference {{ vars.no_opt }}. no_opt = "--no-opt" # et-ws-web-runner's rusty_v8 (deno -> v8) gnullvm prebuilt is broken, so exclude it on Windows. -# The build-based cargo checks (check/clippy/doc) skip it here, matching cargo test's --exclude and the -# skipped test-ws-web-runner task. +# The build-based cargo checks (check/clippy/doc) and the merged cargo-test (nextest) all splice this var, so the +# crate is dropped from the build on gnullvm; config.mingw/msvc clear the var to build + run it against msvc v8. cargo_ws_excludes = "--exclude et-ws-web-runner" # Disable actionlint's shellcheck integration on Windows (user-approved 2026-07-08). # actionlint deadlocks here whenever a workflow `run:` script exceeds the ~4KB anonymous-pipe buffer. Its @@ -338,14 +338,6 @@ shell = "bash -xeuo pipefail -c" # CI failures on Nano (rclone-not-found, sha256 path mangling, etc.) are easier to diagnose with the full # per-command transcript than with the silent default. -[tasks.test-ws-web-runner] -# Override: skip et-ws-web-runner on Windows. -# It pulls deno -> v8, whose build script downloads a prebuilt rusty_v8 static lib; denoland/rusty_v8 ships -# Windows prebuilts only for x86_64-pc-windows-msvc, not the gnullvm target this build uses, so the fetch -# 404s and the from-source fallback can't build under llvm-mingw. The suite still runs on the Linux/macOS jobs. -run = "echo 'skipping et-ws-web-runner on windows: rusty_v8 has no gnullvm prebuilt'" -shell = "bash -euo pipefail -c" - [tasks.action-validator-check] # Broken by latest mise. run = "true" diff --git a/.mise/config.zig.toml b/.mise/config.zig.toml index 95eeea31..2a7ef2bd 100644 --- a/.mise/config.zig.toml +++ b/.mise/config.zig.toml @@ -10,13 +10,41 @@ # C tooling for the zig-data1 module's hand-written C. # The C sources live under services/ws-modules/zig-data1/src/*.c. Two conda packages because mise hands the # `clang-format` name to conda:clang-format, so conda:clang-tools omits it and only supplies clang-tidy. Both -# are conda-forge's native LLVM binaries. cpplint (Google C++ style) has no prebuilt binary -- pipx is its -# only distribution. +# are conda-forge's native LLVM binaries. cpplint (Google C++ style) and flawfinder (C/C++ security scanner) +# have no prebuilt binary -- pipx is their only distribution. "conda:clang-format" = "latest" "conda:clang-tools" = "latest" "pipx:cpplint" = "latest" +"pipx:flawfinder" = "latest" zig = "latest" +# llvm-mingw provides a clang-tidy that can parse the Windows-only mingw-shim (windows.h + MSVC CRT identifiers). +# This entry MUST keep a windows platform and MUST NOT carry an `os = ["linux", "macos"]` scope: MISE_ENV +# configs load after config.windows.toml, so an os-scoped entry here overrides the Windows [tools] entry and +# filters llvm-mingw out of the Windows toolset PATH. cc-rs builds then fall through to the GHA runner's +# system clang (MSVC-default, no mingw sysroot) and every C-compiling build script dies with, e.g.: +# aws-lc-sys@0.41.0: ...\aws-lc\tests\compiler_features_tests\c11.c:7:10: fatal error: 'stdlib.h' file not found +# which took down every Windows job on commit 60b631d4dea8d19f50386f1568bf6128c956795c at +# https://github.com/edge-toolkit/core/actions/runs/28995222108/job/86043285466 (check windows-latest lane). +# The windows-x64 matching below mirrors the config.windows.toml entry -- keep them identical. +[tools."github:mstorsjo/llvm-mingw"] +version = "20260602" + +[tools."github:mstorsjo/llvm-mingw".platforms.linux-arm64] +matching = "ubuntu-22.04-aarch64" + +[tools."github:mstorsjo/llvm-mingw".platforms.linux-x64] +matching = "ubuntu-22.04-x86_64" + +[tools."github:mstorsjo/llvm-mingw".platforms.macos-arm64] +matching = "macos-universal" + +[tools."github:mstorsjo/llvm-mingw".platforms.macos-x64] +matching = "macos-universal" + +[tools."github:mstorsjo/llvm-mingw".platforms.windows-x64] +matching = "ucrt-x86_64" + [tasks.zig-check] run = "zig fmt --check services/ws-modules/ generated/zig-rest/" @@ -35,36 +63,55 @@ run = "git ls-files '*.c' '*.h' | xargs clang-format --dry-run --Werror --style= description = "Lint C sources (clang-tidy)" # Flags after `--` are the compile args. # clang_resource_arg points clang at its builtin headers (set per host -- see config.linux.toml). {{ }} renders -# to empty where unset, leaving a bare `--`. -# services/ws-web-runner/mingw-shim is excluded from the two clang-tidy tasks only. -# clang-tidy compiles its input, and those Windows-only sources (windows.h, MSVC CRT identifiers) cannot -# compile on Linux. +# to empty where unset, leaving a bare `--`. The host clang-tidy can't parse the Windows-only mingw-shim +# (windows.h, MSVC CRT), so it gets a second pass with llvm-mingw's clang-tidy cross-targeting windows; +# `mise where` locates that install on any host (config.windows.toml on Windows, config.zig.toml on Linux/macOS). run = """ git ls-files '*.c' '*.h' ':!services/ws-web-runner/mingw-shim' | xargs -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} +llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" +git ls-files 'services/ws-web-runner/mingw-shim/*.c' | + xargs -I{} "$llvm_ct" --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 """ shell = "bash -euo pipefail -c" [tasks.clang-tidy-fix] description = "Apply clang-tidy's machine-applicable fix-its to C sources in place" +# Mirrors clang-tidy-check's two passes: host clang-tidy for portable C, llvm-mingw's for the windows mingw-shim. run = """ git ls-files '*.c' '*.h' ':!services/ws-web-runner/mingw-shim' | xargs -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} +llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" +git ls-files 'services/ws-web-runner/mingw-shim/*.c' | + xargs -I{} "$llvm_ct" --fix --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 """ shell = "bash -euo pipefail -c" [tasks.cpplint-check] description = "Lint C sources for Google C++ style (cpplint)" +# cpplint and clang-tidy share the `// NOLINT` syntax, so cpplint sees the shim's clang-tidy-named NOLINTs. +# -readability/nolint stops it erroring on those (bugprone-*, clang-diagnostic-*) as unknown cpplint categories. +run = """ +git ls-files '*.c' '*.h' | + xargs cpplint --quiet --linelength=120 --filter=-legal/copyright,-build/include_subdir,-readability/nolint +""" +shell = "bash -euo pipefail -c" + +[tasks.flawfinder-check] +description = "Scan C sources for risky C APIs (flawfinder security lint)" +# flawfinder is a lexical scanner (no compile), so unlike clang-tidy it also covers the mingw-shim sources. +# Gate on risk level >= 2; the blanket level-1 notes (e.g. every strlen) are too noisy to fail CI on. A +# reviewed-safe hit at or above the gate is silenced with an inline `flawfinder: ignore` comment stating why. run = """ git ls-files '*.c' '*.h' | - xargs cpplint --quiet --linelength=120 --filter=-legal/copyright,-build/include_subdir + xargs flawfinder --error-level=2 --minlevel=2 --quiet --dataonly """ shell = "bash -euo pipefail -c" # Namespaced aggregators picked up by the default config's globbed `check`/`fmt`. [tasks."check:zig"] -depends = ["clang-format-check", "clang-tidy-check", "cpplint-check", "zig-check"] -description = "Run Zig + C checks (zig fmt-check, clang-format, clang-tidy, cpplint)" +depends = ["clang-format-check", "clang-tidy-check", "cpplint-check", "flawfinder-check", "zig-check"] +description = "Run Zig + C checks (zig fmt-check, clang-format, clang-tidy, cpplint, flawfinder)" [tasks."fmt:zig"] depends = ["clang-format", "zig-fmt"] diff --git a/CLAUDE.md b/CLAUDE.md index d3fb270f..50ebca7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -441,8 +441,8 @@ If you believe a skip is genuinely warranted, stop and ask the user; do not add ### Known intermittent CI failure: pyo3-runner torch registration timeout -`et-ws-pyo3-runner`'s `module_behaves::case_5_torch` intermittently fails on test.yaml's Windows and macOS lanes -with the literal failure line +`et-ws-pyo3-runner`'s `module_behaves::case_5_torch` intermittently fails on the Linux, Windows, and macOS lanes +of test.yaml and build.yaml with the literal failure line Error: "runner never registered" @@ -458,7 +458,16 @@ merged), and on the `default (macos-latest, 45)` job -- same signature, unix-for `torch/lib/python3.13/site-packages/torch/_subclasses/functional_tensor.py:362` warning path -- on commit `f2a85307a2415f4a50625627795e02c84f1c8e5d` at `https://github.com/edge-toolkit/core/actions/runs/28865327221/job/85613919558` (PR #73), 20.68s into the test -run. If this signature recurs, stop rerunning and fix the root cause: raise (or make torch-case-specific) the +run. Recurred at commit `1e323acb7fc433b66efed904b3b30ab3216dbf90` (PR #76) on three lanes of one run at once -- +the first Linux sighting, and the first time it took more than a single lane of a commit: build.yaml's +`build (ubuntu:22.04)` (~15.76s) at +`https://github.com/edge-toolkit/core/actions/runs/29034248895/job/86174973520`, and test.yaml's `override (mingw)` +(~18.13s) and `override (msvc)` (~18.96s) at +`https://github.com/edge-toolkit/core/actions/runs/29034248854/job/86174895528` and +`https://github.com/edge-toolkit/core/actions/runs/29034248854/job/86174895194`. Three simultaneous same-commit +failures read as the cold torch import consistently overrunning the timeout rather than an occasional flake -- so +the root-cause fix below is now due, not optional. If this signature recurs, stop rerunning and fix the root cause: +raise (or make torch-case-specific) the runner-registration timeout in the pyo3-runner module tests, or warm the torch import before the registration clock starts. @@ -482,6 +491,29 @@ installed cleanly). This is an api.github.com rate-limit/transient-network flake durable fix is to mirror the affected assets via the upstream-cache pattern (which fetches from our own release CDN, off the api.github.com attestation path) rather than adding a retry wrapper. +### Known intermittent CI failure: vector_otlp_relay store-and-forward timing + +`et-ws-wasi-runner`'s `vector_otlp_relay::vector_relays_buffered_otlp_after_backend_comes_online` intermittently +fails with the literal panic line + + mock never received the relayed `relay-probe` span -- store-and-forward failed + +from `services/ws-wasi-runner/tests/vector_otlp_relay.rs`. The test pushes one OTLP trace into Vector while the +sink target is dead, brings a mock collector up on the sink port, then polls the mock for the redelivered span. +The flake is a redelivery-latency race, not a store-and-forward defect: Vector retries the dead sink with an +exponential backoff (`retry_initial_backoff_secs=1`, doubling; `retry_max_duration_secs=300` in +`config/vector-otlp-relay.yaml`), so when its first attempts land in the gap between the mock being told to start +and its listener actually accepting, the next retry can be tens of seconds out -- and at the original 30s poll +ceiling that occasionally overran, so the span arrived just after the test gave up. Observed on the `default +(windows-latest)` lane at commit `71e70e56946abefcea6daf47a7745e5f8f186dad`, +`https://github.com/edge-toolkit/core/actions/runs/28923326008/job/85829470834` (the failure is OS-agnostic -- +any cold/contended runner, macOS included, can hit it). Fix applied: `wait_for_relayed_span` now polls ~120s +(`Fixed::from_millis(250).take(480)`) instead of ~30s, which the poll exits the instant the span lands so the +happy path is unaffected. If this signature recurs at the wider ceiling, the redelivery is being delayed past +120s -- root-cause it rather than widening again: cap Vector's retry backoff interval (or make +`int_otlp_mock::start_on` block until its listener is truly accepting so Vector's early retries don't grow the +backoff), don't just bump the poll. + ## Workarounds When you can't (or shouldn't) fix the root cause right now -- a libc race in an upstream dep, a flaky platform driver, @@ -763,13 +795,53 @@ standalone file or a mise task `run`. The available linters: `{% ... %}` -> empty), and shellchecks the lot. This is how shell-quality lints reach mise task bodies (shellcheck itself doesn't read TOML). - plus hadolint, ls-lint (file/dir naming), zizmor (Actions security), ryl - (YAML), lychee (links), clang-format / clang-tidy / cpplint (C, in the zig config), editorconfig-checker, typos, and - action-validator for their domains. + (YAML), lychee (links), clang-format / clang-tidy / cpplint / flawfinder (C, in the zig config), + editorconfig-checker, typos, and action-validator for their domains. ast-grep has no TOML grammar, so it **cannot** lint TOML -- use a taplo schema or a semgrep `generic` rule there. If none of the above can express a check, propose adding a new mise-installable linter rather than scripting it by hand. +### Maximise linter coverage; suppress only narrowly, with justification + +This project aims for an extremely high DevSecOps bar, and the governing goal is **maximum linter rule coverage** -- +across Rust, every guest language, the configs, Dockerfiles, GitHub Actions, and the external services (DeepSource, +Codacy, ...). The default is always to enable more rules and more linters, never fewer; a rule that catches a real +class of defect is worth the friction. + +Because of that, **never turn a lint off to make code pass, and never avoid a security-related lint.** Do not disable +a rule, switch off a whole analyzer or rule class (e.g. an analyzer's `misra_compliance`-style toggle), exclude a +whole file or directory from analysis, or otherwise silence findings wholesale -- each trades real coverage for a +green check, and silencing a security lint is never acceptable. + +When a finding is a genuine false positive or an unavoidable, reviewed-safe necessity, suppress it at the **narrowest +possible scope** -- one line, one rule -- with a justification stating _why_ it is safe or false. In order of +preference: + +1. **Fix the code** so the finding no longer fires -- the only right answer for anything actionable. +2. **Inline, per-finding suppression carrying a reason** -- `#[expect(..., reason = "...")]`, `// skipcq: `, + `// flawfinder: ignore [why]`, a `nosemgrep: ` note, etc. -- scoped to the single line and single rule, + never a bare blanket ignore that also hides future (possibly real) findings on that line. +3. Only if neither works, a tightly-scoped path-or-rule carve-out in the linter config, commented with the exact + reason -- and this needs operator sign-off (see the rule-deletion note below). + +Excluding a directory or disabling a rule class is the last resort, not the first. Treat any new blanket exclusion or +disabled security lint in a diff as a red flag to push back on. + +When step 3 is genuinely forced -- the analyzer offers no per-line or per-pattern suppression (some external services, +e.g. Codacy, only support path excludes) and the finding is a reviewed-safe necessity that cannot be fixed -- the +exclusion must still be as **narrow as the file layout allows**. Do not exclude a whole directory (or a file that also +holds analyzable code) to silence one un-suppressible construct: first refactor the code so the un-suppressible part is +**isolated into its own smallest-possible file**, then exclude only that file. Everything factored out of it stays +analyzed. The `services/ws-web-runner/mingw-shim/msvc_crt_alloc.c` split is the worked example -- the shim's only +MISRA-21.3-unavoidable heap-allocation code (operator new/delete + `_dupenv_s`) was pulled out of +`msvc_crt_shim.c` / `msvc_crt_locale.c` into that one file so Codacy's path exclude covers just it, leaving the rest of +the shim under full analysis (and even the excluded file stays covered by DeepSource's clang-tidy plus the repo's own +clang-tidy / cpplint / flawfinder). Record the exclusion rationale once, in the **excluded file's own header comment** +-- that is its canonical home, because more than one analyzer config may exclude the same file and the reasoning must +not be copied into each. Every config's exclude entry carries at most a one-line pointer back to the file, never a +second copy of the rationale. + ### When you spot a style or consistency issue, write a rule If a code-review comment, a fix-up commit, or a CLAUDE.md paragraph would tell the next contributor "don't do X" diff --git a/Cargo.lock b/Cargo.lock index a9a24b4b..8fa168c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -4245,6 +4255,7 @@ name = "et-web" version = "0.1.0" dependencies = [ "js-sys", + "minicov", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -4291,6 +4302,7 @@ name = "et-ws-comm1" version = "0.1.0" dependencies = [ "edge-toolkit", + "et-web", "et-ws-wasm-agent", "js-sys", "serde", @@ -4419,6 +4431,7 @@ dependencies = [ name = "et-ws-pyo3-runner" version = "0.1.0" dependencies = [ + "backon", "base64 0.22.1", "edge-toolkit", "et-rest-client", @@ -4601,6 +4614,8 @@ name = "et-ws-wasi-comm1" version = "0.1.0" dependencies = [ "et-path", + "fs-err", + "minicov", "serde_json", "wit-bindgen", ] @@ -4610,6 +4625,8 @@ name = "et-ws-wasi-data1" version = "0.1.0" dependencies = [ "et-path", + "fs-err", + "minicov", "serde_json", "wit-bindgen", ] @@ -4626,6 +4643,7 @@ dependencies = [ "et-test-helpers", "et-ws-runner-common", "et-ws-test-server", + "fs-err", "futures-util", "int-otlp-mock", "libc", @@ -4668,6 +4686,7 @@ dependencies = [ "tracing", "tracing-wasm", "wasm-bindgen", + "wasm-bindgen-futures", "wasm-bindgen-test", "web-sys", ] @@ -6061,6 +6080,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "int-wasm-cov-wrapper" +version = "0.1.0" + [[package]] name = "inventory" version = "0.3.24" diff --git a/Cargo.toml b/Cargo.toml index 7a71c06b..faab554e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ members = [ "utilities/int-gen", "utilities/cli", "utilities/onnx", + "utilities/wasm-cov-wrapper", "generated/rust-rest", ] resolver = "2" @@ -52,6 +53,7 @@ actix-web-thiserror = "0.2" actix-ws = "0.3" async-trait = "0.1" asyncapi-rust = "0.2" +backon = { version = "1.6", default-features = false } base64 = "0.22.1" bytemuck = { version = "1.16", features = ["derive"] } bytes = "1" @@ -93,6 +95,7 @@ kdl = { version = "6", features = ["v1"] } libc = "0.2" local-ip-address = "0.6" log = "0.4" +minicov = "0.3" onnx-extractor = "0.3" openapiv3 = "2" opentelemetry = "0.31" @@ -413,6 +416,9 @@ ignore = [ "utf8parse", # 517 days since last update at daxpedda/web-time. "web-time", + # Not in declared repo (gfx-rs/wgpu): a per-platform helper crate from the wgpu monorepo. + # cargo-unmaintained can't find the individual member at the repo root -- false positive; pulled by wgpu-core. + "wgpu-core-deps-windows-linux-android", # Declared repository WebAssembly/WASI is spec-only (no `crates/` tree). # The crate is effectively orphaned upstream. "witx", diff --git a/Dockerfile b/Dockerfile index 314b704b..631ad06c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -340,7 +340,7 @@ CMD ["et-ws-server"] # as `--build-context extras=target/check-ctx`. `COPY --from=extras . ./` # lands .git/ + Dockerfile* in the workspace. With .git/ on disk the git- # using checks (action-validator, hadolint via `git ls-files`, -# conftest-check-toml, docker-check, gen-specs-check, verification-check) +# conftest-check-toml, dockerignore-check, gen-specs-check, verification-check) # resolve normally. This stage exists only so docker-linux.yaml can run the # full `mise run check` against each matrix base. Being last in this file # means `docker build` with no `--target` builds this stage by default -- diff --git a/Dockerfile.nanoserver b/Dockerfile.nanoserver index b9a21b65..d9451fc6 100644 --- a/Dockerfile.nanoserver +++ b/Dockerfile.nanoserver @@ -317,14 +317,10 @@ RUN (if exist C:\token\gh_token set /p GITHUB_TOKEN=). # Bug-finding analysis only; opinionated readability/style checks are left off to avoid churn on the small C surface. -Checks: "clang-analyzer-*,bugprone-*,performance-*,portability-*" +# cppcoreguidelines-avoid-non-const-global-variables is pulled in specifically (not the whole cppcoreguidelines set) +# to mirror DeepSource's cxx CXX-W2009, so the mingw-shim's ABI globals are caught here too, not only on DeepSource. +Checks: "clang-analyzer-*,bugprone-*,performance-*,portability-*,cppcoreguidelines-avoid-non-const-global-variables" WarningsAsErrors: "*" diff --git a/config/conftest/policy/dockerfile/dockerfile.rego b/config/conftest/policy/dockerfile/dockerfile.rego index a6fe8eb2..c1b3616f 100644 --- a/config/conftest/policy/dockerfile/dockerfile.rego +++ b/config/conftest/policy/dockerfile/dockerfile.rego @@ -3,8 +3,9 @@ # rules: # 1. version drift -- the Dockerfile hard-codes mise install-dir paths (LLVMBIN, the busybox shell, the python dir # on PATH) that embed a tool's pinned version; those must match the [tools] pins (reuses mise.rego's matcher). -# 2. MISE_DISABLE_TOOLS -- every pipx: tool in the always-loaded config.toml must be disabled here (pipx can't run -# on Nano Server), so a newly added pipx tool can't silently break the Windows build. +# 2. MISE_DISABLE_TOOLS -- every pipx: tool in the always-loaded config.toml and in each guest config Nano enables +# (its ENV MISE_ENV) must be disabled here (pipx can't run on Nano Server), so a newly added pipx tool in any of +# those can't silently break the Windows build. package dockerfile import data.mise @@ -47,7 +48,37 @@ disabled_tools contains tool if { tool := trim_space(token) } -# Every pipx:* tool in the always-loaded config.toml must be in Dockerfile.nanoserver's MISE_DISABLE_TOOLS. +# The guest languages Nano Server enables, read from its `ENV MISE_ENV=`. +# Only the always-loaded config.toml and these guest configs load on Nano, so only their pipx tools can run there. +# The dockerfile parser stores an ENV's key and value as adjacent Value elements (["MISE_ENV", "dart,java,..."]), +# so find the "MISE_ENV" key and split the element that follows it. +nano_langs contains lang if { + some file in input + is_array(file.contents) + some instr in file.contents + instr.Cmd == "env" + some i + instr.Value[i] == "MISE_ENV" + some lang in split(instr.Value[i + 1], ",") +} + +# The pipx tools Nano can try to install come only from config.toml and its enabled guest configs. +# Collect the always-loaded config.toml plus each config..toml whose lang is in MISE_ENV. +pipx_scope_path contains path if { + some file in input + endswith(file.path, ".mise/config.toml") + path := file.path +} + +pipx_scope_path contains path if { + some file in input + some lang in nano_langs + endswith(file.path, sprintf(".mise/config.%s.toml", [lang])) + path := file.path +} + +# Every pipx:* tool in an in-scope config must be in Dockerfile.nanoserver's MISE_DISABLE_TOOLS. +# In-scope means the always-loaded config.toml plus each guest config Nano enables. # pipx:* tools can't run on Nano Server (no CPython, and the rustpython-based pipx bootstrap in config.windows.toml's # preinstall is currently broken with STATUS_DLL_NOT_FOUND / STATUS_ENTRYPOINT_NOT_FOUND on Nano's stripped API set, # hence the ENABLE_RUSTPYTHON_PIPX_BOOTSTRAP env gate that defaults off). A previous canary carve-out (pipx:cowsay @@ -55,7 +86,7 @@ disabled_tools contains tool if { # re-introduce when the bootstrap is fixed upstream and re-enabled by default. deny contains msg if { some file in input - endswith(file.path, ".mise/config.toml") + file.path in pipx_scope_path some name, _ in file.contents.tools startswith(name, "pipx:") not disabled_tools[name] diff --git a/config/conftest/policy/gha/gha.rego b/config/conftest/policy/gha/gha.rego index 7539e594..25c76525 100644 --- a/config/conftest/policy/gha/gha.rego +++ b/config/conftest/policy/gha/gha.rego @@ -156,3 +156,16 @@ deny contains msg if { regex.match(`\bapt(\s|$)`, line) msg := sprintf("job %q: use `apt-get`, not `apt` (apt's UI is not stable across releases)", [name]) } + +# Every actions/upload-artifact step must set with.if-no-files-found: error. +# The action defaults to "warn": when the path glob matches nothing it logs a warning and uploads an empty (or no) +# artifact, so a run that produced no report reads as a successful upload and the gap surfaces only far downstream. +# "error" fails the run at upload time instead, catching a step that produced no files immediately. object.get +# supplies the "warn" default so an omitted key -- the common case -- counts as the violation it is. +deny contains msg if { + some name, job in input.jobs + some step in job.steps + startswith(step.uses, "actions/upload-artifact") + object.get(step, ["with", "if-no-files-found"], "warn") != "error" + msg := sprintf("job %q: actions/upload-artifact must set with.if-no-files-found: error", [name]) +} diff --git a/config/conftest/policy/gha_action/gha_action.rego b/config/conftest/policy/gha_action/gha_action.rego index cedcbdd5..f3e2f9ae 100644 --- a/config/conftest/policy/gha_action/gha_action.rego +++ b/config/conftest/policy/gha_action/gha_action.rego @@ -27,3 +27,15 @@ deny contains msg if { is_composite_action if { input.runs.using == "composite" } + +# Every actions/upload-artifact step must set with.if-no-files-found: error. +# Composite-action steps get their own copy of this guard because they live in runs.steps[], which the workflow +# policy's jobs.*.steps[] iteration never reaches; the action's "warn" default silently uploads nothing when a path +# matches no files, so a missing artifact reads as success. "error" fails the run at upload time instead. +deny contains msg if { + is_composite_action + some step in input.runs.steps + startswith(step.uses, "actions/upload-artifact") + object.get(step, ["with", "if-no-files-found"], "warn") != "error" + msg := sprintf("step %q: actions/upload-artifact must set with.if-no-files-found: error", [step.name]) +} diff --git a/config/conftest/policy/mise/mise.rego b/config/conftest/policy/mise/mise.rego index eeecc8dd..98436f4c 100644 --- a/config/conftest/policy/mise/mise.rego +++ b/config/conftest/policy/mise/mise.rego @@ -167,6 +167,14 @@ allowed_os_scoped_tool := { # winlibs mingw-w64 GCC: the toolchain for the x86_64-pc-windows-gnu target (config.mingw.toml). # Upstream ships Windows-only zips, and the env that installs it is itself Windows-only. "github:brechtsanders/winlibs_mingw", + # github:mstorsjo/llvm-mingw: its clang-tidy analyzes the Windows-only mingw-shim C. + # config.zig.toml os-scopes it to linux/macos (the check hosts that lack it); on Windows config.windows.toml's + # own llvm-mingw is reused via auto_env. + "github:mstorsjo/llvm-mingw", + # conda:clang: a wasm-capable LLVM clang for the wasm-coverage builds (minicov's C profiler runtime). + # Scoped to linux/macos in config.coverage.toml -- the coverage workflow runs only there, and Windows + # has no coverage lane. + "conda:clang", } deny contains msg if { diff --git a/config/coverage.toml b/config/coverage.toml new file mode 100644 index 00000000..50a8dbb5 --- /dev/null +++ b/config/coverage.toml @@ -0,0 +1,14 @@ +# coverage.py config for the combined Python report (referenced via COVERAGE_RCFILE by the pytest-cov task). +# Three data sources feed one report: pyface1's pytest run, and the web-runner Pyodide runs of pydata1 + pyface1 +# (whose .coverage files the web-runner integration test drops into target/pycov/). `[tool.coverage.paths]` remaps +# each source's recorded path to the repo-relative one so `coverage combine` merges them into a single file entry: +# pytest records relative paths (pyface1/...); the Pyodide runs record absolute in-interpreter paths +# (/tmp///...), matched by the `**/` alias. First entry in each list is the canonical result and +# must be a real on-disk path so `coverage xml` can read source. + +[tool.coverage.run] +relative_files = true + +[tool.coverage.paths] +pydata1 = ["services/ws-modules/pydata1/pydata1", "pydata1", "**/pydata1"] +pyface1 = ["services/ws-modules/pyface1/pyface1", "pyface1", "**/pyface1"] diff --git a/config/dprint.jsonc b/config/dprint.jsonc index 76af0183..3abb1304 100644 --- a/config/dprint.jsonc +++ b/config/dprint.jsonc @@ -4,20 +4,21 @@ }, "java": {}, "json": {}, - // Match the repo-wide 120 line-length set in .editorconfig and ruff.toml, otherwise dprint's bundled ruff would - // reformat Python files to its default and fight with `mise run ruff-fmt`. + // Match the repo-wide 120 line-length (.editorconfig + ruff.toml) so dprint's bundled ruff agrees with them. + // Otherwise it reformats Python files to its own default line-length and fights with `mise run ruff-fmt`. "ruff": { "lineLength": 120, }, "malva": {}, - // Match the repo-wide 120 line cap; dprint's markdown plugin otherwise defaults to 80 and reformats `js` code - // blocks more aggressively than the typescript plugin would on the same source. + // Match the repo-wide 120 line cap for markdown, which dprint's markdown plugin otherwise sets to 80. + // At that default it also reformats `js` code blocks more aggressively than the typescript plugin would on the + // same source. "markdown": { "lineWidth": 120, }, "markup": {}, - // Match oxfmt's JS/TS style so the two formatters can run on the same - // files without fighting: + // Match oxfmt's JS/TS style so the two formatters can run on the same files without fighting. + // The typescript knobs below each mirror an oxfmt choice: // - arrowFunction.useParentheses=force -> always `(x) =>`, not `x =>` // - memberExpression.linePerExpression -> break chained `.then().catch()` // - binaryExpression.operatorPosition=sameLine -> `&&` / `||` trailing the previous line, not leading the next diff --git a/config/ls-lint.yaml b/config/ls-lint.yaml index 9de6ce4a..c7283325 100644 --- a/config/ls-lint.yaml +++ b/config/ls-lint.yaml @@ -17,6 +17,10 @@ ls: .cs: PascalCase .java: PascalCase + # Ban the `.conf` extension: config files standardize on `.ini`/`.rc`, as rclone.ini and ripgrep.rc do. + # `regex:^$` matches no basename, so every `*.conf` file fails the check. + .conf: regex:^$ + ignore: - .git - target @@ -34,16 +38,16 @@ ignore: - "**/.ruff_cache" - "**/.zig-cache" - "**/zig-out" - # `dart pub get` creates `.dart_tool/`, whose name fails the .dir rule, so it must be ignored. + # These dirs are created mid-run by parallel tasks, so they must be ignored by literal path, not glob. # ls-lint snapshots glob-based ignores once at startup and drops any matching nothing then - # (internal/glob/glob.go deletes zero-match keys), so a `**/.dart_tool` glob loses the race against the parallel - # `dart-pub-get` task creating these dirs mid-run. Literal paths are never glob-expanded, so they stay ignored - # whenever the dir appears; the generated/ dart packages' `.dart_tool` is already covered by `generated` above. + # (internal/glob/glob.go deletes zero-match keys), so a `**/.dart_tool` or `**/bin/Debug` glob loses the race + # against the `dart pub get` / `dotnet build` tasks creating the dirs. Literal paths are never glob-expanded, so + # they stay ignored whenever the dir appears. (generated/ dart packages' `.dart_tool` is covered by `generated`.) - services/ws-modules/dart-comm1/.dart_tool - services/ws-modules/dart-data1/.dart_tool - - "**/obj" - - "**/bin/Debug" - - "**/bin/Release" + - services/ws-modules/dotnet-data1/obj + - services/ws-modules/dotnet-data1/bin/Debug + - services/ws-modules/dotnet-data1/bin/Release # Built module artifacts (wheels, .wasm, generated JS) live under each pkg/. - "**/pkg" # Zig codegen templates dir whose name carries a literal `.in` suffix. diff --git a/config/nextest.toml b/config/nextest.toml index 90c926ed..7a456d9d 100644 --- a/config/nextest.toml +++ b/config/nextest.toml @@ -1,16 +1,12 @@ -# nextest profile used by the coverage workflow via `cargo llvm-cov nextest` (NEXTEST_PROFILE=ci). -# It writes a JUnit report to target/nextest/ci/junit.xml, which the coverage job uploads to Codecov as test -# results (report_type: test_results). The coverage task loads it via `--config-file config/nextest.toml`. +# The coverage mise task selects the `ci` profile to emit target/nextest/ci/junit.xml. [profile.ci.junit] path = "junit.xml" -# Serialize et-ws-web-runner's tests. -# Each boots a heavy deno_runtime MainWorker, and several at once exhaust fd/port budgets. `max-threads = 1` -# runs them one at a time while the rest of the workspace stays parallel -- nextest's equivalent of that -# suite's single-threaded run (nextest ignores RUST_TEST_THREADS). +# Serialize et-ws-web-runner's tests (under `default`, so every profile inherits it). +# Each web-runner test launches a heavy deno_runtime MainWorker, and several at once exhaust fd/port budgets. [test-groups] web-runner = { max-threads = 1 } -[[profile.ci.overrides]] +[[profile.default.overrides]] filter = 'package(et-ws-web-runner)' test-group = 'web-runner' diff --git a/config/oxfmtrc.jsonc b/config/oxfmtrc.jsonc index b323d0f1..06bb6ff6 100644 --- a/config/oxfmtrc.jsonc +++ b/config/oxfmtrc.jsonc @@ -1,5 +1,6 @@ -// oxfmt config (JSON/JSONC like oxlint -- same restriction). Knobs tuned so oxfmt's JS/TS output matches dprint's -// typescript plugin output on this repo, so the two formatters can coexist without fighting: +// oxfmt config, JSON/JSONC only like oxlint (it enforces the same loader restriction on its own config file). +// Knobs are tuned so oxfmt's JS/TS output matches dprint's typescript plugin output on this repo, so the two +// formatters can coexist without fighting: // // - printWidth=120 matches dprint's lineWidth default (also our editorconfig and ruff line cap). // - semi, singleQuote, trailingComma defaults already match dprint's prefer/double/onlyMultiLine equivalents. diff --git a/config/oxlintrc.jsonc b/config/oxlintrc.jsonc index 36e3654a..7b91fe5f 100644 --- a/config/oxlintrc.jsonc +++ b/config/oxlintrc.jsonc @@ -1,6 +1,7 @@ -// oxlint config. Has to be JSON/JSONC: oxlint's loader (`crates/oxc_linter/src/config/oxlintrc.rs`'s `is_json_ext`) -// only accepts `.json` / `.jsonc` and outright rejects everything else with "Only JSON configuration files are -// supported" -- no YAML, no TOML, no TS despite docs hinting otherwise. +// oxlint config, which must be JSON or JSONC -- oxlint's loader rejects every other config format outright. +// The loader (`crates/oxc_linter/src/config/oxlintrc.rs`'s `is_json_ext`) only accepts `.json` / `.jsonc` and +// rejects everything else with "Only JSON configuration files are supported" -- no YAML, no TOML, no TS despite +// docs hinting otherwise. { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", "categories": { @@ -13,27 +14,32 @@ "nursery": "off", }, "rules": { - // __ET_* are sentinel globals shared across the deno shim and the ws-modules: et-ws-web-runner substitutes the - // `__ET_HTTP_BASE__` / `__ET_WS_URL__` placeholders before exec, and the resulting `globalThis.__ET_*` is how the - // modules read the host base URL. Intentional cross-boundary underscore naming, like webpack's `__webpack_*` or - // Python dunders. - "eslint/no-underscore-dangle": ["warn", { "allow": ["__ET_HTTP_BASE", "__ET_WS_URL"] }], - // Worker.postMessage takes no targetOrigin (unlike Window.postMessage); the rule can't distinguish, so it - // false-positives on every worker-side / main-side `worker.postMessage({...})` call we have. + // __ET_* / __et* are intentional sentinel globals shared across the deno shim and the ws-modules. + // et-ws-web-runner substitutes the `__ET_HTTP_BASE__` / `__ET_WS_URL__` placeholders before exec, and the + // resulting `globalThis.__ET_*` is how the modules read the host base URL -- a cross-boundary convention like + // webpack's `__webpack_*` or Python dunders. The coverage path adds `__ET_TEST_COVERAGE` (the on/off flag), + // `__ET_AGENT_ID` (the module's storage bucket, sniffed from et-connect-ack), and `__etPyCov` (the Pyodide + // coverage helper object) to the same convention. + "eslint/no-underscore-dangle": [ + "warn", + { "allow": ["__ET_HTTP_BASE", "__ET_WS_URL", "__ET_TEST_COVERAGE", "__ET_AGENT_ID", "__etPyCov"] }, + ], + // Worker.postMessage takes no targetOrigin (unlike Window.postMessage), and the rule can't tell them apart. + // So it false-positives on every worker-side / main-side `worker.postMessage({...})` call we have. "unicorn/require-post-message-target-origin": "off", - // Type-aware rule (needs `--type-aware` + oxlint-tsgolint + config/tsconfig.json): the local equivalent of - // DeepSource's JS-W1044. Flags `a && a.b` chains that an optional chain (`a?.b`) expresses more safely. + // Type-aware rule flagging `a && a.b` chains that an optional chain (`a?.b`) expresses more safely. + // The local equivalent of DeepSource's JS-W1044; needs `--type-aware` + oxlint-tsgolint + config/tsconfig.json. "typescript/prefer-optional-chain": "error", - // Cyclomatic-complexity ceiling -- the local equivalent of DeepSource's JS-R1005. Keeps branchy functions in - // check; 10 is a practical ceiling (eslint's default 20 is too loose, DeepSource's medium=6 too strict here). + // Cyclomatic-complexity ceiling -- the local twin of DeepSource's JS-R1005, keeping branchy code in check. + // 10 is a practical ceiling: eslint's default of 20 is too loose, and DeepSource's medium=6 too strict here. "eslint/complexity": ["error", { "max": 10 }], }, "overrides": [ { - // shim.js installs DOM stubs (Window / HTMLElement / HTMLCanvasElement / Image classes, `obj.onload = null` - // property inits) so Deno-hosted wasm-bindgen modules see a browser-shaped global. The style rules below - // conflict with that design: stub classes MUST exist as classes for `instanceof`, on-handler properties MUST - // exist as settable null-initialised fields. + // 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 + // inits. The style rules below conflict with that design: stub classes MUST exist as classes for + // `instanceof`, and on-handler properties MUST exist as settable null-initialised fields. "files": ["**/services/ws-web-runner/src/shim.js"], "rules": { "typescript/no-extraneous-class": "off", @@ -42,19 +48,19 @@ }, }, { - // ws-modules' committed shim entry points (`pkg/et_ws_*.js`, hand-written bridges between the host page/worker - // and each module's wasm/JS payload) plus the ws-server static page (`static/*.js`) intentionally use the - // `on =` pattern -- it matches the wasm-bindgen / worker convention, and there's only ever one handler. - // `no-await-in-loop` warnings here are also intentional serialised sequences (init -> message-loop ordering - // matters). `consistent-function-scoping` flags inline closures that exist for readability, not to capture - // state. + // ws-modules' committed shim entry points and the ws-server static page intentionally use `on =`. + // The shim entry points are `pkg/et_ws_*.js` (hand-written bridges between the host page/worker and each + // module's wasm/JS payload); the static page is `static/*.js`. The pattern matches the wasm-bindgen / worker + // convention, and there's only ever one handler. `no-await-in-loop` warnings here are also intentional + // serialised sequences (init -> message-loop ordering matters). `consistent-function-scoping` flags inline + // closures that exist for readability, not to capture state. "files": ["**/services/ws-modules/*/pkg/*.js", "**/services/ws-server/static/*.js"], "rules": { "eslint/no-await-in-loop": "off", "unicorn/consistent-function-scoping": "off", "unicorn/prefer-add-event-listener": "off", - // wasm-bindgen/build-emitted bundles and the browser glue entry points carry generated or inherently - // branchy setup; the complexity ceiling is for hand-written source, not these shims. + // wasm-bindgen/build-emitted bundles and the browser glue entry points carry generated or branchy setup. + // The complexity ceiling is for hand-written source, not these shims. "eslint/complexity": "off", }, }, diff --git a/config/rclone.conf b/config/rclone.ini similarity index 100% rename from config/rclone.conf rename to config/rclone.ini diff --git a/config/ripgrep.rc b/config/ripgrep.rc new file mode 100644 index 00000000..621bd241 --- /dev/null +++ b/config/ripgrep.rc @@ -0,0 +1,7 @@ +# ripgrep default flags for this repo, loaded via RIPGREP_CONFIG_PATH (set in .mise/config.toml's [env]). +# Search hidden dot-dirs by default -- above all `.mise/` (tracked task/tool config that rg would otherwise skip +# because it is a hidden directory) and `.github/` -- so a bare `rg ` covers the whole tracked tree. +# `.git/` is the one hidden dir still excluded (huge, never source); gitignored trees (target/, node_modules/) +# stay excluded regardless because rg keeps honoring .gitignore even with --hidden. +--hidden +--glob=!.git/ diff --git a/config/semgrep/comment-summary-line.yaml b/config/semgrep/comment-summary-line.yaml index 57f01e2f..e29f636f 100644 --- a/config/semgrep/comment-summary-line.yaml +++ b/config/semgrep/comment-summary-line.yaml @@ -3,34 +3,36 @@ rules: languages: [generic] paths: include: - - "*.toml" - - "*.yaml" + - "*.env" + - "*.ini" + - "*.rc" - "*.rego" - "*.s" + - "*.toml" + - "*.yaml" + - ".dockerignore" + - ".gitignore" - "Dockerfile*" exclude: # Skip machine-emitted TOML/YAML (int-gen / regen output). # Hand-edits would drift on the next regeneration, so the comment style there is the generator's job, not ours. - "/generated/**" - "/verification/**" - # Flag the FIRST line of a multi-line `#` comment block that lacks a closing full stop. + # `dart pub`-generated module .gitignore boilerplate, whose comment style is dart's job, not ours. + # Its first line is a bare doc URL and the file is generator-owned, so we do not discipline its comments. + - "/services/ws-modules/dart-*/.gitignore" + # Flag the first line of a multi-line `#` comment block when its final character is not a full stop. # The block-start anchor is the preceding line, consumed into the match: either start-of-file (`\A`) or a line # that is blank or whose first non-whitespace char is not `#` (`[^#\s]`). Keying off the first non-whitespace # char -- not the first raw char -- is what stops an INDENTED comment line (whose leading whitespace would # otherwise read as a non-comment line) from anchoring a spurious match: interior continuation lines, which need - # not end in a period, stay unflagged regardless of indentation. The check is two alternatives under - # `pattern-either` (kept separate so each regex line stays within the 120-char limit): the first flags a - # final char that is neither `.` nor `:` (`[^.\n:]`); the second flags a final `:` whose continuation line is - # NOT an enumeration item -- the negative lookahead `(?![ \t]*([-*]|[0-9]+[.)])[ \t])` reserves a trailing - # `:` for introducing a real list (`-`, `*`, or `N.`/`N)`), so a `:` followed by prose is still flagged. - pattern-either: - - pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^#\s][^\n]*)?\n)[ \t]*#[^\n]*[^.\n:]\n[ \t]*#' - - pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^#\s][^\n]*)?\n)[ \t]*#[^\n]*:\n[ \t]*#(?![ \t]*([-*]|[0-9]+[.)])[ \t])' + # not end in a period, stay unflagged regardless of indentation. The trailing `[^.\n]` matches any final char + # except `.`, so the summary line must end in a full stop -- a trailing `:` is not accepted. + pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^#\s][^\n]*)?\n)[ \t]*#[^\n]*[^.\n]\n[ \t]*#' message: >- A multi-line `#` comment block must open with a single summary line ending in a full stop (PEP 257 style). - This applies to TOML, YAML, Rego, GAS assembly, and Dockerfile comments. The summary describes the whole - block in one sentence; continuation lines follow. End the first comment line with `.` (a `:` introducing an - enumeration is also accepted). + The summary describes the whole block in one sentence; continuation lines follow. End the first comment line + with a `.`. severity: ERROR - id: comment-summary-line-c @@ -39,24 +41,22 @@ rules: include: - "*.c" - "*.h" - # The C twin of comment-summary-line above -- the same convention over the C comment shapes. - # No dedicated C linter has this check (clang-tidy's comment checks stop at namespace-closing and - # task-tag formatting; cpplint only checks comment whitespace), so the generic-mode regexes carry it. - # Four alternatives, two per comment shape: + - "*.jsonc" + # The C-style twin of comment-summary-line above, covering `//` and `/* */` comments (so it also covers .jsonc). + # No dedicated linter has this check (clang-tidy's comment checks stop at namespace-closing and task-tag + # formatting; cpplint only checks comment whitespace), so the generic-mode regexes carry it. Two alternatives, + # one per comment shape: # - `/* ... */` blocks: a line opening `/*` WITHOUT `*/` on the same line (the negative lookahead - # `(?![^\n]*\*/)` is the multi-line test, mirroring the next-line-is-also-comment test of the `#` rule) - # must end in `.` -- or `:` when the ` * `-prefixed continuation line opens an enumeration item. - # - `//` runs: same block-start anchor trick as the `#` rule (preceding line is start-of-file, blank, or - # starts with a non-`/` char), first line of a 2+-line run must end in `.`/enum-introducing `:`. + # `(?![^\n]*\*/)` is the multi-line test, mirroring the next-line-is-also-comment test of the `#` rule) must + # end in `.`. + # - `//` runs: same block-start anchor trick as the `#` rule (preceding line is start-of-file, blank, or starts + # with a non-`/` char); the first line of a 2+-line run must end in `.`. # Single-line `/* ... */` and lone `//` lines are exempt, as single `#` lines are above. pattern-either: - - pattern-regex: '(?m)^[ \t]*/\*(?![^\n]*\*/)[^\n]*[^.\n:]\n' - - pattern-regex: '(?m)^[ \t]*/\*(?![^\n]*\*/)[^\n]*:\n(?![ \t]*\*[ \t]*(-[ \t]|[0-9]+[.)][ \t]))' - - pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^/\s][^\n]*)?\n)[ \t]*//[^\n]*[^.\n:]\n[ \t]*//' - - pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^/\s][^\n]*)?\n)[ \t]*//[^\n]*:\n[ \t]*//(?![ \t]*([-*]|[0-9]+[.)])[ \t])' + - pattern-regex: '(?m)^[ \t]*/\*(?![^\n]*\*/)[^\n]*[^.\n]\n' + - pattern-regex: '(?m)(?:\A|^[ \t]*(?:[^/\s][^\n]*)?\n)[ \t]*//[^\n]*[^.\n]\n[ \t]*//' message: >- - A multi-line C comment must open with a single summary line ending in a full stop (PEP 257 style, as the - comment-summary-line rule enforces for `#` comments). The summary describes the whole block in one sentence; - continuation lines follow. End the first comment line with `.` (a `:` introducing an enumeration is also - accepted). + A multi-line C-style comment (a `/* */` block or a `//` run) must open with a single summary line ending in + a full stop (PEP 257 style). The summary describes the whole block in one sentence; continuation lines + follow. End the first comment line with a `.`. severity: ERROR diff --git a/config/taplo.toml b/config/taplo.toml index be04d505..f735c571 100644 --- a/config/taplo.toml +++ b/config/taplo.toml @@ -21,6 +21,14 @@ keys = ["package"] formatting = { reorder_arrays = false } keys = ["workspace"] +# Do not reorder the coverage.py path lists -- their order is semantic. +# coverage uses each list's FIRST entry as the canonical result path, so alpha-sorting would push the `**/` glob +# first and break the source-path remap. +[[rule]] +formatting = { reorder_arrays = false } +include = ["**/coverage.toml"] +keys = ["tool.coverage.paths"] + # Forbid `path = "..."` dependencies in non-root Cargo.toml files. # The root Cargo.toml's `[workspace.dependencies]` is the only legitimate place for path deps; member crates # reference them via `dep.workspace = true`. diff --git a/data/.gitignore b/data/.gitignore index ca74d7ab..4e5450e7 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,6 +1,6 @@ -# Local-only working data: external upstream repos cloned here for development -# carry their own .git history and must never be committed into this repo. -# Ignore every top-level entry except this file and the tracked model-modules/. +# Local-only working data cloned here for development, never committed into this repo. +# External upstream repos cloned here carry their own .git history. Ignore every top-level +# entry except this file and the tracked model-modules/. /* !/.gitignore !/model-modules/ diff --git a/libs/web/Cargo.toml b/libs/web/Cargo.toml index 15aece9f..1642a0b0 100644 --- a/libs/web/Cargo.toml +++ b/libs/web/Cargo.toml @@ -12,6 +12,7 @@ doctest = false [dependencies] js-sys.workspace = true +minicov = { workspace = true, optional = true } wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true web-sys = { workspace = true, features = [ @@ -23,5 +24,10 @@ web-sys = { workspace = true, features = [ "Navigator", ] } +# Coverage instrumentation, off by default and enabled only by the coverage build of the browser wasm modules. +# Pulls minicov and exposes __et_capture_coverage, which wasm-bindgen collects into every dependent module's glue. +[features] +coverage = ["dep:minicov"] + [lints] workspace = true diff --git a/libs/web/src/error.rs b/libs/web/src/error.rs index 1c6da5c2..41e7ebc9 100644 --- a/libs/web/src/error.rs +++ b/libs/web/src/error.rs @@ -17,11 +17,16 @@ use wasm_bindgen::{JsCast, JsValue}; /// Drops the original (irrelevant -- it's just the same `JsValue` we tried to /// cast) and surfaces a descriptive string instead. pub trait JsCastExt { - fn dyn_into_msg(self, msg: &str) -> Result; + fn dyn_into_msg(self, msg: &str) -> Result + where + T: JsCast; } impl JsCastExt for S { - fn dyn_into_msg(self, msg: &str) -> Result { + fn dyn_into_msg(self, msg: &str) -> Result + where + T: JsCast, + { self.dyn_into::().map_err(|_original| JsValue::from_str(msg)) } } diff --git a/libs/web/src/lib.rs b/libs/web/src/lib.rs index de4eb6df..2f93885b 100644 --- a/libs/web/src/lib.rs +++ b/libs/web/src/lib.rs @@ -6,6 +6,28 @@ pub use self::error::{JsCastExt, JsFunctionExt, JsPromiseExt, JsResultExt}; pub const SENSOR_PERMISSION_GRANTED: &str = "granted"; +/// Return this module's raw minicov coverage buffer (a `.profraw`), or empty on failure. +/// +/// Present only in the `coverage` build. `wasm-bindgen` collects this export into every dependent browser +/// module's JS glue, so the web-runner can pull each module's coverage after running it -- `wasm32-unknown-unknown` +/// has no filesystem, so the bytes come back through JS rather than a file. The web-runner then routes them +/// through the same llc + llvm-cov pipeline the WASI guests use (see the `wasi-cov` mise task). +#[cfg(feature = "coverage")] +#[wasm_bindgen] +#[expect( + unsafe_code, + reason = "minicov::capture_coverage is unsafe; the browser wasm module is single-threaded" +)] +#[must_use] +pub fn __et_capture_coverage() -> Vec { + let mut data = Vec::new(); + // SAFETY: single-threaded browser wasm; called once after the module's run() completes. + match unsafe { minicov::capture_coverage(&mut data) } { + Ok(()) => data, + Err(_) => Vec::new(), + } +} + pub fn get_media_devices(navigator: &web_sys::Navigator) -> Result { let media_devices = js_sys::Reflect::get(navigator, &JsValue::from_str("mediaDevices"))?; diff --git a/services/storage/src/lib.rs b/services/storage/src/lib.rs index fac169a8..3a0bc860 100644 --- a/services/storage/src/lib.rs +++ b/services/storage/src/lib.rs @@ -64,7 +64,10 @@ impl From> for StorageError { } /// Register `PUT /storage/{agent_id}/{filename}` and `GET /storage/...` (static file serving). -pub fn configure(cfg: &mut web::ServiceConfig, config: &StorageConfig) { +pub fn configure(cfg: &mut web::ServiceConfig, config: &StorageConfig) +where + S: Clone + Send + 'static, +{ let storage_dir = config.path.clone(); let _configured = cfg .route("/storage/{agent_id}/{filename}", web::put().to(put_file::)) diff --git a/services/storage/src/routes.rs b/services/storage/src/routes.rs index df16a6bb..5d183b1c 100644 --- a/services/storage/src/routes.rs +++ b/services/storage/src/routes.rs @@ -68,12 +68,15 @@ pub struct BinaryBlob(#[expect(dead_code)] Vec); clippy::future_not_send, reason = "actix-web Payload is !Send by design; handler runs on actix's single-threaded runtime" )] -pub async fn put_file( +pub async fn put_file( req: HttpRequest, mut payload: web::Payload, registry: web::Data>, config: web::Data, -) -> Result { +) -> Result +where + S: Clone + Send + 'static, +{ let agent_id = req.match_info().query("agent_id").to_string(); let filename = req .match_info() diff --git a/services/ws-modules/comm1/Cargo.toml b/services/ws-modules/comm1/Cargo.toml index 195590e4..09a3c52b 100644 --- a/services/ws-modules/comm1/Cargo.toml +++ b/services/ws-modules/comm1/Cargo.toml @@ -13,6 +13,9 @@ test = false [dependencies] edge-toolkit.workspace = true +# Optional and coverage-only: unlike the other browser modules, comm1 has no et-web dependency of its own. +# Pull it under the coverage feature purely for its __et_capture_coverage export (wasm-bindgen collects it into glue). +et-web = { workspace = true, optional = true } et-ws-wasm-agent.workspace = true js-sys.workspace = true serde.workspace = true diff --git a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js index b5bb4013..95abed66 100644 --- a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js +++ b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js @@ -70,6 +70,9 @@ export default async function init() { const ownWheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; await injectWheel(ownWheel); + // Start Pyodide coverage before importing so import-time lines count (no-op unless the runner set the gate). + if (globalThis.__etPyCov) await globalThis.__etPyCov.start(pyodide, "pydata1"); + const pydata1 = pyodide.pyimport("pydata1"); pyMod = { run: pydata1.run, @@ -124,6 +127,7 @@ export async function run() { pyodide.toPy(() => {}), ); } finally { + if (globalThis.__etPyCov) await globalThis.__etPyCov.stop(pyodide, "pydata1"); client.disconnect(); } } diff --git a/services/ws-modules/pyface1/pkg/et_ws_pyface1.js b/services/ws-modules/pyface1/pkg/et_ws_pyface1.js index 5d5acf40..2303e4df 100644 --- a/services/ws-modules/pyface1/pkg/et_ws_pyface1.js +++ b/services/ws-modules/pyface1/pkg/et_ws_pyface1.js @@ -46,6 +46,8 @@ export default async function init() { // package.json so a bump there doesn't require touching this file. const { installWheel: installEtWs } = await import("/modules/et-ws/et_ws.js"); await installEtWs(pyodide); + // Start Pyodide coverage before importing so import-time lines count (no-op unless the runner set the gate). + if (globalThis.__etPyCov) await globalThis.__etPyCov.start(pyodide, "pyface1"); py = pyodide.pyimport("pyface1"); cfg = py.config().toJs({ dict_converter: Object.fromEntries }); } @@ -107,6 +109,8 @@ export async function run() { pyodide.toPy(() => runtime !== state), ); } finally { + // Fires even when getUserMedia throws under the runner, so import + pre-camera lines still get credited. + if (globalThis.__etPyCov) await globalThis.__etPyCov.stop(pyodide, "pyface1"); cleanup(state ?? { client, stream }); } } diff --git a/services/ws-modules/pyface1/pyproject.toml b/services/ws-modules/pyface1/pyproject.toml index 01e4ef8b..eddfdcf5 100644 --- a/services/ws-modules/pyface1/pyproject.toml +++ b/services/ws-modules/pyface1/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" version = "0.1.0" [dependency-groups] -dev = ["pytest"] +dev = ["pytest", "pytest-cov"] [build-system] build-backend = "uv_build" diff --git a/services/ws-modules/wasi-comm1/Cargo.toml b/services/ws-modules/wasi-comm1/Cargo.toml index 31a5dfa4..b365718d 100644 --- a/services/ws-modules/wasi-comm1/Cargo.toml +++ b/services/ws-modules/wasi-comm1/Cargo.toml @@ -14,9 +14,16 @@ test = false # See wasi-data1/Cargo.toml for the rationale behind the wasi-only target scope. # The lib body is gated identically. [target.'cfg(target_os = "wasi")'.dependencies] +fs-err = { workspace = true, optional = true } +minicov = { workspace = true, optional = true } serde_json.workspace = true wit-bindgen.workspace = true +# Coverage instrumentation, off by default and enabled only by the coverage build. +# Pulls minicov, whose capture_coverage the guest dumps to the runner's /cov preopen at the end of run(). +[features] +coverage = ["dep:fs-err", "dep:minicov"] + # Build script (runs on the host) locates the repo root to emit ET_WIT_DIR. [build-dependencies] et-path.workspace = true diff --git a/services/ws-modules/wasi-comm1/src/coverage.rs b/services/ws-modules/wasi-comm1/src/coverage.rs new file mode 100644 index 00000000..e5232a22 --- /dev/null +++ b/services/ws-modules/wasi-comm1/src/coverage.rs @@ -0,0 +1,16 @@ +//! Coverage dump for the instrumented build, isolated into its own file. +//! +//! minicov's `capture_coverage` is an `unsafe fn` (it reads the raw instrumented counter buffers), which +//! Codacy flags for audit. Codacy can only exclude whole paths in-repo, not suppress per line, so this +//! one-function file is the single thing excluded from Codacy (see .codacy.yaml) while the rest of the guest +//! stays analyzed. The unsafe is still covered by the repo's own clippy (the crate expects `unsafe_code`) and +//! by DeepSource. Called from `run()` at the end; writes the profile to the runner's `/cov` preopen. + +pub fn dump() { + let mut coverage = Vec::new(); + // SAFETY: single-threaded guest; capture_coverage reads the instrumented counters once at run() end. + unsafe { + minicov::capture_coverage(&mut coverage).expect("minicov capture_coverage"); + } + fs_err::write("/cov/et_ws_wasi_comm1.profraw", coverage).expect("write /cov profraw"); +} diff --git a/services/ws-modules/wasi-comm1/src/lib.rs b/services/ws-modules/wasi-comm1/src/lib.rs index cf7337a7..9c6a6aab 100644 --- a/services/ws-modules/wasi-comm1/src/lib.rs +++ b/services/ws-modules/wasi-comm1/src/lib.rs @@ -39,6 +39,10 @@ use et::ws_wasi::ws::WsError; use exports::et::ws_wasi::entry::{EntryError, Guest}; use wasi::logging::logging::{self, Level}; +// Coverage dump lives in its own module so Codacy can exclude just that file (its minicov call is unsafe). +#[cfg(feature = "coverage")] +mod coverage; + const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); /// Total time we'll wait for a `list-agents-response`. The server replies /// immediately under normal load, but we leave headroom for the inbox queue. @@ -101,6 +105,8 @@ impl Guest for Component { et::ws_wasi::ws::disconnect(); info("workflow complete"); + #[cfg(feature = "coverage")] + coverage::dump(); Ok(()) } } diff --git a/services/ws-modules/wasi-data1/Cargo.toml b/services/ws-modules/wasi-data1/Cargo.toml index 04b64825..29111943 100644 --- a/services/ws-modules/wasi-data1/Cargo.toml +++ b/services/ws-modules/wasi-data1/Cargo.toml @@ -15,9 +15,16 @@ test = false # The lib body is gated the same way (`#![cfg(target_os = "wasi")]` at the top of src/lib.rs), # so on the host target the crate compiles to an empty cdylib. [target.'cfg(target_os = "wasi")'.dependencies] +fs-err = { workspace = true, optional = true } +minicov = { workspace = true, optional = true } serde_json.workspace = true wit-bindgen.workspace = true +# Coverage instrumentation, off by default and enabled only by the coverage build. +# Pulls minicov, whose capture_coverage the guest dumps to the runner's /cov preopen at the end of run(). +[features] +coverage = ["dep:fs-err", "dep:minicov"] + # Build script (runs on the host) locates the repo root to emit ET_WIT_DIR. [build-dependencies] et-path.workspace = true diff --git a/services/ws-modules/wasi-data1/src/coverage.rs b/services/ws-modules/wasi-data1/src/coverage.rs new file mode 100644 index 00000000..b597b7f1 --- /dev/null +++ b/services/ws-modules/wasi-data1/src/coverage.rs @@ -0,0 +1,16 @@ +//! Coverage dump for the instrumented build, isolated into its own file. +//! +//! minicov's `capture_coverage` is an `unsafe fn` (it reads the raw instrumented counter buffers), which +//! Codacy flags for audit. Codacy can only exclude whole paths in-repo, not suppress per line, so this +//! one-function file is the single thing excluded from Codacy (see .codacy.yaml) while the rest of the guest +//! stays analyzed. The unsafe is still covered by the repo's own clippy (the crate expects `unsafe_code`) and +//! by DeepSource. Called from `run()` at the end; writes the profile to the runner's `/cov` preopen. + +pub fn dump() { + let mut coverage = Vec::new(); + // SAFETY: single-threaded guest; capture_coverage reads the instrumented counters once at run() end. + unsafe { + minicov::capture_coverage(&mut coverage).expect("minicov capture_coverage"); + } + fs_err::write("/cov/et_ws_wasi_data1.profraw", coverage).expect("write /cov profraw"); +} diff --git a/services/ws-modules/wasi-data1/src/lib.rs b/services/ws-modules/wasi-data1/src/lib.rs index 5daf6d8c..8985ef7f 100644 --- a/services/ws-modules/wasi-data1/src/lib.rs +++ b/services/ws-modules/wasi-data1/src/lib.rs @@ -39,6 +39,10 @@ use exports::et::ws_wasi::entry::{EntryError, Guest}; use wasi::keyvalue::store; use wasi::logging::logging::{self, Level}; +// Coverage dump lives in its own module so Codacy can exclude just that file (its minicov call is unsafe). +#[cfg(feature = "coverage")] +mod coverage; + const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); const FILENAME: &str = "test_data.txt"; @@ -97,6 +101,8 @@ impl Guest for Component { et::ws_wasi::ws::disconnect(); info("workflow complete"); + #[cfg(feature = "coverage")] + coverage::dump(); Ok(()) } } diff --git a/services/ws-modules/wasi-graphics-info/.gitignore b/services/ws-modules/wasi-graphics-info/.gitignore index bed95b79..8a7b9ca9 100644 --- a/services/ws-modules/wasi-graphics-info/.gitignore +++ b/services/ws-modules/wasi-graphics-info/.gitignore @@ -1,5 +1,5 @@ -# componentize-py outputs (regenerated each `bindings .` run). The world's -# bindings package is `wit_world/`; the runtime/types/poll-loop helpers and +# componentize-py outputs, regenerated on each `bindings .` run. +# The world's bindings package is `wit_world/`; the runtime/types/poll-loop helpers and # async-support shim are written alongside. /wit_world/ /componentize_py_async_support/ diff --git a/services/ws-pyo3-runner/Cargo.toml b/services/ws-pyo3-runner/Cargo.toml index 99904143..9e0782d5 100644 --- a/services/ws-pyo3-runner/Cargo.toml +++ b/services/ws-pyo3-runner/Cargo.toml @@ -37,6 +37,7 @@ tracing-subscriber.workspace = true pyo3-build-config.workspace = true [dev-dependencies] +backon = { workspace = true, features = ["tokio-sleep"] } base64.workspace = true et-ws-test-server.workspace = true rstest.workspace = true diff --git a/services/ws-pyo3-runner/tests/modules.rs b/services/ws-pyo3-runner/tests/modules.rs index 20cc07b7..1ea1f142 100644 --- a/services/ws-pyo3-runner/tests/modules.rs +++ b/services/ws-pyo3-runner/tests/modules.rs @@ -24,6 +24,7 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; +use backon::{ExponentialBuilder, RetryableWithContext as _}; use edge_toolkit::config::{Language, mise_env_includes}; use edge_toolkit::ws::{ClientMessage, ServerMessage}; use futures_util::{SinkExt as _, StreamExt as _}; @@ -32,6 +33,26 @@ use tokio_tungstenite::{connect_async, tungstenite}; type ControlSocket = tokio_tungstenite::WebSocketStream>; +/// Total wall-clock wait for the runner to register as a peer. +/// +/// Deliberately generous: the torch case's cold first `import torch` (a ~400 MB package) can take tens of +/// seconds on a cold, contended CI runner before the runner even connects, and this wait must outlast that. +/// See the pyo3-runner torch-registration-timeout note in CLAUDE.md. +const PEER_REGISTER_TIMEOUT: Duration = Duration::from_mins(2); +/// Overall `run_exchange` budget for the torch case; must exceed `PEER_REGISTER_TIMEOUT` (its cold import lands +/// inside the peer wait) plus one reply. +const TORCH_EXCHANGE_BUDGET: Duration = Duration::from_mins(3); +/// Overall `run_exchange` budget for the quick (non-torch) cases, which register within a second. +const EXCHANGE_BUDGET: Duration = Duration::from_secs(30); +/// Wait for a single reply frame within an exchange step. +const REPLY_TIMEOUT: Duration = Duration::from_secs(10); +/// Exponential-backoff bounds for the peer-registration poll: tight at first, then cheap while we wait out a +/// slow cold start. Driven by `backon`; the total wall-clock is capped by `PEER_REGISTER_TIMEOUT`. +const POLL_BACKOFF_MIN: Duration = Duration::from_millis(50); +const POLL_BACKOFF_MAX: Duration = Duration::from_millis(500); +/// How long each poll round drains inbound frames looking for the peer before backing off. +const POLL_DRAIN_WINDOW: Duration = Duration::from_millis(250); + /// When a case may not be runnable, the condition under which it self-skips. enum Gate { /// Always runs (needs only the embedded interpreter + bundled module). @@ -91,9 +112,9 @@ async fn module_behaves( // torch's cold import + first op is slow; the rest are quick. let budget = if matches!(gate, Gate::Torch) { - Duration::from_mins(1) + TORCH_EXCHANGE_BUDGET } else { - Duration::from_secs(30) + EXCHANGE_BUDGET }; let outcome = tokio::time::timeout(budget, run_exchange(&mut control, &control_id, &exchange)).await; @@ -258,38 +279,65 @@ async fn control_client(ws_url: &str) -> Result<(ControlSocket, String), Box Result<(), Box> { - // The runner needs ~1s to spawn + init Python + connect; give it room. - let deadline = Instant::now() + Duration::from_secs(15); - while Instant::now() < deadline { - let req = serde_json::to_string(&ClientMessage::ListAgents)?; - control.send(tungstenite::Message::Text(req)).await?; - // Drain responses for a short window before re-polling. - let poll_until = Instant::now() + Duration::from_millis(250); - while Instant::now() < poll_until { - let remaining = poll_until - Instant::now(); - match tokio::time::timeout(remaining, control.next()).await { - Ok(Some(Ok(tungstenite::Message::Text(text)))) => { - if let Ok(ServerMessage::ListAgentsResponse { agents }) = - serde_json::from_str::(&text) - && agents.iter().any(|summary| summary.agent_id != self_id) - { - return Ok(()); - } + let backoff = ExponentialBuilder::default() + .with_min_delay(POLL_BACKOFF_MIN) + .with_max_delay(POLL_BACKOFF_MAX) + .without_max_times(); + // Pass `peer_poll_step` as a bare `async fn` item, not a closure: only a function item can be + // higher-ranked over the socket's borrow (`for<'a> FnMut((&'a mut _, &'a str)) -> Fut<'a>`), which + // `backon`'s context-threading retry requires. `self_id` rides in the context tuple alongside the socket. + let poll = peer_poll_step.retry(backoff).context((control, self_id)); + match tokio::time::timeout(PEER_REGISTER_TIMEOUT, poll).await { + Ok((_ctx, Ok(()))) => Ok(()), + _ => Err("runner never registered".into()), + } +} + +/// One `backon` retry step: run a poll round, then hand the context back so the next attempt reuses it. +async fn peer_poll_step<'sock>( + (control, self_id): (&'sock mut ControlSocket, &'sock str), +) -> ((&'sock mut ControlSocket, &'sock str), Result<(), ()>) { + let outcome = poll_for_peer_once(control, self_id).await; + ((control, self_id), outcome) +} + +/// One registration poll round: request the roster, drain replies for `POLL_DRAIN_WINDOW`, and return +/// `Ok(())` once a peer that isn't us appears. `Err(())` is the "not yet" sentinel `backon` retries on. +async fn poll_for_peer_once(control: &mut ControlSocket, self_id: &str) -> Result<(), ()> { + let Ok(req) = serde_json::to_string(&ClientMessage::ListAgents) else { + return Err(()); + }; + if control.send(tungstenite::Message::Text(req)).await.is_err() { + return Err(()); + } + let poll_until = Instant::now() + POLL_DRAIN_WINDOW; + while Instant::now() < poll_until { + let remaining = poll_until - Instant::now(); + match tokio::time::timeout(remaining, control.next()).await { + Ok(Some(Ok(tungstenite::Message::Text(text)))) => { + if let Ok(ServerMessage::ListAgentsResponse { agents }) = serde_json::from_str::(&text) + && agents.iter().any(|summary| summary.agent_id != self_id) + { + return Ok(()); } - Ok(Some(Ok(_))) => {} - _ => break, } + Ok(Some(Ok(_))) => {} + _ => break, } - tokio::time::sleep(Duration::from_millis(200)).await; } - Err("runner never registered".into()) + Err(()) } /// Drain frames until the first non-protocol text frame (skipping typed et-* envelopes). async fn drain_text(control: &mut ControlSocket) -> Result> { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + REPLY_TIMEOUT; while Instant::now() < deadline { let remaining = deadline - Instant::now(); match tokio::time::timeout(remaining, control.next()).await { @@ -310,7 +358,7 @@ async fn drain_text(control: &mut ControlSocket) -> Result Result, Box> { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + REPLY_TIMEOUT; while Instant::now() < deadline { let remaining = deadline - Instant::now(); match tokio::time::timeout(remaining, control.next()).await { @@ -327,7 +375,7 @@ async fn drain_binary(control: &mut ControlSocket) -> Result, Box Result, Box> { let mut received = Vec::with_capacity(count); - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + REPLY_TIMEOUT; while received.len() < count && Instant::now() < deadline { let remaining = deadline - Instant::now(); match tokio::time::timeout(remaining, control.next()).await { diff --git a/services/ws-server/static/package.json b/services/ws-server/static/package.json index b8e23c44..321be16b 100644 --- a/services/ws-server/static/package.json +++ b/services/ws-server/static/package.json @@ -2,6 +2,7 @@ "description": "edge-toolkit static assets", "name": "et-ws-server-static", "version": "0.1.0", + "type": "module", "files": [ "app.js", "favicon.png", diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 2e868f9d..ee11e9c7 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -27,6 +27,11 @@ default = [] # Build on CUDA hosts with: # cargo build -p et-ws-wasi-runner --features cuda cuda = ["wasmtime-wasi-nn/onnx-cuda"] +# `coverage` (off by default): compiles the guest-coverage preopen into the runner. +# It maps `/cov` to target/wasi-cov so instrumented WASI guests dump their minicov `.profraw` there for the +# wasi-cov task. Only the coverage workflow builds with it (and sets ET_TEST_COVERAGE, which still gates the +# preopen at runtime); every other build omits the code. Mirrors et-ws-web-runner's `coverage` feature. +coverage = [] [dependencies] async-trait.workspace = true @@ -35,6 +40,7 @@ edge-toolkit.workspace = true et-otlp.workspace = true et-rest-client = { workspace = true, features = ["tracing"] } et-ws-runner-common.workspace = true +fs-err.workspace = true futures-util.workspace = true # libc::_exit for the macOS test-only fast exit (see main.rs). # std::process::exit still runs atexit handlers, which is exactly the path that races libc++ during diff --git a/services/ws-wasi-runner/src/config.rs b/services/ws-wasi-runner/src/config.rs index fa66887b..12af2e3b 100644 --- a/services/ws-wasi-runner/src/config.rs +++ b/services/ws-wasi-runner/src/config.rs @@ -20,4 +20,13 @@ pub struct Config { /// OpenTelemetry config, from the `OTLP_*` env vars; `None` logs to stderr. #[serde(default)] pub otlp: Option, + /// Runtime activation of the guest-coverage preopen, read from `ET_TEST_COVERAGE`. + /// + /// Only present under the `coverage` cargo feature -- the preopen code is compiled in only there. When the + /// feature is on, `ET_TEST_COVERAGE=true` preopens a `/cov` dir for instrumented guests to write their minicov + /// `.profraw` into (collected by the wasi-cov task into the combined Rust coverage); `false` (the default) + /// leaves the compiled-in preopen inert. + #[cfg(feature = "coverage")] + #[serde(default)] + pub et_test_coverage: bool, } diff --git a/services/ws-wasi-runner/src/host/mod.rs b/services/ws-wasi-runner/src/host/mod.rs index 8dc87a42..08056fab 100644 --- a/services/ws-wasi-runner/src/host/mod.rs +++ b/services/ws-wasi-runner/src/host/mod.rs @@ -9,6 +9,9 @@ use std::sync::Arc; use tokio::sync::Mutex; use wasmtime::component::ResourceTable; +// DirPerms/FilePerms are only used by the coverage `/cov` preopen below. +#[cfg(feature = "coverage")] +use wasmtime_wasi::{DirPerms, FilePerms}; use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; mod error; @@ -49,8 +52,42 @@ impl HostState { clippy::same_name_method, reason = "convention: HostState::new mirrors WasiCtxBuilder/ResourceTable/Client constructors used here" )] - pub fn new(http_base: &str, ws_url: String, connect_ack_timeout: Option) -> Self { - let wasi_ctx = WasiCtxBuilder::new().inherit_stdio().inherit_env().build(); + #[cfg_attr( + not(feature = "coverage"), + expect( + unused_variables, + reason = "coverage gates the /cov preopen, compiled only under the feature" + ) + )] + pub fn new( + http_base: &str, + ws_url: String, + connect_ack_timeout: Option, + coverage: bool, + ) -> Self { + let mut builder = WasiCtxBuilder::new(); + #[expect(unused_results, reason = "WasiCtxBuilder setters return &mut Self")] + { + builder.inherit_stdio().inherit_env(); + } + // Instrumented guests write their minicov `.profraw` to `/cov`; map it to target/wasi-cov so the + // wasi-cov task finds it. Repo-root-anchored (not CWD) so it lands consistently under nextest. + #[cfg(feature = "coverage")] + if coverage { + #[expect( + unused_results, + clippy::expect_used, + reason = "preopen setter returns &mut Self; a coverage-preopen failure is a misconfigured test run" + )] + { + let cov_dir = edge_toolkit::config::get_project_root().join("target/wasi-cov"); + fs_err::create_dir_all(&cov_dir).expect("create target/wasi-cov"); + builder + .preopened_dir(&cov_dir, "/cov", DirPerms::all(), FilePerms::all()) + .expect("preopen /cov for wasm coverage"); + } + } + let wasi_ctx = builder.build(); Self { wasi_ctx, diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index 027d62d1..d23ea59b 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -34,9 +34,10 @@ pub async fn run_module( module_name: &str, ws_url: &str, connect_ack_timeout: Option, + coverage: bool, ) -> Result<(), RunnerError> { let span = tracing::info_span!("run_module", module = module_name); - run_module_inner(module_name, ws_url, connect_ack_timeout) + run_module_inner(module_name, ws_url, connect_ack_timeout, coverage) .instrument(span) .await } @@ -49,6 +50,7 @@ async fn run_module_inner( module_name: &str, ws_url: &str, connect_ack_timeout: Option, + coverage: bool, ) -> Result<(), RunnerError> { let http_base = derive_http_base(ws_url)?; @@ -78,7 +80,7 @@ async fn run_module_inner( bindings::Runner::add_to_linker::>(&mut linker, |state| state)?; wasmtime_wasi_nn::wit::add_to_linker(&mut linker, host::wasi_nn::view)?; - let host_state = HostState::new(&http_base, ws_url.to_string(), connect_ack_timeout); + let host_state = HostState::new(&http_base, ws_url.to_string(), connect_ack_timeout, coverage); let mut store = Store::new(&engine, host_state); let module = bindings::Runner::instantiate_async(&mut store, &component, &linker).await?; diff --git a/services/ws-wasi-runner/src/main.rs b/services/ws-wasi-runner/src/main.rs index 08b18c5a..1f3b6c70 100644 --- a/services/ws-wasi-runner/src/main.rs +++ b/services/ws-wasi-runner/src/main.rs @@ -22,7 +22,12 @@ async fn main() -> Result<(), Box> { let module = &config.runner.module; let ws_url = &config.ws.server_url; let timeout = config.runner.timeout; - let run = run_module(module, ws_url, config.ws.connect_ack_timeout); + // Coverage preopen exists only under the `coverage` feature; there `ET_TEST_COVERAGE` activates it. + #[cfg(feature = "coverage")] + let coverage = config.et_test_coverage; + #[cfg(not(feature = "coverage"))] + let coverage = false; + let run = run_module(module, ws_url, config.ws.connect_ack_timeout, coverage); // `None` outcome == timed out; `Some(_)` carries the module's own result. let outcome = if let Some(limit) = timeout { info!("et-ws-wasi-runner: module={module} server={ws_url} timeout={limit:?}"); diff --git a/services/ws-wasi-runner/tests/modules.rs b/services/ws-wasi-runner/tests/modules.rs index aace892c..7d693d26 100644 --- a/services/ws-wasi-runner/tests/modules.rs +++ b/services/ws-wasi-runner/tests/modules.rs @@ -29,6 +29,8 @@ fn module_runs_successfully(#[case] module: &str, #[case] language: Language) { // exit(0) short-circuit so ORT 1.22's libc++ teardown race doesn't surface // as a None exit code (see main.rs for the exact stderr signature). // No-op on Linux/Windows. + // ET_TEST_COVERAGE, when set by the coverage workflow, is inherited by the child (Command keeps the parent + // env), so the runner preopens /cov and instrumented guests dump their .profraw -- no forwarding needed. let status = std::process::Command::new(bin) .env("RUNNER_MODULE", module) .env("WS_SERVER_URL", &server.ws_url) diff --git a/services/ws-wasi-runner/tests/vector_otlp_relay.rs b/services/ws-wasi-runner/tests/vector_otlp_relay.rs index 37747829..eaf68d93 100644 --- a/services/ws-wasi-runner/tests/vector_otlp_relay.rs +++ b/services/ws-wasi-runner/tests/vector_otlp_relay.rs @@ -164,9 +164,14 @@ fn otlp_trace_request() -> Vec { request.encode_to_vec() } -/// Poll the mock (via `retry`) until a span named [`SPAN_NAME`] arrives; ~30s. +/// Poll the mock (via `retry`) until a span named [`SPAN_NAME`] arrives; ~120s. +/// +/// Store-and-forward redelivery is inherently latent: Vector retries the initially-dead sink with an exponential +/// backoff (`retry_initial_backoff_secs=1`, doubling), so when its first attempts race the mock's listener coming +/// up, the next retry can land tens of seconds later. The old 30s ceiling intermittently timed that out on cold +/// CI runners; the poll returns the instant the span lands, so the wider ceiling costs nothing on the happy path. fn wait_for_relayed_span(mock: &int_otlp_mock::OtlpMock) -> Option { - retry(Fixed::from_millis(250).take(120), || { + retry(Fixed::from_millis(250).take(480), || { mock.flatten_spans() .into_iter() .find(|span| span.name == SPAN_NAME) diff --git a/services/ws-wasm-agent/Cargo.toml b/services/ws-wasm-agent/Cargo.toml index fc4fb6a2..470d5a57 100644 --- a/services/ws-wasm-agent/Cargo.toml +++ b/services/ws-wasm-agent/Cargo.toml @@ -35,6 +35,7 @@ web-sys = { workspace = true, features = [ ] } [dev-dependencies] +wasm-bindgen-futures.workspace = true wasm-bindgen-test.workspace = true [lints] diff --git a/services/ws-wasm-agent/src/lib.rs b/services/ws-wasm-agent/src/lib.rs index c2feb38e..8575e2da 100644 --- a/services/ws-wasm-agent/src/lib.rs +++ b/services/ws-wasm-agent/src/lib.rs @@ -682,11 +682,10 @@ impl WsClient { self.send(&payload) } - pub fn send_agent_message>( - &self, - to_agent_id: T, - message: serde_json::Value, - ) -> Result<(), JsValue> { + pub fn send_agent_message(&self, to_agent_id: T, message: serde_json::Value) -> Result<(), JsValue> + where + T: Into, + { let payload = serde_json::to_string(&ClientMessage::SendAgentMessage { to_agent_id: to_agent_id.into(), message, @@ -695,12 +694,11 @@ impl WsClient { self.send(&payload) } - pub fn send_client_event, A: Into>( - &self, - capability: C, - action: A, - details: serde_json::Value, - ) -> Result<(), JsValue> { + pub fn send_client_event(&self, capability: C, action: A, details: serde_json::Value) -> Result<(), JsValue> + where + C: Into, + A: Into, + { let message = ClientMessage::ClientEvent { capability: capability.into(), action: action.into(), diff --git a/services/ws-wasm-agent/tests/web.rs b/services/ws-wasm-agent/tests/web.rs index 4de90662..2d6426e7 100644 --- a/services/ws-wasm-agent/tests/web.rs +++ b/services/ws-wasm-agent/tests/web.rs @@ -20,11 +20,11 @@ async fn test_websocket_connection() { // Give it a second to actually connect let promise = Promise::new(&mut |resolve, _| { let window = web_sys::window().unwrap(); - window + let _timeout_id = window .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 1000) .unwrap(); }); - let _ = JsFuture::from(promise).await; + drop(JsFuture::from(promise).await); // Assert connection state is successfully connected or at least it didn't fail let state = client.get_state(); diff --git a/services/ws-web-runner/Cargo.toml b/services/ws-web-runner/Cargo.toml index dffdf8ae..96a63c75 100644 --- a/services/ws-web-runner/Cargo.toml +++ b/services/ws-web-runner/Cargo.toml @@ -54,7 +54,15 @@ fs-err.workspace = true [dev-dependencies] et-ws-test-server.workspace = true +fs-err.workspace = true rstest.workspace = true +# `coverage` (off by default): compiles the browser-module coverage-capture code into the runner. +# It adds the pycov + ws-agent-id shims, the __ET_TEST_COVERAGE prelude, and the wrapper's __et_capture_coverage +# PUT. Only the coverage workflow builds with it (and sets ET_TEST_COVERAGE, which still gates capture at runtime); +# every other build omits the code entirely. See the collect side in tests/modules.rs. +[features] +coverage = [] + [lints] workspace = true diff --git a/services/ws-web-runner/build.rs b/services/ws-web-runner/build.rs index d0a6f11c..79c658e6 100644 --- a/services/ws-web-runner/build.rs +++ b/services/ws-web-runner/build.rs @@ -25,6 +25,7 @@ fn main() { println!("cargo:rerun-if-changed=mingw-shim/msvc_crt_shim.c"); println!("cargo:rerun-if-changed=mingw-shim/msvc_crt_ops.s"); println!("cargo:rerun-if-changed=mingw-shim/msvc_crt_locale.c"); + println!("cargo:rerun-if-changed=mingw-shim/msvc_crt_alloc.c"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); @@ -48,6 +49,12 @@ fn main() { let locale_args = ["-c", "-O2", "-o", &locale_obj, "mingw-shim/msvc_crt_locale.c"]; run(std::process::Command::new(gcc.path()).args(locale_args)); + // msvc_crt_alloc.c (operator new/delete + _dupenv_s) is a standalone object for the same reason: _dupenv_s + // must intercept -lmsvcrt, and the operator-new symbols resolve the msvc_crt_ops.s jumps in the archive. + let alloc_obj = format!("{out_dir}/msvc_crt_alloc.o"); + let alloc_args = ["-c", "-O2", "-o", &alloc_obj, "mingw-shim/msvc_crt_alloc.c"]; + run(std::process::Command::new(gcc.path()).args(alloc_args)); + // The msvc archive embeds `/defaultlib:libcmt` + `/defaultlib:oldnames` directives. lld honours them // (ld.bfd ignores directives) and errors when the libs don't exist; MSVC's static CRT has no mingw // equivalent -- the shim + import libs below stand in for it. Satisfy the directives with empty @@ -72,6 +79,7 @@ fn main() { .current_dir(&out_dir)); println!("cargo:rustc-link-arg={locale_obj}"); + println!("cargo:rustc-link-arg={alloc_obj}"); println!("cargo:rustc-link-arg={out_dir}/libmsvc_crt_shim.a"); println!("cargo:rustc-link-arg=-lvcruntime140"); println!("cargo:rustc-link-arg=-lucrtbase"); diff --git a/services/ws-web-runner/mingw-shim/msvc_crt_alloc.c b/services/ws-web-runner/mingw-shim/msvc_crt_alloc.c new file mode 100644 index 00000000..fc35a878 --- /dev/null +++ b/services/ws-web-runner/mingw-shim/msvc_crt_alloc.c @@ -0,0 +1,93 @@ +/* Heap-allocation shims for the msvc rusty_v8 archive, split out of msvc_crt_shim.c / msvc_crt_locale.c. + * This is the shim's only dynamic-allocation code: the operator new/delete impls forward C++ allocation to the + * mingw heap, and _dupenv_s hands back a heap-owned copy of an environment variable. Both intrinsically call the + * allocation functions MISRA 21.3 forbids -- you cannot implement an allocator without an allocator -- + * so this code is isolated here precisely so the one analyzer that cannot suppress that rule per line (Codacy's + * cxx / cppcheck, which offers only path-level excludes) can exclude just this file while the rest of the shim + * stays fully analyzed. It remains covered by DeepSource's clang-tidy and the repo's own clang-tidy / cpplint / + * flawfinder. Like msvc_crt_locale.c it links as a standalone object so _dupenv_s intercepts -lmsvcrt, and the + * operator-new symbols resolve the msvc_crt_ops.s jumps in the shim archive. */ + +#include +#include +#include +#include + +/* _dupenv_s, reimplemented on the msvcrt heap so allocation and release agree. + * The real one is ucrt-only, so it binds to ucrtbase and would allocate from the UCRT heap -- but the + * archive frees the returned buffer with `free`, which is msvcrt-bound. errno values per the MSVC contract. + * Reads the value with Win32 GetEnvironmentVariableA rather than stdlib getenv: getenv is untrusted-input-prone + * (MISRA 21.8), whereas GetEnvironmentVariableA reports the length itself, so no strlen over-read (CWE-126). */ +// _dupenv_s is declared __declspec(dllimport) by ucrt's stdlib.h, but here we define it on the mingw heap. +// The redeclaration drops dllimport by design -- the archive links our definition, not an import thunk. The +// leading-underscore name is the CRT's, mandated by the archive's ABI, so the reserved-identifier check is +// suppressed alongside it, as on the other shim symbols. +// NOLINTNEXTLINE(clang-diagnostic-inconsistent-dllimport, bugprone-reserved-identifier, cert-dcl37-c) +int _dupenv_s(char **buf, size_t *len, const char *name) { + if ((buf == NULL) || (name == NULL)) { + return 22; /* EINVAL */ + } + *buf = NULL; + if (len != NULL) { + *len = 0; + } + /* First call sizes the value (return includes the NUL); 0 means the variable is unset. */ + DWORD size = GetEnvironmentVariableA(name, NULL, 0); + if (size == 0U) { + return 0; + } + char *out = malloc(size); + if (out == NULL) { + return 12; /* ENOMEM */ + } + /* Second call fills the buffer; its return excludes the NUL. + * A value >= size means the variable changed between the calls (another thread), so treat it as unset. */ + DWORD written = GetEnvironmentVariableA(name, out, size); + if ((written == 0U) || (written >= size)) { + free(out); + return 0; + } + *buf = out; + if (len != NULL) { + *len = (size_t)size; /* buffer size incl NUL, matching the old strlen(value) + 1 */ + } + return 0; +} + +/* MSVC C++ operator new/delete impls (statically linked in MSVC's CRT), forwarded to the mingw heap. + * V8 frees what it allocates, so pairing stays within one heap. Throwing-new degrades to abort-on-OOM. */ +void *shim_op_new(size_t n) { + void *p = malloc(n ? n : 1U); + if (!p) { + abort(); + } + return p; +} +void *shim_op_new_nothrow(size_t n, void *tag) { + (void)tag; + return malloc(n ? n : 1U); +} +void *shim_op_new_aligned(size_t n, size_t align) { + void *p = _aligned_malloc(n ? n : 1U, align); + if (!p) { + abort(); + } + return p; +} +void shim_op_delete(void *p) { free(p); } +void shim_op_delete_sized(void *p, size_t n) { + (void)n; + free(p); +} +void shim_op_delete_aligned(void *p, size_t align) { + (void)align; + _aligned_free(p); +} +// The (size, align) params match the MSVC sized/aligned operator-delete ABI, so their order is fixed here. +// `n` (the size) is unused, so the swap-risk warning does not apply. +// NOLINTNEXTLINE(bugprone-easily-swappable-parameters) +void shim_op_delete_sized_aligned(void *p, size_t n, size_t align) { + (void)n; + (void)align; + _aligned_free(p); +} diff --git a/services/ws-web-runner/mingw-shim/msvc_crt_locale.c b/services/ws-web-runner/mingw-shim/msvc_crt_locale.c index 8799ea93..15999de6 100644 --- a/services/ws-web-runner/mingw-shim/msvc_crt_locale.c +++ b/services/ws-web-runner/mingw-shim/msvc_crt_locale.c @@ -22,7 +22,6 @@ * members define these same names and would collide. */ #include -#include #include static void *ucrt_sym(const char *name) { @@ -57,6 +56,7 @@ void *localeconv(void) { return fn(); } +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void *_create_locale(int category, const char *locale) { static void *(*fn)(int, const char *); if (fn == NULL) { @@ -65,6 +65,7 @@ void *_create_locale(int category, const char *locale) { return fn(category, locale); } +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void _free_locale(void *locale) { static void (*fn)(void *); if (fn == NULL) { @@ -72,29 +73,3 @@ void _free_locale(void *locale) { } fn(locale); } - -/* _dupenv_s, reimplemented on the msvcrt heap so allocation and release agree. - * The real one is ucrt-only, so it binds to ucrtbase and would allocate from the UCRT heap -- but the - * archive frees the returned buffer with `free`, which is msvcrt-bound. errno values per the MSVC - * contract. */ -int _dupenv_s(char **buf, size_t *len, const char *name) { - if (buf == NULL || name == NULL) { - return 22; /* EINVAL */ - } - *buf = NULL; - if (len != NULL) { - *len = 0; - } - const char *value = getenv(name); - if (value == NULL) { - return 0; - } - *buf = _strdup(value); - if (*buf == NULL) { - return 12; /* ENOMEM */ - } - if (len != NULL) { - *len = strlen(value) + 1; - } - return 0; -} diff --git a/services/ws-web-runner/mingw-shim/msvc_crt_shim.c b/services/ws-web-runner/mingw-shim/msvc_crt_shim.c index aff9cbcd..d737193e 100644 --- a/services/ws-web-runner/mingw-shim/msvc_crt_shim.c +++ b/services/ws-web-runner/mingw-shim/msvc_crt_shim.c @@ -4,7 +4,12 @@ * libvcruntime.lib) -- mingw-w64 has no equivalent, so this file supplies them. Symbols exported by * vcruntime140.dll / ucrtbase.dll are NOT shimmed; build.rs links winlibs' import libs for those. * MSVC-mangled `??...` names live in msvc_crt_ops.s (GAS only accepts `?` in quoted symbols), jumping to - * the plain-named impls below -- MSVC x64 and mingw x64 share the Microsoft x64 calling convention. */ + * the plain-named impls below -- MSVC x64 and mingw x64 share the Microsoft x64 calling convention. + * These symbols must keep the CRT's exact reserved names (`_`/`__`-prefixed) and, for the mutable ones, + * non-const storage, to match the archive's ABI -- renaming or const-ing them would break the link. Each + * therefore carries an inline clang-tidy `// NOLINT` for bugprone-reserved-identifier / cert-dcl37-c (plus + * cppcoreguidelines-avoid-non-const-global-variables on the globals) -- the narrowest scope, honored by both + * our clang-tidy and DeepSource's clang-tidy-based cxx (CXX-E2000 reserved identifier, CXX-W2009 non-const). */ #include #include @@ -13,18 +18,23 @@ /* MSVC's "floating point used" CRT marker; the value is what MSVC's own CRT sets. * mingw-w64 keeps its copy in a dedicated archive member that this force-linked object shadows without * collision. */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cppcoreguidelines-avoid-non-const-global-variables) int _fltused = 0x9875; /* winlibs' libucrtbase.a import lib lacks _strtold_l. * MSVC long double IS double (both return in xmm0 with identical argument registers), so forward to the * _strtod_l import untouched. */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) __attribute__((naked)) void _strtold_l(void) { __asm__("jmp _strtod_l"); } /* /GS stack cookie (libvcruntime static). Fixed default cookie; the check is a no-op. */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cppcoreguidelines-avoid-non-const-global-variables) uintptr_t __security_cookie = 0x00002B992DDFA232ULL; +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) __attribute__((naked)) void __security_check_cookie(void) { __asm__("ret"); } /* MSVC stack probe: same contract as libgcc's ___chkstk_ms (rax = frame size, all regs preserved). */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) __attribute__((naked)) void __chkstk(void) { __asm__("jmp ___chkstk_ms"); } /* Control Flow Guard dispatch/check pointers, with the non-CFG default behaviour. @@ -32,7 +42,9 @@ __attribute__((naked)) void __chkstk(void) { __asm__("jmp ___chkstk_ms"); } * jumps to rax, check returns. */ __attribute__((naked)) static void guard_dispatch_impl(void) { __asm__("jmp *%rax"); } __attribute__((naked)) static void guard_check_impl(void) { __asm__("ret"); } +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cppcoreguidelines-avoid-non-const-global-variables) void (*__guard_dispatch_icall_fptr)(void) = guard_dispatch_impl; +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cppcoreguidelines-avoid-non-const-global-variables) void (*__guard_check_icall_fptr)(void) = guard_check_impl; /* Thread-safe static init (vcruntime's thread_safe_statics.cpp contract). @@ -43,6 +55,7 @@ void (*__guard_check_icall_fptr)(void) = guard_check_impl; * be emutls, which MSVC SECREL relocations can't bind to) and stays at INT_MIN forever: after init the * call site then re-enters header on every access, which just reads the completed guard and returns. * Slower than MSVC's epoch fast-path but correct. */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void _Init_thread_header(volatile int *g) { static SRWLOCK lock = SRWLOCK_INIT; AcquireSRWLockExclusive(&lock); @@ -51,8 +64,9 @@ void _Init_thread_header(volatile int *g) { *g = -1; break; } /* claim: caller runs the initializer */ - if (*g != -1) - break; /* completed by another thread */ + if (*g != -1) { + break; /* completed by another thread */ + } ReleaseSRWLockExclusive(&lock); /* in progress elsewhere: spin politely */ Sleep(0); AcquireSRWLockExclusive(&lock); @@ -60,13 +74,16 @@ void _Init_thread_header(volatile int *g) { ReleaseSRWLockExclusive(&lock); } +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void _Init_thread_footer(volatile int *g) { *g = 1; } +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void _Init_thread_abort(volatile int *g) { *g = 0; } /* MSVC dynamic-TLS on-demand hook. * The __tls_guard TLS byte (msvc_crt_ops.s) is pre-set to 1, so MSVC call sites skip this; the mingw-w64 * crt's TLS callback already walks the .CRT$XD* initializer table. */ +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c) void __dyn_tls_on_demand_init(void) {} /* Chromium libc++'s verbose abort, which exists only to die loudly. @@ -75,36 +92,3 @@ _Noreturn void shim_libcpp_verbose_abort(const char *fmt, ...) { (void)fmt; abort(); } - -/* MSVC C++ operator new/delete impls (statically linked in MSVC's CRT), forwarded to the mingw heap. - * V8 frees what it allocates, so pairing stays within one heap. Throwing-new degrades to abort-on-OOM. */ -void *shim_op_new(size_t n) { - void *p = malloc(n ? n : 1); - if (!p) - abort(); - return p; -} -void *shim_op_new_nothrow(size_t n, void *tag) { - (void)tag; - return malloc(n ? n : 1); -} -void *shim_op_new_aligned(size_t n, size_t align) { - void *p = _aligned_malloc(n ? n : 1, align); - if (!p) - abort(); - return p; -} -void shim_op_delete(void *p) { free(p); } -void shim_op_delete_sized(void *p, size_t n) { - (void)n; - free(p); -} -void shim_op_delete_aligned(void *p, size_t align) { - (void)align; - _aligned_free(p); -} -void shim_op_delete_sized_aligned(void *p, size_t n, size_t align) { - (void)n; - (void)align; - _aligned_free(p); -} diff --git a/services/ws-web-runner/src/config.rs b/services/ws-web-runner/src/config.rs index 504931e1..bd19e029 100644 --- a/services/ws-web-runner/src/config.rs +++ b/services/ws-web-runner/src/config.rs @@ -21,4 +21,13 @@ pub struct Config { /// dotnet-data1 crash (e.g. `--no-liftoff`, `--liftoff-only`, `--jitless`). #[serde(default)] pub v8_flags: Option, + /// Runtime activation of the browser-module coverage capture, read from `ET_TEST_COVERAGE`. + /// + /// Only present under the `coverage` cargo feature -- the capture code is compiled in only there. When the + /// feature is on, `ET_TEST_COVERAGE=true` makes each module PUT its minicov `.profraw` / coverage.py data to + /// its own storage bucket for the web-runner test to gather; `false` (the default) leaves the compiled-in + /// capture inert. serde-env parses the value with `str::parse::`, so it must be `true`/`false`. + #[cfg(feature = "coverage")] + #[serde(default)] + pub et_test_coverage: bool, } diff --git a/services/ws-web-runner/src/lib.rs b/services/ws-web-runner/src/lib.rs index 8938fcee..a49d0e5d 100644 --- a/services/ws-web-runner/src/lib.rs +++ b/services/ws-web-runner/src/lib.rs @@ -26,7 +26,7 @@ pub use crate::error::RunnerError; clippy::future_not_send, reason = "MainWorker is !Send; the caller must use a current_thread tokio runtime" )] -pub async fn run_module(module_name: &str, ws_url: &str) -> Result<(), RunnerError> { +pub async fn run_module(module_name: &str, ws_url: &str, coverage: bool) -> Result<(), RunnerError> { // Ensure a rustls crypto provider is installed (needed by deno_tls/deno_fetch). // MainWorker bootstrap installs one but the workspace `et-rest-client` we // use ahead of MainWorker (to fetch package.json) also wants a provider, so @@ -42,7 +42,7 @@ pub async fn run_module(module_name: &str, ws_url: &str) -> Result<(), RunnerErr tracing::info!(%entry_url, "running module JS"); - runtime::run_js_module(&entry_url, &http_base, ws_url, rest).await?; + runtime::run_js_module(&entry_url, &http_base, ws_url, rest, coverage).await?; Ok(()) } diff --git a/services/ws-web-runner/src/main.rs b/services/ws-web-runner/src/main.rs index 1cefd122..37ab57d3 100644 --- a/services/ws-web-runner/src/main.rs +++ b/services/ws-web-runner/src/main.rs @@ -21,7 +21,12 @@ async fn main() -> Result<(), Box> { info!("applied V8_FLAGS: {v8_flags}"); } - let run = run_module(module, ws_url); + // Coverage capture exists only under the `coverage` feature; there `ET_TEST_COVERAGE` activates it. + #[cfg(feature = "coverage")] + let coverage = config.et_test_coverage; + #[cfg(not(feature = "coverage"))] + let coverage = false; + let run = run_module(module, ws_url, coverage); let result = if let Some(timeout) = config.runner.timeout { info!("et-ws-web-runner: module={module} server={ws_url} timeout={timeout:?}"); match tokio::time::timeout(timeout, run).await { diff --git a/services/ws-web-runner/src/runtime.rs b/services/ws-web-runner/src/runtime.rs index b14b73e4..ce089d1a 100644 --- a/services/ws-web-runner/src/runtime.rs +++ b/services/ws-web-runner/src/runtime.rs @@ -110,19 +110,105 @@ const SHIMS: &[&str] = &[ include_str!("shims/xhr.js"), ]; +/// Coverage-capture shim fragments, compiled in only under the `coverage` feature. +/// +/// `ws_agent_id` sniffs the module's server-assigned `agent_id` (its storage bucket); `pycov` defines the Pyodide +/// coverage.py helper. Both are inert until `globalThis.__ET_TEST_COVERAGE` is set (see `shim_js`). +#[cfg(feature = "coverage")] +const COVERAGE_SHIMS: &[&str] = &[include_str!("shims/ws_agent_id.js"), include_str!("shims/pycov.js")]; + /// Render the full shim for a run. /// /// Emits the per-run URL globals -- the ws-server's HTTP base (used for /// `location` and module URL resolution) and the WebSocket URL (exposed on /// `globalThis.__ET_WS_URL`) -- then concatenates every `shims/*.js` fragment. -fn shim_js(http_base: &str, ws_url: &str) -> String { - let prelude = format!("globalThis.__ET_HTTP_BASE = {http_base:?};\nglobalThis.__ET_WS_URL = {ws_url:?};"); +#[cfg_attr( + not(feature = "coverage"), + expect( + unused_variables, + reason = "coverage is only read into the shim under the `coverage` feature" + ) +)] +fn shim_js(http_base: &str, ws_url: &str, coverage: bool) -> String { + // Under the `coverage` feature, ET_TEST_COVERAGE (threaded in as `coverage`) surfaces as the + // globalThis.__ET_TEST_COVERAGE the coverage shims key off. Without the feature the flag has no effect. + #[cfg(feature = "coverage")] + let coverage_line = if coverage { + "\nglobalThis.__ET_TEST_COVERAGE = true;" + } else { + "" + }; + #[cfg(not(feature = "coverage"))] + let coverage_line = ""; + let prelude = + format!("globalThis.__ET_HTTP_BASE = {http_base:?};\nglobalThis.__ET_WS_URL = {ws_url:?};{coverage_line}"); + let shims = SHIMS.iter().copied(); + #[cfg(feature = "coverage")] + let shims = shims.chain(COVERAGE_SHIMS.iter().copied()); std::iter::once(prelude.as_str()) - .chain(SHIMS.iter().copied()) + .chain(shims) .collect::>() .join("\n") } +/// Build the inline ES-module wrapper that imports and runs the fetched module. +/// +/// Dynamic `import()` works from ES-module context but not from `execute_script`, so we synthesise a side ES +/// module that awaits the module's `default`/`run` export. Under the `coverage` feature the `finally` also +/// captures the module's minicov `.profraw` via et-web's `__et_capture_coverage` export (browser wasm has no +/// filesystem) and PUTs it to the module's own agent bucket -- a registered agent (its id sniffed from +/// et-connect-ack by the ws-agent-id shim), so `put_file`'s agent check passes; the web-runner test then scans +/// every bucket for the .profraw. The `finally` means fail-fast modules still dump what they exercised. Without +/// the feature the capture is empty, leaving a bare `finally {}`. +#[expect( + clippy::single_call_fn, + reason = "distinct wrapper-synthesis step; extracted to keep run_js_module small" +)] +fn build_wrapper_code(entry_url: &str) -> String { + #[cfg(feature = "coverage")] + let capture_block = { + let cov_name = entry_url + .rsplit('/') + .next() + .and_then(|file| file.strip_suffix(".js")) + .unwrap_or("module"); + format!( + r#" + if (globalThis.__ET_TEST_COVERAGE && globalThis.__ET_AGENT_ID + && typeof mod.__et_capture_coverage === "function") {{ + const covData = mod.__et_capture_coverage(); + if (covData && covData.length) {{ + const covBase = typeof globalThis.__ET_HTTP_BASE === "string" ? globalThis.__ET_HTTP_BASE : ""; + const covUrl = covBase + "/storage/" + globalThis.__ET_AGENT_ID + "/{cov_name}.profraw"; + await fetch(covUrl, {{ method: "PUT", body: covData }}); + }} + }}"# + ) + }; + #[cfg(not(feature = "coverage"))] + let capture_block = ""; + format!( + r#" +const mod = await import("{entry_url}"); +let invoked = false; +try {{ + if (typeof mod.default === "function") {{ + await mod.default(); + invoked = true; + }} + if (typeof mod.run === "function") {{ + await mod.run(); + invoked = true; + }} + if (!invoked) {{ + throw new Error("module {entry_url} exports neither a `default` nor a `run` function"); + }} +}} finally {{{capture_block} +}} +"# + ) +} + /// Build the `CreateWebWorkerCb` that spawns child `WebWorker`s on fresh OS threads. /// /// The closure captures the bits a worker needs (REST client to build its own @@ -135,6 +221,7 @@ fn create_web_worker_cb( sab_store: CrossIsolateStore>, http_base: String, ws_url: String, + coverage: bool, ) -> Arc { Arc::new(move |args| { let rest = rest.clone(); @@ -152,6 +239,7 @@ fn create_web_worker_cb( sab_store.clone(), http_base.clone(), ws_url.clone(), + coverage, ); let services = WebWorkerServiceOptions::, RealSys> { @@ -207,7 +295,7 @@ fn create_web_worker_cb( // before any module code runs. `bootstrap_from_options` returns // a `(WebWorker, SendableWebWorkerHandle)` tuple. let (mut worker, handle) = WebWorker::bootstrap_from_options(services, options); - let shim = shim_js(&http_base, &ws_url); + let shim = shim_js(&http_base, &ws_url, coverage); // No `Result` channel in this callback, so log and continue. if let Err(e) = worker.js_runtime.execute_script("", shim) { tracing::error!( @@ -237,6 +325,7 @@ pub async fn run_js_module( http_base: &str, ws_url: &str, rest: et_rest_client::Client, + coverage: bool, ) -> Result<(), CoreError> { let module_loader: Rc = Rc::new(ServerModuleLoader { rest: rest.clone() }); @@ -273,7 +362,8 @@ pub async fn run_js_module( ..Default::default() }; - let create_web_worker_cb = create_web_worker_cb(rest, fs, sab_store, http_base.to_string(), ws_url.to_string()); + let create_web_worker_cb = + create_web_worker_cb(rest, fs, sab_store, http_base.to_string(), ws_url.to_string(), coverage); let main_specifier = ModuleSpecifier::parse(entry_url).map_js_err()?; let mut worker = MainWorker::bootstrap_from_options::, RealSys>( &main_specifier, @@ -293,29 +383,11 @@ pub async fn run_js_module( drop( worker .js_runtime - .execute_script("", shim_js(http_base, ws_url))?, + .execute_script("", shim_js(http_base, ws_url, coverage))?, ); - // Load + run the module via an inline wrapper: dynamic `import()` works - // from ES-module context but not from `execute_script`, so synthesise a - // side ES module. - let wrapper_code = format!( - r#" -const mod = await import("{entry_url}"); -let invoked = false; -if (typeof mod.default === "function") {{ - await mod.default(); - invoked = true; -}} -if (typeof mod.run === "function") {{ - await mod.run(); - invoked = true; -}} -if (!invoked) {{ - throw new Error("module {entry_url} exports neither a `default` nor a `run` function"); -}} -"# - ); + // Coverage capture (compiled in only under the `coverage` feature), run in the wrapper's `finally`. + let wrapper_code = build_wrapper_code(entry_url); let wrapper_specifier = ModuleSpecifier::parse("internal:///runner-wrapper.js")?; let wrapper_id = worker .js_runtime diff --git a/services/ws-web-runner/src/shims/pycov.js b/services/ws-web-runner/src/shims/pycov.js new file mode 100644 index 00000000..7e9ee5d9 --- /dev/null +++ b/services/ws-web-runner/src/shims/pycov.js @@ -0,0 +1,37 @@ +// Pyodide coverage collection, active only under the web-runner when ET_TEST_COVERAGE is set. +// The runner surfaces that env var as globalThis.__ET_TEST_COVERAGE (see runtime.rs); when it is present this +// fragment defines globalThis.__etPyCov, a {start, stop} pair the Pyodide module shims call around their +// pyimport + run(). coverage.py ships in the pinned Pyodide distribution (a coverage wasm wheel under +// /modules/pyodide/), so loadPackage pulls it with no network. start() begins tracing before the module is +// imported; stop() writes the .coverage data file and PUTs it to the module's own agent storage bucket +// (/storage//.coverage), where the web-runner integration test collects it for the combined +// Python coverage report. In a browser (no __ET_TEST_COVERAGE) nothing here runs, so shipped modules are +// unaffected. +if (globalThis.__ET_TEST_COVERAGE) { + globalThis.__etPyCov = { + // Load coverage.py from the local Pyodide dist and start tracing `pkg` before it is imported, so + // import-time lines are captured too. Runs synchronously in the Pyodide interpreter. + async start(pyodide, pkg) { + await pyodide.loadPackage("coverage"); + pyodide.runPython(` +import coverage as _et_cov_mod +_et_cov = _et_cov_mod.Coverage(data_file="/tmp/${pkg}.coverage", source=["${pkg}"]) +_et_cov.start() +`); + }, + // Stop tracing, persist the .coverage data file, and PUT it to the module's own agent storage bucket. + // The bucket is the server-assigned agent_id (captured by the ws-agent-id shim) because put_file only accepts + // a registered agent; the web-runner test then scans every agent bucket for the .coverage. Skipped when the + // module never registered an agent (no __ET_AGENT_ID). A PUT failure fails the run -- coverage is its purpose. + async stop(pyodide, pkg) { + pyodide.runPython("_et_cov.stop()\n_et_cov.save()\n"); + const agentId = globalThis.__ET_AGENT_ID; + if (!agentId) { + return; + } + const data = pyodide.FS.readFile(`/tmp/${pkg}.coverage`); + const base = typeof globalThis.__ET_HTTP_BASE === "string" ? globalThis.__ET_HTTP_BASE : ""; + await fetch(`${base}/storage/${agentId}/${pkg}.coverage`, { method: "PUT", body: data }); + }, + }; +} diff --git a/services/ws-web-runner/src/shims/ws_agent_id.js b/services/ws-web-runner/src/shims/ws_agent_id.js new file mode 100644 index 00000000..f028991b --- /dev/null +++ b/services/ws-web-runner/src/shims/ws_agent_id.js @@ -0,0 +1,26 @@ +// Capture the server-assigned agent_id from the et-connect-ack frame, for the coverage test only. +// Browser wasm has no filesystem, so a module's coverage is PUT to ws-server storage -- but put_file only accepts +// a bucket that is a registered agent. The module's own agent_id IS registered, so the runner PUTs there; this +// shim wraps globalThis.WebSocket to observe the inbound et-connect-ack and stash agent_id on globalThis. Inert +// unless __ET_TEST_COVERAGE is set, so shipped runs keep the native WebSocket untouched. +if (globalThis.__ET_TEST_COVERAGE && typeof globalThis.WebSocket === "function") { + const NativeWebSocket = globalThis.WebSocket; + globalThis.WebSocket = class extends NativeWebSocket { + constructor(...args) { + super(...args); + this.addEventListener("message", (event) => { + if (typeof event.data !== "string") { + return; + } + try { + const message = JSON.parse(event.data); + if (message?.type === "et-connect-ack" && typeof message.agent_id === "string") { + globalThis.__ET_AGENT_ID = message.agent_id; + } + } catch { + /* Non-JSON or non-ack frames are irrelevant to agent-id capture. */ + } + }); + } + }; +} diff --git a/services/ws-web-runner/tests/modules.rs b/services/ws-web-runner/tests/modules.rs index 246ee80d..9fb79fa2 100644 --- a/services/ws-web-runner/tests/modules.rs +++ b/services/ws-web-runner/tests/modules.rs @@ -42,6 +42,8 @@ )] use edge_toolkit::config::{Language, mise_env_includes}; +#[cfg(feature = "coverage")] +use fs_err as fs; use rstest::rstest; #[rstest] @@ -71,6 +73,8 @@ fn module_runs_successfully(#[case] module: &str, #[case] language: Language) { } let server = et_ws_test_server::start(); run_runner_with_timeout(module, &server.ws_url, 90); + #[cfg(feature = "coverage")] + collect_module_coverage(&server); } /// dotnet-data1's `pkg/` wasm artifacts only exist after `build-ws-dotnet-data1-module` has run on this @@ -128,20 +132,123 @@ fn multi_agent_module(#[case] module: &str, #[case] language: Language) { assert!(failed.is_empty(), "{module} failed in: {}", failed.join(", ")); } -/// Spawn one `et-ws-web-runner` against `ws_url` and panic with the -/// captured stdout/stderr on non-zero exit. `timeout_secs` is passed as -/// `RUNNER_TIMEOUT` (humantime, e.g. `120s`); the multi-agent harness bumps -/// it because two cold V8 starts contending for the same box widen the -/// discovery window past the single-agent budget. -fn run_runner_with_timeout(module: &str, ws_url: &str, timeout_secs: u32) { +/// Load + run each hardware/sensor module and assert it fails without the device API it needs. +/// +/// The modules listed under "Hardware / browser-only APIs" above touch a browser sensor/media API the runner's +/// shims deliberately do NOT provide (only `navigator.userAgent` is stubbed) -- camera `getUserMedia`, +/// `DeviceMotionEvent`, geolocation, `navigator.bluetooth`, `NDEFReader`, `webkitSpeechRecognition`. So they +/// cannot complete under Deno, which is why they are excluded from `module_runs_successfully`. Running them +/// anyway still exercises the fetch + module-graph load + entry-evaluation paths -- in the runner AND in each +/// module's wasm-bindgen glue up to the point it reaches for the missing API -- which is otherwise uncovered +/// (har1 and face-detection especially). We assert a non-zero exit: the module either throws when the absent API +/// is touched or is killed by `RUNNER_TIMEOUT` while awaiting a device callback that never fires. A module that +/// unexpectedly EXITS 0 here is a real finding -- the runner can now run it, so move it to +/// `module_runs_successfully`. +#[rstest] +#[case::audio1("et-ws-audio1", Language::Rust)] +#[case::bluetooth("et-ws-bluetooth", Language::Rust)] +#[case::face_detection("et-ws-face-detection", Language::Rust)] +#[case::geolocation("et-ws-geolocation", Language::Rust)] +#[case::har1("et-ws-har1", Language::Rust)] +#[case::nfc("et-ws-nfc", Language::Rust)] +#[case::sensor1("et-ws-sensor1", Language::Rust)] +#[case::speech_recognition("et-ws-speech-recognition", Language::Rust)] +#[case::video1("et-ws-video1", Language::Rust)] +#[case::pyface1("et-ws-pyface1", Language::Python)] +fn hardware_module_load_fails(#[case] module: &str, #[case] language: Language) { + if !mise_env_includes(language) { + println!( + "skipping {module}: requires the `{}` mise env, not loaded", + language.as_str() + ); + return; + } + let server = et_ws_test_server::start(); + // A missing sensor/media API throws promptly once the module runs, so the module usually exits well under + // this bound; it only bites if a module instead awaits a device callback that never fires, in which case + // RUNNER_TIMEOUT kills it -- still the non-zero exit we assert. + let output = run_runner(module, &server.ws_url, 30); + #[cfg(feature = "coverage")] + collect_module_coverage(&server); + assert!( + !output.status.success(), + concat!( + "{} exited 0, but it was expected to fail without its browser sensor/media API. ", + "If the runner can now run it, move it to `module_runs_successfully`.\n", + "--- stdout ---\n{}\n--- stderr ---\n{}", + ), + module, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Spawn one `et-ws-web-runner` against `ws_url` and return its captured output. +/// +/// `timeout_secs` is passed as `RUNNER_TIMEOUT` (humantime, e.g. `120s`); the +/// multi-agent harness bumps it because two cold V8 starts contending for the +/// same box widen the discovery window past the single-agent budget. +fn run_runner(module: &str, ws_url: &str, timeout_secs: u32) -> std::process::Output { let bin = env!("CARGO_BIN_EXE_et-ws-web-runner"); - let output = std::process::Command::new(bin) + // ET_TEST_COVERAGE, when set by the coverage workflow, is inherited by the child (Command keeps the parent + // env), so the Pyodide shims collect coverage into ws-server storage -- no explicit forwarding needed. + std::process::Command::new(bin) .env("RUNNER_MODULE", module) .env("WS_SERVER_URL", ws_url) .env("RUNNER_TIMEOUT", format!("{timeout_secs}s")) .output() - .expect("failed to spawn et-ws-web-runner"); + .expect("failed to spawn et-ws-web-runner") +} +/// Collect the coverage a module PUT into the test server's storage. +/// +/// Each module writes its coverage to its own agent bucket (`/`), because the storage `put_file` only +/// accepts a registered agent as the bucket. So this scans every agent bucket under the storage dir and routes +/// files by extension into the two later coverage tasks: +/// - `.coverage` (Pyodide coverage.py data) -> `target/pycov/` renamed to coverage.py's parallel-data +/// convention (`.coverage.`) for the `pytest-cov` combine. +/// - `.profraw` (Rust browser-wasm minicov) -> `target/wasi-cov/` (where the `wasm-cov` task turns each into +/// lcov via the same llc/llvm-cov pipeline). +/// +/// Compiled in only under the `coverage` feature (like the runner's capture code); the call sites are gated to +/// match. Without a coverage build this whole helper is absent, and a plain test run never captures. +#[cfg(feature = "coverage")] +fn collect_module_coverage(server: &et_ws_test_server::TestServer) { + let root = edge_toolkit::config::get_project_root(); + let Ok(buckets) = fs::read_dir(server.storage_dir.path()) else { + return; + }; + for bucket in buckets.flatten() { + let Ok(entries) = fs::read_dir(bucket.path()) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + match path.extension().and_then(|ext| ext.to_str()) { + Some("coverage") => { + let dest_dir = root.join("target/pycov"); + fs::create_dir_all(&dest_dir).expect("create target/pycov"); + let stem = path + .file_stem() + .expect("coverage data file has a stem") + .to_string_lossy(); + let _copied = fs::copy(&path, dest_dir.join(format!(".coverage.{stem}"))).expect("copy pycov"); + } + Some("profraw") => { + let dest_dir = root.join("target/wasi-cov"); + fs::create_dir_all(&dest_dir).expect("create target/wasi-cov"); + let name = path.file_name().expect("profraw has a file name"); + let _copied = fs::copy(&path, dest_dir.join(name)).expect("copy wasmcov profraw"); + } + _ => {} + } + } + } +} + +/// Run `module` and panic with the captured stdout/stderr on non-zero exit. +fn run_runner_with_timeout(module: &str, ws_url: &str, timeout_secs: u32) { + let output = run_runner(module, ws_url, timeout_secs); if output.status.success() { return; } diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs index 683a3dcc..5b5e75f5 100644 --- a/utilities/cli/src/error.rs +++ b/utilities/cli/src/error.rs @@ -87,9 +87,10 @@ pub enum CliError { /// Parse `src` as TOML into `T`, attaching `path` to the error on failure. /// Replaces a `.map_err(...)` at every call site. -pub fn parse_toml>(path: P, src: &str) -> Result +pub fn parse_toml(path: P, src: &str) -> Result where T: for<'de> serde::Deserialize<'de>, + P: AsRef, { match toml::from_str(src) { Ok(value) => Ok(value), @@ -101,9 +102,10 @@ where } /// Parse `src` as JSON into `T`, attaching `path` to the error on failure. -pub fn parse_json>(path: P, src: &str) -> Result +pub fn parse_json(path: P, src: &str) -> Result where T: for<'de> serde::Deserialize<'de>, + P: AsRef, { match serde_json::from_str(src) { Ok(value) => Ok(value), @@ -116,7 +118,10 @@ where /// Serialize `value` as pretty JSON, surfacing the failure as /// [`CliError::SerializeJson`]. There's no input path to attach. -pub fn serialize_json_pretty(value: &T) -> Result { +pub fn serialize_json_pretty(value: &T) -> Result +where + T: serde::Serialize, +{ match serde_json::to_string_pretty(value) { Ok(out) => Ok(out), Err(source) => Err(CliError::SerializeJson(source)), diff --git a/utilities/wasm-cov-wrapper/Cargo.toml b/utilities/wasm-cov-wrapper/Cargo.toml new file mode 100644 index 00000000..7ea9727c --- /dev/null +++ b/utilities/wasm-cov-wrapper/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "int-wasm-cov-wrapper" +description = "RUSTC_WORKSPACE_WRAPPER that instruments only workspace crates for browser-module wasm coverage" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false +repository.workspace = true + +[[bin]] +doctest = false +name = "int-wasm-cov-wrapper" +path = "src/main.rs" +test = false + +[lints] +workspace = true diff --git a/utilities/wasm-cov-wrapper/src/main.rs b/utilities/wasm-cov-wrapper/src/main.rs new file mode 100644 index 00000000..b99bea83 --- /dev/null +++ b/utilities/wasm-cov-wrapper/src/main.rs @@ -0,0 +1,78 @@ +//! `RUSTC_WORKSPACE_WRAPPER` for the browser-module wasm coverage build. +//! +//! cargo runs this as `int-wasm-cov-wrapper ` for **workspace** crates only (never registry +//! dependencies), so only our own crates are instrumented -- dependencies get neither coverage nor a broken +//! `-Cinstrument-coverage` link (a dependency cdylib such as tracing-wasm or wasm-streams would otherwise want an +//! unresolved `__llvm_profile_runtime`). Real crate compiles (those carrying `--crate-name`) get the wasm coverage +//! flags appended; cargo's `-vV` / `--print` probes pass through untouched. minicov (pulled by `et-web/coverage`) +//! supplies the profiler runtime for the instrumented crates, and the `--emit=llvm-ir` output feeds the wasm-cov +//! task that turns each module's coverage map into lcov. +//! +//! Excluded from Codacy analysis (`.codacy.yaml`): Codacy's Rust security rule flags `args_os()` flowing into a +//! subprocess spawn as a command-injection shape and cannot suppress it per line. That is a false positive here -- +//! the args are cargo's own trusted rustc invocation, and forwarding argv to rustc is this file's entire purpose, +//! so no code change removes it. The file is kept minimal and single-purpose so the path exclude is as narrow as +//! possible; it stays fully covered by clippy and DeepSource's Rust analyzer. +#![expect( + clippy::print_stderr, + reason = "a build-tool wrapper reports its own startup failure to stderr" +)] + +use std::ffi::OsString; +use std::process::{Command, ExitCode}; + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let Some(rustc) = args.next() else { + eprintln!("int-wasm-cov-wrapper: expected the rustc path as the first argument"); + return ExitCode::FAILURE; + }; + let mut rustc_args: Vec = args.collect(); + + // Instrument only real crate compiles for the wasm target. `--crate-name` excludes cargo's `rustc -vV` / + // `--print` probes; the `wasm32` target gate excludes host build scripts + proc-macros -- those compile for + // the host, and instrumenting them just litters `.profraw` files in the module dir when they run at build + // time (their coverage is irrelevant to the browser module anyway). + if is_wasm_crate_compile(&rustc_args) { + rustc_args.extend( + [ + "--emit=llvm-ir", + "-Cinstrument-coverage", + "-Ccodegen-units=1", + "-Clto=off", + "-Zno-profiler-runtime", + ] + .into_iter() + .map(OsString::from), + ); + } + + match Command::new(&rustc).args(&rustc_args).status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(_nonzero) => ExitCode::FAILURE, + Err(error) => { + eprintln!("int-wasm-cov-wrapper: failed to run rustc: {error}"); + ExitCode::FAILURE + } + } +} + +/// True when these rustc args are a real crate compile (`--crate-name`) targeting wasm32. +/// +/// Both gates matter: `--crate-name` skips cargo's `-vV`/`--print` probes, and the wasm32 target skips host +/// build scripts + proc-macros (whose instrumented binaries would drop stray `.profraw` files at build time). +/// Handles both `--target wasm32-...` (two args) and `--target=wasm32-...` (one arg) spellings. +fn is_wasm_crate_compile(rustc_args: &[OsString]) -> bool { + let is_crate = rustc_args.iter().any(|arg| arg.to_str() == Some("--crate-name")); + let targets_wasm = rustc_args.iter().enumerate().any(|(index, arg)| { + let Some(text) = arg.to_str() else { return false }; + text.strip_prefix("--target=") + .is_some_and(|value| value.starts_with("wasm32")) + || (text == "--target" + && rustc_args + .get(index + 1) + .and_then(|next| next.to_str()) + .is_some_and(|value| value.starts_with("wasm32"))) + }); + is_crate && targets_wasm +}