tractor/tests/test_ipc_eof.py

323 lines
12 KiB
Python
Raw Normal View History

Notify unfinished `Context` owners on IPC EOF Save the proposed transport-fault handling for discovered peers. `process_messages()` reports EOF then exits, leaving parent ctx owners blocked until another send detects the loss. Wake unfinished owners on that exact channel with `TransportClosed`, including owners paused at application checkpoints unrelated to IPC. This scope-cancellation policy remains provisional pending the transport contract discussion. Saving the impl and regression cases on this branch does not settle that policy or imply acceptance. Deats, - Match `Context.chan` by identity and notify all unfinished parent ctxs on the failed connection, leaving other channels alone. - Preserve received outcomes and record buffered `Return` receipt using the `Unresolved` sentinel before EOF can replace success. - Keep local transport loss distinct from a remote cancellation request: do not invent `Context._canceller` provenance. - Preserve the original disconnect cause through owner cancellation and unwinding in `Context._maybe_raise_remote_err()`. Also, - Exercise two owners sharing a discovered sibling channel, blocked in stream receive or an application `Event`, plus a healthy peer that continues to echo after fault handling. - Synchronize EOF injection after startup and task quiescence. Cover drained and buffered final results without timing sleeps. - Regress cause preservation directly with a connection-reset cause and a distinct owner-unwinding exception. - Carry the staged structured session provenance unchanged. Its broader cancellation observations do not expand this boundary; the root-owned `cancel_soon()` fix remains outside this save. Exact-tree project tests remain pending in the commit plan. Earlier session results in the provenance log are not passes for this tree. Prompt-IO: ai/prompt-io/opencode/20260916_ib_disconnect_cleanup.md (this patch was generated in some part by `opencode` using `gpt-6-astra` (`openai`))
2026-09-19 00:33:15 +00:00
'''
Exercise the proposed discovered-peer EOF notification policy.
'''
from unittest.mock import Mock
import pytest
import trio
from trio.testing import wait_all_tasks_blocked
import tractor
from tractor._exceptions import TransportClosed
from tractor.msg.types import Return
_streams: dict[str, tractor.Context] = {}
@tractor.context
async def stream_peer(
ctx: tractor.Context,
) -> None:
'''
Publish a live stream and echo until its caller finishes.
'''
_streams[ctx.cid] = ctx
await ctx.started()
async with ctx.open_stream() as stream:
await stream.send('ready')
async for value in stream:
if value == 'finish':
break
await stream.send(value)
async def send_peer_eof(cid: str) -> None:
'''
Close only this test channel's outgoing transport half.
This helper sends no Stop, Return, or Error. Keeping the read
half open avoids a local fd-close error crashing the peer actor
and confusing transport propagation with supervision.
'''
ctx = _streams[cid]
await ctx.chan._transport.stream.send_eof()
@tractor.context
async def consume_peer(
owner: tractor.Context,
wait_on: str,
reg_addr,
) -> bool:
'''
Exercise a sibling connection outside child supervision.
'''
with trio.fail_after(10):
async with (
# Discovery creates a sibling channel, independent of
# the root actor's control connection to eof_peer.
tractor.find_actor(
'eof_peer',
registry_addrs=[reg_addr],
) as peer,
# This second peer is a control: the fault on peer's
# channel must not terminate unrelated communications.
tractor.find_actor(
'healthy_peer',
registry_addrs=[reg_addr],
) as healthy,
healthy.open_context(stream_peer) as (good, _),
good.open_stream() as good_stream,
trio.open_nursery() as tn,
):
# owner.chan carries the root's orchestration messages;
# losing peer.channel must not lose the result channel.
assert peer.channel is not owner.chan
# Confirm the healthy context is live before injecting
# the fault, not merely connect to it after teardown.
assert await good_stream.receive() == 'ready'
errors: list[TransportClosed] = []
# Set only once BOTH affected context owners observe
# TransportClosed; one notified owner is insufficient.
finished = trio.Event()
# Model an application checkpoint unrelated to IPC.
# Never release it for fault cases: only the proposed
# context-scope cancellation may interrupt that wait.
# Completed contexts are explicitly released after EOF.
paused = trio.Event()
# A local report hook confirms process_messages handled
# EOF before we inspect a completed context's outcome.
eof_seen = trio.Event()
graceful = wait_on in {'returned', 'buffered'}
report = TransportClosed.report_n_maybe_raise
def report_eof(exc, *args, **kwargs):
# During this gate only the injected peer channel
# closes; healthy/control teardown happens later.
eof_seen.set()
return report(exc, *args, **kwargs)
# This sibling is a fresh test actor; observe the real
# handler without changing its exception policy.
TransportClosed.report_n_maybe_raise = report_eof
async def consume(
task_status=trio.TASK_STATUS_IGNORED,
):
try:
async with (
peer.open_context(stream_peer) as (ctx, _),
ctx.open_stream() as stream,
):
assert await stream.receive() == 'ready'
if graceful:
# Observe Context._deliver_msg receipt,
# without requiring the owner to drain
# the final result from its feeder.
returned = trio.Event()
deliver = ctx._deliver_msg
async def record_return(msg):
result = await deliver(msg)
if isinstance(msg, Return):
returned.set()
return result
ctx._deliver_msg = record_return
# Ask the remote context to finish while
# leaving its shared actor channel open.
await stream.send('finish')
await returned.wait()
if wait_on == 'returned':
assert (
await ctx.wait_for_result()
) is None
# Export the cid only after stream startup
# and any graceful Return delivery. The root
# uses it to select the faulted connection.
task_status.started(ctx)
if graceful:
await paused.wait()
assert ctx._remote_error is None
elif wait_on == 'receive':
await stream.receive()
else:
await paused.wait()
if not graceful:
pytest.fail('resumed without EOF')
except TransportClosed as exc:
assert not graceful
assert exc is ctx._remote_error
assert exc.__cause__ is exc._src_exc
assert 'closed' in exc.message
# This is a local transport observation, not
# evidence that a peer requested cancellation.
assert ctx.canceller is None
assert not ctx.cancel_called
assert not ctx.cancel_acked
errors.append(exc)
if len(errors) == 2:
finished.set()
# Both contexts share one discovered channel but own
# separate local scopes. EOF must notify both owners.
first = await tn.start(consume)
second = await tn.start(consume)
assert first.chan is second.chan
# Neither readiness handshake alone proves the owner is
# blocked at the intended checkpoint. Quiescence does.
await wait_all_tasks_blocked()
original_send = peer.channel.send
checking_eof = True
async def send_after_notification(*args, **kwargs):
# Cleanup may send, but must not discover the EOF.
if checking_eof:
assert first._remote_error is not None
assert second._remote_error is not None
return await original_send(*args, **kwargs)
if not graceful:
peer.channel.send = send_after_notification
# Release the root's fault injection only after both
# owners are waiting and the no-send-detection guard is
# installed. The root uses a separate control channel.
await owner.started(first.cid)
try:
with trio.fail_after(2):
await eof_seen.wait()
if graceful:
paused.set()
else:
await finished.wait()
finally:
checking_eof = False
assert len(errors) == (0 if graceful else 2)
assert paused.is_set() is graceful
assert good._remote_error is None
# Prove the unrelated peer still serves application
# traffic after EOF handling, not just that it has no
# recorded error yet.
await good_stream.send('still alive')
assert await good_stream.receive() == 'still alive'
return True
@pytest.mark.parametrize(
'wait_on',
[
# Unfinished context blocked in its receive API: wake it
# without requiring an outbound write to discover EOF.
'receive',
# Unfinished context blocked on an application Event:
# exercise the proposed interruption of non-IPC awaits.
'event',
# Final Return already consumed: preserve success when the
# connection closes after its remote task has completed.
'returned',
# Final Return received but not drained: preserve the same
# success even though the owner has not requested its result.
'buffered',
],
)
def test_discovered_peer_eof(
reg_addr,
wait_on: str,
) -> None:
'''
Exercise the proposed EOF-driven cancellation of context owners.
Previously process_messages logged TransportClosed and returned,
leaving parent Context scopes alive until a later outbound send.
Two owners share a discovered channel, independently of the
ActorNursery's child-control channel. Both receive an initial
stream item, then block in receive() or on an unset Event.
Nursery.start() and wait_all_tasks_blocked() establish that
ordering before a control RPC half-closes the peer transport.
Under the proposed policy both owners must raise TransportClosed
before the deadline without sending to discover the failure.
The Event case deliberately asserts interruption of a non-IPC
checkpoint; whether to adopt that contract remains under review.
An unrelated peer stream must still echo afterward and close
gracefully. The original EOF cause must survive unwinding.
Graceful variants deliver Return before EOF, either draining its
value or leaving it buffered while the owner pauses. Observing
delivery and the EOF report synchronizes those races without a
timing sleep. Neither completed owner may be cancelled by EOF.
'''
async def main():
async with (
tractor.open_nursery(
registry_addrs=[reg_addr],
) as an,
):
# Retain a root-to-peer portal exclusively for injecting
# EOF. Its transport is not the sibling channel tested.
control = await an.start_actor(
'eof_peer',
enable_modules=[__name__],
)
await an.start_actor(
'healthy_peer',
enable_modules=[__name__],
)
# A separate consumer ensures failure cannot propagate
# merely through the root's child-supervision nursery.
consumer = await an.start_actor(
'consumer',
enable_modules=[__name__],
)
async with consumer.open_context(
consume_peer,
wait_on=wait_on,
reg_addr=reg_addr,
) as (ctx, cid):
# consume_peer exports cid after both owners are
# blocked; only then break their shared connection.
await control.run(send_peer_eof, cid=cid)
assert await ctx.wait_for_result() is True
await an.cancel()
trio.run(main)
def test_disconnect_cause_survives_unwind() -> None:
'''
Preserve the transport cause when cancelling a context owner.
Context result/stream receivers can supply their cancellation as
from_src_exc while unwinding. That must not replace the original
socket error in TransportClosed.__cause__. Exercise the actual
raise path with a reset cause and a distinct unwinding exception;
both exception identity and the original cause must survive.
'''
ctx = Mock()
ctx._is_self_cancelled.return_value = False
cause = ConnectionResetError('test peer reset')
error = TransportClosed('test disconnect', src_exc=cause)
with pytest.raises(TransportClosed) as raised:
tractor.Context._maybe_raise_remote_err(
ctx,
error,
from_src_exc=RuntimeError('owner unwinding'),
)
assert raised.value is error
assert error.__cause__ is cause
assert error.src_exc is cause