Skip to content

Commit 10e9bb1

Browse files
authored
gh-112334: Regression test that vfork is used when expected. (#112734)
Regression test that vfork is used when expected by subprocess. This is written integration test style, it uses strace if it is present and appears to work to find out what system call actually gets used in different scenarios. Test coverage is added for the default behavior and that of each of the specific arguments that must disable the use of vfork. obviously not an entire test matrix, but it covers the most important aspects. If there are ever issues with this test being flaky or failing on new platforms, rather than try and adapt it for all possible platforms, feel free to narrow the range it gets tested on when appropriate. That is not likely to reduce coverage.
1 parent ed8720a commit 10e9bb1

File tree

4 files changed

+101
-15
lines changed

4 files changed

+101
-15
lines changed

.github/workflows/posix-deps-apt.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ apt-get -yq install \
2121
libssl-dev \
2222
lzma \
2323
lzma-dev \
24+
strace \
2425
tk-dev \
2526
uuid-dev \
2627
xvfb \

Lib/test/support/script_helper.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,28 @@ def fail(self, cmd_line):
9292
# Executing the interpreter in a subprocess
9393
@support.requires_subprocess()
9494
def run_python_until_end(*args, **env_vars):
95+
"""Used to implement assert_python_*.
96+
97+
*args are the command line flags to pass to the python interpreter.
98+
**env_vars keyword arguments are environment variables to set on the process.
99+
100+
If __run_using_command= is supplied, it must be a list of
101+
command line arguments to prepend to the command line used.
102+
Useful when you want to run another command that should launch the
103+
python interpreter via its own arguments. ["/bin/echo", "--"] for
104+
example could print the unquoted python command line instead of
105+
run it.
106+
"""
95107
env_required = interpreter_requires_environment()
108+
run_using_command = env_vars.pop('__run_using_command', None)
96109
cwd = env_vars.pop('__cwd', None)
97110
if '__isolated' in env_vars:
98111
isolated = env_vars.pop('__isolated')
99112
else:
100113
isolated = not env_vars and not env_required
101114
cmd_line = [sys.executable, '-X', 'faulthandler']
115+
if run_using_command:
116+
cmd_line = run_using_command + cmd_line
102117
if isolated:
103118
# isolated mode: ignore Python environment variables, ignore user
104119
# site-packages, and don't add the current directory to sys.path

Lib/test/test_subprocess.py

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,21 +1561,6 @@ def test_class_getitems(self):
15611561
self.assertIsInstance(subprocess.Popen[bytes], types.GenericAlias)
15621562
self.assertIsInstance(subprocess.CompletedProcess[str], types.GenericAlias)
15631563

1564-
@unittest.skipIf(not sysconfig.get_config_var("HAVE_VFORK"),
1565-
"vfork() not enabled by configure.")
1566-
@mock.patch("subprocess._fork_exec")
1567-
def test__use_vfork(self, mock_fork_exec):
1568-
self.assertTrue(subprocess._USE_VFORK) # The default value regardless.
1569-
mock_fork_exec.side_effect = RuntimeError("just testing args")
1570-
with self.assertRaises(RuntimeError):
1571-
subprocess.run([sys.executable, "-c", "pass"])
1572-
mock_fork_exec.assert_called_once()
1573-
self.assertTrue(mock_fork_exec.call_args.args[-1])
1574-
with mock.patch.object(subprocess, '_USE_VFORK', False):
1575-
with self.assertRaises(RuntimeError):
1576-
subprocess.run([sys.executable, "-c", "pass"])
1577-
self.assertFalse(mock_fork_exec.call_args_list[-1].args[-1])
1578-
15791564

15801565
class RunFuncTestCase(BaseTestCase):
15811566
def run_python(self, code, **kwargs):
@@ -3360,6 +3345,89 @@ def exit_handler():
33603345
self.assertEqual(out, b'')
33613346
self.assertIn(b"preexec_fn not supported at interpreter shutdown", err)
33623347

3348+
@unittest.skipIf(not sysconfig.get_config_var("HAVE_VFORK"),
3349+
"vfork() not enabled by configure.")
3350+
@mock.patch("subprocess._fork_exec")
3351+
def test__use_vfork(self, mock_fork_exec):
3352+
self.assertTrue(subprocess._USE_VFORK) # The default value regardless.
3353+
mock_fork_exec.side_effect = RuntimeError("just testing args")
3354+
with self.assertRaises(RuntimeError):
3355+
subprocess.run([sys.executable, "-c", "pass"])
3356+
mock_fork_exec.assert_called_once()
3357+
# NOTE: These assertions are *ugly* as they require the last arg
3358+
# to remain the have_vfork boolean. We really need to refactor away
3359+
# from the giant "wall of args" internal C extension API.
3360+
self.assertTrue(mock_fork_exec.call_args.args[-1])
3361+
with mock.patch.object(subprocess, '_USE_VFORK', False):
3362+
with self.assertRaises(RuntimeError):
3363+
subprocess.run([sys.executable, "-c", "pass"])
3364+
self.assertFalse(mock_fork_exec.call_args_list[-1].args[-1])
3365+
3366+
@unittest.skipIf(not sysconfig.get_config_var("HAVE_VFORK"),
3367+
"vfork() not enabled by configure.")
3368+
@unittest.skipIf(sys.platform != "linux", "Linux only, requires strace.")
3369+
def test_vfork_used_when_expected(self):
3370+
# This is a performance regression test to ensure we default to using
3371+
# vfork() when possible.
3372+
strace_binary = "/usr/bin/strace"
3373+
# The only system calls we are interested in.
3374+
strace_filter = "--trace=clone,clone2,clone3,fork,vfork,exit,exit_group"
3375+
true_binary = "/bin/true"
3376+
strace_command = [strace_binary, strace_filter]
3377+
3378+
try:
3379+
does_strace_work_process = subprocess.run(
3380+
strace_command + [true_binary],
3381+
stderr=subprocess.PIPE,
3382+
stdout=subprocess.DEVNULL,
3383+
)
3384+
rc = does_strace_work_process.returncode
3385+
stderr = does_strace_work_process.stderr
3386+
except OSError:
3387+
rc = -1
3388+
stderr = ""
3389+
if rc or (b"+++ exited with 0 +++" not in stderr):
3390+
self.skipTest("strace not found or not working as expected.")
3391+
3392+
with self.subTest(name="default_is_vfork"):
3393+
vfork_result = assert_python_ok(
3394+
"-c",
3395+
textwrap.dedent(f"""\
3396+
import subprocess
3397+
subprocess.check_call([{true_binary!r}])"""),
3398+
__run_using_command=strace_command,
3399+
)
3400+
# Match both vfork() and clone(..., flags=...|CLONE_VFORK|...)
3401+
self.assertRegex(vfork_result.err, br"(?i)vfork")
3402+
# Do NOT check that fork() or other clones did not happen.
3403+
# If the OS denys the vfork it'll fallback to plain fork().
3404+
3405+
# Test that each individual thing that would disable the use of vfork
3406+
# actually disables it.
3407+
for sub_name, preamble, sp_kwarg, expect_permission_error in (
3408+
("!use_vfork", "subprocess._USE_VFORK = False", "", False),
3409+
("preexec", "", "preexec_fn=lambda: None", False),
3410+
("setgid", "", f"group={os.getgid()}", True),
3411+
("setuid", "", f"user={os.getuid()}", True),
3412+
("setgroups", "", "extra_groups=[]", True),
3413+
):
3414+
with self.subTest(name=sub_name):
3415+
non_vfork_result = assert_python_ok(
3416+
"-c",
3417+
textwrap.dedent(f"""\
3418+
import subprocess
3419+
{preamble}
3420+
try:
3421+
subprocess.check_call(
3422+
[{true_binary!r}], **dict({sp_kwarg}))
3423+
except PermissionError:
3424+
if not {expect_permission_error}:
3425+
raise"""),
3426+
__run_using_command=strace_command,
3427+
)
3428+
# Ensure neither vfork() or clone(..., flags=...|CLONE_VFORK|...).
3429+
self.assertNotRegex(non_vfork_result.err, br"(?i)vfork")
3430+
33633431

33643432
@unittest.skipUnless(mswindows, "Windows specific tests")
33653433
class Win32ProcessTestCase(BaseTestCase):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Adds a regression test to verify that ``vfork()`` is used when expected by
2+
:mod:`subprocess` on vfork enabled POSIX systems (Linux).

0 commit comments

Comments
 (0)