Skip to content

fix(langfuse): guard _get_langfuse() against concurrent-init TOCTOU - #42326

Closed
nftpoetrist wants to merge 1 commit into
NousResearch:mainfrom
nftpoetrist:fix/langfuse-toctou-client-init
Closed

fix(langfuse): guard _get_langfuse() against concurrent-init TOCTOU#42326
nftpoetrist wants to merge 1 commit into
NousResearch:mainfrom
nftpoetrist:fix/langfuse-toctou-client-init

Conversation

@nftpoetrist

Copy link
Copy Markdown
Contributor

Summary

_get_langfuse() holds a global _LANGFUSE_CLIENT variable with no lock
protecting the double-check initialization. Two concurrent first callers (e.g.
two gateway sessions both triggering a Langfuse hook at the same time) both
pass the _LANGFUSE_CLIENT is not None guard, both construct a
Langfuse(**kwargs) client, and the loser's client leaks an open HTTPS
connection and a background flush thread. Under sustained gateway load this
accumulates silently.

This is the same TOCTOU class fixed for the Honcho and FAL clients in
plugins/plugin_utils.py (PR #24759 / commit 47d5177a7).

Changes:

  • Add _LANGFUSE_CLIENT_LOCK = threading.Lock() alongside the existing
    _STATE_LOCK (deliberately separate — _STATE_LOCK is already on the hot
    path for every hook call and nesting would risk deadlock).
  • Wrap the entire init body of _get_langfuse() in a double-checked lock.
  • The two outer fast-path checks (_INIT_FAILED / is not None) remain
    outside the lock so every hook call after the first stays zero-contention.
  • All existing _INIT_FAILED sentinel semantics (missing SDK, missing creds,
    placeholder keys, construction exception) are preserved unchanged.

Test plan

  • tests/plugins/test_langfuse_plugin.py — 37 existing tests all pass
  • Behavior identical to before for the single-threaded case (fast path
    unchanged, _INIT_FAILED cached on any init failure, same log messages)
  • No new imports or external dependencies

Two concurrent first callers both pass the ``_LANGFUSE_CLIENT is not
None`` guard, both construct a ``Langfuse(**kwargs)`` client, and the
loser's client leaks an open HTTPS connection and a background flush
thread.  This is the same TOCTOU class fixed for the Honcho and FAL
clients via ``plugins/plugin_utils.py`` (PR NousResearch#24759).

Add ``_LANGFUSE_CLIENT_LOCK = threading.Lock()`` and wrap the entire
init path in a double-checked lock.  The two outer fast-path checks
(``_INIT_FAILED`` / ``is not None``) stay outside the lock so the hot
path — every hook call after the first — takes zero contention.
``_STATE_LOCK`` is deliberately not reused: it guards the hot-path
``_TRACE_STATE`` dict and nesting it here would risk deadlock.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge labels Jun 8, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Positive verification comment — reviewed the full diff, no issues found.

The double-checked locking pattern for _LANGFUSE_CLIENT initialization is correctly implemented:

  1. Fast path outside the lock (_INIT_FAILED / is not None) avoids contention on the hot path
  2. Inner re-check after acquiring _LANGFUSE_CLIENT_LOCK prevents the classic TOCTOU race
  3. A separate lock from _STATE_LOCK avoids nesting-then-deadlock risk with the hot-path lock (good call — documented in the comment)
  4. The _INIT_FAILED sentinel is set inside the lock on every failure branch, so a racing thread that enters the lock after a failed init will correctly fast-return None

No resource leak, no missed edge case. The comment at line 55-60 explaining why a separate lock is used is particularly good.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused concurrency fix. The premise remains valid on current main: plugins/observability/langfuse/__init__.py:159-162 checks the singleton without synchronization, and construction remains unguarded at :222.

Problems

  • This PR has no regression coverage for the claimed race. tests/plugins/test_langfuse_plugin.py:879-922 is threaded coverage for _STATE_LOCK queue draining, not _get_langfuse() initialization.

Suggested changes

  • Add a barrier-based concurrent-first-call test with a delayed counting fake Langfuse constructor. Assert one construction and object identity across callers, mirroring the repository's existing singleton regression test pattern in tests/test_honcho_client_concurrency.py:48-77.
  • When salvaging, transplant the lock around the current initializer rather than applying blindly: current main changed this module after the PR base in f4fbaa6cda8b54f3da8e99da5e3ed44c7bd39d69.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users label Jul 14, 2026
erosika added a commit to erosika/hermes-agent that referenced this pull request Aug 10, 2026
_get_langfuse() double-checked a global with no lock, so two concurrent
first callers (e.g. two gateway sessions firing hooks at once) could
both pass the None guard, both construct a client, and leak the loser's
HTTP connection and background flush thread. First build is now
serialized by a module lock with a re-check inside; the settled fast
path stays lock-free. The atexit finalizer registration moves inside
the locked section so it registers exactly once, after the winning
client.

Adopted from NousResearch#42326 — thanks @nftpoetrist for the report and fix.

Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
@erosika

erosika commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

adopted into #83437 with co-author credit — thanks for catching the concurrent-init leak. one integration change: the atexit finalizer registration (added in that PR) moved inside the locked section so it registers exactly once.

kshitijk4poor added a commit that referenced this pull request Aug 13, 2026
… fan-out

Salvaged from PR #83437 by @erosika, with adopted fixes from @bgodlin (#81054),
@aldoeliacim (#82332), @nftpoetrist (#42326), @rodboev (#39653), @FnExpress
(#64292, supersedes #32175 by @db-aeon), @Per0-1 (#61166), @NaMinhyeok (#64797),
and @liuhao1024 (#43130).

Widens the bundled Langfuse plugin from 6 to 11 hooks and fixes two
attribution bugs. Also adopts shutdown/atexit lifecycle fixes and composes
8 prior community PRs with interaction-fix follow-ups.

Model attribution: on_pre_llm_request and on_post_llm_call now prefer the
wire value (request body model, response model) over the agent attribute,
which goes stale after /model switch or provider fallback.

Cost total: both cost paths now send a summed total alongside the per-type
breakdown, since Langfuse does not derive calculatedTotalCost from
cost_details keys. Subscription-included routes send no cost keys at all.

New coverage: api_request_error closes failed generations with ERROR level;
on_session_finalize/on_session_end close dangling traces for tool-only and
interrupted turns; subagent_start/subagent_stop trace delegated children as
spans; MoA advisor fan-out emits one generation per advisor priced at the
advisor's own model.

Capture modes: HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full (default
sanitized). Sanitized mode redacts secret patterns before truncation.

Adopted lifecycle fixes: shutdown client at session finalize when
reason=shutdown (not on session rotation); atexit finalizer ends open root
spans for short-lived processes; root context manager exited to prevent
interpreter-teardown TypeError; TOCTOU on _get_langfuse() fixed with lock;
reasoning_content surfaced in traces; system prompt included in generation
input for Anthropic/Codex/Bedrock; SDK v3 update_trace replaces set_trace_io.

Closes #29482, #43129, #72661.
Supersedes #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130.
Partially addresses #67544 (capture modes + secret redaction; user_id remains open).
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #85439 — your fix was adopted and composed into the wider Langfuse tracing PR by @erosika. Your contribution is credited in the commit body. Thanks @nftpoetrist!

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

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants