mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-24 05:47:32 +00:00
237 lines
8.3 KiB
Python
237 lines
8.3 KiB
Python
"""Real watcher isolation and deterministic shutdown at the Step boundary."""
|
|
|
|
import asyncio
|
|
import threading
|
|
from contextlib import aclosing
|
|
from unittest.mock import AsyncMock
|
|
|
|
import anyio
|
|
import pytest
|
|
from watchfiles import Change
|
|
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.components.runtime_context import RuntimeContext
|
|
from reme.steps.index import _threaded_watch
|
|
from reme.steps.index._threaded_watch import watch_in_thread
|
|
from reme.steps.index.watch_changes import WatchChangesStep
|
|
|
|
|
|
async def _until(predicate, timeout=5):
|
|
async with asyncio.timeout(timeout):
|
|
while not predicate():
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
def _workers():
|
|
return {t for t in threading.enumerate() if t.name.startswith("reme-watch")}
|
|
|
|
|
|
def _step(workspace, stop, mode="watch"):
|
|
(workspace / "daily").mkdir(parents=True)
|
|
context = ApplicationContext(workspace_dir=str(workspace), daily_dir="daily")
|
|
step = WatchChangesStep(
|
|
app_context=context,
|
|
watch_mode=mode,
|
|
force_polling=False,
|
|
step=10,
|
|
debounce=50,
|
|
)
|
|
step.context = RuntimeContext(stop_event=stop, watch_dirs=["daily_dir"], watch_suffixes=["md"])
|
|
step.dispatch_steps = AsyncMock()
|
|
return step
|
|
|
|
|
|
def test_watch_mode_defaults_and_validation():
|
|
"""Existing callers retain awatch; unknown modes fail explicitly."""
|
|
assert WatchChangesStep().watch_mode == "awatch"
|
|
for value in ("invalid", "", None, True):
|
|
with pytest.raises(ValueError, match="watch_mode"):
|
|
WatchChangesStep(watch_mode=value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_mode_uses_awatch_and_closes_on_dispatch_failure(tmp_path, monkeypatch):
|
|
"""An exception in downstream processing closes the default generator."""
|
|
closed = []
|
|
|
|
async def fake_awatch(*_paths, **_kwargs):
|
|
try:
|
|
yield {(Change.added, str(tmp_path / "daily/note.md"))}
|
|
finally:
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr("reme.steps.index.watch_changes.awatch", fake_awatch)
|
|
step = _step(tmp_path, asyncio.Event(), mode="awatch")
|
|
step.dispatch_steps.side_effect = RuntimeError("dispatch failed")
|
|
with pytest.raises(RuntimeError, match="dispatch failed"):
|
|
await step.execute()
|
|
assert closed == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_41_real_watchers_deliver_changes_without_borrowing_request_threads(tmp_path):
|
|
"""Every watcher starts and dispatches beyond AnyIO's default capacity."""
|
|
before = _workers()
|
|
limiter = anyio.to_thread.current_default_thread_limiter()
|
|
borrowed_before = limiter.borrowed_tokens
|
|
stop = asyncio.Event()
|
|
steps = [_step(tmp_path / str(i), stop) for i in range(41)]
|
|
tasks = [asyncio.create_task(step.execute()) for step in steps]
|
|
try:
|
|
await _until(lambda: len(_workers() - before) == 41)
|
|
# Repeated writes also cover watchers whose initial scan is still starting.
|
|
async with asyncio.timeout(5):
|
|
while not all(step.dispatch_steps.await_count for step in steps):
|
|
for i in range(41):
|
|
path = tmp_path / str(i) / "daily/note.md"
|
|
path.write_text(path.read_text() + "x" if path.exists() else "x", encoding="utf-8")
|
|
await asyncio.sleep(0.05)
|
|
assert limiter.borrowed_tokens == borrowed_before
|
|
async with asyncio.timeout(1):
|
|
assert await anyio.to_thread.run_sync(lambda: "request served") == "request served"
|
|
for step in steps:
|
|
changes = step.dispatch_steps.await_args.kwargs["changes"]
|
|
assert changes and all(item["path"].endswith("note.md") for item in changes)
|
|
finally:
|
|
stop.set()
|
|
await asyncio.wait_for(asyncio.gather(*tasks), timeout=5)
|
|
assert _workers() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("stop_type", [asyncio.Event, threading.Event])
|
|
async def test_idle_stop_releases_private_worker(tmp_path, monkeypatch, stop_type):
|
|
"""Both supported background-job stop events terminate an idle wait."""
|
|
before = _workers()
|
|
entered = threading.Event()
|
|
|
|
def idle_watch(*_paths, stop_event, **_kwargs):
|
|
entered.set()
|
|
stop_event.wait()
|
|
yield from ()
|
|
|
|
monkeypatch.setattr(_threaded_watch, "watch", idle_watch)
|
|
stop = stop_type()
|
|
step = _step(tmp_path, stop)
|
|
task = asyncio.create_task(step.execute())
|
|
await _until(entered.is_set)
|
|
stop.set()
|
|
await asyncio.wait_for(task, timeout=1)
|
|
assert _workers() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("force_polling", [False, True])
|
|
async def test_real_idle_watcher_stops_with_low_power_poll_interval(tmp_path, force_polling):
|
|
"""Stopping must not wait for the five-second polling interval."""
|
|
before = _workers()
|
|
stop = asyncio.Event()
|
|
step = _step(tmp_path, stop)
|
|
step.force_polling = force_polling
|
|
task = asyncio.create_task(step.execute())
|
|
try:
|
|
await _until(lambda: bool(_workers() - before))
|
|
await asyncio.sleep(0.05)
|
|
finally:
|
|
stop.set()
|
|
await asyncio.wait_for(task, timeout=2)
|
|
assert _workers() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_waits_for_initial_scan_even_when_cancelled_twice(tmp_path, monkeypatch):
|
|
"""Cancellation cannot detach a still-running native watcher."""
|
|
before = _workers()
|
|
entered = threading.Event()
|
|
stopping = threading.Event()
|
|
release = threading.Event()
|
|
closed = threading.Event()
|
|
|
|
def slow_watch(*_paths, stop_event, **_kwargs):
|
|
try:
|
|
entered.set()
|
|
stop_event.wait()
|
|
stopping.set()
|
|
assert release.wait(5)
|
|
yield from ()
|
|
finally:
|
|
closed.set()
|
|
|
|
monkeypatch.setattr(_threaded_watch, "watch", slow_watch)
|
|
task = asyncio.create_task(_step(tmp_path, asyncio.Event()).execute())
|
|
try:
|
|
await _until(entered.is_set)
|
|
task.cancel()
|
|
await _until(stopping.is_set)
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
finally:
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert closed.is_set()
|
|
assert _workers() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_watch_failure_propagates_and_allows_restart(tmp_path, monkeypatch):
|
|
"""Supervision can retry a failed watcher without accumulating threads."""
|
|
before = _workers()
|
|
attempts = []
|
|
|
|
def broken_watch(*_paths, **_kwargs):
|
|
attempts.append(threading.get_ident())
|
|
raise OSError("watch failed")
|
|
yield # pylint: disable=unreachable
|
|
|
|
monkeypatch.setattr(_threaded_watch, "watch", broken_watch)
|
|
step = _step(tmp_path, asyncio.Event())
|
|
for _ in range(2):
|
|
with pytest.raises(OSError, match="watch failed"):
|
|
await step.execute()
|
|
assert _workers() == before
|
|
assert len(attempts) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_backpressure_and_dispatch_failure_close_on_worker(tmp_path, monkeypatch):
|
|
"""Batches are pulled on demand and the generator closes on its worker."""
|
|
before = _workers()
|
|
threads = []
|
|
closed = threading.Event()
|
|
|
|
def batches(*_paths, **_kwargs):
|
|
threads.append(threading.get_ident())
|
|
try:
|
|
yield {(Change.added, str(tmp_path / "daily/note.md"))}
|
|
raise AssertionError("must not pull a batch while dispatch is pending")
|
|
finally:
|
|
threads.append(threading.get_ident())
|
|
closed.set()
|
|
|
|
monkeypatch.setattr(_threaded_watch, "watch", batches)
|
|
step = _step(tmp_path, asyncio.Event())
|
|
|
|
async def fail_dispatch(*_args, **_kwargs):
|
|
await asyncio.sleep(0.05)
|
|
assert not closed.is_set()
|
|
raise RuntimeError("dispatch failed")
|
|
|
|
step.dispatch_steps.side_effect = fail_dispatch
|
|
with pytest.raises(RuntimeError, match="dispatch failed"):
|
|
await step.execute()
|
|
assert closed.is_set()
|
|
assert len(threads) == 2 and threads[0] == threads[1] != threading.get_ident()
|
|
assert _workers() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_stopped_watcher_does_not_start_thread(tmp_path):
|
|
"""A stop requested before startup allocates no worker."""
|
|
before = _workers()
|
|
stop = asyncio.Event()
|
|
stop.set()
|
|
async with aclosing(watch_in_thread(tmp_path, stop_event=stop)) as watcher:
|
|
assert [batch async for batch in watcher] == []
|
|
assert _workers() == before
|