From 692abd8379d198d818222342aa537d27c0bf567b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Tue, 1 Sep 2026 21:39:00 +0200 Subject: [PATCH 1/7] gh-156780: Emscripten: add missing EM_JS_DEPS EM_JS bodies are emitted verbatim, so Emscripten cannot see the JS symbols they reference. Declare them. Co-Authored-By: Claude Opus 5 --- .../Build/2026-09-01-23-32-01.gh-issue-156780.Kz8vQr.rst | 3 +++ Python/emscripten_syscalls.c | 8 ++++++++ Python/emscripten_trampoline.c | 3 +++ Python/sysmodule.c | 2 ++ 4 files changed, 16 insertions(+) create mode 100644 Misc/NEWS.d/next/Build/2026-09-01-23-32-01.gh-issue-156780.Kz8vQr.rst diff --git a/Misc/NEWS.d/next/Build/2026-09-01-23-32-01.gh-issue-156780.Kz8vQr.rst b/Misc/NEWS.d/next/Build/2026-09-01-23-32-01.gh-issue-156780.Kz8vQr.rst new file mode 100644 index 000000000000000..f27d43db09fea4a --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-09-01-23-32-01.gh-issue-156780.Kz8vQr.rst @@ -0,0 +1,3 @@ +Emscripten: declare the JS symbols used by :c:macro:`EM_JS` bodies with +``EM_JS_DEPS``, so that Emscripten includes them instead of silently omitting +them. diff --git a/Python/emscripten_syscalls.c b/Python/emscripten_syscalls.c index 48ca208dedb14bb..cdd5fc6e91e89bc 100644 --- a/Python/emscripten_syscalls.c +++ b/Python/emscripten_syscalls.c @@ -132,6 +132,10 @@ EM_JS_MACROS(void, _emscripten_promising_main_js, (void), { }; }) +EM_JS_DEPS(_emscripten_promising_main, + "$FS,$PATH,$FS_getMode,$resolveGlobalSymbol," + "emscripten_exit_with_live_runtime"); + __attribute__((constructor)) void _emscripten_promising_main(void) { _emscripten_promising_main_js(); } @@ -199,6 +203,8 @@ EM_JS_MACROS(__externref_t, __maybe_fd_read_async, ( }; ); +EM_JS_DEPS(__maybe_fd_read_async, "$SYSCALLS"); + // Bind original fd_read syscall to __wasi_fd_read_orig(). __wasi_errno_t __wasi_fd_read_orig(__wasi_fd_t fd, const __wasi_iovec_t *iovs, size_t iovs_len, __wasi_size_t *nread) @@ -280,6 +286,8 @@ EM_JS_MACROS(__externref_t, __maybe_poll_async, (intptr_t fds, int nfds, int tim })(); }); +EM_JS_DEPS(__maybe_poll_async, "$FS"); + // Bind original poll syscall to syscall_poll_orig(). int syscall_poll_orig(intptr_t fds, int nfds, int timeout) __attribute__((__import_module__("env"), diff --git a/Python/emscripten_trampoline.c b/Python/emscripten_trampoline.c index 1833311ca74d9dd..75cfde6b76f2174 100644 --- a/Python/emscripten_trampoline.c +++ b/Python/emscripten_trampoline.c @@ -94,6 +94,9 @@ addOnPreRun(function setEmscriptenTrampoline() { }); ); +EM_JS_DEPS(_PyEM_TrampolineCall, + "$wasmTable,$wasmMemory,$addFunction,$addOnPreRun"); + PyObject* _PyEM_TrampolineCall(PyCFunctionWithKeywords func, PyObject* self, diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 5051dfc3ec0f861..3218e4947598911 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3905,6 +3905,8 @@ EM_JS(char *, _Py_emscripten_runtime, (void), { #endif }); +EM_JS_DEPS(_Py_emscripten_runtime, "$stringToNewUTF8"); + static PyObject * make_emscripten_info(void) { From 3916b1d6a8a18514e830d910e47841fbdd5da1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Tue, 1 Sep 2026 22:47:40 +0200 Subject: [PATCH 2/7] gh-156780: Emscripten: move promising-main setup out of libpython The FS.createAsyncInputDevice() definition and the resolveGlobalSymbol hook that wraps main() in WebAssembly.promising() only make sense when Python is the program, but they lived in libpython, so every embedder got them. PLATFORM_OBJS goes into LIBRARY_OBJS and so into libpython. Add PLATFORM_MAIN_OBJS for platform objects that belong to the interpreter alone, and move this setup to Programs/emscripten_beforemain.c. Co-Authored-By: Claude Opus 5 --- Makefile.pre.in | 7 ++- Programs/emscripten_beforemain.c | 95 ++++++++++++++++++++++++++++++++ Python/emscripten_syscalls.c | 86 ----------------------------- configure | 4 ++ configure.ac | 4 ++ 5 files changed, 108 insertions(+), 88 deletions(-) create mode 100644 Programs/emscripten_beforemain.c diff --git a/Makefile.pre.in b/Makefile.pre.in index d3d24a13898d992..358af0badeb19f1 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -966,9 +966,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 diff --git a/Programs/emscripten_beforemain.c b/Programs/emscripten_beforemain.c new file mode 100644 index 000000000000000..357c852ee9d7da2 --- /dev/null +++ b/Programs/emscripten_beforemain.c @@ -0,0 +1,95 @@ +/* Emscripten setup for when Python is the program. Linked into the + * interpreter only, never into libpython. + */ + +#include +#include + +// Variant of EM_JS that does C preprocessor substitution on the body +#define EM_JS_MACROS(ret, func_name, args, body...) \ + EM_JS(ret, func_name, args, body) +EM_JS_MACROS(void, _PyEmscripten_BeforeMain_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. + FS.createAsyncInputDevice = function(parent, name, input) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + var path = PATH.join2(parent, name); + var mode = FS_getMode(true, false); + FS.createDevice.major ||= 64; + var dev = FS.makedev(FS.createDevice.major++, 0); + async function getDataBuf() { + var buf; + try { + buf = await input(); + } catch (e) { + throw new FS.ErrnoError(EIO); + } + if (!buf?.byteLength) { + throw new FS.ErrnoError(EAGAIN); + } + ops._dataBuf = buf; + } + + var ops = { + _dataBuf: new Uint8Array(0), + open(stream) { + stream.seekable = false; + }, + async readAsync(stream, buffer, offset, length, pos /* ignored */) { + buffer = buffer.subarray(offset, offset + length); + if (!ops._dataBuf.byteLength) { + await getDataBuf(); + } + var toRead = Math.min(ops._dataBuf.byteLength, buffer.byteLength); + buffer.subarray(0, toRead).set(ops._dataBuf); + buffer = buffer.subarray(toRead); + ops._dataBuf = ops._dataBuf.subarray(toRead); + if (toRead) { + stream.node.atime = Date.now(); + } + return toRead; + }, + }; + 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(_PyEmscripten_BeforeMain, + "$FS,$PATH,$FS_getMode,$resolveGlobalSymbol," + "emscripten_exit_with_live_runtime"); + +__attribute__((constructor)) void _PyEmscripten_BeforeMain(void) { + _PyEmscripten_BeforeMain_js(); +} diff --git a/Python/emscripten_syscalls.c b/Python/emscripten_syscalls.c index cdd5fc6e91e89bc..de9a602d7a6a93e 100644 --- a/Python/emscripten_syscalls.c +++ b/Python/emscripten_syscalls.c @@ -54,92 +54,6 @@ __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. - FS.createAsyncInputDevice = function(parent, name, input) { - parent = typeof parent == 'string' ? parent : FS.getPath(parent); - var path = PATH.join2(parent, name); - var mode = FS_getMode(true, false); - FS.createDevice.major ||= 64; - var dev = FS.makedev(FS.createDevice.major++, 0); - async function getDataBuf() { - var buf; - try { - buf = await input(); - } catch (e) { - throw new FS.ErrnoError(EIO); - } - if (!buf?.byteLength) { - throw new FS.ErrnoError(EAGAIN); - } - ops._dataBuf = buf; - } - - var ops = { - _dataBuf: new Uint8Array(0), - open(stream) { - stream.seekable = false; - }, - async readAsync(stream, buffer, offset, length, pos /* ignored */) { - buffer = buffer.subarray(offset, offset + length); - if (!ops._dataBuf.byteLength) { - await getDataBuf(); - } - var toRead = Math.min(ops._dataBuf.byteLength, buffer.byteLength); - buffer.subarray(0, toRead).set(ops._dataBuf); - buffer = buffer.subarray(toRead); - ops._dataBuf = ops._dataBuf.subarray(toRead); - if (toRead) { - stream.node.atime = Date.now(); - } - return toRead; - }, - }; - 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"); - -__attribute__((constructor)) void _emscripten_promising_main(void) { - _emscripten_promising_main_js(); -} - #define IOVEC_T_BUF_OFFSET 0 #define IOVEC_T_BUF_LEN_OFFSET 4 diff --git a/configure b/configure index ea560d600722592..b23d5e85d2a8ae0 100755 --- a/configure +++ b/configure @@ -879,6 +879,7 @@ TRUE MACHDEP_OBJS DYNLOADFILE DLINCLDIR +PLATFORM_MAIN_OBJS PLATFORM_OBJS PLATFORM_HEADERS DTRACE_OBJS @@ -20206,10 +20207,12 @@ fi PLATFORM_HEADERS= PLATFORM_OBJS= +PLATFORM_MAIN_OBJS= case $ac_sys_system in #( Emscripten) : + as_fn_append PLATFORM_MAIN_OBJS ' Programs/emscripten_beforemain.o' as_fn_append PLATFORM_OBJS ' Python/emscripten_signal.o Python/emscripten_trampoline.o Python/emscripten_trampoline_wasm.o' if test "x$enable_emscripten_syscalls" = xyes then : @@ -20225,6 +20228,7 @@ esac + # -I${DLINCLDIR} is added to the compile rule for importdl.o DLINCLDIR=. diff --git a/configure.ac b/configure.ac index 7dfc6ca29297a8b..3a3c33518073891 100644 --- a/configure.ac +++ b/configure.ac @@ -5401,9 +5401,12 @@ fi dnl Platform-specific C and header files. PLATFORM_HEADERS= PLATFORM_OBJS= +dnl Objects linked into the interpreter only, not into libpython. +PLATFORM_MAIN_OBJS= AS_CASE([$ac_sys_system], [Emscripten], [ + AS_VAR_APPEND([PLATFORM_MAIN_OBJS], [' Programs/emscripten_beforemain.o']) AS_VAR_APPEND([PLATFORM_OBJS], [' Python/emscripten_signal.o Python/emscripten_trampoline.o Python/emscripten_trampoline_wasm.o']) AS_VAR_IF([enable_emscripten_syscalls], [yes], [ AS_VAR_APPEND([PLATFORM_OBJS], [' Python/emscripten_syscalls.o']) @@ -5413,6 +5416,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]) From 12a6bbcf2293c88b691b59d1f20467694b854983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Tue, 1 Sep 2026 22:47:54 +0200 Subject: [PATCH 3/7] gh-156780: Emscripten: support static linking Export _PyRuntime's address from the call trampoline so it works without -sEXPORTED_FUNCTIONS, do not suspend in poll() when there is nothing to await, pass -sALLOW_TABLE_GROWTH when not linking with MAIN_MODULE, and wrap main() through the _main binding when there is no resolveGlobalSymbol. Co-Authored-By: Claude Opus 5 --- Programs/emscripten_beforemain.c | 61 +++++++++++++++++++++----------- Python/emscripten_syscalls.c | 13 +++++++ Python/emscripten_trampoline.c | 6 +++- configure | 10 +++++- configure.ac | 7 +++- pyconfig.h.in | 3 ++ 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/Programs/emscripten_beforemain.c b/Programs/emscripten_beforemain.c index 357c852ee9d7da2..2e3792745d8180e 100644 --- a/Programs/emscripten_beforemain.c +++ b/Programs/emscripten_beforemain.c @@ -2,12 +2,31 @@ * interpreter only, never into libpython. */ +#include "pyconfig.h" + #include #include // Variant of EM_JS that does C preprocessor substitution on the body #define EM_JS_MACROS(ret, func_name, args, body...) \ EM_JS(ret, func_name, args, body) + +#ifdef Py_EMSCRIPTEN_DYNAMIC_LINKING +#define _Py_EM_WRAP_MAIN \ + const origResolveGlobalSymbol = resolveGlobalSymbol; \ + resolveGlobalSymbol = function (name, direct = false) { \ + const orig = origResolveGlobalSymbol(name, direct); \ + if (name === "main") { \ + orig.sym = _PyEM_promising(orig.sym); \ + } \ + return orig; \ + }; +#else +// wasmExports["main"], not _main: promising() rejects the export wrapper. +#define _Py_EM_WRAP_MAIN \ + _main = _PyEM_promising(wasmExports["main"]); +#endif + EM_JS_MACROS(void, _PyEmscripten_BeforeMain_js, (void), { // Define FS.createAsyncInputDevice(), This is quite similar to // FS.createDevice() defined here: @@ -61,34 +80,36 @@ EM_JS_MACROS(void, _PyEmscripten_BeforeMain_js, (void), { // 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; + _Py_EM_WRAP_MAIN +} +// * wrap the entry point 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. +function _PyEM_promising(orig) { + const main = WebAssembly.promising(orig); + return (...args) => { + (async () => { + const ret = await main(...args); + Module.onExit?.(ret); + })(); + _emscripten_exit_with_live_runtime(); }; -}) +} +) +#ifdef Py_EMSCRIPTEN_DYNAMIC_LINKING EM_JS_DEPS(_PyEmscripten_BeforeMain, "$FS,$PATH,$FS_getMode,$resolveGlobalSymbol," "emscripten_exit_with_live_runtime"); +#else +EM_JS_DEPS(_PyEmscripten_BeforeMain, + "$FS,$PATH,$FS_getMode,emscripten_exit_with_live_runtime"); +#endif __attribute__((constructor)) void _PyEmscripten_BeforeMain(void) { _PyEmscripten_BeforeMain_js(); diff --git a/Python/emscripten_syscalls.c b/Python/emscripten_syscalls.c index de9a602d7a6a93e..d284086a968374d 100644 --- a/Python/emscripten_syscalls.c +++ b/Python/emscripten_syscalls.c @@ -161,6 +161,19 @@ EM_JS_MACROS(__externref_t, __maybe_poll_async, (intptr_t fds, int nfds, int tim if (!WebAssembly.promising) { return null; } + // Suspending at all requires a promising stack, so do not if there is + // nothing to await. + var async = false; + for (var i = 0; i < nfds; i++) { + var s = FS.getStream(HEAP32[(fds + POLLFD_SIZE * i + POLLFD_FD)/4]); + if (s && s.stream_ops.pollAsync) { + async = true; + break; + } + } + if (!async) { + return null; + } return (async function() { try { var nonzero = 0; diff --git a/Python/emscripten_trampoline.c b/Python/emscripten_trampoline.c index 75cfde6b76f2174..5ece2e702c01ea1 100644 --- a/Python/emscripten_trampoline.c +++ b/Python/emscripten_trampoline.c @@ -47,6 +47,10 @@ typedef PyObject* (*TrampolineFunc)(int* success, PyObject* args, PyObject* kw); +// The JS glue only binds _PyRuntime if it is exported, which an embedder +// linking libpython does not do. +EMSCRIPTEN_KEEPALIVE _PyRuntimeState *const _PyEM_runtime = &_PyRuntime; + /** * Backwards compatible trampoline works with all JS runtimes */ @@ -90,7 +94,7 @@ function getPyEMTrampolinePtr() { addOnPreRun(function setEmscriptenTrampoline() { const ptr = getPyEMTrampolinePtr(); const offset = HEAP32[__PyEM_EMSCRIPTEN_TRAMPOLINE_OFFSET / 4]; - HEAP32[(__PyRuntime + offset) / 4] = ptr; + HEAP32[(HEAPU32[__PyEM_runtime / 4] + offset) / 4] = ptr; }); ); diff --git a/configure b/configure index b23d5e85d2a8ae0..80e1d29d433c286 100755 --- a/configure +++ b/configure @@ -9984,6 +9984,14 @@ then : as_fn_append LINKFORSHARED " -sMAIN_MODULE" +printf "%s\n" "#define Py_EMSCRIPTEN_DYNAMIC_LINKING 1" >>confdefs.h + + +else case e in #( + e) + as_fn_append LINKFORSHARED " -sALLOW_TABLE_GROWTH" + ;; +esac fi if test "x$enable_wasm_pthreads" = xyes @@ -20212,8 +20220,8 @@ PLATFORM_MAIN_OBJS= case $ac_sys_system in #( Emscripten) : - as_fn_append PLATFORM_MAIN_OBJS ' Programs/emscripten_beforemain.o' as_fn_append PLATFORM_OBJS ' Python/emscripten_signal.o Python/emscripten_trampoline.o Python/emscripten_trampoline_wasm.o' + as_fn_append PLATFORM_MAIN_OBJS ' Programs/emscripten_beforemain.o' if test "x$enable_emscripten_syscalls" = xyes then : diff --git a/configure.ac b/configure.ac index 3a3c33518073891..07b410c50db9a1d 100644 --- a/configure.ac +++ b/configure.ac @@ -2446,6 +2446,11 @@ AS_CASE([$ac_sys_system], AS_VAR_IF([enable_wasm_dynamic_linking], [yes], [ AS_VAR_APPEND([LINKFORSHARED], [" -sMAIN_MODULE"]) + AC_DEFINE([Py_EMSCRIPTEN_DYNAMIC_LINKING], [1], + [Define if the Emscripten build is linked with -sMAIN_MODULE.]) + ], [ + dnl Implied by MAIN_MODULE; the call trampoline's addFunction() needs it. + AS_VAR_APPEND([LINKFORSHARED], [" -sALLOW_TABLE_GROWTH"]) ]) AS_VAR_IF([enable_wasm_pthreads], [yes], [ @@ -5406,8 +5411,8 @@ PLATFORM_MAIN_OBJS= AS_CASE([$ac_sys_system], [Emscripten], [ - AS_VAR_APPEND([PLATFORM_MAIN_OBJS], [' Programs/emscripten_beforemain.o']) AS_VAR_APPEND([PLATFORM_OBJS], [' Python/emscripten_signal.o Python/emscripten_trampoline.o Python/emscripten_trampoline_wasm.o']) + AS_VAR_APPEND([PLATFORM_MAIN_OBJS], [' Programs/emscripten_beforemain.o']) AS_VAR_IF([enable_emscripten_syscalls], [yes], [ AS_VAR_APPEND([PLATFORM_OBJS], [' Python/emscripten_syscalls.o']) ]) diff --git a/pyconfig.h.in b/pyconfig.h.in index 64b5c52790b4581..13cfed9acc8b825 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1898,6 +1898,9 @@ /* Define if you want to build an interpreter with many run-time checks. */ #undef Py_DEBUG +/* Define if the Emscripten build is linked with -sMAIN_MODULE. */ +#undef Py_EMSCRIPTEN_DYNAMIC_LINKING + /* Defined if Python is built as a shared library. */ #undef Py_ENABLE_SHARED From 2e46f9a26f077308e5adc04cd3dd5f1ad8c4efd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Tue, 1 Sep 2026 23:14:40 +0200 Subject: [PATCH 4/7] gh-156780: Emscripten: add an embedding smoke test Nothing covers libpython linked into a program whose main() is not Python's: every wasm test builds through Platforms/emscripten, which always passes --enable-wasm-dynamic-linking. web_embed_test links libpython without -sMAIN_MODULE and without the interpreter's -sEXPORTED_FUNCTIONS, then imports from the stdlib zip and calls through the trampoline. It covers the EM_JS_DEPS and _PyRuntime regressions, not the suspending syscall paths, which it does not reach. Co-Authored-By: Claude Opus 5 --- .github/workflows/reusable-emscripten.yml | 2 + Makefile.pre.in | 23 +++++++++- ...-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst | 4 ++ Platforms/emscripten/web_embed_test/main.c | 42 +++++++++++++++++++ .../emscripten/web_embed_test/run_test.sh | 20 +++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst create mode 100644 Platforms/emscripten/web_embed_test/main.c create mode 100755 Platforms/emscripten/web_embed_test/run_test.sh diff --git a/.github/workflows/reusable-emscripten.yml b/.github/workflows/reusable-emscripten.yml index c8832342d9892da..74b25ad1784ac94 100644 --- a/.github/workflows/reusable-emscripten.yml +++ b/.github/workflows/reusable-emscripten.yml @@ -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 diff --git a/Makefile.pre.in b/Makefile.pre.in index 358af0badeb19f1..0e2ec8372989259 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -791,7 +791,7 @@ build_wasm: check-clean-src $(BUILDPYTHON) platform sharedmods \ python-config checksharedmods $(BUILD_DETAILS) .PHONY: build_emscripten -build_emscripten: build_wasm web_example web_example_pyrepl_jspi +build_emscripten: build_wasm web_example web_example_pyrepl_jspi web_embed_test # Check that the source is clean when building out of source. .PHONY: check-clean-src @@ -1118,6 +1118,25 @@ 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 + +web_embed_test/stdlib.zip: $(ZIP_STDLIB) + @mkdir -p web_embed_test + @cp $< $@ + +# Deliberately linked without $(LINKFORSHARED): an embedder gets neither +# -sMAIN_MODULE nor the interpreter's -sEXPORTED_FUNCTIONS. +web_embed_test/main.js: $(WEB_EMBED_TEST_DIR)/main.c $(LINK_PYTHON_DEPS) web_embed_test/stdlib.zip + @mkdir -p web_embed_test + $(LINKCC) $(PY_STDMODULE_CFLAGS) $(PY_CORE_EXE_LDFLAGS) -o $@ $(WEB_EMBED_TEST_DIR)/main.c \ + $(LINK_PYTHON_OBJS) $(LIBS) $(MODLIBS) $(SYSLIBS) \ + -sALLOW_MEMORY_GROWTH -sINITIAL_MEMORY=64MB -sSTACK_SIZE=5MB \ + -sALLOW_TABLE_GROWTH -sFORCE_FILESYSTEM -sEXIT_RUNTIME \ + --preload-file web_embed_test/stdlib.zip@/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) @@ -3305,7 +3324,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 diff --git a/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst b/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst new file mode 100644 index 000000000000000..f21af0d6c2f5ddd --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst @@ -0,0 +1,4 @@ +Emscripten: ``libpython`` can now be linked into a program statically. The +setup that wraps ``main()`` in ``WebAssembly.promising()`` moves to the +interpreter, so an embedder whose ``main()`` is not Python's no longer gets +it, and the call trampoline no longer needs the interpreter's link flags. diff --git a/Platforms/emscripten/web_embed_test/main.c b/Platforms/emscripten/web_embed_test/main.c new file mode 100644 index 000000000000000..c1812b10173a87e --- /dev/null +++ b/Platforms/emscripten/web_embed_test/main.c @@ -0,0 +1,42 @@ +/* Smoke test for libpython linked into a program whose main() is not Python's. + * + * Linked without -sMAIN_MODULE and without the interpreter's + * -sEXPORTED_FUNCTIONS, so it fails if libpython depends on either. + */ + +#include +#include + +// Imports come from the preloaded zip, len() goes through the call trampoline. +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; + PyWideStringList_Append(&config.module_search_paths, L"/lib/stdlib.zip"); + PyConfig_SetBytesString(&config, &config.executable, "/embed"); + + status = Py_InitializeFromConfig(&config); + PyConfig_Clear(&config); + if (PyStatus_Exception(status)) { + puts("web_embed_test: Py_InitializeFromConfig failed"); + return 1; + } + + if (PyRun_SimpleString(SCRIPT) != 0) { + puts("web_embed_test: script failed"); + return 1; + } + + Py_Finalize(); + puts("web_embed_test: ok"); + return 0; +} diff --git a/Platforms/emscripten/web_embed_test/run_test.sh b/Platforms/emscripten/web_embed_test/run_test.sh new file mode 100755 index 000000000000000..76c18006c597d30 --- /dev/null +++ b/Platforms/emscripten/web_embed_test/run_test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Run the smoke test built by "make web_embed_test". +set -euo pipefail +cd "$(dirname "$0")/../../.." + +CROSS_BUILD_DIR=${CROSS_BUILD_DIR:-cross-build} +# Run from the build directory: Emscripten resolves main.data relative to cwd. +cd "$CROSS_BUILD_DIR/wasm32-emscripten/build/python/web_embed_test" + +NODE=${NODE:-node} +# Node 24 needs JSPI enabled explicitly; it is on by default afterwards. +if [ "$("$NODE" -e 'process.stdout.write(process.version.slice(1).split(".")[0])')" = "24" ]; then + NODE_FLAGS=--experimental-wasm-jspi +else + NODE_FLAGS= +fi + +out=$("$NODE" $NODE_FLAGS main.js 2>&1) || true +echo "$out" +grep -q "web_embed_test: ok" <<<"$out" From 55cdf8901c8dc0e8d8d3cbb007fc8a63febe027d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Wed, 2 Sep 2026 09:50:07 +0200 Subject: [PATCH 5/7] gh-156780: Emscripten: drop the duplicate sysmodule EM_JS_DEPS main already declares $stringToNewUTF8 for _Py_emscripten_runtime, so declaring it again is a redefinition of __em_lib_deps__Py_emscripten_runtime. Co-Authored-By: Claude Opus 5 --- Python/sysmodule.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 3218e4947598911..5051dfc3ec0f861 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3905,8 +3905,6 @@ EM_JS(char *, _Py_emscripten_runtime, (void), { #endif }); -EM_JS_DEPS(_Py_emscripten_runtime, "$stringToNewUTF8"); - static PyObject * make_emscripten_info(void) { From 8a2da5b10654385134842b24e388092fab4c67fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Wed, 2 Sep 2026 09:50:07 +0200 Subject: [PATCH 6/7] gh-156780: Emscripten: gate the suspending syscalls on the entry-point wrapper _PyEmscripten_BeforeMain_js() now records that it wrapped main(), and the fd_read() and poll() paths suspend only when it did. Suspending needs a promising stack, which an embedder whose main() is not Python's never has. Co-Authored-By: Claude Opus 5 --- Programs/emscripten_beforemain.c | 3 +++ Python/emscripten_syscalls.c | 17 ++--------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/Programs/emscripten_beforemain.c b/Programs/emscripten_beforemain.c index 2e3792745d8180e..6911dda39d4a855 100644 --- a/Programs/emscripten_beforemain.c +++ b/Programs/emscripten_beforemain.c @@ -84,6 +84,9 @@ EM_JS_MACROS(void, _PyEmscripten_BeforeMain_js, (void), { Module.onExit = (code) => process.exit(code); } _Py_EM_WRAP_MAIN + // main() now runs on a promising stack, so libpython may suspend in a + // syscall. + Module.Py_EmscriptenStackSwitching = true; } // * wrap the entry point with WebAssembly.promising, // * call exit_with_live_runtime() to prevent emscripten from shutting down diff --git a/Python/emscripten_syscalls.c b/Python/emscripten_syscalls.c index d284086a968374d..7b431ee84f4f93d 100644 --- a/Python/emscripten_syscalls.c +++ b/Python/emscripten_syscalls.c @@ -74,7 +74,7 @@ EM_JS_MACROS(__externref_t, __maybe_fd_read_async, ( size_t iovcnt, __wasi_size_t *nread ), { - if (!WebAssembly.promising) { + if (!Module.Py_EmscriptenStackSwitching) { return null; } var stream; @@ -158,20 +158,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) { - return null; - } - // Suspending at all requires a promising stack, so do not if there is - // nothing to await. - var async = false; - for (var i = 0; i < nfds; i++) { - var s = FS.getStream(HEAP32[(fds + POLLFD_SIZE * i + POLLFD_FD)/4]); - if (s && s.stream_ops.pollAsync) { - async = true; - break; - } - } - if (!async) { + if (!Module.Py_EmscriptenStackSwitching) { return null; } return (async function() { From fa3261b7702edeaff184681e4ec3abf203bb7acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Wed, 2 Sep 2026 09:50:07 +0200 Subject: [PATCH 7/7] gh-156780: Simplify the NEWS entry Co-Authored-By: Claude Opus 5 --- .../Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst b/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst index f21af0d6c2f5ddd..57c4202f36c44bf 100644 --- a/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst +++ b/Misc/NEWS.d/next/Build/2026-09-01-23-32-22.gh-issue-156780.Qw3nRt.rst @@ -1,4 +1 @@ -Emscripten: ``libpython`` can now be linked into a program statically. The -setup that wraps ``main()`` in ``WebAssembly.promising()`` moves to the -interpreter, so an embedder whose ``main()`` is not Python's no longer gets -it, and the call trampoline no longer needs the interpreter's link flags. +Emscripten: ``libpython.a`` can now be linked into a program statically.