tractor/tests/test_aio_channel_exit.py

676 lines
25 KiB
Python

'''
Inter-loop channel teardown must handle the issued exit signal.
'''
import asyncio
import os
from pathlib import Path
import signal
import subprocess
import sys
from contextlib import asynccontextmanager as acm
from collections.abc import AsyncIterator
import pytest
import tractor
from tractor import (
to_asyncio,
trionics,
)
import trio
_num_tasks: int = 2
_rpc_repetitions: int = 2
_actor_test_timeout_s: float = 10
async def exercise_trio_side_exit(
cached: bool,
cancel_trio_side: bool,
suppress: bool,
expect_aio_side_err_type: str|None,
greenback_shim: bool,
suppress_aio_side_tte: bool = False,
) -> None:
'''
Exit direct and cached inter-loop channels in an infected actor.
Failure mechanism:
- The trio-side parent task exits with asyncio still running.
- ``open_channel_from()`` signals the asyncio task to exit.
- Previously, that signal escaped channel teardown and became
an unhandled exception from the asyncio done callback.
Cases:
- ``cached`` wraps the channel API in ``maybe_open_context()``.
- ``cancel_trio_side`` cancels the caller at a Trio checkpoint.
- ``suppress`` selects graceful or strict exit-signal reporting.
- ``expect_aio_side_err_type`` is a class name or ``None``.
Class names travel as RPC payloads; actual exception objects
remain inside this actor for identity/cause assertions.
- ``greenback_shim`` wraps the asyncio task without enabling PDB.
- ``suppress_aio_side_tte`` handles the issued signal and returns
normally instead of raising it or a distinct cleanup failure.
Ordering and proof:
- ``tn.start()`` waits for a sibling subscription; ``release``
lets it retire while the original subscriber remains alive.
- The asyncio task must finish before channel-context exit ends.
- Identity assertions distinguish our signal from cleanup faults.
- Each of ``_num_tasks`` iterations opens a fresh asyncio task.
- A host-loop callback sets ``drained`` before callback-failure
inspection, independently of actor/RPC survival.
'''
if expect_aio_side_err_type not in {
None,
'RuntimeError',
'TrioTaskExited',
}:
raise ValueError(
f'Unknown aio error case: {expect_aio_side_err_type!r}'
)
# Keep the actual objects: equality/type checks cannot prove that
# teardown distinguishes an issued signal from a new same-type
# asyncio failure. Task/signal/failure entries follow iteration
# order; callback_errors records host-loop exception contexts.
callback_errors: list[dict] = []
tasks: list[asyncio.Task] = []
failures: list[Exception] = []
signals: list[BaseException] = []
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(
lambda loop, context: callback_errors.append(context),
)
async def block_until_trio_side_exit(
chan: to_asyncio.LinkedTaskChannel,
) -> None:
'''
Block until the channel API signals trio-side parent exit.
- Record task identity, finish startup, send a channel value,
then await an event which is never set.
- Record the received ``TrioTaskExited`` object and either
re-raise it or raise the selected distinct cleanup failure.
- ``tasks``, ``signals`` and ``failures`` retain the objects
checked after teardown; ``greenback_shim`` selects whether
this coroutine initializes a Greenback portal.
'''
task: asyncio.Task|None = asyncio.current_task()
assert task is not None
tasks.append(task)
if greenback_shim:
import greenback
await greenback.ensure_portal()
chan.started_nowait('ready')
chan.send_nowait('tick')
try:
await asyncio.Event().wait()
except to_asyncio.TrioTaskExited as signal:
signals.append(signal)
# Returning here tests successful asyncio-side completion
# after it handles the trio-side parent's exit signal.
if suppress_aio_side_tte:
return
# Healthy cleanup re-raises the very signal issued by
# open_channel_from(); suppression uses object identity.
if expect_aio_side_err_type is None:
raise signal
failure: RuntimeError|to_asyncio.TrioTaskExited
if expect_aio_side_err_type == 'RuntimeError':
# Fail during cleanup after receiving the channel
# owner's exit notification.
failure = RuntimeError(
'asyncio cleanup failed after trio-side exit',
)
elif expect_aio_side_err_type == 'TrioTaskExited':
# Deliberately use the SAME TYPE as the issued signal
# but a DIFFERENT INSTANCE. A blanket TrioTaskExited
# handler would hide this distinct asyncio error.
failure = to_asyncio.TrioTaskExited(
'new asyncio TTE, not the issued exit signal',
)
# Retain both objects: assertions below require the new
# failure to propagate with the issued signal as cause.
# "Independent" means distinct from the issued signal;
# the cleanup failure still follows that notification.
failures.append(failure)
raise failure from signal
@acm
async def open_raw_aio_chan() -> AsyncIterator[
to_asyncio.LinkedTaskChannel
]:
'''
Open the blocking asyncio task through open_channel_from().
- Run ``block_until_trio_side_exit`` using the actual
``to_asyncio.open_channel_from()`` API.
- Forward captured ``suppress`` unchanged so strict reporting
and graceful suppression exercise the same ownership path.
- Yield after the asyncio task's startup handshake. Context
exit signals that task and waits for it to finish.
'''
chan: to_asyncio.LinkedTaskChannel
first: str
async with to_asyncio.open_channel_from(
block_until_trio_side_exit,
suppress_graceful_exits=suppress,
) as (chan, first):
assert first == 'ready'
yield chan
@acm
async def open_maybe_cached_aio_chan() -> AsyncIterator[
to_asyncio.LinkedTaskChannel
]:
'''
Optionally cache the channel with ``maybe_open_context()``.
With captured ``cached=True``, overlapping calling trio.Tasks
receive the same channel instance. Otherwise each call enters
``open_raw_aio_chan()`` directly.
'''
chan: to_asyncio.LinkedTaskChannel
if cached:
async with trionics.maybe_open_context(
acm_func=open_raw_aio_chan,
# Same factory/key means both calling trio.Tasks get
# the same underlying channel. Reuse it on subsequent
# iterations to check retirement and reallocation.
key='aio_chan',
) as (_, chan):
yield chan
else:
async with open_raw_aio_chan() as chan:
yield chan
async def subscribe_cached_chan(
expected: to_asyncio.LinkedTaskChannel,
release: trio.Event,
*,
task_status: trio.TaskStatus[None] = (
trio.TASK_STATUS_IGNORED
),
) -> None:
'''
Close a subscription while another stays open in the caller.
- Enter ``open_maybe_cached_aio_chan`` in another trio.Task.
- Require ``expected`` identity to prove actual cache reuse.
- Report started only once the broadcast subscription exists.
- Wait for ``release`` before exiting; the caller joins this
task before checking that the asyncio task is still alive.
'''
other: to_asyncio.LinkedTaskChannel
async with open_maybe_cached_aio_chan() as other:
assert other is expected
async with other.subscribe():
task_status.started()
await release.wait()
try:
task_index: int
for task_index in range(_num_tasks):
caught: BaseException|None = None
try:
cs: trio.CancelScope
chan: to_asyncio.LinkedTaskChannel
bchan: trionics.BroadcastReceiver
with trio.CancelScope() as cs:
async with open_maybe_cached_aio_chan() as chan:
async with chan.subscribe() as bchan:
if cached:
release: trio.Event = trio.Event()
tn: trio.Nursery
async with (
trio.open_nursery() as tn
):
await tn.start(
subscribe_cached_chan,
chan,
release,
)
release.set()
# The sibling has closed its handles.
# Our subscription still retains the
# aio task; this is not final exit.
assert not tasks[task_index].done()
# Consume a real broadcast value before
# exit or last-consumer cancellation.
assert await bchan.receive() == 'tick'
if cancel_trio_side:
cs.cancel()
await trio.lowlevel.checkpoint()
except Exception as error:
caught = error
assert tasks[task_index].done()
num_signals: int = len(signals)
expect_num_signals: int = task_index + 1
task_error: BaseException|None = (
tasks[task_index].exception()
)
assert num_signals == expect_num_signals, (
f'Expected one issued TTE per asyncio task\n'
f'task index: {task_index}\n'
f'expected signals: {expect_num_signals}\n'
f'recorded signal count: {num_signals}\n'
f'caught trio-side error: {caught!r}\n'
f'asyncio task error: {task_error!r}\n'
f'recorded signals: {signals!r}\n'
)
if expect_aio_side_err_type:
# For TTE, matching the issued signal's type must NOT
# suppress a distinct asyncio-side exception.
# Keep its identity AND its original shutdown cause.
assert caught is failures[task_index]
assert caught.__cause__ is signals[task_index]
elif suppress_aio_side_tte:
assert caught is None, repr(caught)
assert tasks[task_index].exception() is None
elif not suppress:
# Strict reporting returns the exact issued signal,
# not a new wrapper or an asyncio-created substitute.
assert caught is signals[task_index]
else:
# Default policy absorbs expected owner exit, even
# when the cached resource's last consumer cancels.
assert caught is None, repr(caught)
# Cross the host loop's ready queue before accepting callback
# cleanliness, independently of actor survival.
drained: trio.Event = trio.Event()
loop.call_soon(drained.set)
await drained.wait()
if not expect_aio_side_err_type:
# Expected shutdown must have no unhandled callbacks.
# Fault cases above test propagation and causes, without
# asserting a broader callback-cleanliness policy.
callback_report: str = '\n'.join(
f'callback {index}:\n'
f' message: {context.get("message")!r}\n'
f' exception: {context.get("exception")!r}\n'
f' handle: {context.get("handle")!r}\n'
f' task: {context.get("task")!r}\n'
f' future: {context.get("future")!r}'
for index, context in enumerate(callback_errors)
)
assert not callback_errors, (
f'Unhandled asyncio callbacks after channel exit\n'
f'cached: {cached}\n'
f'cancel_trio_side: {cancel_trio_side}\n'
f'suppress_graceful_exits: {suppress}\n'
f'greenback_shim: {greenback_shim}\n'
f'{callback_report}\n'
)
finally:
loop.set_exception_handler(previous_handler)
@pytest.mark.parametrize(
'greenback_shim',
[False, True],
ids=['native', 'greenback'],
)
@pytest.mark.parametrize(
'infect_root',
[False, True],
ids=['infected-child', 'infected-root'],
)
@pytest.mark.parametrize(
'cached,cancel_trio_side,suppress,expect_aio_side_err_type,'
'suppress_aio_side_tte',
[
# Expected TTE ack: direct use, last cached caller
# exit, and cancellation of that last cached caller.
pytest.param(
False, False, True, None, False,
id='direct-close',
),
pytest.param(
True, False, True, None, False,
id='last-cache-user',
),
pytest.param(
True, True, True, None, False,
id='cancelled-last-cache-user',
),
# Strict reporting must preserve the issued TTE's identity.
pytest.param(
True, False, False, None, False,
id='explicit-exit-reporting',
),
# New asyncio-side cleanup failures must escape suppression,
# even when the exception class matches the issued signal.
pytest.param(
True, False, True, 'RuntimeError', False,
id='independent-cleanup-error',
),
pytest.param(
True, False, True, 'TrioTaskExited', False,
id='independent-same-type-error',
),
# The asyncio task may handle the TTE and finish normally.
pytest.param(
False, False, True, None, True,
id='direct-suppress-tte-and-return',
),
pytest.param(
True, False, True, None, True,
id='cached-suppress-tte-and-return',
),
# Strict trio-side policy must not manufacture a task error
# when the asyncio-side task handled its signal successfully.
pytest.param(
False, False, False, None, True,
id='strict-direct-suppress-tte-and-return',
),
pytest.param(
True, False, False, None, True,
id='strict-cached-suppress-tte-and-return',
),
],
)
def test_channel_owner_exit(
reg_addr: tuple[str, int],
cached: bool,
cancel_trio_side: bool,
suppress: bool,
expect_aio_side_err_type: str|None,
greenback_shim: bool,
infect_root: bool,
suppress_aio_side_tte: bool,
) -> None:
'''
Exercise trio-side channel exit in an infected root or child.
Regression:
- ``open_channel_from()`` signalled the running asyncio task when
its trio-side parent task left the channel context.
- Raising that issued ``TrioTaskExited`` escaped default graceful
suppression and was also raised from the asyncio done callback.
Proof boundary:
- Infected-child cases invoke ``exercise_trio_side_exit`` by TCP
RPC. Infected-root cases call it directly in the guest Trio run
hosted by asyncio, with the root actor runtime open.
- Both use the actual channel API and broadcast subscriptions;
cached cases additionally wrap it in ``maybe_open_context()``.
- Sequential repeats check continued actor usability after prior
asyncio tasks finish; the child path also checks RPC usability.
- Strict reporting and independent faults retain their identity;
broad exception-type suppression cannot satisfy the matrix.
- The deadline bounds hangs; no live broker or debugger is used.
Downstream reproduction:
Piker encountered this during quote-task restart after a farm
reset, using a cached channel. This test needs neither that
application nor caching in its direct-channel case.
'''
if not infect_root:
async def main() -> None:
'''
Run the selected case directly in a root or via child
RPCs.
- reg_addr isolates the runtime's TCP registry.
- Each call finishes before the next starts.
- The child path explicitly cancels the actor after its
RPCs; completing an RPC does not end the actor's
lifetime.
- A delayed done-callback failure could terminate the
child after an RPC body finishes. Another RPC checks
usability; the endpoint also inspects callback
exceptions directly.
'''
an: tractor.ActorNursery
with trio.fail_after(_actor_test_timeout_s):
async with tractor.open_nursery(
registry_addrs=[reg_addr],
) as an:
portal: tractor.Portal = await an.start_actor(
'aio_channel_owner',
enable_modules=[__name__],
infect_asyncio=True,
)
# portal.run() waits for this remote task's final
# result. These RPCs are sequential, not concurrent;
# only the endpoint's subscription tasks overlap.
for _ in range(_rpc_repetitions):
await portal.run(
exercise_trio_side_exit,
cached=cached,
cancel_trio_side=cancel_trio_side,
suppress=suppress,
expect_aio_side_err_type=(
expect_aio_side_err_type
),
greenback_shim=greenback_shim,
suppress_aio_side_tte=suppress_aio_side_tte,
)
await portal.cancel_actor()
trio.run(main)
else: # infected-root case
async def infected_root_main():
'''
Same as `main` above except invoke
`exercise_trio_side_exit` in-task with no subactor spawn.
'''
# run_as_asyncio_guest() supplies the host asyncio loop.
# No RPC/child actor is involved: call the same endpoint
# directly with an infected root runtime open.
with trio.fail_after(_actor_test_timeout_s):
async with tractor.open_root_actor(
registry_addrs=[reg_addr],
):
for _ in range(_rpc_repetitions):
await exercise_trio_side_exit(
cached=cached,
cancel_trio_side=cancel_trio_side,
suppress=suppress,
expect_aio_side_err_type=(
expect_aio_side_err_type
),
greenback_shim=greenback_shim,
suppress_aio_side_tte=(
suppress_aio_side_tte
),
)
to_asyncio.run_as_asyncio_guest(
trio_main=infected_root_main,
)
async def suppress_tte_and_block(marker: str) -> None:
'''
Leave channel teardown waiting on a task that handled its TTE.
``marker`` is a test-owned file shared with the parent process.
Write it ONLY after receiving the issued signal, so cancellation
of the actor nursery cannot race ahead of the blocked teardown.
'''
async def block_until_trio_side_exit(
chan: to_asyncio.LinkedTaskChannel,
) -> None:
'''
Handle the issued TTE but keep the asyncio task running.
Capture marker to acknowledge that the hang precondition was
reached. Ordinary later asyncio cancellation remains enabled;
the assertion is actor termination, not mandatory hard-kill.
'''
chan.started_nowait()
try:
await asyncio.Event().wait()
except to_asyncio.TrioTaskExited as signal:
# Publish the marker only for this channel's issued TTE,
# after trio-side context exit has actually begun.
assert signal is chan._aio_owner_exit
assert chan._trio_exited
assert chan._aio_task is asyncio.current_task()
assert not chan._aio_task.done()
Path(marker).write_text(str(os.getpid()))
await asyncio.Event().wait()
chan: to_asyncio.LinkedTaskChannel
async with to_asyncio.open_channel_from(
block_until_trio_side_exit,
) as (chan, first):
assert first is None
assert chan._aio_task is not None
assert not chan._aio_task.done()
assert not chan._trio_exited
# Leaving this body immediately sends TTE. Context exit then
# waits for the asyncio task, which handles TTE and blocks
# again; the RPC cannot finish without external cancellation.
async def cancel_an_with_blocked_aio(
marker: str,
host: str,
port: int,
) -> None:
'''
Cancel the actor nursery after its child suppresses channel TTE.
Called in a separate parent process by the watchdog test below.
ActorNursery.cancel() must terminate the child and nursery exit
must join it. A deadline in the pytest process detects failure
independently of either actor's event loop.
'''
async def invoke_blocked_rpc() -> None:
'''
Keep one RPC pending while the parent cancels its actor tree.
The parent joins this `trio` task through `tn` after
`an.cancel()`.
'''
await portal.run(
suppress_tte_and_block,
marker=marker,
)
an: tractor.ActorNursery
async with tractor.open_nursery(
registry_addrs=[(host, port)],
debug_mode=False,
) as an:
portal: tractor.Portal = await an.start_actor(
'blocked_aio_channel',
enable_modules=[__name__],
infect_asyncio=True,
debug_mode=False,
)
# Retain the runtime's process handle across nursery cleanup;
# successful cancel-RPC delivery alone does not prove exit.
child, = an._children.values()
proc: trio.Process = child[1]
tn: trio.Nursery
async with trio.open_nursery() as tn:
tn.start_soon(invoke_blocked_rpc)
while not Path(marker).exists():
await trio.sleep(0.01)
await an.cancel()
tn.cancel_scope.cancel()
assert proc.poll() is not None, (
'ActorNursery exited without joining the blocked child'
)
def test_cancel_an_reaps_blocked_aio(
tmp_path: Path,
reg_addr: tuple[str, int],
_reap_watchdog_s: float = 30,
) -> None:
'''
Nursery cancellation must terminate a TTE-suppressing child.
- Start a dedicated parent process with its own process group.
- Wait inside that process for the child's TTE receipt marker,
then call an.cancel() and check its process handle after exit.
- Expected termination uses Tractor's own cancellation/reaping.
Watchdog rescue kills this test's group and FAILS the test.
It cannot count as evidence of successful runtime teardown.
'''
if os.name != 'posix':
pytest.skip('The watchdog uses POSIX process groups')
marker: Path = tmp_path / 'tte-suppressed'
code: str = (
'import sys, trio; '
'from tests.test_aio_channel_exit '
'import cancel_an_with_blocked_aio; '
'trio.run(cancel_an_with_blocked_aio, '
'sys.argv[1], sys.argv[2], int(sys.argv[3]))'
)
parent: subprocess.Popen = subprocess.Popen(
[
sys.executable,
'-c',
code,
str(marker),
reg_addr[0],
str(reg_addr[1]),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
try:
output, _ = parent.communicate(timeout=_reap_watchdog_s)
except subprocess.TimeoutExpired:
os.killpg(parent.pid, signal.SIGKILL)
output, _ = parent.communicate()
pytest.fail(
f'Watchdog rescued blocked actor-nursery teardown\n'
f'TTE received: {marker.exists()}\n'
f'{output}'
)
assert marker.exists(), f'Child never received TTE\n{output}'
assert parent.returncode == 0, (
f'Blocked-child cancellation failed\n{output}'
)