gh-156780: Emscripten: support building CPython with static linking (#156781)

Adds the ability to build Emscripten statically, so that it can be linked in as
an embedded library. Includes a test of the embedding case.
---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 19:58:32 +00:00
1 parent 1d53cdbdb8
commit d6b77c8686
10 files changed
+168 -49

No files matched your search

@@ -79,3 +79,5 @@ jobs:
run: python3 Platforms/emscripten run --test
- name: "Test Repl"
run: Platforms/emscripten/browser_test/run_test.sh
- name: "Test embedding"
run: Platforms/emscripten/web_embed_test/run_test.sh
+26 -6
View File
@@ -971,9 +971,12 @@ clinic: check-clean-src
clinic-tests: check-clean-src $(srcdir)/Lib/test/clinic.test.c
$(PYTHON_FOR_REGEN) $(srcdir)/Tools/clinic/clinic.py -f $(srcdir)/Lib/test/clinic.test.c
# Objects linked into the interpreter only, not into libpython.
PLATFORM_MAIN_OBJS= @PLATFORM_MAIN_OBJS@
# Build the interpreter
$(BUILDPYTHON): Programs/python.o $(LINK_PYTHON_DEPS)
$(LINKCC) $(PY_CORE_EXE_LDFLAGS) $(LINKFORSHARED) -o $@ Programs/python.o $(LINK_PYTHON_OBJS) $(LIBS) $(MODLIBS) $(SYSLIBS)
$(BUILDPYTHON): Programs/python.o $(PLATFORM_MAIN_OBJS) $(LINK_PYTHON_DEPS)
$(LINKCC) $(PY_CORE_EXE_LDFLAGS) $(LINKFORSHARED) -o $@ Programs/python.o $(PLATFORM_MAIN_OBJS) $(LINK_PYTHON_OBJS) $(LIBS) $(MODLIBS) $(SYSLIBS)
platform: $(PYTHON_FOR_BUILD_DEPS) pybuilddir.txt
$(RUNSHARED) $(PYTHON_FOR_BUILD) -c 'import sys ; from sysconfig import get_platform ; print("%s-%d.%d" % (get_platform(), *sys.version_info[:2]))' >platform
@@ -1120,6 +1123,20 @@ web_example/python.mjs web_example/python.wasm: $(BUILDPYTHON)
cp python.mjs web_example/python.mjs
cp python.wasm web_example/python.wasm
WEB_EMBED_TEST_DIR=$(EMSCRIPTEN_DIR)/web_embed_test
# Linked like an embedder would: against libpython.a, without $(LINKFORSHARED)
# and so without -sMAIN_MODULE or the interpreter's -sEXPORTED_FUNCTIONS.
web_embed_test/main.js: $(WEB_EMBED_TEST_DIR)/main.c $(LIBRARY) $(ZIP_STDLIB)
@mkdir -p web_embed_test
$(LINKCC) $(PY_STDMODULE_CFLAGS) $(PY_CORE_EXE_LDFLAGS) -O2 -g0 -o $@ \
$(WEB_EMBED_TEST_DIR)/main.c $(LIBRARY) $(LIBS) $(MODLIBS) $(SYSLIBS) \
-sALLOW_MEMORY_GROWTH -sSTACK_SIZE=5MB \
--preload-file $(ZIP_STDLIB)@/lib/stdlib.zip
.PHONY: web_embed_test
web_embed_test: web_embed_test/main.js
.PHONY: web_example
web_example: web_example/python.mjs web_example/python.worker.mjs web_example/index.html web_example/server.py web_example/$(ZIP_STDLIB)
@@ -1620,8 +1637,8 @@ regen-re: $(BUILDPYTHON)
# using Tools/build/generate_re_casefix.py
$(RUNSHARED) ./$(BUILDPYTHON) $(srcdir)/Tools/build/generate_re_casefix.py $(srcdir)/Lib/re/_casefix.py
Programs/_testembed: Programs/_testembed.o $(LINK_PYTHON_DEPS)
$(LINKCC) $(PY_CORE_EXE_LDFLAGS) $(LINKFORSHARED) -o $@ Programs/_testembed.o $(LINK_PYTHON_OBJS) $(LIBS) $(MODLIBS) $(SYSLIBS)
Programs/_testembed: Programs/_testembed.o $(PLATFORM_MAIN_OBJS) $(LINK_PYTHON_DEPS)
$(LINKCC) $(PY_CORE_EXE_LDFLAGS) $(LINKFORSHARED) -o $@ Programs/_testembed.o $(PLATFORM_MAIN_OBJS) $(LINK_PYTHON_OBJS) $(LIBS) $(MODLIBS) $(SYSLIBS)
############################################################################
# "Bootstrap Python" used to run Programs/_freeze_module.py
@@ -2251,7 +2268,7 @@ regen-slots: Python/slots.toml
$(PYTHON_FOR_REGEN) $(srcdir)/Tools/build/generate_slots.py \
--generate-all
$(LIBRARY_OBJS) $(MODOBJS) Programs/python.o: $(PYTHON_HEADERS)
$(LIBRARY_OBJS) $(MODOBJS) Programs/python.o $(PLATFORM_MAIN_OBJS): $(PYTHON_HEADERS)
######################################################################
@@ -2977,6 +2994,9 @@ libainstall: all scripts
fi; \
fi; \
$(INSTALL_DATA) Programs/python.o $(DESTDIR)$(LIBPL)/python.o; \
for i in $(PLATFORM_MAIN_OBJS); do \
$(INSTALL_DATA) $$i $(DESTDIR)$(LIBPL); \
done; \
fi
$(INSTALL_DATA) Modules/config.c $(DESTDIR)$(LIBPL)/config.c
$(INSTALL_DATA) $(srcdir)/Modules/config.c.in $(DESTDIR)$(LIBPL)/config.c.in
@@ -3291,7 +3311,7 @@ clean-retain-profile: pycremoval
find build -name '*.py[co]' -exec rm -f {} ';' || true
-rm -f pybuilddir.txt
-rm -f _bootstrap_python
-rm -rf web_example python.mjs python.wasm python*.symbols python*.map
-rm -rf web_example web_embed_test python.mjs python.wasm python*.symbols python*.map
-rm -f Programs/_testembed Programs/_freeze_module
-rm -rf Python/deepfreeze
-rm -f Python/frozen_modules/*.h
@@ -0,0 +1 @@
Emscripten ``libpython.a`` can now be linked into a program statically.
+1 -1
View File
@@ -3014,7 +3014,7 @@ EM_JS(int, emscripten_set_up_async_input_device_js, (void), {
await sleep(5);
return bufs[(idx ++) % 3];
});
return !!WebAssembly.promising;
return !!Module._Py_EmscriptenStackSwitching;
});
static PyObject *
@@ -0,0 +1,50 @@
/* Smoke test for libpython linked into a program whose main() is not Python's. */
#include <Python.h>
#include <stdio.h>
// Imports come from the preloaded zip, len() goes through the call trampoline
// and poll() through the syscall overrides.
static const char *SCRIPT =
"import json, select\n"
"select.poll().poll(0)\n"
"print(json.dumps({'embedded': len('ok')}))\n";
int main(void)
{
PyStatus status;
PyConfig config;
PyConfig_InitIsolatedConfig(&config);
config.write_bytecode = 0;
config.module_search_paths_set = 1;
status = PyWideStringList_Append(&config.module_search_paths,
L"/lib/stdlib.zip");
if (PyStatus_Exception(status)) {
goto exception;
}
status = PyConfig_SetBytesString(&config, &config.executable, "/embed");
if (PyStatus_Exception(status)) {
goto exception;
}
status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
goto exception;
}
PyConfig_Clear(&config);
if (PyRun_SimpleString(SCRIPT) != 0) {
puts("web_embed_test: script failed");
return 1;
}
if (Py_FinalizeEx() < 0) {
puts("web_embed_test: Py_FinalizeEx failed");
return 1;
}
puts("web_embed_test: ok");
return 0;
exception:
PyConfig_Clear(&config);
Py_ExitStatusException(status);
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Build and run the libpython embedding smoke test.
set -euo pipefail
cd "$(dirname "$0")/../../.."
BUILD_DIR=${CROSS_BUILD_DIR:-cross-build}/wasm32-emscripten/build/python
make -C "$BUILD_DIR" web_embed_test
# Node 24 needs JSPI enabled explicitly; it is on by default afterwards.
NODE=${NODE:-node}
NODE_FLAGS=
if [ "$("$NODE" -p 'process.versions.node.split(".")[0]')" = 24 ]; then
NODE_FLAGS=--experimental-wasm-jspi
fi
# Run from the build directory: Emscripten resolves main.data relative to cwd.
cd "$BUILD_DIR/web_embed_test"
rc=0
out=$("$NODE" $NODE_FLAGS main.js 2>&1) || rc=$?
echo "$out"
[ "$rc" -eq 0 ] && grep -q "web_embed_test: ok" <<<"$out"
+14
View File
@@ -0,0 +1,14 @@
/* Emscripten setup for when Python is the program. Linked into the
* interpreter only, never into libpython.
*
* Runs main() under WebAssembly.promising so that libpython can suspend the
* wasm stack in a syscall (see Python/emscripten_syscalls.c).
*/
void _PyEmscripten_BeforeMain_js(void);
__attribute__((constructor)) void
_PyEmscripten_BeforeMain(void)
{
_PyEmscripten_BeforeMain_js();
}
+46 -42
View File
@@ -54,13 +54,40 @@ __attribute__((constructor)) void __syscall_init_umask(void) {
#define EM_JS_MACROS(ret, func_name, args, body...) \
EM_JS(ret, func_name, args, body)
EM_JS_MACROS(void, _emscripten_promising_main_js, (void), {
// Define FS.createAsyncInputDevice(), This is quite similar to
// FS.createDevice() defined here:
// https://github.com/emscripten-core/emscripten/blob/4.0.11/src/lib/libfs.js?plain=1#L1642
// but instead of returning one byte at a time, the input() function should
// return a Uint8Array. This makes the handler code simpler, the
// `createAsyncInputDevice` simpler, and everything faster.
EM_JS(void, _PyEmscripten_BeforeMain_js, (void), {
if (!WebAssembly.promising) {
// No stack switching support =(
return;
}
if (ENVIRONMENT_IS_NODE && !Module.onExit) {
Module.onExit = (code) => process.exit(code);
}
// promising() needs the raw export; _main may be a JS wrapper around it.
const main = WebAssembly.promising(wasmExports.__main_argc_argv);
_main = (...args) => {
Module._Py_EmscriptenStackSwitching = true;
(async () => {
const ret = await main(...args);
Module.onExit?.(ret);
})();
_emscripten_exit_with_live_runtime();
};
// callMain() takes the entry point from _main, or from wasmImports.main
// when linked with -sMAIN_MODULE.
if ("main" in wasmImports) {
wasmImports.main = _main;
}
})
EM_JS_DEPS(_PyEmscripten_BeforeMain, "emscripten_exit_with_live_runtime");
// Define FS.createAsyncInputDevice(), This is quite similar to
// FS.createDevice() defined here:
// https://github.com/emscripten-core/emscripten/blob/4.0.11/src/lib/libfs.js?plain=1#L1642
// but instead of returning one byte at a time, the input() function should
// return a Uint8Array. This makes the handler code simpler, the
// `createAsyncInputDevice` simpler, and everything faster.
EM_JS_MACROS(void, __fs_init_async_input_device_js, (void), {
FS.createAsyncInputDevice = function(parent, name, input) {
parent = typeof parent == 'string' ? parent : FS.getPath(parent);
var path = PATH.join2(parent, name);
@@ -103,44 +130,14 @@ EM_JS_MACROS(void, _emscripten_promising_main_js, (void), {
FS.registerDevice(dev, ops);
return FS.mkdev(path, mode, dev);
};
if (!WebAssembly.promising) {
// No stack switching support =(
return;
}
const origResolveGlobalSymbol = resolveGlobalSymbol;
if (ENVIRONMENT_IS_NODE && !Module.onExit) {
Module.onExit = (code) => process.exit(code);
}
// * wrap the main symbol with WebAssembly.promising,
// * call exit_with_live_runtime() to prevent emscripten from shutting down
// the runtime before the promise resolves,
// * call onExit / process.exit ourselves, since exit_with_live_runtime()
// prevented Emscripten from calling it normally.
resolveGlobalSymbol = function (name, direct = false) {
const orig = origResolveGlobalSymbol(name, direct);
if (name === "main") {
const main = WebAssembly.promising(orig.sym);
orig.sym = (...args) => {
(async () => {
const ret = await main(...args);
Module.onExit?.(ret);
})();
_emscripten_exit_with_live_runtime();
};
}
return orig;
};
})
EM_JS_DEPS(_emscripten_promising_main,
"$FS,$PATH,$FS_getMode,$resolveGlobalSymbol,"
"emscripten_exit_with_live_runtime");
EM_JS_DEPS(__fs_init_async_input_device, "$FS,$PATH,$FS_getMode");
__attribute__((constructor)) void _emscripten_promising_main(void) {
_emscripten_promising_main_js();
__attribute__((constructor)) void __fs_init_async_input_device(void) {
__fs_init_async_input_device_js();
}
#define IOVEC_T_BUF_OFFSET 0
#define IOVEC_T_BUF_LEN_OFFSET 4
#define IOVEC_T_SIZE 8
@@ -154,13 +151,20 @@ _Static_assert(sizeof(__wasi_iovec_t) == IOVEC_T_SIZE,
// If the stream has a readAsync handler, read to buffer defined in iovs, write
// number of bytes read to *nread, and return a promise that resolves to the
// errno. Otherwise, return null.
//
// Reading from an async input device and poll() suspend the wasm stack
// instead of blocking when main() runs under WebAssembly.promising, which
// Programs/emscripten_beforemain.c arranges for the interpreter. An embedder
// running its own promising entry point opts in with
// Module._Py_EmscriptenStackSwitching = true;
// Otherwise these calls keep their synchronous behavior.
EM_JS_MACROS(__externref_t, __maybe_fd_read_async, (
__wasi_fd_t fd,
const __wasi_iovec_t *iovs,
size_t iovcnt,
__wasi_size_t *nread
), {
if (!WebAssembly.promising) {
if (!Module._Py_EmscriptenStackSwitching) {
return null;
}
var stream;
@@ -244,7 +248,7 @@ _Static_assert(offsetof(struct pollfd, revents) == 6, "Unepxected pollfd struct
_Static_assert(sizeof(struct pollfd) == 8, "Unepxected pollfd struct layout");
EM_JS_MACROS(__externref_t, __maybe_poll_async, (intptr_t fds, int nfds, int timeout), {
if (!WebAssembly.promising) {
if (!Module._Py_EmscriptenStackSwitching) {
return null;
}
return (async function() {
Generated Vendored
+4
View File
@@ -878,6 +878,7 @@ TRUE
MACHDEP_OBJS
DYNLOADFILE
DLINCLDIR
PLATFORM_MAIN_OBJS
PLATFORM_OBJS
PLATFORM_HEADERS
DTRACE_OBJS
@@ -20434,6 +20435,7 @@ fi
PLATFORM_HEADERS=
PLATFORM_OBJS=
PLATFORM_MAIN_OBJS=
case $ac_sys_system in #(
Emscripten) :
@@ -20444,6 +20446,7 @@ case $ac_sys_system in #(
if test "x$enable_emscripten_syscalls" = xyes
then :
as_fn_append PLATFORM_MAIN_OBJS ' Programs/emscripten_beforemain.o'
as_fn_append PLATFORM_OBJS ' Python/emscripten_syscalls.o'
fi
@@ -20460,6 +20463,7 @@ esac
# -I${DLINCLDIR} is added to the compile rule for importdl.o
DLINCLDIR=.
+3
View File
@@ -5488,6 +5488,7 @@ fi
dnl Platform-specific C and header files.
PLATFORM_HEADERS=
PLATFORM_OBJS=
PLATFORM_MAIN_OBJS=
AS_CASE([$ac_sys_system],
[Emscripten], [
@@ -5495,6 +5496,7 @@ AS_CASE([$ac_sys_system],
AS_VAR_APPEND([CFLAGS_NODIST], [" -mgc"])
AS_VAR_IF([enable_emscripten_syscalls], [yes], [
AS_VAR_APPEND([PLATFORM_MAIN_OBJS], [' Programs/emscripten_beforemain.o'])
AS_VAR_APPEND([PLATFORM_OBJS], [' Python/emscripten_syscalls.o'])
])
AS_VAR_APPEND([PLATFORM_HEADERS], [' $(srcdir)/Include/internal/pycore_emscripten_signal.h'])
@@ -5506,6 +5508,7 @@ AS_CASE([$ac_sys_system],
)
AC_SUBST([PLATFORM_HEADERS])
AC_SUBST([PLATFORM_OBJS])
AC_SUBST([PLATFORM_MAIN_OBJS])
# -I${DLINCLDIR} is added to the compile rule for importdl.o
AC_SUBST([DLINCLDIR])