feat(lfx): add opt-in --reset-environ and --sync-workers to lfx serve

Two opt-in flags for per-request isolation in multi-worker lfx serve, both defaulting to the existing committed behavior:

- --reset-environ: snapshot/restore os.environ around each flow run so a flow's env mutations (or request-scoped credentials) cannot leak into the next request served by the same warm worker (gated by LFX_SERVE_RESET_ENVIRON; read per request in guarded_execute).

- --sync-workers: serve via gunicorn's blocking sync worker behind an a2wsgi ASGI->WSGI bridge so the kernel routes each request to an idle worker (one whole request per worker at a time) instead of queueing behind an in-flight request on a busy async worker. The bridge is built lazily post-fork; refused on Windows.

Also fix LFXGunicornApp.load() to honor the app import string (it hardcoded the ASGI app, which fed the WSGI sync worker the wrong callable), and declare a2wsgi as a Unix-only dependency alongside gunicorn.
This commit is contained in:
Jordan Frazier
2026-06-05 11:04:13 -04:00
parent eb469eb98a
commit cb81d4ed79
11 changed files with 491 additions and 9 deletions

View File

@ -259,6 +259,13 @@ To view the LFX server's API docs and schema, see the `/docs` endpoint at `http:
| `--verbose`, `-v` | Show diagnostic output and execution details. |
| `--flow-json` | Read inline flow JSON content as a string. Example: `uv run lfx serve --flow-json '{...}'`. |
| `--stdin` | Read JSON flow content from `stdin`. Example: `cat flow.json | uv run lfx serve --stdin`. |
| `--workers`, `-w` | Number of worker processes. Use with `--flow-dir` for multi-worker flow sharing. Default: `1`. |
| `--flow-dir` | Directory for filesystem-backed flow storage shared across workers (e.g. `/tmp/lfx-flows` for single-pod, or a PVC mount for cross-pod). Defaults to in-memory only when omitted. |
| `--max-requests` | Recycle each worker after this many requests (gunicorn, Unix-only, `--workers > 1`); `1` gives per-request recycling. Default: unset (workers are never recycled). |
| `--limit-concurrency` | Max in-flight requests per worker (`--workers > 1`); excess get HTTP 503. Default: unset (unlimited). |
| `--no-env-fallback` / `--env-fallback` | Disable the `os.environ` fallback for credential variables; variables not supplied via `global_vars` per request resolve to `None` instead of reading the process environment. Default: `--env-fallback` (fallback enabled). |
| `--reset-environ` / `--no-reset-environ` | Snapshot `os.environ` before each flow run and restore it afterward, so a flow's environment mutations (or request-scoped credentials) cannot leak into the next request served by the same warm worker. Default: `--no-reset-environ` (off). |
| `--sync-workers` / `--no-sync-workers` | Multi-worker only (`--workers > 1`, Unix). Use gunicorn's blocking `sync` worker so the kernel routes each request to an idle worker instead of queueing it behind an in-flight request on a busy async worker. Requires the `a2wsgi` package (`pip install a2wsgi`). Default: `--no-sync-workers` (async worker). |
## Run the simple agent flow with `lfx run`

View File

@ -17,6 +17,10 @@ dependencies = [
# gunicorn powers `lfx serve --workers N` (preload + max_requests=1 per-request
# isolation). Unix-only; on Windows multi-worker serve is refused with a clear error.
"gunicorn>=22.0; sys_platform != 'win32'",
# a2wsgi bridges the ASGI app onto gunicorn's sync worker for
# `lfx serve --sync-workers` (one whole request per worker / idle-worker routing).
# Unix-only like gunicorn; the sync-worker path is refused on Windows.
"a2wsgi>=1.10.0; sys_platform != 'win32'",
"typer>=0.16.0,<1.0.0",
"platformdirs>=4.3.8,<5.0.0",
"aiofiles>=24.1.0,<25.0.0",

View File

@ -177,6 +177,25 @@ def register(app: typer.Typer) -> None:
"instead of reading from the process environment."
),
),
reset_environ: bool = typer.Option(
False,
"--reset-environ/--no-reset-environ",
help=(
"Snapshot os.environ before each flow run and restore it afterward, so a "
"flow's environment mutations (or request-scoped credentials) cannot leak "
"into the next request served by the same warm worker. Off by default."
),
),
sync_workers: bool = typer.Option(
False,
"--sync-workers/--no-sync-workers",
help=(
"Use gunicorn's blocking 'sync' worker (Unix, --workers > 1) so the kernel "
"routes each request to an idle worker instead of queueing it behind an "
"in-flight request on a busy async worker. Requires the 'a2wsgi' package. "
"Off by default (async worker)."
),
),
) -> None:
"""Serve LFX flows as a web API (lazy-loaded)."""
from pathlib import Path
@ -201,4 +220,6 @@ def register(app: typer.Typer) -> None:
no_env_fallback=no_env_fallback,
max_requests=max_requests,
limit_concurrency=limit_concurrency,
reset_environ=reset_environ,
sync_workers=sync_workers,
)

View File

@ -221,6 +221,25 @@ def serve_command(
"instead of reading from the process environment."
),
),
reset_environ: bool = typer.Option(
False, # noqa: FBT003
"--reset-environ/--no-reset-environ",
help=(
"Snapshot os.environ before each flow run and restore it afterward, so a "
"flow's environment mutations (or request-scoped credentials) cannot leak "
"into the next request served by the same warm worker. Off by default."
),
),
sync_workers: bool = typer.Option(
False, # noqa: FBT003
"--sync-workers/--no-sync-workers",
help=(
"Use gunicorn's blocking 'sync' worker (Unix, --workers > 1) so the kernel "
"routes each request to an idle worker instead of queueing it behind an "
"in-flight request on a busy async worker. Requires the 'a2wsgi' package. "
"Off by default (async worker)."
),
),
) -> None:
"""Serve LFX flows as a web API.
@ -383,8 +402,16 @@ def serve_command(
verbose_print=verbose_print,
max_requests=max_requests,
limit_concurrency=limit_concurrency,
reset_environ=reset_environ,
sync_workers=sync_workers,
)
else:
from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV
# Single worker also serves many requests warm, so honor --reset-environ
# here (read per request by guarded_execute). --sync-workers is a
# multi-worker routing concern and has no effect with one worker.
os.environ[_SERVE_RESET_ENVIRON_ENV] = "1" if reset_environ else "0"
serve_app = create_multi_serve_app(registry=registry)
uvicorn.run(serve_app, host=host, port=port, workers=1, log_level=log_level)
except KeyboardInterrupt:
@ -413,6 +440,8 @@ def _launch_workers(
verbose_print: Callable[[str], None],
max_requests: int | None,
limit_concurrency: int | None,
reset_environ: bool = False,
sync_workers: bool = False,
) -> None:
"""Launch ``workers`` worker processes for ``lfx serve --workers N``.
@ -434,11 +463,19 @@ def _launch_workers(
falls back to uvicorn's own multi-worker supervisor (no preload, no per-request
recycling). ``--limit-concurrency`` is still honored there (uvicorn-native), but
``--max-requests`` (recycling) is refused, since it cannot be supported.
``reset_environ`` (``--reset-environ``) is forwarded to the workers via
``LFX_SERVE_RESET_ENVIRON`` so each worker snapshots/restores ``os.environ``
around every flow run (see ``guarded_execute``). ``sync_workers``
(``--sync-workers``, Unix only) swaps the async worker for gunicorn's blocking
``sync`` worker wrapped by an a2wsgi ASGI->WSGI bridge, so the kernel routes each
request to an idle worker. Both default off.
"""
from lfx.cli.serve_app import (
_SERVE_FLOW_DIR_ENV,
_SERVE_LIMIT_CONCURRENCY_ENV,
_SERVE_NO_ENV_FALLBACK_ENV,
_SERVE_RESET_ENVIRON_ENV,
_SERVE_STARTUP_PATHS_ENV,
)
@ -459,6 +496,9 @@ def _launch_workers(
# Read per worker by LFXUvicornWorker (Unix); passed to uvicorn.run on Windows.
if limit_concurrency is not None:
os.environ[_SERVE_LIMIT_CONCURRENCY_ENV] = str(limit_concurrency)
# Read per request by guarded_execute in each worker. Always set explicitly so a
# stray inherited value can't silently flip behavior.
os.environ[_SERVE_RESET_ENVIRON_ENV] = "1" if reset_environ else "0"
try:
if sys.platform == "win32":
@ -469,6 +509,13 @@ def _launch_workers(
"without isolation, or deploy on Linux/macOS for per-request isolation."
)
raise typer.Exit(1)
if sync_workers:
verbose_print(
"Error: --sync-workers uses gunicorn's sync worker, which is not available on "
"Windows. Omit --sync-workers to run multi-worker on Windows, or deploy on "
"Linux/macOS for idle-worker routing."
)
raise typer.Exit(1)
# gunicorn cannot run on Windows; fall back to uvicorn's multi-worker
# supervisor. No preload/COW and no per-request recycling (no isolation),
# though --limit-concurrency is still honored (uvicorn-native).
@ -491,14 +538,32 @@ def _launch_workers(
else:
from lfx.cli.serve_gunicorn import LFXGunicornApp
if sync_workers:
# Fail fast in the parent rather than per-worker on first request.
try:
import a2wsgi # noqa: F401
except ImportError as exc:
verbose_print(
"Error: --sync-workers requires the 'a2wsgi' package. Install it with: pip install a2wsgi"
)
raise typer.Exit(1) from exc
# gunicorn's blocking sync worker stops accepting while a request runs,
# so the kernel routes the next request to an idle worker. It serves the
# ASGI app through the a2wsgi WSGI bridge (built lazily, post-fork).
app_import_string = "lfx.cli.serve_preloaded_app:wsgi_application"
worker_class = "sync"
else:
# Async worker; applies LFX_SERVE_LIMIT_CONCURRENCY (gunicorn's
# UvicornWorker cannot forward uvicorn's limit_concurrency).
app_import_string = "lfx.cli.serve_preloaded_app:app"
worker_class = "lfx.cli.serve_gunicorn.LFXUvicornWorker"
LFXGunicornApp(
"lfx.cli.serve_preloaded_app:app",
app_import_string,
{
"bind": f"{host}:{port}",
"workers": workers,
# Custom worker applies LFX_SERVE_LIMIT_CONCURRENCY (gunicorn's
# UvicornWorker cannot forward uvicorn's limit_concurrency).
"worker_class": "lfx.cli.serve_gunicorn.LFXUvicornWorker",
"worker_class": worker_class,
"preload_app": True,
# None -> 0 (gunicorn's default: never recycle). 1 -> recycle per request.
"max_requests": max_requests if max_requests is not None else 0,
@ -517,6 +582,7 @@ def _launch_workers(
_SERVE_NO_ENV_FALLBACK_ENV,
_SERVE_STARTUP_PATHS_ENV,
_SERVE_LIMIT_CONCURRENCY_ENV,
_SERVE_RESET_ENVIRON_ENV,
):
os.environ.pop(k, None)

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio
import json
import os
import time
import traceback
import uuid
@ -57,6 +58,12 @@ _SERVE_STARTUP_PATHS_ENV = f"{_SERVE_ENV_PREFIX}STARTUP_PATHS"
# uvicorn limit_concurrency applied per worker by LFXUvicornWorker (gunicorn's
# UvicornWorker doesn't expose it). Set by serve_command; read in each worker.
_SERVE_LIMIT_CONCURRENCY_ENV = f"{_SERVE_ENV_PREFIX}LIMIT_CONCURRENCY"
# Opt-in (`lfx serve --reset-environ`): when "1", guarded_execute snapshots
# os.environ before each flow run and restores it after, so env mutations made by
# one request (or request-scoped credentials) cannot leak into the next request
# served by the same warm worker. Off by default. Set by serve_command; read per
# request in guarded_execute.
_SERVE_RESET_ENVIRON_ENV = f"{_SERVE_ENV_PREFIX}RESET_ENVIRON"
api_key_query = APIKeyQuery(name=API_KEY_NAME, scheme_name="API key query", auto_error=False)
api_key_header = APIKeyHeader(name=API_KEY_NAME, scheme_name="API key header", auto_error=False)
@ -73,9 +80,22 @@ async def guarded_execute(graph_copy, input_value, session_id=None):
Serializes the env-sensitive execution section so two concurrent requests in
the same async worker can never overlap a flow run before the worker recycles.
When ``LFX_SERVE_RESET_ENVIRON`` is "1" (``lfx serve --reset-environ``), the
process environment is snapshotted before the run and restored afterward, so a
flow's os.environ mutations (or request-scoped credentials) cannot leak into the
next request served by the same warm worker. Off by default — the snapshot is
skipped entirely unless opted in.
"""
async with _EXECUTE_GUARD:
return await execute_graph_with_capture(graph_copy, input_value, session_id=session_id)
reset_environ = os.environ.get(_SERVE_RESET_ENVIRON_ENV) == "1"
env_snapshot = dict(os.environ) if reset_environ else None
try:
return await execute_graph_with_capture(graph_copy, input_value, session_id=session_id)
finally:
if env_snapshot is not None and os.environ != env_snapshot:
os.environ.clear()
os.environ.update(env_snapshot)
def verify_api_key(

View File

@ -129,7 +129,12 @@ class LFXGunicornApp(BaseApplication):
self.cfg.set(key, value)
def load(self):
# preload_app=True -> gunicorn imports this string in the master pre-fork.
from lfx.cli.serve_preloaded_app import app
# preload_app=True -> gunicorn imports this "module:attr" string in the master
# pre-fork. The async path passes the ASGI app ("...:app"); --sync-workers
# passes the WSGI bridge ("...:wsgi_application"). Honor whichever was given
# rather than hardcoding one, so the worker gets the callable it expects.
import importlib
return app
module_str, _, attr = self._app_import_string.partition(":")
module = importlib.import_module(module_str)
return getattr(module, attr)

View File

@ -17,3 +17,26 @@ from lfx.cli.serve_app import build_registry_from_env, create_multi_serve_app
registry = build_registry_from_env()
app = create_multi_serve_app(registry=registry)
# WSGI bridge entrypoint for the opt-in ``lfx serve --sync-workers`` mode, which
# runs gunicorn's blocking ``sync`` worker so the kernel routes each request to an
# idle worker (an async worker keeps accepting connections while busy, which can
# queue a second request behind an in-flight one even when other workers are idle).
# The ASGI->WSGI bridge is created LAZILY on first request, never at preload: a2wsgi
# spins up a background event-loop thread, and threads do not survive fork(), so a
# bridge built in the preload master would be dead in every forked worker. Building
# it on first call constructs it inside the (post-fork) worker process instead.
_bridge = None
def wsgi_application(environ, start_response):
"""Lazily-constructed a2wsgi bridge wrapping the preloaded ASGI ``app``."""
global _bridge # noqa: PLW0603
if _bridge is None:
try:
from a2wsgi import ASGIMiddleware
except ImportError as exc: # pragma: no cover - exercised via --sync-workers without the dep
msg = "lfx serve --sync-workers requires the 'a2wsgi' package. Install it with: pip install a2wsgi"
raise RuntimeError(msg) from exc
_bridge = ASGIMiddleware(app)
return _bridge(environ, start_response)

View File

@ -709,7 +709,8 @@ def _run_serve_capturing_gunicorn(*, workers, max_requests, captured, limit_conc
patch("lfx.cli.serve_gunicorn.LFXGunicornApp", FakeGunicornApp),
):
# Direct call (not via CLI): typer defaults are not applied, so pass
# max_requests / limit_concurrency explicitly even when None.
# max_requests / limit_concurrency / reset_environ / sync_workers
# explicitly even when None/False (OptionInfo sentinels are truthy).
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
@ -725,6 +726,8 @@ def _run_serve_capturing_gunicorn(*, workers, max_requests, captured, limit_conc
no_env_fallback=False,
max_requests=max_requests,
limit_concurrency=limit_concurrency,
reset_environ=False,
sync_workers=False,
)
@ -828,6 +831,8 @@ def test_serve_command_sets_startup_paths_env_for_multi_worker(tmp_path):
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
assert _SERVE_STARTUP_PATHS_ENV in captured_env, "LFX_SERVE_STARTUP_PATHS must be set before the launch"
@ -886,6 +891,8 @@ def test_serve_command_does_not_set_startup_paths_when_flow_dir_set(tmp_path):
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
assert _SERVE_STARTUP_PATHS_ENV in captured_env, "env var must still be set (to empty list)"
@ -938,6 +945,8 @@ def test_serve_command_warns_when_workers_gt1_without_flow_dir():
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
assert any("--flow-dir" in msg for msg in stderr_output), (
@ -978,6 +987,8 @@ def test_serve_command_rejects_py_with_multiple_workers(tmp_path):
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
assert any(".py" in msg and "cannot be used" in msg for msg in stderr_output), stderr_output
@ -1031,6 +1042,8 @@ def test_serve_command_allows_py_with_multiple_workers_no_flow_dir(tmp_path):
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
# LFX_SERVE_STARTUP_PATHS must contain the .py path so workers can reload it
@ -1084,6 +1097,8 @@ def test_serve_command_no_warning_when_workers_gt1_with_flow_dir(tmp_path):
stdin=False,
check_variables=False,
no_env_fallback=False,
reset_environ=False,
sync_workers=False,
)
assert not any("--flow-dir" in msg for msg in stderr_output)

View File

@ -0,0 +1,180 @@
"""End-to-end env-isolation tests for `lfx serve` (spawns a live multi-worker server).
Demonstrates the actual security guarantee — caller A writes os.environ, caller B
must not read it — across real worker processes (fork + recycle), which can only be
observed with live processes, not in-process unit tests.
Two scenarios using a probe flow whose component writes a canary to os.environ and
reports whether it SAW a prior write:
- ``--workers 1`` (no recycle): a single persistent process -> later requests see the
canary -> ``LEAKED`` (proves the hazard is real, and that this harness detects it).
- ``--workers 2 --max-requests 1 --limit-concurrency 1``: each request runs in a
freshly-forked, recycled, single-in-flight worker -> always ``clean``.
Skipped in CI (spawns gunicorn + a real server; slow) — run locally as a harness.
"""
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from contextlib import contextmanager
from pathlib import Path
import pytest
_is_ci = os.environ.get("CI", "").lower() in {"1", "true", "yes"}
pytestmark = [
pytest.mark.skipif(_is_ci, reason="spawns a live multi-worker server; not for fast CI"),
pytest.mark.skipif(sys.platform == "win32", reason="isolation path uses gunicorn (Unix-only)"),
]
PROBE_FLOW = """
import os
from lfx.components.input_output import ChatInput, ChatOutput
from lfx.custom import Component
from lfx.graph import Graph
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
PROBE_KEY = "LFX_LEAK_PROBE"
CANARY = "SECRET-CANARY-VALUE"
class EnvLeakProbe(Component):
display_name = "Env Leak Probe"
inputs = [MessageTextInput(name="input_value", display_name="Input")]
outputs = [Output(name="status", display_name="Status", method="get_status")]
def get_status(self) -> Message:
seen = os.environ.get(PROBE_KEY)
os.environ[PROBE_KEY] = CANARY
status = "LEAKED" if seen == CANARY else "clean"
return Message(text=f"{status}|pid={os.getpid()}")
chat_input = ChatInput()
leak = EnvLeakProbe().set(input_value=chat_input.message_response)
chat_output = ChatOutput().set(input_value=leak.get_status)
graph = Graph(chat_input, chat_output)
"""
_API_KEY = "leak-test-key" # pragma: allowlist secret
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _get(url: str):
req = urllib.request.Request(url, headers={"x-api-key": _API_KEY}) # noqa: S310
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return json.load(resp)
def _run_flow(port: int, flow_id: str, value: str) -> str:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/flows/{flow_id}/run",
data=json.dumps({"input_value": value}).encode(),
method="POST",
headers={"x-api-key": _API_KEY, "content-type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
return json.load(resp)["result"]
@contextmanager
def _serve(tmp_path: Path, extra_args: list[str]):
flow = tmp_path / "leak_flow.py"
flow.write_text(PROBE_FLOW, encoding="utf-8")
port = _free_port()
log = (tmp_path / "server.log").open("w")
env = {**os.environ, "LANGFLOW_API_KEY": _API_KEY}
proc = subprocess.Popen( # noqa: S603
[sys.executable, "-m", "lfx", "serve", str(flow), "--host", "127.0.0.1", "--port", str(port), *extra_args],
env=env,
stdout=log,
stderr=subprocess.STDOUT,
)
try:
flow_id = _wait_ready(port, proc, tmp_path)
yield port, flow_id
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
log.close()
def _wait_ready(port: int, proc: subprocess.Popen, tmp_path: Path, timeout: float = 120.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
log = (tmp_path / "server.log").read_text(encoding="utf-8")
msg = f"serve exited early (code {proc.returncode}):\n{log[-2000:]}"
raise RuntimeError(msg)
try:
flows = _get(f"http://127.0.0.1:{port}/flows")
if flows:
return flows[0]["id"]
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
pass
time.sleep(1)
msg = "serve did not become ready in time"
raise RuntimeError(msg)
def _statuses(results):
# Each result is "clean|pid=NNNN" or "LEAKED|pid=NNNN".
return [r.split("|", 1)[0] for r in results]
def test_no_env_leak_multi_worker_warm(tmp_path):
"""workers=2 with warm long-lived workers (--max-requests 0, no recycling) -> no env leak.
This is the production model: workers are NOT recycled per request, so isolation comes
entirely from per-request os.environ snapshot/restore, even as each warm worker serves
many requests.
"""
with _serve(tmp_path, ["--workers", "2", "--max-requests", "0", "--reset-environ"]) as (port, fid):
results = [_run_flow(port, fid, f"req{i}") for i in range(12)]
assert _statuses(results) == ["clean"] * 12, results
def test_no_env_leak_single_worker(tmp_path):
"""workers=1 (single persistent process, NO recycling) -> still no cross-request leak.
This is the strongest demonstration that per-request os.environ snapshot/restore in
``guarded_execute`` is what enforces isolation: every request is served by the SAME
process (recycling never happens here), yet none sees a prior request's env write.
Before that fix, this exact scenario leaked from the 2nd request onward.
"""
with _serve(tmp_path, ["--workers", "1", "--reset-environ"]) as (port, fid):
results = [_run_flow(port, fid, f"req{i}") for i in range(12)]
assert _statuses(results) == ["clean"] * 12, results
# All requests served by the same process (no recycling) -> isolation came from
# per-request env restore, not from a fresh process.
pids = {r.split("pid=", 1)[1] for r in results if "pid=" in r}
assert len(pids) == 1, f"expected a single reused worker process, got pids={pids} results={results}"
def test_sync_workers_serves_requests(tmp_path):
"""--sync-workers (gunicorn sync worker + a2wsgi bridge) serves requests via the real CLI.
Proves the opt-in sync-worker path boots and the ASGI->WSGI bridge handles requests,
and that --reset-environ still enforces isolation under the sync worker.
"""
with _serve(tmp_path, ["--workers", "2", "--sync-workers", "--reset-environ"]) as (port, fid):
results = [_run_flow(port, fid, f"req{i}") for i in range(8)]
assert all("pid=" in r for r in results), results # the a2wsgi bridge served every request
assert _statuses(results) == ["clean"] * 8, results # reset-environ holds under sync worker

View File

@ -16,6 +16,7 @@ Covers the preload/warm/guard/fork-safety pieces of the gunicorn
"""
import json
import os
import sys
from pathlib import Path
@ -430,3 +431,129 @@ def test_pre_fork_flags_ghost_thread_but_not_benign():
blob = "\n".join(warnings)
assert "Ghost threads" in blob and "EvilGhostThread" in blob, warnings
assert "OTel-benign" not in blob # benign-named threads are filtered out
# ---------------------------------------------------------------------------
# Opt-in flags: --reset-environ (os.environ snapshot/restore) and --sync-workers
# (gunicorn sync worker + a2wsgi bridge). Both default OFF so the committed
# behavior is unchanged; these tests assert the opt-in wiring.
# ---------------------------------------------------------------------------
async def test_guarded_execute_restores_environ_when_enabled(monkeypatch):
"""With LFX_SERVE_RESET_ENVIRON=1, a flow's os.environ mutation is rolled back."""
from lfx.cli import serve_app
monkeypatch.setenv(serve_app._SERVE_RESET_ENVIRON_ENV, "1")
monkeypatch.delenv("LEAKED_BY_FLOW", raising=False)
async def fake_capture(graph, input_value, session_id=None): # noqa: ARG001
os.environ["LEAKED_BY_FLOW"] = "secret"
return ([], "")
monkeypatch.setattr(serve_app, "execute_graph_with_capture", fake_capture)
await serve_app.guarded_execute(object(), "a", None)
# The mutation made during the run is restored after it (no cross-request leak).
assert "LEAKED_BY_FLOW" not in os.environ
async def test_guarded_execute_does_not_reset_environ_by_default(monkeypatch):
"""Default (flag off): os.environ mutations persist — committed behavior is unchanged."""
from lfx.cli import serve_app
monkeypatch.delenv(serve_app._SERVE_RESET_ENVIRON_ENV, raising=False)
monkeypatch.delenv("LEAKED_BY_FLOW", raising=False)
async def fake_capture(graph, input_value, session_id=None): # noqa: ARG001
os.environ["LEAKED_BY_FLOW"] = "secret"
return ([], "")
monkeypatch.setattr(serve_app, "execute_graph_with_capture", fake_capture)
try:
await serve_app.guarded_execute(object(), "a", None)
assert os.environ.get("LEAKED_BY_FLOW") == "secret" # not restored
finally:
monkeypatch.delenv("LEAKED_BY_FLOW", raising=False)
def _capture_gunicorn_launch(monkeypatch, **launch_overrides):
"""Run ``_launch_workers`` on the Unix gunicorn path with a fake LFXGunicornApp.
Returns a dict with the captured ``app_import_string``, gunicorn ``options``,
and a snapshot of ``os.environ`` taken inside ``run()`` (i.e. what forked
workers would inherit).
"""
from lfx.cli import commands
captured: dict = {}
class FakeGunicornApp:
def __init__(self, app_import_string, options):
captured["app_import_string"] = app_import_string
captured["options"] = options
def run(self):
captured["env"] = dict(os.environ)
monkeypatch.setattr("lfx.cli.serve_gunicorn.LFXGunicornApp", FakeGunicornApp)
kwargs = {
"host": "127.0.0.1",
"port": 8000,
"workers": 2,
"log_level": "warning",
"flow_dir": None,
"no_env_fallback": False,
"script_paths": None,
"temp_file_to_cleanup": None,
"verbose_print": lambda *_a, **_k: None,
"max_requests": None,
"limit_concurrency": None,
}
kwargs.update(launch_overrides)
commands._launch_workers(**kwargs)
return captured
def test_launch_workers_default_uses_async_uvicorn_worker(monkeypatch):
"""Default (no --sync-workers): the async LFXUvicornWorker serves the ASGI app."""
captured = _capture_gunicorn_launch(monkeypatch)
assert captured["app_import_string"] == "lfx.cli.serve_preloaded_app:app"
assert captured["options"]["worker_class"] == "lfx.cli.serve_gunicorn.LFXUvicornWorker"
def test_launch_workers_sync_workers_uses_sync_worker(monkeypatch):
"""--sync-workers swaps in gunicorn's sync worker serving the a2wsgi WSGI bridge."""
captured = _capture_gunicorn_launch(monkeypatch, sync_workers=True)
assert captured["app_import_string"] == "lfx.cli.serve_preloaded_app:wsgi_application"
assert captured["options"]["worker_class"] == "sync"
def test_launch_workers_sync_workers_without_a2wsgi_errors(monkeypatch):
"""--sync-workers fails fast in the parent when a2wsgi is not installed."""
import typer
monkeypatch.setitem(sys.modules, "a2wsgi", None) # forces `import a2wsgi` to raise ImportError
with pytest.raises(typer.Exit):
_capture_gunicorn_launch(monkeypatch, sync_workers=True)
# env vars set before the failed launch are still cleaned up
from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV
assert _SERVE_RESET_ENVIRON_ENV not in os.environ
def test_launch_workers_reset_environ_exports_env_for_workers(monkeypatch):
"""--reset-environ exports LFX_SERVE_RESET_ENVIRON=1 (inherited by workers), then cleans up."""
from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV
captured = _capture_gunicorn_launch(monkeypatch, reset_environ=True)
assert captured["env"].get(_SERVE_RESET_ENVIRON_ENV) == "1"
assert _SERVE_RESET_ENVIRON_ENV not in os.environ # cleaned up after launch
def test_launch_workers_reset_environ_off_by_default(monkeypatch):
"""Without --reset-environ, the env var is exported as "0" (snapshot/restore disabled)."""
from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV
captured = _capture_gunicorn_launch(monkeypatch)
assert captured["env"].get(_SERVE_RESET_ENVIRON_ENV) == "0"

14
uv.lock generated
View File

@ -79,6 +79,18 @@ http-server = [
{ name = "starlette", marker = "(python_full_version < '3.14' and platform_machine == 'arm64') or (python_full_version < '3.14' and sys_platform != 'darwin')" },
]
[[package]]
name = "a2wsgi"
version = "1.10.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/cb/822c56fbea97e9eee201a2e434a80437f6750ebcb1ed307ee3a0a7505b14/a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45", size = 18799, upload-time = "2025-06-18T09:00:10.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/d5/349aba3dc421e73cbd4958c0ce0a4f1aa3a738bc0d7de75d2f40ed43a535/a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d", size = 17389, upload-time = "2025-06-18T09:00:09.676Z" },
]
[[package]]
name = "accelerate"
version = "1.13.0"
@ -8989,6 +9001,7 @@ name = "lfx"
version = "0.5.0"
source = { editable = "src/lfx" }
dependencies = [
{ name = "a2wsgi", marker = "sys_platform != 'win32'" },
{ name = "ag-ui-protocol" },
{ name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@ -9061,6 +9074,7 @@ integration = [
[package.metadata]
requires-dist = [
{ name = "a2wsgi", marker = "sys_platform != 'win32'", specifier = ">=1.10.0" },
{ name = "ag-ui-protocol", specifier = ">=0.1.10" },
{ name = "aiofile", specifier = ">=3.8.0,<4.0.0" },
{ name = "aiofiles", specifier = ">=24.1.0,<25.0.0" },