Skip to content

chore: run more of the test suite on the Web Assembly job - #16191

Draft
basvandijk wants to merge 19 commits into
leanprover:masterfrom
basvandijk:wasm-tests
Draft

basvandijk wants to merge 19 commits into
leanprover:masterfrom
basvandijk:wasm-tests

Conversation

@basvandijk

Copy link
Copy Markdown
Contributor

This PR makes the Web Assembly CI job run 177 tests instead of six, in parallel instead of one at a time.

The job runs the elab and elab_fail tests that execute code whose behaviour depends on the platform: word size, floating point, byte arrays, bignums (the wasm build has no GMP), the file system, tasks, hashing and other platform APIs. Two grep patterns in ci.yml select them, less 15 tests that cannot work in lean.js because they spawn processes, run CaDiCaL, import Lake or LeanExport, set environment variables in their .init.sh, or expect Linux behaviour or a 64-bit word size. The misc scripts that test the command line and markdown_conformance, a lean --run program, complete the list. It is kept as wasmTests in ci.yml, from which the matrix entry builds the -R argument of ctest. A lean.js process spends about 10 s of CPU initializing Lean, so the whole elab pile would take hours; with the step's default -j$NPROC this selection takes 8 to 10 minutes, depending on the runner's speed. Every failed test is rerun once (--repeat until-pass:2, with --timeout 300) because of two rare failures of lean.js itself: a hang under load, about one in 1,300 test runs on four CPUs, and a trap at exit after the complete output of a lean --run process, a few percent of its runs. doc/make/emscripten.md describes the selection.

This PR is stacked on #16149 and carries its commits until that PR merges; it will be rebased onto master afterwards. Until then, the diff of this PR alone is basvandijk:wasm-symbols...basvandijk:wasm-tests.

Claude Code, an AI coding assistant, helped with the investigation, the changes, the testing and this description. I reviewed and checked them.

🤖 Generated with Claude Code

basvandijk and others added 19 commits November 2, 2026 11:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t `lean.js` links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Emscripten the shared libraries are dummies: `lean.js` links the static archives (`LEAN_EXE_LINKER_FLAGS`), and `src/CMakeLists.txt` only touches `libInit_shared.so`, `libleanshared_1.so`, `libleanshared_2.so` and `libleanshared.so`. The `lean` rule in `stdlib.make` still depended on them, so once per build, after `lib/temp/empty.c` had been created, `make` ran their recipes, that is `emcc -shared`, which Emscripten before 6.0 emulated with a static object file and a warning and which Emscripten 6 turns into a real side-module link of all of Lean. And because the dummies are touched on every build, `make install` relinked `lean.js`, nine of the Web Assembly job's sixty minutes.

