|
4 | 4 | import inspect |
5 | 5 | import logging |
6 | 6 | import os |
| 7 | +import pickle |
7 | 8 | import signal |
8 | 9 | import threading |
9 | 10 | import time |
10 | | -from multiprocessing import Process, freeze_support, Queue, set_start_method |
| 11 | +from multiprocessing import Process, freeze_support, Queue, set_start_method, get_start_method |
11 | 12 | from sys import platform |
12 | 13 | from typing import List, Optional, Any, Dict |
13 | 14 |
|
|
37 | 38 | _mp_fork_set = False |
38 | 39 | if not _mp_fork_set: |
39 | 40 | try: |
40 | | - # The prometheus_client library holds a module-level threading lock; |
41 | | - # forking while that lock is held causes a deadlock in child processes. |
42 | | - # Set CONDUCTOR_MP_START_METHOD=spawn to avoid this if you hit the |
43 | | - # deadlock. Default is fork for backward compatibility (spawn requires |
44 | | - # all Process arguments to be picklable). |
45 | | - _default_method = "spawn" if platform == "win32" else "fork" |
| 41 | + # Default start method: "spawn" on every platform. |
| 42 | + # |
| 43 | + # fork() is fundamentally unsafe in a process that holds native |
| 44 | + # framework state or non-main threads: |
| 45 | + # - macOS: Apple frameworks (CFNetwork, Security, libdispatch) may |
| 46 | + # SIGSEGV in forked children, producing silently-restarting worker |
| 47 | + # processes with exitcode=-11. CPython switched its own macOS |
| 48 | + # default to spawn in 3.8 for the same reason (bpo-33725). |
| 49 | + # - all POSIX: forking while another thread holds a lock (e.g. the |
| 50 | + # prometheus_client module-level lock, or the TaskHandler monitor |
| 51 | + # thread restarting a worker) leaves that lock permanently held in |
| 52 | + # the child -> silent deadlock. |
| 53 | + # Workers (including the @worker_task decorator path) are pickle-safe, |
| 54 | + # so spawn works everywhere. Deployments that rely on fork's |
| 55 | + # copy-on-write inheritance can opt back in with |
| 56 | + # CONDUCTOR_MP_START_METHOD=fork (re-exposing the hazards above). |
| 57 | + # NOTE: spawn requires the standard `if __name__ == "__main__":` guard |
| 58 | + # in the entrypoint script. |
| 59 | + _default_method = "spawn" |
46 | 60 | _method = os.environ.get("CONDUCTOR_MP_START_METHOD", "").strip().lower() or _default_method |
47 | 61 | if _method not in _VALID_MP_START_METHODS: |
48 | 62 | logger.warning( |
@@ -341,8 +355,26 @@ def start_processes(self) -> None: |
341 | 355 | logger.info("Starting worker processes...") |
342 | 356 | freeze_support() |
343 | 357 | self._monitor_stop_event.clear() |
344 | | - self.__start_task_runner_processes() |
345 | | - self.__start_metrics_provider_process() |
| 358 | + try: |
| 359 | + self.__start_task_runner_processes() |
| 360 | + self.__start_metrics_provider_process() |
| 361 | + except (TypeError, pickle.PicklingError, AttributeError) as e: |
| 362 | + # Under the 'spawn'/'forkserver' start methods, Process arguments |
| 363 | + # are pickled at start(); unpicklable state fails here. Clean up |
| 364 | + # everything already started - otherwise the non-daemon logger |
| 365 | + # subprocess keeps the interpreter alive forever after the |
| 366 | + # exception (observed as a hang after "cannot pickle |
| 367 | + # '_thread.lock' object"). |
| 368 | + logger.error( |
| 369 | + "Failed to start worker processes with start method %r: %s. " |
| 370 | + "Workers must be defined at module level (not nested inside " |
| 371 | + "functions), and configuration, metrics_settings and " |
| 372 | + "event_listeners must be picklable. " |
| 373 | + "See https://github.com/conductor-oss/conductor-python/issues/264", |
| 374 | + get_start_method(allow_none=True), e, |
| 375 | + ) |
| 376 | + self.stop_processes() |
| 377 | + raise |
346 | 378 | self.__start_monitor_thread() |
347 | 379 | logger.info("Started all processes") |
348 | 380 |
|
@@ -445,6 +477,21 @@ def __check_and_restart_processes(self) -> None: |
445 | 477 | worker = self.workers[i] if i < len(self.workers) else None |
446 | 478 | worker_name = worker.get_task_definition_name() if worker is not None else f"worker[{i}]" |
447 | 479 | logger.warning("Worker process exited (worker=%s, pid=%s, exitcode=%s)", worker_name, process.pid, exitcode) |
| 480 | + if exitcode < 0: |
| 481 | + # Negative exitcode == killed by signal (e.g. -11 is |
| 482 | + # SIGSEGV). Under the 'fork' start method this is the |
| 483 | + # classic symptom of fork-unsafe native libraries in the |
| 484 | + # parent (Apple frameworks on macOS, numpy/Accelerate, |
| 485 | + # grpc, ...). |
| 486 | + logger.warning( |
| 487 | + "Worker process %s was killed by signal %s. If this " |
| 488 | + "repeats on every restart, the process is likely " |
| 489 | + "crashing at startup: use the 'spawn' start method " |
| 490 | + "(CONDUCTOR_MP_START_METHOD=spawn, the default) and " |
| 491 | + "set PYTHONFAULTHANDLER=1 to capture the crashing " |
| 492 | + "stack trace.", |
| 493 | + worker_name, -exitcode, |
| 494 | + ) |
448 | 495 | if not self.restart_on_failure: |
449 | 496 | continue |
450 | 497 | self.__restart_worker_process(i) |
|
0 commit comments