deer-flow/backend/tests/blocking_io/test_skills_custom_router.py
xiaodu55 736b5a4216
fix(gateway): keep custom-skill rollback filesystem IO off the event loop (#5751)
* fix(gateway): keep custom-skill rollback filesystem IO off the event loop

Closes #5747. Three blocking sites remained on the Gateway event loop in
the rollback route and its shared response reader, all offloaded through
asyncio.to_thread following the #5729 pattern:

- storage.validate_skill_markdown_content() creates a temp dir and writes
  SKILL.md into it (site 1)
- the post-scan read of the file being replaced (site 2, named by #5729's
  test docstring)
- _read_custom_skill_response(): load_skills() walks every skill directory
  and read_custom_skill() opens SKILL.md — O(installed skills) loop time
  paid per accepted rollback and per GET /skills/custom/{name} (site 3)

A blocking_io regression drives the full accepted-rollback path (past the
security scan) under the strict gate; the two pre-existing cases only
covered the pre-scan branches.

* fix(gateway): keep the skill element type in the response-parts closure

Review nit on #5751: tuple[object, ...] erased the element type for the
_skill_to_response call; Skill is already imported in this module.
2026-09-23 15:38:40 +08:00

108 lines
4.5 KiB
Python

"""Regression anchor: the custom-skill rollback route must keep its history reads off the loop.
``rollback_custom_skill`` offloads storage construction, existence probes and
the ``custom/.history/<name>.jsonl`` read through ``asyncio.to_thread``, matching
the adjacent ``get_custom_skill_history`` handler. History entries contain the
full previous and new skill content, so reading and parsing the entire history
on the Gateway loop would stall other requests. The post-scan current-content
read is outside this anchor's coverage and remains a separate blocking-IO fix.
The two branches driven here both return before the awaited security scan, so
the anchor needs no scanner or model stub: the 404 branch covers construction
plus the existence probes, and the out-of-range branch covers the full
history-file read and parse.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import UUID
import pytest
from fastapi import HTTPException, Request
from app.gateway.routers.skills import SkillRollbackRequest, rollback_custom_skill
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
pytestmark = pytest.mark.asyncio
_SKILL_NAME = "loop-rollback-skill"
_SKILL_MD = f"---\nname: {_SKILL_NAME}\ndescription: Anchor fixture skill.\n---\n\n# {_SKILL_NAME}\n"
def _custom_dir() -> Path:
return get_paths().user_custom_skills_dir(get_effective_user_id())
@pytest.fixture(autouse=True)
def _isolate_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
def _admin_request() -> Request:
# AuthMiddleware normally supplies this state; keep the real admin check.
user = SimpleNamespace(id=UUID("11111111-2222-3333-4444-555555555555"), system_role="admin")
return Request({"type": "http", "headers": [], "state": {"user": user}})
def _install_skill() -> None:
skill_dir = _custom_dir() / _SKILL_NAME
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(_SKILL_MD, encoding="utf-8")
def _write_history(records: list[dict]) -> None:
history_dir = _custom_dir() / ".history"
history_dir.mkdir(parents=True, exist_ok=True)
(history_dir / f"{_SKILL_NAME}.jsonl").write_text("".join(json.dumps(r) + "\n" for r in records), encoding="utf-8")
async def test_rollback_missing_skill_does_not_block_event_loop() -> None:
"""The 404 branch must not resolve paths or probe the filesystem from the loop."""
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
with pytest.raises(HTTPException) as excinfo:
await rollback_custom_skill(_SKILL_NAME, SkillRollbackRequest(history_index=0), _admin_request(), config)
assert excinfo.value.status_code == 404
async def test_rollback_history_read_does_not_block_event_loop() -> None:
"""The out-of-range branch reads and parses the whole history file first."""
await asyncio.to_thread(_install_skill)
await asyncio.to_thread(_write_history, [{"action": "edit", "ts": 1, "prev_content": _SKILL_MD, "new_content": _SKILL_MD}])
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
with pytest.raises(HTTPException) as excinfo:
await rollback_custom_skill(_SKILL_NAME, SkillRollbackRequest(history_index=99), _admin_request(), config)
assert excinfo.value.status_code == 400
assert "history_index is out of range" in str(excinfo.value.detail)
async def test_rollback_accepted_path_does_not_block_event_loop(monkeypatch) -> None:
"""The accepted-rollback path (validate → scan → current-content read →
write → append → response read) must keep every filesystem operation off
the loop (#5747)."""
await asyncio.to_thread(_install_skill)
await asyncio.to_thread(
_write_history,
[{"action": "edit", "ts": 1, "prev_content": _SKILL_MD, "new_content": _SKILL_MD}],
)
monkeypatch.setattr(
"app.gateway.routers.skills.scan_skill_content",
AsyncMock(return_value=SimpleNamespace(decision="allow", reason="ok", static_findings=[])),
)
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
response = await rollback_custom_skill(_SKILL_NAME, SkillRollbackRequest(history_index=0), _admin_request(), config)
assert response.content == _SKILL_MD