diff --git a/Lib/concurrent/futures/process.py b/Lib/concurrent/futures/process.py index c130259acb737ab..5ff660b89b2615d 100644 --- a/Lib/concurrent/futures/process.py +++ b/Lib/concurrent/futures/process.py @@ -707,8 +707,9 @@ def _check_system_limits(): raise NotImplementedError(_system_limited) try: nsems_max = os.sysconf("SC_SEM_NSEMS_MAX") - except (AttributeError, ValueError): - # sysconf not available or setting not available + except (AttributeError, ValueError, OSError): + # sysconf not available, setting not available, or the read was + # denied (e.g. a sandbox profile that denies sysctl reads on macOS) return if nsems_max == -1: # indetermined limit, assume that limit is determined diff --git a/Lib/test/test_concurrent_futures/test_process_pool.py b/Lib/test/test_concurrent_futures/test_process_pool.py index dafbda862c51c24..052064d97f19e08 100644 --- a/Lib/test/test_concurrent_futures/test_process_pool.py +++ b/Lib/test/test_concurrent_futures/test_process_pool.py @@ -8,6 +8,7 @@ import unittest.mock import weakref from concurrent import futures +from concurrent.futures import process as futures_process from concurrent.futures.process import BrokenProcessPool from test import support @@ -543,6 +544,48 @@ def test_force_shutdown_workers_stops_pool(self, function_name): break +@unittest.skipUnless(hasattr(os, "sysconf"), "requires os.sysconf()") +class CheckSystemLimitsTest(unittest.TestCase): + # gh-155912: a denied sysconf read is the same condition as an + # unavailable one and must not be fatal. + + def setUp(self): + support.skip_if_broken_multiprocessing_synchronize() + saved = (futures_process._system_limits_checked, + futures_process._system_limited) + + def restore(): + (futures_process._system_limits_checked, + futures_process._system_limited) = saved + + self.addCleanup(restore) + + def check(self, **kwargs): + futures_process._system_limits_checked = False + futures_process._system_limited = None + with unittest.mock.patch.object(os, "sysconf", **kwargs): + futures_process._check_system_limits() + + def test_denied_read_is_not_fatal(self): + self.check(side_effect=PermissionError(1, "Operation not permitted")) + + def test_other_oserror_is_not_fatal(self): + self.check(side_effect=OSError(5, "Input/output error")) + + def test_unsupported_name_is_not_fatal(self): + self.check(side_effect=ValueError) + + def test_indeterminate_limit_is_not_fatal(self): + self.check(return_value=-1) + + def test_sufficient_semaphores(self): + self.check(return_value=87381) + + def test_too_few_semaphores_still_raises(self): + with self.assertRaises(NotImplementedError): + self.check(return_value=10) + + create_executor_tests(globals(), ProcessPoolExecutorTest, executor_mixins=(ProcessPoolForkMixin, ProcessPoolForkserverMixin, diff --git a/Misc/NEWS.d/next/Library/2026-08-23-14-54-39.gh-issue-155912.Hk7Qa2.rst b/Misc/NEWS.d/next/Library/2026-08-23-14-54-39.gh-issue-155912.Hk7Qa2.rst new file mode 100644 index 000000000000000..b51f0590beecf19 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-23-14-54-39.gh-issue-155912.Hk7Qa2.rst @@ -0,0 +1,3 @@ +Fix :class:`concurrent.futures.ProcessPoolExecutor` construction when +:func:`os.sysconf` raises :exc:`OSError`, for instance when a sandbox denies +the read.