Skip to content

ports/webassembly: Add cooperative VM yield to the JS event loop.#19427

Draft
Gadgetoid wants to merge 6 commits into
micropython:masterfrom
pimoroni:webassembly-asyncify-jspi
Draft

ports/webassembly: Add cooperative VM yield to the JS event loop.#19427
Gadgetoid wants to merge 6 commits into
micropython:masterfrom
pimoroni:webassembly-asyncify-jspi

Conversation

@Gadgetoid

@Gadgetoid Gadgetoid commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

I appreciate this is an enormous tangled mess of very complicated, very deep-rooted and very scary changes.

I'm raising this for visibility, rather than as a serious proposal for upstream in its current form, but it does present some useful points.

Chiefly that JSPI is the future (A JSPI build run a Mandelbrot demo about 10x faster than its Asyncify counterpart)
, and we ought to seriously look into a cooperative WebAssembly port. The current setup functionally requires async to be usable beyond trivial things, and absolutely falls apart if you allow users to do something so brazen as while True:. It's great for what it's great for (folks have built some genuinely amazing stuff) but isn't exactly representative of MicroPython.

Additionally there are many challenges to running useful MicroPython code in the browser. This PR includes jsfetch which gives MicroPython the ability to fetch URLs. Some simple wrappers around urllib and requests make this more or less an implementation detail and allow our (Pimoroni's) Badgeware code to run unmodified on both real hardware, and in browser, even when it's fetching data from APIs.

I also include a no-op for @micropython.native and have been investigating doing something similar for Viper, but the latter is rather more difficult to syntax verify but not actually compile. My focus has been on parity between our devices and the web simulator, so little bits of friction like this would otherwise stop a user from just copy-pasting code between the two.

Testing

I'm running this setup (shipping both Asyncify and JSPI alongside each other and auto-picking the best one for any given browser) in our prototype Badgeware simulator and it'll also be powering live examples in our documentation.

Trade-offs and Alternatives

This is the culmination of some months looking in to making MicroPython's WebAssembly port useful for my specific purposes. As such it's a hulking big change that - while it doesn't (afaik) break any existing uses of this port - carries a burden of understanding that I can't shoulder alone. Some input from other port users would be useful, and certainly some insight from new users of this port - I hope to put together a nice turnkey demo you can build on - would be appreciated.

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.

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.51%. Comparing base (13303f8) to head (984cdc0).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19427   +/-   ##
=======================================
  Coverage   98.51%   98.51%           
=======================================
  Files         177      177           
  Lines       22927    22927           
=======================================
  Hits        22586    22586           
  Misses        341      341           

☔ 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.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code size report:

Reference:  py/py.mk: Move MICROPY_FORCE_32BIT to the Windows port's Makefile. [13303f8]
Comparison: webassembly: Add opt-in @micropython.native bytecode no-op. [merge of 984cdc0]
  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 Gadgetoid force-pushed the webassembly-asyncify-jspi branch 3 times, most recently from 84f6d03 to 676df50 Compare July 5, 2026 09:22
mp_js_hook (the Node stdin poll for the interrupt character) passed a
spurious argument to mp_hal_get_interrupt_char(), which takes none. Recent
Emscripten builds assert on the argument-count mismatch and abort the VM
mid-execution ("native function mp_hal_get_interrupt_char called with 1
args but expects 0"), which surfaces once the VM hook fires during a
longer-running script. Drop the argument.

Signed-off-by: Phil Howard <[email protected]>
@Gadgetoid Gadgetoid force-pushed the webassembly-asyncify-jspi branch from 676df50 to e0f217f Compare July 5, 2026 13:54
@Gadgetoid

Gadgetoid commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

This is possibly of interest to @LeaNumworks upon whose work I have no doubt been building.

Right now the Numworks simulator has A Bad Time if you're so bold as to use a loop. This is exactly the sort of behaviour this PR fixes successfully for our own simulator.

A very unhappy numworks simulator asked to do while True: pass

And since a picture - or a live demo - is worth a thousand words: https://gadgetoid.github.io/upyweb/

@Gadgetoid Gadgetoid force-pushed the webassembly-asyncify-jspi branch from e0f217f to 14f7e4d Compare July 5, 2026 14:08
Gadgetoid added 5 commits July 5, 2026 17:52
Long-running or self-looping Python (a bare `while True:` game loop, a
busy computation) currently blocks the browser's main thread for as long
as it runs, because the MicroPython entry points execute to completion
synchronously. Add an optional cooperative yield so the VM periodically
hands the JS event loop a turn without the script having to `await`.

mpconfigport.h: MICROPY_ENABLE_VM_YIELD (default off) routes the existing
VM hook to mp_js_yield().
main.c: mp_js_yield() issues a throttled emscripten_sleep() from the hook,
and mp_js_sleep_ms() backs mp_hal_delay_ms(). Both suspend only when the
whole JS->MP call chain is suspend-capable (external_call_depth ==
suspendable_depth), so a synchronous re-entry from JS can't be unwound. A
MP_WEAK mp_js_yield_hook() lets an embedding run work at a safe point.
mphalport.c: mp_hal_delay_ms() sleeps via the event loop when safe.
api.js: an invoke()/settle() indirection issues the entry-point call the
right way for the build's backend (JSPI promising export, Asyncify
ccall({async:true}), or a plain synchronous ccall) and awaits the result
only when it is a Promise. The backend is announced by async_asyncify.js
and async_jspi.js, added to SRC_JS by the variant; a plain build stays
synchronous.
Makefile: make CSTD and SUPPORT_LONGJMP overridable by variants; add a
test_async target that runs the async-only fixtures (which SKIP elsewhere)
against a suspend-capable build.
variants/asyncify: reference Asyncify build with the yield enabled.
variants/jspi: experimental build using Emscripten JSPI (Wasm stack
switching) instead of Asyncify.
tests/ports/webassembly/async_yield.mjs: yield smoke test, skipped unless
the build is suspend-capable.
tools/ci.sh: build the asyncify variant and run `make test_async` on it.

The default standard and pyscript variants are unchanged and remain
synchronous.

Signed-off-by: Phil Howard <[email protected]>
A self-looping program (a bare `while True:` game loop) never returns to
the top level, which broke two assumptions of the port:

- Stopping meant tearing the whole instance down. Expose interrupt()
  (mp_sched_keyboard_interrupt) so a host can raise KeyboardInterrupt and
  stop a running script in place, then reuse the live instance.
  mp_js_sleep_ms() now slices the sleep and handles pending exceptions
  between slices, so time.sleep() is promptly interruptible too.
- Under MICROPY_GC_SPLIT_HEAP_AUTO, gc_collect() defers collection to the
  top level, which a forever-loop never reaches, so the heap grew
  unbounded to the wasm ceiling. Collect mid-execution from the VM yield
  hook instead. Asyncify scans the stack + registers; JSPI can't (switched
  stack, no register spill), so it scans the tracked C-stack range with
  bounds captured at the outermost entry. gc_alloc_threshold bounds the
  transient garbage held between collects.

