Skip to content

Fatal SuspendError when time.sleep() (or run_sync()) is called beneath an Emscripten dynamic-linking stub #6551

Open

Description

@agriyakhetarpal

🐛 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:

  1. The extension calls one of its own inline functions or template instantiations (weak symbols with default visibility)
  2. 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.so

repro_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 pyarrow one
  • 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

  1. hoodmane commented on Nov 12, 2026

    @hoodmane
    Member

    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 SuspendError around the stack switch, but that is also unfortunately kind of hard. I really wish that they had accepted my suggestion of a can_stack_swich() function which would have been trivial for us to use.

  2. ryanking13 commented on Nov 13, 2026

    @ryanking13
    Member

    Interesting. This behavior is pretty surprising indeed and we need to fix at least for time.sleep().

  3. hoodmane commented on Nov 13, 2026

    @hoodmane
    Member

    The extension calls some function that lives in another shared library of the same package

    This only happens if the other shared library is loaded after the current one. If it's loaded first, a stub is not generated.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions