mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 12:30:37 +00:00
* fix(subagents): drain abandoned capacity waiters before cancellation Continue the unmerged queued-waiter cleanup approach from #7571, with deterministic cancellation and timeout controls. Co-authored-by: 熊小雨 <1178327328@qq.com> * test(subagents): gate capacity timeout cleanup deterministically --------- Co-authored-by: linkangjun <331227811+linkangjun@users.noreply.github.com> Co-authored-by: 熊小雨 <1178327328@qq.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
164 lines
5.7 KiB
Python
164 lines
5.7 KiB
Python
"""Cross-thread safety of the shared subagent execution capacity."""
|
|
|
|
import asyncio
|
|
from collections import deque
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
|
|
from deerflow.subagents.capacity import SubagentExecutionCapacity
|
|
|
|
|
|
class _NoIterationDeque(deque):
|
|
"""A deque whose iteration always fails, as if mutated concurrently."""
|
|
|
|
def __iter__(self):
|
|
raise RuntimeError("deque mutated during iteration")
|
|
|
|
|
|
def test_snapshot_does_not_iterate_waiters():
|
|
"""snapshot() must be readable from a non-loop thread.
|
|
|
|
``configure_subagent_execution_capacity`` reads a snapshot while the loop
|
|
thread owns the waiters deque; iterating it cross-thread can raise
|
|
``deque mutated during iteration``. The snapshot must therefore derive
|
|
``queued`` without iterating.
|
|
"""
|
|
capacity = SubagentExecutionCapacity(SubagentRuntimeConfig(max_running=2, max_queued=4))
|
|
capacity._waiters = _NoIterationDeque(range(2))
|
|
|
|
snapshot = capacity.snapshot()
|
|
|
|
assert snapshot.max_running == 2
|
|
assert snapshot.max_queued == 4
|
|
assert snapshot.queued == 2
|
|
assert snapshot.running == 0
|
|
assert snapshot.admission_policy == "queue"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_reports_running_and_queued_waiters():
|
|
"""The derived queued count still reflects pending waiters."""
|
|
capacity = SubagentExecutionCapacity(SubagentRuntimeConfig(max_running=1, max_queued=4, queue_timeout_seconds=5))
|
|
|
|
async def hold_slot():
|
|
async with capacity.slot():
|
|
await asyncio.sleep(60)
|
|
|
|
holder = asyncio.create_task(hold_slot())
|
|
queued = asyncio.create_task(capacity._acquire())
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
|
|
snapshot = capacity.snapshot()
|
|
assert snapshot.running == 1
|
|
assert snapshot.queued == 1
|
|
finally:
|
|
queued.cancel()
|
|
holder.cancel()
|
|
await asyncio.gather(queued, holder, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_cancellation_cannot_leak_running_slot(monkeypatch):
|
|
"""A second cancellation during cleanup must not strand process capacity."""
|
|
capacity = SubagentExecutionCapacity(SubagentRuntimeConfig(max_running=1, max_queued=1, queue_timeout_seconds=1))
|
|
entered = asyncio.Event()
|
|
release_started = asyncio.Event()
|
|
never = asyncio.Event()
|
|
original_release = capacity._release
|
|
|
|
async def observed_release():
|
|
release_started.set()
|
|
await original_release()
|
|
|
|
monkeypatch.setattr(capacity, "_release", observed_release)
|
|
|
|
async def hold_slot():
|
|
async with capacity.slot():
|
|
entered.set()
|
|
await never.wait()
|
|
|
|
holder = asyncio.create_task(hold_slot())
|
|
await asyncio.wait_for(entered.wait(), timeout=1)
|
|
await capacity._lock.acquire()
|
|
try:
|
|
holder.cancel()
|
|
await asyncio.wait_for(release_started.wait(), timeout=1)
|
|
holder.cancel()
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
capacity._lock.release()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await holder
|
|
|
|
assert capacity.snapshot().running == 0
|
|
async with asyncio.timeout(0.5):
|
|
async with capacity.slot():
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("owner_released", [False, True])
|
|
@pytest.mark.parametrize("abandonment", ["cancel", "timeout"])
|
|
async def test_abandoned_waiter_drains_cleanup_after_repeated_cancellation(monkeypatch, owner_released, abandonment):
|
|
"""Once cleanup starts, more cancellations cannot orphan a transferred slot."""
|
|
waiter_ready = asyncio.Event()
|
|
expire_waiter = asyncio.Event()
|
|
cleanup_started = asyncio.Event()
|
|
|
|
class ObservedLock(asyncio.Lock):
|
|
async def acquire(self):
|
|
if self.locked():
|
|
cleanup_started.set()
|
|
return await super().acquire()
|
|
|
|
capacity = SubagentExecutionCapacity(SubagentRuntimeConfig(max_running=1, max_queued=1))
|
|
capacity._lock = ObservedLock()
|
|
real_wait_for = asyncio.wait_for
|
|
|
|
async def wait_for_slot(awaitable, timeout):
|
|
if capacity._waiters and awaitable is capacity._waiters[0]:
|
|
waiter_ready.set()
|
|
if abandonment == "timeout":
|
|
# Exercise asyncio's timeout only once cleanup will contend for the lock.
|
|
await expire_waiter.wait()
|
|
timeout = 0
|
|
return await real_wait_for(awaitable, timeout)
|
|
|
|
monkeypatch.setattr(asyncio, "wait_for", wait_for_slot)
|
|
await capacity._acquire()
|
|
queued = asyncio.create_task(capacity._acquire())
|
|
try:
|
|
await real_wait_for(waiter_ready.wait(), 3)
|
|
async with capacity._lock:
|
|
if abandonment == "cancel":
|
|
if owner_released:
|
|
capacity._release_locked()
|
|
queued.cancel()
|
|
else:
|
|
expire_waiter.set()
|
|
await real_wait_for(cleanup_started.wait(), 3)
|
|
if owner_released and abandonment == "timeout":
|
|
# A timed-out waiter's future is cancelled, so release skips it.
|
|
capacity._release_locked()
|
|
queued.cancel("cancelled while draining")
|
|
await asyncio.sleep(0)
|
|
queued.cancel("cancelled again")
|
|
|
|
async with asyncio.timeout(3):
|
|
with pytest.raises(asyncio.CancelledError, match="cancelled while draining"):
|
|
await queued
|
|
finally:
|
|
if not queued.done():
|
|
queued.cancel()
|
|
await asyncio.gather(queued, return_exceptions=True)
|
|
if not owner_released:
|
|
await capacity._release()
|
|
snapshot = capacity.snapshot()
|
|
assert (snapshot.running, snapshot.queued) == (0, 0)
|
|
async with asyncio.timeout(0.5):
|
|
async with capacity.slot():
|
|
pass
|