main.c: interrupt / sliced sleep, and the mid-execution collector
(gc_collect_if_pending, gc_collect_cstack, MP_JS_NOTE_CSTACK_TOP, and the
MICROPY_GC_SCAN_REGISTERS / MICROPY_GC_TRACK_CSTACK selection).
api.js: expose interrupt().
variants/asyncify, variants/jspi: enable the auto split heap (and memory
growth) so a looping program is the case the mid-loop collector bounds.
tests/ports/webassembly: async_interrupt, async_restart and async_gc
fixtures for interrupt, in-place restart and bounded GC.

Signed-off-by: Phil Howard <[email protected]>
A browser can't open raw sockets, so the socket-based `requests` /
`urllib.urequest` from micropython-lib don't work in this port. Add a
small `_jsfetch` module that does HTTP(S) via the browser's fetch(), for
Python code (or a higher-level pure-Python client) to build on.

_jsfetch.request() kicks off the fetch then suspends the WASM stack (via
emscripten_sleep) until it settles, giving Python an ordinary blocking
call, so it needs a suspend-capable build. It is gated on
MICROPY_PY_JSFETCH and enabled by the asyncify and jspi variants.

jsfetch/jsfetch.c: the `_jsfetch` C module (request()).
jsfetch/jsfetch.js: the fetch() half, as an emscripten --js-library.
jsfetch/jsfetch.mk: build fragment; include it from a suspend build.
mpconfigport.h: MICROPY_PY_JSFETCH gate (default off).
variants/asyncify, variants/jspi: include jsfetch/jsfetch.mk.
tests/ports/webassembly/async_jsfetch.mjs: cover a blocking GET against a
local server.

Signed-off-by: Phil Howard <[email protected]>
Asyncify instruments every function that could be on the stack at a
suspend. For MicroPython that is almost the whole runtime: indirect calls
(the NLR jump callbacks and the print/type-slot vtables) are
conservatively assumed to reach a suspend, so even float boxing in a hot
loop is instrumented (ASYNCIFY_ADVISE reported ~3200 functions).

Add an asyncify-fast variant that turns on ASYNCIFY_IGNORE_INDIRECT and
re-adds just the indirect-dispatch sites that genuinely sit above a
suspend (the VM call machinery + protocol slots, in asyncify_add.txt);
ASYNCIFY_ADD re-roots the analysis so their direct callers are picked up
automatically. It also needs the Wasm longjmp backend, as MicroPython's
NLR wraps execution in setjmp and the emscripten invoke_* trampolines
abort once indirect auto-detection is off.

The result runs the arithmetic / alloc / object-op hot path
uninstrumented - about 2.5x faster, and 31% smaller wasm here.

EXPERIMENTAL: the ADD list must stay complete - a suspend reachable
through an un-listed indirect call would corrupt the unwind silently.

variants/asyncify-fast: reuse the asyncify variant, add the instrumentation
flags and the Wasm exception/longjmp backend.
asyncify_add.txt: the curated dispatch-site list.
tests/ports/webassembly/asyncify_fast.mjs: force a suspend through every
dispatch path and cross-check a checksum against the full build.
tools/ci.sh: build the asyncify-fast variant and run its fixture.

Signed-off-by: Phil Howard <[email protected]>
There is no native code emitter on wasm (MICROPY_EMIT_NATIVE == 0), so
@micropython.native was a SyntaxError. Native code has identical Python
semantics to bytecode - only faster - so allow a port to compile it as
bytecode instead: code written for hardware that decorates hot functions
with @Native then runs unchanged (just not accelerated).
@micropython.viper is deliberately left erroring, as its semantics differ.

This adds the mechanism only, as an opt-in that defaults off
(MICROPY_NATIVE_NOOP). No variant here enables it, so the default builds
are unchanged and native/viper feature detection is unaffected; a consumer
enables it in its own variant.

py/mpconfig.h: add the MICROPY_NATIVE_NOOP option (default off).
py/compile.c: when it is enabled and there is no native emitter, map
@micropython.native to MP_EMIT_OPT_BYTECODE in compile_built_in_decorator.

Signed-off-by: Phil Howard <[email protected]>
@Gadgetoid Gadgetoid force-pushed the webassembly-asyncify-jspi branch from 14f7e4d to 984cdc0 Compare July 5, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant