deer-flow/backend/tests/blocking_io/test_threads_delete.py
Hyeonsang Cho 0223d949bf
fix(threads): remove a deleted thread's files off the event loop (#6319)
* fix(threads): remove a deleted thread's files off the event loop

DELETE /api/threads/{id} called shutil.rmtree over the thread directory
directly on the event loop, so deleting a thread with a large workspace
stalled every other Gateway request and live SSE stream until the whole
tree was gone. Branch copying in the same router already offloads its
filesystem work through run_file_io; deletion did not.

The removal now runs on the file-IO pool. It is wrapped in await_drained
rather than bare-awaited: cancelling the await abandons the worker, not
the removal, so the durable delete reservation would otherwise release
while files are still being deleted, and a newly admitted run could
write into a directory that is about to disappear.

* docs(changelog): reference #6319 in the thread delete off-loop entry

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-05 21:27:46 +08:00

61 lines
2.4 KiB
Python

"""Regression anchor: deleting a thread must not block the event loop.
``DELETE /api/threads/{id}`` removes the thread's whole directory tree
(workspace, uploads, outputs). ``shutil.rmtree`` over a large workspace
stalls every other request on the Gateway loop, so the handler offloads it
via ``run_file_io``; if the removal regresses onto the event loop, the strict
Blockbuster gate raises ``BlockingError``, the handler maps it to a 500, and
this test fails.
The handler is driven through ``__wrapped__`` (past the authz decorator) so
the goal lock, the thread-operation reservation and every cleanup step run as
in production. Imports live at module scope so their file reads happen at
collection time, not on the loop under test; test-side seeding and checks are
offloaded with ``asyncio.to_thread``.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from app.gateway.routers import threads
from deerflow.config.paths import Paths
from deerflow.runtime.user_context import get_effective_user_id
pytestmark = pytest.mark.asyncio
class _RunManager:
@asynccontextmanager
async def reserve_thread_operation(self, _thread_id: str, **_kwargs):
yield
def _seed_workspace(paths: Paths, thread_id: str, user_id: str) -> Path:
paths.ensure_thread_dirs(thread_id, user_id=user_id)
workspace = paths.sandbox_work_dir(thread_id, user_id=user_id)
for index in range(20):
(workspace / f"file-{index}.txt").write_text("content", encoding="utf-8")
return paths.thread_dir(thread_id, user_id=user_id)
async def test_delete_thread_does_not_block_event_loop(tmp_path: Path) -> None:
user_id = get_effective_user_id()
# test-side setup and seeding (offloaded; not exercised on the loop)
paths = await asyncio.to_thread(Paths, tmp_path)
thread_dir = await asyncio.to_thread(_seed_workspace, paths, "thread-loop-delete", user_id)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(run_manager=_RunManager(), checkpointer=None)))
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
response = await threads.delete_thread_data.__wrapped__("thread-loop-delete", request)
assert response.success is True
assert response.message == "Deleted local thread data for thread-loop-delete"
assert not await asyncio.to_thread(thread_dir.exists)