From 9e5dee318b773e2c1175411fe03c27f4dd8bb83b Mon Sep 17 00:00:00 2001 From: JasonH Date: Fri, 23 Oct 2026 08:31:54 +0800 Subject: [PATCH] fix(startup): make Gateway readiness timeout configurable (#7309) --- .env.example | 4 + README.md | 13 ++ .../test_serve_gateway_startup_timeout.py | 148 ++++++++++++++++++ scripts/AGENTS.md | 7 + scripts/serve.sh | 11 +- 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_serve_gateway_startup_timeout.py diff --git a/.env.example b/.env.example index 9a0ed23cbc..87520bcd64 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ UNBROWSE_API_KEY=your-unbrowse-api-key # Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026. # GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +# Local make dev/start Gateway readiness wait, in positive integer seconds (default: 30). +# Increase for slow cold starts; does not change Docker health checks or request timeouts. +# DEER_FLOW_GATEWAY_STARTUP_TIMEOUT=240 + # Host interface the nginx entry port listens on, for the Docker stack and for # local `make dev` / `make start` (where the Gateway and frontend always stay on # loopback). Defaults to 127.0.0.1 (loopback only), matching the diff --git a/README.md b/README.md index c52ed94972..7578325fe3 100644 --- a/README.md +++ b/README.md @@ -756,6 +756,19 @@ Local services always use their internal ports (`8001`, `3000`, and `2026`). The root `.env` variable `PORT` configures only the published Docker ingress; it does not change the Next.js port used by `make dev`. +Local startup waits up to 30 seconds for the Gateway port. On a slow cold start, +set a longer wait without editing the launcher: + +```bash +DEER_FLOW_GATEWAY_STARTUP_TIMEOUT=240 make dev +``` + +The value must be a positive integer in seconds. You can also set it in the +root `.env`, whose value takes precedence over the shell. It applies to local +`make dev`, `make start`, and their daemon variants, not Docker health checks +or LLM request timeouts. Check `logs/gateway.log` for startup errors before +increasing the wait; an expired deadline still stops the services. + #### Startup Modes DeerFlow runs the agent runtime inside the Gateway API. Development mode enables hot-reload; production mode uses a pre-built frontend. diff --git a/backend/tests/test_serve_gateway_startup_timeout.py b/backend/tests/test_serve_gateway_startup_timeout.py new file mode 100644 index 0000000000..a69dcd12c5 --- /dev/null +++ b/backend/tests/test_serve_gateway_startup_timeout.py @@ -0,0 +1,148 @@ +"""Run the launcher and real port-wait loop without starting application services.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from support.shell import require_script_bash + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Stub process launches and readiness/time, retaining serve.sh's admission, +# timeout forwarding, failure diagnostics and cleanup, plus the real wait loop. +_PRELUDE = r""" +git() { return 1; } +pgrep() { return 1; } +nginx() { return 0; } +ss() { return 1; } +netstat() { return 1; } +powershell.exe() { return 1; } +lsof() { + case "$*" in + *-iTCP:8001*) + if [ -f "$TEST_STARTED" ] && [ "$(cat "$TEST_CLOCK")" -ge "$TEST_READY_AT" ]; then + echo 12345 + return 0 + fi + ;; + esac + return 1 +} +sleep() { printf '%s\n' "$(( $(cat "$TEST_CLOCK") + 1 ))" > "$TEST_CLOCK"; } +python3() { "$TEST_PYTHON" "$@"; } +env() { + if [ "$1" = python3 ]; then shift; python3 "$@"; else command env "$@"; fi +} +sh() { + case "$*" in + *uvicorn*) + printf 'Gateway is still initializing\n' > logs/gateway.log + touch "$TEST_STARTED" + ;; + esac +} +nohup() { sh "$@"; } +bash() { + if [ "$1" = ./scripts/wait-for-port.sh ] && [ "$2" != 8001 ]; then return 0; fi + command "$TEST_BASH" "$@" +} +export -f lsof sleep ss netstat powershell.exe +source "$@" +""" + + +def _run_launcher(tmp_path: Path, *, timeout: str | None, ready_at: int = 145, args: tuple[str, ...] = ("--dev",), dotenv: bool = False) -> subprocess.CompletedProcess[str]: + checkout = tmp_path / "checkout" + scripts = checkout / "scripts" + scripts.mkdir(parents=True) + (checkout / "backend").mkdir() + for name in ("serve.sh", "wait-for-port.sh", "detect_uv_extras.py"): + shutil.copy2(REPO_ROOT / "scripts" / name, scripts / name) + (scripts / "nginx-local-conf.sh").write_text("printf 'test-nginx.conf\\n'\n", encoding="utf-8", newline="\n") + (scripts / "config-upgrade.sh").write_text("exit 0\n", encoding="utf-8", newline="\n") + (scripts / "cleanup-containers.sh").write_text('printf "cleanup\\n" >> "$TEST_CLEANUP"\n', encoding="utf-8", newline="\n") + (checkout / "config.yaml").write_text("models: []\n", encoding="utf-8", newline="\n") + (tmp_path / "clock").write_text("0\n", encoding="utf-8", newline="\n") + bash = require_script_bash() + env = {key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "ENV", "DEER_FLOW_GATEWAY_STARTUP_TIMEOUT", "DEER_FLOW_ENV_FILE"}} + env.update( + TEST_BASH=bash, + TEST_PYTHON=Path(sys.executable).as_posix(), + TEST_CLOCK=(tmp_path / "clock").as_posix(), + TEST_STARTED=(tmp_path / "started").as_posix(), + TEST_CLEANUP=(tmp_path / "cleanup").as_posix(), + TEST_READY_AT=str(ready_at), + DEER_FLOW_HOME=(tmp_path / "runtime").as_posix(), + DEER_FLOW_PROJECT_ROOT=checkout.as_posix(), + DEER_FLOW_CONFIG_PATH=(checkout / "config.yaml").as_posix(), + ) + if timeout is not None: + if dotenv: + (checkout / ".env").write_text(f"DEER_FLOW_GATEWAY_STARTUP_TIMEOUT={timeout}\n", encoding="utf-8", newline="\n") + else: + env["DEER_FLOW_GATEWAY_STARTUP_TIMEOUT"] = timeout + return subprocess.run( + [bash, "-c", _PRELUDE, "startup-timeout-test", (scripts / "serve.sh").as_posix(), "--skip-install", *args], + cwd=checkout, + env=env, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=20, + check=False, + ) + + +@pytest.mark.parametrize("args", [("--dev",), ("--prod",), ("--dev", "--daemon")]) +def test_configured_gateway_timeout_allows_slow_startup(tmp_path: Path, args: tuple[str, ...]) -> None: + result = _run_launcher(tmp_path, timeout="240", args=args) + assert result.returncode == 0, result.stdout + result.stderr + assert "Gateway started on localhost:8001" in result.stdout + + +def test_gateway_timeout_can_be_loaded_from_dotenv(tmp_path: Path) -> None: + result = _run_launcher(tmp_path, timeout="240", dotenv=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_default_gateway_timeout_accepts_a_fast_startup(tmp_path: Path) -> None: + result = _run_launcher(tmp_path, timeout=None, ready_at=5) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_default_gateway_timeout_remains_bounded(tmp_path: Path) -> None: + result = _run_launcher(tmp_path, timeout=None) + assert result.returncode == 1, result.stdout + result.stderr + assert "after 30s" in result.stdout + assert "Gateway is still initializing" in result.stdout + assert "DEER_FLOW_GATEWAY_STARTUP_TIMEOUT" in result.stdout + assert (tmp_path / "cleanup").read_text(encoding="utf-8").splitlines() == ["cleanup", "cleanup"] + + +def test_configured_gateway_timeout_still_times_out_and_cleans_up(tmp_path: Path) -> None: + result = _run_launcher(tmp_path, timeout="40") + assert result.returncode == 1, result.stdout + result.stderr + assert "after 40s" in result.stdout + assert (tmp_path / "cleanup").read_text(encoding="utf-8").splitlines() == ["cleanup", "cleanup"] + + +@pytest.mark.parametrize("value", ["", "0", "-1", "1.5", "abc", "9999999999999999999999999999999"]) +@pytest.mark.parametrize("action", ["--dev", "--restart"]) +def test_invalid_gateway_timeout_does_not_stop_services(tmp_path: Path, value: str, action: str) -> None: + result = _run_launcher(tmp_path, timeout=value, args=(action,)) + assert result.returncode == 1, result.stdout + result.stderr + assert "DEER_FLOW_GATEWAY_STARTUP_TIMEOUT must be a positive integer" in result.stdout + result.stderr + assert not (tmp_path / "cleanup").exists() + assert not (tmp_path / "started").exists() + + +def test_invalid_gateway_timeout_does_not_block_stop(tmp_path: Path) -> None: + result = _run_launcher(tmp_path, timeout="invalid", args=("--stop",)) + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / "cleanup").exists() + assert not (tmp_path / "started").exists() diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 2285414fb6..2a428aa939 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -51,6 +51,13 @@ trimming its contents. Offline CLI coverage: ## Service Startup Contracts +`serve.sh` uses `DEER_FLOW_GATEWAY_STARTUP_TIMEOUT` for the local Gateway's +port-readiness wait (positive integer seconds, default 30). Read it after the +root `.env`, and reject invalid values before any service cleanup or startup; +`--stop` bypasses this startup-only validation. Keep frontend/nginx deadlines +unchanged. `backend/tests/test_serve_gateway_startup_timeout.py` runs the full +launcher and real wait loop with process launches, readiness and time stubbed. + Both launchers call `sandbox-mode.sh` via Bash; keep it Bash/awk-only. Dev passes `$PROJECT_ROOT/config.yaml`; prod passes `$DEER_FLOW_CONFIG_PATH`. For AIO, omitted/blank/empty-quoted/YAML-null URLs (including comments) select diff --git a/scripts/serve.sh b/scripts/serve.sh index 4da1628a12..60550516f5 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -297,6 +297,11 @@ fi # not tear down a running stack. Gateway and frontend always bind loopback; # nginx is the only entry point, as in the Docker stack. if [ "$ACTION" != "stop" ]; then + GATEWAY_STARTUP_TIMEOUT="${DEER_FLOW_GATEWAY_STARTUP_TIMEOUT-30}" + if [[ ! "$GATEWAY_STARTUP_TIMEOUT" =~ ^[0-9]+$ ]] || ! [ "$GATEWAY_STARTUP_TIMEOUT" -gt 0 ] 2>/dev/null; then + echo "✗ DEER_FLOW_GATEWAY_STARTUP_TIMEOUT must be a positive integer (seconds)." >&2 + exit 1 + fi NGINX_CONF="$(bash "$REPO_ROOT/scripts/nginx-local-conf.sh")" || exit 1 fi @@ -494,6 +499,10 @@ run_service() { local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log" echo "✗ $name failed to start." [ -f "$logfile" ] && tail -20 "$logfile" + if [ "$name" = "Gateway" ]; then + echo " Check logs/gateway.log for startup errors. If Gateway is still initializing," + echo " increase DEER_FLOW_GATEWAY_STARTUP_TIMEOUT (seconds; current: $GATEWAY_STARTUP_TIMEOUT)." + fi cleanup 1 } echo "✓ $name started on localhost:$port" @@ -507,7 +516,7 @@ mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp # 1. Gateway API run_service "Gateway" \ "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 127.0.0.1 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \ - 8001 30 + 8001 "$GATEWAY_STARTUP_TIMEOUT" # 2. Frontend run_service "Frontend" \