`lean.js` now depends on `libInit.a`, `libStd.a`, `libLean.a`, `libleancpp.a`, `libleanrt.a`, `libleanshell.a` and `libleanmain.a`, with the Lake `.trace` files of the first three like the other binaries, and the dummy `leanshared` target depends on `leanrt`, which nothing ordered before `lean.js` so far. The shared-library recipes are no longer reachable on Emscripten; the native rule is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since Emscripten 6.0.6, `emcc` links libc++ and libc++abi only when run as `em++` or with `-sDEFAULT_TO_CXX=1`, and it has always ignored `-lstdc++` for compatibility with GNU toolchains. `lean.js` is linked by `leanc.sh`, that is by `emcc`, with `ERROR_ON_UNDEFINED_SYMBOLS=0` and `MAIN_MODULE=1`, so on Emscripten 6 the link succeeds and `lean.js` fails when Node.js starts it. On Emscripten the C++ standard library flag is therefore `-sDEFAULT_TO_CXX=1`; before 6.0.6 the setting is on by default, so this changes nothing there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`-sALLOW_MEMORY_GROWTH=1` takes effect only when linking, but `EMSCRIPTEN_SETTINGS` passed it to every compilation as well, through `LEAN_EXTRA_CXX_FLAGS` and libuv's `CMAKE_C_FLAGS`, which emcc reports 147 times per build as `linker setting ignored during compilation`. The flags for compiling and linking, `-fwasm-exceptions -pthread -flto`, are now `EMSCRIPTEN_FLAGS` and the settings `EMSCRIPTEN_LINK_SETTINGS`; both go into `LEAN_EXTRA_LINKER_FLAGS`, which `leanc.sh` adds to every link, so `LEAN_EXE_LINKER_FLAGS` does not repeat them. Settings use the `-sKEY=VALUE` spelling that Emscripten documents. `LINKABLE=1` is dropped: `MAIN_MODULE=1` implies it in every Emscripten version from 3.1.44 to 6.0.13, and Emscripten 4.0.15 deprecated it, so newer versions warn about setting it explicitly. The comment quoting Emscripten's old advice to pass all `-s` flags at compile time went with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since Emscripten 4.0.11 the `libclang_rt.builtins` system library bundles LLVM's profiling runtime, and a main module (`-sMAIN_MODULE=1`) links its system libraries as whole archives, so the runtime's static initializer runs in every `lean.js` process and its `atexit` hook writes `default.profraw` into the current directory: 128 bytes after `lean --version`, 640 bytes after a test. That fails CI's check that the test suite leaves the source tree clean and would litter every user's working directory. `lean_main` now points the runtime at `/dev/null`. The symbol is weak, so Emscripten versions without the runtime, such as 3.1.44, link and behave as before. Reported upstream as emscripten-core/emscripten#28123.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emscripten 3.1.44 is from July 2023; 6.0.13 is the current release. `setup-emsdk` installs it directly and keys its cache on the version. The previous commits cover the two changes in Emscripten 6 that affect this build: `-shared` builds real side modules, and `emcc` no longer links the C++ runtime by default. `lean.js` still runs on Node.js 26; emcc uses the Node.js bundled with the SDK for its own passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The page described the Lean 3 build (`emconfigure`, the `lean_js_js` and `lean_js_wasm` targets, lean-web-editor) and was linked from nowhere. It now describes the build as the Web Assembly CI job runs it: a native 32-bit stage 0 compiles the `.olean` files, Emscripten cross-compiles stage 1 into `lean.js` and `lean.wasm`, Node.js 24 or newer runs them through the `bin/lean` launcher, and the tests run one at a time. `ci.yml` and `build-template.yml` remain the authoritative source of the flags and the SDK version. `doc/make/index.md` links the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The interpreter switches to native code by looking up a declaration's mangled symbol at run time. In `lean.js` that lookup went through Emscripten's `dlsym`, which enumerates every export of the module on each call (`Object.keys(lib.exports).indexOf(symbol)` in `_dlsym_js`, Emscripten 6.0.13); with about 250,000 exports one lookup costs 65 to 130 ms, and `lean` performs one per imported module and two per imported parser constant at startup, 2,630 of them for `Init`, which is most of the time a test takes on the Web Assembly CI job.

`lean.js` now carries its own symbol table. `script/wasm_symbols.py` reads the global symbols of the Lean archives that `lean.js` links (`libInit.a`, `libStd.a`, `libLean.a`) from their wasm object files, which also give each function's type, and writes a sorted name/address table with a binary-search lookup, `lean_lookup_native_symbol`, which `lookup_symbol_in_cur_exe` consults on Emscripten; since the table holds every symbol of those archives, only the runtime's symbols are still looked up with `dlsym`. `stdlib.make` builds the table and links it into `lean.js`. It is about 28 MB of static data (the names alone are 25 MB), so the link's initial memory is raised from Emscripten's default 16 MB to 64 MB. The script also writes the list of the runtime archives' global symbols, which the next commit uses as the export list.

On an AMD EPYC 7302 with Emscripten 6.0.13 and Node.js 26, `lean` on an empty file takes 21 s instead of 236 s (210 s of which were the 2,630 `dlsym` calls; now there are none), `tests/elab/Format.lean` 27 s instead of 193 s, and the six tests of the CI job 2 m 42 s instead of about 20 m. Lean's own profiler reports import 2.1 s instead of 57 s and interpretation 10 ms instead of 161 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With the interpreter's symbol table inside the module, `lean.wasm` no longer has to export all of Lean for `dlsym`. `lean.js` is now linked with `-sMAIN_MODULE=2` and the export list that `script/wasm_symbols.py` writes, the global symbols of the C and C++ runtime archives (600 names instead of 247,834 exports), so Emscripten removes unused code and the JavaScript glue no longer declares a variable per export, which was 76% of the 113 MB `lean.js`. Every pthread is a Node.js worker that loads that file, so each thread now starts in 0.5 s instead of 5 s and a `lean` process needs a third of the memory. Such a module includes only the JavaScript library functions the linked code references, so the helpers that Lean's `EM_ASM` blocks use are requested with `EXPORTED_RUNTIME_METHODS`. The Node.js heap override for emcc's JavaScript passes and the two `TODO(WN)` items in `src/CMakeLists.txt` about dead-code elimination and unused system libraries are obsolete and removed, and the comment on the CI job's Node.js step no longer cites the export count.

