Repository navigation
Description
Describe your environment
OS: Debian (python:3.12-slim, python:3.14-slim containers), also Alpine 3.24
Python version: 3.12, 3.14
SDK version: 1.44.0, 1.45.1 (1.43.0 is not affected)
API version: same as SDK
What happened?
Since 1.44.0 (#5280) TracerProvider registers an after_in_child fork handler that calls _get_process_dependent_resource(), which goes through get_aggregated_resources() and ThreadPoolExecutor.submit(). submit() takes concurrent.futures.thread._global_shutdown_lock.
concurrent.futures.thread registers its own fork handlers when it is imported: before=_global_shutdown_lock.acquire, after_in_child=_global_shutdown_lock._at_fork_reinit. before handlers run in reverse registration order and after_in_child handlers in registration order. So if concurrent.futures.thread is imported for the first time after the TracerProvider was created, the child runs the SDK's handler while the lock is still held by the before handler. submit() waits forever and the handler that would reset the lock never runs.
Python only runs fork handlers in the child of subprocess.Popen when a preexec_fn is given, so every such Popen hangs: the child never execs and the parent blocks reading the exec error pipe. This is deterministic, not a race, and independent of the threading model (we first saw it under eventlet, but it reproduces the same way with plain threads).
concurrent.futures.thread is imported lazily (concurrent.futures.ThreadPoolExecutor is resolved through __getattr__), and nothing in TracerProvider.__init__ imports it when the application passes its own resource= (so Resource.create() is never called). OpenStack's osprofiler does exactly that in its OTLP driver; the first processutils.execute() afterwards (it always passes a preexec_fn) hung a Cinder volume service in production.
This is related to but different from #5533: there the child blocks on _service_instance_id_lock inside a detector; here it blocks before any detector runs, in submit() itself, so the changes in #5545 and #5706 (how the executor is shut down) do not affect it.
Steps to Reproduce
import signal
import subprocess
import sys
import threading
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
# An application that passes its own resource, so Resource.create() (which
# would import concurrent.futures.thread) is never called.
provider = TracerProvider(resource=Resource({"service.name": "repro"}))
# concurrent.futures.thread is imported for the first time after the
# provider registered its at-fork hook.
import concurrent.futures.thread # noqa: E402,F401
def spawn():
# Any fork that runs at-fork hooks in the child, e.g. a preexec_fn.
proc = subprocess.Popen(
["true"], preexec_fn=lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL)
)
proc.wait()
t = threading.Thread(target=spawn, daemon=True)
t.start()
t.join(10)
print("HUNG: child never exec'd" if t.is_alive() else "ok")
sys.exit(1 if t.is_alive() else 0)pip install opentelemetry-sdk==1.45.1 && python repro.py
| Python 3.12 | Python 3.14 | |
|---|---|---|
| sdk 1.43.0 | ok | ok |
| sdk 1.44.0 | HUNG | HUNG |
| sdk 1.45.1 | HUNG | HUNG |
With 1.45.1 the script passes if the Popen has no preexec_fn, or if concurrent.futures.thread is imported before the TracerProvider is created.
Expected Result
Creating a TracerProvider does not make later forks of the process hang.
Actual Result
The forked child is stuck in the SDK's after_in_child handler forever. Stack of the stuck child (from oslo.reports' Guru Meditation Report, triggered with SIGUSR2):
subprocess.py _execute_child: self.pid = _fork_exec(
opentelemetry/sdk/trace/__init__.py _after_in_child: at_fork()
opentelemetry/sdk/trace/__init__.py _handle_fork: self._update_resource(_get_process_dependent_resource())
opentelemetry/sdk/resources/__init__.py _get_process_dependent_resource: return get_aggregated_resources(
opentelemetry/sdk/resources/__init__.py get_aggregated_resources: futures = [executor.submit(detector.detect) for detector in detectors]
concurrent/futures/thread.py submit: with self._shutdown_lock, _global_shutdown_lock:
Additional context
Possible fixes, any of which would avoid the ordering problem:
- import
concurrent.futures.threadat module level inopentelemetry.sdk.resources(or wherever the fork handler is registered), so its handlers are registered first; - run the process-dependent detectors synchronously in the fork handler instead of on a thread pool: the child of a fork before exec is a poor place to start threads anyway (Blocked resource detector can wedge forked children: get_aggregated_resources timeout does not bound the wait #5533 shows another way this goes wrong).
As a workaround we import concurrent.futures.thread before creating the provider.
Would you like to implement a fix?
None
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
trace: make tracing processes safe to fork