Repository navigation
WASM: Host a WASM testbed site under the QuantEcon org #950
Description
Proposal: host a WASM testbed site under the QuantEcon org
Motivation
Work on #925 has surfaced two bugs in the emscripten-forge Numba build (the jitted-generator hang, #927; and a crash when saving to the persistent cache), with diagnoses, reproducers, and in one case a monkeypatch-verified fix. We should have our own place to try candidate fixes in a real browser kernel and to check that the affected quantecon functions work, both for these two issues and for whatever comes next in the campaign.
Upstream CI (fork + PR) can build a patched package and run the recipe's own tests in wasm, but only as part of a PR, and it cannot run the end-to-end check with quantecon on the xeus-python/JupyterLite stack that #925 targets. A C-level fix such as the generator alias cannot be tested by monkeypatching a live kernel at all; it needs a rebuilt package running in the browser.
Proposal
A small repo (e.g. QuantEcon/wasm-testbed) publishing a JupyterLite site to GitHub Pages whose environment can include locally patched packages. The result is our own counterpart of https://notebook.link/@anutosh491/numba-ecosystem — a URL that opens an xeus-python notebook in the browser with the smoke notebooks preloaded, nothing to install — except that we choose what goes into the environment, and every deployment corresponds to a commit.
- A fork of emscripten-forge/recipes with a branch per candidate fix to
recipes_emscripten/numba; buildnumbaforemscripten-wasm32either locally (pixi run build-emscripten-wasm32-pkg) or via a draft PR on the fork (CI uploads the built.condaas an artifact). - A GitHub Actions workflow that indexes the patched
.condainto a static conda channel withrattler-index, builds the site withjupyterlite-xeusfrom anenvironment.ymllisting that channel first, then emscripten-forge and conda-forge, and deploys to Pages. Swapping the package under test is a one-line change. - Smoke notebooks shipped with the site: the environment-check cell, the per-issue MWEs (currently the five-line generator MWE and the persistent-cache MWE), and the quantecon checks (
support_enumeration/vertex_enumerationon small games with known equilibria;simplex_gridunder a warm cache), extended as new issues appear. - Results go into WASM: JupyterLite proof-of-concept deployment and browser smoke suite #928's table. If we open upstream PRs, the browser evidence comes from here.
Implementation notes
- Make the patched package win the solve. Listing the custom channel first is not enough: at equal version the solver prefers the higher build number, and the stock package is build 1. Bump
build: numberin the patched recipe (or pin the build inenvironment.yml); otherwise the site silently uses stock numba. - The channel is only needed at build time. The browser never contacts a channel;
jupyter lite buildbakes the packages into the site. So a local directory in the CI workspace suffices (channels: - ./channel, withemscripten-wasm32/andnoarch/subdirs indexed byrattler-index fs channel/). If the xeus build's solver turns out not to accept local paths, the fallback is aquanteconchannel on prefix.dev (rattler-build upload prefix), the mechanism emscripten-forge itself uses. - Commit the
.condainto the repo (channel/emscripten-wasm32/, a few MB) rather than fetching CI artifacts, which expire and need tokens across repos. - Prove the patched build is in use. The environment-check cell should print the numba build string, and the
ctypessymbol check from WASM: support_enumeration and vertex_enumeration hang in the browser — root cause is jitted generators (numba.np.linalg._LAPACK verified working) #927 (numba_make_generatorresolves only under the patch) is the definitive marker.
Notes
- Patched packages must be built from the recipe so they carry the channel's pins (
llvmlite ==0.49.0,numpy 2.4.*); mixing them with stock emscripten-forge packages is then safe. - Once a fix lands upstream, the corresponding package is dropped from the channel; with an empty custom channel the site is just the WASM: JupyterLite proof-of-concept deployment and browser smoke suite #928 deployment.
- Nothing runs server-side: the site is static files on Pages.
- The site's
notebooks/?path=smoke_test.ipynbURL deep-links straight into the smoke notebook; that is the link to cite next to results in WASM: JupyterLite proof-of-concept deployment and browser smoke suite #928.
🤖 Generated with Claude Code (Claude Fable 5.1)
Activity
mmcky commentedon Sep 9, 2026
Priority raised — #950 is now the lead item of #925. The argument that moved it is not the one this issue was opened on. Being able to build a patched numba and run it in a real kernel is not just how we check our own claims; it is how we design and test the patches we contribute upstream. Two of the campaign's five gaps (#927, #944) are blocked on upstream fixes we have already diagnosed but cannot build or test, so today we can only report bugs. With this testbed we can send fixes with browser evidence attached. #925's work plan and sequencing are updated.
To make the first contribution concrete, here is a source audit of the #927 symbol mismatch, done 2026-09-09. The fix is a single alias, and it is complete.
numba/core/base.py::_load_global_helpersregisters every helper from both helper dicts under a uniform name:Checking each family against the C symbol it actually resolves to:
_helperlib.c_helpersnumba_<key>(via_declpointer(#func, &numba_##func)in_helpermod.c)numba_<key>_nrt.c_helpersNRT_<name>NRT_<name>_dynfunc.c_helpersmake_generatorNumba_make_generator(via_declpointer(#func, &Numba_##func)in_dynfuncmod.c)numba_make_generatorSo
make_generatoris the sole helper in numba whose registered name differs in case from its C symbol — it is the only entry in_dynfunc.c_helpers, and the capitalNumba_prefix is an outlier that no other helper uses. Natively this never surfaces, becausell.add_symbolbridges the two spellings inside the JIT's symbol table and the C symbol stays hidden. On Emscripten the generated side module resolves helper imports through the dynamic loader instead, and0002-export-runtime-helpers-for-wasm.patchexports only the capital spelling — so exactly one import goes unresolved, and because the wasm linker runs with--allow-undefinedit fails at first call rather than at link time. That is the hang.The consequence for the patch is that no survey of other helpers is needed: aliasing this one name is sufficient and nothing else is exposed. Candidate, to be added to patch 0002 after the definition of
Numba_make_generatorin_dynfunc.c:Caveats, stated plainly because they are the point of this issue. The audit above is source-verified, not browser-verified — it is read from numba 0.62.1 sources and the emscripten-forge patch set, and the browser build is 0.67.0. Neither the alias above nor the thin-forwarding-function alternative has been compiled, and
__attribute__((alias))under the wasm backend is precisely the kind of thing that needs to be tried rather than reasoned about. There is also a design question worth settling before we file: the mismatch is latent in numba itself, so the durable fix may belong upstream in numba (make_dynfunc.cconsistent with_helperlib.c) rather than in an emscripten-forge patch that compensates for it. @anutosh491 offered on 2026-08-19 to take this one; that offer is still open and worth answering either way.All of which is the case for building this first: right now every sentence in the paragraph above ends in "we cannot check that yet".
Refs: #925, #927, #928, #933
mmcky commentedon Sep 9, 2026
Implementation mechanics: verified against live sources
Verified 2026-09-09 by downloading the actual artifacts and running the actual tools — micromamba 2.9.0, jupyterlite-xeus 5.0.0, jupyterlite-core 0.8.3, empack 6.0.1, rattler-index 0.31.5 — rather than from documentation. The implementation notes hold up better than I expected: most of what follows confirms them, one of their open questions is now answered, and the whole channel mechanic was proven end to end. Two things are wrong, both in ways that would silently ship stock numba rather than fail loudly.
Two corrections
The artifact is a
.tar.bz2, not a.conda. Every emscripten-forge pixi build task passes--package-format tar-bz2;emci/rattler_build.pyhardcodes the same; all 2735 packages in theemscripten-wasm32repodata are.tar.bz2andpackages.condais present-but-empty (count 0);emci/upload.pyrejects.condaoutright. The real file isnumba-0.67.0-py313h02b136b_1.tar.bz2, 1,318,840 bytes, sha2566842b3db…7584d; the.condaURL 404s. This matters more than a filename nit, because a*.condaglob in the build workflow matches nothing, and copying zero packages intochannel/still produces a valid, correctly-indexed, empty channel — so the build succeeds and ships stock numba. That is exactly the failure the notes warn about under "Make the patched package win the solve", reached through a different door. The size figure is also good news for "commit the package into the repo": 1.3 MB per patched build, so twenty candidate rebuilds cost about 26 MB of history."Make the patched package win the solve" reaches the right remedy by the wrong mechanism. The note says listing the channel first is not enough because "at equal version the solver prefers the higher build number". The solver is micromamba, and under its default
flexiblepriority and understrict, libsolv runsprune_to_highest_priobeforeprune_to_best_version: the first-listed channel wins regardless of version or build number. This was demonstrated both ways — a local build_0beating upstream's_1, and a syntheticfoo 1.0beatingfoo 2.0. Build number only decides underchannel_priority: disabled. The practical upshot is that the note's parenthetical second option is the stronger one and should be promoted to the primary: pin the build inenvironment.ymlasnumba=0.67.0=py313h02b136b_2, which passes through jupyterlite-xeus verbatim and converts "the patched build is in use" from a runtime assertion into a build-time invariant — a missing patched package then fails the build instead of quietly resolving to stock. Bumpbuild: numberas well, since jupyterlite-xeus does not pass--no-rcand a stray runnercondarcorCONDA_CHANNEL_PRIORITYcan flip the mode todisabled, which is the one setting where channel order stops mattering. SettingMAMBA_CHANNEL_PRIORITY=strictin the workflow closes that off directly.One trivial nit while here: the recipe path is
recipes/recipes_emscripten/numba/, with a leadingrecipes/.The open question in the notes, answered: yes, local paths work
The notes hedge with "If the xeus build's solver turns out not to accept local paths, the fallback is a
quanteconchannel on prefix.dev". That fallback is not needed. The solver is micromamba, invoked as a subprocess —jupyterlite_xeus/create_conda_env.pyrunsmicromamba create --yes --no-pyc --prefix <p> --relocate-prefix "" --root-prefix <r> --platform=emscripten-wasm32 -c <chan>… <specs>, passing everychannels:entry through verbatim as-c. A grep forrattler,libmambapyorconda.apiacross the whole 5.0.0 sdist returns zero hits.And it was proven end to end, not merely read: downloading the real
numba-0.67.0-py313h02b136b_1.tar.bz2, injecting a marker file, rebuilding it as build_2, indexing withrattler-index fs, and runningmicromamba create --platform=emscripten-wasm32against the local channel installed that package into a real emscripten-wasm32 prefix, resolvingllvmlite 0.49.0,numpy 2.4.6andpython 3.13.1cleanly from emscripten-forge andpython_abifrom conda-forge. This also confirms the note under Notes that mixing a recipe-built package with stock emscripten-forge packages is safe: channel priority prunes per package name, so a one-package local channel shadows nothing else. Keep the local channel to exactly the packages under test for that reason.The one consequence of it being micromamba: the build job must put
micromambaonPATHbeforejupyter lite buildimports the addon, becauseshutil.which("micromamba")is evaluated at module import and raises if absent. Installing jupyterlite-xeus from conda-forge pulls it automatically; installing from PyPI does not.Details behind the notes, worth recording because of how they fail
noarch/subdir the notes already specify is not optional, and its absence is fatal and misdiagnosable. Without a readable<channel>/noarch/repodata.jsonmicromamba aborts the entire solve — even when the platform subdir is fine and another channel could satisfy the spec — withDownload error (37) … Subdir …/noarch not loaded!, which reads like a network fault rather than a channel-layout error. A missing or empty platform subdir, by contrast, is tolerated silently. Worth a comment in the workflow so nobody "fixes" it by adding retries.file://URL generated by the workflow, e.g.file://${GITHUB_WORKSPACE}/channel. The notes'./channelis correctly formed — a barechannelwith no leading./is typed as an anaconda.org channel name and silently resolves nothing — but a relative path resolves against thejupyter lite buildprocess CWD, not againstenvironment.yml's directory, so it breaks the first time the build moves into a subdirectory.rattler-indexflag surface has moved.rattler-index fs <dir>is right, but--write-zstand--write-shardsno longer exist in 0.31.5 — they are now[index-config]keys in a TOML passed with--config, and the rattler-build docs page describing the flags is stale. Defaults write sharded repodata that micromamba then ignores forfile://channels, i.e. four extra files churning on every index run. It is also a Rust CLI available from conda-forge for linux-64 and not on PyPI. The step can be skipped entirely:rattler-build publish <pkg>.tar.bz2 --to file://$PWD/channeldoes copy, index and noarch-init in one command.conda-forgeis required, not merely conventional. emscripten-forge-4x ships nopython_abirecord while numba pinspython_abi 3.13.* *_cp313, so the three-channel order in the notes is load-bearing.{output}/xeus/{env}/kernel_packages/*.tar.gzand the worker fetches only from the Pages origin. But jupyterlite-xeus copies the channel list verbatim intoempack_env_meta.json, which mambajs uses to solve in-browser%pip/%conda installcalls — so afile://entry lands in the deployed site's metadata and would break dynamic installs there. Harmless for the smoke notebooks as specified; worth knowing before anyone adds an install cell.ctypesmarker are right and should stay, but grepping the resultingconda-meta/numba-*.jsonfor the expected build string and the local channel path fails the job an hour before the browser would.The site itself was built and driven, not estimated
The site also builds and runs. From this repo's
ci/wasm/environment.ymlverbatim: 47 packages, 214.0 MB installed prefix, built site 159.7 MB (116.0 MB with--no-sourcemaps, 90.8 MB as the Pages tar.gz) against GitHub's documented 1 GB published-site limit — roughly 6x headroom. A first-time visitor is served 96.65 MB over 133 requests, about 72-76 MB on the wire since Pages gzipsapplication/wasmbut not the already-compressed package chunks; that puts the 100 GB/month soft bandwidth limit at roughly 1,300 cold visits.jupyter lite buildtook 46.8-48.5 s. In headless Chromium against the built site:import quantecon4.05 s, first@njitcompile 1.49-1.57 s,simplex_grid(3, 4)0.50 s on a cold cache. The wasm32 memory ceiling is tighter than #925 records —xpython.jsdeclaresmaximum: 32768pages, a hard 2 GiB, and a numpy allocation actually failed at 1792 MiB.On proving which numba is loaded, there is something better than the ctypes probe. empack ships a per-package
/conda-meta/<name>-<version>-<build>.jsoninto the Emscripten filesystem carryingbuild,build_number,channeland the sourceurl. Confirmed by reading it from a live browser kernel.numba.__version__yields only0.67.0andimportlib.metadatano more, so/conda-metais the provenance cell; the #927ctypessymbol probe stays as the behavioural marker.Two additions to the record
The Motivation's read of upstream CI checks out; here are the numbers behind it.
build_recipes.yamlbuilds recipes changed betweenorigin/mainandHEADand runs the recipe's owntest_numba.pyinside three real headless browser backends via Playwright. On emscripten-forge/recipes#6293 the whole job — a genuine from-source rebuild with all ten patches applied,emcccompilingnumba._dynfunc/_dispatcher/_helperlib, plus all three in-browser runs — took 3 min 20 s. The "only as part of a PR" qualifier is exact rather than approximate: the workflow'spushtrigger is restricted tobranches: [main], so pushing a patch branch to a fork builds nothing; a pull request is required, though one opened within the fork against the fork's ownmainis enough and needs no upstream involvement. And the boundary falls precisely where the Motivation puts it — quantecon is absent from the recipe's test environment (#932) and the recipe tests never touch the xeus-python/JupyterLite stack, so "support_enumerationandvertex_enumerationreturn the correct equilibria under the patched build" is evidence that only this testbed can produce. That is the sharpest single argument for building it, and it is worth promoting into the Proposal explicitly, because it is also the argument that survives if a maintainer asks why the recipe's own tests are not sufficient.The #944 trigger is an in-session cache restore, not a browser session boundary. Numba's
_object_getbuffer_hooknulls_compiled_object,_unserializere-sets it, and_get_compiled_objectthen raises atcodegen.py:632— the exact frame in the traceback. The failure was reproduced in a single native CPython process by monkeypatching only patch 0006's twocodegen.pyhunks, with no browser and no second session; the session boundary in the existing repro is incidental, its only job being to make the callee a cache hit rather than an in-memory hit. This does not change the case for the testbed, but it does change two things downstream. A regression test for #944 can live in an ordinary pytest file rather than requiring browser orchestration. And a deployed site is the worst place to observe the bug: CI visits cold on every run and will never see it, while real visitors warm their cache and will — so the site can read green while being broken for humans. Deliberate reproduction needs a Playwright persistent context withpage.reload()inside oneBrowserContext, or a human with a warm profile, and the smoke notebooks should say which state they are asserting rather than leaving it to chance.One item to validate during implementation
Whether
/driveis actually mounted on a Pages-hosted JupyterLite. Patch 0006 setsdefault_cache_dir = "/drive/.cache/numba" if sys.platform == "emscripten" and os.path.isdir("/drive")and otherwise falls back toCACHE_DIR = "", i.e. MEMFS, which is destroyed on every kernel restart. Whether jupyterlite-xeus still gates that mount onserviceWorker?.enabled || crossOriginIsolatedcould not be settled from source — the gate appears to have been removed in 2024 but the current computation is unclear, and GitHub Pages cannot send COOP/COEP headers.This is deliberately left open rather than guessed at, and it is cheap to settle:
os.path.isdir('/drive')andnumba.config.CACHE_DIRfrom a cell against a locally served build, then again against the first deployed site. Treat it as a gate on the warm-cache work rather than a background risk — if/driveturns out not to be mounted, the persistent Numba cache does not exist on the deployed site, #944 is unobservable there no matter how the notebooks are written, and the cache-state cell in00_environment.ipynbshould say so plainly instead of implying a warm cache that cannot form. Both probes belong in that notebook permanently, since the answer can change under jupyterlite-xeus upgrades.Decisions taken
Name. The testbed repo will be
QuantEcon/test-quanteconpy-wasm— ruled in, and available. It sits under thetest-{name}entry in QEP-3's type registry ("test double or CI target for tooling, pilot-scoped or standing"), as a standing target for this repo's WASM work, alongside the eight existingtest-*repos. One follow-up for whoever files the rename audit: status-projects calls the projectquantecon-py-wasm, so the name segments differ by a hyphen (quanteconpy-wasmhere). Not worth blocking on, but worth choosing deliberately.Sequencing. Report #927 upstream first — it has never been filed, and searches of emscripten-forge/recipes issues and pull requests for
numba_make_generator,make_generatorandgenerator numbaall return zero results, so there is currently nothing upstream to assign, label or find. Filing the report commits us to no patch and creates the reference an eventual pull request can point at. Then stand up the testbed, prove each candidate patch there, and only then open the upstream patch pull requests citing the testbed URL. The reasoning behind that order is the paragraph above on what only the testbed can demonstrate: a recipe-level test can show the generator MWE running and the symbol resolving, but notsupport_enumerationreturning the correct equilibria, and the latter is what a reviewer should be shown.Registration. status-projects needs only a
links.reposaddition — thequantecon-py-wasmrow already carries a hosted WASM testbed as deliverable 5.🤖 Generated with Claude Code