deer-flow/backend/tests/test_subagent_batch_shutdown_timeout.py
Nan Gao a684ae2fe3
fix(sandbox): leave SIGTERM/SIGINT teardown to the Gateway lifespan (#7560)
* fix(sandbox): leave SIGTERM/SIGINT teardown to the Gateway lifespan

AIO and E2B providers built on the event-loop thread installed SIGTERM/SIGINT
handlers that force-destroyed every sandbox synchronously, before uvicorn
started the run drain. The Gateway lifespan now holds sandbox shutdown
ownership from its first step, so providers skip those handlers and the
existing hook closes the provider after run drain and MCP teardown. SIGHUP
keeps the provider fallback. AIO acquire now refuses once shutdown began.

* docs(sandbox): document Gateway-owned sandbox shutdown and AIO admission fence

* test(gateway): inspect the lifespan body now that lifespan wraps it

* docs(changelog): reference #7560 for the sandbox SIGTERM drain-order fix

* ci: retrigger sandbox image smoke after a registry pull timeout
2026-10-26 20:38:32 +08:00

157 lines
5.1 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from app.gateway import app as gateway_app
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.subagents import batch_service as batch_service_module
from deerflow.subagents.batch_service import SubagentBatchService
@pytest.mark.asyncio
async def test_subagent_batch_service_shutdown_is_bounded(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
async def hang() -> None:
await asyncio.sleep(3600)
app = FastAPI()
service = MagicMock()
service.stop = AsyncMock(side_effect=hang)
app.state.subagent_batch_service = service
app.state.subagent_batches_available = True
monkeypatch.setattr(gateway_app, "_SHUTDOWN_HOOK_TIMEOUT_SECONDS", 0.01)
started = asyncio.get_running_loop().time()
with caplog.at_level(logging.WARNING, logger="app.gateway.app"):
await gateway_app._shutdown_subagent_batch_service(app)
elapsed = asyncio.get_running_loop().time() - started
assert elapsed < 0.5
assert app.state.subagent_batches_available is False
service.stop.assert_awaited_once()
assert "Subagent batch service shutdown exceeded" in caplog.text
@pytest.mark.asyncio
async def test_gateway_shutdown_timeout_does_not_cancel_batch_cleanup(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
poller_entered = asyncio.Event()
poller_cancelled = asyncio.Event()
release_poller = asyncio.Event()
async def reluctant_poller() -> None:
poller_entered.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
poller_cancelled.set()
await release_poller.wait()
service = SubagentBatchService(
repository=SimpleNamespace(),
config=SubagentBatchesConfig(),
runtime_config=SubagentRuntimeConfig(),
)
monkeypatch.setattr(service, "_run", reluctant_poller)
await service.start()
original_poller = service._poller
await asyncio.wait_for(poller_entered.wait(), timeout=1)
app = FastAPI()
app.state.subagent_batch_service = service
app.state.subagent_batches_available = True
monkeypatch.setattr(gateway_app, "_SHUTDOWN_HOOK_TIMEOUT_SECONDS", 0.01)
with caplog.at_level(logging.WARNING, logger="app.gateway.app"):
await gateway_app._shutdown_subagent_batch_service(app)
await asyncio.wait_for(poller_cancelled.wait(), timeout=1)
cleanup_task = service._stop_cleanup_task
assert cleanup_task is not None
assert not cleanup_task.done()
assert original_poller is not None
assert not original_poller.done()
assert not original_poller.cancelled()
assert service._stopping
with pytest.raises(RuntimeError, match="before stop completes"):
await service.start()
retry_stop = asyncio.create_task(service.stop())
await asyncio.sleep(0)
assert service._stop_cleanup_task is cleanup_task
release_poller.set()
await asyncio.wait_for(retry_stop, timeout=1)
assert service._poller is None
assert service._stop_cleanup_task is cleanup_task
assert cleanup_task.done()
assert not service._stopping
assert "Subagent batch service shutdown exceeded" in caplog.text
await service.start()
restarted_poller = service._poller
assert restarted_poller is not None
assert restarted_poller is not original_poller
await asyncio.wait_for(service.stop(), timeout=1)
assert restarted_poller.done()
assert service._poller is None
def test_lifespan_uses_bounded_subagent_batch_shutdown() -> None:
source = inspect.getsource(gateway_app._gateway_lifespan)
assert "await _shutdown_subagent_batch_service(app)" in source
@pytest.mark.asyncio
async def test_batch_stop_requests_owned_cancellation_before_poller_drain(
monkeypatch: pytest.MonkeyPatch,
) -> None:
release_poller = asyncio.Event()
poller_cancelled = asyncio.Event()
requested: list[str] = []
async def slow_poller() -> None:
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
poller_cancelled.set()
await release_poller.wait()
async def item_work() -> None:
await asyncio.sleep(3600)
service = SubagentBatchService.__new__(SubagentBatchService)
service._stop = asyncio.Event()
service._poller = asyncio.create_task(slow_poller())
item_task = asyncio.create_task(item_work())
service._executions = {"item-1": item_task}
service._execution_ids = {"item-1": "execution-1"}
service._item_batches = {"item-1": "batch-1"}
monkeypatch.setattr(
batch_service_module,
"request_cancel_background_task",
requested.append,
)
stop_task = asyncio.create_task(service.stop())
await poller_cancelled.wait()
assert requested == ["execution-1"]
assert item_task.cancelled() or item_task.cancelling()
release_poller.set()
await stop_task