''' Context status ownership and public Trio cancellation reasons. ''' from unittest.mock import Mock import msgspec import pytest import trio from tractor._context import ( Context, ContextCancelReason, ContextStatus, Unresolved, ) from tractor._exceptions import ( ContextCancelled, MsgTypeError, RemoteActorError, TransportClosed, _raise_from_unexpected_msg, ) from tractor.msg import Error, Return, Started LOCAL = ('local', 'actor-id') PEER = ('peer', 'actor-id') def make_context(**kwargs) -> Context: ''' Supply inert actor resources around a real Context and channels. ''' send, recv = trio.open_memory_channel(1) ctx = Context( Mock(aid=Mock(uid=PEER)), 'context-id', Mock(aid=Mock(uid=LOCAL)), recv, send, Mock(), 'test:context', **kwargs, ) ctx.pformat = lambda **kw: 'test context' return ctx def boxed_error(error_type, **kwargs): ''' Construct real exception metadata without an actor process tree. ''' return error_type( 'fault', ipc_msg=Error( src_uid=PEER, src_type_str=error_type.__name__, boxed_type_str=error_type.__name__, relay_path=[PEER], cid='context-id', **kwargs, ), ) def test_status_initialization_and_isolation(): ''' The prototype duplicated ContextStatus fields on Context, so runtime writes never updated its status. Mutate one context's status and verify a second context retains all initial values. Public outcome and cancellation APIs must read the changed store while independent contexts remain unresolved and uncancelled. Diagnostics must expose state without including live resources. ''' values = dict( _outcome_msg=Return(cid='context-id', pld=42), _result=42, _local_error=ValueError('local'), _remote_error=ValueError('remote'), _cancel_called=True, _canceller=LOCAL, _cancel_msg={'cancel': 'reason'}, _started_called=True, _started_msg=Started(cid='context-id', pld='ready'), _started_pld=['ready'], _stream_opened=True, ) first = make_context() other = make_context() assert first._status is not other._status assert set(values) == set(ContextStatus.__struct_fields__) for name, value in values.items(): setattr(first._status, name, value) assert msgspec.structs.asdict(other._status) == { '_outcome_msg': Unresolved, '_result': Unresolved, '_local_error': None, '_remote_error': None, '_cancel_called': False, '_canceller': None, '_cancel_msg': None, '_started_called': False, '_started_msg': None, '_started_pld': None, '_stream_opened': False, } assert first.cancel_called assert first.canceller == LOCAL assert not first.cancel_acked assert not first.cancelled_caught assert first.maybe_error is values['_local_error'] assert first.outcome is first.maybe_error assert first.has_outcome assert not other.cancel_called assert other.canceller is None assert not other.cancel_acked assert not other.cancelled_caught assert other.maybe_error is None assert other.outcome is Unresolved assert not other.has_outcome assert msgspec.structs.asdict(first._status) == values diagnostic = repr(first._status) assert str(first._status) == diagnostic indented = first._status.pformat(field_indent=4, indent=3) for name in values: assert f'\n {name}=' in indented assert indented.endswith('\n )') assert first._status.pformat(4, 3) == indented for name in values: assert f'{name}=' in diagnostic for name in ( '_strict_started', '_cancel_on_msgerr', '_enter_debugger_on_cancel', '_stream', '_caller_info', '_scope', '_scope_tn', '_task', ): assert name not in ContextStatus.__struct_fields__ assert f'{name}=' not in diagnostic assert first._scope_token is not other._scope_token @pytest.mark.parametrize('value', [None, 0, False, 'result']) def test_public_result_from_status(value): ''' Moving Context._result must preserve resolved falsey payloads. Supply a completed parent-side status, then read its public outcome and await the result twice. Neither call may drain the empty feeder or mistake None, zero or False for Unresolved. A sibling context must retain its independent unresolved state. ''' async def main(): ctx = make_context(_portal=Mock()) other = make_context() ctx._status._result = value ctx._status._outcome_msg = Return( cid=ctx.cid, pld=value, ) assert ctx.has_outcome assert ctx.outcome is value assert ctx.maybe_error is None for _ in range(2): assert await ctx.wait_for_result() is value assert not other.has_outcome assert other.outcome is Unresolved trio.run(main) @pytest.mark.parametrize('cancel_called', [False, True]) def test_transport_is_not_self_cancelled(cancel_called): ''' A pending local cancel followed by raw TransportClosed used to read src_uid before checking for ContextCancelled and crashed. Feed that exact state into both classification entry points; transport observation must never acknowledge self-cancellation. ''' ctx = make_context() ctx._status._cancel_called = cancel_called ctx._status._canceller = LOCAL ctx._status._remote_error = TransportClosed('EOF') assert not ctx.cancel_acked assert not ctx._is_self_cancelled(TransportClosed('other EOF')) @pytest.mark.parametrize( 'kind, origin', [ ('transport', 'local_transport'), ('task', 'remote_task'), ('boxed_transport', 'remote_task'), ('cancel', 'remote_cancel'), ('protocol', 'local_protocol'), ('boxed_protocol', 'remote_task'), ], ) def test_reason_delivery_and_first_fault(kind, origin): ''' A cancel payload must describe the first cancelling fault, not the latest ContextStatus._remote_error. Deliver a fault, then another before checkpointing the real Trio scope. Inspect Cancelled.reason publicly to prove payload delivery, scope and actor identity, immutable metadata, and mutable error lifetime. Protocol cases deliberately use the same peer src_uid to ensure local observation is distinguished from a boxed remote fault. ''' async def main(): ctx = make_context() ctx._status._cancel_called = kind == 'transport' if kind == 'transport': error = TransportClosed('EOF') elif kind == 'cancel': error = boxed_error(ContextCancelled, canceller=PEER) elif 'protocol' in kind: error = boxed_error(MsgTypeError) else: error = boxed_error(RemoteActorError) if kind == 'boxed_transport': error._ipc_msg.boxed_type_str = 'TransportClosed' later = TransportClosed('later EOF') observed = [] with trio.CancelScope() as scope: ctx._scope = scope ctx._maybe_cancel_and_set_remote_error( error, local_protocol=kind == 'protocol', ) ctx._maybe_cancel_and_set_remote_error(later) for _ in range(2): try: await trio.lowlevel.checkpoint() except trio.Cancelled as cancelled: observed.append(cancelled.reason) reason = observed[0] assert isinstance(reason, ContextCancelReason) assert observed[1] is reason assert reason.error is error assert ctx._status._remote_error is later assert reason.cid == ctx.cid assert reason.actor_uid == LOCAL assert reason.peer_uid == PEER assert reason.scope_token is ctx._scope_token assert reason.scope_token is not scope assert reason.origin == origin assert isinstance(reason.message, str) for name in reason.__struct_fields__: with pytest.raises(AttributeError): setattr(reason, name, None) error.add_note('retained exception remains mutable') assert reason.error.__notes__ == [ 'retained exception remains mutable', ] trio.run(main) @pytest.mark.parametrize('blocked_on_receive', [False, True]) def test_self_cancel_ack_scope_policy(blocked_on_receive): ''' Moving cancellation state must preserve self-cancel ack policy. A real ContextCancelled names the local actor as canceller; simulate its owner blocked on the feeder or elsewhere. Only the latter needs scope interruption, with a remote_cancel reason; both paths must acknowledge the same request through status. ''' async def main(): ctx = make_context() ctx._status._cancel_called = True ctx._is_blocked_on_rx_chan = lambda: blocked_on_receive with trio.CancelScope() as scope: ctx._scope = scope error = boxed_error(ContextCancelled, canceller=LOCAL) ctx._maybe_cancel_and_set_remote_error(error) assert ctx.cancel_acked assert ctx._status._canceller == LOCAL assert scope.cancel_called is not blocked_on_receive if not blocked_on_receive: with pytest.raises(trio.Cancelled) as caught: await trio.lowlevel.checkpoint() assert caught.value.reason.origin == 'remote_cancel' assert caught.value.reason.error is error trio.run(main) def test_existing_cancel_reason_and_protocol_policy(): ''' Reason metadata must neither overwrite an already cancelled scope nor defeat Context._cancel_on_msgerr. Disable that policy and deliver a local MTE, then cancel explicitly and deliver EOF. Status must record each error, but the first actual cancellation payload must still reach the checkpoint unchanged. ''' async def main(): ctx = make_context(_cancel_on_msgerr=False) original_reason = object() with trio.CancelScope() as scope: ctx._scope = scope error = boxed_error(MsgTypeError) ctx._maybe_cancel_and_set_remote_error( error, local_protocol=True, ) assert ctx._status._remote_error is error assert not scope.cancel_called scope.cancel(reason=original_reason) later = TransportClosed('EOF') ctx._maybe_cancel_and_set_remote_error(later) assert ctx._status._remote_error is later with pytest.raises(trio.Cancelled) as caught: await trio.lowlevel.checkpoint() assert caught.value.reason is original_reason trio.run(main) @pytest.mark.parametrize('ancestor_first', [False, True]) def test_ancestor_reason_is_not_context_reason(ancestor_first): ''' ContextStatus._remote_error cannot identify which scope supplied a Cancelled. Cancel nested real scopes in either order. Observe the first propagated reason at a checkpoint, then shield the inner scope. Trio 0.34 retains that first reason even after the shield changes. Assert the source distinction without private Trio attributes or assuming the latest fault wins. ''' async def main(): ctx = make_context() ancestor_reason = object() with trio.CancelScope() as ancestor: with trio.CancelScope() as scope: ctx._scope = scope if ancestor_first: ancestor.cancel(reason=ancestor_reason) ctx._maybe_cancel_and_set_remote_error( TransportClosed('EOF'), ) if not ancestor_first: ancestor.cancel(reason=ancestor_reason) with pytest.raises(trio.Cancelled) as caught: await trio.lowlevel.checkpoint() reason = caught.value.reason if ancestor_first: assert reason is ancestor_reason else: assert isinstance(reason, ContextCancelReason) assert reason.scope_token is ctx._scope_token scope.shield = True with pytest.raises(trio.Cancelled) as caught: await trio.lowlevel.checkpoint() assert caught.value.reason is reason trio.run(main) @pytest.mark.parametrize('local_protocol', [False, True]) def test_protocol_origin_at_raise_boundary(local_protocol): ''' PldRx emulates Error with the peer src_uid for local validation failures, indistinguishable from remote MTE by uid alone. Call its real raising boundary with each source exception and inspect the ensuing cancellation. ValidationError must select local protocol origin; a received Error must remain remote task origin. ''' async def main(): ctx = make_context() error = boxed_error(MsgTypeError) src_err = ( msgspec.ValidationError('invalid payload') if local_protocol else RuntimeError('received Error') ) with trio.CancelScope() as scope: ctx._scope = scope with pytest.raises(MsgTypeError): _raise_from_unexpected_msg( ctx, error._ipc_msg, src_err, Mock(), expect_msg=Return, ) with pytest.raises(trio.Cancelled) as caught: await trio.lowlevel.checkpoint() assert caught.value.reason.origin == ( 'local_protocol' if local_protocol else 'remote_task' ) trio.run(main)