Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/lfx/src/lfx/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,18 @@ def serve_command(
os.environ[_SERVE_RESET_ENVIRON_ENV] = "1" if reset_environ else "0"
try:
serve_app = create_multi_serve_app(registry=registry, identity_config=identity_config)
uvicorn.run(serve_app, host=host, port=port, workers=1, log_level=log_level)
uvicorn.run(
serve_app,
host=host,
port=port,
workers=1,
log_level=log_level,
# Start every request task from a clean context, or a pipelined request
# inherits the previous request's ended server span and its own span is
# emitted as an INTERNAL child of an unrelated request. See
# ``LFXUvicornWorker.CONFIG_KWARGS`` for the mechanism.
reset_contextvars=True,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
finally:
# Symmetry with _launch_workers: don't leave our key in the parent env.
os.environ.pop(_SERVE_RESET_ENVIRON_ENV, None)
Expand Down Expand Up @@ -717,6 +728,8 @@ def _launch_workers(
workers=workers,
log_level=log_level,
factory=True,
# Same reason as the single-worker path above.
reset_contextvars=True,
)
else:
# gunicorn ships with lfx on Linux/macOS; a missing import means a trimmed/broken
Expand Down
16 changes: 16 additions & 0 deletions src/lfx/src/lfx/cli/serve_gunicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ class LFXUvicornWorker(UvicornWorker):
here and can host per-worker customization if needed later.
"""

# ``reset_contextvars``: start every request task from a clean context. A request that
# arrives while another is still in flight on the same connection is queued as a pipelined
# request, and uvicorn then starts it from inside the finishing request's task
# (httptools_impl ``on_response_complete`` -> ``_start_asgi_task``). ``create_task`` copies
# the context, so the new request begins with the previous request's already-ended server
# span still current. OpenTelemetry's ASGI middleware reads that as nesting and emits the
# request as an INTERNAL child of an unrelated, finished request instead of as a SERVER
# root, which merges unrelated traces and hides most HTTP traffic from RED metrics and
# service maps. A browser page load triggers it; sequential curl never does. See CPython
# #140947.
#
# Spread over the base class's kwargs rather than replacing them, so the inherited loop and
# http choices still apply. Kept in step with the two ``uvicorn.run`` calls in
# ``lfx.cli.commands``, and with ``langflow.server.LangflowUvicornWorker``.
CONFIG_KWARGS = {**UvicornWorker.CONFIG_KWARGS, "reset_contextvars": True}


class LFXGunicornApp(BaseApplication):
def __init__(self, app_import_string: str, options: dict) -> None:
Expand Down
73 changes: 73 additions & 0 deletions src/lfx/tests/unit/cli/test_serve_request_context_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Every ``lfx serve`` launch path must start each request task from a clean context.

A request that arrives while another is still in flight on the same connection is queued as a
pipelined request, and uvicorn starts it from inside the finishing request's task
(``httptools_impl.on_response_complete`` -> ``_start_asgi_task``). ``create_task`` copies the
context, so without ``reset_contextvars`` the new request begins with the previous request's
already-ended server span still current. OpenTelemetry's ASGI middleware reads that as nesting
and emits the request as an INTERNAL child of an unrelated finished request rather than as a
SERVER root, merging unrelated traces and hiding most HTTP traffic from RED metrics.

langflow set the flag on both of its launch paths; lfx serve has three and had none of them,
so the same defect stayed live on the runtime that actually serves traffic. These tests cover
all three, and the source-level one covers a fourth if anyone adds it.
"""

from __future__ import annotations

import ast
from pathlib import Path

import lfx.cli.commands
import pytest
import uvicorn

pytest.importorskip("gunicorn", reason="the gunicorn worker path is Unix-only")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def test_the_gunicorn_worker_resets_the_context_and_keeps_its_inherited_options():
"""Asserted through a real ``uvicorn.Config``, so a renamed or rejected option fails here.

The inherited keys matter too: ``CONFIG_KWARGS`` is spread over the base class's, and
replacing it outright would silently drop uvicorn's loop and http selection.
"""
from lfx.cli.serve_gunicorn import LFXUvicornWorker
from uvicorn.workers import UvicornWorker

config = uvicorn.Config("lfx.cli.serve_app:create_serve_app", **LFXUvicornWorker.CONFIG_KWARGS)

assert config.reset_contextvars is True
for key, value in UvicornWorker.CONFIG_KWARGS.items():
assert LFXUvicornWorker.CONFIG_KWARGS[key] == value, f"dropped inherited {key}"


def _uvicorn_run_calls() -> list[ast.Call]:
source = Path(lfx.cli.commands.__file__).read_text(encoding="utf-8")
return [
node
for node in ast.walk(ast.parse(source))
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "run"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "uvicorn"
]


def test_every_uvicorn_launch_in_the_serve_command_resets_the_context():
"""Read from the source rather than by starting a server or patching ``uvicorn.run``.

This is the guard that would have caught the original gap. Both call sites are literal
kwargs, and a new launch path added later fails here until it opts in too.
"""
calls = _uvicorn_run_calls()
assert len(calls) >= 2, "expected the single-worker and Windows multi-worker launches"

for call in calls:
passed = {kw.arg: kw.value for kw in call.keywords}
assert "reset_contextvars" in passed, f"uvicorn.run at line {call.lineno} does not reset the context"
value = passed["reset_contextvars"]
assert isinstance(value, ast.Constant), (
f"uvicorn.run at line {call.lineno} passes a non-literal reset_contextvars={ast.unparse(value)}"
)
assert value.value is True, f"uvicorn.run at line {call.lineno} passes reset_contextvars={value.value!r}"
Loading