mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 17:46:20 +00:00
* 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
195 lines
6.4 KiB
Python
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
|