From 8e75accaa1757b117eedfbee8f688fc2258b4268 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Lespiau Date: Thu, 15 Oct 2026 16:17:54 +0000 Subject: [PATCH 1/6] gh-159449: Fix a free-threaded asyncio race that corrupts the task lists When a thread exits with pending asyncio Tasks, PyThreadState_Clear() moves them onto the shared interp->asyncio_tasks_head list, but each Task still records the _Py_ThreadId() of the thread that created it. unregister_task() skips the stop-the-world pause whenever that id matches the current thread. That also happens for a later thread that reuses the exited thread's pthread_t, and for destructors that run later in PyThreadState_Clear(). The resulting unlocked llist_remove() calls on the shared list race with each other and can leave an already-removed node (next == NULL) linked into it. asyncio.all_tasks(), which asyncio.run() calls on shutdown, then crashes with SIGSEGV in _asyncio_all_tasks(). Record the unique id (PyThreadState.id) of the creating thread state instead, and only take the lock-free path while that thread state is not being cleared. Also drop the assertion in add_tasks_llist() that every linked Task is pending. A Task's state is set before unregister_task() unlinks it, and that removal can be waiting for the stop-the-world pause during which the list is walked. add_one_task() already filters out Tasks that are done. --- Lib/test/test_asyncio/test_free_threading.py | 66 +++++++++++++++++++ ...-10-15-10-15-27.gh-issue-159449.xsdzFf.rst | 1 + Modules/_asynciomodule.c | 33 +++++++--- 3 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-10-15-27.gh-issue-159449.xsdzFf.rst diff --git a/Lib/test/test_asyncio/test_free_threading.py b/Lib/test/test_asyncio/test_free_threading.py index c60b61c7d41115f..85b74a8b1e9b528 100644 --- a/Lib/test/test_asyncio/test_free_threading.py +++ b/Lib/test/test_asyncio/test_free_threading.py @@ -116,6 +116,72 @@ def runner(): support.gc_collect() self.assertIsNone(wr()) + def test_lingering_task_different_thread_finalized(self) -> None: + # When a thread exits with pending tasks, PyThreadState_Clear() moves + # them onto interp->asyncio_tasks_head. Unregistering those tasks from + # later threads (which may reuse the exited thread's OS thread ID) or + # during PyThreadState_Clear() while all_tasks() walks the task lists + # must not corrupt the shared list. + tls = threading.local() + + async def never_finishes(): + await asyncio.Future() + + def make_pending_tasks(loop, count): + tasks = [] + for _ in range(count): + task = loop.create_task(never_finishes()) + task._log_destroy_pending = False + tasks.append(task) + return tasks + + def worker(batches): + loop = asyncio.new_event_loop() + loop.set_task_factory(self.factory) + # Tasks held in thread-local storage are cleared by PyThreadState_Clear + # after llist_concat moves tstate->asyncio_tasks_head to interp. + tls_tasks = make_pending_tasks(loop, 8) + for task in tls_tasks: + task.get_coro().close() + tls.tasks = tls_tasks + # Tasks returned in batches stay alive until a subsequent worker + # thread (often reusing this thread's OS thread ID) unregisters them. + batches.append((loop, make_pending_tasks(loop, 8))) + + stop = threading.Event() + + def inspector(): + loop = asyncio.new_event_loop() + try: + while not stop.is_set(): + asyncio.all_tasks(loop) + finally: + loop.close() + + inspectors = [Thread(target=inspector) for _ in range(2)] + with threading_helper.start_threads(inspectors): + try: + for _ in range(15): + batches = [] + workers = [Thread(target=worker, args=(batches,)) for _ in range(4)] + with threading_helper.start_threads(workers): + pass + + def cleanup(batch): + loop, tasks = batch + for task in tasks: + task.cancel() + task.get_coro().close() + tasks.clear() + loop.close() + + cleaners = [Thread(target=cleanup, args=(b,)) for b in batches] + with threading_helper.start_threads(cleaners): + pass + support.gc_collect() + finally: + stop.set() + def test_run_coroutine_threadsafe(self) -> None: results = [] diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-10-15-27.gh-issue-159449.xsdzFf.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-10-15-27.gh-issue-159449.xsdzFf.rst new file mode 100644 index 000000000000000..f592e76fdbff707 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-10-15-27.gh-issue-159449.xsdzFf.rst @@ -0,0 +1 @@ +Fix a free-threaded asyncio race leading to segfaults. diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index 18e731336d6da1d..155e343988a2236 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -68,8 +68,9 @@ typedef struct TaskObj { PyObject *task_context; struct llist_node task_node; #ifdef Py_GIL_DISABLED - // thread id of the thread where this task was created - uintptr_t task_tid; + // unique id (PyThreadState.id) of the thread state that created this + // task, see unregister_task() + uint64_t task_tstate_id; #endif } TaskObj; @@ -2151,6 +2152,9 @@ register_task(_PyThreadStateImpl *ts, TaskObj *task) assert(task->task_node.prev != NULL); return; } +#ifdef Py_GIL_DISABLED + task->task_tstate_id = ts->base.id; +#endif struct llist_node *head = &ts->asyncio_tasks_head; llist_insert_tail(head, &task->task_node); } @@ -2170,15 +2174,19 @@ static void unregister_task(TaskObj *task) { #ifdef Py_GIL_DISABLED - // check if we are in the same thread - // if so, we can avoid locking - if (task->task_tid == _Py_ThreadId()) { + PyThreadState *tstate = _PyThreadState_GET(); + // The lock-free path is only safe while the task is still linked into the + // current thread's own list. That holds if this thread state created the + // task and has not started PyThreadState_Clear(), which moves lingering + // tasks onto the shared interp->asyncio_tasks_head list. Compare thread + // state ids rather than _Py_ThreadId(), which is reused by threads that + // start after the creating thread has exited. + if (task->task_tstate_id == tstate->id && !tstate->_status.finalizing) { unregister_task_safe(task); } else { - // we are in a different thread - // stop the world then check and remove the task - PyThreadState *tstate = _PyThreadState_GET(); + // the task may be linked into another thread's list or into the + // interpreter's list: stop the world then check and remove the task _PyEval_StopTheWorld(tstate->interp); unregister_task_safe(task); _PyEval_StartTheWorld(tstate->interp); @@ -2307,7 +2315,9 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop, Py_CLEAR(self->task_fut_waiter); #ifdef Py_GIL_DISABLED - self->task_tid = _Py_ThreadId(); + if (self->task_node.next == NULL) { + self->task_tstate_id = _PyThreadState_GET()->id; + } #endif self->task_must_cancel = 0; self->task_log_destroy_pending = 1; @@ -3948,7 +3958,10 @@ add_tasks_llist(struct llist_node *head, PyListObject *tasks) struct llist_node *node; llist_for_each_safe(node, head) { TaskObj *task = llist_data(node, TaskObj, task_node); - assert(task->task_state == STATE_PENDING); + // The task may already be done but not yet unlinked: its state is set + // before unregister_task() removes it, and that removal may be + // waiting for a stop-the-world pause. add_one_task() filters out + // tasks that are done. // The linked list holds borrowed references to task // as such it is possible that the task is concurrently // deallocated while added to this list. From e60bb199fda7f36e8116906468b37749f1ca54cb Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:46:41 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst new file mode 100644 index 000000000000000..9c0410bce795c75 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst @@ -0,0 +1 @@ +Fix a free-threaded asyncio race that can lead to a segfault with asyncio. From acea81d7b3d7dcf458ae4b0985248c35e4657c22 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Lespiau Date: Thu, 15 Oct 2026 18:49:33 +0000 Subject: [PATCH 3/6] Remove extra blub-it file. --- .../2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst deleted file mode 100644 index 9c0410bce795c75..000000000000000 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-18-46-39.gh-issue-159449.NP2Zw2.rst +++ /dev/null @@ -1 +0,0 @@ -Fix a free-threaded asyncio race that can lead to a segfault with asyncio. From 58c83fcef31c7e47f5fbef4a2e47e57acba1d343 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Lespiau Date: Mon, 2 Nov 2026 14:50:49 +0100 Subject: [PATCH 4/6] Remove comments and rebase to head. --- Lib/test/test_asyncio/test_free_threading.py | 11 +-- Modules/_asynciomodule.c | 92 +++++--------------- 2 files changed, 23 insertions(+), 80 deletions(-) diff --git a/Lib/test/test_asyncio/test_free_threading.py b/Lib/test/test_asyncio/test_free_threading.py index 85b74a8b1e9b528..36a6c474b502b9a 100644 --- a/Lib/test/test_asyncio/test_free_threading.py +++ b/Lib/test/test_asyncio/test_free_threading.py @@ -117,11 +117,6 @@ def runner(): self.assertIsNone(wr()) def test_lingering_task_different_thread_finalized(self) -> None: - # When a thread exits with pending tasks, PyThreadState_Clear() moves - # them onto interp->asyncio_tasks_head. Unregistering those tasks from - # later threads (which may reuse the exited thread's OS thread ID) or - # during PyThreadState_Clear() while all_tasks() walks the task lists - # must not corrupt the shared list. tls = threading.local() async def never_finishes(): @@ -138,14 +133,12 @@ def make_pending_tasks(loop, count): def worker(batches): loop = asyncio.new_event_loop() loop.set_task_factory(self.factory) - # Tasks held in thread-local storage are cleared by PyThreadState_Clear - # after llist_concat moves tstate->asyncio_tasks_head to interp. + tls_tasks = make_pending_tasks(loop, 8) for task in tls_tasks: task.get_coro().close() tls.tasks = tls_tasks - # Tasks returned in batches stay alive until a subsequent worker - # thread (often reusing this thread's OS thread ID) unregisters them. + batches.append((loop, make_pending_tasks(loop, 8))) stop = threading.Event() diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index 5cad18b0100232c..c4e11cdfa45575d 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -71,6 +71,7 @@ typedef struct TaskObj { // unique id (PyThreadState.id) of the thread state that created this // task, see unregister_task() uint64_t task_tstate_id; + #endif } TaskObj; @@ -1796,9 +1797,10 @@ FutureIter_dealloc(PyObject *it) } static PySendResult -FutureIter_am_send_lock_held(FutureObj *fut, PyObject **result) +FutureIter_am_send_lock_held(futureiterobject *it, PyObject **result) { PyObject *res; + FutureObj *fut = it->future; _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(fut); *result = NULL; @@ -1829,24 +1831,11 @@ FutureIter_am_send(PyObject *op, PyObject **result) { futureiterobject *it = (futureiterobject*)op; - - FutureObj *fut; - Py_BEGIN_CRITICAL_SECTION(op); - fut = (FutureObj*)Py_XNewRef(it->future); - Py_END_CRITICAL_SECTION(); - - if (fut == NULL) { - PyErr_SetNone(PyExc_StopIteration); - *result = NULL; - return PYGEN_ERROR; - } - /* arg is unused, see the comment on FutureIter_send for clarification */ PySendResult res; - Py_BEGIN_CRITICAL_SECTION(fut); - res = FutureIter_am_send_lock_held(fut, result); + Py_BEGIN_CRITICAL_SECTION(it->future); + res = FutureIter_am_send_lock_held(it, result); Py_END_CRITICAL_SECTION(); - Py_DECREF(fut); return res; } @@ -1940,9 +1929,7 @@ FutureIter_throw(PyObject *op, PyObject *const *args, Py_ssize_t nargs) goto fail; } - Py_BEGIN_CRITICAL_SECTION(self); Py_CLEAR(self->future); - Py_END_CRITICAL_SECTION(); PyErr_Restore(type, val, tb); @@ -1955,16 +1942,6 @@ FutureIter_throw(PyObject *op, PyObject *const *args, Py_ssize_t nargs) return NULL; } -static PyObject * -FutureIter_close(PyObject *self, PyObject *arg) -{ - futureiterobject *it = (futureiterobject*)self; - Py_BEGIN_CRITICAL_SECTION(self); - Py_CLEAR(it->future); - Py_END_CRITICAL_SECTION(); - Py_RETURN_NONE; -} - static int FutureIter_clear(PyObject *op) { @@ -1973,6 +1950,13 @@ FutureIter_clear(PyObject *op) return 0; } +static PyObject * +FutureIter_close(PyObject *self, PyObject *arg) +{ + (void)FutureIter_clear(self); + Py_RETURN_NONE; +} + static int FutureIter_traverse(PyObject *op, visitproc visit, void *arg) { @@ -1989,22 +1973,6 @@ static PyMethodDef FutureIter_methods[] = { {NULL, NULL} /* Sentinel */ }; -static PyObject * -FutureIter_get_fi_future(PyObject *op, void *Py_UNUSED(closure)) -{ - futureiterobject *it = (futureiterobject*)op; - PyObject *fut; - Py_BEGIN_CRITICAL_SECTION(op); - fut = it->future ? Py_NewRef(it->future) : Py_None; - Py_END_CRITICAL_SECTION(); - return fut; -} - -static PyGetSetDef FutureIter_getsetlist[] = { - {"fi_future", FutureIter_get_fi_future, NULL, NULL}, - {NULL} /* Sentinel */ -}; - static PyType_Slot FutureIter_slots[] = { {Py_tp_dealloc, FutureIter_dealloc}, {Py_tp_getattro, PyObject_GenericGetAttr}, @@ -2013,7 +1981,6 @@ static PyType_Slot FutureIter_slots[] = { {Py_tp_iter, PyObject_SelfIter}, {Py_tp_iternext, FutureIter_iternext}, {Py_tp_methods, FutureIter_methods}, - {Py_tp_getset, FutureIter_getsetlist}, // async methods {Py_am_send, FutureIter_am_send}, @@ -2186,9 +2153,6 @@ register_task(_PyThreadStateImpl *ts, TaskObj *task) assert(task->task_node.prev != NULL); return; } -#ifdef Py_GIL_DISABLED - task->task_tstate_id = ts->base.id; -#endif struct llist_node *head = &ts->asyncio_tasks_head; llist_insert_tail(head, &task->task_node); } @@ -2208,19 +2172,15 @@ static void unregister_task(TaskObj *task) { #ifdef Py_GIL_DISABLED - PyThreadState *tstate = _PyThreadState_GET(); - // The lock-free path is only safe while the task is still linked into the - // current thread's own list. That holds if this thread state created the - // task and has not started PyThreadState_Clear(), which moves lingering - // tasks onto the shared interp->asyncio_tasks_head list. Compare thread - // state ids rather than _Py_ThreadId(), which is reused by threads that - // start after the creating thread has exited. - if (task->task_tstate_id == tstate->id && !tstate->_status.finalizing) { + // check if we are in the same thread + // if so, we can avoid locking + if (task->task_tid == _Py_ThreadId()) { unregister_task_safe(task); } else { - // the task may be linked into another thread's list or into the - // interpreter's list: stop the world then check and remove the task + // we are in a different thread + // stop the world then check and remove the task + PyThreadState *tstate = _PyThreadState_GET(); _PyEval_StopTheWorld(tstate->interp); unregister_task_safe(task); _PyEval_StartTheWorld(tstate->interp); @@ -2349,9 +2309,7 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop, Py_CLEAR(self->task_fut_waiter); #ifdef Py_GIL_DISABLED - if (self->task_node.next == NULL) { - self->task_tstate_id = _PyThreadState_GET()->id; - } + self->task_tid = _Py_ThreadId(); #endif self->task_must_cancel = 0; self->task_log_destroy_pending = 1; @@ -2813,12 +2771,7 @@ _asyncio_Task_get_name_impl(TaskObj *self) { if (self->task_name) { if (PyLong_CheckExact(self->task_name)) { - // Formatting can run arbitrary code (e.g. a GC finalizer that logs - // and re-enters get_name()), which may replace self->task_name. - // Hold a strong reference to the counter while formatting it. - PyObject *counter = Py_NewRef(self->task_name); - PyObject *name = PyUnicode_FromFormat("Task-%S", counter); - Py_DECREF(counter); + PyObject *name = PyUnicode_FromFormat("Task-%S", self->task_name); if (name == NULL) { return NULL; } @@ -3997,10 +3950,7 @@ add_tasks_llist(struct llist_node *head, PyListObject *tasks) struct llist_node *node; llist_for_each_safe(node, head) { TaskObj *task = llist_data(node, TaskObj, task_node); - // The task may already be done but not yet unlinked: its state is set - // before unregister_task() removes it, and that removal may be - // waiting for a stop-the-world pause. add_one_task() filters out - // tasks that are done. + assert(task->task_state == STATE_PENDING); // The linked list holds borrowed references to task // as such it is possible that the task is concurrently // deallocated while added to this list. From 0def8d4668fdef721cf61168be2607f5dde8603a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Lespiau Date: Mon, 2 Nov 2026 16:53:30 +0100 Subject: [PATCH 5/6] Restore main changes in _asynciomodule.c and remove comments --- Lib/test/test_asyncio/test_free_threading.py | 2 - Modules/_asynciomodule.c | 82 ++++++++++++++------ 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/Lib/test/test_asyncio/test_free_threading.py b/Lib/test/test_asyncio/test_free_threading.py index 36a6c474b502b9a..5276706a830c9d0 100644 --- a/Lib/test/test_asyncio/test_free_threading.py +++ b/Lib/test/test_asyncio/test_free_threading.py @@ -133,12 +133,10 @@ def make_pending_tasks(loop, count): def worker(batches): loop = asyncio.new_event_loop() loop.set_task_factory(self.factory) - tls_tasks = make_pending_tasks(loop, 8) for task in tls_tasks: task.get_coro().close() tls.tasks = tls_tasks - batches.append((loop, make_pending_tasks(loop, 8))) stop = threading.Event() diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index c4e11cdfa45575d..eee7d2d794d881c 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -68,10 +68,7 @@ typedef struct TaskObj { PyObject *task_context; struct llist_node task_node; #ifdef Py_GIL_DISABLED - // unique id (PyThreadState.id) of the thread state that created this - // task, see unregister_task() uint64_t task_tstate_id; - #endif } TaskObj; @@ -1797,10 +1794,9 @@ FutureIter_dealloc(PyObject *it) } static PySendResult -FutureIter_am_send_lock_held(futureiterobject *it, PyObject **result) +FutureIter_am_send_lock_held(FutureObj *fut, PyObject **result) { PyObject *res; - FutureObj *fut = it->future; _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(fut); *result = NULL; @@ -1831,11 +1827,24 @@ FutureIter_am_send(PyObject *op, PyObject **result) { futureiterobject *it = (futureiterobject*)op; + + FutureObj *fut; + Py_BEGIN_CRITICAL_SECTION(op); + fut = (FutureObj*)Py_XNewRef(it->future); + Py_END_CRITICAL_SECTION(); + + if (fut == NULL) { + PyErr_SetNone(PyExc_StopIteration); + *result = NULL; + return PYGEN_ERROR; + } + /* arg is unused, see the comment on FutureIter_send for clarification */ PySendResult res; - Py_BEGIN_CRITICAL_SECTION(it->future); - res = FutureIter_am_send_lock_held(it, result); + Py_BEGIN_CRITICAL_SECTION(fut); + res = FutureIter_am_send_lock_held(fut, result); Py_END_CRITICAL_SECTION(); + Py_DECREF(fut); return res; } @@ -1929,7 +1938,9 @@ FutureIter_throw(PyObject *op, PyObject *const *args, Py_ssize_t nargs) goto fail; } + Py_BEGIN_CRITICAL_SECTION(self); Py_CLEAR(self->future); + Py_END_CRITICAL_SECTION(); PyErr_Restore(type, val, tb); @@ -1942,6 +1953,16 @@ FutureIter_throw(PyObject *op, PyObject *const *args, Py_ssize_t nargs) return NULL; } +static PyObject * +FutureIter_close(PyObject *self, PyObject *arg) +{ + futureiterobject *it = (futureiterobject*)self; + Py_BEGIN_CRITICAL_SECTION(self); + Py_CLEAR(it->future); + Py_END_CRITICAL_SECTION(); + Py_RETURN_NONE; +} + static int FutureIter_clear(PyObject *op) { @@ -1950,13 +1971,6 @@ FutureIter_clear(PyObject *op) return 0; } -static PyObject * -FutureIter_close(PyObject *self, PyObject *arg) -{ - (void)FutureIter_clear(self); - Py_RETURN_NONE; -} - static int FutureIter_traverse(PyObject *op, visitproc visit, void *arg) { @@ -1973,6 +1987,22 @@ static PyMethodDef FutureIter_methods[] = { {NULL, NULL} /* Sentinel */ }; +static PyObject * +FutureIter_get_fi_future(PyObject *op, void *Py_UNUSED(closure)) +{ + futureiterobject *it = (futureiterobject*)op; + PyObject *fut; + Py_BEGIN_CRITICAL_SECTION(op); + fut = it->future ? Py_NewRef(it->future) : Py_None; + Py_END_CRITICAL_SECTION(); + return fut; +} + +static PyGetSetDef FutureIter_getsetlist[] = { + {"fi_future", FutureIter_get_fi_future, NULL, NULL}, + {NULL} /* Sentinel */ +}; + static PyType_Slot FutureIter_slots[] = { {Py_tp_dealloc, FutureIter_dealloc}, {Py_tp_getattro, PyObject_GenericGetAttr}, @@ -1981,6 +2011,7 @@ static PyType_Slot FutureIter_slots[] = { {Py_tp_iter, PyObject_SelfIter}, {Py_tp_iternext, FutureIter_iternext}, {Py_tp_methods, FutureIter_methods}, + {Py_tp_getset, FutureIter_getsetlist}, // async methods {Py_am_send, FutureIter_am_send}, @@ -2153,6 +2184,9 @@ register_task(_PyThreadStateImpl *ts, TaskObj *task) assert(task->task_node.prev != NULL); return; } +#ifdef Py_GIL_DISABLED + task->task_tstate_id = ts->base.id; +#endif struct llist_node *head = &ts->asyncio_tasks_head; llist_insert_tail(head, &task->task_node); } @@ -2172,15 +2206,11 @@ static void unregister_task(TaskObj *task) { #ifdef Py_GIL_DISABLED - // check if we are in the same thread - // if so, we can avoid locking - if (task->task_tid == _Py_ThreadId()) { + PyThreadState *tstate = _PyThreadState_GET(); + if (task->task_tstate_id == tstate->id && !tstate->_status.finalizing) { unregister_task_safe(task); } else { - // we are in a different thread - // stop the world then check and remove the task - PyThreadState *tstate = _PyThreadState_GET(); _PyEval_StopTheWorld(tstate->interp); unregister_task_safe(task); _PyEval_StartTheWorld(tstate->interp); @@ -2309,7 +2339,9 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop, Py_CLEAR(self->task_fut_waiter); #ifdef Py_GIL_DISABLED - self->task_tid = _Py_ThreadId(); + if (self->task_node.next == NULL) { + self->task_tstate_id = _PyThreadState_GET()->id; + } #endif self->task_must_cancel = 0; self->task_log_destroy_pending = 1; @@ -2771,7 +2803,12 @@ _asyncio_Task_get_name_impl(TaskObj *self) { if (self->task_name) { if (PyLong_CheckExact(self->task_name)) { - PyObject *name = PyUnicode_FromFormat("Task-%S", self->task_name); + // Formatting can run arbitrary code (e.g. a GC finalizer that logs + // and re-enters get_name()), which may replace self->task_name. + // Hold a strong reference to the counter while formatting it. + PyObject *counter = Py_NewRef(self->task_name); + PyObject *name = PyUnicode_FromFormat("Task-%S", counter); + Py_DECREF(counter); if (name == NULL) { return NULL; } @@ -3950,7 +3987,6 @@ add_tasks_llist(struct llist_node *head, PyListObject *tasks) struct llist_node *node; llist_for_each_safe(node, head) { TaskObj *task = llist_data(node, TaskObj, task_node); - assert(task->task_state == STATE_PENDING); // The linked list holds borrowed references to task // as such it is possible that the task is concurrently // deallocated while added to this list. From 5a64c43bf6e641e80fec6c02511abc649df00a75 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Lespiau Date: Mon, 2 Nov 2026 16:57:21 +0100 Subject: [PATCH 6/6] Restore original comments. --- Modules/_asynciomodule.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index eee7d2d794d881c..67e860d2860fb08 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -68,6 +68,7 @@ typedef struct TaskObj { PyObject *task_context; struct llist_node task_node; #ifdef Py_GIL_DISABLED + // thread id of the thread where this task was created uint64_t task_tstate_id; #endif } TaskObj; @@ -2207,10 +2208,14 @@ unregister_task(TaskObj *task) { #ifdef Py_GIL_DISABLED PyThreadState *tstate = _PyThreadState_GET(); + // check if we are in the same thread + // if so, we can avoid locking if (task->task_tstate_id == tstate->id && !tstate->_status.finalizing) { unregister_task_safe(task); } else { + // we are in a different thread + // stop the world then check and remove the task _PyEval_StopTheWorld(tstate->interp); unregister_task_safe(task); _PyEval_StartTheWorld(tstate->interp);