tractor/tests/test_cancel_soon.py

249 lines
8.5 KiB
Python
Raw Normal View History

'''
Synchronous cancellation after the RPC service nursery exits.
'''
from contextlib import contextmanager
from pathlib import Path
import platform
from traceback import format_exc
from uuid import uuid4
import pytest
import trio
from trio.testing import wait_all_tasks_blocked
import tractor
from tractor._testing import tractor_test
from tractor.ipc import _shm
async def start_late_cancel(
finalizer_path: str,
close_services: bool,
shm_key: str,
cancel_path: str,
) -> dict:
'''
Arrange cancellation from outside the RPC service nursery.
'''
actor: tractor.Actor = tractor.current_actor()
root_tn: trio.Nursery = actor._root_tn
service_tn: trio.Nursery = actor._service_tn
checked: trio.Event = trio.Event()
result_path: Path = Path(finalizer_path)
@contextmanager
def report_failure():
'''
Preserve child failures even after its IPC loop exits.
'''
try:
yield
except Exception:
if not result_path.exists():
result_path.write_text(format_exc())
# Unwind this fresh child even when the regression
# leaves its shielded parent-channel loop alive.
actor._parent_chan_cs.cancel()
root_tn.cancel_scope.cancel()
raise
def finalize() -> None:
'''
Prove runtime unwinding reached the lifetime callbacks.
'''
with report_failure():
assert checked.is_set()
assert root_tn._closed
assert actor.cancel_complete
# Completed teardown must not schedule into the closed
# Actor._root_tn, including repeated finalizer calls.
assert actor.cancel_soon() is False
assert actor.cancel_soon() is False
result_path.write_text('finalized')
actor.lifetime_stack.callback(finalize)
# ExitStack is LIFO: allocator destroy/close callbacks must
# finish before finalize publishes its success marker.
with report_failure():
shmarr = _shm.open_shm_ndarray(
size=4,
key=shm_key,
dtype=[('index', 'i8')],
)
async def cancel_after_rpc() -> None:
'''
Let the setup RPC return before shutting down services.
'''
with (
report_failure(),
trio.CancelScope(shield=True),
trio.fail_after(5),
):
# The parent first proves every segment exists and
# closes its probe handles, then releases this gate.
while not Path(cancel_path).exists():
await trio.sleep(0.01)
await wait_all_tasks_blocked()
if close_services:
service_tn.cancel_scope.cancel()
await wait_all_tasks_blocked()
assert service_tn._closed
assert not root_tn._closed
assert not actor.cancel_complete
assert actor._parent_chan_cs.shield
# No checkpoint between calls: the first schedules the
# root-owned task; the duplicate must not schedule one.
assert actor.cancel_soon() is True
assert actor.cancel_soon() is False
await actor._cancel_complete.wait()
await wait_all_tasks_blocked()
assert actor._parent_chan_cs.cancel_called
assert root_tn.cancel_scope.cancel_called
assert actor.cancel_soon() is False
checked.set()
root_tn.start_soon(cancel_after_rpc)
return shmarr.token
@pytest.mark.parametrize(
'infect_asyncio',
[
False,
pytest.param(
True,
marks=pytest.mark.skipif(
platform.system() == 'Windows',
reason='asyncio infection is unsupported on Windows',
),
),
],
)
@pytest.mark.parametrize('close_services', [False, True])
@tractor_test
async def test_cancel_soon_finishes_runtime(
reg_addr: tuple,
tmp_path: Path,
close_services: bool,
infect_asyncio: bool,
) -> None:
'''
Late Actor.cancel_soon must finish teardown after services exit.
An IB disconnect left Actor._service_tn closed while the root
nursery and shielded parent-channel loop survived. A late
synchronous cancellation then raised RuntimeError from
Nursery.start_soon, preventing the asyncio host from waiting
for its Trio guest and the actor's lifetime finalizers.
A fresh child schedules a root-nursery controller. Trio's
wait_all_tasks_blocked lets its setup RPC finish, then proves
service-nursery exit before requesting cancellation. The open
service variant also guards ordinary self-cancellation. Run
both with native Trio and an asyncio-infected child runtime.
Repeated requests before the canceller runs, after completion,
and from a lifetime callback must return False; only the first
request returns True. A shielded controller checks that
Actor.cancel actually cancels the
parent-channel scope and the root nursery. Only runtime exit
may write the finalizer marker; the parent waits for it before
leaving its actor nursery, so parent teardown cannot cause a
false pass. No signals or external services are involved.
The child allocates a four-row array via open_shm_ndarray.
A file gate holds cancellation until the parent has opened and
closed all three data/first/last segments. The allocator's
lifetime destroy/close callbacks precede the success marker.
Before exiting its actor nursery, the parent proves all three
names are gone. No parent lifetime callback owns these segments;
a finally block cleans only this test's unique names if a
failure leaks them, after the assertions have already failed.
'''
pytest.importorskip('numpy')
finalizer_path: Path = tmp_path / 'finalized'
cancel_path: Path = tmp_path / 'cancel'
shm_key: str = f'cancel_soon_{uuid4().hex}'
token: _shm.NDToken = _shm._make_token(
key=shm_key,
size=4,
dtype=[('index', 'i8')],
)
# Know the exact names even if allocation or the setup RPC
# fails partway through. _make_token also handles macOS names.
names: tuple[str, ...] = (
token.shm_name,
token.shm_first_index_name,
token.shm_last_index_name,
)
assert len(set(names)) == 3
# Reject any pre-existing name before entering our cleanup
# block: a collision would not belong to this test invocation.
for name in names:
try:
probe = _shm.SharedMemory(name, create=False)
except FileNotFoundError:
continue
else:
probe.close()
pytest.fail(f'SHM name already exists: {name}')
try:
with trio.fail_after(10):
async with tractor.open_nursery() as an:
portal: tractor.Portal = await an.start_actor(
'late_cancel',
enable_modules=[__name__],
infect_asyncio=infect_asyncio,
)
allocated: dict = await portal.run(
start_late_cancel,
finalizer_path=str(finalizer_path),
close_services=close_services,
shm_key=shm_key,
cancel_path=str(cancel_path),
)
assert _shm.NDToken.from_msg(allocated) == token
for name in names:
probe = _shm.SharedMemory(name, create=False)
probe.close()
cancel_path.touch()
while not finalizer_path.exists():
await trio.sleep(0.01)
result: str = finalizer_path.read_text()
assert result == 'finalized', result
remaining: list[str] = []
for name in names:
try:
probe = _shm.SharedMemory(name, create=False)
except FileNotFoundError:
continue
else:
probe.close()
remaining.append(name)
assert not remaining, remaining
finally:
# No scanning: only this invocation's three unique names.
# Run after child teardown, never before the absence check.
for name in names:
try:
leaked = _shm.SharedMemory(name, create=False)
except FileNotFoundError:
continue
try:
leaked.unlink()
finally:
leaked.close()