Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 776 – Emscripten Support

PEP 776 – Emscripten Support

Author:
Hood Chatham <roberthoodchatham at gmail.com>
Sponsor:
Łukasz Langa <lukasz at python.org>
Discussions-To:
Discourse thread
Status:
Active
Type:
Informational
Created:
18-Mar-2025
Python-Version:
3.14
Post-History:
18-Mar-2025, 28-Mar-2025
Resolution:
04-Apr-2026

Table of Contents

Abstract

Emscripten is a complete open source compiler toolchain. It compiles C/C++ code into WebAssembly/JavaScript executables, for use in JavaScript runtimes, including browsers and Node.js. The Rust language also maintains an Emscripten target.

This PEP formalizes the addition of Tier 3 for Emscripten support in Python 3.14 which was approved by the Steering Council on October 25, 2024. The goals are:

  1. To describe the current state of the CPython Emscripten runtime
  2. To describe the current state of the Pyodide runtime
  3. To identify minor features to be upstreamed from the Pyodide runtime into the CPython Emscripten runtime

The minor features identified here are all features that could be implemented without a PEP. We discuss more significant runtime features that we would like to implement but we defer decisions on those features to subsequent PEPs.

Motivation

A web browser is a universal computing platform, available on Windows, macOS, Linux, and every smartphone.

The Pyodide project has supported Emscripten Python since 2018. Hundreds of thousands of students have learned Python through Pyodide via projects like Capytale and PyodideU. Pyodide is also increasingly being used by Python packages to provide interactive documentation. This demonstrates both the importance and the maturity of the Emscripten platform.

Emscripten and WASI are also the only supported platforms that offer any meaningful sandboxing.

Emscripten Platform Information

“Pyodide” vs “Emscripten Python”

For the sake of this document, we use the term “Emscripten Python” to refer to the Emscripten Python maintained in the python/cpython repository, without any downstream additions. We contrast the features present in Emscripten Python to the features present in Pyodide.

Pyodide is maintained on GitHub and distributed via jsDelivr, npm, and GitHub releases.

Emscripten Python is not distributed, but it is possible to build by following the instructions in the devguide

Background on Emscripten

Emscripten consists of a C and C++ compiler and linker based on LLVM, together with a runtime based on a mildly patched musl libc.

Emscripten is a POSIX-based platform. It uses the WebAssembly binary format, and the WebAssembly dynamic linking section.

The emcc compiler is a wrapper around clang. The emcc linker is a wrapper around wasm-ld (also part of the LLVM toolchain).

Emscripten support for portable C/C++ code source compatibility with Linux is fairly comprehensive, with certain expected exceptions to be spelled out. CPython already supports compilation to Emscripten, and it only requires a very modest number of modifications to the normal Linux target.

POSIX Compliance

Emscripten is a POSIX platform. However, there are POSIX APIs that exist but always fail when called and POSIX APIs that don’t exist at all. In particular, there are problems with networking APIs and blocking I/O, and there is no support for fork(). See Emscripten Portability Guidelines.

Emscripten executables can be linked with threading support, but it comes with several limitations:

  • Enabling threading requires websites to be served with special security headers that indicate acceptance of the possibility of Spectre-style information leakage. These headers are a usability hazard for users who are not intimately familiar with the web platform.
  • If an executable is linked with both threading and a dynamic loader, Emscripten prints a warning that using dynamic loading and pthreads together is experimental. It may cause performance problems or crashes. These problems may require WebAssembly standards work to resolve.

Because of these limitations, Pyodide standardizes a no-pthreads build of Python. If there is sufficient demand, a pthreads build with no dynamic loader could be added later.

Development Tools

Emscripten development tools are equally well supported on Linux, Windows, and macOS. The upstream tools include:

  • The Emscripten Software Developer Kit (emsdk) which can be used to install the Emscripten compiler toolchain (emcc).
  • emcc is a C and C++ compiler, linker, and a sysroot with headers for the system libraries. The system libraries themselves are generated on the fly based on the ABI requested.
  • Node.js can be used as an “emulator” to run Emscripten programs from the command line. This emulation behaves best on Linux with macOS as a runner up. Node.js is the most convenient way to test Emscripten programs.
  • It is possible to run Emscripten programs inside of any web browser. Browser automation tools like Selenium, Playwright, or Puppeteer can be used to test features that are browser-only.

Pyodide’s tools:

  • pyodide build can be used to cross compile Python packages to run on Emscripten. Cross compilation works best on Linux, there is experimental support on macOS, and it is entirely unsupported on Windows.
  • pyodide venv can make a virtual environment that runs in Pyodide.
  • pytest-pyodide can test Python code against various JavaScript runtimes.

cibuildwheel supports building wheels to target Emscripten using pyodide build.

In the short term, Pyodide’s packaging tooling will stay in the Pyodide repository. It is an open question where Pyodide’s packaging tooling should live in the long term. Two sensible options would be for it to remain under the pyodide organization or be moved into the pypa organization on GitHub.

Emscripten Application Lifecycle

An Emscripten “binary” consists of a pair of files, an .mjs file and a .wasm file. The .wasm file contains all of the compiled C/C++/Rust code. The .mjs file contains the lifecycle code to set up the runtime, locate the .wasm file, compile it, instantiate it, call the main() function, and to shut down the runtime on exit. It also includes an implementation for all of the system calls, including the file system, the dynamic loader, and any logic to expose additional functionality from the JavaScript runtime to C code.

The .mjs file exports a single bootstrapEmscriptenExecutable() JavaScript function that bootstraps the runtime, calls the main() function, and returns an API object that can be used to call C functions. Each time it is called produces a complete and independent copy of the runtime with its own separate address space.

The bootstrapEmscriptenExecutable() takes a large number of runtime settings. The full list is described in the Emscripten documentation here. The most important of these are as follows:

  • thisProgram: The value of argv[0]. In Python, this makes its way into sys.executable.
  • arguments: The list of string arguments to be passed to main().
  • preRun: A list of callbacks which are invoked after the JavaScript runtime and file system have been bootstrapped but before calling main(). Useful to set up the file system, environment variables, and standard streams.
  • print / printErr : Initial handlers for stdout and stderr. They are line buffered and performing a flush() of a partial line forces an extra new line. If tty-like behavior is desired, the standard stream devices should be replaced in a preRun() hook.
  • onExit: A handler that is called when the runtime exits.
  • instantiateWasm: A callback that is called to instantiate the WebAssembly module. Overriding the WebAssembly instantiation procedure via this function is useful when you have other custom asynchronous startup actions or downloads that can be performed in parallel to WebAssembly compilation. Implementing this callback allows performing all of these in parallel.

File System Setup

The Standard Library

In order for Python to run, it needs access to the standard library in the Emscripten file system. There are several possible approaches to this:

  • The Emscripten linker has a --preload-file flag that will automatically handle loading files. Information about how it works is available here. This is the simplest approach, but Pyodide has moved away from it because it embeds the files into a custom archive format that cannot be processed with standard tooling.
  • For Node.js, use the NODEFS to mount a native directory with the files into the Emscripten file system. This is the most efficient option but is Node only. It is closely analogous to what WASI does.
  • Put the standard library into a zip archive and use ZipImporter. Using an uncompressed zip file allows the web server and client to apply better compression to the standard library itself. It also uses the more efficient native decompression algorithms of the browser rather than less efficient WebAssembly decompression. The disadvantages of this are a higher memory footprint and breaking inspect & various tests that do not expect the standard library to be packaged in this way.
  • Put the standard library into an uncompressed tar archive and mount it into a TARFS read only file system backed by the tar file. This has the best memory usage, runtime performance, and transfer size of the options that can be used in the browser. The disadvantage is that Emscripten does not itself include a TARFS so it requires a downstream implementation.

Pyodide uses the ZipImporter approach in every runtime. Python uses the NODEFS approach when run with node and the ZipImporter approach for the web example. We will continue with this approach.

The ZipImporter provides a clean resolution for a bootstrapping problem: the Python runtime is capable of unpacking a wide variety of archive formats, but the Python runtime is not ready to use until the standard library is already available. Since zipimport.py is a frozen module, it avoids these problems. All of the other approaches solve the bootstrapping problem by setting up the standard library using JavaScript.

Third-party packages

It is also necessary to make any needed packages available in the Emscripten file system. Currently Emscripten CPython has no support for packages. Pyodide uses two different approaches for packages:

  • In the browser, Pyodide downloads and unpacks wheels into the MEMFS site-packages directory. It then preloads all dynamic libraries in the wheel. The work of downloading and installing all the packages is redone every time the runtime starts.
  • The Pyodide python CLI entrypoint mounts all of the host file system as NODEFS directories before it bootstraps Python. This allows the normal virtual environment mechanism to work. Pyodide virtual environments contain a patched copy of pip and a custom pip.conf so that pip will install Pyodide wheels. On startup the Pyodide python CLI will preload all Emscripten dynamic libraries that are in the site-packages directory.

Console and Interactive Usage

stdin defaults to always returning EOF, while stdout and stderr default to calling console.log and console.error respectively. It is possible to pass handlers to bootstrapEmscriptenExecutable() to configure the standard streams, but no matter what the I/O devices have undesirable line buffering behavior that forces a new line when flushed. To implement a well behaved TTY in-browser, it is necessary to remove the default I/O devices and replace them in a preRun hook.

Making stdin work correctly in the browser poses an additional challenge because it is not allowed to block for user input in the main thread of the browser. If Emscripten is run in a web worker and served with the shared memory headers, it is possible to receive input using shared memory and atomics. It is also possible for a stdin device to block in a simpler and more efficient manner using stack switching using the experimental JavaScript Promise Integration API.

Pyodide replaces the standard I/O devices in order to fix the line buffering behavior. When Pyodide is run in Node.js, stdin, stdout, and stderr are by default connected to process.stdin, process.stdout, and process.stderr and so the standard streams work as a tty out of the box. Pyodide also ensures that shutil.get_terminal_size returns results consistent with process.stdout.rows and process.stdout.columns. Pyodide currently has no support for stack switching stdin.

Currently, the Emscripten Python Node.js runner uses the default I/O that Emscripten provides. The web example uses Atomics for stdin and has custom stdout and stderr handlers, but they exhibit the undesirable line buffering behavior. We will upstream the standard streams behaviors from Pyodide.

In the long term, we hope to implement stack switching stdin devices, but that is out of scope for this PEP.

Traps and Uncaught Exceptions

We consider the C runtime state to be corrupted if there is a WebAssembly trap, an unhandled JavaScript exception, or an uncaught WebAssembly throw instruction.

Unlike in other platforms, there is no operating system to shut down the executable when there is a trap or other unrecoverable corruption of the libc runtime. We need to provide our own code to print tracebacks, dump the memory, or do whatever else is helpful for debugging a crash. If we expose a JavaScript API, we also must ensure that it is disabled after an unrecoverable crash to prevent downstream users from observing the Python runtime in an inconsistent state.

In order to detect fatal errors, Pyodide uses the following approach: all fallable calls from WebAssembly into JavaScript are wrapped with a JavaScript try/catch block. Any caught JavaScript exceptions are translated into Python exceptions. This ensures that any recoverable JavaScript error is caught before it unwinds through any WebAssembly frames. All entrypoints to WebAssembly are also wrapped with JavaScript try/catch blocks. Any exceptions caught there have unwound WebAssembly frames and are thus considered to be fatal errors (though there is a special case to handle exit()). This requires foundational integration with the Python/JavaScript foreign function interface.

When the Pyodide runtime catches a fatal exception, it introspects the error to determine whether it came from a trap, a logic error in a system call, a setjmp() without a longjmp(), or a libcxxabi call to __cxa_throw() (an uncaught C++ exception or Rust panic). We render as informative an error message as we can. We also call _Py_DumpTraceback() so we can display a Python traceback in addition to the JS/WebAssembly traceback. It also disables the JavaScript API so that further attempts to call into Python result in an error saying that the runtime has fatally failed.

Normally, WebAssembly symbols are stripped so the WebAssembly frames are not very useful. Compiling and linking with