On an AMD EPYC 7302 with Emscripten 6.0.13 and Node.js 26: `lean.js` is 201 KB instead of 113 MB, `lean.wasm` 131 MB instead of 163 MB with the table (135 MB before it), `lean` on an empty file takes 5.0 s instead of 21 s with the table alone (236 s before), `tests/elab/Format.lean` 6.3 s and 1.5 GB instead of 27 s and 5.5 GB (193 s and 5.3 GB before), and the six tests of the CI job 42 s instead of 2 m 42 s (about 20 m before). The eight `uv__*` symbols the link reported undefined disappear with the unused libuv code; the five `lean_uv_*` ones and `dl_iterate_phdr` remain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the sentences in `doc/make/emscripten.md` about `lean.wasm` exporting about 250,000 symbols, the Node.js version that requires, `-sMAIN_MODULE=1 -sEXPORT_ALL=1` and the Node.js heap limit of the link with how the interpreter finds compiled code in `lean.js`: the table that `script/wasm_symbols.py` generates from the Lean archives and the export list of the runtime archives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The job ran six hand-picked tests, one at a time, because a WebAssembly `lean` took two minutes to start and could need 10 GB. With the native symbol table of `lean.js` a process starts in a few seconds and needs up to about 3 GB, so the tests run with the step's `-j$NPROC`. A `lean.js` process still spends about 10 s of CPU initializing Lean, so the whole `elab` pile would take hours; the job now runs the `elab` and `elab_fail` tests that execute code whose behaviour depends on the platform (word size, floating point, byte arrays, bignums, the file system, tasks, hashing, platform APIs), selected by two `grep` patterns given in `ci.yml`, less 15 tests that cannot work in `lean.js`, plus the `misc` scripts that test the command line and `markdown_conformance`: 177 tests, listed as `wasmTests`, from which the entry builds the `-R` argument of `ctest`. Every failed test is rerun once (`--repeat until-pass:2 --timeout 300`) because of two rare failures of `lean.js` itself: a hang under load and a trap at exit of a `lean --run` process. `doc/make/emscripten.md` describes the selection.

In a container with a 4-CPU quota and 15 GiB like the runner the selection takes 15 min 17 s with a peak of 9.3 GiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@basvandijk

Copy link
Copy Markdown
Contributor Author

