Repository navigation
Description
Describe your environment
OS: Linux / macOS (All *nix platforms with os.register_at_fork)
Python version: Python 3.9+ (All supported Python versions)
SDK version: main (and all released versions of opentelemetry-sdk since the fork-recovery fallback was added to BatchProcessor)
What happened?
BatchProcessor in opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py has two separate fork recovery paths:
- A primary
os.register_at_fork(after_in_child=...)hook that callsself._at_fork_reinit()directly, unconditionally, on every fork that goes through CPython's own fork wrapper. - A fallback inside
emit(), for forks that bypass path 1 entirely (e.g. a C extension calling libcfork()directly instead ofos.fork()):
if self._pid != os.getpid():
self._bsp_reset_once.do_once(self._at_fork_reinit)self._bsp_reset_once is a Once (opentelemetry.util._once.Once), which executes its target exactly once for the lifetime of the object, the same semantics as Go's sync.Once (confirmed by reading the class: a plain boolean flag with no reset method). _at_fork_reinit() never resets self._bsp_reset_once back to a fresh Once().
Consequence: this fallback can recover from exactly one bypassed fork, ever, in the lifetime of a BatchProcessor. A second one in the same process's lifetime is silently ignored:
self._pidstays stale forever.- No new worker thread is spawned to drain the queue.
- Telemetry enqueued after that point is never exported.
- Nothing is logged. No error, no warning.
For comparison, Once is used in exactly one other place in the SDK (_shutdown_once, gating shutdown()), where "exactly once, ever" is the correct semantic since shutdown genuinely only happens once per object. Fork recovery needs to recur for every fork, not fire only once, so the same primitive doesn't fit here. This affects both BatchSpanProcessor and BatchLogRecordProcessor, since both delegate to this shared class.
Steps to Reproduce
Raw libc fork() bypasses os.register_at_fork entirely. This is the realistic trigger (a C extension forking directly), not os.fork()/multiprocessing/subprocess, which all go through the registry and are unaffected.
import ctypes
import os
import time
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
libc = ctypes.CDLL(None)
exporter = InMemorySpanExporter()
bsp = BatchSpanProcessor(exporter, schedule_delay_millis=10, max_export_batch_size=1)
provider = TracerProvider()
provider.add_span_processor(bsp)
def span(label):
with provider.get_tracer("t").start_as_current_span(label):
pass
span("prefork")
time.sleep(0.3)
pid1 = libc.fork()
if pid1 == 0:
span("child1") # first missed fork, fallback recovers correctly
time.sleep(0.3)
print("child1 exported:", len(exporter.get_finished_spans()))
pid2 = libc.fork()
if pid2 == 0:
span("child2") # second missed fork, fallback is exhausted
time.sleep(0.3)
print("child2 exported:", len(exporter.get_finished_spans()))
os._exit(0)
os.waitpid(pid2, 0)
os._exit(0)
os.waitpid(pid1, 0)Expected Result
Expected Result
Both child1 and child2 recover and export their own span, same as the first.
child1 exported: 2
child2 exported: 3
Actual Result
child1 recovers correctly. child2 does not:
child1 exported: 2
child2 exported: 2
bp._pid stays stuck at child1's pid, bp._bsp_reset_once._done stays True, no new worker thread is spawned in child2, and child2's span is never exported. No error or warning is logged anywhere.
Additional context
Reachability is narrow: it only matters for forks that bypass os.register_at_fork, and only on the second such occurrence in a process's lifetime. Standard os.fork(), multiprocessing, and subprocess are unaffected since they go through the registry and hit the (correct, unconditional) primary path every time. Worth fixing regardless, since the fallback's entire purpose, catching the cases the primary path misses, is defeated after its first use.
Would you like to implement a fix?
Yes
Tip
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it. Learn more here.
Activity