Fatal error in the garbage collector when a task is suspended (mark_stacks walks reused argument stack)
Bug
A suspended task's entry frames sit on argument stack that another task reuses, and CPython's incremental collector walks the frames of every thread state. It follows those links out of the heap and Pyodide dies with RuntimeError: memory access out of bounds.
mark_stacks() (Python/gc.c) follows previous from every thread state's current_frame. Since #6421 each in-flight promising task owns a thread state for its lifetime, so suspended tasks get walked on every increment. Their entry frames are _PyEval_EvalFrameDefault's _PyEntryFrame entry local, on the argument stack, which evictStackUpTo() in stack_state.mjs evicts to a copy so a resuming task can write over those addresses.
Only generation 1 is affected, the one automatic collection runs. gc_collect_young() and gc_collect_full() don't call mark_stacks().
To Reproduce
import { loadPyodide } from 'pyodide';
const pyodide = await loadPyodide();
pyodide.globals.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
pyodide.runPython(`
import gc
from pyodide.ffi import run_sync
gc.disable()
# __getattr__ is called from C, so each level puts an entry frame on the argument stack
def nested(depth, then):
if depth == 0:
return then()
return getattr(type('G', (), {'__getattr__': lambda self, name: nested(depth - 1, then)})(), 'x')
def first():
run_sync(sleep(50))
# Resumes above second's stack, grows down over it, then collects
nested(2, lambda: gc.collect(1))
def second():
nested(2, lambda: run_sync(sleep(200)))
`);
const first = pyodide.globals.get('first').callPromising();
await new Promise((resolve) => setTimeout(resolve, 10));
const second = pyodide.globals.get('second').callPromising();
await first;
await second;
console.log('no crash');RuntimeError: memory access out of bounds
at wasm-function[4219] <- mark_stacks
at wasm-function[4215] <- _PyGC_Collectgc.disable() only makes the collection explicit. The same crash arrives through _Py_HandlePending with automatic collection left on.
Expected behavior
Prints no crash, as it does with gc.collect(0) or gc.collect(2), and when the suspended task finishes before the other resumes.
Environment
- Pyodide Version: 314.0.6, also on
mainatb40e93b8 - Browser version: n/a, Node 24.21.0 with JSPI
- Any other relevant information: CPython 3.14.2, Linux x86-64
Additional context
Which combinations crash depends on stack layout, so the shape above is deliberate: across map, max(key=), and __getattr__ re-entry at depths 1–6, 11 of 32 crashed and the rest hung or silently marked the wrong frames. Walking the chains from JS shows it directly. A suspended task's entry frames come back with owner bytes of 87 or 53, or its chain splices into the running task's frames.
Source: pyodide/pyodide