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/3] 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/3] =?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/3] 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.