How this was tested:

  • The Web Assembly job runs only at check level 2 or higher, so the CI evidence is a run of the job on this branch's current head, on my fork: https://github.com/basvandijk/lean4/actions/runs/41464801005 (emsdk 6.0.13, Node.js 26.13.0, a 4-CPU ubuntu-24.04 runner). All 177 tests passed, no retries, ctest in 10 min 3 s (2 to 34 s per test, median 12 s), the Test step in 10.1 min and the whole job in 60 min with warm caches (Build Target Stage 37.4 min, Pack 9 min); the build prints the same warnings as perf: resolve native symbols inside lean.wasm instead of exporting all of Lean #16149's trial run, https://github.com/basvandijk/lean4/actions/runs/41286288809.
  • An earlier run, from before this branch was rebased onto a master that brought four more tests into the selection, passed the 173 tests then selected in 7 min 51 s of ctest (https://github.com/basvandijk/lean4/actions/runs/41344542135, job 48 min). Apart from those four tests, the difference is the runner, not the code: in the later run the 173 tests took 25 % longer, in total and test by test (median ratio 1.25), and so did the build's compilation of the same 1,139 sources of Emscripten's libc (8.97 s instead of 7.19 s), while in the local 4-CPU container below the same 173 tests took 1 % longer after the rebase.
  • Local builds in a Docker container mirroring the runner (ubuntu:24.04, clang 18.1.3, cmake 3.31.6, emsdk 6.0.13, Node.js 26.13.0) with the job's CMAKE_OPTIONS plus -DLEAN_EXTRA_OPTS=-j4, on a 64-thread AMD EPYC 7302 host. To find out what works in lean.js at all, every pile that needs only lean (elab, elab_fail, elab_bench, docparse, misc, markdown_conformance, doc/examples) ran once with ctest -j16: 4,195 tests, 88 failures, 1 h 04 min. 62 of the 77 failures in the piles that are relevant here (elab, elab_fail, docparse, misc, markdown_conformance) were read and sorted: 44 bv_decide tests fail with "unsupported operation (error code: 52, function not implemented)" when the tactic spawns cadical; six tests spawn processes (importStructure does so through findSysroot); newdo imports Lake and leanExportParse imports LeanExport, which the wasm build does not have; nat_size_limit reads LEAN_NAT_MAX_SIZE from its .init.sh, and lean_main copies only LEAN_PATH into Emscripten's environment; file_not_found expects EBADF where Emscripten reports EACCES, handleLocking expects exclusive flocks that Emscripten's libc does not implement, sint_div_overflow and float_conversions assume a 64-bit ISize; instances cannot importModules Lean on top of import Lean (a standalone run traps at 3.2 GB of RSS, past the 2 GB wasm32 heap); grind_11081, grind_nestedDecidable_kernel_issue and issue9018 end with "RangeError: Maximum call stack size exceeded" in a Node.js worker thread, whose stack is 4 MB (node --stack-size=8192 covers only the main thread, and Lean's own stack check assumes the 8 MB it asked for); misc/uint64_mul_mod_portable.sh needs leanc. The other 26 failures: 13 docparse traps (below), two timeouts without output (csimpAttrFn, specInvariantAttr, which pass alone in 12 s and 7 s), and 11 elab_bench and doc/examples tests outside the selection.
  • A second run of these piles without those 77 failures (ctest -j16, 4,026 tests) failed only 13 docparse tests, each with "memory access out of bounds" after its complete expected output.
  • A third run in a container limited like the runner (docker run --cpus=4 --memory=15g --memory-swap=18g, a cgroup quota of four CPUs rather than four dedicated cores; ctest -j4; every lean given -j4 as the runner's four hardware threads would; CTest's default 1500 s timeout) of the 4,041 tests that can work: 18 failures, that is 15 docparse traps at exit (4.5 % of the pile's lean --run processes) and three tests that hung without output until the timeout (9366, 15605, grind_canon_bug_2; 7 to 15 s normally, and they pass in the first two runs), 5 h 58 min, peak memory of the container's cgroup 8.9 GiB, no OOM kill. This run gives the per-test costs behind the selection: a lean.js process elaborating a file needs 9 to 10 s of CPU at least (lean --version needs 3 s), the median test 16 s, so the elab pile alone costs more than four hours of wall time on four such cores; Node.js 26 already compiles the module lazily, and --wasm-lazy-compilation, --no-wasm-tier-up and --liftoff-only change nothing. Over the nine ctest runs of this branch reported here, 6 of 13,352 test runs hung, in 5 tests (15605 twice, both times under the 4-CPU quota, while it passed 8 times in a row under a synthetic four-way load and in both 64-CPU runs): 4 of the 5,131 test runs on four CPUs (under this quota and in the two CI runs, which had none) and 2 of the 8,221 on 64 CPUs, hence the retry.
  • The selection itself, in the same container with the job's exact options (--timeout 300 --repeat until-pass:2, the generated -R), twice, the second time on the current head: 177 tests, all passed, no retries, 15 min 17 s and 15 min 20 s of wall time for 3,643 s and 3,655 s of CPU (CPU-bound on the four-CPU quota; per elab or elab_fail test 9 to 57 s of CPU, median 19 s), peak memory 9.3 and 9.1 GiB. Before the rebase, the 173 tests then selected passed in the same way in 14 min 46 s. An earlier 212-test variant of the list, before the criterion required the tests to execute code, also passed in 17 min 41 s, with one test (15605, no longer selected) killed at 300 s and passing on its retry.
  • The -R argument was checked rather than trusted: wasmTests evaluated as the matrix script does yields a regex that matches exactly the 177 intended names and nothing else among the 4,780 registered tests, ctest -N -R on the wasm build selects 177, and the two grep patterns in the ci.yml comment select 186 files, which minus the 15 named exceptions are the 171 elab and elab_fail names (--no-tests=error would only catch an empty selection, and a mistyped name matches nothing). The names contain no regex metacharacters.
  • Controls: tempfile and libuv, two of the six tests the job ran before, are in the list and pass in every run (positive); 14 of the 15 exceptions failed for the stated reasons in the one run that included them, elab/Process.lean with the unsupported-operation error, and interp_prims, new on master since that run, fails because its expected output has 64-bit USize values where lean.js prints the correct 32-bit ones (4294967293 for (-1 : USize) - 2) (negative); ctest --repeat until-pass:2 --timeout 3 reruns a test that timed out (checked with a toy project under cmake 3.31.6).
  • Not tested: a wasm build with -sMAXIMUM_MEMORY=4GB; the docparse pile in the job (left out: 44 s of CPU per test and the exit trap); the --run piles that spawn processes (server, server_interactive) and the compile piles, which need leanc.

@basvandijk

Copy link
Copy Markdown
Contributor Author

changelog-no

@github-actions github-actions Bot added the changelog-no Do not include this PR in the release changelog label Nov 2, 2026
@github-actions github-actions Bot added the toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN label Nov 2, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog-no Do not include this PR in the release changelog toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant