Test `ContextStatus` ownership and cancel reasons

Add twenty cases around real ctx state, channels and Trio scopes,
with inert actor resources.

Cover independent status initialization and diagnostics, falsey
results, repeated waits and transport-vs-self-cancel detection.
Check first-fault delivery and shallow-frozen identity metadata.
Exercise self-cancel ack policy, existing and ancestor reasons,
and local validation provenance at the message-raising boundary.

Use `.wait_for_result()` so these tests also cover the subsequent
deprecated-wrapper removal. Test provenance is already recorded
with the preceding status implementation.

Prompt-IO: ai/prompt-io/opencode/20260919_context_fault_status.md

(this patch was generated in some part by `opencode`
using `gpt-6-astra` (`openai`))
ipc_fault_regressions
Gud Boi 2026-09-22 18:55:06 -04:00
parent e100d06732
commit bcb58f2af5
1 changed files with 395 additions and 0 deletions

View File

@ -0,0 +1,395 @@
'''
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)