Repository navigation
Description
🐛 Bug
time.sleep() (and anything else that uses run_sync()) crashes the interpreter with SuspendError: trying to suspend JS frames, when it is called from a Python callback invoked by an extension module, if the extension's call path crosses one of the JavaScript stubs generated by Emscripten's dynamic linker for function imports.
In this situation, because can_run_sync() returns True, time.sleep() takes the stack-switching path, and the failure is fatal rather than raising a Python exception.
If I understand correctly, there are two ways for such a stub to end up on the stack:
- The extension calls one of its own inline functions or template instantiations (weak symbols with default visibility)
- The extension calls some function that lives in another shared library of the same package
The second one means that user code can also hit this with packages. For example, I found that pyarrow.array() on a generator that sleeps is enough to do so.
To Reproduce
1. With pyarrow
// node repro_pyarrow.mjs /path/to/pyodide-314.0.7
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { loadPyodide } = require(`${process.argv[2]}/pyodide.js`);
const pyodide = await loadPyodide();
await pyodide.loadPackage("pyarrow");
await pyodide.runPythonAsync(`
import time
import pyarrow as pa
def gen():
time.sleep(0.01)
yield 1
print(pa.array(gen(), type=pa.int64()))
`);reports
Pyodide has suffered a fatal error. Please report this to the Pyodide maintainers.
The cause of the fatal error was:
SuspendError: trying to suspend JS frames
at wasm://wasm/0249d42a:wasm-function[529]:0x71987
...
The stub on the stack here is for arrow::py::ConvertPySequence, which pyarrow/lib.cpython-314-wasm32-emscripten.so imports from pyarrow/libarrow_python.so.
2. Non-arrow reproducer
Similarly – note; this is mostly crafted with AI (Claude Opus 5.5) and reviewed by me, so it's a bit verbose:
weakstub.cc:
#include <Python.h>
// inline so it has weak linkage and default visibility
__attribute__((noinline)) inline PyObject* weak_call(PyObject* cb) {
return PyObject_CallNoArgs(cb);
}
// the same function without `inline`
__attribute__((noinline)) PyObject* strong_call(PyObject* cb) {
return PyObject_CallNoArgs(cb);
}
static PyObject* call_weak(PyObject*, PyObject* cb) { return weak_call(cb); }
static PyObject* call_strong(PyObject*, PyObject* cb) { return strong_call(cb); }
static PyMethodDef methods[] = {
{"call_weak", call_weak, METH_O, nullptr},
{"call_strong", call_strong, METH_O, nullptr},
{nullptr, nullptr, 0, nullptr}};
static PyModuleDef moduledef = {PyModuleDef_HEAD_INIT, "weakstub", nullptr, -1, methods};
PyMODINIT_FUNC PyInit_weakstub(void) { return PyModule_Create(&moduledef); }To build:
FLAGS="-O2 -fPIC -fwasm-exceptions -sSUPPORT_LONGJMP=wasm"
em++ $FLAGS -I"$(pyodide config get python_include_dir)" -c weakstub.cc -o weakstub.o
em++ $FLAGS -sSIDE_MODULE=1 -sWASM_BIGINT=1 weakstub.o -o weakstub.cpython-314-wasm32-emscripten.sorepro_weakstub.mjs:
// node repro_weakstub.mjs /path/to/pyodide-314.0.7 strong|weak
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
const require = createRequire(import.meta.url);
const [dist, which] = process.argv.slice(2);
const { loadPyodide } = require(`${dist}/pyodide.js`);
const pyodide = await loadPyodide();
const so = "weakstub.cpython-314-wasm32-emscripten.so";
pyodide.FS.writeFile(`/lib/python3.14/site-packages/${so}`, readFileSync(so));
await pyodide.runPythonAsync(`
import time
import weakstub
weakstub.call_${which}(lambda: time.sleep(0.01))
print("call_${which}: ok")
`);which reports:
$ node repro_weakstub.mjs ./pyodide strong
call_strong: ok
$ node repro_weakstub.mjs ./pyodide weak
Pyodide has suffered a fatal error. Please report this to the Pyodide maintainers.
The cause of the fatal error was:
SuspendError: trying to suspend JS frames
The only difference between the two functions is the inline keyword. The built module does both, which is that it exports _Z9weak_callP7_object and then imports it from env:
(import "env" "_Z9weak_callP7_object" (func $fimport$2 (param i32) (result i32)))
(export "_Z9weak_callP7_object" (func $2))
Printing new Error().stack from the callback, instead of sleeping, shows the JavaScript frame in the middle of the WebAssembly frames. can_run_sync() reports True at the same point:
can_run_sync(): True
...
at wasm://wasm/0249d42a:wasm-function[1203]:0xbdf54
at wasm://wasm/7bcce0e2:wasm-function[5]:0x1ad
at stubs.<computed> (pyodide.asm.mjs:1:17052)
at wasm://wasm/7bcce0e2:wasm-function[7]:0x1c1
at wasm://wasm/6a0cf496:wasm-function[0]:0xa7
...
Expected behavior
time.sleep() should work here, as it did before 0.28. If stack switching isn't possible from this call stack, I guess it should fall back to the blocking sleep?
Also, a failed run_sync() should raise a Python exception and leave the interpreter usable.
Environment
- Pyodide Version: 314.0.7
- Browser version: Node.js 24.22.1 on macOS arm64 for both reproducers, and Chrome for Testing 157.0.8103.78 (module web worker) for the
pyarrowone - Any other relevant information: the same crash also shows up on Node.js 24.21.0 on Linux the CI for PyArrow
Additional context
xref apache/arrow#47330, while debugging test_read_csv_gil_deadlock, where the read() sleeps
Claude suggested that I try linking with -Wl,-Bsymbolic, which I don't fully understand, but that had no effect on the weak self-imports.
Activity
hoodmane commentedon Nov 12, 2026
Yeah this is a problem I've been ignoring for a long time. I attempted to fix it twice upstream:
emscripten-core/emscripten#23619
emscripten-core/emscripten#24161
It's a bit complicated though because each stub shape needs a dynamically generated wasm stub but there is no easy way to introspect the module for the imported stub shapes. The wasm type reflection proposal added a mechanism for that but it is withdrawn. The remaining option is we need to parse it manually out of the wasm bytes. In Emscripten itself, it's allowed that the instance is instantiated from a WasmModule and not from the bytes, which would cause trouble for this. I'm not sure how often that code path is used in practice though.
I think the simplest improvement would be to catch
SuspendErroraround the stack switch, but that is also unfortunately kind of hard. I really wish that they had accepted my suggestion of acan_stack_swich()function which would have been trivial for us to use.ryanking13 commentedon Nov 13, 2026
Interesting. This behavior is pretty surprising indeed and we need to fix at least for time.sleep().
hoodmane commentedon Nov 13, 2026
This only happens if the other shared library is loaded after the current one. If it's loaded first, a stub is not generated.