'''
``async with ():`` inlined context-stream cancellation testing.

Verify the we raise errors when streams are opened prior to
sync-opening a ``tractor.Context`` beforehand.

'''
from itertools import count
import math
from pathlib import Path
import platform
from pprint import pformat
import sys
from types import SimpleNamespace
from typing import (
    Callable,
)
from unittest.mock import (
    AsyncMock,
    Mock,
)

import pytest
import trio
import tractor
from tractor import (
    Actor,
    Context,
    current_actor,
)
from tractor._exceptions import (
    StreamOverrun,
    ContextCancelled,
    TransportClosed,
)
from tractor.runtime._state import current_ipc_ctx

from tractor._testing import (
    tractor_test,
    expect_ctxc,
)

from ._helpers import (
    CancellationMarkers,
    non_registration_contexts,
)

# ``Context`` semantics are as follows,
#  ------------------------------------

# - standard setup/teardown:
#   ``Portal.open_context()`` starts a new
#   remote task context in another actor. The target actor's task must
#   call ``Context.started()`` to unblock this entry on the parent side.
#   the child task executes until complete and returns a final value
#   which is delivered to the parent side and retreived via
#   ``Context.result()``.

# - cancel termination:
#   context can be cancelled on either side where either end's task can
#   call ``Context.cancel()`` which raises a local ``trio.Cancelled``
#   and sends a task cancel request to the remote task which in turn
#   raises a ``trio.Cancelled`` in that scope, catches it, and re-raises
#   as ``ContextCancelled``. This is then caught by
#   ``Portal.open_context()``'s exit and we get a graceful termination
#   of the linked tasks.

# - error termination:
#   error is caught after all context-cancel-scope tasks are cancelled
#   via regular ``trio`` cancel scope semantics, error is sent to other
#   side and unpacked as a `RemoteActorError`.


# ``Context.open_stream() as stream: MsgStream:`` msg semantics are:
#  -----------------------------------------------------------------

# - either side can ``.send()`` which emits a 'yield' msgs and delivers
#   a value to the a ``MsgStream.receive()`` call.

# - stream closure: one end relays a 'stop' message which terminates an
#   ongoing ``MsgStream`` iteration.

# - cancel/error termination: as per the context semantics above but
#   with implicit stream closure on the cancelling end.


def test_overrun_error_send_tolerates_transport_close(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Preserve a stream overrun when its error can not be shipped.

    A full local stream buffer makes `Context._deliver_msg()` package
    `StreamOverrun` for the remote sender. On Darwin, a concurrently
    closing socket is wrapped as `TransportClosed`; allowing that
    secondary error to escape replaces the primary overrun and crashes
    the actor-wide RPC loop. This fake context forces that ordering and
    proves failed error shipment reports non-delivery without raising.

    '''
    error_msg = tractor.msg.Error(
        src_uid=('local', 'test'),
        src_type_str='StreamOverrun',
        boxed_type_str='StreamOverrun',
        relay_path=[],
        sender=('peer', 'test'),
        cid='overrun',
    )
    packed: dict[str, object] = {}

    # Spy on the generated `StreamOverrun` and return a stable wire
    # `Error`; the real packer adds traceback/relay details unrelated to
    # this test's secondary transport-close contract.
    def pack_overrun(
        local_err: BaseException,
        cid: str,
        **kwargs: object,
    ) -> tractor.msg.Error:
        packed['local_err'] = local_err
        packed['cid'] = cid
        packed['kwargs'] = kwargs
        return error_msg

    monkeypatch.setattr(
        'tractor._context.pack_from_raise',
        pack_overrun,
    )

    async def main() -> None:
        send_chan = Mock()
        send_chan.send_nowait.side_effect = trio.WouldBlock
        chan = SimpleNamespace(
            aid=SimpleNamespace(uid=('peer', 'test')),
            send=AsyncMock(
                side_effect=TransportClosed('peer closed'),
            ),
        )
        local_aid = SimpleNamespace(
            name='local',
            reprol=lambda: 'local@test',
        )
        ctx = SimpleNamespace(
            cid='overrun',
            chan=chan,
            _send_chan=send_chan,
            _nsf='tests:overrun',
            side='parent',
            peer_side='child',
            _portal=object(),
            _task=None,
            repr_api='Context',
            repr_caller='test',
            _in_overrun=False,
            _actor=SimpleNamespace(aid=local_aid),
            _stream_opened=True,
            _allow_overruns=False,
        )
        msg = tractor.msg.Yield(
            cid=ctx.cid,
            pld='payload',
        )

        delivered: bool = await Context._deliver_msg(ctx, msg)

        assert delivered is False
        assert isinstance(packed['local_err'], StreamOverrun)
        assert packed['cid'] == ctx.cid
        chan.send.assert_awaited_once_with(error_msg)

    trio.run(main)

_state: bool = False


@tractor.context
async def startup_cancel_target(
    ctx: Context,
    started_path: str,
    cancelled_path: str,
) -> None:
    with CancellationMarkers(
        started_path,
        cancelled_path,
    ):
        await ctx.started()
        await trio.sleep_forever()


async def return_one() -> int:
    return 1


@tractor.context
async def too_many_starteds(
    ctx: Context,
) -> None:
    '''
    Call ``Context.started()`` more then once (an error).

    '''
    await ctx.started()
    try:
        await ctx.started()
    except RuntimeError:
        raise


@tractor.context
async def not_started_but_stream_opened(
    ctx: Context,
) -> None:
    '''
    Enter ``Context.open_stream()`` without calling ``.started()``.

    '''
    try:
        async with ctx.open_stream():
            assert 0
    except RuntimeError:
        raise


@pytest.mark.parametrize(
    'target',
    [
        too_many_starteds,
        not_started_but_stream_opened,
    ],
    ids='misuse_type={}'.format,
)
def test_started_misuse(
    target: Callable,
    reg_addr: tuple,
    debug_mode: bool,
):
    async def main():
        async with tractor.open_nursery(
            registry_addrs=[reg_addr],
            debug_mode=debug_mode,
        ) as an:
            portal = await an.start_actor(
                target.__name__,
                enable_modules=[__name__],
            )

            async with portal.open_context(target) as (ctx, sent):
                await trio.sleep(1)

    with pytest.raises(tractor.RemoteActorError):
        trio.run(main)


@tractor.context
async def simple_setup_teardown(

    ctx: Context,
    data: int,
    block_forever: bool = False,

) -> None:

    # startup phase
    global _state
    _state = True

    assert current_ipc_ctx() is ctx

    # signal to parent that we're up
    await ctx.started(data + 1)

    try:
        if block_forever:
            # block until cancelled
            await trio.sleep_forever()
        else:
            return 'yo'
    finally:
        _state = False


async def assert_state(value: bool) -> None:
    global _state
    assert _state == value


@tractor_test
async def test_cancel_during_context_startup(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
    start_method: str,
    debug_mode: bool,
) -> None:
    '''
    Cancel a context after sending `Start` but before its ack.

    `Portal.open_context()` allocates its caller-side `Context` while
    entering the async context manager. Cancellation used to strand
    that local context and leave the remote target running. The patched
    `Channel.send()` publishes `Start`, then blocks before
    `Actor.start_remote_task()` can await `StartAck`. Cancelling the
    caller proves cleanup issues one bounded, non-recursive cancel RPC,
    stops the target and removes both helper contexts. A subsequent
    RPC proves the caller-owned actor remains usable.

    '''
    started_path = tmp_path / 'startup_started'
    cancelled_path = tmp_path / 'startup_cancelled'
    start_sent = trio.Event()
    start_funcs: list[str] = []
    original_send = tractor.Channel.send

    async def delay_after_start(
        chan: tractor.Channel,
        payload: object,
        hide_tb: bool = False,
        send_deadline: float = float('inf'),
    ) -> None:
        await original_send(
            chan,
            payload,
            hide_tb=hide_tb,
            send_deadline=send_deadline,
        )
        # The patched method keeps `Channel.send()`'s broad message
        # contract. It sees both the requested endpoint `Start` and the
        # internal `self._cancel_task` startup RPC used for cleanup.
        if isinstance(payload, tractor.msg.Start):
            start_funcs.append(payload.func)
            if payload.func == 'startup_cancel_target':
                start_sent.set()
            await trio.sleep_forever()

    async def open_target(
        portal: tractor.Portal,
    ) -> None:
        async with portal.open_context(
            startup_cancel_target,
            started_path=str(started_path),
            cancelled_path=str(cancelled_path),
        ):
            raise AssertionError('context startup should be cancelled')

    async with tractor.open_nursery() as an:
        actor: Actor = tractor.current_actor()
        portal: tractor.Portal = await an.start_actor(
            'startup_cancel_worker',
            enable_modules=[__name__],
        )
        contexts_before = non_registration_contexts(actor)
        monkeypatch.setattr(
            tractor.Channel,
            'send',
            delay_after_start,
        )

        async with trio.open_nursery() as tn:
            tn.start_soon(open_target, portal)
            with trio.fail_after(5):
                await start_sent.wait()
                while not started_path.exists():
                    await trio.sleep(0.01)
            tn.cancel_scope.cancel()

        monkeypatch.setattr(
            tractor.Channel,
            'send',
            original_send,
        )
        assert cancelled_path.exists()
        assert non_registration_contexts(actor) == contexts_before
        assert await portal.run_from_ns(
            __name__,
            'return_one',
        ) == 1
        assert non_registration_contexts(actor) == contexts_before
        assert start_funcs == [
            'startup_cancel_target',
            '_cancel_task',
        ]
        await portal.cancel_actor()


@tractor_test
async def test_start_serialization_error_cleans_context(
    start_method: str,
    debug_mode: bool,
) -> None:
    '''
    Deallocate caller state when `Start` can not be serialized.

    `Actor.start_remote_task()` registers its caller-side `Context`
    before encoding the request. An unsupported argument used to raise
    `MsgTypeError` before publication while leaking that registry
    entry. Comparing the context registry around the failed start
    proves cleanup, and a following valid context proves no bytes
    reached or damaged the reused portal's transport.

    '''
    async with tractor.open_nursery() as an:
        actor: Actor = tractor.current_actor()
        portal: tractor.Portal = await an.start_actor(
            'serialization_error_worker',
            enable_modules=[__name__],
        )
        contexts_before = non_registration_contexts(actor)
        with pytest.raises(tractor.MsgTypeError):
            async with portal.open_context(
                simple_setup_teardown,
                data=object(),
            ):
                raise AssertionError('invalid `Start` was accepted')

        assert non_registration_contexts(actor) == contexts_before
        async with portal.open_context(
            simple_setup_teardown,
            data=1,
        ) as (ctx, started):
            assert started == 2
            assert await ctx.wait_for_result() == 'yo'

        assert non_registration_contexts(actor) == contexts_before
        await portal.cancel_actor()


@tractor_test
async def test_start_module_error_cleans_context(
    start_method: str,
    debug_mode: bool,
) -> None:
    '''
    Deallocate caller state after a remote startup rejection.

    A target actor without this test module rejects the requested
    context before sending `StartAck`. That remote
    `ModuleNotExposed` used to escape startup validation while leaving
    the caller context registered. The boxed error and before/after
    registry comparison prove the remote failure remains visible and
    local startup state is released.

    '''
    async with tractor.open_nursery() as an:
        actor: Actor = tractor.current_actor()
        portal: tractor.Portal = await an.start_actor(
            'module_error_worker',
        )
        contexts_before = non_registration_contexts(actor)
        with pytest.raises(tractor.RemoteActorError) as excinfo:
            async with portal.open_context(
                simple_setup_teardown,
                data=1,
            ):
                raise AssertionError('unexposed context was started')

        assert excinfo.value.boxed_type is tractor.ModuleNotExposed
        assert non_registration_contexts(actor) == contexts_before
        await portal.cancel_actor()


@pytest.mark.parametrize(
    'error_parent',
    [False, ValueError, KeyboardInterrupt],
)
@pytest.mark.parametrize(
    'child_blocks_forever',
    [False, True],
    ids=lambda item: f'child_blocks_forever={item}'
)
@pytest.mark.parametrize(
    'pointlessly_open_stream',
    [False, True],
    ids=lambda item: f'open_stream={item}'
)
def test_simple_context(
    error_parent,
    child_blocks_forever,
    pointlessly_open_stream,
    reg_addr: tuple,
    debug_mode: bool,
    is_forking_spawner: bool,
):

    timeout: float = 1.5
    # windows and forking-spawner both have "slower but more
    # deterministic" cancel teardown.
    if platform.system() == 'Windows':
        timeout = 4
    elif is_forking_spawner:
        timeout = 3

    async def main():

        with trio.fail_after(timeout):
            async with tractor.open_nursery(
                registry_addrs=[reg_addr],
                debug_mode=debug_mode,
            ) as an:
                portal = await an.start_actor(
                    'simple_context',
                    enable_modules=[__name__],
                )

                try:
                    async with (
                        portal.open_context(
                            simple_setup_teardown,
                            data=10,
                            block_forever=child_blocks_forever,
                        ) as (ctx, sent),
                    ):
                        assert current_ipc_ctx() is ctx
                        assert sent == 11

                        if child_blocks_forever:
                            await portal.run(assert_state, value=True)
                        else:
                            assert await ctx.result() == 'yo'

                        if pointlessly_open_stream:
                            async with ctx.open_stream():
                                if error_parent:
                                    raise error_parent

                                if child_blocks_forever:
                                    await ctx.cancel()
                                else:
                                    # in this case the stream will send a
                                    # 'stop' msg to the far end which needs
                                    # to be ignored
                                    pass

                        else:
                            if error_parent:
                                raise error_parent

                            # cancel AFTER we open a stream
                            # to avoid a cancel raised inside
                            # `.open_stream()`
                            await ctx.cancel()
                finally:

                    # after cancellation
                    if not error_parent:
                        await portal.run(assert_state, value=False)

                    # shut down daemon
                    await portal.cancel_actor()

    if error_parent:
        try:
            trio.run(main)
        except error_parent:
            pass
        except BaseExceptionGroup as beg:
            # XXX: on windows — and under `trio>=0.33`'s strict
            # exception-groups, where a lone `error_parent` now
            # arrives wrapped in a 1-exc `ExceptionGroup` rather than
            # collapsed to the bare exc — accept either the
            # `error_parent` nested in the group OR a cancel-only
            # (multi-cancelled) group.
            from tractor.trionics import is_multi_cancelled
            assert (
                is_multi_cancelled(beg)
                or
                beg.subgroup(error_parent) is not None
            )
    else:
        trio.run(main)


@pytest.mark.parametrize(
    'child_returns_early',
    [True, False],
    ids=lambda item: f'child_returns_early={item}'
)
@pytest.mark.parametrize(
    'cancel_method',
    ['ctx', 'portal'],
    ids=lambda item: f'cancel_method={item}'
)
@pytest.mark.parametrize(
    'chk_ctx_result_before_exit',
    [True, False],
    ids=lambda item: f'chk_ctx_result_before_exit={item}'
)
def test_parent_cancels(
    cancel_method: str,
    chk_ctx_result_before_exit: bool,
    child_returns_early: bool,
    reg_addr: tuple,
    debug_mode: bool,
):
    '''
    Verify that when the opening side of a context (aka the parent)
    cancels that context, the ctx does not raise a cancelled when
    either calling `.result()` or on context exit.

    '''
    async def check_canceller(
        ctx: Context,
    ) -> None:
        actor: Actor = current_actor()
        uid: tuple = actor.aid.uid
        _ctxc: ContextCancelled|None = None

        if (
            cancel_method == 'portal'
            and not child_returns_early
        ):
            try:
                res = await ctx.result()
                assert 0, 'Portal cancel should raise!'

            except ContextCancelled as ctxc:
                # with trio.CancelScope(shield=True):
                #     await tractor.pause()
                _ctxc = ctxc
                assert ctx.chan._cancel_called
                assert ctxc.canceller == uid
                assert ctxc is ctx.maybe_error

        # NOTE: should not ever raise even in the `ctx`
        # case since self-cancellation should swallow the ctxc
        # silently!
        else:
            try:
                res = await ctx.result()
            except ContextCancelled as ctxc:
                pytest.fail(f'should not have raised ctxc\n{ctxc}')

        # we actually get a result
        if child_returns_early:
            assert res == 'yo'
            assert ctx.outcome is res
            assert ctx.maybe_error is None

        else:
            err: Exception = ctx.outcome
            assert isinstance(err, ContextCancelled)
            assert (
                tuple(err.canceller)
                ==
                uid
            )
            assert (
                err
                is ctx.maybe_error
                is ctx._remote_error
            )
            if le := ctx._local_error:
                assert err is le

            # else:
                # TODO: what should this be then?
                # not defined until block closes right?
                #
                # await tractor.pause()
                # assert ctx._local_error is None

        # TODO: don't need this right?
        # if _ctxc:
        #     raise _ctxc


    async def main():

        async with tractor.open_nursery(
            registry_addrs=[reg_addr],
            debug_mode=debug_mode,
        ) as an:
            portal = await an.start_actor(
                'simple_context',
                enable_modules=[__name__],
            )
            timeout: float = (
                0.5
                if not child_returns_early
                else 2
            )
            with trio.fail_after(timeout):
                async with (
                    expect_ctxc(
                        yay=(
                            not child_returns_early
                            and cancel_method == 'portal'
                        )
                    ),

                    portal.open_context(
                        simple_setup_teardown,
                        data=10,
                        block_forever=not child_returns_early,
                    ) as (ctx, sent),
                ):

                    if child_returns_early:
                        # ensure we block long enough before sending
                        # a cancel such that the child has already
                        # returned it's result.
                        await trio.sleep(0.5)

                    if cancel_method == 'ctx':
                        print('cancelling with `Context.cancel()`')
                        await ctx.cancel()

                    elif cancel_method == 'portal':
                        print('cancelling with `Portal.cancel_actor()`')
                        await portal.cancel_actor()

                    else:
                        pytest.fail(
                            f'Unknown `cancel_method={cancel_method} ?'
                        )

                    if chk_ctx_result_before_exit:
                        await check_canceller(ctx)

            if not chk_ctx_result_before_exit:
                await check_canceller(ctx)

            if cancel_method != 'portal':
                await portal.cancel_actor()

            # XXX NOTE XXX: non-normal yet purposeful
            # test-specific ctxc suppression is implemented!
            #
            # WHY: the `.cancel_actor()` case (cancel_method='portal')
            # will cause both:
            #  * the `ctx.result()` inside `.open_context().__aexit__()`
            #  * AND the `ctx.result()` inside `check_canceller()`
            # to raise ctxc.
            #
            #   which should in turn cause `ctx._scope` to
            # catch any cancellation?
            if (
                not child_returns_early
                and cancel_method != 'portal'
            ):
                assert not ctx._scope.cancelled_caught

    trio.run(main)


# basic stream terminations:
# - child context closes without using stream
# - parent context closes without using stream
# - parent context calls `Context.cancel()` while streaming
#   is ongoing resulting in child being cancelled
# - child calls `Context.cancel()` while streaming and parent
#   sees stream terminated in `RemoteActorError`

# TODO: future possible features
# - restart request: far end raises `ContextRestart`


@tractor.context
async def close_ctx_immediately(
    ctx: Context,

) -> None:

    await ctx.started()
    global _state

    async with ctx.open_stream():
        pass

    print('child returning!')


@pytest.mark.parametrize(
    'parent_send_before_receive',
    [
        False,
        True,
    ],
    ids=lambda item: f'child_send_before_receive={item}'
)
@tractor_test
async def test_child_exits_ctx_after_stream_open(
    debug_mode: bool,
    parent_send_before_receive: bool,
):
    '''
    child context closes without using stream.

    This should result in a msg sequence
    |_<root>_
             |_<fast_stream_closer>

             <= {'started': <Any>, 'cid': <str>}
             <= {'stop': True, 'cid': <str>}
             <= {'result': Any, ..}

     (ignored by child)
    => {'stop': True, 'cid': <str>}

    '''
    timeout: float = (
        0.5 if not debug_mode else 999
    )
    async with tractor.open_nursery(
        debug_mode=debug_mode,
    ) as an:
        portal = await an.start_actor(
            'fast_stream_closer',
            enable_modules=[__name__],
        )

        with trio.fail_after(timeout):
            async with portal.open_context(
                close_ctx_immediately,

                # flag to avoid waiting the final result
                # cancel_on_exit=True,

            ) as (ctx, sent):
                assert sent is None

                with trio.fail_after(0.4):
                    async with ctx.open_stream() as stream:
                        if parent_send_before_receive:
                            print('sending first msg from parent!')
                            await stream.send('yo')

                        # should fall through since ``StopAsyncIteration``
                        # should be raised through translation of
                        # a ``trio.EndOfChannel`` by
                        # ``trio.abc.ReceiveChannel.__anext__()``
                        msg = 10
                        async for msg in stream:
                            # trigger failure if we DO NOT
                            # get an EOC!
                            assert 0
                        else:
                            # never should get anythinig new from
                            # the underlying stream
                            assert msg == 10

                            # verify stream is now closed
                            try:
                                with trio.fail_after(0.3):
                                    print('parent trying to `.receive()` on EoC stream!')
                                    await stream.receive()
                                    assert 0, 'should have raised eoc!?'
                            except trio.EndOfChannel:
                                print('parent got EoC as expected!')
                                pass
                                # raise

                # TODO: should be just raise the closed resource err
                # directly here to enforce not allowing a re-open
                # of a stream to the context (at least until a time of
                # if/when we decide that's a good idea?)
                try:
                    with trio.fail_after(timeout):
                        async with ctx.open_stream() as stream:
                            pass
                except trio.ClosedResourceError:
                    pass

                # if ctx._rx_chan._state.data:
                #     await tractor.pause()

        await portal.cancel_actor()


@tractor.context
async def expect_cancelled(
    ctx: Context,
    send_before_receive: bool = False,

) -> None:
    global _state
    _state = True

    await ctx.started()

    try:
        async with ctx.open_stream() as stream:

            if send_before_receive:
                await stream.send('yo')

            async for msg in stream:
                await stream.send(msg)  # echo server

    except trio.Cancelled:

        # on ctx.cancel() the internal RPC scope is cancelled but
        # never caught until the func exits.
        assert ctx._scope.cancel_called
        assert not ctx._scope.cancelled_caught

        # should be the RPC cmd request for `._cancel_task()`
        assert ctx._cancel_msg
        # which, has not yet resolved to an error outcome
        # since this rpc func has not yet exited.
        assert not ctx.maybe_error
        assert not ctx._final_result_is_set()

        # debug REPL if needed
        # with trio.CancelScope(shield=True):
        #     await tractor.pause()

        # expected case
        _state = False
        raise

    else:
        assert 0, "child wasn't cancelled !?"


@pytest.mark.parametrize(
    'child_send_before_receive',
    [
        False,
        True,
    ],
    ids=lambda item: f'child_send_before_receive={item}'
)
@pytest.mark.parametrize(
    'rent_wait_for_msg',
    [
        False,
        True,
    ],
    ids=lambda item: f'rent_wait_for_msg={item}'
)
@pytest.mark.parametrize(
    'use_ctx_cancel_method',
    [
        False,
        'pre_stream',
        'post_stream_open',
        'post_stream_close',
    ],
    ids=lambda item: f'use_ctx_cancel_method={item}'
)
@tractor_test
async def test_parent_exits_ctx_after_child_enters_stream(
    use_ctx_cancel_method: bool|str,
    debug_mode: bool,
    rent_wait_for_msg: bool,
    child_send_before_receive: bool,
):
    '''
    Parent-side of IPC context closes without sending on `MsgStream`.

    '''
    async with tractor.open_nursery(
        debug_mode=debug_mode,
    ) as an:
        root: Actor = current_actor()
        portal = await an.start_actor(
            'ctx_cancelled',
            enable_modules=[__name__],
        )

        async with portal.open_context(
            expect_cancelled,
            send_before_receive=child_send_before_receive,
        ) as (ctx, sent):
            assert sent is None

            await portal.run(assert_state, value=True)

            # call `ctx.cancel()` explicitly
            if use_ctx_cancel_method == 'pre_stream':
                await ctx.cancel()

                # NOTE: means the local side `ctx._scope` will
                # have been cancelled by an ctxc ack and thus
                # `._scope.cancelled_caught` should be set.
                async with (
                    expect_ctxc(
                        # XXX: the cause is US since we call
                        # `Context.cancel()` just above!
                        yay=True,

                        # XXX: must be propagated to __aexit__
                        # and should be silently absorbed there
                        # since we called `.cancel()` just above ;)
                        reraise=True,
                    ) as maybe_ctxc,
                ):
                    async with ctx.open_stream() as stream:

                        if rent_wait_for_msg:
                            async for msg in stream:
                                print(f'PARENT rx: {msg!r}\n')
                                break

                        if use_ctx_cancel_method == 'post_stream_open':
                            await ctx.cancel()

                    if use_ctx_cancel_method == 'post_stream_close':
                        await ctx.cancel()

                ctxc: tractor.ContextCancelled = maybe_ctxc.value
                assert (
                    ctxc.canceller
                    ==
                    current_actor().aid.uid
                    ==
                    root.aid.uid
                )

                # channel should still be up
                assert portal.channel.connected()

                # ctx is closed here
                await portal.run(
                    assert_state,
                    value=False,
                )

            # XXX CHILD-BLOCKS case, we SHOULD NOT exit from the
            # `.open_context()` before the child has returned,
            # errored or been cancelled!
            else:
                try:
                    with trio.fail_after(
                        0.5  # if not debug_mode else 999
                    ):
                        res = await ctx.wait_for_result()
                        assert res is not tractor._context.Unresolved
                        assert 0, "Callee should have blocked!?"
                except trio.TooSlowError:
                    # NO-OP -> since already triggered by
                    # `trio.fail_after()` above!
                    await ctx.cancel()

        # NOTE: local scope should have absorbed the cancellation since
        # in this case we call `ctx.cancel()` and the local
        # `._scope` does not get `.cancel_called` and thus
        # `.cancelled_caught` neither will ever bet set.
        if use_ctx_cancel_method:
            assert not ctx._scope.cancelled_caught

        # rxed ctxc response from far end
        assert ctx.cancel_acked
        assert (
            ctx._remote_error
            is ctx._local_error
            is ctx.maybe_error
            is ctx.outcome
        )

        try:
            async with ctx.open_stream() as stream:
                async for msg in stream:
                    pass
        except tractor.ContextCancelled:
            pass
        else:
            assert 0, "Should have received closed resource error?"

        # ctx is closed here
        await portal.run(assert_state, value=False)

        # channel should not have been destroyed yet, only the
        # inter-actor-task context
        assert portal.channel.connected()

        # teardown the actor
        await portal.cancel_actor()


@tractor_test
async def test_multitask_parent_cancels_from_nonroot_task(
    debug_mode: bool,
):
    async with tractor.open_nursery(
        debug_mode=debug_mode,
    ) as an:
        portal = await an.start_actor(
            'ctx_cancelled',
            enable_modules=[__name__],
        )

        async with portal.open_context(
            expect_cancelled,
        ) as (ctx, sent):

            await portal.run(assert_state, value=True)
            assert sent is None

            async with ctx.open_stream() as stream:

                async def send_msg_then_cancel():
                    await stream.send('yo')
                    await portal.run(assert_state, value=True)
                    await ctx.cancel()
                    await portal.run(assert_state, value=False)

                async with trio.open_nursery() as n:
                    n.start_soon(send_msg_then_cancel)

                    try:
                        async for msg in stream:
                            assert msg == 'yo'

                    except tractor.ContextCancelled:
                        raise  # XXX: must be propagated to __aexit__

                # channel should still be up
                assert portal.channel.connected()

                # ctx is closed here
                await portal.run(assert_state, value=False)

        # channel should not have been destroyed yet, only the
        # inter-actor-task context
        assert portal.channel.connected()

        # teardown the actor
        await portal.cancel_actor()


@tractor.context
async def cancel_self(
    ctx: Context,

) -> None:
    global _state
    _state = True

    # since we call this the below `.open_stream()` should always
    # error!
    await ctx.cancel()

    # should inline raise immediately
    try:
        async with ctx.open_stream():
            pass
    # except tractor.ContextCancelled:
    except RuntimeError:
        # suppress for now so we can do checkpoint tests below
        print('Got expected runtime error for stream-after-cancel')

    else:
        raise RuntimeError('Context didnt cancel itself?!')

    # check that``trio.Cancelled`` is now raised on any further
    # checkpoints since the self cancel above will have cancelled
    # the `Context._scope.cancel_scope: trio.CancelScope`
    try:
        with trio.fail_after(0.1):
            await trio.sleep_forever()
    except trio.Cancelled:
        raise

    except trio.TooSlowError:
        # should never get here
        assert 0

    raise RuntimeError('Context didnt cancel itself?!')


@tractor_test
async def test_child_cancels_before_started(
    debug_mode: bool,
):
    '''
    Callee calls `Context.cancel()` while streaming and parent
    sees stream terminated in `ContextCancelled`.

    '''
    async with tractor.open_nursery(
        debug_mode=debug_mode,
    ) as an:
        portal = await an.start_actor(
            'cancels_self',
            enable_modules=[__name__],
        )
        try:

            async with portal.open_context(
                cancel_self,
            ) as (ctx, sent):
                async with ctx.open_stream():
                    await trio.sleep_forever()

        # raises a special cancel signal
        except tractor.ContextCancelled as ce:
            _ce = ce  # for debug on crash
            ce.boxed_type == trio.Cancelled

            # the traceback should be informative
            assert 'itself' in ce.tb_str
            assert ce.tb_str == ce.msgdata['tb_str']

        # teardown the actor
        await portal.cancel_actor()


@tractor.context
async def never_open_stream(

    ctx:  Context,

) -> None:
    '''
    Context which never opens a stream and blocks.

    '''
    await ctx.started()
    await trio.sleep_forever()


@tractor.context
async def keep_sending_from_child(
    ctx:  Context,
    msg_buffer_size: int|None = None,

) -> None:
    '''
    Send endlessly on the child stream.

    '''
    await ctx.started()
    async with ctx.open_stream(
        msg_buffer_size=msg_buffer_size,
    ) as stream:
        for msg in count():
            print(f'child sending {msg}')
            await stream.send(msg)
            await trio.sleep(0.01)


@pytest.mark.parametrize(
    'overrun_by',
    [
        ('parent', 1, never_open_stream),
        ('child', 0, keep_sending_from_child),
    ],
    ids=[
         ('parent_1buf_never_open_stream'),
         ('child_0buf_keep_sending_from_child'),
    ]
)
def test_one_end_stream_not_opened(
    overrun_by: tuple[str, int, Callable],
    reg_addr: tuple,
    debug_mode: bool,
):
    '''
    This should exemplify the bug from:
    https://github.com/goodboy/tractor/issues/265

    '''
    overrunner, buf_size_increase, entrypoint = overrun_by
    from tractor.runtime._runtime import Actor
    buf_size = buf_size_increase + Actor.msg_buffer_size

    timeout: float = (
        1 if sys.platform == 'linux'
        else 3
    )

    async def main():
        async with tractor.open_nursery(
            registry_addrs=[reg_addr],
            debug_mode=debug_mode,
        ) as an:
            portal = await an.start_actor(
                entrypoint.__name__,
                enable_modules=[__name__],
            )

            with trio.fail_after(timeout):
                async with portal.open_context(
                    entrypoint,
                ) as (ctx, sent):
                    assert sent is None

                    if 'parent' in overrunner:
                        async with ctx.open_stream() as stream:

                            # itersend +1 msg more then the buffer size
                            # to cause the most basic overrun.
                            for i in range(buf_size):
                                print(f'sending {i}')
                                await stream.send(i)

                            else:
                                # expect overrun error to be relayed back
                                # and this sleep interrupted
                                await trio.sleep_forever()

                    else:
                        # child overruns parent case so we do nothing here
                        await trio.sleep_forever()

            await portal.cancel_actor()

    # 2 overrun cases and the no overrun case (which pushes right up to
    # the msg limit)
    if (
        overrunner == 'parent'
    ):
        with pytest.raises(tractor.RemoteActorError) as excinfo:
            trio.run(main)

        assert excinfo.value.boxed_type == StreamOverrun

    elif overrunner == 'child':
        with pytest.raises(tractor.RemoteActorError) as excinfo:
            trio.run(main)

        # TODO: embedded remote errors so that we can verify the source
        # error? the child delivers an error which is an overrun
        # wrapped in a remote actor error.
        assert excinfo.value.boxed_type == tractor.RemoteActorError

    else:
        trio.run(main)


@tractor.context
async def echo_back_sequence(
    ctx: Context,
    seq: list[int],
    wait_for_cancel: bool,
    allow_overruns_side: str,
    be_slow: bool = False,
    msg_buffer_size: int = 1,

) -> None:
    '''
    Send endlessly on the child stream using a small buffer size
    setting on the contex to simulate backlogging that would normally
    cause overruns.

    '''
    # NOTE: ensure that if the parent is expecting to cancel this task
    # that we stay echoing much longer then they are so we don't
    # return early instead of receive the cancel msg.
    total_batches: int = (
        1000 if wait_for_cancel
        else 6
    )

    await ctx.started()
    # await tractor.pause()
    async with ctx.open_stream(
        msg_buffer_size=msg_buffer_size,

        # literally the point of this test XD
        allow_overruns=(allow_overruns_side in {'child', 'both'}),
    ) as stream:

        # ensure mem chan settings are correct
        assert (
            ctx._send_chan._state.max_buffer_size
            ==
            msg_buffer_size
        )

        seq = list(seq)  # bleh, msgpack sometimes ain't decoded right
        for i in range(total_batches):
            print(f'starting new stream batch {i} iter in child')
            batch = []

            # EoC case, delay a little instead of hot
            # iter-stopping (since apparently py3.11+ can do that
            # faster then a ctxc can be sent) on the async for
            # loop when child was requested to ctxc.
            if (
                stream.closed
                or
                ctx.cancel_called
            ):
                print('child stream already closed!?!')
                await trio.sleep(0.05)
                continue

            async for msg in stream:
                batch.append(msg)
                if batch == seq:
                    break

                if be_slow:
                    await trio.sleep(0.05)

                print('child waiting on next')

            print(f'child echoing back latest batch\n{batch}')
            for msg in batch:
                print(f'child sending msg\n{msg}')
                await stream.send(msg)

    try:
        return 'yo'
    finally:
        print(
            'exiting child with context:\n'
            f'{pformat(ctx)}\n'
        )


@pytest.mark.parametrize(
    # aka the side that will / should raise
    # and overrun under normal conditions.
    'allow_overruns_side',
    ['parent', 'child', 'none', 'both'],
    ids=lambda item: f'allow_overruns_side={item}'
)
@pytest.mark.parametrize(
    # aka the side that will / should raise
    # and overrun under normal conditions.
    'slow_side',
    ['parent', 'child'],
    ids=lambda item: f'slow_side={item}'
)
@pytest.mark.parametrize(
    'cancel_ctx',
    [True, False],
    ids=lambda item: f'cancel_ctx={item}'
)
def test_maybe_allow_overruns_stream(
    cancel_ctx: bool,
    slow_side: str,
    allow_overruns_side: str,

    # conftest wide
    loglevel: str,
    reg_addr: tuple,
    debug_mode: bool,
):
    '''
    Demonstrate small overruns of each task back and forth
    on a stream not raising any errors by default by setting
    the ``allow_overruns=True``.

    The original idea here was to show that if you set the feeder mem
    chan to a size smaller then the # of msgs sent you could could not
    get a `StreamOverrun` crash plus maybe get all the msgs that were
    sent. The problem with the "real backpressure" case is that due to
    the current arch it can result in the msg loop being blocked and thus
    blocking cancellation - which is like super bad. So instead this test
    had to be adjusted to more or less just "not send overrun errors" so
    as to handle the case where the sender just moreso cares about not getting
    errored out when it send to fast..

    '''
    async def main():
        async with tractor.open_nursery(
            registry_addrs=[reg_addr],
            debug_mode=debug_mode,
        ) as an:
            portal = await an.start_actor(
                'child_sends_forever',
                enable_modules=[__name__],
                loglevel=loglevel,
                debug_mode=debug_mode,
            )

            # stream-sequence batch info with send delay to determine
            # approx timeout determining whether test has hung.
            total_batches: int = 2
            num_items: int = 10
            seq = list(range(num_items))
            parent_send_delay: float = 0.16
            timeout: float = math.ceil(
                total_batches * num_items * parent_send_delay
            )
            with trio.fail_after(timeout):
                async with portal.open_context(
                    echo_back_sequence,
                    seq=seq,
                    wait_for_cancel=cancel_ctx,
                    be_slow=(slow_side == 'child'),
                    allow_overruns_side=allow_overruns_side,

                ) as (ctx, sent):
                    assert sent is None

                    async with ctx.open_stream(
                        msg_buffer_size=1 if slow_side == 'parent' else None,
                        allow_overruns=(allow_overruns_side in {'parent', 'both'}),
                    ) as stream:

                        for _ in range(total_batches):
                            for msg in seq:
                                # print(f'root tx {msg}')
                                await stream.send(msg)
                                if slow_side == 'parent':
                                    # NOTE: we make the parent slightly
                                    # slower, when it is slow, to make sure
                                    # that in the overruns everywhere case
                                    await trio.sleep(parent_send_delay)

                            batch = []
                            async for msg in stream:
                                print(f'root rx {msg}')
                                batch.append(msg)
                                if batch == seq:
                                    break

                    if cancel_ctx:
                        # cancel the remote task
                        print('Requesting `ctx.cancel()` in parent!')
                        await ctx.cancel()

                res: str|ContextCancelled = await ctx.result()

                if cancel_ctx:
                    assert isinstance(res, ContextCancelled)
                    assert tuple(res.canceller) == current_actor().aid.uid

                else:
                    print(f'RX ROOT SIDE RESULT {res}')
                    assert res == 'yo'

            # cancel the daemon
            await portal.cancel_actor()

    if (
        allow_overruns_side == 'both'
        or slow_side == allow_overruns_side
    ):
        trio.run(main)

    elif (
        slow_side != allow_overruns_side
    ):

        with pytest.raises(tractor.RemoteActorError) as excinfo:
            trio.run(main)

        err = excinfo.value

        if (
            allow_overruns_side == 'none'
        ):
            # depends on timing is is racy which side will
            # overrun first :sadkitty:

            # NOTE: i tried to isolate to a deterministic case here
            # based on timeing, but i was kinda wasted, and i don't
            # think it's sane to catch them..
            assert err.boxed_type in (
                tractor.RemoteActorError,
                StreamOverrun,
            )

        elif (
            slow_side == 'child'
        ):
            assert err.boxed_type == StreamOverrun

        elif slow_side == 'parent':
            assert err.boxed_type == tractor.RemoteActorError
            assert 'StreamOverrun' in err.tb_str
            assert err.tb_str == err.msgdata['tb_str']

    else:
        # if this hits the logic blocks from above are not
        # exhaustive..
        pytest.fail('PARAMETRIZED CASE GEN PROBLEM YO')


def test_ctx_with_self_actor(
    loglevel: str,
    reg_addr: tuple,
    debug_mode: bool,
):
    '''
    NOTE: for now this is an INVALID OP!

    BUT, eventually presuming we add a "side" key to `Actor.get_context()`,
    we might be able to get this working symmetrically, but should we??

    Open a context back to the same actor and ensure all cancellation
    and error semantics hold the same.

    '''
    async def main():
        async with tractor.open_nursery(
            registry_addrs=[reg_addr],
            debug_mode=debug_mode,
            enable_modules=[__name__],
        ) as an:
            assert an
            async with (
                tractor.find_actor('root') as portal,
                portal.open_context(
                    expect_cancelled,
                    # echo_back_sequence,
                    # seq=seq,
                    # wait_for_cancel=cancel_ctx,
                    # be_slow=(slow_side == 'child'),
                    # allow_overruns_side=allow_overruns_side,

                ) as (ctx, sent),
                ctx.open_stream() as ipc,
            ):
                assert sent is None

                seq = list(range(10))
                for i in seq:
                    await ipc.send(i)
                    rx: int = await ipc.receive()
                    assert rx == i

                await ctx.cancel()

    with pytest.raises(RuntimeError) as excinfo:
        trio.run(main)

    assert 'Invalid Operation' in repr(excinfo.value)
