Skip to content

gh-159449: Fix a free-threaded asyncio race that corrupts the task lists - #159483

Open
jblespiau wants to merge 4 commits into
python:mainfrom
jblespiau:fix-asyncio-task-unregister-race
Open

jblespiau wants to merge 4 commits into
python:mainfrom
jblespiau:fix-asyncio-task-unregister-race

Conversation

@jblespiau

@jblespiau jblespiau commented Oct 15, 2026 •

Copy link
Copy Markdown

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.

3.14 fix is in #159451 that we can reopen if this one is accepted.

…ask 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.
@python-cla-bot

python-cla-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant