#6464·pyodide

Fatal error in the garbage collector when a task is suspended (mark_stacks walks reused argument stack)

Author: ThailerLCreated Sep 16, 2026Updated Sep 16, 2026
Labelsbug

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

javascript
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_Collect

gc.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 main at b40e93b8
  • 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.