gh-155717: use `spawn` as the default start method for read-only filesystems (#155827)

picnixz <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/c1447994a42004a81a8caca026218daf69d451c8
commit: c1447994a42004a81a8caca026218daf69d451c8
branch: main
author: Bénédikt Tran <[email protected]>
committer: picnixz <[email protected]>
date: 2026-08-16T10:23:02+02:00
summary:

gh-155717: use `spawn` as the default start method for read-only filesystems (#155827)

The "forkserver" start method (the default start method on non-Windows systems)
requires the ability to write temporary files, which is not possible if TMPDIR
is read-only (e.g., k8s containers mounted with `readOnlyRootFilesystem=True`). 

On such filesystems, the default start method changes from "forkserver" to "spawn".

files:
A Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst
M Lib/multiprocessing/context.py
M Lib/multiprocessing/util.py
M Lib/test/_test_multiprocessing.py

diff --git a/Lib/multiprocessing/context.py b/Lib/multiprocessing/context.py
index 45c393798deaca2..e94e6c8690bf506 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 549fb07c27549e0..cf7e0b2990598b7 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 is
+    # called to determine 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 ba1c0de5d283323..4aaaa22f4274f03 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]))
 
+    @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
+        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:
+            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 000000000000000..0994dc8d03051f4
--- /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" <multiprocessing-start-methods>`
+instead of ``"forkserver"``. Patch by Bénédikt Tran.

_______________________________________________
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]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.