2023-01-27 21:40:28 +00:00
|
|
|
'''
|
|
|
|
|
Sketchy network blackoutz, ugly byzantine gens, puedes eschuchar la
|
|
|
|
|
cancelacion?..
|
|
|
|
|
|
|
|
|
|
'''
|
2026-09-26 00:27:46 +00:00
|
|
|
from contextlib import asynccontextmanager as acm
|
2024-03-12 19:48:20 +00:00
|
|
|
from types import ModuleType
|
2023-01-28 03:59:15 +00:00
|
|
|
|
2023-01-27 21:40:28 +00:00
|
|
|
import pytest
|
|
|
|
|
from _pytest.pathlib import import_path
|
|
|
|
|
import trio
|
2023-01-28 22:25:07 +00:00
|
|
|
import tractor
|
2025-04-05 22:54:52 +00:00
|
|
|
from tractor import (
|
2026-09-26 00:27:46 +00:00
|
|
|
Portal,
|
2025-04-05 22:54:52 +00:00
|
|
|
TransportClosed,
|
|
|
|
|
)
|
2026-09-26 00:27:46 +00:00
|
|
|
from tractor.runtime._supervise import ActorNursery
|
2024-03-12 19:48:20 +00:00
|
|
|
from tractor._testing import (
|
2023-01-27 21:40:28 +00:00
|
|
|
examples_dir,
|
2024-04-05 14:53:07 +00:00
|
|
|
break_ipc,
|
2026-09-26 00:27:46 +00:00
|
|
|
assert_child_reaped,
|
2023-01-27 21:40:28 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
@pytest.fixture
|
|
|
|
|
def reaped_children(monkeypatch: pytest.MonkeyPatch):
|
|
|
|
|
'''
|
|
|
|
|
Retain spawned process handles and check synchronous reaping.
|
|
|
|
|
|
|
|
|
|
Snapshot each ActorNursery._children entry at publication, before
|
|
|
|
|
failure teardown removes it. Check both backend completion and
|
|
|
|
|
PID disappearance after trio.run(), without an external reaper.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
procs = []
|
|
|
|
|
start_actor = ActorNursery.start_actor
|
|
|
|
|
|
|
|
|
|
async def record_child(an, *args, **kwargs):
|
|
|
|
|
'''
|
|
|
|
|
Record only children spawned by this test's actor nursery.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
portal = await start_actor(an, *args, **kwargs)
|
|
|
|
|
procs.append(an._children[portal.channel.aid.uid][1])
|
|
|
|
|
return portal
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(ActorNursery, 'start_actor', record_child)
|
|
|
|
|
yield procs
|
|
|
|
|
for proc in procs:
|
|
|
|
|
assert_child_reaped(proc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def fault_contexts(monkeypatch: pytest.MonkeyPatch):
|
|
|
|
|
'''
|
|
|
|
|
Observe fault state without altering cancellation or propagation.
|
|
|
|
|
|
|
|
|
|
Collect the entered `tractor.Context`s in a `list` delivered
|
|
|
|
|
to the consuming test. This allows introspecting a ctx's recorded
|
|
|
|
|
fault even if teardown incorrectly swallows that failure.
|
|
|
|
|
|
|
|
|
|
On teardown ensure transport-loss does not set
|
|
|
|
|
`ContextStatus._canceller`: connection loss alone supplies no
|
|
|
|
|
remote cancellation or task-error provenance. For the simulated
|
|
|
|
|
user-interrupt cases, ensure `Context.cancel_called` is True
|
|
|
|
|
because teardown requested cancellation of the remote task.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
contexts: list[tractor.Context] = []
|
|
|
|
|
open_context = Portal.open_context
|
|
|
|
|
|
|
|
|
|
@acm
|
|
|
|
|
async def record_context(portal, func, **kwargs):
|
|
|
|
|
'''
|
|
|
|
|
Hook into all `Portal.open_context() as pair` calls, stashing
|
|
|
|
|
their contexts in a `list` for consumption by dependent
|
|
|
|
|
tests for introspection.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
ctx: tractor.Context|None = None
|
|
|
|
|
try:
|
|
|
|
|
async with open_context(
|
|
|
|
|
portal,
|
|
|
|
|
func,
|
|
|
|
|
**kwargs,
|
|
|
|
|
) as (ctx, sent):
|
|
|
|
|
if func.__name__ in {
|
|
|
|
|
'recv_and_spawn_net_killers',
|
|
|
|
|
'break_ipc_after_started',
|
|
|
|
|
}:
|
|
|
|
|
contexts.append(ctx)
|
|
|
|
|
yield (ctx, sent)
|
|
|
|
|
finally:
|
|
|
|
|
if (
|
|
|
|
|
ctx in contexts
|
|
|
|
|
and
|
|
|
|
|
isinstance(ctx.maybe_error, TransportClosed)
|
|
|
|
|
):
|
|
|
|
|
# MsgStream closure is distinct from transport loss.
|
|
|
|
|
# After teardown, send prioritizes the ctx error,
|
|
|
|
|
# while receive retains the stream's terminal marker.
|
|
|
|
|
stream = ctx._stream
|
|
|
|
|
assert stream.closed
|
|
|
|
|
assert not ctx.chan.connected()
|
|
|
|
|
with pytest.raises(TransportClosed) as err:
|
|
|
|
|
await stream.send('after-close')
|
|
|
|
|
assert err.value is ctx.maybe_error
|
|
|
|
|
terminal = stream._eoc or stream._closed
|
|
|
|
|
assert isinstance(terminal, BaseException)
|
|
|
|
|
with pytest.raises(type(terminal)) as err:
|
|
|
|
|
await stream.receive()
|
|
|
|
|
assert err.value is terminal
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(Portal, 'open_context', record_context)
|
|
|
|
|
yield contexts
|
|
|
|
|
for ctx in contexts:
|
|
|
|
|
if isinstance(ctx.maybe_error, TransportClosed):
|
|
|
|
|
assert ctx.canceller is None
|
|
|
|
|
elif isinstance(ctx._status._local_error, KeyboardInterrupt):
|
|
|
|
|
assert ctx.cancel_called
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def assert_transport_closed(
|
|
|
|
|
exc: TransportClosed|BaseExceptionGroup,
|
|
|
|
|
local: bool,
|
|
|
|
|
) -> tuple[TransportClosed, ...]:
|
|
|
|
|
'''
|
|
|
|
|
Validate every failure leaf, including its socket-level source.
|
|
|
|
|
|
|
|
|
|
Parent close interrupts receive with ClosedResourceError.
|
|
|
|
|
Peer close can report clean EOF or a reset, depending on buffered
|
|
|
|
|
traffic. No arbitrary exception or remote cancellation qualifies.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
if isinstance(exc, BaseExceptionGroup):
|
|
|
|
|
return tuple(
|
|
|
|
|
fault
|
|
|
|
|
for leaf in exc.exceptions
|
|
|
|
|
for fault in assert_transport_closed(leaf, local=local)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert isinstance(exc, TransportClosed)
|
|
|
|
|
cause = exc.__cause__
|
|
|
|
|
assert exc.src_exc is cause
|
|
|
|
|
if local:
|
|
|
|
|
assert isinstance(cause, trio.ClosedResourceError)
|
|
|
|
|
assert 'closed locally' in str(exc)
|
|
|
|
|
elif cause is None:
|
|
|
|
|
assert 'gracefully closed' in str(exc)
|
|
|
|
|
else:
|
|
|
|
|
assert 'closed by peer' in str(exc)
|
|
|
|
|
assert isinstance(
|
|
|
|
|
cause,
|
|
|
|
|
(ValueError, trio.BrokenResourceError,
|
|
|
|
|
ConnectionResetError),
|
|
|
|
|
)
|
|
|
|
|
if isinstance(cause, ValueError):
|
|
|
|
|
assert 'unclean EOF' in str(cause)
|
|
|
|
|
|
|
|
|
|
# TODO, should we expect a certain exc-message (per tpt) too?
|
|
|
|
|
# e.g. cause.args[0] == 'another task closed this fd'
|
|
|
|
|
return (exc,)
|
|
|
|
|
|
|
|
|
|
|
2023-01-27 21:40:28 +00:00
|
|
|
@pytest.mark.parametrize(
|
2024-03-12 19:48:20 +00:00
|
|
|
'pre_aclose_msgstream',
|
2026-09-26 00:27:46 +00:00
|
|
|
[False, True],
|
|
|
|
|
ids=['no_msgstream_aclose', 'pre_aclose_msgstream'],
|
2023-01-28 03:59:15 +00:00
|
|
|
)
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
'ipc_break',
|
|
|
|
|
[
|
2026-09-26 00:27:46 +00:00
|
|
|
# No breaks: retain the healthy Ctrl-C cancellation contract.
|
|
|
|
|
dict(break_parent_ipc_after=False,
|
|
|
|
|
break_child_ipc_after=False),
|
|
|
|
|
|
|
|
|
|
# Only parent breaks.
|
|
|
|
|
dict(break_parent_ipc_after=500,
|
|
|
|
|
break_child_ipc_after=False),
|
|
|
|
|
|
|
|
|
|
# Only child breaks.
|
|
|
|
|
dict(break_parent_ipc_after=False,
|
|
|
|
|
break_child_ipc_after=500),
|
|
|
|
|
|
|
|
|
|
# Both configured: parent breaks first.
|
|
|
|
|
dict(break_parent_ipc_after=500,
|
|
|
|
|
break_child_ipc_after=800),
|
2023-01-28 21:44:35 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
# Both configured: child breaks first.
|
|
|
|
|
dict(break_parent_ipc_after=800,
|
|
|
|
|
break_child_ipc_after=500),
|
2023-01-28 03:59:15 +00:00
|
|
|
],
|
|
|
|
|
ids=[
|
|
|
|
|
'no_break',
|
|
|
|
|
'break_parent',
|
|
|
|
|
'break_child',
|
2023-01-28 21:44:35 +00:00
|
|
|
'break_both_parent_first',
|
|
|
|
|
'break_both_child_first',
|
2023-01-28 03:59:15 +00:00
|
|
|
],
|
2023-01-27 21:40:28 +00:00
|
|
|
)
|
2023-01-28 21:44:35 +00:00
|
|
|
def test_ipc_channel_break_during_stream(
|
2023-01-27 21:40:28 +00:00
|
|
|
debug_mode: bool,
|
2024-03-12 19:48:20 +00:00
|
|
|
loglevel: str,
|
2023-01-27 22:02:36 +00:00
|
|
|
spawn_backend: str,
|
2026-09-26 00:27:46 +00:00
|
|
|
ipc_break: dict,
|
2024-03-12 19:48:20 +00:00
|
|
|
pre_aclose_msgstream: bool,
|
2025-04-04 04:05:55 +00:00
|
|
|
tpt_proto: str,
|
2026-09-26 00:27:46 +00:00
|
|
|
reg_addr: tuple[str, int|str],
|
|
|
|
|
reaped_children,
|
|
|
|
|
fault_contexts: list[tractor.Context],
|
2023-01-27 21:40:28 +00:00
|
|
|
):
|
|
|
|
|
'''
|
2026-09-26 00:27:46 +00:00
|
|
|
Detect local/peer closure without waiting for a later Ctrl-C.
|
2023-01-28 21:44:35 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
The old scenario required ContextCancelled and a hang even when
|
|
|
|
|
EOF had already notified the unfinished context. Sequence-number
|
|
|
|
|
thresholds choose the first breaker; the child closes before its
|
|
|
|
|
trigger reply, preventing the parent from advancing past it.
|
2023-01-27 21:40:28 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
With pre-close, MsgStream absorbs the peer's `Stop`, but pending
|
|
|
|
|
Context.wait_for_result() must fail on transport loss. Check each
|
|
|
|
|
exception leaf and its source, bound teardown, and verify the
|
|
|
|
|
child is gone synchronously. No-break runs still raise synthetic
|
|
|
|
|
KeyboardInterrupt and reap the child through normal cancellation.
|
|
|
|
|
|
|
|
|
|
If Context._scope absorbs EOF-driven task cancellation, teardown
|
|
|
|
|
must raise the recorded fault despite a disconnected channel.
|
|
|
|
|
That path must not manufacture a cancel request or peer ack, and
|
|
|
|
|
must preserve the original TransportClosed object and its cause.
|
2023-01-27 22:02:36 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
The fixture checks stream closure separately from transport
|
|
|
|
|
connectivity after unwinding, then retries send and receive to
|
|
|
|
|
verify their distinct error precedence and terminal identities.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
if spawn_backend != 'trio' and debug_mode:
|
|
|
|
|
pytest.skip('debug_mode only supported on trio spawner')
|
2023-01-27 22:02:36 +00:00
|
|
|
|
2024-03-12 19:48:20 +00:00
|
|
|
mod: ModuleType = import_path(
|
2026-09-26 00:27:46 +00:00
|
|
|
examples_dir() / 'advanced_faults'
|
2024-04-05 14:53:07 +00:00
|
|
|
/ 'ipc_failure_during_stream.py',
|
2023-01-27 21:40:28 +00:00
|
|
|
root=examples_dir(),
|
2025-03-21 00:58:56 +00:00
|
|
|
consider_namespace_packages=False,
|
2023-01-27 21:40:28 +00:00
|
|
|
)
|
2026-09-26 00:27:46 +00:00
|
|
|
parent = ipc_break['break_parent_ipc_after']
|
|
|
|
|
child = ipc_break['break_child_ipc_after']
|
|
|
|
|
breaking = parent or child
|
|
|
|
|
local = bool(
|
|
|
|
|
parent
|
2024-03-12 19:48:20 +00:00
|
|
|
and
|
2026-09-26 00:27:46 +00:00
|
|
|
(
|
|
|
|
|
(not child)
|
|
|
|
|
or
|
|
|
|
|
(parent < child)
|
2023-01-28 21:44:35 +00:00
|
|
|
)
|
2026-09-26 00:27:46 +00:00
|
|
|
)
|
2024-03-12 19:48:20 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
async def run():
|
|
|
|
|
'''
|
|
|
|
|
Bound the example, including actor nursery teardown.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
with trio.fail_after(10):
|
|
|
|
|
await mod.main(
|
|
|
|
|
debug_mode=debug_mode,
|
|
|
|
|
start_method=spawn_backend,
|
|
|
|
|
loglevel=loglevel,
|
|
|
|
|
pre_close=pre_aclose_msgstream,
|
|
|
|
|
tpt_proto=tpt_proto,
|
|
|
|
|
reg_addr=reg_addr,
|
|
|
|
|
**ipc_break,
|
2024-07-05 17:31:29 +00:00
|
|
|
)
|
2025-04-05 22:54:52 +00:00
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
if not breaking:
|
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
|
|
|
trio.run(run)
|
|
|
|
|
else:
|
|
|
|
|
with pytest.raises((TransportClosed, ExceptionGroup)) as err:
|
|
|
|
|
trio.run(run)
|
|
|
|
|
ctx = fault_contexts[0]
|
|
|
|
|
pytest.fail(
|
|
|
|
|
'Transport failure was swallowed:\n'
|
|
|
|
|
f'{ctx.repr_outcome()}\n'
|
|
|
|
|
f'error={ctx.maybe_error!r}'
|
2026-02-19 21:02:05 +00:00
|
|
|
)
|
2026-09-26 00:27:46 +00:00
|
|
|
faults = assert_transport_closed(err.value, local=local)
|
|
|
|
|
ctx = fault_contexts[0]
|
|
|
|
|
assert any(fault is ctx.maybe_error for fault in faults)
|
|
|
|
|
if ctx._scope.cancelled_caught:
|
|
|
|
|
error = ctx._status._remote_error
|
|
|
|
|
assert any(fault is error for fault in faults)
|
|
|
|
|
assert ctx._status._local_error is error
|
|
|
|
|
assert not ctx.cancel_called
|
|
|
|
|
assert not ctx.cancel_acked
|
|
|
|
|
assert len(reaped_children) == len(fault_contexts) == 1
|
2023-01-28 22:25:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@tractor.context
|
|
|
|
|
async def break_ipc_after_started(
|
|
|
|
|
ctx: tractor.Context,
|
|
|
|
|
) -> None:
|
2026-09-26 00:27:46 +00:00
|
|
|
'''
|
|
|
|
|
Wait for the caller's stream barrier, then close unfinished IPC.
|
|
|
|
|
|
|
|
|
|
'''
|
2023-01-28 22:25:07 +00:00
|
|
|
await ctx.started()
|
|
|
|
|
async with ctx.open_stream() as stream:
|
2026-09-26 00:27:46 +00:00
|
|
|
assert await stream.receive() == 'ready'
|
2024-04-05 14:53:07 +00:00
|
|
|
|
|
|
|
|
# TODO: make a test which verifies the error
|
|
|
|
|
# for this, i.e. raises a `MsgTypeError`
|
|
|
|
|
# await ctx.chan.send(None)
|
|
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
await break_ipc(stream=stream, pre_close=True)
|
|
|
|
|
await trio.sleep_forever()
|
2023-01-28 22:25:07 +00:00
|
|
|
|
|
|
|
|
|
2026-09-26 00:27:46 +00:00
|
|
|
def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages(
|
|
|
|
|
reg_addr: tuple[str, int|str],
|
|
|
|
|
tpt_proto: str,
|
|
|
|
|
spawn_backend: str,
|
|
|
|
|
loglevel: str,
|
|
|
|
|
reaped_children,
|
|
|
|
|
fault_contexts,
|
|
|
|
|
):
|
2023-01-28 22:25:07 +00:00
|
|
|
'''
|
2026-09-26 00:27:46 +00:00
|
|
|
Reap a child after detected close without a later user SIGINT.
|
|
|
|
|
|
|
|
|
|
Previously the parent slept, timed out its context, then raised
|
|
|
|
|
KeyboardInterrupt to engage process supervision ("zombie lord").
|
|
|
|
|
EOF now notifies the unfinished context immediately. A ready
|
|
|
|
|
message ensures both streams exist before the child sends `Stop`
|
|
|
|
|
and closes IPC; the caller then waits for the missing result.
|
|
|
|
|
|
|
|
|
|
A bounded TransportClosed exit proves fault-driven teardown, and
|
|
|
|
|
reaped_children checks process completion and PID disappearance
|
|
|
|
|
synchronously. Timer-driven cancellation cannot pass this test.
|
2023-01-28 22:25:07 +00:00
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
async def main():
|
2026-09-26 00:27:46 +00:00
|
|
|
'''
|
|
|
|
|
Await transport failure and bound the entire nursery exit.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
with trio.fail_after(10):
|
|
|
|
|
async with tractor.open_nursery(
|
|
|
|
|
registry_addrs=[reg_addr],
|
|
|
|
|
ensure_registry=True,
|
|
|
|
|
enable_transports=[tpt_proto],
|
|
|
|
|
start_method=spawn_backend,
|
|
|
|
|
loglevel=loglevel,
|
|
|
|
|
) as an:
|
2025-03-03 18:57:54 +00:00
|
|
|
portal = await an.start_actor(
|
2024-03-19 23:33:06 +00:00
|
|
|
'ipc_breaker',
|
|
|
|
|
enable_modules=[__name__],
|
|
|
|
|
)
|
2026-09-26 00:27:46 +00:00
|
|
|
async with portal.open_context(
|
|
|
|
|
break_ipc_after_started,
|
|
|
|
|
) as (ctx, sent):
|
|
|
|
|
async with ctx.open_stream() as stream:
|
|
|
|
|
await stream.send('ready')
|
|
|
|
|
await stream.receive()
|
|
|
|
|
await ctx.wait_for_result()
|
|
|
|
|
pytest.fail('broken context returned a result')
|
|
|
|
|
|
|
|
|
|
with pytest.raises((TransportClosed, ExceptionGroup)) as err:
|
2023-01-28 22:25:07 +00:00
|
|
|
trio.run(main)
|
2026-09-26 00:27:46 +00:00
|
|
|
faults = assert_transport_closed(err.value, local=False)
|
|
|
|
|
ctx = fault_contexts[0]
|
|
|
|
|
assert any(fault is ctx.maybe_error for fault in faults)
|
|
|
|
|
assert len(reaped_children) == len(fault_contexts) == 1
|