Skip to content

webassembly standard variant broken with modern Emscripten: two bugs in library.js and api.js #19380

Description

@o-murphy

Port, board and/or hardware

webassembly port, standard variant (the one built with -s ASYNCIFY).

MicroPython version

Tested with:

  • MicroPython v1.28.0 and master (both affected - bugs are present in both)
  • emscripten/emsdk:latest (≥ 3.1.68, Clang 18+; also reproduced with 6.0.1)
  • Node.js v22 (default in Ubuntu 24.04)
$ git describe --dirty
v1.28.0

# emscripten/emsdk:latest
$ emcc --version
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 6.0.1 (25e4e8d6550d392ba9e0c2936bce7cf41ee47cc0)

Reproduction

Build

git clone --depth=1 --branch v1.28.0 https://github.com/micropython/micropython.git
cd micropython
make -C mpy-cross
make -C ports/webassembly
# or with a newer emsdk Docker image:
# docker run --rm -v "$PWD:/mpy" emscripten/emsdk:latest \
#   bash -c 'make -C /mpy/mpy-cross && make -C /mpy/ports/webassembly'

Run any script that triggers exception handling (setjmp/longjmp)

node ports/webassembly/build-standard/micropython.mjs - <<'EOF'
try:
    raise ValueError("test")
except ValueError:
    print("caught")
EOF

Expected behaviour

Script runs and prints caught.

Observed behaviour

Bug 1 - library.js: wrong ccall argument signature for mp_hal_get_interrupt_char

mp_hal_get_interrupt_char is declared in C as int mp_hal_get_interrupt_char(void) - it takes no arguments.
library.js calls it with one spurious argument:

// ports/webassembly/library.js
const mp_interrupt_char = Module.ccall(
    "mp_hal_get_interrupt_char",
    "number",
    ["number"],   // ← wrong: function takes void
    ["null"],     // ← wrong: no argument to pass
);

Modern Emscripten (≥ 3.1.68) performs strict ABI checking on ccall and aborts:

RuntimeError: Aborted(Assertion failed: …)

Fix: pass empty arrays for both argTypes and args:

const mp_interrupt_char = Module.ccall(
    "mp_hal_get_interrupt_char",
    "number",
    [],
    [],
);

Bug 2 - api.js: runPython calls mp_js_do_exec without { async: true }

