deer-flow/backend/tests/test_browser_close_cancellation.py
poijygfdyy b46fb476ed
fix(browser): keep session teardown alive across caller cancellation (#5444)
* test(browser): reproduce close cancellation orphan

* fix(browser): shield session teardown from caller cancellation

* test(browser): cover close-all teardown robustness

* fix(browser): harden close-all teardown submission

* test(browser): drain close-all failure callbacks
2026-09-15 08:14:42 +08:00

195 lines
6.4 KiB
Python

from __future__ import annotations
import asyncio
from concurrent.futures import Future
from typing import Any, cast
import pytest
import deerflow.community.browser_automation.session as session_module
from deerflow.community.browser_automation.session import BrowserSession, BrowserSessionManager
class _ControllablePrivateLoop:
"""Model the cancellation boundary between a caller loop and Playwright's loop."""
def __init__(self) -> None:
self.cleanup_futures: list[Future[None]] = []
self.run_cancelled = False
async def run(self, coro: Any) -> None:
# Current main awaits the private-loop proxy directly, so caller
# cancellation propagates through wrap_future and cancels cleanup.
coro.close()
try:
await asyncio.Future()
except asyncio.CancelledError:
self.run_cancelled = True
raise
def submit(self, coro: Any) -> Future[None]:
# The fixed close path hands cleanup to the private loop and awaits its
# concurrent future behind a shield. Closing the coroutine here avoids
# needing Playwright in this focused lifecycle test.
coro.close()
cleanup_future: Future[None] = Future()
self.cleanup_futures.append(cleanup_future)
return cleanup_future
class _FailingPrivateLoop(_ControllablePrivateLoop):
def __init__(self) -> None:
super().__init__()
self.submitted_coro: Any | None = None
def submit(self, coro: Any) -> Future[None]:
self.submitted_coro = coro
raise RuntimeError("private loop closed")
def _session(loop: _ControllablePrivateLoop) -> BrowserSession:
return BrowserSession(
cast(Any, loop),
headless=True,
timeout_ms=1000,
viewport={"width": 1000, "height": 500},
)
@pytest.mark.asyncio
async def test_browser_close_caller_cancellation_does_not_cancel_private_cleanup() -> None:
loop = _ControllablePrivateLoop()
session = _session(loop)
close_task = asyncio.create_task(session.close())
await asyncio.sleep(0)
close_task.cancel("caller stopped")
with pytest.raises(asyncio.CancelledError):
await close_task
assert not loop.run_cancelled
assert len(loop.cleanup_futures) == 1
assert not loop.cleanup_futures[0].cancelled()
loop.cleanup_futures[0].set_result(None)
await asyncio.sleep(0)
def test_submit_close_closes_coroutine_when_submission_fails() -> None:
loop = _FailingPrivateLoop()
session = _session(loop)
with pytest.raises(RuntimeError, match="private loop closed"):
session._submit_close()
assert loop.submitted_coro is not None
assert loop.submitted_coro.cr_frame is None
@pytest.mark.asyncio
async def test_manager_close_session_cancellation_keeps_detached_cleanup_running() -> None:
loop = _ControllablePrivateLoop()
session = _session(loop)
manager = BrowserSessionManager()
manager._sessions["thread-a"] = session
manager._last_used["thread-a"] = 0.0
close_task = asyncio.create_task(manager.close_session("thread-a"))
await asyncio.sleep(0)
assert "thread-a" not in manager._sessions
assert len(loop.cleanup_futures) == 1
close_task.cancel("request stopped")
with pytest.raises(asyncio.CancelledError):
await close_task
assert not loop.cleanup_futures[0].cancelled()
loop.cleanup_futures[0].set_result(None)
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_manager_close_all_submits_every_cleanup_before_cancellable_wait() -> None:
loop = _ControllablePrivateLoop()
manager = BrowserSessionManager()
manager._sessions.update({"thread-a": _session(loop), "thread-b": _session(loop)})
manager._last_used.update({"thread-a": 0.0, "thread-b": 0.0})
close_task = asyncio.create_task(manager.close_all_sessions())
await asyncio.sleep(0)
assert manager._sessions == {}
assert len(loop.cleanup_futures) == 2
close_task.cancel("shutdown interrupted")
with pytest.raises(asyncio.CancelledError):
await close_task
assert all(not future.cancelled() for future in loop.cleanup_futures)
for future in loop.cleanup_futures:
future.set_result(None)
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_manager_close_all_continues_after_sync_submission_failure() -> None:
failing_loop = _FailingPrivateLoop()
healthy_loop = _ControllablePrivateLoop()
manager = BrowserSessionManager()
manager._sessions.update({"broken": _session(failing_loop), "healthy": _session(healthy_loop)})
manager._last_used.update({"broken": 0.0, "healthy": 0.0})
close_task = asyncio.create_task(manager.close_all_sessions())
await asyncio.sleep(0)
assert manager._sessions == {}
assert len(healthy_loop.cleanup_futures) == 1
assert failing_loop.submitted_coro is not None
assert failing_loop.submitted_coro.cr_frame is None
healthy_loop.cleanup_futures[0].set_result(None)
assert await close_task == 2
@pytest.mark.asyncio
async def test_manager_close_all_consumes_group_failure_after_caller_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
loop = _ControllablePrivateLoop()
manager = BrowserSessionManager()
manager._sessions["thread-a"] = _session(loop)
manager._last_used["thread-a"] = 0.0
original_gather = asyncio.gather
original_consume = session_module._consume_future_exception
group_future: asyncio.Future[Any] | None = None
consumed: list[asyncio.Future[Any]] = []
def tracking_gather(*aws: Any, **kwargs: Any) -> asyncio.Future[Any]:
nonlocal group_future
group_future = cast(asyncio.Future[Any], original_gather(*aws, **kwargs))
return group_future
def recording_consume(future: asyncio.Future[Any]) -> None:
consumed.append(future)
original_consume(future)
monkeypatch.setattr(asyncio, "gather", tracking_gather)
monkeypatch.setattr(session_module, "_consume_future_exception", recording_consume)
close_task = asyncio.create_task(manager.close_all_sessions())
await asyncio.sleep(0)
assert group_future is not None
close_task.cancel("shutdown interrupted")
with pytest.raises(asyncio.CancelledError):
await close_task
loop.cleanup_futures[0].set_exception(RuntimeError("teardown failed"))
await asyncio.sleep(0)
await asyncio.sleep(0)
await asyncio.sleep(0)
assert group_future.done()
assert group_future in consumed