Skip to content

TracerProvider fork handler hangs every fork with preexec_fn when concurrent.futures.thread is imported after the provider (1.44+) #5789

Open

Description

@fivetime

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:

As a workaround we import concurrent.futures.thread before creating the provider.

Would you like to implement a fix?

None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions