Describe your environment
Reproduced on Linux (in a container, which is where it actually bites) and on macOS:
OS: Debian GNU/Linux 13 (trixie), kernel 6.8.0-1055-gke # container image, GKE node
Python version: 3.12.13
SDK version: 1.44.0
API version: 1.44.0
OS: macOS (Darwin 25.4.0)
Python version: 3.12.13
SDK version: 1.44.0
API version: 1.44.0
Not platform specific — it only depends on threading.Lock + os.register_at_fork semantics.
What happened?
Two related problems, one is the cause of the other.
1. get_aggregated_resources() timeout does not bound the wait.
The detectors run in a with concurrent.futures.ThreadPoolExecutor(...) block. future.result(timeout=timeout) only bounds how long we wait for the result — leaving the with block calls Executor.shutdown(wait=True), which joins the still-running worker. So a detector that takes longer than timeout still blocks the whole call for its full duration, and a detector that blocks forever makes the call never return. The Detector ... took longer than N seconds, skipping warning is emitted, which makes it look like the timeout worked, but it did not.
https://github.com/open-telemetry/opentelemetry-python/blob/v1.44.0/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py
2. Because of 1, a forked child can hang permanently inside the at_fork handler added in 1.44.
1.44 (#5280, motivated by #5257) added process-dependent resource refresh:
def _handle_fork(self) -> None:
self._tracers_lock = threading.Lock()
self._update_resource(_get_process_dependent_resource())
registered via os.register_at_fork(after_in_child=...) on both TracerProvider and MeterProvider. _get_process_dependent_resource() goes through get_aggregated_resources(), and ServiceInstanceIdResourceDetector.detect() acquires the module-level _service_instance_id_lock (a plain threading.Lock).
If any thread in the parent holds that lock at the moment another thread calls fork(), the child inherits it locked with no owner left to release it. The pool worker blocks forever, shutdown(wait=True) never returns, and the child never returns from fork() — it is stuck inside the at_fork handler before any user code runs.
Under a prefork worker model this is fatal rather than merely slow: the child never signals readiness, so the supervisor kills it (e.g. Celery prefork SIGKILLs the child), producing a crash loop. It happens on every fork, including worker recycling (celery --max-tasks-per-child, gunicorn --max-requests).
The critical section is tiny (uuid4() + a pid comparison), so the race is unlikely — but it is unrecoverable when it happens, and the fork path is exercised constantly in prefork deployments.
Steps to Reproduce
Part 1 — the timeout does not bound the wait (public API only, deterministic):
import time
from opentelemetry.sdk.resources import Resource, ResourceDetector, get_aggregated_resources
class SlowDetector(ResourceDetector):
def detect(self):
time.sleep(8)
return Resource.get_empty()
t0 = time.time()
get_aggregated_resources([SlowDetector()], Resource.get_empty(), timeout=1)
print(f"returned after {time.time() - t0:.1f}s")
Part 2 — forked child hangs. The _service_instance_id_lock is touched directly only to make the race deterministic; in production the same state occurs whenever a thread happens to be inside ServiceInstanceIdResourceDetector.detect() while another thread forks.
import os, threading, time
import opentelemetry.sdk.resources as res
from opentelemetry.sdk.trace import TracerProvider
provider = TracerProvider() # registers TracerProvider._handle_fork via os.register_at_fork
started = threading.Event()
def hold_lock():
with res._service_instance_id_lock:
started.set()
time.sleep(30)
threading.Thread(target=hold_lock, daemon=True).start()
started.wait()
pid = os.fork()
if pid == 0:
print("child: returned from fork, reached user code")
os._exit(0)
deadline = time.time() + 10
while time.time() < deadline:
if os.waitpid(pid, os.WNOHANG)[0]:
print("child exited normally")
break
time.sleep(0.1)
else:
print("child is STILL STUCK 10s after fork")
os.kill(pid, 9); os.waitpid(pid, 0)
Expected Result
Part 1: get_aggregated_resources(..., timeout=1) returns in about 1s, skipping the slow detector.
Part 2: the child returns from fork() and reaches user code, at worst without the process-dependent attribute.
Actual Result
Part 1 — the warning fires at 1s but the call blocks for the detector's full 8s:
Detector <__main__.SlowDetector object at 0x100f02840> took longer than 1 seconds, skipping
returned after 8.0s
Part 2 — the child never returns from fork(); child: returned from fork, reached user code is never printed:
Detector <opentelemetry.sdk.resources.ServiceInstanceIdResourceDetector object at 0x103f4e300> took longer than 5 seconds, skipping
child is STILL STUCK 10s after fork
Additional context
Any of these would fix Part 2; the first two also fix Part 1:
- Don't join blocked workers —
executor.shutdown(wait=False, cancel_futures=True) instead of relying on the with block. Leaks a thread, but the timeout becomes real.
- Don't use a thread pool on the fork path. Right after
fork() the child is single-threaded, so spawning a pool there buys nothing, and the timeout isolation it is meant to provide does not work anyway (Part 1).
- Re-initialize the lock in the child — have
opentelemetry.sdk.resources register os.register_at_fork(after_in_child=...) to replace _service_instance_id_lock with a fresh Lock(). Most local fix, but leaves Part 1 in place.
Note that a userland workaround is not really available. The usual pattern of bracketing the fork (before=lock.acquire, after_in_*=lock.release) does not help here: after_in_child handlers run in registration order, and opentelemetry-instrument creates the providers at interpreter startup, so the SDK's _handle_fork always runs before any handler an application can register.
Possibly related: #4345 (deadlock reaching metric reader storage under gunicorn+gevent), #4215, #3307. #3885 is the metrics-under-fork problem that per-pid service.instance.id addresses, so the fork refresh path matters and is worth keeping — it just should not be able to wedge the child.
Would you like to implement a fix?
No
Describe your environment
Reproduced on Linux (in a container, which is where it actually bites) and on macOS:
Not platform specific — it only depends on
threading.Lock+os.register_at_forksemantics.What happened?
Two related problems, one is the cause of the other.
1.
get_aggregated_resources()timeoutdoes not bound the wait.The detectors run in a
with concurrent.futures.ThreadPoolExecutor(...)block.future.result(timeout=timeout)only bounds how long we wait for the result — leaving thewithblock callsExecutor.shutdown(wait=True), which joins the still-running worker. So a detector that takes longer thantimeoutstill blocks the whole call for its full duration, and a detector that blocks forever makes the call never return. TheDetector ... took longer than N seconds, skippingwarning is emitted, which makes it look like the timeout worked, but it did not.https://github.com/open-telemetry/opentelemetry-python/blob/v1.44.0/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py
2. Because of 1, a forked child can hang permanently inside the
at_forkhandler added in 1.44.1.44 (#5280, motivated by #5257) added process-dependent resource refresh:
registered via
os.register_at_fork(after_in_child=...)on bothTracerProviderandMeterProvider._get_process_dependent_resource()goes throughget_aggregated_resources(), andServiceInstanceIdResourceDetector.detect()acquires the module-level_service_instance_id_lock(a plainthreading.Lock).If any thread in the parent holds that lock at the moment another thread calls
fork(), the child inherits it locked with no owner left to release it. The pool worker blocks forever,shutdown(wait=True)never returns, and the child never returns fromfork()— it is stuck inside theat_forkhandler before any user code runs.Under a prefork worker model this is fatal rather than merely slow: the child never signals readiness, so the supervisor kills it (e.g. Celery prefork SIGKILLs the child), producing a crash loop. It happens on every fork, including worker recycling (
celery --max-tasks-per-child,gunicorn --max-requests).The critical section is tiny (
uuid4()+ a pid comparison), so the race is unlikely — but it is unrecoverable when it happens, and the fork path is exercised constantly in prefork deployments.Steps to Reproduce
Part 1 — the timeout does not bound the wait (public API only, deterministic):
Part 2 — forked child hangs. The
_service_instance_id_lockis touched directly only to make the race deterministic; in production the same state occurs whenever a thread happens to be insideServiceInstanceIdResourceDetector.detect()while another thread forks.Expected Result
Part 1:
get_aggregated_resources(..., timeout=1)returns in about 1s, skipping the slow detector.Part 2: the child returns from
fork()and reaches user code, at worst without the process-dependent attribute.Actual Result
Part 1 — the warning fires at 1s but the call blocks for the detector's full 8s:
Part 2 — the child never returns from
fork();child: returned from fork, reached user codeis never printed:Additional context
Any of these would fix Part 2; the first two also fix Part 1:
executor.shutdown(wait=False, cancel_futures=True)instead of relying on thewithblock. Leaks a thread, but the timeout becomes real.fork()the child is single-threaded, so spawning a pool there buys nothing, and the timeout isolation it is meant to provide does not work anyway (Part 1).opentelemetry.sdk.resourcesregisteros.register_at_fork(after_in_child=...)to replace_service_instance_id_lockwith a freshLock(). Most local fix, but leaves Part 1 in place.Note that a userland workaround is not really available. The usual pattern of bracketing the fork (
before=lock.acquire,after_in_*=lock.release) does not help here:after_in_childhandlers run in registration order, andopentelemetry-instrumentcreates the providers at interpreter startup, so the SDK's_handle_forkalways runs before any handler an application can register.Possibly related: #4345 (deadlock reaching metric reader storage under gunicorn+gevent), #4215, #3307. #3885 is the metrics-under-fork problem that per-pid
service.instance.idaddresses, so the fork refresh path matters and is worth keeping — it just should not be able to wedge the child.Would you like to implement a fix?
No