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
- Use-after-free in
asyncioTask deallocation via re-registering task incall_exception_handler#142556 (fixed by gh-142556: fix crash when a task gets re-registered during finalization #142565) — same function. Its fix established the current ordering and leftPyObject_GC_UnTracklast, which is precisely the window exploited here. Distinct bug (single-threaded re-registration UAF). test_asyncio.test_free_threading.TestCFreeThreading.test_all_tasks_racecrashes intermittently in free-threaded builds #128656 — a different assertion in the same area (PyObject_CallFinalizerFromDealloccalled with a non-zero refcount).
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)]