mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-26 18:06:00 +00:00
e344be8d94
* feat(tests): add Blockbuster runtime gate for event-loop blocking IO Adds a strict runtime gate that fails CI when sync blocking IO calls run on the asyncio event loop thread through DeerFlow business code. Components: - backend/tests/support/detectors/blocking_io_runtime.py — Blockbuster context scoped to `app.*` and `deerflow.*` so test infrastructure, pytest internals, and third-party libraries stay silent. - backend/tests/blocking_io/conftest.py — pytest_runtest_protocol hookwrapper that wraps every item (setup + call + teardown) with the strict context. Respects `@pytest.mark.allow_blocking_io` opt-out. - backend/tests/blocking_io/test_skills_load.py — regression anchor for the #1917 fix (asyncio.to_thread offload around LocalSkillStorage.load_skills). - backend/tests/blocking_io/test_sqlite_lifespan.py — regression anchor for the #1912 fix (asyncio.to_thread offload around ensure_sqlite_parent_dir). - backend/tests/blocking_io/test_gate_smoke.py — meta-test asserting the gate actually catches unoffloaded blocking IO and that the `@pytest.mark.allow_blocking_io` opt-out works. - backend/Makefile — `make test-blocking-io` target. - .github/workflows/backend-blocking-io-tests.yml — hard-fail PR gate on ubuntu-latest. Windows matrix deferred to follow-up. Dependencies: - blockbuster>=1.5.26,<1.6 added to dev group. Coverage boundary (called out in PR body): the gate only catches blocking IO on code paths the test suite actually exercises. Static AST inventory (separate, informational) is the complementary coverage tool. Three blind spot categories — untested paths, mocked-away paths, env-mismatched paths — are documented in the PR description. Findings surfaced while authoring this PR: - resolve_sqlite_conn_str in runtime/store/_sqlite_utils.py:19 does sync Path.resolve() -> os.path.abspath on the lifespan loop thread, ahead of the #1912 fix. Not addressed here; tracked as follow-up. Tests: 4 passed locally (`make test-blocking-io`). Lint/format: clean (`ruff check` and `ruff format --check`). * fix(tests): scope Blockbuster gate to blocking-io suite * fix(tests): harden Blockbuster runtime gate * test(blocking-io): add project rule extension point * test(blocking-io): address review cleanup
103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
"""Regression test: skill loading must remain releasable to a worker thread.
|
|
|
|
Anchors the production offload from `subagents/executor.py:_load_skills`,
|
|
where both `get_or_new_skill_storage` and the sync `storage.load_skills(...)`
|
|
method are dispatched via `asyncio.to_thread`. That fix addressed #1917,
|
|
where `os.walk` inside `load_skills` blocked the LangGraph async event loop.
|
|
|
|
This test invokes the production `_load_skills()` call path under the strict
|
|
Blockbuster context against a real `LocalSkillStorage` instance pointed at
|
|
a tmp directory. If the production `asyncio.to_thread` offload is removed,
|
|
Blockbuster raises `BlockingError` and this test fails.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_MISSING = object()
|
|
_EXECUTOR_IMPORT_MOCKS = (
|
|
"deerflow.agents",
|
|
"deerflow.agents.thread_state",
|
|
"deerflow.models",
|
|
)
|
|
|
|
|
|
def _seed_skill(skills_root: Path) -> None:
|
|
skill = skills_root / "public" / "demo"
|
|
skill.mkdir(parents=True, exist_ok=True)
|
|
(skill / "SKILL.md").write_text(
|
|
"---\nname: demo\ndescription: regression-test skill\n---\n# demo\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def _real_subagent_executor() -> Iterator[type]:
|
|
"""Import the real executor despite the suite-level circular-import mock."""
|
|
original_modules = {name: sys.modules.get(name, _MISSING) for name in _EXECUTOR_IMPORT_MOCKS}
|
|
original_executor = sys.modules.get("deerflow.subagents.executor", _MISSING)
|
|
parent_module = sys.modules.get("deerflow.subagents")
|
|
original_parent_executor = getattr(parent_module, "executor", _MISSING) if parent_module is not None else _MISSING
|
|
|
|
sys.modules.pop("deerflow.subagents.executor", None)
|
|
for name in _EXECUTOR_IMPORT_MOCKS:
|
|
sys.modules[name] = MagicMock()
|
|
|
|
try:
|
|
executor_module = importlib.import_module("deerflow.subagents.executor")
|
|
yield executor_module.SubagentExecutor
|
|
finally:
|
|
if original_executor is _MISSING:
|
|
sys.modules.pop("deerflow.subagents.executor", None)
|
|
else:
|
|
sys.modules["deerflow.subagents.executor"] = original_executor
|
|
|
|
if parent_module is not None:
|
|
if original_parent_executor is _MISSING:
|
|
try:
|
|
delattr(parent_module, "executor")
|
|
except AttributeError:
|
|
pass
|
|
else:
|
|
parent_module.executor = original_parent_executor
|
|
|
|
for name, module in original_modules.items():
|
|
if module is _MISSING:
|
|
sys.modules.pop(name, None)
|
|
else:
|
|
sys.modules[name] = module
|
|
|
|
|
|
async def test_load_skills_via_to_thread_does_not_block_event_loop(tmp_path: Path) -> None:
|
|
from deerflow.config.skills_config import SkillsConfig
|
|
from deerflow.subagents.config import SubagentConfig
|
|
|
|
_seed_skill(tmp_path)
|
|
|
|
with _real_subagent_executor() as SubagentExecutor:
|
|
executor = SubagentExecutor(
|
|
config=SubagentConfig(
|
|
name="demo",
|
|
description="Loads skills through the production async path.",
|
|
),
|
|
tools=[],
|
|
app_config=SimpleNamespace(skills=SkillsConfig(path=str(tmp_path))),
|
|
parent_model="test-model",
|
|
)
|
|
|
|
skills = await executor._load_skills()
|
|
|
|
assert isinstance(skills, list)
|
|
assert any(s.name == "demo" for s in skills)
|