Repository navigation
fix(sdk): run process-dependent resource detectors synchronously in fork handlers (#5789) - #5790
chiragjangiir wants to merge 8 commits into
Conversation
…ork handlers (open-telemetry#5789) Refresh process-dependent resources synchronously inside _get_process_dependent_resource() rather than delegating to get_aggregated_resources(). Delegating to get_aggregated_resources() spawns a ThreadPoolExecutor in the fork handler's after_in_child hook. If concurrent.futures.thread was imported after the provider, the executor's submit() call deadlocks trying to acquire _global_shutdown_lock before concurrent.futures.thread's after_in_child hook has a chance to reinitialize the lock. Running the detectors synchronously eliminates thread pool worker creation and lock acquisition in the newly forked child, preserving detector order, exception handling, raise_on_error semantics, and attribute merging without deadlock hazards. Fixes open-telemetry#5789.
|
|
Pull request dashboard statusWaiting on maintainers · refreshed 2026-10-23 21:29 UTC Merge when ready. Status above doesn't look right?
|
There was a problem hiding this comment.
🟡 Changes recommended
Synchronous aggregation still creates a thread pool when the initial resource is omitted.
What changed in this PR
Addresses #5789 by running process-dependent resource detectors synchronously during SDK fork callbacks.
Changes:
- Adds synchronous resource aggregation and uses it for fork-time refreshes.
- Adds aggregation tests and a subprocess deadlock regression test.
- Documents the fix in the changelog.
| File | Description |
|---|---|
| opentelemetry-sdk/tests/trace/test_trace.py | Adds fork deadlock regression coverage with subprocess cleanup. |
| opentelemetry-sdk/tests/resources/test_resources.py | Tests synchronous merging, error handling, and thread-pool avoidance. |
| opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py | Adds synchronous detection for process-dependent resource refreshes. |
| .changelog/5789.fixed | Records the fork deadlock fix. |
Review effort: Balanced
| """ | ||
| detectors_merged_resource = initial_resource or Resource.create() | ||
|
|
||
| if not use_threads: |
There was a problem hiding this comment.
This still just duplicates the code, we should adapt this to be a true generalization.
…us mode on default creation Refactor get_aggregated_resources() to unify detector result resolution and error handling across both threaded and synchronous execution paths, eliminating duplicated loops. Additionally, pass `use_threads` to Resource.create() when initial_resource is omitted so synchronous aggregation does not spawn a thread pool.
|
Thanks for the feedback @herin049. I've refactored I also addressed the case where |
There was a problem hiding this comment.
🟢 Approval recommended
The change removes the reported lock dependency, preserves default threaded behavior, and includes focused regression coverage with no unresolved findings.
No open issues 🎉
1 resolved since last review
Review effort: Balanced
| detectors: Sequence["ResourceDetector"], | ||
| initial_resource: Resource | None = None, | ||
| timeout: int = 5, | ||
| use_threads: bool = True, |
There was a problem hiding this comment.
Lets make this a keyword only, private argument.
| use_threads: bool = True, | |
| *, | |
| _use_thread_pool: bool = True, |
| def create( | ||
| attributes: Attributes | None = None, | ||
| schema_url: str | None = None, | ||
| use_threads: bool = True, |
There was a problem hiding this comment.
| use_threads: bool = True, | |
| *, | |
| _use_thread_pool: bool = True, |
| Args: | ||
| attributes: Optional zero or more key-value pairs. | ||
| schema_url: Optional URL pointing to the schema | ||
| use_threads: Optional flag to run detectors in a thread pool or synchronously |
There was a problem hiding this comment.
| use_threads: Optional flag to run detectors in a thread pool or synchronously |
| :param initial_resource: Static resource. This has highest priority | ||
| :param timeout: Number of seconds to wait for each detector to return | ||
| :param timeout: Number of seconds to wait for each detector to return when running in a thread pool | ||
| :param use_threads: True to run detectors in a thread pool, False to run synchronously (timeouts do not apply in synchronous mode) |
There was a problem hiding this comment.
| :param use_threads: True to run detectors in a thread pool, False to run synchronously (timeouts do not apply in synchronous mode) |
| # A detector that exceeds `timeout` is skipped, but shutdown still joins | ||
| # its worker so no threads outlive this call. A detector that blocks | ||
| # indefinitely therefore blocks this call. `cancel_futures=True` drops | ||
| # detectors that have not started yet if a `raise_on_error` detector raises. |
There was a problem hiding this comment.
These comments don't add much value.
| # A detector that exceeds `timeout` is skipped, but shutdown still joins | |
| # its worker so no threads outlive this call. A detector that blocks | |
| # indefinitely therefore blocks this call. `cancel_futures=True` drops | |
| # detectors that have not started yet if a `raise_on_error` detector raises. |
- Make thread pool configuration private keyword-only (_use_thread_pool) in get_aggregated_resources() and Resource.create() per review feedback - Use context manager for subprocess.Popen in fork deadlock regression test to resolve pylint R1732 - Rename changelog fragment to 5790.fixed to match pull request number
| "requires os.fork and os.register_at_fork", | ||
| ) | ||
| def test_tracer_provider_fork_with_preexec_fn_does_not_deadlock(self): | ||
| code = ( |
There was a problem hiding this comment.
Can we clean this up to follow the pattern used by the other fork-based tests?
|
Thanks for the feedback @herin049! I've moved the fork-test runner into a standalone helper script under |
|
Hi @herin049, The recent CI failure on Pylint now rates the module at 10.00/10 and the fork deadlock regression tests pass locally. |
Since opentelemetry-sdk 1.44.0, a TracerProvider registers an os.fork() handler, which runs a ThreadPoolExecutor in the child. concurrent.futures.thread registers fork handlers as well: it acquires its global lock before the fork and resets it in the child. The child handlers run in registration order. When concurrent.futures.thread is imported after the TracerProvider was created, the TracerProvider handler submits to the executor while the lock is still held, and the child deadlocks. This happens in liveproxyd: the first fork of the daemonization runs the TracerProvider handler, which imports concurrent.futures.thread. The second fork then deadlocks. The process keeps the stdout of "omd start" open, so "omd start" never finishes. Until now, the OTLP exporter module imported concurrent.futures.thread through grpc before the TracerProvider was created. Importing the exporter only when needed would expose the deadlock. This is an upstream bug: open-telemetry/opentelemetry-python#5789 The fix open-telemetry/opentelemetry-python#5790 is approved, but not yet released. Until then, cmk.trace imports concurrent.futures.thread itself, so its fork handlers are always registered first. This is the only place which creates a TracerProvider. The import can be removed once we use an SDK release with the fix. The test covers the daemonizing double fork and stays. Change-Id: I32ed037a3b84148eaab57e7a64f656ed5424d5cf Jira-Ref: CMK-40299

Description
Fixes #5789
Background & Root Cause
In
opentelemetry-sdk >= 1.44, providers (TracerProvider,MeterProvider,LoggerProvider) registeros.register_at_fork(after_in_child=...)hooks to refresh process-dependent resources via_get_process_dependent_resource().Previously,
_get_process_dependent_resource()delegated toget_aggregated_resources(), which spawns aconcurrent.futures.ThreadPoolExecutor.In CPython's
concurrent.futures.thread, fork hooks are registered upon import:before: acquires_global_shutdown_lockafter_in_child: calls_global_shutdown_lock._at_fork_reinit()Fork callbacks run
after_in_childhooks in order of registration. If a provider is instantiated beforeconcurrent.futures.threadis imported (e.g. applications passing custom resources before importing thread pools or web frameworks):concurrent.futures.threadacquires_global_shutdown_lock.after_in_childcallback executes beforeconcurrent.futures.thread'safter_in_childcallback.get_aggregated_resources()creates aThreadPoolExecutorand callssubmit(), which attempts to acquire_global_shutdown_lock.subprocess.Popen(..., preexec_fn=...), the parent hangs indefinitely waiting forerrpipe_read.Solution
Execute process-dependent detectors synchronously in
_get_process_dependent_resource(). The process-dependent detectors (ProcessResourceDetectorandServiceInstanceIdResourceDetector) perform fast, local in-memory lookups (< 0.1 ms). Running them synchronously eliminates thread pool creation and thread lock acquisition inafter_in_childwhile strictly preserving:raise_on_errorsemanticsVerification
test_tracer_provider_fork_with_preexec_fn_does_not_deadlockinopentelemetry-sdk/tests/trace/test_trace.pythat reliably reproduces the deadlock without the fix and passes with the fix.pytest opentelemetry-sdk/tests/trace/test_trace.py -k test_tracer_provider_fork_with_preexec_fn_does_not_deadlock(PASSED)pytest opentelemetry-sdk/tests/resources opentelemetry-sdk/tests/trace(372 passed, 2 skipped, 58 subtests passed)ruff checkandruff format --check(All passed).changelog/5789.fixed.Type of change
Checklist: