tractor/tests/test_to_actor.py

784 lines
21 KiB
Python

'''
`tractor.to_actor`: one-shot single-remote-task API suite.
Verifies the "spiritual successor" to (and eventual
replacement of) `ActorNursery.run_in_actor()`; see
https://github.com/goodboy/tractor/issues/477
'''
from functools import partial
from pathlib import Path
import pytest
import trio
from trio.testing import MockClock
import tractor
from tractor import (
RemoteActorError,
to_actor,
)
from tractor._testing import tractor_test
from tractor._exceptions import ActorTooSlowError
from tractor.msg import ptr as msgptr
from tractor.msg.ptr import NamespacePath
from tractor.to_actor import _api as to_actor_api
async def add_one(
n: int,
) -> int:
return n + 1
async def raise_value_error() -> None:
raise ValueError('kaboom')
async def echo_control_names(
value: int,
/,
*,
name: str,
portal: str,
an: str,
runtime_kwargs: str,
) -> dict[str, int|str]:
return {
'value': value,
'name': name,
'portal': portal,
'an': an,
'runtime_kwargs': runtime_kwargs,
}
async def mark_task_cancellation(
started_path: str,
cancelled_path: str,
) -> None:
Path(started_path).touch()
try:
await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def echo_startup_control(
_cancel_on_startup: str,
) -> str:
return _cancel_on_startup
async def collect_args(
*args: object,
) -> tuple[object, ...]:
return args
async def collect_call(
*args: object,
**kwargs: object,
) -> tuple[tuple[object, ...], dict[str, object]]:
return args, kwargs
def _non_registration_contexts(
actor: tractor.Actor,
) -> dict[tuple, str]:
return {
key: str(ctx._nsf)
for key, ctx in actor._contexts.items()
if str(ctx._nsf) != (
'tractor.discovery._registry:'
'Registrar.register_actor'
)
}
def test_namespace_path_retains_target_ref(
monkeypatch: pytest.MonkeyPatch,
):
'''
Reuse the client-side target ref when splitting its namespace path.
`NamespacePath.from_ref()` previously discarded `add_one`, so
`to_tuple()` imported and resolved the just-created string again.
Replacing `resolve_name()` with a failure proves the retained ref
supplies the tuple without a redundant lookup. The public module
alias assertion also keeps internal `_api.__name__` authoritative.
'''
target = NamespacePath.from_ref(add_one)
def fail_resolve(name: str) -> object:
raise AssertionError(f'unexpected lookup for {name!r}')
monkeypatch.setattr(
msgptr,
'resolve_name',
fail_resolve,
)
assert target.to_tuple() == (
add_one.__module__,
add_one.__name__,
)
assert to_actor.MODULE == to_actor_api.__name__
assert not hasattr(to_actor_api, 'MODULE')
@tractor_test
async def test_one_shot_in_private_nursery(
start_method: str,
debug_mode: bool,
):
'''
No `an`/`portal` provided: a private actor-nursery
is opened (and torn down) scoped to just the call.
'''
assert await to_actor.run(
add_one,
1,
) == 2
def test_one_shot_boots_implicit_runtime(
reg_addr: tuple,
start_method: str,
loglevel: str,
):
'''
Outside any actor-runtime `to_actor.run()` boots one
implicitly (just like bare `open_nursery()` usage)
configured via pass-through `runtime_kwargs`.
'''
async def main() -> None:
assert tractor.current_actor(
err_on_no_runtime=False,
) is None
result = await to_actor.run(
add_one,
41,
runtime_kwargs=dict(
registry_addrs=[reg_addr],
start_method=start_method,
loglevel=loglevel,
),
)
assert result == 42
trio.run(main)
@tractor_test
async def test_remote_error_relayed_to_caller_task(
start_method: str,
debug_mode: bool,
):
'''
A remote task error is raised directly in the
caller's task as a boxed `RemoteActorError` instead
of surfacing at actor-nursery teardown as with the
legacy `.run_in_actor()` API.
'''
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(raise_value_error)
assert excinfo.value.boxed_type is ValueError
@tractor_test
async def test_spawn_from_caller_nursery(
start_method: str,
debug_mode: bool,
):
'''
Pass a caller-managed `an: ActorNursery` for the spawn.
Previously `to_actor.run()` treated an actor-runtime cancel ack
as process reaping, so the call returned while the child monitor
and its `ActorNursery._children` record remained alive until the
entire nursery exited. The assertion inside the still-open
nursery proves child-process joining and record removal now
complete before the one-shot call returns.
'''
async with tractor.open_nursery() as an:
assert await to_actor.run(
add_one,
10,
an=an,
) == 11
assert not an._children
@tractor_test
async def test_cancel_ack_failure_hard_reaps_child(
monkeypatch: pytest.MonkeyPatch,
start_method: str,
debug_mode: bool,
):
'''
Escalate a failed cancel acknowledgement and reap the child.
`Portal.cancel_actor()` can return `False` when its transport is
already closed without confirming runtime cancellation. The old
one-shot path ignored that result, released the nursery-wide join
gate and then waited forever for a still-running process. This
test forces that exact result without cancelling the actor, caps
the call to detect the former hang and verifies the child monitor
removes its `ActorNursery._children` record before returning.
'''
async def cancel_without_ack(
portal: tractor.Portal,
timeout: float|None = None,
raise_on_timeout: bool = False,
) -> bool:
assert raise_on_timeout
return False
monkeypatch.setattr(
tractor.Portal,
'cancel_actor',
cancel_without_ack,
)
async with tractor.open_nursery() as an:
with trio.fail_after(5):
assert await to_actor.run(
add_one,
20,
an=an,
) == 21
assert not an._children
def test_cancel_actor_timeout_closes_blocked_send():
'''
Thread one absolute cancel deadline into shielded frame publication.
The cancel RPC's outer timeout cannot penetrate a complete-frame
shield. The fake private RPC applies the forwarded send deadline to
its own shielded wait, then checkpoints into the outer scope. A
bounded `ActorTooSlowError` and the recorded absolute deadline prove
publication and acknowledgement share one timeout budget.
'''
class ConnectedChannel:
def __init__(self) -> None:
self._cancel_called = False
self.aid = tractor.msg.Aid(
name='blocked_peer',
uuid='test',
)
def connected(self) -> bool:
return True
async def main() -> None:
channel = ConnectedChannel()
portal = object.__new__(tractor.Portal)
portal._chan = channel
deadlines: list[float] = []
async def blocked_cancel(
namespace: str,
function: str,
kwargs: dict[str, object],
cancel_on_startup: bool,
send_deadline: float,
) -> None:
assert (namespace, function) == ('self', 'cancel')
assert kwargs == {}
assert not cancel_on_startup
deadlines.append(send_deadline)
with trio.CancelScope(
deadline=send_deadline,
shield=True,
):
await trio.sleep_forever()
await trio.lowlevel.checkpoint_if_cancelled()
portal._run_from_ns = blocked_cancel
with pytest.raises(ActorTooSlowError):
await portal.cancel_actor(
timeout=1,
raise_on_timeout=True,
)
assert deadlines == [1.]
trio.run(
main,
clock=MockClock(autojump_threshold=0),
)
def test_late_child_reap_registration_is_released():
'''
Preserve a nursery-wide reap request across child startup.
A child monitor can checkpoint while connecting to its parent as
the surrounding `ActorNursery` begins teardown. Previously the
nursery signalled only already-registered child events, so a
monitor registering afterward waited forever. This models that
ordering by publishing the nursery-wide request first and proves
the later per-child event inherits its set state immediately.
'''
an = object.__new__(tractor.ActorNursery)
an._join_procs = trio.Event()
an._child_reap_requests = {}
an._child_reaped = {}
an._join_procs.set()
reap_request, _ = an._register_child_reap(
('late_child', 'uid'),
)
assert reap_request.is_set()
@tractor_test
async def test_remote_error_from_caller_nursery(
start_method: str,
debug_mode: bool,
):
'''
With a caller-managed `an` the remote error also
surfaces in the caller's task, INSIDE the nursery
block, allowing inline (supervision-style) handling.
'''
async with tractor.open_nursery() as an:
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
raise_value_error,
an=an,
)
assert excinfo.value.boxed_type is ValueError
@tractor_test
async def test_reuse_existing_actor_via_portal(
start_method: str,
debug_mode: bool,
):
'''
Pass `portal=` to schedule the one-shot task in an
already-running actor; no spawn, no implicit reap.
The low-level `Portal.run_from_ns()` assertion also proves its
target kwargs remain separate from the private startup-cancel
policy used by context cleanup.
'''
async with tractor.open_nursery() as an:
actor = tractor.current_actor()
portal: tractor.Portal = await an.start_actor(
'one_shot_worker',
enable_modules=[
__name__,
to_actor.MODULE,
],
)
contexts_before = _non_registration_contexts(actor)
for i in range(3):
assert await to_actor.run(
add_one,
i,
portal=portal,
) == i + 1
assert await portal.run_from_ns(
__name__,
'echo_startup_control',
_cancel_on_startup='target_value',
) == 'target_value'
assert _non_registration_contexts(actor) == contexts_before
# still alive: caller owns the actor's lifetime.
await portal.cancel_actor()
@tractor_test
async def test_concurrent_one_shots_from_task_nursery(
start_method: str,
debug_mode: bool,
):
'''
The worker-pool-ish pattern from #477: concurrency
is composed with a plain (caller-side) `trio` task
nursery scheduling multiple one-shot calls against
a shared caller-managed actor-nursery; error
collection thus lives entirely in caller-code.
'''
results: dict[int, int] = {}
async def one_shot(
an: tractor.ActorNursery,
i: int,
) -> None:
results[i] = await to_actor.run(
add_one,
i,
an=an,
name=f'one_shot_{i}',
)
async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
for i in range(4):
tn.start_soon(one_shot, an, i)
assert results == {
i: i + 1 for i in range(4)
}
def test_rejects_sync_fn():
'''
Non-async callables error BEFORE any spawn (or even
runtime-boot) happens.
'''
def not_async() -> None:
...
with pytest.raises(TypeError):
trio.run(
partial(
to_actor.run,
not_async,
)
)
def test_rejects_streaming_fn():
'''
Async-gen (streaming) fns are not one-shot-able,
same constraint as `Portal.run()`.
'''
async def agen():
yield 1
with pytest.raises(TypeError):
trio.run(
partial(
to_actor.run,
agen,
)
)
def test_partial_placeholder_normalization(
monkeypatch: pytest.MonkeyPatch,
):
'''
Preserve Python 3.14 `functools.partial` placeholder semantics.
The test environment runs Python 3.13, so this installs an identity
sentinel matching Python 3.14's `functools.Placeholder` API.
Interleaved placeholders prove call-time positional arguments are
merged in order. Undersupply and a mismatched final target
signature both fail locally before actor runtime startup.
'''
placeholder = object()
monkeypatch.setattr(
to_actor_api.functools,
'Placeholder',
placeholder,
raising=False,
)
fn = partial(
collect_args,
placeholder,
2,
placeholder,
)
normalized_fn, args, kwargs = to_actor_api._normalize_call(
fn,
(1, 3, 4),
)
assert normalized_fn is collect_args
assert args == (1, 2, 3, 4)
assert kwargs == {}
with pytest.raises(TypeError, match='Not enough positional'):
to_actor_api._normalize_call(fn, (1,))
with pytest.raises(TypeError, match='too many positional'):
to_actor_api._normalize_call(
partial(add_one, 1),
(2,),
)
def test_nested_partial_normalization():
'''
Flatten every retained `functools.partial` layer before RPC.
CPython normally combines nested partials, but preserves the inner
object when it has instance attributes. Unwrapping only the outer
layer left a non-namespace-addressable partial as the RPC target.
The custom attribute triggers that retained shape; the assertions
prove positional ordering and outer-keyword precedence match a
direct nested-partial call.
'''
inner = partial(
collect_call,
1,
label='inner',
)
inner.note = 'retain this partial layer'
outer = partial(
inner,
2,
label='outer',
)
fn, args, kwargs = to_actor_api._normalize_call(
outer,
(3,),
)
assert fn is collect_call
assert args == (1, 2, 3)
assert kwargs == {'label': 'outer'}
def test_rejects_portal_and_an_combo():
'''
`portal=` and `an=` are mutually exclusive
placement options.
'''
with pytest.raises(ValueError):
trio.run(
partial(
to_actor.run,
add_one,
1,
portal=object(),
an=object(),
)
)
@pytest.mark.parametrize(
'placement',
['an', 'portal'],
)
@pytest.mark.parametrize(
'runtime_kwargs',
[
{},
{'loglevel': 'cancel'},
],
ids=['empty', 'configured'],
)
def test_rejects_runtime_kwargs_with_placement(
placement: str,
runtime_kwargs: dict,
):
'''
`runtime_kwargs` only applies when the call opens
its own private actor-nursery; passing it alongside
a placement opt is an error, never silently
ignored. In particular, an empty dict still means the
caller provided this mutually exclusive option; testing
both placement modes prevents truthiness checks from
accepting it before any actor runtime is started.
'''
with pytest.raises(ValueError):
trio.run(
partial(
to_actor.run,
add_one,
1,
**{
placement: object(),
'runtime_kwargs': runtime_kwargs,
},
)
)
@tractor_test
async def test_trio_style_args_and_partial_kwargs(
start_method: str,
debug_mode: bool,
):
'''
Forward positional args and partial-bound keyword arguments.
The original API captured every keyword matching an actor
control, so ordinary target parameters such as `name`, `portal`,
`an` and `runtime_kwargs` could not be called. This test uses a
positional-only target argument plus all colliding keyword names.
Binding the target keywords with `functools.partial()` proves the
Trio-style calling convention keeps target inputs separate from
actor controls.
'''
fn = partial(
echo_control_names,
name='target_name',
portal='target_portal',
an='target_an',
runtime_kwargs='target_runtime_kwargs',
)
async with tractor.open_nursery() as an:
result = await to_actor.run(
fn,
42,
an=an,
name='actor_name',
)
assert result == {
'value': 42,
'name': 'target_name',
'portal': 'target_portal',
'an': 'target_an',
'runtime_kwargs': 'target_runtime_kwargs',
}
@tractor_test
async def test_portal_task_cancelled_with_local_caller(
tmp_path: Path,
start_method: str,
debug_mode: bool,
):
'''
Couple a reused portal's remote task to its local caller.
The former `Portal.run()` path abandoned its remote task when the
local `to_actor.run()` caller was cancelled. The target writes
one file after starting and another from its cancellation
`finally`. Cancelling the local task nursery and observing the
second file proves `Portal.open_context()` propagated
cancellation before the caller exited. A subsequent call proves
the caller-owned actor was not cancelled with that task.
'''
started_path = tmp_path / 'started'
cancelled_path = tmp_path / 'cancelled'
async with tractor.open_nursery() as an:
actor = tractor.current_actor()
portal: tractor.Portal = await an.start_actor(
'context_worker',
enable_modules=[
__name__,
to_actor.MODULE,
],
)
contexts_before = _non_registration_contexts(actor)
async with trio.open_nursery() as tn:
tn.start_soon(
partial(
to_actor.run,
mark_task_cancellation,
str(started_path),
str(cancelled_path),
portal=portal,
),
)
with trio.fail_after(5):
while not started_path.exists():
await trio.sleep(0.01)
tn.cancel_scope.cancel()
assert cancelled_path.exists()
assert _non_registration_contexts(actor) == contexts_before
assert await to_actor.run(
add_one,
1,
portal=portal,
) == 2
assert _non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@tractor_test
async def test_context_trampoline_preserves_module_allowlist(
start_method: str,
debug_mode: bool,
):
'''
Keep target resolution behind the actor's RPC module allowlist.
Loading the target with `NamespacePath.load_ref()` would silently
bypass the actor's existing module-exposure boundary. This actor
exposes only the trusted trampoline, not the test module; the
boxed `ModuleNotExposed` proves the trampoline delegates target
resolution to `Actor._get_rpc_func()`.
'''
async with tractor.open_nursery() as an:
actor = tractor.current_actor()
portal: tractor.Portal = await an.start_actor(
'restricted_context_worker',
enable_modules=[to_actor.MODULE],
)
contexts_before = _non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
add_one,
1,
portal=portal,
)
assert excinfo.value.boxed_type is tractor.ModuleNotExposed
assert _non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@tractor_test
async def test_portal_requires_context_trampoline(
start_method: str,
debug_mode: bool,
):
'''
Require explicit trampoline exposure on a caller-owned actor.
Automatically exposing the module in every actor weakens the RPC
allowlist for actors that never use `to_actor.run()`. A portal to
such an actor instead fails with the usual `ModuleNotExposed`,
naming the module callers must opt into.
'''
async with tractor.open_nursery() as an:
actor = tractor.current_actor()
portal: tractor.Portal = await an.start_actor(
'no_context_trampoline_worker',
enable_modules=[__name__],
)
contexts_before = _non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
add_one,
1,
portal=portal,
)
err = excinfo.value
assert err.boxed_type is tractor.ModuleNotExposed
assert to_actor.MODULE in str(err)
assert _non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()