Abort `TIPC` topology streams on overflow
Keep `_stream_name_events()` non-blocking so a slow memory-channel consumer cannot back up the kernel topology queue. Raise `TIPCNameEventOverflow` and end the subscription rather than drop a transition or let the socket reader stall. Discovery consumers must then resubscribe and rebuild their name-table view. Also, - document topology semantics and scope with Linux references - diagram the `.connect()`/`.getpeername()` withdrawal schedules - explain the child-service and callable requirements in the two-host example Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) https://github.com/goodboy/tractor/pull/493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/pr493_review
parent
c1501a36d5
commit
53516b094c
|
|
@ -47,6 +47,10 @@ async def main() -> None:
|
||||||
reg: TIPCAddress = TIPCAddress.get_root()
|
reg: TIPCAddress = TIPCAddress.get_root()
|
||||||
print(f'host A publishing {reg}')
|
print(f'host A publishing {reg}')
|
||||||
|
|
||||||
|
# A registrar root is not an entry in its own
|
||||||
|
# `Registrar._registry`, so host B cannot discover that root by
|
||||||
|
# its actor name. `open_nursery()` keeps the root as registrar
|
||||||
|
# while the named child below registers as the dialable service.
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
enable_transports=['tipc'],
|
enable_transports=['tipc'],
|
||||||
registry_addrs=[reg.unwrap()],
|
registry_addrs=[reg.unwrap()],
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ async def main() -> None:
|
||||||
' |_does `tipc link list` show the peer?\n'
|
' |_does `tipc link list` show the peer?\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `.open_context()` derives a `NamespacePath` from a
|
||||||
|
# callable. Importing `echo` also loads its module path
|
||||||
|
# locally, while host A's `enable_modules` authorizes the
|
||||||
|
# corresponding remote callable.
|
||||||
async with (
|
async with (
|
||||||
ptl.open_context(
|
ptl.open_context(
|
||||||
echo,
|
echo,
|
||||||
|
|
|
||||||
|
|
@ -892,6 +892,11 @@ def test_topology_event_scope_is_unknown():
|
||||||
Decode a valid publication and assert its address uses the
|
Decode a valid publication and assert its address uses the
|
||||||
explicit unknown sentinel rather than inventing reachability.
|
explicit unknown sentinel rather than inventing reachability.
|
||||||
|
|
||||||
|
TIPC address types and publication scopes:
|
||||||
|
https://docs.kernel.org/networking/tipc.html
|
||||||
|
Event wire format (which has no scope field):
|
||||||
|
https://github.com/torvalds/linux/blob/master/include/uapi/linux/tipc.h
|
||||||
|
|
||||||
'''
|
'''
|
||||||
event: _tipc.TIPCNameEvent|None = _tipc._decode_name_event(
|
event: _tipc.TIPCNameEvent|None = _tipc._decode_name_event(
|
||||||
_pack_topology_event(TIPC_PUBLISHED),
|
_pack_topology_event(TIPC_PUBLISHED),
|
||||||
|
|
@ -902,20 +907,24 @@ def test_topology_event_scope_is_unknown():
|
||||||
assert not event.addr.is_valid
|
assert not event.addr.is_valid
|
||||||
|
|
||||||
|
|
||||||
def test_topology_stream_applies_backpressure():
|
def test_topology_stream_surfaces_overflow():
|
||||||
'''
|
'''
|
||||||
A full memory channel used to drop topology transitions, letting
|
Topology transitions are `TIPC_PUBLISHED`/`TIPC_WITHDRAWN`
|
||||||
a future push registry continue with permanently incomplete
|
changes to the set of ports matching a subscribed name sequence,
|
||||||
state. A zero-capacity channel deterministically exercises that
|
as emitted by the kernel topology server:
|
||||||
pressure: `send_nowait()` loses the event, while `await send()`
|
https://github.com/torvalds/linux/blob/master/net/tipc/topsrv.c
|
||||||
synchronizes the reader and consumer without loss.
|
|
||||||
|
|
||||||
The receive completing with the exact publication proves the
|
Blocking this reader on `tx.send()` stops draining the topology
|
||||||
stream waits for capacity instead of discarding the transition.
|
socket and merely pushes pressure into the kernel queue. Silently
|
||||||
|
dropping with `send_nowait()` is worse: a push registry keeps a
|
||||||
|
stale view without knowing it missed a transition.
|
||||||
|
|
||||||
|
A zero-capacity channel deterministically fills the user-space
|
||||||
|
boundary. The dedicated overflow error proves the reader remains
|
||||||
|
non-blocking while forcing consumers to resubscribe and rebuild
|
||||||
|
instead of trusting incomplete state.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
frame_ready = trio.Event()
|
|
||||||
|
|
||||||
class _OneEventSocket:
|
class _OneEventSocket:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sent: bool = False
|
self.sent: bool = False
|
||||||
|
|
@ -923,26 +932,23 @@ def test_topology_stream_applies_backpressure():
|
||||||
async def recv(self, size: int) -> bytes:
|
async def recv(self, size: int) -> bytes:
|
||||||
if not self.sent:
|
if not self.sent:
|
||||||
self.sent = True
|
self.sent = True
|
||||||
# `Event.set()` does not checkpoint, so the old
|
|
||||||
# `send_nowait()` runs before the consumer below.
|
|
||||||
frame_ready.set()
|
|
||||||
return _pack_topology_event(TIPC_PUBLISHED)
|
return _pack_topology_event(TIPC_PUBLISHED)
|
||||||
await trio.sleep_forever()
|
raise AssertionError('reader continued after overflow')
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
tx, rx = trio.open_memory_channel(0)
|
tx, rx = trio.open_memory_channel(0)
|
||||||
async with trio.open_nursery() as tn:
|
with pytest.raises(
|
||||||
tn.start_soon(
|
_tipc.TIPCNameEventOverflow,
|
||||||
_tipc._stream_name_events,
|
match='resubscribe and rebuild',
|
||||||
|
) as excinfo:
|
||||||
|
await _tipc._stream_name_events(
|
||||||
_OneEventSocket(),
|
_OneEventSocket(),
|
||||||
TRACTOR_STYPE,
|
TRACTOR_STYPE,
|
||||||
tx,
|
tx,
|
||||||
)
|
)
|
||||||
await frame_ready.wait()
|
assert excinfo.value.event.kind == 'published'
|
||||||
with trio.fail_after(1):
|
with pytest.raises(trio.EndOfChannel):
|
||||||
event: _tipc.TIPCNameEvent = await rx.receive()
|
await rx.receive()
|
||||||
assert event.kind == 'published'
|
|
||||||
tn.cancel_scope.cancel()
|
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
@ -958,6 +964,9 @@ def test_topology_timeout_closes_stream():
|
||||||
event followed by `EndOfChannel` proves the stream terminates at
|
event followed by `EndOfChannel` proves the stream terminates at
|
||||||
the kernel subscription boundary.
|
the kernel subscription boundary.
|
||||||
|
|
||||||
|
Subscription timeout semantics:
|
||||||
|
https://github.com/torvalds/linux/blob/master/include/uapi/linux/tipc.h
|
||||||
|
|
||||||
'''
|
'''
|
||||||
class _TimeoutSocket:
|
class _TimeoutSocket:
|
||||||
async def recv(self, size: int) -> bytes:
|
async def recv(self, size: int) -> bytes:
|
||||||
|
|
|
||||||
|
|
@ -705,6 +705,21 @@ class MsgpackTIPCStream(MsgpackTransport):
|
||||||
#
|
#
|
||||||
# Reuse that tolerant observation: a peer can withdraw
|
# Reuse that tolerant observation: a peer can withdraw
|
||||||
# between `.connect()` and a second `.getpeername()`.
|
# between `.connect()` and a second `.getpeername()`.
|
||||||
|
#
|
||||||
|
# Peer / kernel dial task
|
||||||
|
# | |
|
||||||
|
# |<----- connect(name) -------|
|
||||||
|
# |------ connected ---------->| (A)
|
||||||
|
# |<----- getpeername() -------| (B, ctor)
|
||||||
|
# |-- port-id or `ENOTCONN` -->|
|
||||||
|
# X withdraws |
|
||||||
|
# |<----- getpeername() -------| (C, old code)
|
||||||
|
# |------ `ENOTCONN` --------->|
|
||||||
|
#
|
||||||
|
# If withdrawal precedes B, `_maybe_sockaddr()` records
|
||||||
|
# no port-id and the handshake observes the close. If it
|
||||||
|
# follows B, that first observation remains useful. The
|
||||||
|
# removed C lookup created the only raw-`ENOTCONN` race.
|
||||||
observed_raddr: TIPCAddress = tpt_stream._raddr
|
observed_raddr: TIPCAddress = tpt_stream._raddr
|
||||||
tpt_stream._raddr = destaddr.with_port_id(
|
tpt_stream._raddr = destaddr.with_port_id(
|
||||||
node=observed_raddr.maybe_node,
|
node=observed_raddr.maybe_node,
|
||||||
|
|
@ -860,8 +875,17 @@ class TIPCNameEvent(
|
||||||
frozen=True,
|
frozen=True,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
A kernel name-table transition: some service name was
|
A kernel name-table transition, not an application msg.
|
||||||
published or withdrawn somewhere in the cluster.
|
|
||||||
|
A transition reports that the set of TIPC ports matching a
|
||||||
|
subscribed service-name sequence changed: `TIPC_PUBLISHED`
|
||||||
|
adds a matching `(node, ref)`, `TIPC_WITHDRAWN` removes one,
|
||||||
|
and `TIPC_SUBSCR_TIMEOUT` ends a finite subscription.
|
||||||
|
|
||||||
|
Wire definitions:
|
||||||
|
https://github.com/torvalds/linux/blob/master/include/uapi/linux/tipc.h
|
||||||
|
Topology server:
|
||||||
|
https://github.com/torvalds/linux/blob/master/net/tipc/topsrv.c
|
||||||
|
|
||||||
'''
|
'''
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
|
|
@ -882,6 +906,28 @@ class TIPCNameEvent(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TIPCNameEventOverflow(RuntimeError):
|
||||||
|
'''
|
||||||
|
The user-space event buffer lost authoritative continuity.
|
||||||
|
|
||||||
|
The socket reader must not remain blocked while its kernel
|
||||||
|
topology subscription is live. Raising aborts that subscription
|
||||||
|
and forces the consumer to resubscribe and rebuild its name-table
|
||||||
|
view instead of using silently stale state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event: TIPCNameEvent,
|
||||||
|
) -> None:
|
||||||
|
self.event = event
|
||||||
|
super().__init__(
|
||||||
|
'TIPC topology event buffer overflowed; '
|
||||||
|
'resubscribe and rebuild the name-table view.\n'
|
||||||
|
f'lost event: {event!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mk_subscr(
|
def _mk_subscr(
|
||||||
stype: int,
|
stype: int,
|
||||||
lower: int,
|
lower: int,
|
||||||
|
|
@ -984,7 +1030,10 @@ async def _stream_name_events(
|
||||||
)) is None:
|
)) is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await tx.send(ev)
|
try:
|
||||||
|
tx.send_nowait(ev)
|
||||||
|
except trio.WouldBlock as src_err:
|
||||||
|
raise TIPCNameEventOverflow(ev) from src_err
|
||||||
if ev.kind == 'timeout':
|
if ev.kind == 'timeout':
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue