deer-flow/backend/tests/blocking_io/test_dingtalk_receive_file.py
Aari 9e0fbd60fa
fix(sandbox): isolate concurrent subagent shell sessions (#5134)
* fix(sandbox): isolate concurrent subagent shell sessions

* fix(sandbox): make execution acquire idempotent

* fix(sandbox): close execution lifecycle gaps

* fix(sandbox): serialize retained client lifecycle

* fix(sandbox): close remaining client lifecycle gaps

* fix(sandbox): unwind failed client lookup

* fix(sandbox): protect internal lease identities

* fix(sandbox): make cancellation reconciliation durable

* fix(sandbox): fence cancelled workers and IM uploads
2026-09-02 21:05:23 +08:00

145 lines
5.2 KiB
Python

"""Regression anchor: DingTalk ``receive_file`` must not block the event loop.
``_receive_single_file`` prepares the thread directories, resolves the uploads
dir, scans it for the uniqueness claim, and writes the attachment — all blocking
filesystem IO that must run inside ``asyncio.to_thread`` (and sandbox sync must
go through ``acquire_async`` + an offloaded ``update_file``). This anchor drives
the real ``receive_file`` under the strict Blockbuster gate; if any of that
regresses back onto the event loop, Blockbuster raises ``BlockingError``.
The ``Paths`` construction is offloaded only because ``Paths.__init__`` resolves
paths synchronously; the surface under test (``receive_file``'s persist path) is
exercised on the event loop, not bypassed. The download itself is mocked — the
network leg is httpx-async and not the subject here.
"""
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
pytestmark = pytest.mark.asyncio
class _BlockingRemoteSandbox:
def __init__(self) -> None:
self.update_started = threading.Event()
self.allow_update = threading.Event()
self.closed = False
self.updates: list[tuple[str, bytes]] = []
self.released_scopes: list[str] = []
def update_file(self, path: str, content: bytes) -> None:
self.update_started.set()
assert self.allow_update.wait(timeout=2)
assert not self.closed
self.updates.append((path, content))
def release_command_scope(self, scope_id: str) -> None:
self.released_scopes.append(scope_id)
class _BlockingRemoteProvider:
def __init__(self) -> None:
self.sandbox = _BlockingRemoteSandbox()
self.release_calls: list[str] = []
async def acquire_async(self, thread_id=None, *, user_id=None):
return "remote-sandbox"
def get(self, sandbox_id: str):
return self.sandbox if sandbox_id == "remote-sandbox" else None
def release(self, sandbox_id: str) -> None:
self.release_calls.append(sandbox_id)
self.sandbox.closed = True
async def test_receive_file_persist_does_not_block_event_loop(tmp_path, monkeypatch) -> None:
from app.channels.dingtalk import DingTalkChannel
from app.channels.message_bus import MessageBus
from deerflow.config.paths import Paths
paths = await asyncio.to_thread(Paths, str(tmp_path))
monkeypatch.setattr("app.channels.dingtalk.get_paths", lambda: paths)
async def _acquire_async(thread_id, user_id=None):
return "local"
monkeypatch.setattr(
"app.channels.dingtalk.get_sandbox_provider",
lambda: SimpleNamespace(acquire_async=_acquire_async, get=lambda sid: None),
)
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"DATA")
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert "/uploads/a.pdf" in out.text
assert out.files == []
async def test_cancelled_receive_file_holds_sandbox_lease_until_remote_sync_finishes(tmp_path, monkeypatch) -> None:
from app.channels.dingtalk import DingTalkChannel
from app.channels.message_bus import MessageBus
from deerflow.config.paths import Paths
from deerflow.sandbox.lease import discard_sandbox_lease_manager, get_sandbox_lease_manager
paths = await asyncio.to_thread(Paths, str(tmp_path))
provider = _BlockingRemoteProvider()
manager = get_sandbox_lease_manager(provider)
monkeypatch.setattr("app.channels.dingtalk.get_paths", lambda: paths)
monkeypatch.setattr("app.channels.dingtalk.get_sandbox_provider", lambda: provider)
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"DATA")
await manager.acquire_async("active-run", "thread-1", user_id="ou-user")
receive_task = asyncio.create_task(
channel._receive_single_file(
"download-code",
"file",
"report.pdf",
"thread-1",
user_id="ou-user",
)
)
try:
assert await asyncio.to_thread(provider.sandbox.update_started.wait, 1)
for _ in range(3):
receive_task.cancel()
await asyncio.sleep(0)
assert not receive_task.done()
await manager.release_async("active-run")
assert provider.release_calls == []
assert not provider.sandbox.closed
provider.sandbox.allow_update.set()
with pytest.raises(asyncio.CancelledError):
await receive_task
assert provider.sandbox.updates == [("/mnt/user-data/uploads/report.pdf", b"DATA")]
assert provider.release_calls == ["remote-sandbox"]
assert provider.sandbox.closed
finally:
provider.sandbox.allow_update.set()
if not receive_task.done():
receive_task.cancel()
with pytest.raises(asyncio.CancelledError):
await receive_task
await manager.release_async("active-run")
discard_sandbox_lease_manager(provider)