deer-flow/backend/tests/blocking_io/test_workspace_changes_cancellation.py
Jun d1f1c49dcd
fix(workspace-changes): avoid draining metadata scans on cancellation (#5234)
* fix(workspace-changes): keep metadata cancellation responsive

* test(workspace-changes): cover metadata cancellation latency

* docs(workspace-changes): document cancellation ownership

* style(workspace-changes): format cancellation regressions

* docs(workspace-changes): remove unapproved nested guidance

* fix(workspace-changes): log only real cancellation drains

* style(workspace-changes): apply repository ruff format

* docs(harness): record workspace scan cancellation ownership

* docs: compact harness guidance below inherited size limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-07 15:18:58 +08:00

121 lines
4.6 KiB
Python

"""Cancellation regressions for workspace snapshot scans.
Text snapshots own a temporary cache that must outlive an already-running scan,
so cancellation deliberately drains that worker before cleanup. Metadata-only
snapshots own no such resource and must propagate cancellation promptly while
still consuming/logging the worker's eventual outcome.
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
import threading
from pathlib import Path
from typing import Any
import pytest
from deerflow.workspace_changes import recorder
from deerflow.workspace_changes.types import WorkspaceSnapshot
pytestmark = pytest.mark.asyncio
async def _reset_paths(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
import deerflow.config.paths as paths_mod
monkeypatch.setattr(paths_mod, "_paths", None)
async def test_metadata_only_cancel_does_not_wait_for_scan_worker(tmp_path: Path, monkeypatch, caplog) -> None:
"""No text cache means cancellation must not wait for the worker scan."""
await _reset_paths(tmp_path, monkeypatch)
entered = threading.Event()
release = threading.Event()
finished = threading.Event()
def _blocking_scan(
*_args: Any,
text_cache_dir: str | Path | None = None,
**_kwargs: Any,
) -> WorkspaceSnapshot:
assert text_cache_dir is None
entered.set()
release.wait(timeout=5)
finished.set()
raise RuntimeError("late metadata scan failure")
monkeypatch.setattr(recorder, "scan_workspace_roots", _blocking_scan)
caplog.set_level(logging.INFO, logger=recorder.__name__)
task = asyncio.create_task(recorder.capture_workspace_snapshot("t1", include_text=False))
assert await asyncio.to_thread(entered.wait, 5), "metadata scan worker did not start"
try:
task.cancel()
for _ in range(5):
await asyncio.sleep(0)
assert task.done(), "metadata-only cancellation waited for a scan with no cache resource to protect"
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
assert await asyncio.to_thread(finished.wait, 5), "metadata scan worker did not finish after release"
for _ in range(100):
if any("Workspace scan failed after snapshot cancellation" in record.getMessage() for record in caplog.records):
break
await asyncio.sleep(0.01)
assert any("Workspace scan failed after snapshot cancellation" in record.getMessage() for record in caplog.records), "the detached metadata scan's late failure must be consumed and logged"
async def test_text_scan_cancel_logs_drain_and_late_failure(tmp_path: Path, monkeypatch, caplog) -> None:
"""A text-cache scan still drains, and that cancellation latency is observable."""
await _reset_paths(tmp_path, monkeypatch)
cache_root = tmp_path / "tmp"
cache_root.mkdir()
monkeypatch.setattr(tempfile, "tempdir", str(cache_root))
entered = threading.Event()
release = threading.Event()
def _blocking_scan(
*_args: Any,
text_cache_dir: str | Path | None = None,
**_kwargs: Any,
) -> WorkspaceSnapshot:
assert text_cache_dir is not None
cache_dir = Path(text_cache_dir)
assert cache_dir.exists()
entered.set()
release.wait(timeout=5)
assert cache_dir.exists(), "text cache was removed while the scan worker was still running"
raise RuntimeError("late text scan failure")
monkeypatch.setattr(recorder, "scan_workspace_roots", _blocking_scan)
caplog.set_level(logging.INFO, logger=recorder.__name__)
task = asyncio.create_task(recorder.capture_workspace_snapshot("t1", include_text=True))
assert await asyncio.to_thread(entered.wait, 5), "text scan worker did not start"
task.cancel()
for _ in range(5):
await asyncio.sleep(0)
assert not task.done(), "text-cache cancellation must keep ownership until the scan drains"
assert any("Waiting for cancelled workspace snapshot scan to finish before text-cache cleanup" in record.getMessage() for record in caplog.records), "entering the cancellation drain should be observable"
release.set()
with pytest.raises(asyncio.CancelledError):
await task
leftovers = await asyncio.to_thread(lambda: sorted(cache_root.glob("deerflow-workspace-changes-*")))
assert leftovers == [], f"cancelled text scan leaked a cache dir: {leftovers}"
assert any("Workspace scan failed after snapshot cancellation" in record.getMessage() for record in caplog.records), "a scan failure during cancellation drain must retain diagnostics"