Skip to content

webassembly: Add minimal JSPI support and tests for Pyodide parity. - #19594

Open
ntoll wants to merge 3 commits into
micropython:masterfrom
ntoll:webassembly-add-minimal-jspi-support
Open

ntoll wants to merge 3 commits into
micropython:masterfrom
ntoll:webassembly-add-minimal-jspi-support

Conversation

@ntoll

@ntoll ntoll commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Related to @Gadgetoid's work in #19427 which formed the basis of a technical investigation found in this gist.

This PR adds minimal JavaScript Promise Integration (JSPI) to MicroPython's webassembly port. This feature will only work with browsers that support JSPI or recent versions of Node (24+) that also support it.

JavaScript Promise Integration (JSPI) is a WebAssembly standard, now shipping in V8-based browsers and coming soon to Firefox and Safari. It lets a running WebAssembly computation suspend on a JavaScript Promise and resume when it resolves. Synchronous-looking Python appears to safely block in the asynchronous browser world (result = run_sync(fetch(...))) without actually blocking the main thread or incurring Asyncify's historical penalty in binary size and speed. It is the standards-based mechanism that efficiently lets sync-shaped code interoperate cleanly with the promise-based web platform.

The PR adds jsffi.run_sync() and jsffi.can_run_sync() with Pyodide API parity, delivered as a new jspi build variant. runPythonAsync() is the sole promising entry; one suspension in flight, enforced globally; both functions exist on every build with graceful degradation.

Note: this proposal permits exactly one suspension in flight at a time - a deliberate, justified divergence from Pyodide (see the decision in section C2 of the linked gist) that trades an exotic capability for a drastically simpler / smaller implementation.

Testing

Testing is entirely contained within the webassembly port.

Prerequisits

Emscripten:

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
emcc --version           # confirms emcc is on PATH

Node (25 or later):

# Install nvm if absent; see https://github.com/nvm-sh/nvm for
# the current install command, or use your existing copy.
nvm install 26
nvm use 26

Build and test

cd ports/webassembly
make VARIANT=jspi submodules
make VARIANT=jspi
make VARIANT=jspi test

Expect emcc: warning: -sJSPI (ASYNCIFY=2) is still experimental during the build. This labels Emscripten's toolchain integration, not the JSPI standard itself (which is W3C Phase 4 and shipping in all three engine families).

NB: If you're using Node24 you'll need to add --experimental-wasm-jspi to all node commands.

Smoke tests

Start the REPL:

make VARIANT=jspi repl

Then try this:

import jsffi, js
jsffi.run_sync(js.Promise.resolve(42))

A picture is worth a thousand words:

jspi_repl.mp4

Note: CTRL-C and exit() don't appear to work with the node based REPL.

I've also attached xterm-input-demo.zip. Copy micropython.mjs and micropython.wasm from ports/webassembly/build-jspi/ into the assets directory inside the unzipped directory, then serve the project root over HTTP (e.g. python3 -m http.server) and open index.html in a JSPI-capable browser via http://localhost:8000 (e.g. recent Chrome).

📦 xterm-input-demo.zip

This demonstrates the use of JSPI in the browser to run blocking Python code on the browser's main thread without blocking the main thread. I replace the builtin input function with one that interacts with Xterm via a promise, resolved when the user's input is submitted.

It's the canonical impossible thing - blocking input() on the browser's main thread beloved by literally every introduction to Python - working in stock-shaped Python, with no worker, no Asyncify, no SharedArrayBuffer faffing about, or incomprehensible HTTP headers. Nice'n'simple. 🙂

name = input("What is your name? ")
print(f"Hello, {name}!")

It's the two-line argument for the whole PR, and the seed of BFP's need to avoid all the web-worker complexity of PyScript via JSPI. 🎉 🤗 💪 🚀

Again, a picture is worth a thousand words:

jspi_browser.mp4

Trade-offs and Alternatives

The resulting assets are going to be marginally bigger, but within what I suspect are acceptable limits.

Generative AI

I used generative AI tools when creating this PR, but a human has checked the code and is responsible for the code and the description above.

Specifically:

  • Claude was used to read and do an initial analysis of @Gadgetoid's work in ports/webassembly: Add cooperative VM yield to the JS event loop. #19427. This was substantially re-written and edited by @ntoll into this gist.
  • Once given the go-ahead, Claude was used to extract Phil's code and do an initial refactor to the smallest cut of JSPI support. This was extensively reviewed and tested by @ntoll who added revisions to the commentary contained within the code (which could/should be removed?).
  • I wrote this PR description by hand.

@codecov

codecov Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.59%. Comparing base (5f2181f) to head (fba8faa).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19594   +/-   ##
=======================================
  Coverage   98.59%   98.59%           
=======================================
  Files         182      182           
  Lines       23316    23316           
  Branches        5        5           
=======================================
  Hits        22988    22988           
  Misses        327      327           
  Partials        1        1           
Flag Coverage Δ
unix-coverage-32bit 98.59% <ø> (ø)
unix-coverage-64bit 98.52% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ntoll
ntoll force-pushed the webassembly-add-minimal-jspi-support branch from f6de36b to fba8faa Compare August 7, 2026 17:40
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code size report:

Reference:  py/gc: Track the min/max of the entire heap area when using split heap. [5f2181f]
Comparison: webassembly: Fix eslint failures about delete operator. [merge of fba8faa]
  mpy-cross:    +0 +0.000% 
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:    +0 +0.000% standard
      stm32:    +0 +0.000% PYBV10
      esp32:    +0 +0.000% ESP32_GENERIC
     mimxrt:    +0 +0.000% TEENSY40
        rp2:    +0 +0.000% RPI_PICO_W
       samd:    +0 +0.000% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

@Gadgetoid

Copy link
Copy Markdown
Contributor

trades an exotic capability for a drastically simpler / smaller implementation.

Understatement of the year 😆

I think it's the right call. Careful management of suspended state is not a can of worms we want to open.

The resulting assets are going to be marginally bigger

Is this true? My JSPI builds are about 700k vs my Asyncify builds at 1.7MB (though I'm bolting on a lot of stuff that might spiral Asyncify's instrumentation out of control). Probably wouldn't hurt to have code size reports for WASM?

It's the two-line argument for the whole PR

Mine would be:

while True:
    print("All work and no play make JSPI a dull boy!")

😆

@ntoll

ntoll commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@Gadgetoid - so I've just built three webassembly variants: JSPI, PyScript and standard (asyncify). Here's the sizes for all the assets (micropython.mjs and micropython.wasm) along with their real gzipped cost (how they'll appear down the wire):

Variant                 .mjs raw     .mjs gz     .wasm raw     .wasm gz
--------------------    ---------    --------    ----------    ---------
standard (Asyncify)     234.1 KiB    58.4 KiB    1091.1 KiB    472.0 KiB
pyscript                212.1 KiB    53.4 KiB     432.8 KiB    190.9 KiB
jspi (pyscript+JSPI)    217.4 KiB    55.4 KiB     423.3 KiB    190.2 KiB

No surprises that asyncify is much larger than the others.

The difference between PyScript and JSPI (which is itself just the PyScript variant with the new JSPI code) is a couple of kilobytes more in JSPI's mjs file for the Emscripten JSPI "stuff". Interestingly, JSPI's wasm file is actually smaller than PyScript's because JSPI requires wasm native longjmp instead of the JavaScript trampoline mechanism (JSPI can only suspend a WASM only stack), which actually removes code.

@dpgeorge are you happy with these numbers?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants