Clarify bounded IPC frame publication
Older review threads left partial-frame scheduling, send-lock ownership, deadline-only stream destruction and cancellation precedence unclear in both transport tests and source comments. Document exact sender/parent ordering, name send events explicitly and explain why stream alignment controls sibling reuse. Clarify private context controls, overrun relay failure and transport shield boundaries. Review: PR #481 (goodboy) https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/to_actor_subpkg
parent
760abc8268
commit
48844aa4ac
|
|
@ -33,18 +33,19 @@ def test_cancelled_transport_send_completes_frame():
|
||||||
|
|
||||||
A cancelled `send_all()` may leave an arbitrary frame prefix on the
|
A cancelled `send_all()` may leave an arbitrary frame prefix on the
|
||||||
wire. Closing the actor-wide stream avoids decoder corruption but
|
wire. Closing the actor-wide stream avoids decoder corruption but
|
||||||
also destroys unrelated contexts using that channel. The fake
|
also destroys unrelated contexts using that channel. On its first
|
||||||
stream publishes two header bytes and blocks, letting this test
|
call, the fake stream publishes two header bytes and blocks until
|
||||||
cancel the sender inside frame publication. The sender must remain
|
the parent test releases it. This lets the parent cancel the sender
|
||||||
blocked until the complete frame is written, then observe pending
|
while frame publication is suspended. The sender must remain inside
|
||||||
cancellation; a second complete frame proves channel reuse remains
|
`send_first()` until the complete frame is written, then observe
|
||||||
safe.
|
pending cancellation; a second sender proves sibling contexts can
|
||||||
|
safely reuse the still frame-aligned stream.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
class PartialSendStream:
|
class PartialSendStream:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.send_entered = trio.Event()
|
self.send_all_entered = trio.Event()
|
||||||
self.release = trio.Event()
|
self.send_all_release = trio.Event()
|
||||||
self.closed = False
|
self.closed = False
|
||||||
self.wire = bytearray()
|
self.wire = bytearray()
|
||||||
|
|
||||||
|
|
@ -55,8 +56,8 @@ def test_cancelled_transport_send_completes_frame():
|
||||||
assert data
|
assert data
|
||||||
if not self.wire:
|
if not self.wire:
|
||||||
self.wire.extend(data[:2])
|
self.wire.extend(data[:2])
|
||||||
self.send_entered.set()
|
self.send_all_entered.set()
|
||||||
await self.release.wait()
|
await self.send_all_release.wait()
|
||||||
self.wire.extend(data[2:])
|
self.wire.extend(data[2:])
|
||||||
else:
|
else:
|
||||||
self.wire.extend(data)
|
self.wire.extend(data)
|
||||||
|
|
@ -115,21 +116,29 @@ def test_cancelled_transport_send_completes_frame():
|
||||||
tn.start_soon(
|
tn.start_soon(
|
||||||
send_first,
|
send_first,
|
||||||
)
|
)
|
||||||
await stream.send_entered.wait()
|
await stream.send_all_entered.wait()
|
||||||
sender_scopes[0].cancel()
|
sender_scopes[0].cancel()
|
||||||
await wait_all_tasks_blocked()
|
await wait_all_tasks_blocked()
|
||||||
|
|
||||||
assert not stream.closed
|
assert not stream.closed
|
||||||
|
# Cancellation is pending, but complete-frame shielding
|
||||||
|
# keeps `send_first()` suspended in `.send_all()`.
|
||||||
assert not sender_done.is_set()
|
assert not sender_done.is_set()
|
||||||
|
|
||||||
stream.release.set()
|
# Let the underlying frame write finish after the parent
|
||||||
|
# has requested sender cancellation.
|
||||||
|
stream.send_all_release.set()
|
||||||
await sender_done.wait()
|
await sender_done.wait()
|
||||||
|
|
||||||
assert cancelled_caught
|
assert cancelled_caught
|
||||||
assert not stream.closed
|
assert not stream.closed
|
||||||
|
# The initial two-byte prefix was completed into one valid
|
||||||
|
# frame before cancellation reached `send_first()`.
|
||||||
assert count_frames(stream.wire) == 1
|
assert count_frames(stream.wire) == 1
|
||||||
|
|
||||||
await transport.send(second_msg)
|
await transport.send(second_msg)
|
||||||
|
# A sibling sender can append and decode another frame only
|
||||||
|
# because the first cancellation preserved stream alignment.
|
||||||
assert count_frames(stream.wire) == 2
|
assert count_frames(stream.wire) == 2
|
||||||
|
|
||||||
tn.cancel_scope.cancel()
|
tn.cancel_scope.cancel()
|
||||||
|
|
@ -139,14 +148,15 @@ def test_cancelled_transport_send_completes_frame():
|
||||||
|
|
||||||
def test_transport_send_deadline_closes_partial_frame():
|
def test_transport_send_deadline_closes_partial_frame():
|
||||||
'''
|
'''
|
||||||
Bound one shielded frame without exposing a corrupt stream.
|
Destroy a stalled partial frame before another sender can append.
|
||||||
|
|
||||||
Ordinary cancellation cannot interrupt complete-frame publication.
|
Ordinary cancellation cannot interrupt complete-frame publication.
|
||||||
Actor-wide cancellation instead passes its absolute deadline into
|
Bounded actor/context cancellation instead passes its absolute
|
||||||
this operation. The fake stream writes a partial header and stalls;
|
deadline into this operation. The fake stream writes a partial
|
||||||
when the send's own deadline fires, the transport must close the
|
header and stalls; when the send's own deadline fires, the transport
|
||||||
stream before releasing its shared send lock and report the channel
|
must close the stream before releasing its shared send lock. This
|
||||||
unusable.
|
prevents the next sender from appending bytes which a decoder would
|
||||||
|
treat as the remainder of the corrupt first frame.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
class StalledStream:
|
class StalledStream:
|
||||||
|
|
@ -186,9 +196,9 @@ def test_transport_send_deadline_closes_partial_frame():
|
||||||
send_deadline=1,
|
send_deadline=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert stream.closed
|
assert stream.closed # partial-frame timeout destroys stream
|
||||||
assert len(stream.wire) == 2
|
assert len(stream.wire) == 2 # only a header fragment was sent
|
||||||
assert not transport._send_lock.locked()
|
assert not transport._send_lock.locked() # cleanup released lock
|
||||||
|
|
||||||
trio.run(
|
trio.run(
|
||||||
main,
|
main,
|
||||||
|
|
@ -200,30 +210,35 @@ def test_cancelled_transport_send_preserves_cancellation():
|
||||||
'''
|
'''
|
||||||
Prefer sender cancellation when teardown closes the stream.
|
Prefer sender cancellation when teardown closes the stream.
|
||||||
|
|
||||||
`MsgpackTransport.send()` shields frame publication. Before this
|
`MsgpackTransport.send()` shields frame publication at
|
||||||
regression fix, if an outer scope cancelled the sender and actor
|
`tractor.ipc._transport:MsgpackTransport.send`. Before this fix, an
|
||||||
teardown then made `send_all()` raise `ClosedResourceError`, the
|
outer `move_on_after()`/cancel scope could cancel `Channel.send()`
|
||||||
transport error escaped instead of the pending cancellation. That
|
while actor teardown closed the shared stream. The resulting
|
||||||
defeated `move_on_after()` and failed otherwise orderly teardown.
|
`ClosedResourceError` escaped from the transport handler instead of
|
||||||
|
its `checkpoint_if_cancelled()` redelivering pending cancellation.
|
||||||
|
|
||||||
The fake stream blocks inside the shield until the test cancels the
|
The fake stream blocks inside the shield until the test cancels the
|
||||||
sender, then raises the same close error seen on macOS UDS. Observing
|
sender. Parent-controlled release then simulates actor teardown
|
||||||
`CancelScope.cancelled_caught` proves cancellation wins once the
|
closing the socket and raises the `ClosedResourceError` observed on
|
||||||
shield unwinds.
|
macOS UDS. `CancelScope.cancelled_caught` proves the handler's
|
||||||
|
checkpoint preserved cancellation as the primary outcome instead of
|
||||||
|
leaking that secondary close error.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
class ClosingStream:
|
class ClosingStream:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.send_entered = trio.Event()
|
self.send_all_entered = trio.Event()
|
||||||
self.release = trio.Event()
|
self.send_all_release = trio.Event()
|
||||||
|
|
||||||
async def send_all(
|
async def send_all(
|
||||||
self,
|
self,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
) -> None:
|
) -> None:
|
||||||
assert data
|
assert data
|
||||||
self.send_entered.set()
|
self.send_all_entered.set()
|
||||||
await self.release.wait()
|
await self.send_all_release.wait()
|
||||||
|
# Model actor teardown closing the shared transport while
|
||||||
|
# this sender is still inside the complete-frame shield.
|
||||||
raise trio.ClosedResourceError(
|
raise trio.ClosedResourceError(
|
||||||
'this socket was already closed'
|
'this socket was already closed'
|
||||||
)
|
)
|
||||||
|
|
@ -256,12 +271,12 @@ def test_cancelled_transport_send_preserves_cancellation():
|
||||||
|
|
||||||
async with trio.open_nursery() as tn:
|
async with trio.open_nursery() as tn:
|
||||||
tn.start_soon(send)
|
tn.start_soon(send)
|
||||||
await stream.send_entered.wait()
|
await stream.send_all_entered.wait()
|
||||||
sender_scopes[0].cancel()
|
sender_scopes[0].cancel()
|
||||||
await wait_all_tasks_blocked()
|
await wait_all_tasks_blocked()
|
||||||
|
|
||||||
assert not sender_done.is_set()
|
assert not sender_done.is_set()
|
||||||
stream.release.set()
|
stream.send_all_release.set()
|
||||||
await sender_done.wait()
|
await sender_done.wait()
|
||||||
|
|
||||||
assert cancelled_caught
|
assert cancelled_caught
|
||||||
|
|
|
||||||
|
|
@ -1113,6 +1113,10 @@ class Context:
|
||||||
# NOTE: we're telling the far end actor to cancel a task
|
# NOTE: we're telling the far end actor to cancel a task
|
||||||
# corresponding to *this actor*. The far end local channel
|
# corresponding to *this actor*. The far end local channel
|
||||||
# instance is passed to `Actor._cancel_task()` implicitly.
|
# instance is passed to `Actor._cancel_task()` implicitly.
|
||||||
|
# Use private `Portal._run_from_ns()` because cancellation
|
||||||
|
# needs its internal `cancel_on_startup=False` policy and
|
||||||
|
# the transaction's shared absolute `send_deadline`; public
|
||||||
|
# `run_from_ns()` exposes neither control.
|
||||||
await self._portal._run_from_ns(
|
await self._portal._run_from_ns(
|
||||||
'self',
|
'self',
|
||||||
'_cancel_task',
|
'_cancel_task',
|
||||||
|
|
@ -1955,6 +1959,9 @@ class Context:
|
||||||
# the sender; the main motivation is that using bp can block the
|
# the sender; the main motivation is that using bp can block the
|
||||||
# msg handling loop which calls into this method!
|
# msg handling loop which calls into this method!
|
||||||
except trio.WouldBlock:
|
except trio.WouldBlock:
|
||||||
|
# `send_chan.send_nowait(msg)` found the local receive feeder
|
||||||
|
# full. With overruns disabled below, report that primary
|
||||||
|
# local overflow to the far-end sender as `StreamOverrun`.
|
||||||
|
|
||||||
# XXX: always push an error even if the local receiver
|
# XXX: always push an error even if the local receiver
|
||||||
# is in overrun state - i.e. if an 'error' msg is
|
# is in overrun state - i.e. if an 'error' msg is
|
||||||
|
|
@ -2027,12 +2034,13 @@ class Context:
|
||||||
await chan.send(err_msg)
|
await chan.send(err_msg)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# XXX: the local consumer may have closed its side of
|
# The `StreamOverrun` shipment can fail secondarily when
|
||||||
# the IPC, in which case context/channel teardown owns
|
# context/channel teardown has already closed shared IPC.
|
||||||
# cancellation of the far-end streaming task. The same
|
# Local stream closure may surface as
|
||||||
# shipment can raise `TransportClosed` when either peer
|
# `BrokenResourceError`; either peer closing the transport
|
||||||
# has already closed the shared IPC channel. In both
|
# can surface as `TransportClosed`. In both cases teardown
|
||||||
# cases the primary overrun can no longer be reported.
|
# owns far-end cancellation and the primary overrun can no
|
||||||
|
# longer be delivered.
|
||||||
except (
|
except (
|
||||||
TransportClosed,
|
TransportClosed,
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
|
|
|
||||||
|
|
@ -448,9 +448,11 @@ class MsgpackTransport(MsgTransport):
|
||||||
If `strict_types == True` then a `MsgTypeError` will be raised on any
|
If `strict_types == True` then a `MsgTypeError` will be raised on any
|
||||||
invalid msg type
|
invalid msg type
|
||||||
|
|
||||||
`send_deadline` bounds publication of this complete frame. A
|
`send_deadline` bounds publication of one complete
|
||||||
timeout destroys the stream because a partial prefix may have
|
length-prefixed frame. If it expires after a prefix or payload
|
||||||
reached the wire.
|
fragment reaches the wire, a later sender would append bytes the
|
||||||
|
peer decoder treats as the remainder of that corrupt frame. The
|
||||||
|
stream is therefore destroyed before releasing `._send_lock`.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
__tracebackhide__: bool = hide_tb
|
__tracebackhide__: bool = hide_tb
|
||||||
|
|
@ -505,18 +507,17 @@ class MsgpackTransport(MsgTransport):
|
||||||
try:
|
try:
|
||||||
# Every IPC msg is length-prefixed and all contexts
|
# Every IPC msg is length-prefixed and all contexts
|
||||||
# on this actor pair share one transport stream. If
|
# on this actor pair share one transport stream. If
|
||||||
# cancellation interrupts `send_all()`, an unknown
|
# the send deadline interrupts `send_all()`, an unknown
|
||||||
# frame prefix may already be on the wire; allowing
|
# frame prefix may already be on the wire; allowing the
|
||||||
# the next sender to append would corrupt framing.
|
# next sender to append would corrupt framing.
|
||||||
# Closing the stream avoids that corruption but lets
|
|
||||||
# one context-local cancellation destroy every sibling
|
|
||||||
# context using the channel.
|
|
||||||
#
|
#
|
||||||
# Keep the `._send_lock` and defer cancellation only
|
# The enclosing `async with self._send_lock` retains the
|
||||||
# for complete frame publication. Broken/closed stream
|
# lock through shielded publication and any forced-close
|
||||||
# failures still escape to the handlers below. Once
|
# cleanup. Context-manager exit releases it only after a
|
||||||
# the frame is complete, the explicit checkpoint
|
# complete frame or destruction of the corrupt stream.
|
||||||
# immediately delivers any pending cancellation.
|
# Ordinary outer cancellation remains shielded until
|
||||||
|
# complete publication; only expiry of `send_deadline`
|
||||||
|
# intentionally closes a partial stream here.
|
||||||
#
|
#
|
||||||
# Ordinary sends may delay cancellation while the remote
|
# Ordinary sends may delay cancellation while the remote
|
||||||
# peer actor is not reading. Bounded actor/context cancel
|
# peer actor is not reading. Bounded actor/context cancel
|
||||||
|
|
@ -539,6 +540,8 @@ class MsgpackTransport(MsgTransport):
|
||||||
f'deadline of {send_deadline!r}'
|
f'deadline of {send_deadline!r}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The frame is complete and still aligned. Redeliver any
|
||||||
|
# pending outer cancellation before normal lock release.
|
||||||
await trio.lowlevel.checkpoint_if_cancelled()
|
await trio.lowlevel.checkpoint_if_cancelled()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -794,8 +794,9 @@ class Actor:
|
||||||
cancel_on_startup: bool = True,
|
cancel_on_startup: bool = True,
|
||||||
|
|
||||||
# Optional absolute deadline for publishing this exact `Start`
|
# Optional absolute deadline for publishing this exact `Start`
|
||||||
# frame. Used by actor-wide cancel RPCs whose outer timeout
|
# frame. Used by bounded actor/context cancel RPCs whose outer
|
||||||
# cannot penetrate complete-frame transport shielding.
|
# timeout cannot penetrate `Channel.send()` forwarding into the
|
||||||
|
# shield in `MsgpackTransport.send()`.
|
||||||
send_deadline: float = float('inf'),
|
send_deadline: float = float('inf'),
|
||||||
|
|
||||||
) -> Context:
|
) -> Context:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue