From 63ad59bb3a33d4a39db9002cb6eb4c014a98fb13 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 7 Oct 2026 09:56:59 -0700 Subject: [PATCH 1/2] gh-158973: Reset the warnings lock after fork in the child --- Include/internal/pycore_interp_structs.h | 4 + Include/internal/pycore_warnings.h | 5 + Lib/test/test_warnings/__init__.py | 95 +++++++++++++++++++ ...00-00-00.gh-issue-158973.warnings-fork.rst | 3 + Modules/posixmodule.c | 5 + Python/_warnings.c | 28 ++++++ 6 files changed, 140 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-07-00-00-00.gh-issue-158973.warnings-fork.rst diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index fe501be1afc59b1..c0f7e0595c9cc46 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -649,6 +649,10 @@ struct _warnings_runtime_state { PyObject *once_registry; /* Dict */ PyObject *default_action; /* String */ _PyRecursiveMutex lock; +#ifdef HAVE_FORK + // Whether the thread calling fork() owns the warnings lock. + bool lock_held_at_fork; +#endif long filters_version; PyObject *context; }; diff --git a/Include/internal/pycore_warnings.h b/Include/internal/pycore_warnings.h index e1348d529a22463..d3c0c549373257e 100644 --- a/Include/internal/pycore_warnings.h +++ b/Include/internal/pycore_warnings.h @@ -12,6 +12,11 @@ extern int _PyWarnings_InitState(PyInterpreterState *interp); extern PyObject* _PyWarnings_Init(void); +#ifdef HAVE_FORK +extern void _PyWarnings_BeforeFork(PyInterpreterState *interp); +extern void _PyWarnings_AfterFork(PyInterpreterState *interp); +#endif + extern void _PyErr_WarnUnawaitedCoroutine(PyObject *coro); extern void _PyErr_WarnUnawaitedAgenMethod(PyAsyncGenObject *agen, PyObject *method); diff --git a/Lib/test/test_warnings/__init__.py b/Lib/test/test_warnings/__init__.py index 5b5d5c2f70ffd7a..7262fb0d38344c0 100644 --- a/Lib/test/test_warnings/__init__.py +++ b/Lib/test/test_warnings/__init__.py @@ -15,6 +15,7 @@ from test.support import import_helper from test.support import isolation from test.support import os_helper +from test.support import threading_helper from test.support import warnings_helper from test.support import force_not_colorized from test.support.script_helper import assert_python_ok, assert_python_failure @@ -1632,6 +1633,100 @@ def test_release_lock_no_lock(self): ): c_warnings._release_lock() + @support.cpython_only + @unittest.skipUnless(support.has_fork_support, 'requires working os.fork') + @threading_helper.requires_working_threading() + def test_fork_other_thread_holds_lock(self): + code = textwrap.dedent(''' + import _warnings + import os + import threading + import warnings + from test import support + + parent_pid = os.getpid() + read_fd, write_fd = os.pipe() + local = threading.local() + ready = threading.Event() + release = threading.Event() + warnings.simplefilter('ignore', DeprecationWarning) + warnings.simplefilter('ignore', ResourceWarning) + + class WarnOnDelete: + def __del__(self): + if os.getpid() != parent_pid: + # Even an ignored warning must acquire the lock. + warnings.warn('child cleanup', ResourceWarning) + os.write(write_fd, b'finalized') + + def worker(): + local.obj = WarnOnDelete() + _warnings._acquire_lock() + _warnings._acquire_lock() + try: + ready.set() + release.wait() + finally: + _warnings._release_lock() + _warnings._release_lock() + + thread = threading.Thread(target=worker) + thread.start() + # Release the worker before fork's parent-side warning attempts + # to acquire the warnings lock. + os.register_at_fork(after_in_parent=release.set) + try: + assert ready.wait(support.LONG_TIMEOUT) + pid = os.fork() + if pid == 0: + # The finalizer must run before os.fork() returns. + os._exit(0) + support.wait_process(pid, exitcode=0) + finally: + release.set() + thread.join() + os.close(write_fd) + assert os.read(read_fd, 100) == b'finalized' + os.close(read_fd) + ''') + assert_python_ok('-c', code) + + @support.cpython_only + @unittest.skipUnless(support.has_fork_support, 'requires working os.fork') + def test_fork_current_thread_holds_lock(self): + code = textwrap.dedent(''' + import _warnings + import os + import warnings + from test import support + + warnings.simplefilter('ignore') + for _ in range(3): + _warnings._acquire_lock() + pid = os.fork() + try: + # A warning must not change the inherited recursion depth. + warnings.warn('after fork') + for _ in range(3): + _warnings._release_lock() + try: + _warnings._release_lock() + except RuntimeError: + pass + else: + raise AssertionError('unexpected recursion depth') + _warnings._acquire_lock() + _warnings._release_lock() + except BaseException: + if pid == 0: + os._exit(1) + raise + if pid == 0: + os._exit(0) + support.wait_process(pid, exitcode=0) + ''') + assert_python_ok('-c', code) + class _DeprecatedTest(BaseTest, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-07-00-00-00.gh-issue-158973.warnings-fork.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-07-00-00-00.gh-issue-158973.warnings-fork.rst new file mode 100644 index 000000000000000..9a98cf6e0333845 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-07-00-00-00.gh-issue-158973.warnings-fork.rst @@ -0,0 +1,3 @@ +Reset the warnings lock in the child after :func:`os.fork`, before clearing +other threads' states, to avoid deadlocks when their finalizers issue warnings. +Preserve the lock's recursion depth if the forking thread owns it. diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index 0a451b8a833e67e..b0442ef031c0401 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -31,6 +31,7 @@ #include "pycore_time.h" // _PyLong_FromTime_t() #include "pycore_tuple.h" // _PyTuple_FromPairSteal #include "pycore_typeobject.h" // _PyType_AddMethod() +#include "pycore_warnings.h" // _PyWarnings_AfterFork() #ifndef MS_WINDOWS # include "posixmodule.h" // _PyLong_FromUid() @@ -716,6 +717,7 @@ PyOS_BeforeFork(void) _PyImport_AcquireLock(interp); _PyEval_StopTheWorldAll(&_PyRuntime); HEAD_LOCK(&_PyRuntime); + _PyWarnings_BeforeFork(interp); } void @@ -786,6 +788,9 @@ PyOS_AfterFork_Child(void) _Py_jit_debug_mutex = (PyMutex){0}; #endif + // Thread-state cleanup can run finalizers that issue warnings. + _PyWarnings_AfterFork(tstate->interp); + reset_remotedebug_data(tstate); reset_asyncio_state((_PyThreadStateImpl *)tstate); diff --git a/Python/_warnings.c b/Python/_warnings.c index 586cec195fac680..6743bc27cc8e8f2 100644 --- a/Python/_warnings.c +++ b/Python/_warnings.c @@ -9,6 +9,7 @@ #include "pycore_traceback.h" // _Py_DisplaySourceLine() #include "pycore_tuple.h" // _PyTuple_FromPair #include "pycore_unicodeobject.h" // _PyUnicode_EqualToASCIIString() +#include "pycore_warnings.h" // _PyWarnings_AfterFork() #include #include "clinic/_warnings.c.h" @@ -276,6 +277,33 @@ warnings_lock_held(WarningsState *st) return PyMutex_IsLocked(&st->lock.mutex); } +#ifdef HAVE_FORK +void +_PyWarnings_BeforeFork(PyInterpreterState *interp) +{ + WarningsState *st = warnings_get_state(interp); + // Record ownership before fork(): the thread ID can change in the child. + st->lock_held_at_fork = _PyRecursiveMutex_IsLockedByCurrentThread(&st->lock); +} + +void +_PyWarnings_AfterFork(PyInterpreterState *interp) +{ + WarningsState *st = warnings_get_state(interp); + if (st->lock_held_at_fork) { + // The surviving thread will still release the lock as its stack + // unwinds. Preserve the recursion depth, but discard dead waiters. + st->lock.mutex = (PyMutex){._bits = _Py_LOCKED}; + st->lock.thread = PyThread_get_thread_ident_ex(); + } + else { + // The owner (if any) no longer exists in the child. + st->lock = (_PyRecursiveMutex){0}; + } + st->lock_held_at_fork = false; +} +#endif + static PyObject * get_warnings_context(PyInterpreterState *interp) { From 2f7a64ce96f7183c5550ecc32a105d91ef122a61 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 7 Oct 2026 11:22:45 -0700 Subject: [PATCH 2/2] Add helper function. Add _PyRecursiveMutex_at_fork_reinit() helper function. --- Include/internal/pycore_lock.h | 3 +++ Python/_warnings.c | 12 +----------- Python/lock.c | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Include/internal/pycore_lock.h b/Include/internal/pycore_lock.h index 7dd2419ececf299..db7141d5dc710ac 100644 --- a/Include/internal/pycore_lock.h +++ b/Include/internal/pycore_lock.h @@ -173,6 +173,9 @@ PyAPI_FUNC(void) _PyRecursiveMutex_Lock(_PyRecursiveMutex *m); extern PyLockStatus _PyRecursiveMutex_LockTimed(_PyRecursiveMutex *m, PyTime_t timeout, _PyLockFlags flags); PyAPI_FUNC(void) _PyRecursiveMutex_Unlock(_PyRecursiveMutex *m); extern int _PyRecursiveMutex_TryUnlock(_PyRecursiveMutex *m); +#ifdef HAVE_FORK +extern void _PyRecursiveMutex_at_fork_reinit(_PyRecursiveMutex *m, int owned); +#endif // A readers-writer (RW) lock. The lock supports multiple concurrent readers or // a single writer. The lock is write-preferring: if a writer is waiting while diff --git a/Python/_warnings.c b/Python/_warnings.c index 6743bc27cc8e8f2..8fecff060fdad93 100644 --- a/Python/_warnings.c +++ b/Python/_warnings.c @@ -282,7 +282,6 @@ void _PyWarnings_BeforeFork(PyInterpreterState *interp) { WarningsState *st = warnings_get_state(interp); - // Record ownership before fork(): the thread ID can change in the child. st->lock_held_at_fork = _PyRecursiveMutex_IsLockedByCurrentThread(&st->lock); } @@ -290,16 +289,7 @@ void _PyWarnings_AfterFork(PyInterpreterState *interp) { WarningsState *st = warnings_get_state(interp); - if (st->lock_held_at_fork) { - // The surviving thread will still release the lock as its stack - // unwinds. Preserve the recursion depth, but discard dead waiters. - st->lock.mutex = (PyMutex){._bits = _Py_LOCKED}; - st->lock.thread = PyThread_get_thread_ident_ex(); - } - else { - // The owner (if any) no longer exists in the child. - st->lock = (_PyRecursiveMutex){0}; - } + _PyRecursiveMutex_at_fork_reinit(&st->lock, st->lock_held_at_fork); st->lock_held_at_fork = false; } #endif diff --git a/Python/lock.c b/Python/lock.c index b669e3dcdbc9993..a2d47eea64a006b 100644 --- a/Python/lock.c +++ b/Python/lock.c @@ -453,6 +453,25 @@ _PyRecursiveMutex_TryUnlock(_PyRecursiveMutex *m) return 0; } +#ifdef HAVE_FORK +// Reinitialize the mutex in the child process after fork(). The caller must +// record before fork() whether the forking thread owns the mutex, since the +// thread ident can change in the child (gh-126688). If it does, the child +// keeps the mutex with the same recursion level; otherwise the owner and any +// waiters no longer exist in the child and the mutex is reset. +void +_PyRecursiveMutex_at_fork_reinit(_PyRecursiveMutex *m, int owned) +{ + if (owned) { + m->mutex = (PyMutex){._bits = _Py_LOCKED}; + _Py_atomic_store_ullong_relaxed(&m->thread, PyThread_get_thread_ident_ex()); + } + else { + memset(m, 0, sizeof(*m)); + } +} +#endif + #define _Py_WRITE_LOCKED 1 #define _PyRWMutex_READER_SHIFT 2 #define _Py_RWMUTEX_MAX_READERS (UINTPTR_MAX >> _PyRWMutex_READER_SHIFT)