Share actor-context test helpers

The context and one-shot suites duplicated cancellation file markers
and filtering of registrar-owned runtime contexts.

Move those mechanics into `tests._helpers` while retaining each
endpoint's distinct startup handshake. Also update the startup-cancel
`Channel.send()` mock to accept and forward the new `send_deadline` arg.

Caught-during: review remediation
Found-via: `/run-tests` test_cancel_during_context_startup[trio]

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/to_actor_subpkg
Gud Boi 2026-08-24 23:13:20 -04:00
parent 617ca1de43
commit 0a580df63d
3 changed files with 87 additions and 51 deletions

50
tests/_helpers.py 100644
View File

@ -0,0 +1,50 @@
'''
Shared helpers for actor-runtime test suites.
'''
from pathlib import Path
from types import TracebackType
import tractor
class CancellationMarkers:
'''
Mark a test endpoint's lifetime without cleanup checkpoints.
'''
def __init__(
self,
started_path: str,
cancelled_path: str,
) -> None:
self.started_path = started_path
self.cancelled_path = cancelled_path
def __enter__(self) -> None:
Path(self.started_path).touch()
def __exit__(
self,
exc_type: type[BaseException]|None,
exc_value: BaseException|None,
traceback: TracebackType|None,
) -> None:
Path(self.cancelled_path).touch()
def non_registration_contexts(
actor: tractor.Actor,
) -> dict[tuple, str]:
'''
Snapshot application contexts without registrar-service traffic.
'''
return {
key: str(ctx._nsf)
for key, ctx in actor._contexts.items()
if str(ctx._nsf) != (
'tractor.discovery._registry:'
'Registrar.register_actor'
)
}

View File

@ -40,6 +40,11 @@ from tractor._testing import (
expect_ctxc, expect_ctxc,
) )
from ._helpers import (
CancellationMarkers,
non_registration_contexts,
)
# ``Context`` semantics are as follows, # ``Context`` semantics are as follows,
# ------------------------------------ # ------------------------------------
@ -164,31 +169,18 @@ def test_overrun_error_send_tolerates_transport_close(
_state: bool = False _state: bool = False
def _non_registration_contexts(
actor: 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'
)
}
@tractor.context @tractor.context
async def startup_cancel_target( async def startup_cancel_target(
ctx: Context, ctx: Context,
started_path: str, started_path: str,
cancelled_path: str, cancelled_path: str,
) -> None: ) -> None:
Path(started_path).touch() with CancellationMarkers(
try: started_path,
cancelled_path,
):
await ctx.started() await ctx.started()
await trio.sleep_forever() await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def return_one() -> int: async def return_one() -> int:
@ -317,11 +309,13 @@ async def test_cancel_during_context_startup(
chan: tractor.Channel, chan: tractor.Channel,
payload: object, payload: object,
hide_tb: bool = False, hide_tb: bool = False,
send_deadline: float = float('inf'),
) -> None: ) -> None:
await original_send( await original_send(
chan, chan,
payload, payload,
hide_tb=hide_tb, hide_tb=hide_tb,
send_deadline=send_deadline,
) )
if isinstance(payload, tractor.msg.Start): if isinstance(payload, tractor.msg.Start):
if payload.func == 'startup_cancel_target': if payload.func == 'startup_cancel_target':
@ -344,7 +338,7 @@ async def test_cancel_during_context_startup(
'startup_cancel_worker', 'startup_cancel_worker',
enable_modules=[__name__], enable_modules=[__name__],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
monkeypatch.setattr( monkeypatch.setattr(
tractor.Channel, tractor.Channel,
'send', 'send',
@ -365,12 +359,12 @@ async def test_cancel_during_context_startup(
original_send, original_send,
) )
assert cancelled_path.exists() assert cancelled_path.exists()
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
assert await portal.run_from_ns( assert await portal.run_from_ns(
__name__, __name__,
'return_one', 'return_one',
) == 1 ) == 1
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()
@ -396,7 +390,7 @@ async def test_start_serialization_error_cleans_context(
'serialization_error_worker', 'serialization_error_worker',
enable_modules=[__name__], enable_modules=[__name__],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
with pytest.raises(tractor.MsgTypeError): with pytest.raises(tractor.MsgTypeError):
async with portal.open_context( async with portal.open_context(
simple_setup_teardown, simple_setup_teardown,
@ -404,7 +398,7 @@ async def test_start_serialization_error_cleans_context(
): ):
raise AssertionError('invalid `Start` was accepted') raise AssertionError('invalid `Start` was accepted')
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
async with portal.open_context( async with portal.open_context(
simple_setup_teardown, simple_setup_teardown,
data=1, data=1,
@ -412,7 +406,7 @@ async def test_start_serialization_error_cleans_context(
assert started == 2 assert started == 2
assert await ctx.wait_for_result() == 'yo' assert await ctx.wait_for_result() == 'yo'
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()
@ -437,7 +431,7 @@ async def test_start_module_error_cleans_context(
portal: tractor.Portal = await an.start_actor( portal: tractor.Portal = await an.start_actor(
'module_error_worker', 'module_error_worker',
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
with pytest.raises(tractor.RemoteActorError) as excinfo: with pytest.raises(tractor.RemoteActorError) as excinfo:
async with portal.open_context( async with portal.open_context(
simple_setup_teardown, simple_setup_teardown,
@ -446,7 +440,7 @@ async def test_start_module_error_cleans_context(
raise AssertionError('unexposed context was started') raise AssertionError('unexposed context was started')
assert excinfo.value.boxed_type is tractor.ModuleNotExposed assert excinfo.value.boxed_type is tractor.ModuleNotExposed
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()

View File

@ -25,6 +25,11 @@ from tractor.msg.ptr import NamespacePath
from tractor.spawn import _mp as mp_spawn from tractor.spawn import _mp as mp_spawn
from tractor.to_actor import _api as to_actor_api from tractor.to_actor import _api as to_actor_api
from ._helpers import (
CancellationMarkers,
non_registration_contexts,
)
async def add_one( async def add_one(
n: int, n: int,
@ -58,11 +63,11 @@ async def mark_task_cancellation(
started_path: str, started_path: str,
cancelled_path: str, cancelled_path: str,
) -> None: ) -> None:
Path(started_path).touch() with CancellationMarkers(
try: started_path,
cancelled_path,
):
await trio.sleep_forever() await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def echo_startup_control( async def echo_startup_control(
@ -84,19 +89,6 @@ async def collect_call(
return args, kwargs 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( def test_namespace_path_retains_target_ref(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
@ -588,7 +580,7 @@ async def test_reuse_existing_actor_via_portal(
to_actor.MODULE, to_actor.MODULE,
], ],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
for i in range(3): for i in range(3):
assert await to_actor.run( assert await to_actor.run(
add_one, add_one,
@ -601,7 +593,7 @@ async def test_reuse_existing_actor_via_portal(
'echo_startup_control', 'echo_startup_control',
_cancel_on_startup='target_value', _cancel_on_startup='target_value',
) == 'target_value' ) == 'target_value'
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
# still alive: caller owns the actor's lifetime. # still alive: caller owns the actor's lifetime.
await portal.cancel_actor() await portal.cancel_actor()
@ -887,7 +879,7 @@ async def test_portal_task_cancelled_with_local_caller(
to_actor.MODULE, to_actor.MODULE,
], ],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
async with trio.open_nursery() as tn: async with trio.open_nursery() as tn:
tn.start_soon( tn.start_soon(
@ -905,13 +897,13 @@ async def test_portal_task_cancelled_with_local_caller(
tn.cancel_scope.cancel() tn.cancel_scope.cancel()
assert cancelled_path.exists() assert cancelled_path.exists()
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
assert await to_actor.run( assert await to_actor.run(
add_one, add_one,
1, 1,
portal=portal, portal=portal,
) == 2 ) == 2
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()
@ -937,7 +929,7 @@ async def test_context_trampoline_preserves_module_allowlist(
'restricted_context_worker', 'restricted_context_worker',
enable_modules=[to_actor.MODULE], enable_modules=[to_actor.MODULE],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run( await to_actor.run(
add_one, add_one,
@ -946,7 +938,7 @@ async def test_context_trampoline_preserves_module_allowlist(
) )
assert excinfo.value.boxed_type is tractor.ModuleNotExposed assert excinfo.value.boxed_type is tractor.ModuleNotExposed
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()
@ -970,7 +962,7 @@ async def test_portal_requires_context_trampoline(
'no_context_trampoline_worker', 'no_context_trampoline_worker',
enable_modules=[__name__], enable_modules=[__name__],
) )
contexts_before = _non_registration_contexts(actor) contexts_before = non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run( await to_actor.run(
add_one, add_one,
@ -981,5 +973,5 @@ async def test_portal_requires_context_trampoline(
err = excinfo.value err = excinfo.value
assert err.boxed_type is tractor.ModuleNotExposed assert err.boxed_type is tractor.ModuleNotExposed
assert to_actor.MODULE in str(err) assert to_actor.MODULE in str(err)
assert _non_registration_contexts(actor) == contexts_before assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()