`force_flush()` deadlocks when a span exporter reads `get_tracer_provider().resource`
Maintainers usually reply within 1 day
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
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from pydantic/logfire
-
Windows: 16 tests in `tests/test_cli.py` fail, and the CI test job only runs on LinuxPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Docs: update the Playground cost estimate note for Gateway custom pricesPossibly taken @henricop claimed this 13 days ago. Open
Difficulty 1/5 Under an hour Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 1 day
-
Feature Request
Difficulty 4/5 3-5 days Newbie friendliness 56/100
Maintainers usually reply within 1 day
-
`logfire clean --logs` fails on Windows with `PermissionError` and leaves the credentials file in placePossibly taken @AdityaSreevatsaK claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 35/100
Maintainers usually reply within 1 day
All issues in pydantic/logfire
Similar issues
-
documentation type/bug
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
add entry to blockOpen
Difficulty 1/5 Under an hour Newbie friendliness 65/100
StevenBlack/hosts#3299 ·
-
`garak --report` overwrites the input report when its name has no ".report"Possibly taken @0z5a claimed this today. Openneeds-triage
Difficulty 2/5 Half a day Newbie friendliness 72/100
Maintainers usually reply within 2 days
-
trace: Pi's 1-hour cache writes are priced at 1.25x, but billed at 2xPossibly taken @Shizoqua claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
microsoft/TokenEconomizer#221 ·
Maintainers usually reply within 1 day
-
plot_graph crashes when a mesh node has no m2m edgePossibly taken @tygvi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mllam/neural-lam#883 · 1 comment ·
Maintainers usually reply within 2 days