https://github.com/python/cpython/commit/c879b2a7a52549dd310aa1cca1a00ff8f36a25ba
commit: c879b2a7a52549dd310aa1cca1a00ff8f36a25ba
branch: main
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-01-18T14:04:18-08:00
summary:
gh-141860: Add on_error= keyword arg to
`multiprocessing.set_forkserver_preload` (GH-141859)
Add a keyword-only `on_error` parameter to
`multiprocessing.set_forkserver_preload()`. This allows the user to have
exceptions during optional `forkserver` start method module preloading cause
the forkserver subprocess to warn (generally to stderr) or exit with an error
(preventing use of the forkserver) instead of being silently ignored.
This _also_ fixes an oversight, errors when preloading a `__main__` module are
now treated the similarly. Those would always raise unlike other modules in
preload, but that had gone unnoticed as up until bug fix PR GH-135295 in 3.14.1
and 3.13.8, the `__main__` module was never actually preloaded.
Based on original work by Nick Neumann @aggieNick02 in GH-99515.
files:
A Lib/test/test_multiprocessing_forkserver/test_preload.py
A Misc/NEWS.d/next/Library/2025-11-22-20-30-00.gh-issue-141860.frksvr.rst
M Doc/library/multiprocessing.rst
M Lib/multiprocessing/context.py
M Lib/multiprocessing/forkserver.py
M Lib/test/test_multiprocessing_forkserver/__init__.py
M Misc/ACKS
diff --git a/Doc/library/multiprocessing.rst b/Doc/library/multiprocessing.rst
index 88813c6f1a4bbf..b158ee1d42c774 100644
--- a/Doc/library/multiprocessing.rst
+++ b/Doc/library/multiprocessing.rst
@@ -1234,22 +1234,32 @@ Miscellaneous
.. versionchanged:: 3.11
Accepts a :term:`path-like object`.
-.. function:: set_forkserver_preload(module_names)
+.. function:: set_forkserver_preload(module_names, *, on_error='ignore')
Set a list of module names for the forkserver main process to attempt to
import so that their already imported state is inherited by forked
- processes. Any :exc:`ImportError` when doing so is silently ignored.
- This can be used as a performance enhancement to avoid repeated work
- in every process.
+ processes. This can be used as a performance enhancement to avoid repeated
+ work in every process.
For this to work, it must be called before the forkserver process has been
launched (before creating a :class:`Pool` or starting a :class:`Process`).
+ The *on_error* parameter controls how :exc:`ImportError` exceptions during
+ module preloading are handled: ``"ignore"`` (default) silently ignores
+ failures, ``"warn"`` causes the forkserver subprocess to emit an
+ :exc:`ImportWarning` to stderr, and ``"fail"`` causes the forkserver
+ subprocess to exit with the exception traceback on stderr, making
+ subsequent process creation fail with :exc:`EOFError` or
+ :exc:`ConnectionError`.
+
Only meaningful when using the ``'forkserver'`` start method.
See :ref:`multiprocessing-start-methods`.
.. versionadded:: 3.4
+ .. versionchanged:: next
+ Added the *on_error* parameter.
+
.. function:: set_start_method(method, force=False)
Set the method which should be used to start child processes.
diff --git a/Lib/multiprocessing/context.py b/Lib/multiprocessing/context.py
index 051d567d457928..a73261cde856bb 100644
--- a/Lib/multiprocessing/context.py
+++ b/Lib/multiprocessing/context.py
@@ -177,12 +177,15 @@ def set_executable(self, executable):
from .spawn import set_executable
set_executable(executable)
- def set_forkserver_preload(self, module_names):
+ def set_forkserver_preload(self, module_names, *, on_error='ignore'):
'''Set list of module names to try to load in forkserver process.
- This is really just a hint.
+
+ The on_error parameter controls how import failures are handled:
+ "ignore" (default) silently ignores failures, "warn" emits warnings,
+ and "fail" raises exceptions breaking the forkserver context.
'''
from .forkserver import set_forkserver_preload
- set_forkserver_preload(module_names)
+ set_forkserver_preload(module_names, on_error=on_error)
def get_context(self, method=None):
if method is None:
diff --git a/Lib/multiprocessing/forkserver.py
b/Lib/multiprocessing/forkserver.py
index 15c455a598dc27..d89b24ac59bec0 100644
--- a/Lib/multiprocessing/forkserver.py
+++ b/Lib/multiprocessing/forkserver.py
@@ -42,6 +42,7 @@ def __init__(self):
self._inherited_fds = None
self._lock = threading.Lock()
self._preload_modules = ['__main__']
+ self._preload_on_error = 'ignore'
def _stop(self):
# Method used by unit tests to stop the server
@@ -64,11 +65,22 @@ def _stop_unlocked(self):
self._forkserver_address = None
self._forkserver_authkey = None
- def set_forkserver_preload(self, modules_names):
- '''Set list of module names to try to load in forkserver process.'''
+ def set_forkserver_preload(self, modules_names, *, on_error='ignore'):
+ '''Set list of module names to try to load in forkserver process.
+
+ The on_error parameter controls how import failures are handled:
+ "ignore" (default) silently ignores failures, "warn" emits warnings,
+ and "fail" raises exceptions breaking the forkserver context.
+ '''
if not all(type(mod) is str for mod in modules_names):
raise TypeError('module_names must be a list of strings')
+ if on_error not in ('ignore', 'warn', 'fail'):
+ raise ValueError(
+ f"on_error must be 'ignore', 'warn', or 'fail', "
+ f"not {on_error!r}"
+ )
self._preload_modules = modules_names
+ self._preload_on_error = on_error
def get_inherited_fds(self):
'''Return list of fds inherited from parent process.
@@ -107,6 +119,14 @@ def connect_to_new_process(self, fds):
wrapped_client, self._forkserver_authkey)
connection.deliver_challenge(
wrapped_client, self._forkserver_authkey)
+ except (EOFError, ConnectionError, BrokenPipeError) as exc:
+ if (self._preload_modules and
+ self._preload_on_error == 'fail'):
+ exc.add_note(
+ "Forkserver process may have crashed during module
"
+ "preloading. Check stderr."
+ )
+ raise
finally:
wrapped_client._detach()
del wrapped_client
@@ -154,6 +174,8 @@ def ensure_running(self):
main_kws['main_path'] = data['init_main_from_path']
if 'sys_argv' in data:
main_kws['sys_argv'] = data['sys_argv']
+ if self._preload_on_error != 'ignore':
+ main_kws['on_error'] = self._preload_on_error
with socket.socket(socket.AF_UNIX) as listener:
address = connection.arbitrary_address('AF_UNIX')
@@ -198,8 +220,69 @@ def ensure_running(self):
#
#
+def _handle_import_error(on_error, modinfo, exc, *, warn_stacklevel):
+ """Handle an import error according to the on_error policy."""
+ match on_error:
+ case 'fail':
+ raise
+ case 'warn':
+ warnings.warn(
+ f"Failed to preload {modinfo}: {exc}",
+ ImportWarning,
+ stacklevel=warn_stacklevel + 1
+ )
+ case 'ignore':
+ pass
+
+
+def _handle_preload(preload, main_path=None, sys_path=None, sys_argv=None,
+ on_error='ignore'):
+ """Handle module preloading with configurable error handling.
+
+ Args:
+ preload: List of module names to preload.
+ main_path: Path to __main__ module if '__main__' is in preload.
+ sys_path: sys.path to use for imports (None means use current).
+ sys_argv: sys.argv to use (None means use current).
+ on_error: How to handle import errors ("ignore", "warn", or "fail").
+ """
+ if not preload:
+ return
+
+ if sys_argv is not None:
+ sys.argv[:] = sys_argv
+ if sys_path is not None:
+ sys.path[:] = sys_path
+
+ if '__main__' in preload and main_path is not None:
+ process.current_process()._inheriting = True
+ try:
+ spawn.import_main_path(main_path)
+ except Exception as e:
+ # Catch broad Exception because import_main_path() uses
+ # runpy.run_path() which executes the script and can raise
+ # any exception, not just ImportError
+ _handle_import_error(
+ on_error, f"__main__ from {main_path!r}", e, warn_stacklevel=2
+ )
+ finally:
+ del process.current_process()._inheriting
+
+ for modname in preload:
+ try:
+ __import__(modname)
+ except ImportError as e:
+ _handle_import_error(
+ on_error, f"module {modname!r}", e, warn_stacklevel=2
+ )
+
+ # gh-135335: flush stdout/stderr in case any of the preloaded modules
+ # wrote to them, otherwise children might inherit buffered data
+ util._flush_std_streams()
+
+
def main(listener_fd, alive_r, preload, main_path=None, sys_path=None,
- *, sys_argv=None, authkey_r=None):
+ *, sys_argv=None, authkey_r=None, on_error='ignore'):
"""Run forkserver."""
if authkey_r is not None:
try:
@@ -210,26 +293,7 @@ def main(listener_fd, alive_r, preload, main_path=None,
sys_path=None,
else:
authkey = b''
- if preload:
- if sys_argv is not None:
- sys.argv[:] = sys_argv
- if sys_path is not None:
- sys.path[:] = sys_path
- if '__main__' in preload and main_path is not None:
- process.current_process()._inheriting = True
- try:
- spawn.import_main_path(main_path)
- finally:
- del process.current_process()._inheriting
- for modname in preload:
- try:
- __import__(modname)
- except ImportError:
- pass
-
- # gh-135335: flush stdout/stderr in case any of the preloaded modules
- # wrote to them, otherwise children might inherit buffered data
- util._flush_std_streams()
+ _handle_preload(preload, main_path, sys_path, sys_argv, on_error)
util._close_stdin()
diff --git a/Lib/test/test_multiprocessing_forkserver/__init__.py
b/Lib/test/test_multiprocessing_forkserver/__init__.py
index d91715a344dfa7..7b1b884ab297b5 100644
--- a/Lib/test/test_multiprocessing_forkserver/__init__.py
+++ b/Lib/test/test_multiprocessing_forkserver/__init__.py
@@ -9,5 +9,8 @@
if sys.platform == "win32":
raise unittest.SkipTest("forkserver is not available on Windows")
+if not support.has_fork_support:
+ raise unittest.SkipTest("requires working os.fork()")
+
def load_tests(*args):
return support.load_package_tests(os.path.dirname(__file__), *args)
diff --git a/Lib/test/test_multiprocessing_forkserver/test_preload.py
b/Lib/test/test_multiprocessing_forkserver/test_preload.py
new file mode 100644
index 00000000000000..f8e119aa367637
--- /dev/null
+++ b/Lib/test/test_multiprocessing_forkserver/test_preload.py
@@ -0,0 +1,230 @@
+"""Tests for forkserver preload functionality."""
+
+import contextlib
+import multiprocessing
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from multiprocessing import forkserver, spawn
+
+
+class TestForkserverPreload(unittest.TestCase):
+ """Tests for forkserver preload functionality."""
+
+ def setUp(self):
+ self._saved_warnoptions = sys.warnoptions.copy()
+ # Remove warning options that would convert ImportWarning to errors:
+ # - 'error' converts all warnings to errors
+ # - 'error::ImportWarning' specifically converts ImportWarning
+ # Keep other specific options like 'error::BytesWarning' that
+ # subprocess's _args_from_interpreter_flags() expects to remove
+ sys.warnoptions[:] = [
+ opt for opt in sys.warnoptions
+ if opt not in ('error', 'error::ImportWarning')
+ ]
+ self.ctx = multiprocessing.get_context('forkserver')
+ forkserver._forkserver._stop()
+
+ def tearDown(self):
+ sys.warnoptions[:] = self._saved_warnoptions
+ forkserver._forkserver._stop()
+
+ @staticmethod
+ def _send_value(conn, value):
+ """Send value through connection. Static method to be picklable as
Process target."""
+ conn.send(value)
+
+ @contextlib.contextmanager
+ def capture_forkserver_stderr(self):
+ """Capture stderr from forkserver by preloading a module that
redirects it.
+
+ Yields (module_name, capture_file_path). The capture file can be read
+ after the forkserver has processed preloads. This works because
+ forkserver.main() calls util._flush_std_streams() after preloading,
+ ensuring captured output is written before we read it.
+ """
+ tmpdir = tempfile.mkdtemp()
+ capture_module = os.path.join(tmpdir, '_capture_stderr.py')
+ capture_file = os.path.join(tmpdir, 'stderr.txt')
+ try:
+ with open(capture_module, 'w') as f:
+ # Use line buffering (buffering=1) to ensure warnings are
written.
+ # Enable ImportWarning since it's ignored by default.
+ f.write(
+ f'import sys, warnings; '
+ f'sys.stderr = open({capture_file!r}, "w", buffering=1); '
+ f'warnings.filterwarnings("always",
category=ImportWarning)\n'
+ )
+ sys.path.insert(0, tmpdir)
+ yield '_capture_stderr', capture_file
+ finally:
+ sys.path.remove(tmpdir)
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+ def test_preload_on_error_ignore_default(self):
+ """Test that invalid modules are silently ignored by default."""
+ self.ctx.set_forkserver_preload(['nonexistent_module_xyz'])
+
+ r, w = self.ctx.Pipe(duplex=False)
+ p = self.ctx.Process(target=self._send_value, args=(w, 42))
+ p.start()
+ w.close()
+ result = r.recv()
+ r.close()
+ p.join()
+
+ self.assertEqual(result, 42)
+ self.assertEqual(p.exitcode, 0)
+
+ def test_preload_on_error_ignore_explicit(self):
+ """Test that invalid modules are silently ignored with
on_error='ignore'."""
+ self.ctx.set_forkserver_preload(['nonexistent_module_xyz'],
on_error='ignore')
+
+ r, w = self.ctx.Pipe(duplex=False)
+ p = self.ctx.Process(target=self._send_value, args=(w, 99))
+ p.start()
+ w.close()
+ result = r.recv()
+ r.close()
+ p.join()
+
+ self.assertEqual(result, 99)
+ self.assertEqual(p.exitcode, 0)
+
+ def test_preload_on_error_warn(self):
+ """Test that invalid modules emit warnings with on_error='warn'."""
+ with self.capture_forkserver_stderr() as (capture_mod, stderr_file):
+ self.ctx.set_forkserver_preload(
+ [capture_mod, 'nonexistent_module_xyz'], on_error='warn')
+
+ r, w = self.ctx.Pipe(duplex=False)
+ p = self.ctx.Process(target=self._send_value, args=(w, 123))
+ p.start()
+ w.close()
+ result = r.recv()
+ r.close()
+ p.join()
+
+ self.assertEqual(result, 123)
+ self.assertEqual(p.exitcode, 0)
+
+ with open(stderr_file) as f:
+ stderr_output = f.read()
+ self.assertIn('nonexistent_module_xyz', stderr_output)
+ self.assertIn('ImportWarning', stderr_output)
+
+ def test_preload_on_error_fail_breaks_context(self):
+ """Test that invalid modules with on_error='fail' breaks the
forkserver."""
+ with self.capture_forkserver_stderr() as (capture_mod, stderr_file):
+ self.ctx.set_forkserver_preload(
+ [capture_mod, 'nonexistent_module_xyz'], on_error='fail')
+
+ r, w = self.ctx.Pipe(duplex=False)
+ try:
+ p = self.ctx.Process(target=self._send_value, args=(w, 42))
+ with self.assertRaises((EOFError, ConnectionError,
BrokenPipeError)) as cm:
+ p.start()
+ notes = getattr(cm.exception, '__notes__', [])
+ self.assertTrue(notes, "Expected exception to have __notes__")
+ self.assertIn('Forkserver process may have crashed', notes[0])
+
+ with open(stderr_file) as f:
+ stderr_output = f.read()
+ self.assertIn('nonexistent_module_xyz', stderr_output)
+ self.assertIn('ModuleNotFoundError', stderr_output)
+ finally:
+ w.close()
+ r.close()
+
+ def test_preload_valid_modules_with_on_error_fail(self):
+ """Test that valid modules work fine with on_error='fail'."""
+ self.ctx.set_forkserver_preload(['os', 'sys'], on_error='fail')
+
+ r, w = self.ctx.Pipe(duplex=False)
+ p = self.ctx.Process(target=self._send_value, args=(w, 'success'))
+ p.start()
+ w.close()
+ result = r.recv()
+ r.close()
+ p.join()
+
+ self.assertEqual(result, 'success')
+ self.assertEqual(p.exitcode, 0)
+
+ def test_preload_invalid_on_error_value(self):
+ """Test that invalid on_error values raise ValueError."""
+ with self.assertRaises(ValueError) as cm:
+ self.ctx.set_forkserver_preload(['os'], on_error='invalid')
+ self.assertIn("on_error must be 'ignore', 'warn', or 'fail'",
str(cm.exception))
+
+
+class TestHandlePreload(unittest.TestCase):
+ """Unit tests for _handle_preload() function."""
+
+ def setUp(self):
+ self._saved_main = sys.modules['__main__']
+
+ def tearDown(self):
+ spawn.old_main_modules.clear()
+ sys.modules['__main__'] = self._saved_main
+
+ def test_handle_preload_main_on_error_fail(self):
+ """Test that __main__ import failures raise with on_error='fail'."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f:
+ f.write('raise RuntimeError("test error in __main__")\n')
+ f.flush()
+ with self.assertRaises(RuntimeError) as cm:
+ forkserver._handle_preload(['__main__'], main_path=f.name,
on_error='fail')
+ self.assertIn("test error in __main__", str(cm.exception))
+
+ def test_handle_preload_main_on_error_warn(self):
+ """Test that __main__ import failures warn with on_error='warn'."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f:
+ f.write('raise ImportError("test import error")\n')
+ f.flush()
+ with self.assertWarns(ImportWarning) as cm:
+ forkserver._handle_preload(['__main__'], main_path=f.name,
on_error='warn')
+ self.assertIn("Failed to preload __main__", str(cm.warning))
+ self.assertIn("test import error", str(cm.warning))
+
+ def test_handle_preload_main_on_error_ignore(self):
+ """Test that __main__ import failures are ignored with
on_error='ignore'."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f:
+ f.write('raise ImportError("test import error")\n')
+ f.flush()
+ forkserver._handle_preload(['__main__'], main_path=f.name,
on_error='ignore')
+
+ def test_handle_preload_main_valid(self):
+ """Test that valid __main__ preload works."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f:
+ f.write('test_var = 42\n')
+ f.flush()
+ forkserver._handle_preload(['__main__'], main_path=f.name,
on_error='fail')
+
+ def test_handle_preload_module_on_error_fail(self):
+ """Test that module import failures raise with on_error='fail'."""
+ with self.assertRaises(ModuleNotFoundError):
+ forkserver._handle_preload(['nonexistent_test_module_xyz'],
on_error='fail')
+
+ def test_handle_preload_module_on_error_warn(self):
+ """Test that module import failures warn with on_error='warn'."""
+ with self.assertWarns(ImportWarning) as cm:
+ forkserver._handle_preload(['nonexistent_test_module_xyz'],
on_error='warn')
+ self.assertIn("Failed to preload module", str(cm.warning))
+
+ def test_handle_preload_module_on_error_ignore(self):
+ """Test that module import failures are ignored with
on_error='ignore'."""
+ forkserver._handle_preload(['nonexistent_test_module_xyz'],
on_error='ignore')
+
+ def test_handle_preload_combined(self):
+ """Test preloading both __main__ and modules."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f:
+ f.write('import sys\n')
+ f.flush()
+ forkserver._handle_preload(['__main__', 'os', 'sys'],
main_path=f.name, on_error='fail')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/Misc/ACKS b/Misc/ACKS
index 49a8deb30fc83c..feb16a62792e7f 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1340,6 +1340,7 @@ Trent Nelson
Andrew Nester
Osvaldo Santana Neto
Chad Netzer
+Nick Neumann
Max Neunhöffer
Anthon van der Neut
George Neville-Neil
diff --git
a/Misc/NEWS.d/next/Library/2025-11-22-20-30-00.gh-issue-141860.frksvr.rst
b/Misc/NEWS.d/next/Library/2025-11-22-20-30-00.gh-issue-141860.frksvr.rst
new file mode 100644
index 00000000000000..b1efd9c014f1f4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-11-22-20-30-00.gh-issue-141860.frksvr.rst
@@ -0,0 +1,5 @@
+Add an ``on_error`` keyword-only parameter to
+:func:`multiprocessing.set_forkserver_preload` to control how import failures
+during module preloading are handled. Accepts ``'ignore'`` (default, silent),
+``'warn'`` (emit :exc:`ImportWarning`), or ``'fail'`` (raise exception).
+Contributed by Nick Neumann and Gregory P. Smith.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]