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
Gud Boi 2026-08-18 14:20:47 -04:00
parent c1501a36d5
commit 53516b094c
4 changed files with 91 additions and 25 deletions

View File

@ -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()],

View File

@ -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,

View File

@ -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:

View File

@ -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