diff --git a/src/lfx/pyproject.toml b/src/lfx/pyproject.toml index e705c6339c3a..a2b7b26bfb50 100644 --- a/src/lfx/pyproject.toml +++ b/src/lfx/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "pillow>=12.3.0,<13.0.0", "fastapi>=0.135.0,<1.0.0", # [standard] adds uvloop + httptools (fast loop + HTTP parser) for the `lfx serve` worker path. - "uvicorn[standard]>=0.34.3,<1.0.0", + "uvicorn[standard]>=0.45.0,<1.0.0", # gunicorn powers `lfx serve --workers N` on Unix; Windows falls back to uvicorn and rejects gunicorn-only flags. "gunicorn>=22.0; sys_platform != 'win32'", # a2wsgi bridges the ASGI app onto gunicorn's sync worker for `lfx serve --use-sync-workers` (Unix). diff --git a/src/lfx/src/lfx/cli/commands.py b/src/lfx/src/lfx/cli/commands.py index 9a917ba779a7..d6dbe6beeb6a 100644 --- a/src/lfx/src/lfx/cli/commands.py +++ b/src/lfx/src/lfx/cli/commands.py @@ -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, + ) finally: # Symmetry with _launch_workers: don't leave our key in the parent env. os.environ.pop(_SERVE_RESET_ENVIRON_ENV, None) @@ -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 diff --git a/src/lfx/src/lfx/cli/serve_gunicorn.py b/src/lfx/src/lfx/cli/serve_gunicorn.py index 3de47c157b47..1ae2f5f28dd6 100644 --- a/src/lfx/src/lfx/cli/serve_gunicorn.py +++ b/src/lfx/src/lfx/cli/serve_gunicorn.py @@ -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: diff --git a/src/lfx/tests/unit/cli/test_serve_request_context_isolation.py b/src/lfx/tests/unit/cli/test_serve_request_context_isolation.py new file mode 100644 index 000000000000..086e2f5676b9 --- /dev/null +++ b/src/lfx/tests/unit/cli/test_serve_request_context_isolation.py @@ -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 + + +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. + """ + pytest.importorskip("gunicorn", reason="the gunicorn worker path is Unix-only") + + 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}" diff --git a/uv.lock b/uv.lock index 7e8cf8dc3cfa..883cfaccea36 100644 --- a/uv.lock +++ b/uv.lock @@ -9297,7 +9297,7 @@ requires-dist = [ { name = "tomli", specifier = ">=2.2.1,<3.0.0" }, { name = "typer", specifier = ">=0.16.0,<1.0.0" }, { name = "typing-extensions", specifier = ">=4.14.0,<5.0.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.3,<1.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.45.0,<1.0.0" }, { name = "validators", specifier = ">=0.34.0,<1.0.0" }, { name = "wheel", specifier = ">=0.46.2,<1.0.0" }, ]