mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-17 18:33:21 +02:00
238 lines
7.5 KiB
Python
238 lines
7.5 KiB
Python
"""Process-level regression tests for graceful signal shutdown."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager, nullcontext
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
pytestmark = [
|
|
pytest.mark.subprocess_heavy,
|
|
pytest.mark.skipif(
|
|
sys.platform == "win32", reason="POSIX signal semantics are required"
|
|
),
|
|
]
|
|
|
|
SERVER_MODULE = "tests.server.signal_shutdown_server"
|
|
PROCESS_TIMEOUT = 10.0
|
|
|
|
|
|
async def test_run_http_owns_lifespan_when_asgi_lifespan_is_disabled() -> None:
|
|
"""Keep the process-scoped lifecycle owner retained by PR #4446."""
|
|
events: list[str] = []
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
events.append("startup")
|
|
try:
|
|
yield {}
|
|
finally:
|
|
events.append("shutdown")
|
|
|
|
server = FastMCP("outer-lifespan-owner", lifespan=lifespan)
|
|
with (
|
|
patch("fastmcp.server.mixins.transport.uvicorn.Config"),
|
|
patch("fastmcp.server.mixins.transport.uvicorn.Server") as server_class,
|
|
):
|
|
server_class.return_value._serve = AsyncMock()
|
|
server_class.return_value.capture_signals.return_value = nullcontext()
|
|
await server.run_http_async(
|
|
transport="sse",
|
|
show_banner=False,
|
|
uvicorn_config={"lifespan": "off"},
|
|
)
|
|
server_class.return_value._serve.assert_awaited_once_with(sockets=None)
|
|
|
|
assert events == ["startup", "shutdown"]
|
|
|
|
|
|
def _available_port() -> int:
|
|
with socket.socket() as listener:
|
|
listener.bind(("127.0.0.1", 0))
|
|
return int(listener.getsockname()[1])
|
|
|
|
|
|
async def test_cancellation_completes_async_lifespan_teardown() -> None:
|
|
events: list[str] = []
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
events.append("startup")
|
|
try:
|
|
yield {}
|
|
finally:
|
|
await anyio.sleep(0.05)
|
|
events.append("shutdown")
|
|
|
|
server = FastMCP("cancel-shutdown", lifespan=lifespan)
|
|
port = _available_port()
|
|
|
|
async def serve() -> None:
|
|
await server.run_http_async(host="127.0.0.1", port=port, show_banner=False)
|
|
|
|
with anyio.fail_after(4):
|
|
async with anyio.create_task_group() as tasks:
|
|
tasks.start_soon(serve)
|
|
while True:
|
|
try:
|
|
stream = await anyio.connect_tcp("127.0.0.1", port)
|
|
except OSError:
|
|
await anyio.sleep(0.01)
|
|
else:
|
|
await stream.aclose()
|
|
break
|
|
tasks.cancel_scope.cancel()
|
|
assert events == ["startup", "shutdown"]
|
|
|
|
|
|
async def test_original_signal_handlers_are_restored_during_teardown() -> None:
|
|
def custom_handler(signum: int, frame: Any) -> None:
|
|
pass
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
try:
|
|
yield {}
|
|
finally:
|
|
assert signal.getsignal(signal.SIGTERM) is custom_handler
|
|
|
|
server = FastMCP("restore-handlers", lifespan=lifespan)
|
|
original = signal.signal(signal.SIGTERM, custom_handler)
|
|
try:
|
|
with patch(
|
|
"fastmcp.server.mixins.transport.uvicorn.Server._serve",
|
|
new_callable=AsyncMock,
|
|
):
|
|
await server.run_http_async(show_banner=False)
|
|
assert signal.getsignal(signal.SIGTERM) is custom_handler
|
|
finally:
|
|
signal.signal(signal.SIGTERM, original)
|
|
|
|
|
|
def _wait_for_http(port: int, process: subprocess.Popen[str]) -> None:
|
|
deadline = time.monotonic() + PROCESS_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
raise AssertionError("HTTP server exited before accepting connections")
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
|
|
return
|
|
except OSError:
|
|
time.sleep(0.01)
|
|
raise AssertionError("HTTP server did not accept connections")
|
|
|
|
|
|
@pytest.mark.timeout(20)
|
|
@pytest.mark.parametrize("transport", ["http", "sse"])
|
|
@pytest.mark.parametrize(
|
|
"shutdown_signal", [signal.SIGINT, signal.SIGTERM], ids=["sigint", "sigterm"]
|
|
)
|
|
def test_signal_runs_lifespan_teardown_and_exits(
|
|
tmp_path: Path, transport: str, shutdown_signal: signal.Signals
|
|
) -> None:
|
|
"""A normal container stop must release resources and finish promptly.
|
|
|
|
The readiness checks are transport-level rather than lifespan-level. This
|
|
prevents a false pass caused by signaling HTTP before Uvicorn installs its
|
|
handlers. The teardown itself awaits before writing its marker, so this
|
|
also verifies that asynchronous cleanup is allowed to finish.
|
|
"""
|
|
events_path = tmp_path / "events.txt"
|
|
port = _available_port()
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
SERVER_MODULE,
|
|
"--events",
|
|
str(events_path),
|
|
"--port",
|
|
str(port),
|
|
"--transport",
|
|
transport,
|
|
]
|
|
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
try:
|
|
_wait_for_http(port, process)
|
|
|
|
process.send_signal(shutdown_signal)
|
|
try:
|
|
process.wait(timeout=PROCESS_TIMEOUT)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
stdout, stderr = process.communicate()
|
|
pytest.fail(
|
|
f"{transport} server did not exit after {shutdown_signal.name}; "
|
|
f"stdout={stdout!r}, stderr={stderr!r}"
|
|
)
|
|
|
|
stdout, stderr = process.communicate()
|
|
events = events_path.read_text().splitlines()
|
|
assert events == ["startup", "shutdown"], (
|
|
f"transport={transport}, signal={shutdown_signal.name}, "
|
|
f"returncode={process.returncode}, stdout={stdout!r}, stderr={stderr!r}"
|
|
)
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate()
|
|
|
|
|
|
@pytest.mark.timeout(20)
|
|
@pytest.mark.parametrize("transport", ["http", "sse"])
|
|
def test_second_sigterm_interrupts_slow_lifespan_teardown(
|
|
tmp_path: Path, transport: str
|
|
) -> None:
|
|
events_path = tmp_path / "events.txt"
|
|
port = _available_port()
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
SERVER_MODULE,
|
|
"--events",
|
|
str(events_path),
|
|
"--port",
|
|
str(port),
|
|
"--transport",
|
|
transport,
|
|
"--slow-cleanup",
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
try:
|
|
_wait_for_http(port, process)
|
|
process.send_signal(signal.SIGTERM)
|
|
deadline = time.monotonic() + PROCESS_TIMEOUT
|
|
while "shutdown-started" not in events_path.read_text():
|
|
assert process.poll() is None, "Server exited before cleanup"
|
|
assert time.monotonic() < deadline, "Cleanup did not start"
|
|
time.sleep(0.01)
|
|
process.send_signal(signal.SIGTERM)
|
|
process.wait(timeout=2)
|
|
assert process.returncode == -signal.SIGTERM
|
|
assert events_path.read_text().splitlines() == ["startup", "shutdown-started"]
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate()
|