Skip to content

BatchProcessor's fork recovery fallback only recovers once; a second missed fork silently stops export forever #5843

Open

Description

@Neustren

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:

  1. A primary os.register_at_fork(after_in_child=...) hook that calls self._at_fork_reinit() directly, unconditionally, on every fork that goes through CPython's own fork wrapper.
  2. A fallback inside emit(), for forks that bypass path 1 entirely (e.g. a C extension calling libc fork() directly instead of os.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._pid stays 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions