Make @worker_task workers spawn-safe; default to spawn on all platforms#414
Merged
Conversation
Fixes the macOS failure mode where every @worker_task worker subprocess died with SIGSEGV (exitcode=-11) under the forced 'fork' start method and was silently restarted by the TaskHandler monitor every ~5s, so no task ever completed (issues #264, #271). Worker pickling (required by spawn/forkserver, which pickle Process args): - Worker.api_client is now a lazy property: no eager httpx.Client/TLS/auth in the parent process, and no '_thread.lock' in pickled state - Worker.__getstate__/__setstate__ exclude process-local runtime state (pending-tasks lock, background event loop, pending async futures) and rebuild it in the child - @worker_task-decorated functions pickle as importable references (module, qualname, __wrapped__ depth) resolved in the child, fixing "PicklingError: it's not the same object as module.name" caused by the decorator rebinding the module-level name to its wrapper Start method: - Default is now 'spawn' on all platforms (was fork everywhere except Windows). fork is unsafe with native state: Apple frameworks SIGSEGV on macOS (CPython's own default is spawn there since 3.8, bpo-33725) and fork-with-held-lock deadlocks on POSIX. CONDUCTOR_MP_START_METHOD=fork remains as an escape hatch Diagnostics and robustness: - TaskHandler.start_processes() cleans up already-started subprocesses and raises an actionable error when worker state cannot be pickled (was: the non-daemon logger process kept the interpreter alive forever -> hang) - Worker processes killed by a signal now log the signal number and PYTHONFAULTHANDLER=1 guidance instead of restarting silently Tests: - tests/unit/worker/test_worker_spawn_safety.py: pickle round-trip, identity preservation, async detection, lazy api_client, runtime-state isolation, fail-fast for nested functions, and execution of a Worker in a real spawn child process - tests/unit/worker/test_worker_config_integration.py no longer replaces sys.modules['...task_handler'] with a Mock at import time; that leaked into the whole pytest session and broke pickle-by-reference for every later test that started a real process - examples/spawn_safety_probe.py: standalone before/after probe script
Codecov Report❌ Patch coverage is
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
kowser-orkes
approved these changes
Jul 7, 2026
v1r3n
approved these changes
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On macOS, any worker registered via
@worker_taskcrashes withexitcode=-11(SIGSEGV) at startup under the SDK-forcedforkstart method and is silently restarted byTaskHandler.monitor_processesevery ~5s (the monitor interval), so no task ever completes. The documented escape hatchCONDUCTOR_MP_START_METHOD=spawnfailed immediately withTypeError: cannot pickle '_thread.lock' object— and then hung the interpreter forever (the non-daemon logger subprocess never got its shutdown sentinel).Fixes #264, fixes #271.
Root causes
Three distinct pickling defects blocked
spawn(which picklesProcessargs), verified by attribute-level probing:Worker.__init__eagerly builtApiClient()→httpx.Client+RESTClientObject._reset_lockTypeError: cannot pickle '_thread.lock' objectWorker._pending_tasks_lockrawthreading.Lockon the instance@worker_taskrebinds the module-level name to its wrapper whileWorkerholds the original functionPicklingError: it's not the same object as module.nameThe eager
ApiClientalso performed TLS/auth in the parent before fork() — precisely the native state that makes forked children SIGSEGV on macOS (Apple frameworks; see CPython bpo-33725, which is why CPython itself defaults to spawn on macOS since 3.8). The restart path is worse: the monitor re-forks from a background thread every 5s.Changes
Worker pickling
Worker.api_clientis now a lazy property (created in the process that uses it; setter preserved for injection)__getstate__/__setstate__exclude process-local state (lock, background loop, pending async futures) and rebuild it in the child(module, qualname, __wrapped__ depth)resolved in the child; plain module-level functions keep default pickling; nested functions/lambdas fail fast in the parent with actionable guidanceStart method
spawnon all platforms (was fork everywhere except Windows). This reinstates the direction ofefeabbfa, whose revert reason ("spawn requires all Process arguments to be picklable") is resolved by this PR.CONDUCTOR_MP_START_METHOD=forkremains for deployments relying on copy-on-write inheritanceRobustness/diagnostics
start_processes()cleans up already-started subprocesses on failure and raises with guidance (fixes the hang)PYTHONFAULTHANDLER=1hint instead of restarting silentlydocs/WORKER.mdno longer tells macOS users to forceset_start_method('fork')(that advice reproduced the bug); documents spawn requirements (__main__guard, module-level workers, picklable args)Test hygiene
test_worker_config_integration.pyreplacedsys.modules['...task_handler']with aMockat import time, poisoning the whole pytest session (invisible under fork, fatal under spawn) — removedVerification
examples/spawn_safety_probe.py: before → 3 FAIL (both pickle errors + spawn child failure); after → ALL CHECKS PASSED on Python 3.11.14 and 3.13.7 (macOS 15.1, Apple Silicon)TaskHandlerrepro (worker +MetricsSettings(http_port=...), Orkes-style HTTPS auth config) on Python 3.11: worker pid stable,restart_count=0, metrics provider serving, clean shutdown@worker_taskWorker inside a real spawn child processBehavior change / migration
Linux default changes fork → spawn. Requirements under spawn are the standard multiprocessing rules:
if __name__ == '__main__':guard, module-level workers, picklableTaskHandlerargs. Anything relying on fork COW inheritance: setCONDUCTOR_MP_START_METHOD=fork.Related work
Overlaps with #<branch
fix/worker-task-spawn-safety> (@Kowser) — same diagnosis for the Worker pickling; this PR additionally removes the parent-side TLS-before-fork poison (lazyApiClient), resolves functions via importlib (coversimport_modules=[...]workers whose modules aren't pre-imported in the child), changes the default start method, fixes the start-failure hang, and adds diagnostics. Happy to reconcile the two.