From 13a942577830ff6eaa30466b2abc3bd34ccc94bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:30:37 +0200 Subject: [PATCH 1/4] gh-155717: use `spawn` as the default start method for read-only filesystems --- Lib/multiprocessing/context.py | 8 ++++- Lib/multiprocessing/util.py | 31 ++++++++++++++-- Lib/test/_test_multiprocessing.py | 35 +++++++++++++++++++ ...-08-15-09-47-23.gh-issue-155717.jWFLR2.rst | 3 ++ 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst diff --git a/Lib/multiprocessing/context.py b/Lib/multiprocessing/context.py index 45c393798deaca..e94e6c8690bf50 100644 --- a/Lib/multiprocessing/context.py +++ b/Lib/multiprocessing/context.py @@ -4,6 +4,7 @@ from . import process from . import reduction +from . import util __all__ = () @@ -333,7 +334,12 @@ def _check_available(self): # bpo-33725: running arbitrary code after fork() is no longer reliable # on macOS since macOS 10.14 (Mojave). Use spawn by default instead. # gh-84559: We changed everyones default to a thread safeish one in 3.14. - if reduction.HAVE_SEND_HANDLE and sys.platform != 'darwin': + if ( + reduction.HAVE_SEND_HANDLE + and sys.platform != 'darwin' + # gh-155717: forkserver requires to write temporary files + and util._has_writeable_tempdir() + ): _default_context = DefaultContext(_concrete_contexts['forkserver']) else: _default_context = DefaultContext(_concrete_contexts['spawn']) diff --git a/Lib/multiprocessing/util.py b/Lib/multiprocessing/util.py index 549fb07c27549e..f54df3a5c46406 100644 --- a/Lib/multiprocessing/util.py +++ b/Lib/multiprocessing/util.py @@ -10,6 +10,7 @@ import os import itertools import sys +import tempfile import weakref import atexit import threading # we want threading to install it's @@ -143,6 +144,7 @@ def is_abstract_socket_namespace(address): # On Windows platforms, we do not create AF_UNIX sockets. _SUN_PATH_MAX = None if os.name == 'nt' else 92 + def _remove_temp_dir(rmtree, tempdir): rmtree(tempdir) @@ -152,7 +154,8 @@ def _remove_temp_dir(rmtree, tempdir): if current_process is not None: current_process._config['tempdir'] = None -def _get_base_temp_dir(tempfile): + +def _get_base_temp_dir(): """Get a temporary directory where socket files will be created. To prevent additional imports, pass a pre-imported 'tempfile' module. @@ -208,12 +211,13 @@ def _get_base_temp_dir(tempfile): assert len(base_system_tempdir) + 14 + 14 < _SUN_PATH_MAX return base_system_tempdir + def get_temp_dir(): # get name of a temp directory which will be automatically cleaned up tempdir = process.current_process()._config.get('tempdir') if tempdir is None: - import shutil, tempfile - base_tempdir = _get_base_temp_dir(tempfile) + import shutil + base_tempdir = _get_base_temp_dir() tempdir = tempfile.mkdtemp(prefix='pymp-', dir=base_tempdir) info('created temp directory %s', tempdir) # keep a strong reference to shutil.rmtree(), since the finalizer @@ -223,6 +227,27 @@ def get_temp_dir(): process.current_process()._config['tempdir'] = tempdir return tempdir + +def _has_writeable_tempdir(): + # 'forkserver' requires writeable temporary files. This function must + # is called for defining the default context's start method. + # + # See: https://github.com/python/cpython/issues/155717. + + path = _get_base_temp_dir() + if path is None: + return False + + # os.access() is advisory and racy. It can lie on read-only filesystems, + # NFS/network mounts, containers, and immutable-flag files, so we simply + # try to create a file to check if this works and delete it otherwise. + try: + with tempfile.NamedTemporaryFile(dir=path): + return True + except OSError: + return False + + # # Support for reinitialization of objects when bootstrapping a child process # diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index ba1c0de5d28332..af1dd149f5e6be 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -26,6 +26,7 @@ import struct import tempfile import operator +import pathlib import pickle import weakref import warnings @@ -6355,6 +6356,40 @@ def test_nested_startmethod(self): # there is no synchronization in the test. self.assertSetEqual(set(results), set([2, 1])) + @support.subTests("mode", [ + os.R_OK, # read-only directory + os.R_OK | os.X_OK, # read-only directory + os.W_OK # write-only directory _without_ permissions for creating files + ]) + def test_forkserver_requires_writeable_tempdir(self, mode): + # Regression test to ensure that the defualt start method is + # not 'forkserver' when the temporary directory is not writeable. + # + # See https://github.com/python/cpython/issues/155717. + + cmd = '''if 1: + import os, tempfile + # We fake the read-onlyiness of /tmp (which is a fallback when + # the user-defined TMPDIR is not acceptable) by hardcoding the + # temporary directory for this specific test. + tempfile.tempdir = os.environ["TMPDIR"] + + # Imported after patching 'tempfile' so that the default start + # method is deduced according to the permissions of TMPDIR. + import multiprocessing + if __name__ == "__main__": + print(multiprocessing.get_start_method()) + ''' + + with support.os_helper.temp_dir() as root: + # read-only directory + TMPDIR = pathlib.Path(root, "TMPDIR") + TMPDIR.mkdir(mode=mode) + file = pathlib.Path(TMPDIR, "file") + self.assertRaises(OSError, file.touch) + _, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR) + self.assertEqual(out.decode().strip(), "spawn") + @unittest.skipIf(sys.platform == "win32", "test semantics don't make sense on Windows") diff --git a/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst b/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst new file mode 100644 index 00000000000000..0994dc8d03051f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst @@ -0,0 +1,3 @@ +:mod:`multiprocessing`'s default start method on systems with non-writeable +tempfile filesystem is now :ref:`"spawn" ` +instead of ``"forkserver"``. Patch by Bénédikt Tran. From ffc128ae95a0ea28d8f73218dfa5b6bb27729b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:46:51 +0200 Subject: [PATCH 2/4] Update Lib/test/_test_multiprocessing.py --- Lib/test/_test_multiprocessing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index af1dd149f5e6be..b635e8a740a708 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -6382,7 +6382,6 @@ def test_forkserver_requires_writeable_tempdir(self, mode): ''' with support.os_helper.temp_dir() as root: - # read-only directory TMPDIR = pathlib.Path(root, "TMPDIR") TMPDIR.mkdir(mode=mode) file = pathlib.Path(TMPDIR, "file") From 04dc83f6f6eaf94155bc6d2873c3ce683c521ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:50:20 +0200 Subject: [PATCH 3/4] Update Lib/test/_test_multiprocessing.py --- Lib/test/_test_multiprocessing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index b635e8a740a708..c4b5dbe2016c1f 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -6356,6 +6356,7 @@ def test_nested_startmethod(self): # there is no synchronization in the test. self.assertSetEqual(set(results), set([2, 1])) + @unittest.skipIf(os.name != "nt", "requires POSIX") @support.subTests("mode", [ os.R_OK, # read-only directory os.R_OK | os.X_OK, # read-only directory From 570fcebabde6e87c2b1255ceb05bcc970e0c5934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:50:43 +0200 Subject: [PATCH 4/4] Update Lib/test/_test_multiprocessing.py --- Lib/test/_test_multiprocessing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index c4b5dbe2016c1f..4aaaa22f4274f0 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -6356,7 +6356,7 @@ def test_nested_startmethod(self): # there is no synchronization in the test. self.assertSetEqual(set(results), set([2, 1])) - @unittest.skipIf(os.name != "nt", "requires POSIX") + @unittest.skipIf(os.name == "nt", "requires POSIX") @support.subTests("mode", [ os.R_OK, # read-only directory os.R_OK | os.X_OK, # read-only directory