Crash report
What happened?
On the free-threaded build, TaskObj_dealloc leaves an _asyncio.Task at refcount 0 while it is still GC-tracked, across a _PyEval_StopTheWorld(). A gc.collect() on another thread walks the tracked set during that window and finds the refcount-0 object: the interpreter aborts on a debug build, and hangs or segfaults on a release build.
Root cause
Modules/_asynciomodule.c, TaskObj_dealloc:
static void
TaskObj_dealloc(PyObject *self)
{
if (PyObject_CallFinalizerFromDealloc(self) < 0) {
return; // resurrected
}
// unregister the task after finalization so that
// if the task gets resurrected, it remains registered
unregister_task((TaskObj *)self); // <-- may _PyEval_StopTheWorld()
PyTypeObject *tp = Py_TYPE(self);
PyObject_GC_UnTrack(self); // <-- untrack happens only here
...
}
The refcount is already 0 on entry, but the Task stays on the GC tracked list across both the finalizer and unregister_task(). For a Task deallocated on a thread other than its creator, unregister_task() takes a stop-the-world branch:
static void
unregister_task(TaskObj *task)
{
#ifdef Py_GIL_DISABLED
if (task->task_tid == _Py_ThreadId()) {
unregister_task_safe(task);
} else {
PyThreadState *tstate = _PyThreadState_GET();
_PyEval_StopTheWorld(tstate->interp); // <-- the window
unregister_task_safe(task);
_PyEval_StartTheWorld(tstate->interp);
}
#else
unregister_task_safe(task);
#endif
}
FutureObj_dealloc has the same finalizer-before-untrack shape but untracks immediately afterwards, and does not reproduce — the stop-the-world inside unregister_task() is what widens the window enough to be hit reliably.
Reproducer
import sys, gc, threading, _asyncio
assert not sys._is_gil_enabled()
N, ITERS = 4, 6000
barrier = threading.Barrier(N)
def worker():
barrier.wait()
for i in range(ITERS):
try:
_asyncio.Task([1, 2, 3]) # fails the coro check before task_tid is set
except Exception:
pass
if i % 16 == 0:
gc.collect()
ts = [threading.Thread(target=worker) for _ in range(N)]
for t in ts: t.start()
for t in ts: t.join()
Task([1, 2, 3]) fails __init__'s coroutine check before task_tid is assigned, so task_tid keeps its zero value and every transient Task takes the stop-the-world branch on teardown. A well-formed Task deallocated on a thread other than its creator takes the identical branch.
Observed
Free-threaded CPython 3.16.0a0 (main, bcf98ddbc40):
build
result
| --disable-gil debug |
validate_refcounts abort, exit 134 (10/10) |
| --disable-gil release |
interpreter wedges (5/5); segfault also observed |
Debug:
Python/gc_free_threading.c:1083: validate_refcounts: Assertion
"_Py_REFCNT(((PyObject*)((op)))) > 0" failed:
tracked objects must have a reference count > 0
object type name: _asyncio.Task
object refcount : 0
Fatal Python error: _PyObject_AssertFailed
Release (no Py_DEBUG, no sanitizer) — gc.collect() spins forever while every other thread parks behind it:
t1 (spinning, 100% CPU):
_mi_page_free_collect Objects/mimalloc/page.c:245
<- mi_heap_visit_blocks(visitor=update_refs)
<- gc_visit_heaps_lock_held Python/gc_free_threading.c:395
<- deduce_unreachable_heap Python/gc_free_threading.c:1447
<- gc_collect_main Python/gc_free_threading.c:2257
t0 / t2 / t3 (parked):
_PyMutex_Lock(&_PyRuntime+169592) <- _PyParkingLot_Park <- _PySemaphore_Wait
Still wedged after 150 s for work that an identical-shape control (a plain class whose __init__ raises — same threads, same gc.collect() cadence, no stop-the-world in the dealloc) completes in 0.11 s. It reproduces with as few as 10 transient Tasks, and with only 2 threads.
Suggested fix
Move PyObject_GC_UnTrack(self) to the top of TaskObj_dealloc, before the finalizer and unregister_task() — the standard dealloc discipline (subtype_dealloc untracks first). Resurrection re-tracks the object, so this remains compatible with gh-142556, whose fix introduced the current finalize → unregister → untrack ordering. An object at refcount 0 should not stay on the GC tracked list across a _PyEval_StopTheWorld().
Related
Full report, reproducer and complete release-build backtraces: https://gist.github.com/devdanzin/df90699fe37599d5502ce39f718f6657
AI Disclaimer: this report was drafted by Claude Code, which also created and ran the reproducer; the maintainer reviewed and edited it.
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:37:00) [Clang 21.1.8 (6ubuntu1)]
Crash report
What happened?
On the free-threaded build, TaskObj_dealloc leaves an _asyncio.Task at refcount 0 while it is still GC-tracked, across a _PyEval_StopTheWorld(). A gc.collect() on another thread walks the tracked set during that window and finds the refcount-0 object: the interpreter aborts on a debug build, and hangs or segfaults on a release build.
Root cause
Modules/_asynciomodule.c, TaskObj_dealloc:
The refcount is already 0 on entry, but the Task stays on the GC tracked list across both the finalizer and unregister_task(). For a Task deallocated on a thread other than its creator, unregister_task() takes a stop-the-world branch:
FutureObj_dealloc has the same finalizer-before-untrack shape but untracks immediately afterwards, and does not reproduce — the stop-the-world inside unregister_task() is what widens the window enough to be hit reliably.
Reproducer
Task([1, 2, 3]) fails __init__'s coroutine check before task_tid is assigned, so task_tid keeps its zero value and every transient Task takes the stop-the-world branch on teardown. A well-formed Task deallocated on a thread other than its creator takes the identical branch.
Observed
Free-threaded CPython 3.16.0a0 (main, bcf98ddbc40):
Debug:
Release (no Py_DEBUG, no sanitizer) — gc.collect() spins forever while every other thread parks behind it:
Still wedged after 150 s for work that an identical-shape control (a plain class whose __init__ raises — same threads, same gc.collect() cadence, no stop-the-world in the dealloc) completes in 0.11 s. It reproduces with as few as 10 transient Tasks, and with only 2 threads.
Suggested fix
Move PyObject_GC_UnTrack(self) to the top of TaskObj_dealloc, before the finalizer and unregister_task() — the standard dealloc discipline (subtype_dealloc untracks first). Resurrection re-tracks the object, so this remains compatible with gh-142556, whose fix introduced the current finalize → unregister → untrack ordering. An object at refcount 0 should not stay on the GC tracked list across a _PyEval_StopTheWorld().
Related
Full report, reproducer and complete release-build backtraces: https://gist.github.com/devdanzin/df90699fe37599d5502ce39f718f6657
AI Disclaimer: this report was drafted by Claude Code, which also created and ran the reproducer; the maintainer reviewed and edited it.
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:37:00) [Clang 21.1.8 (6ubuntu1)]