Skip to content

gh-100228: Warn from os.fork() if other threads exist. #100229

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Dec 29, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(__xor__)
STRUCT_FOR_ID(_abc_impl)
STRUCT_FOR_ID(_abstract_)
STRUCT_FOR_ID(_active)
STRUCT_FOR_ID(_annotation)
STRUCT_FOR_ID(_anonymous_)
STRUCT_FOR_ID(_argtypes_)
Expand All @@ -239,6 +240,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(_initializing)
STRUCT_FOR_ID(_is_text_encoding)
STRUCT_FOR_ID(_length_)
STRUCT_FOR_ID(_limbo)
STRUCT_FOR_ID(_lock_unlock_module)
STRUCT_FOR_ID(_loop)
STRUCT_FOR_ID(_needs_com_addref_)
Expand Down
2 changes: 2 additions & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions Lib/test/test_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -4534,6 +4534,31 @@ def test_fork(self):
assert_python_ok("-c", code)
assert_python_ok("-c", code, PYTHONMALLOC="malloc_debug")

@unittest.skipUnless(sys.platform in ("linux", "darwin"),
"Only Linux and macOS detect this today.")
def test_fork_warns_when_non_python_thread_exists(self):
code = """if 1:
import os, threading, warnings
from _testcapi import _spawn_pthread_waiter, _end_spawned_pthread
_spawn_pthread_waiter()
try:
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
if os.fork() == 0:
assert len(ws) == 0 # Warn only the parent
os._exit(0) # child
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
assert threading.active_count() == 1, threading.enumerate()
finally:
_end_spawned_pthread()
"""
_, out, err = assert_python_ok("-c", code)
self.assertEqual(err.decode("utf-8"), "")
self.assertEqual(out.decode("utf-8"), "")


# Only test if the C version is provided, otherwise TestPEP519 already tested
# the pure Python implementation.
Expand Down
48 changes: 29 additions & 19 deletions Lib/test/test_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ def test_dummy_thread_after_fork(self):
# Issue #14308: a dummy thread in the active list doesn't mess up
# the after-fork mechanism.
code = """if 1:
import _thread, threading, os, time
import _thread, threading, os, time, warnings

def background_thread(evt):
# Creates and registers the _DummyThread instance
Expand All @@ -575,11 +575,16 @@ def background_thread(evt):
_thread.start_new_thread(background_thread, (evt,))
evt.wait()
assert threading.active_count() == 2, threading.active_count()
if os.fork() == 0:
assert threading.active_count() == 1, threading.active_count()
os._exit(0)
else:
os.wait()
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
if os.fork() == 0:
assert threading.active_count() == 1, threading.active_count()
os._exit(0)
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
os.wait()
"""
_, out, err = assert_python_ok("-c", code)
self.assertEqual(out, b'')
Expand Down Expand Up @@ -645,29 +650,34 @@ def test_main_thread_after_fork(self):
@unittest.skipUnless(hasattr(os, 'waitpid'), "test needs os.waitpid()")
def test_main_thread_after_fork_from_nonmain_thread(self):
code = """if 1:
import os, threading, sys
import os, threading, sys, warnings
from test import support

def func():
pid = os.fork()
if pid == 0:
main = threading.main_thread()
print(main.name)
print(main.ident == threading.current_thread().ident)
print(main.ident == threading.get_ident())
# stdout is fully buffered because not a tty,
# we have to flush before exit.
sys.stdout.flush()
else:
support.wait_process(pid, exitcode=0)
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
pid = os.fork()
if pid == 0:
main = threading.main_thread()
print(main.name)
print(main.ident == threading.current_thread().ident)
print(main.ident == threading.get_ident())
# stdout is fully buffered because not a tty,
# we have to flush before exit.
sys.stdout.flush()
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
support.wait_process(pid, exitcode=0)

th = threading.Thread(target=func)
th.start()
th.join()
"""
_, out, err = assert_python_ok("-c", code)
data = out.decode().replace('\r', '')
self.assertEqual(err, b"")
self.assertEqual(err.decode('utf-8'), "")
self.assertEqual(data, "Thread-1 (func)\nTrue\nTrue\n")

def test_main_thread_during_shutdown(self):
Expand Down
2 changes: 2 additions & 0 deletions Lib/threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,8 @@ def active_count():
enumerate().

"""
# NOTE: if the logic in here ever changes, update Modules/posixmodule.c
# warn_about_fork_with_threads() to match.
with _active_limbo_lock:
return len(_active) + len(_limbo)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
A :exc:`DeprecationWarning` may be raised when :func:`os.fork()` or
:func:`os.forkpty()` is called from multi-threaded processes. This is to
help people realize they are doing something unsafe. Lack of a warning
does not indicate that the fork call was actually safe.
46 changes: 46 additions & 0 deletions Modules/_testcapimodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
#include "structmember.h" // for offsetof(), T_OBJECT
#include <float.h> // FLT_MAX
#include <signal.h>
#ifndef MS_WINDOWS
#include <unistd.h>
#endif

#ifdef HAVE_SYS_WAIT_H
#include <sys/wait.h> // W_STOPCODE
Expand Down Expand Up @@ -870,6 +873,45 @@ test_thread_state(PyObject *self, PyObject *args)
Py_RETURN_NONE;
}

#ifndef MS_WINDOWS
static PyThread_type_lock wait_done = NULL;

static void wait_for_lock(void *unused) {
PyThread_acquire_lock(wait_done, 1);
}

// These can be used to test things that care about the existence of another
// thread that the Python runtime doesn't know about.

static PyObject *
spawn_pthread_waiter(PyObject *self, PyObject *Py_UNUSED(ignored))
{
if (wait_done) {
PyErr_SetString(PyExc_RuntimeError, "thread already running");
return NULL;
}
wait_done = PyThread_allocate_lock();
if (wait_done == NULL)
return PyErr_NoMemory();
PyThread_acquire_lock(wait_done, 1);
PyThread_start_new_thread(wait_for_lock, NULL);
Py_RETURN_NONE;
}

static PyObject *
end_spawned_pthread(PyObject *self, PyObject *Py_UNUSED(ignored))
{
if (!wait_done) {
PyErr_SetString(PyExc_RuntimeError, "call _spawn_pthread_waiter 1st");
return NULL;
}
PyThread_release_lock(wait_done);
PyThread_free_lock(wait_done);
wait_done = NULL;
Py_RETURN_NONE;
}
#endif // not MS_WINDOWS

/* test Py_AddPendingCalls using threads */
static int _pending_callback(void *arg)
{
Expand Down Expand Up @@ -3262,6 +3304,10 @@ static PyMethodDef TestMethods[] = {
{"test_get_type_name", test_get_type_name, METH_NOARGS},
{"test_get_type_qualname", test_get_type_qualname, METH_NOARGS},
{"_test_thread_state", test_thread_state, METH_VARARGS},
#ifndef MS_WINDOWS
{"_spawn_pthread_waiter", spawn_pthread_waiter, METH_NOARGS},
{"_end_spawned_pthread", end_spawned_pthread, METH_NOARGS},
#endif
{"_pending_threadfunc", pending_threadfunc, METH_VARARGS},
#ifdef HAVE_GETTIMEOFDAY
{"profile_int", profile_int, METH_NOARGS},
Expand Down
95 changes: 95 additions & 0 deletions Modules/posixmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
*/
#if defined(__APPLE__)

#include <mach/mach.h>

#if defined(__has_builtin)
#if __has_builtin(__builtin_available)
#define HAVE_BUILTIN_AVAILABLE 1
Expand Down Expand Up @@ -6737,6 +6739,96 @@ os_register_at_fork_impl(PyObject *module, PyObject *before,
}
#endif /* HAVE_FORK */

// Common code to raise a warning if we know there is more than one thread
// running in the process. Best effort, silent if unable to count threads.
// Constraint: Avoids locks. Quick. Never leaves an error set.
static void warn_about_fork_with_threads(const char* name) {
static pid_t already_warned = 0;
#if defined(HAVE_GETPID)
if (getpid() == already_warned) {
// Avoid any system calls to reconfirm in this process if the warning might
// be silenced.
return;
}
#endif
Py_ssize_t num_python_threads = 0;
#if defined(__APPLE__) && defined(HAVE_GETPID)
mach_port_t macos_self = mach_task_self();
mach_port_t macos_task;
if (task_for_pid(macos_self, getpid(), &macos_task) == KERN_SUCCESS) {
thread_array_t macos_threads;
mach_msg_type_number_t macos_n_threads;
if (task_threads(macos_task, &macos_threads,
&macos_n_threads) == KERN_SUCCESS) {
num_python_threads = macos_n_threads;
}
}
#elif defined(__linux__)
// Linux /proc/self/stat 20th field is the number of threads.
FILE* proc_stat = fopen("/proc/self/stat", "r");
if (proc_stat) {
size_t n;
char stat_line[160];
n = fread(&stat_line, 1, 159, proc_stat);
stat_line[n] = '\0';
fclose(proc_stat);

char *saveptr = NULL;
char *field = strtok_r(stat_line, " ", &saveptr);
unsigned int idx;
for (idx = 19; idx && field; --idx) {
field = strtok_r(NULL, " ", &saveptr);
}
if (idx == 0 && field) { // found the 20th field
num_python_threads = atoi(field); // 0 on error
}
}
#endif
if (num_python_threads <= 0) {
// Fall back to just the number of threads this CPython runtime knows about.
PyObject *threading = PyImport_GetModule(&_Py_ID(threading));
if (!threading) {
PyErr_Clear();
return;
}
PyObject *threading_active = PyObject_GetAttr(threading, &_Py_ID(_active));
if (!threading_active) {
PyErr_Clear();
Py_DECREF(threading);
return;
}
PyObject *threading_limbo = PyObject_GetAttr(threading, &_Py_ID(_limbo));
if (!threading_limbo) {
PyErr_Clear();
Py_XDECREF(threading);
Py_XDECREF(threading_active);
return;
}
// Worst case if someone replaced threading._active or threading._limbo
// with non-dicts, we get -1 from *Length() below and undercount.
// Whatever. That shouldn't happen, we're best effort so we clear errors
// and move on.
assert(PyMapping_Check(threading_active));
assert(PyMapping_Check(threading_limbo));
// Duplicating what threading.active_count() does but without holding
// threading._active_limbo_lock so our count could be inaccurate if these
// dicts are mid-update from another thread. Not a big deal.
num_python_threads = (PyMapping_Length(threading_active)
+ PyMapping_Length(threading_limbo));
PyErr_Clear();
Py_DECREF(threading);
Py_DECREF(threading_active);
Py_DECREF(threading_limbo);
}
if (num_python_threads > 1) {
PyErr_WarnFormat(
PyExc_DeprecationWarning, 1,
"multi-threaded process, %s() may cause deadlocks.", name);
#ifdef HAVE_GETPID
already_warned = getpid();
#endif
}
}

#ifdef HAVE_FORK1
/*[clinic input]
Expand All @@ -6763,6 +6855,7 @@ os_fork1_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("fork1");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}
Expand Down Expand Up @@ -6802,6 +6895,7 @@ os_fork_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("fork");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}
Expand Down Expand Up @@ -7471,6 +7565,7 @@ os_forkpty_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("forkpty");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}
Expand Down