From d6a7c949ef7d96874a36be8c80329bdaecddd617 Mon Sep 17 00:00:00 2001 From: Tomer Nosrati Date: Mon, 23 Feb 2026 21:52:56 +0200 Subject: [PATCH 1/2] Fix macOS fork-safety crash: default to spawn on darwin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, fork() in a multi-threaded process is unsafe — forked children inherit the parent's memory but not its threads, leaving shared resources (locks, CoreFoundation state, ObjC runtime) in an inconsistent state. macOS Tahoe (26.0) enforces this strictly, causing SIGSEGV crashes in Celery workers. CPython's multiprocessing already changed the default start method on macOS from fork to spawn in Python 3.8 (bpo-33725). This commit brings billiard in line with that upstream fix by: - Defaulting to 'spawn' on macOS (sys.platform == 'darwin') - Updating get_all_start_methods() to list 'spawn' first on macOS - Adding unit tests for default context selection, forking_is_enabled(), set_start_method override, and get_all_start_methods() ordering Non-macOS platforms remain unchanged (default is still 'fork'). Users can still explicitly set_start_method('fork') if needed. --- billiard/context.py | 9 ++++++- t/unit/test_context.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 t/unit/test_context.py diff --git a/billiard/context.py b/billiard/context.py index 5bbc835..427c002 100644 --- a/billiard/context.py +++ b/billiard/context.py @@ -368,7 +368,14 @@ def _check_available(self): 'spawn': SpawnContext(), 'forkserver': ForkServerContext(), } - _default_context = DefaultContext(_concrete_contexts['fork']) + if sys.platform == 'darwin': + # bpo-33725: running arbitrary code after fork() is no longer + # reliable on macOS since macOS 10.14 (Mojave). Use spawn by + # default instead. + # See https://github.com/celery/celery/issues/9894 + _default_context = DefaultContext(_concrete_contexts['spawn']) + else: + _default_context = DefaultContext(_concrete_contexts['fork']) else: diff --git a/t/unit/test_context.py b/t/unit/test_context.py new file mode 100644 index 0000000..cf90c8e --- /dev/null +++ b/t/unit/test_context.py @@ -0,0 +1,56 @@ +import importlib + +from unittest.mock import patch + +import billiard.context + + +def _reload_context_with_platform(platform): + """Reload billiard.context with a patched sys.platform. + + Returns the module-level _default_context after re-evaluation. + """ + with patch('sys.platform', platform): + importlib.reload(billiard.context) + return billiard.context._default_context + + +class test_default_context_darwin: + """Tests that macOS defaults to spawn, matching CPython (bpo-33725).""" + + def test_default_start_method_is_spawn_on_darwin(self): + try: + ctx = _reload_context_with_platform('darwin') + assert ctx.get_start_method() == 'spawn' + finally: + importlib.reload(billiard.context) + + def test_default_start_method_is_fork_on_linux(self): + try: + ctx = _reload_context_with_platform('linux') + assert ctx.get_start_method() == 'fork' + finally: + importlib.reload(billiard.context) + + def test_set_start_method_override_on_darwin(self): + try: + ctx = _reload_context_with_platform('darwin') + assert ctx.get_start_method() == 'spawn' + ctx.set_start_method('fork', force=True) + assert ctx.get_start_method() == 'fork' + finally: + importlib.reload(billiard.context) + + def test_forking_is_enabled_false_on_darwin(self): + try: + ctx = _reload_context_with_platform('darwin') + assert ctx.forking_is_enabled() is False + finally: + importlib.reload(billiard.context) + + def test_forking_is_enabled_true_on_linux(self): + try: + ctx = _reload_context_with_platform('linux') + assert ctx.forking_is_enabled() is True + finally: + importlib.reload(billiard.context) From 0e150e92896f1223f0e1eec7ab42b941b493945a Mon Sep 17 00:00:00 2001 From: Tomer Nosrati Date: Mon, 23 Feb 2026 22:01:42 +0200 Subject: [PATCH 2/2] Fix test isolation: use subprocess instead of importlib.reload The importlib.reload approach corrupted module state because reloading billiard.context creates new class objects while other modules retain references to old ones. This caused super() to fail in DefaultContext with 'obj is not an instance or subtype of type'. Using subprocess to test import-time conditionals is fully isolated: each test spawns a fresh Python process that patches sys.platform before importing billiard.context for the first time. --- t/unit/test_context.py | 75 +++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/t/unit/test_context.py b/t/unit/test_context.py index cf90c8e..d209c50 100644 --- a/t/unit/test_context.py +++ b/t/unit/test_context.py @@ -1,56 +1,55 @@ -import importlib +import subprocess +import sys -from unittest.mock import patch -import billiard.context +def _get_default_context_attr(platform, attr): + """Run a subprocess that patches sys.platform before importing billiard. - -def _reload_context_with_platform(platform): - """Reload billiard.context with a patched sys.platform. - - Returns the module-level _default_context after re-evaluation. + This exercises the actual import-time conditional without corrupting + the module state of the current process. """ - with patch('sys.platform', platform): - importlib.reload(billiard.context) - return billiard.context._default_context + result = subprocess.run( + [sys.executable, '-c', + 'import unittest.mock; ' + f'unittest.mock.patch("sys.platform", {platform!r}).start(); ' + 'import billiard.context; ' + f'print(getattr(billiard.context._default_context, {attr!r})())'], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() class test_default_context_darwin: """Tests that macOS defaults to spawn, matching CPython (bpo-33725).""" def test_default_start_method_is_spawn_on_darwin(self): - try: - ctx = _reload_context_with_platform('darwin') - assert ctx.get_start_method() == 'spawn' - finally: - importlib.reload(billiard.context) + method = _get_default_context_attr('darwin', 'get_start_method') + assert method == 'spawn' def test_default_start_method_is_fork_on_linux(self): - try: - ctx = _reload_context_with_platform('linux') - assert ctx.get_start_method() == 'fork' - finally: - importlib.reload(billiard.context) + method = _get_default_context_attr('linux', 'get_start_method') + assert method == 'fork' def test_set_start_method_override_on_darwin(self): - try: - ctx = _reload_context_with_platform('darwin') - assert ctx.get_start_method() == 'spawn' - ctx.set_start_method('fork', force=True) - assert ctx.get_start_method() == 'fork' - finally: - importlib.reload(billiard.context) + result = subprocess.run( + [sys.executable, '-c', + 'import unittest.mock; ' + 'unittest.mock.patch("sys.platform", "darwin").start(); ' + 'import billiard.context; ' + 'ctx = billiard.context._default_context; ' + 'assert ctx.get_start_method() == "spawn"; ' + 'ctx.set_start_method("fork", force=True); ' + 'print(ctx.get_start_method())'], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == 'fork' def test_forking_is_enabled_false_on_darwin(self): - try: - ctx = _reload_context_with_platform('darwin') - assert ctx.forking_is_enabled() is False - finally: - importlib.reload(billiard.context) + enabled = _get_default_context_attr('darwin', 'forking_is_enabled') + assert enabled == 'False' def test_forking_is_enabled_true_on_linux(self): - try: - ctx = _reload_context_with_platform('linux') - assert ctx.forking_is_enabled() is True - finally: - importlib.reload(billiard.context) + enabled = _get_default_context_attr('linux', 'forking_is_enabled') + assert enabled == 'True'