deer-flow/backend/tests/test_gateway_startup.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

178 lines
6.4 KiB
Python

"""Regression coverage for local and Docker Gateway startup contracts."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from support.compose import DOCKER, requires_docker_compose
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
# Only the two deploy.sh tests shell out; they need Git Bash on Windows (the
# WSL launcher and Store alias stubs cannot run the repo scripts).
SCRIPT_BASH = find_script_bash()
requires_script_bash = pytest.mark.skipif(SCRIPT_BASH is None, reason="repo shell-script tests need Git Bash on Windows")
def _read(path: str) -> str:
return (REPO_ROOT / path).read_text(encoding="utf-8")
def _deploy_fixture(tmp_path: Path, *, docker_script: str) -> tuple[Path, dict[str, str]]:
worktree = tmp_path / "repo"
shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts")
shutil.copytree(REPO_ROOT / "docker", worktree / "docker")
(worktree / "backend").mkdir()
(worktree / "config.yaml").write_text("sandbox:\n use: deerflow.sandbox:LocalSandboxProvider\n", encoding="utf-8")
(worktree / "extensions_config.json").write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
docker = bin_dir / "docker"
docker.write_text(docker_script, encoding="utf-8")
docker.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BETTER_AUTH_SECRET"] = "test-better-auth-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-internal-auth-token"
return worktree, env
def test_gateway_runtime_commands_never_sync_dependencies() -> None:
"""A built environment must not resolve or install packages at process start."""
compose = _read("docker/docker-compose.yaml")
serve = _read("scripts/serve.sh")
# Dependency syncing is independent of the surrounding shell/exec form.
# Exact container launch forms belong to test_gateway_sigterm_delivery.py.
assert "uv run --no-sync uvicorn app.gateway.app:app" in compose
assert "uv run --no-sync uvicorn app.gateway.app:app" in serve
def test_local_frontend_ignores_public_docker_port() -> None:
"""Root PORT config is public Docker ingress, not the local Next.js port."""
serve = _read("scripts/serve.sh")
assert "env PORT=3000" in serve
assert 'run_service "Frontend"' in serve
assert "3000 300" in serve
def test_production_gateway_has_a_real_readiness_probe() -> None:
"""Compose readiness must exercise the Gateway HTTP endpoint."""
compose = _read("docker/docker-compose.yaml")
assert "healthcheck:" in compose
assert "http://127.0.0.1:8001/health" in compose
assert "gateway:\n condition: service_healthy" in compose
@requires_script_bash
def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> None:
capture = tmp_path / "docker-args.txt"
worktree, env = _deploy_fixture(
tmp_path,
docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$@" > "$CAPTURE_DOCKER_ARGS"\nexit 0\n'),
)
env["CAPTURE_DOCKER_ARGS"] = str(capture)
result = subprocess.run(
[SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
args = capture.read_text(encoding="utf-8").splitlines()
assert "--wait" in args
assert "--wait-timeout" in args
assert "DeerFlow is running!" in result.stdout
@requires_script_bash
def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_path: Path) -> None:
capture = tmp_path / "docker-calls.txt"
worktree, env = _deploy_fixture(
tmp_path,
docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$*" >> "$CAPTURE_DOCKER_CALLS"\ncase " $* " in\n *" up "*) exit 1 ;;\n *) exit 0 ;;\nesac\n'),
)
env["CAPTURE_DOCKER_CALLS"] = str(capture)
result = subprocess.run(
[SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "DeerFlow is running!" not in result.stdout
assert "DeerFlow services failed to become ready" in result.stderr
assert "supports `docker compose up --wait`" in result.stderr
assert "upgrade to Docker Compose 2.24+" in result.stderr
calls = capture.read_text(encoding="utf-8")
assert any(call.endswith(" ps") for call in calls.splitlines())
assert " logs --no-color --tail 100 gateway" in calls
# Hands only read-only calls to the real client: `up`, `down` and `logs` become
# `config --quiet` on the same global flags, so Compose parses exactly the
# project the entry point would act on without touching a running stack.
# Anything else fails loudly instead of reaching the real daemon.
_READ_ONLY_COMPOSE = """#!/usr/bin/env bash
args=()
for arg in "$@"; do
case "$arg" in
up|down|logs) exec "$REAL_DOCKER" "${args[@]}" config --quiet ;;
config|version) exec "$REAL_DOCKER" "$@" ;;
esac
args+=("$arg")
done
echo "unexpected docker call: $*" >&2
exit 97
"""
@requires_script_bash
@requires_docker_compose
@pytest.mark.parametrize(
"command",
[("deploy.sh",), ("deploy.sh", "start"), ("deploy.sh", "down"), ("docker.sh", "logs", "--prod")],
ids=["make-up", "deploy-start", "make-down", "make-prod-logs"],
)
def test_production_entry_points_parse_the_compose_project_without_env_files(tmp_path: Path, command: tuple[str, ...]) -> None:
"""A fresh clone has no .env or frontend/.env (both gitignored).
`make up`, `make down` and `make prod-logs` all load docker-compose.yaml,
so each must get past Compose's parse on such a checkout.
"""
worktree, env = _deploy_fixture(tmp_path, docker_script=_READ_ONLY_COMPOSE)
env["REAL_DOCKER"] = DOCKER
env["DEER_FLOW_HOME"] = str(tmp_path / "deer-flow-home")
assert not (worktree / ".env").exists()
assert not (worktree / "frontend" / ".env").exists()
script, *args = command
result = subprocess.run(
[SCRIPT_BASH, str(worktree / "scripts" / script), *args],
cwd=worktree,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert "env file" not in result.stderr