ReMe/tests/unit/test_async_utils.py
jinliyl cd3cbbadd8
Some checks failed
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
CI and Release / Docker / Build and test / amd64 (push) Waiting to run
CI and Release / Docker / Build and test / arm64 (push) Waiting to run
CI and Release / Docker / Publish multi-platform tags (push) Blocked by required conditions
CI / Python packages / Build and verify distributions (push) Waiting to run
CI / Python quality / GitHub Actions (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Python tests / Unit Tests - py3.14 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
CI / ReMe Studio / Studio checks (push) Has been cancelled
fix(memory): preserve file consistency and decision client lifecycle (#672)
* fix(memory): keep writes and startup indexes consistent

* fix(release): verify current Auto Fin plugin backends

* fix(memory): preserve write ownership and deduplicate startup changes

* fix(compaction): offload decision client initialization safely

* fix(file-io): preserve cancellation origin and file permissions
2026-10-26 13:08:02 +08:00

100 lines
3.1 KiB
Python

"""Side-effecting thread calls retain ownership through cancellation."""
import asyncio
import threading
import time
import anyio
import pytest
from reme.utils.async_utils import complete_in_thread
@pytest.mark.asyncio
@pytest.mark.parametrize("cancellations", [0, 1, 3])
@pytest.mark.parametrize("fail", [False, True])
async def test_complete_in_thread_finishes_before_propagating_result(cancellations, fail):
"""Repeated cancellation waits for success or failure without detaching work."""
started, release, finished = threading.Event(), threading.Event(), threading.Event()
def operation():
started.set()
try:
assert release.wait(5)
if fail:
raise RuntimeError("thread failed")
return "complete"
finally:
finished.set()
task = asyncio.create_task(complete_in_thread(operation))
try:
assert await asyncio.to_thread(started.wait, 5)
for _ in range(cancellations):
task.cancel()
await asyncio.sleep(0)
assert not task.done()
assert not finished.is_set()
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
assert finished.is_set()
if cancellations:
with pytest.raises(asyncio.CancelledError):
task.result()
elif fail:
with pytest.raises(RuntimeError, match="thread failed"):
task.result()
else:
assert task.result() == "complete"
@pytest.mark.asyncio
@pytest.mark.parametrize("scope_name", ["fail_after", "move_on_after"])
@pytest.mark.parametrize("fail", [False, True])
async def test_complete_in_thread_preserves_anyio_cancel_scope(scope_name, fail):
"""AnyIO must recognize its original cancellation after the thread finishes."""
finished = threading.Event()
def operation():
time.sleep(0.05)
finished.set()
if fail:
raise RuntimeError("thread failed after cancellation")
async def run_scope():
with getattr(anyio, scope_name)(0.01) as scope:
await complete_in_thread(operation)
assert scope.cancelled_caught
if scope_name == "fail_after":
with pytest.raises(TimeoutError):
await run_scope()
else:
await run_scope()
assert finished.is_set()
@pytest.mark.asyncio
async def test_complete_in_thread_retains_first_cancellation_message():
"""Repeated cancellation must retain the original caller's reason."""
started, release = threading.Event(), threading.Event()
def operation():
started.set()
assert release.wait(5)
task = asyncio.create_task(complete_in_thread(operation))
try:
assert await asyncio.to_thread(started.wait, 5)
for message in ("original cancellation", "cancel again", "cancel once more"):
task.cancel(message)
await asyncio.sleep(0)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError, match="original cancellation"):
await task
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)