The standard variant is built with -s ASYNCIFY and -s SUPPORT_LONGJMP=emscripten.
These flags cause Emscripten to instrument any C function that transitively uses longjmp (used by MicroPython's exception machinery) - including mp_js_do_exec.

api.js calls it via Module.ccall(...) without the { async: true } option:

// ports/webassembly/api.js
runPython(code) {
    …
    Module.ccall(          // ← missing { async: true }
        "mp_js_do_exec",
        "number",
        ["pointer", "number", "pointer"],
        [buf, len, value],
    );
    …
},

As soon as the first Python try/except (or any other code path that calls longjmp) is executed, Emscripten's Asyncify assertion fires:

RuntimeError: Aborted(Assertion failed: The call to mp_js_do_exec is running
asynchronously. If this was intended, add the async option to the ccall/cwrap call.)
    at Object.ccall (micropython.mjs:…)
    at Object.runPython (micropython.mjs:…)
    at runCLI (micropython.mjs:…)

This happens even for trivially-short scripts because MicroPython's try/except implementation always goes through longjmp.

Fix: make runPython async and pass { async: true } to ccall; update the call site in runCLI as well:

// api.js
async runPython(code) {
    const len = Module.lengthBytesUTF8(code);
    const buf = Module._malloc(len + 1);
    Module.stringToUTF8(code, buf, len + 1);
    const value = Module._malloc(3 * 4);
    await Module.ccall(
        "mp_js_do_exec",
        "number",
        ["pointer", "number", "pointer"],
        [buf, len, value],
        { async: true },    // ← required for Asyncify-instrumented functions
    );
    Module._free(buf);
    return proxy_convert_mp_to_js_obj_jsside_with_free(value);
},
// runCLI (same file)
await mp.runPython(contents);   // ← was: mp.runPython(contents)

Additional Information

  • The pyscript variant (-s ALLOW_MEMORY_GROWTH, no Asyncify) is not affected by Bug 2.
    It uses mp_js_do_exec_async via a different code path that is already properly async.
  • Bug 2 also affects master as of the date of this report.
  • A complete working patch (applied via sed at build time) is maintained at
    https://github.com/o-murphy/micropython-bclibc (usermod/Makefile, wasm_docker_build macro).

Code of Conduct

Yes, I agree

Activity

  1. dpgeorge commented on Jun 25, 2026

    @dpgeorge
    Member

    Thanks for the report.

    Indeed the ccall of mp_hal_get_interrupt_char should have no arguments, that needs to be fixed.

    For the asyncify issue when calling runPython(): it's true that when asyncify is enabled the ccall must use {async:true}. But I think to fix that there needs to be a new API entry point runPythonWithAsyncify() (similar to replProcessCharWithAsyncify()).

  2. added this to the release-1.29.0 milestone on Jun 25, 2026
  3. o-murphy commented on Jun 25, 2026

    @o-murphy
    ContributorAuthor

    @dpgeorge

    I have a bunch of patches that resolve this problem.

    0001-main.c-fix-external-call-depth-unused.patch

    --- a/ports/webassembly/main.c
    +++ b/ports/webassembly/main.c
    @@ -47,7 +47,7 @@
     // This counter tracks the current depth of calls into C code that originated
     // externally, ie from JavaScript.  When the counter is 0 that corresponds to
     // the top-level call into C.
    -static size_t external_call_depth = 0;
    +static size_t external_call_depth __attribute__((unused)) = 0;
     
     // Emscripten defaults to a 64k C-stack, so our limit should be less than that.
     #define CSTACK_SIZE (32 * 1024)

    0002-library.js-fix-interrupt-char-abi.patch

    --- a/ports/webassembly/library.js
    +++ b/ports/webassembly/library.js
    @@ -35,8 +35,8 @@
                 const mp_interrupt_char = Module.ccall(
                     "mp_hal_get_interrupt_char",
                     "number",
    -                ["number"],
    -                ["null"],
    +                [],
    +                [],
                 );
                 const fs = require("fs");
     
    

    0003-api.js-fix-runpython-async.patch

    --- a/ports/webassembly/api.js
    +++ b/ports/webassembly/api.js
    @@ -133,16 +133,17 @@
                 Module._free(value);
             },
             pyimport: pyimport,
    -        runPython(code) {
    +        async runPython(code) {
                 const len = Module.lengthBytesUTF8(code);
                 const buf = Module._malloc(len + 1);
                 Module.stringToUTF8(code, buf, len + 1);
                 const value = Module._malloc(3 * 4);
    -            Module.ccall(
    +            await Module.ccall(
                     "mp_js_do_exec",
                     "number",
                     ["pointer", "number", "pointer"],
                     [buf, len, value],
    +                { async: true },
                 );
                 Module._free(buf);
                 return proxy_convert_mp_to_js_obj_jsside_with_free(value);
    @@ -250,7 +251,7 @@
             }
     
             try {
    -            mp.runPython(contents);
    +            await mp.runPython(contents);
             } catch (error) {
                 if (error.name === "PythonError") {
                     if (error.type === "SystemExit") {
  4. o-murphy commented on Jun 25, 2026

    @o-murphy
    ContributorAuthor

    @dpgeorge
    Should I open pull request to upstream?
    I can add entry point you did propose

  5. dpgeorge commented on Jun 26, 2026

    @dpgeorge
    Member

    Feel free to open a PR for the mp_hal_get_interrupt_char() fix.

    But for the other issues it's a more complicated. If you enable ASYNCIFY then all api.js entry points really need to be async, but that completely changes the API. Need to investigate how pyodide does it.

    For now, I suggest using the pyscript variant.

  6. o-murphy commented on Jun 26, 2026

    @o-murphy
    ContributorAuthor

    @dpgeorge seems like mp_hal_get_interrupt_char fix done

  7. o-murphy commented on Jun 28, 2026

    @o-murphy
    ContributorAuthor

    @dpgeorge @agatti
    The PR #19382 fixes the first bug described in this issue. It is not affect api.js so should be clean to be merged. I'm waiting for review

  8. o-murphy commented on Jul 16, 2026

    @o-murphy
    ContributorAuthor

    @dpgeorge @agatti
    Hi maintainers! What is the discussion stuck on? What we need to make a decision about second bug described in the issue? Do you have some suggestions from what to start? Did somewho make some research how pyodide solves this problem?

  9. dpgeorge commented on Jul 16, 2026

    @dpgeorge
    Member

    It looks like Pyodide does not use ASYNCIFY at all (grepping their code base shows no signs of ASYNCIFY). So they do not encounter this issue. They do support JSPI though (preliminary support).

    I think the only reasonable course of action here is for MicroPython to drop support for ASYNCIFY. It's just not possible to make it work well with the API in api.js. Also, ASYNCIFY is very inefficient.

    Instead of ASYNCIFY we should try to integrate JSPI support.

    But in the meantime, I suggest using the pyscript variant of the webassembly port (make VARIANT=pyscript). That works well and does not use ASYNCIFY.

  10. dpgeorge commented on Jul 16, 2026

    @dpgeorge
    Member

    See also #19427.

  11. dpgeorge commented on Aug 13, 2026

    @dpgeorge
    Member

    See #19594 for JSPI support.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions