Skip to content

Commit 894f2c3

Browse files
authored
gh-100228: Warn from os.fork() if other threads exist. (#100229)
Not comprehensive, best effort warning. There are cases when threads exist on some platforms that this code cannot detect. macOS when API permissions allow and Linux with a readable /proc procfs present are the currently supported cases where a warning should show up reliably. Starting with a DeprecationWarning for now, it is less disruptive than something like RuntimeWarning and most likely to only be seen in people's CI tests - a good place to start with this messaging.
1 parent 2df82db commit 894f2c3

12 files changed

+283
-66
lines changed

Include/internal/pycore_global_objects_fini_generated.h

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_global_strings.h

+2
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ struct _Py_global_strings {
219219
STRUCT_FOR_ID(__xor__)
220220
STRUCT_FOR_ID(_abc_impl)
221221
STRUCT_FOR_ID(_abstract_)
222+
STRUCT_FOR_ID(_active)
222223
STRUCT_FOR_ID(_annotation)
223224
STRUCT_FOR_ID(_anonymous_)
224225
STRUCT_FOR_ID(_argtypes_)
@@ -239,6 +240,7 @@ struct _Py_global_strings {
239240
STRUCT_FOR_ID(_initializing)
240241
STRUCT_FOR_ID(_is_text_encoding)
241242
STRUCT_FOR_ID(_length_)
243+
STRUCT_FOR_ID(_limbo)
242244
STRUCT_FOR_ID(_lock_unlock_module)
243245
STRUCT_FOR_ID(_loop)
244246
STRUCT_FOR_ID(_needs_com_addref_)

Include/internal/pycore_runtime_init_generated.h

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_unicodeobject_generated.h

+4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Lib/test/fork_wait.py

+15-16
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import threading
1414
from test import support
1515
from test.support import threading_helper
16+
import warnings
1617

1718

1819
LONGSLEEP = 2
@@ -63,19 +64,17 @@ def test_wait(self):
6364

6465
prefork_lives = self.alive.copy()
6566

66-
if sys.platform in ['unixware7']:
67-
cpid = os.fork1()
68-
else:
69-
cpid = os.fork()
70-
71-
if cpid == 0:
72-
# Child
73-
time.sleep(LONGSLEEP)
74-
n = 0
75-
for key in self.alive:
76-
if self.alive[key] != prefork_lives[key]:
77-
n += 1
78-
os._exit(n)
79-
else:
80-
# Parent
81-
self.wait_impl(cpid, exitcode=0)
67+
# Ignore the warning about fork with threads.
68+
with warnings.catch_warnings(category=DeprecationWarning,
69+
action="ignore"):
70+
if (cpid := os.fork()) == 0:
71+
# Child
72+
time.sleep(LONGSLEEP)
73+
n = 0
74+
for key in self.alive:
75+
if self.alive[key] != prefork_lives[key]:
76+
n += 1
77+
os._exit(n)
78+
else:
79+
# Parent
80+
self.wait_impl(cpid, exitcode=0)

Lib/test/test_os.py

+28
Original file line numberDiff line numberDiff line change
@@ -4577,6 +4577,34 @@ def test_fork(self):
45774577
assert_python_ok("-c", code)
45784578
assert_python_ok("-c", code, PYTHONMALLOC="malloc_debug")
45794579

4580+
@unittest.skipUnless(sys.platform in ("linux", "darwin"),
4581+
"Only Linux and macOS detect this today.")
4582+
def test_fork_warns_when_non_python_thread_exists(self):
4583+
code = """if 1:
4584+
import os, threading, warnings
4585+
from _testcapi import _spawn_pthread_waiter, _end_spawned_pthread
4586+
_spawn_pthread_waiter()
4587+
try:
4588+
with warnings.catch_warnings(record=True) as ws:
4589+
warnings.filterwarnings(
4590+
"always", category=DeprecationWarning)
4591+
if os.fork() == 0:
4592+
assert not ws, f"unexpected warnings in child: {ws}"
4593+
os._exit(0) # child
4594+
else:
4595+
assert ws[0].category == DeprecationWarning, ws[0]
4596+
assert 'fork' in str(ws[0].message), ws[0]
4597+
# Waiting allows an error in the child to hit stderr.
4598+
exitcode = os.wait()[1]
4599+
assert exitcode == 0, f"child exited {exitcode}"
4600+
assert threading.active_count() == 1, threading.enumerate()
4601+
finally:
4602+
_end_spawned_pthread()
4603+
"""
4604+
_, out, err = assert_python_ok("-c", code, PYTHONOPTIMIZE='0')
4605+
self.assertEqual(err.decode("utf-8"), "")
4606+
self.assertEqual(out.decode("utf-8"), "")
4607+
45804608

45814609
# Only test if the C version is provided, otherwise TestPEP519 already tested
45824610
# the pure Python implementation.

Lib/test/test_thread.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from test.support import threading_helper
66
import _thread as thread
77
import time
8+
import warnings
89
import weakref
910

1011
from test import lock_tests
@@ -238,11 +239,13 @@ def test_forkinthread(self):
238239
def fork_thread(read_fd, write_fd):
239240
nonlocal pid
240241

241-
# fork in a thread
242-
pid = os.fork()
243-
if pid:
244-
# parent process
245-
return
242+
# Ignore the warning about fork with threads.
243+
with warnings.catch_warnings(category=DeprecationWarning,
244+
action="ignore"):
245+
# fork in a thread (DANGER, undefined per POSIX)
246+
if (pid := os.fork()):
247+
# parent process
248+
return
246249

247250
# child process
248251
try:

Lib/test/test_threading.py

+65-45
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import signal
2121
import textwrap
2222
import traceback
23+
import warnings
2324

2425
from unittest import mock
2526
from test import lock_tests
@@ -563,7 +564,7 @@ def test_dummy_thread_after_fork(self):
563564
# Issue #14308: a dummy thread in the active list doesn't mess up
564565
# the after-fork mechanism.
565566
code = """if 1:
566-
import _thread, threading, os, time
567+
import _thread, threading, os, time, warnings
567568
568569
def background_thread(evt):
569570
# Creates and registers the _DummyThread instance
@@ -575,11 +576,16 @@ def background_thread(evt):
575576
_thread.start_new_thread(background_thread, (evt,))
576577
evt.wait()
577578
assert threading.active_count() == 2, threading.active_count()
578-
if os.fork() == 0:
579-
assert threading.active_count() == 1, threading.active_count()
580-
os._exit(0)
581-
else:
582-
os.wait()
579+
with warnings.catch_warnings(record=True) as ws:
580+
warnings.filterwarnings(
581+
"always", category=DeprecationWarning)
582+
if os.fork() == 0:
583+
assert threading.active_count() == 1, threading.active_count()
584+
os._exit(0)
585+
else:
586+
assert ws[0].category == DeprecationWarning, ws[0]
587+
assert 'fork' in str(ws[0].message), ws[0]
588+
os.wait()
583589
"""
584590
_, out, err = assert_python_ok("-c", code)
585591
self.assertEqual(out, b'')
@@ -598,13 +604,15 @@ def test_is_alive_after_fork(self):
598604
for i in range(20):
599605
t = threading.Thread(target=lambda: None)
600606
t.start()
601-
pid = os.fork()
602-
if pid == 0:
603-
os._exit(11 if t.is_alive() else 10)
604-
else:
605-
t.join()
607+
# Ignore the warning about fork with threads.
608+
with warnings.catch_warnings(category=DeprecationWarning,
609+
action="ignore"):
610+
if (pid := os.fork()) == 0:
611+
os._exit(11 if t.is_alive() else 10)
612+
else:
613+
t.join()
606614

607-
support.wait_process(pid, exitcode=10)
615+
support.wait_process(pid, exitcode=10)
608616

609617
def test_main_thread(self):
610618
main = threading.main_thread()
@@ -645,29 +653,34 @@ def test_main_thread_after_fork(self):
645653
@unittest.skipUnless(hasattr(os, 'waitpid'), "test needs os.waitpid()")
646654
def test_main_thread_after_fork_from_nonmain_thread(self):
647655
code = """if 1:
648-
import os, threading, sys
656+
import os, threading, sys, warnings
649657
from test import support
650658
651659
def func():
652-
pid = os.fork()
653-
if pid == 0:
654-
main = threading.main_thread()
655-
print(main.name)
656-
print(main.ident == threading.current_thread().ident)
657-
print(main.ident == threading.get_ident())
658-
# stdout is fully buffered because not a tty,
659-
# we have to flush before exit.
660-
sys.stdout.flush()
661-
else:
662-
support.wait_process(pid, exitcode=0)
660+
with warnings.catch_warnings(record=True) as ws:
661+
warnings.filterwarnings(
662+
"always", category=DeprecationWarning)
663+
pid = os.fork()
664+
if pid == 0:
665+
main = threading.main_thread()
666+
print(main.name)
667+
print(main.ident == threading.current_thread().ident)
668+
print(main.ident == threading.get_ident())
669+
# stdout is fully buffered because not a tty,
670+
# we have to flush before exit.
671+
sys.stdout.flush()
672+
else:
673+
assert ws[0].category == DeprecationWarning, ws[0]
674+
assert 'fork' in str(ws[0].message), ws[0]
675+
support.wait_process(pid, exitcode=0)
663676
664677
th = threading.Thread(target=func)
665678
th.start()
666679
th.join()
667680
"""
668681
_, out, err = assert_python_ok("-c", code)
669682
data = out.decode().replace('\r', '')
670-
self.assertEqual(err, b"")
683+
self.assertEqual(err.decode('utf-8'), "")
671684
self.assertEqual(data, "Thread-1 (func)\nTrue\nTrue\n")
672685

673686
def test_main_thread_during_shutdown(self):
@@ -1173,15 +1186,18 @@ def do_fork_and_wait():
11731186
else:
11741187
os._exit(50)
11751188

1176-
# start a bunch of threads that will fork() child processes
1177-
threads = []
1178-
for i in range(16):
1179-
t = threading.Thread(target=do_fork_and_wait)
1180-
threads.append(t)
1181-
t.start()
1189+
# Ignore the warning about fork with threads.
1190+
with warnings.catch_warnings(category=DeprecationWarning,
1191+
action="ignore"):
1192+
# start a bunch of threads that will fork() child processes
1193+
threads = []
1194+
for i in range(16):
1195+
t = threading.Thread(target=do_fork_and_wait)
1196+
threads.append(t)
1197+
t.start()
11821198

1183-
for t in threads:
1184-
t.join()
1199+
for t in threads:
1200+
t.join()
11851201

11861202
@support.requires_fork()
11871203
def test_clear_threads_states_after_fork(self):
@@ -1194,18 +1210,22 @@ def test_clear_threads_states_after_fork(self):
11941210
threads.append(t)
11951211
t.start()
11961212

1197-
pid = os.fork()
1198-
if pid == 0:
1199-
# check that threads states have been cleared
1200-
if len(sys._current_frames()) == 1:
1201-
os._exit(51)
1202-
else:
1203-
os._exit(52)
1204-
else:
1205-
support.wait_process(pid, exitcode=51)
1206-
1207-
for t in threads:
1208-
t.join()
1213+
try:
1214+
# Ignore the warning about fork with threads.
1215+
with warnings.catch_warnings(category=DeprecationWarning,
1216+
action="ignore"):
1217+
pid = os.fork()
1218+
if pid == 0:
1219+
# check that threads states have been cleared
1220+
if len(sys._current_frames()) == 1:
1221+
os._exit(51)
1222+
else:
1223+
os._exit(52)
1224+
else:
1225+
support.wait_process(pid, exitcode=51)
1226+
finally:
1227+
for t in threads:
1228+
t.join()
12091229

12101230

12111231
class SubinterpThreadingTests(BaseTestCase):

Lib/threading.py

+2
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,8 @@ def active_count():
14901490
enumerate().
14911491
14921492
"""
1493+
# NOTE: if the logic in here ever changes, update Modules/posixmodule.c
1494+
# warn_about_fork_with_threads() to match.
14931495
with _active_limbo_lock:
14941496
return len(_active) + len(_limbo)
14951497

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
A :exc:`DeprecationWarning` may be raised when :func:`os.fork()` or
2+
:func:`os.forkpty()` is called from multi-threaded processes. Forking
3+
with threads is unsafe and can cause deadlocks, crashes and subtle
4+
problems. Lack of a warning does not indicate that the fork call was
5+
actually safe, as Python may not be aware of all threads.

0 commit comments

Comments
 (0)