`force_flush()` deadlocks when a span exporter reads `get_tracer_provider().resource`

Open
#2,789 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@prassu017 is already working on this.

Since Nov 11, 2026.

  • #2791 by @prassu017 — open

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Stale
Tech stack
python
Domain
observability

Research direction

Start with logfire-sdk/logfire/_internal/tracer.py, where the issue identifies ProxyTracerProvider.force_flush and shutdown as holding the proxy lock while calling the SDK provider. Review the linked pull request #2791 and compare the provider calls with the issue’s suggested fix. Done means force_flush no longer deadlocks with an exporter reading the global provider’s resource; the issue says shutdown likely needs the same change but was not tested.

Written by the indexing model from the issue text.

Description

Versions

From logfire.logfire_info(), trimmed to the packages the script uses.

logfire="5.6.0"
platform="macOS-27.2-arm64-arm-64bit-Mach-O"
python="3.14.6 (main, Sep 29 2026, 23:07:15) [Clang 21.0.0 (clang-2100.3.34.2)]"
[related_packages]
opentelemetry-api="1.45.2"
opentelemetry-sdk="1.45.2"

Also reproduced on logfire 5.4.0.
logfire-sdk/logfire/_internal/tracer.py on main at 0a17b8d3 has the same code.

Reproduction

No network and no third-party exporter. The exporter reads the global tracer provider's resource, as an exporter may do to describe its process.
ProxyTracerProvider defines resource itself, as the SDK's TracerProvider does.

import threading

import logfire
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
from opentelemetry.trace import get_tracer_provider


class ResourceReadingExporter(SpanExporter):
    def export(self, spans):
        get_tracer_provider().resource
        return SpanExportResult.SUCCESS


logfire.configure(send_to_logfire=False, console=False, additional_span_processors=[BatchSpanProcessor(ResourceReadingExporter())])
with logfire.span("one span"):
    pass

flusher = threading.Thread(target=logfire.force_flush, daemon=True)
flusher.start()
flusher.join(5)
print("HANGS" if flusher.is_alive() else "returned")  # prints HANGS

Cause

ProxyTracerProvider.force_flush holds self.lock, a plain threading.Lock, while it calls the inner SDK provider's force_flush.
That flush runs the batch processor's export, and so the exporter, inside the lock.
get_tracer_provider() returns the ProxyTracerProvider, and its resource property takes the same lock.

The stack of the flushing thread on 5.6.0, most recent call first, with the script saved as repro.py:

logfire/_internal/tracer.py:117 in resource                        <- waits for self.lock
repro.py:10 in export
opentelemetry/sdk/_shared_internal/__init__.py:187 in _export      <- holds the processor's _export_lock
opentelemetry/sdk/_shared_internal/__init__.py:236 in force_flush
opentelemetry/sdk/trace/export/__init__.py:221 in force_flush
logfire/_internal/exporters/processor_wrapper.py:136 in force_flush
logfire/_internal/exporters/processor_wrapper.py:136 in force_flush
logfire/_internal/exporters/wrapper.py:74 in force_flush
logfire/_internal/exporters/wrapper.py:74 in force_flush
logfire/_internal/exporters/wrapper.py:74 in force_flush
opentelemetry/sdk/trace/__init__.py:217 in force_flush
opentelemetry/sdk/trace/__init__.py:1425 in force_flush
logfire/_internal/tracer.py:125 in force_flush                     <- holds self.lock
logfire/_internal/config.py:1798 in force_flush
logfire/_internal/main.py:930 in force_flush

A second form, across two threads

The batch processor's worker thread exports on its own schedule.
When it is inside export, it holds the processor's _export_lock and waits for the proxy lock in resource.
If another thread is in logfire.force_flush at that moment, it holds the proxy lock and waits for _export_lock.
Each thread waits for the other. A reentrant lock does not help here, because the two locks are held by different threads.

What else hangs afterwards

The proxy lock stays held, so every later call that takes it blocks too, such as get_tracer(), resource and set_provider().
logfire patches os._exit to flush first, so os._exit() also never returns after the deadlock.
A normal interpreter exit still finishes, about 30 seconds late, so the script above is slow to end after it prints.

Results

Each case ran the script above, or its two-thread variant with the worker held inside export while force_flush is called.
The RLock and the changed force_flush were patched in at run time. Every case gave the same result on 5.6.0 and 5.4.0.

Case As released lock replaced by RLock Inner provider called after releasing the lock
One thread hangs returns returns
Two threads hangs hangs returns
os._exit(0) after the one-thread case hangs exits exits

Suggested fix

Read the inner provider under the lock and call it after releasing the lock.

def force_flush(self, timeout_millis: int = 30000) -> bool:
    with self.lock:
        provider = self.provider
    if isinstance(provider, SDKTracerProvider):
        return provider.force_flush(timeout_millis)
    return True

shutdown holds the lock around the inner provider's shutdown in the same way, and likely needs the same change. That was not tested.

Related

The Azure Monitor trace exporter (azure-monitor-opentelemetry-exporter 1.0.0b58) reads get_tracer_provider().resource in export unless it is given tracer_provider=, so logfire.force_flush() hangs with that exporter whenever spans are queued.

#2404 fixed the one-thread form of this pattern in ProxyLoggerProvider with an RLock, for #2403. The two-thread form was not tested there.

Dominant language
Python
Stars
4.5k
Forks
365
Avg merge
1d 8h
Merged PRs (30d)
153

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pydantic/logfire

All issues in pydantic/logfire

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.