deer-flow/backend/tests/test_gateway_sigterm_delivery.py
ajayr 28a06aa777
fix(deploy): exec the gateway server so SIGTERM reaches uvicorn (#6836)
* fix(deploy): exec the gateway server so SIGTERM reaches uvicorn

The production compose command, both CMDs in backend/Dockerfile and the
Helm chart start the Gateway as `sh -c "cd backend && ... uv run ..."`.
dash forks uv and stays PID 1 without a SIGTERM handler, so the kernel
drops the stop signal: uvicorn never shuts down, the lifespan teardown
(memory flush, run drain, service stops) never runs, and every stop waits
out the grace period and ends in SIGKILL (exit 137).

exec the server in every production launch path, as
docker/dev-entrypoint.sh already does, and give the compose gateway
`init: true` so orphaned grandchildren are still reaped once sh is no
longer PID 1. A regression test pins exec in each launch path.

Fixes #6835

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): link the gateway SIGTERM entry to #6836

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(gateway): decouple dependency sync from exec launch form

* docs: compact startup guidance to pass inherited size limits

* docs: trim inherited startup guidance after main merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 15:59:08 +08:00

69 lines
3.7 KiB
Python

"""The Gateway must receive the stop signal in every production launch path (#6835).
Container runtimes signal PID 1. A ``sh -c "cd backend && … uv run …"`` command
forks uv and leaves the shell as PID 1 with no SIGTERM handler, so the kernel
drops the signal: uvicorn never runs its lifespan teardown and every stop ends
in SIGKILL after the grace period. Each production launch path must therefore
``exec`` the server, and the compose gateway must also run an init process:
``exec`` removes the orphan reaping the shell provided as a side effect.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_PATH = REPO_ROOT / "docker" / "docker-compose.yaml"
DOCKERFILE_PATH = REPO_ROOT / "backend" / "Dockerfile"
HELM_GATEWAY_PATH = REPO_ROOT / "deploy" / "helm" / "deer-flow" / "templates" / "gateway-deployment.yaml"
DEV_ENTRYPOINT_PATH = REPO_ROOT / "docker" / "dev-entrypoint.sh"
# ``exec`` makes uv replace the shell as PID 1, so the runtime's stop signal
# reaches uvicorn instead of being dropped.
EXEC_SERVER = "exec uv run --no-sync uvicorn app.gateway.app:app"
def _compose_gateway() -> dict:
compose = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
return compose["services"]["gateway"]
def test_compose_gateway_execs_the_server() -> None:
"""A forked server under ``sh -c`` never sees ``docker stop``'s SIGTERM (#6835)."""
command = _compose_gateway()["command"]
# ``command`` may load as a scalar string or a list depending on YAML style.
if isinstance(command, list):
command = " ".join(str(part) for part in command)
assert EXEC_SERVER in command, f"docker/docker-compose.yaml must exec the Gateway so SIGTERM reaches uvicorn (#6835); got: {command}"
def test_compose_gateway_runs_an_init_process() -> None:
"""With the shell replaced, orphaned sandbox children need Docker's init to reap them (#6835)."""
init = _compose_gateway().get("init")
assert init is True, f"docker/docker-compose.yaml must set init: true so orphaned grandchildren are still reaped (#6835); got: {init!r}"
def test_dockerfile_cmds_exec_the_server() -> None:
"""Both image stages are launch paths: a bare ``sh -c`` makes ``docker stop`` wait out the grace period (#6835)."""
lines = DOCKERFILE_PATH.read_text(encoding="utf-8").splitlines()
cmds = [line for line in lines if line.startswith("CMD") and "uvicorn app.gateway.app:app" in line]
assert len(cmds) == 2, f"backend/Dockerfile must keep one Gateway CMD per stage (#6835); got {len(cmds)}: {cmds}"
for line in cmds:
assert EXEC_SERVER in line, f"backend/Dockerfile CMD must exec the Gateway so SIGTERM reaches uvicorn (#6835); got: {line}"
def test_helm_gateway_args_exec_the_server() -> None:
"""Without ``exec`` the kubelet's SIGTERM is dropped and the chart's graceful-shutdown budget never starts (#6835)."""
lines = HELM_GATEWAY_PATH.read_text(encoding="utf-8").splitlines()
launches = [line for line in lines if "uv run --no-sync uvicorn app.gateway.app:app" in line]
assert len(launches) == 1, f"expected exactly one Gateway launch in the Helm chart (#6835); got {len(launches)}: {launches}"
assert EXEC_SERVER in launches[0], f"deploy/helm/deer-flow/templates/gateway-deployment.yaml must exec the Gateway so the kubelet's SIGTERM reaches uvicorn (#6835); got: {launches[0]}"
def test_dev_entrypoint_execs_the_server() -> None:
"""The dev entrypoint already execs; a regression reintroduces the dropped SIGTERM on every dev-container restart (#6835)."""
entrypoint = DEV_ENTRYPOINT_PATH.read_text(encoding="utf-8")
assert f"PYTHONPATH=. {EXEC_SERVER}" in entrypoint, "docker/dev-entrypoint.sh must exec the Gateway so the container's stop signal reaches uvicorn (#6835)"