Add the `TIPC_TOP_SRV` name-event wire codec

First half of plan 01 §5.2 (layer B): the `struct` layouts and
the `TIPCNameEvent` type for the kernel's *push-based* name
table, w/o any socket plumbing yet. Pure-python, so it tests
w/o a loaded `tipc` module.

Deats,
- `_SUBSCR_FMT = '=5I8s'` (28B `struct tipc_subscr`) and
  `_EVENT_FMT = '=10I8s'` (48B `struct tipc_event`).
- `_mk_subscr()` masks the timeout: python exposes
  `TIPC_WAIT_FOREVER` as **`-1`** which `struct` flat refuses
  to pack into an unsigned `'I'`.
- `_decode_name_event()` *drops* runt frames and unknown event
  codes rather than raising — a confused kernel must not be
  able to kill the reader task.

XXX two corrections to what the plan §5.2 sketch claimed, both
verified against a live kernel,
- the event is **48B** (`4+4+4+8+28`), NOT 40.
- native (`'='`) byte-order is **accepted**; publish+withdraw
  both round-tripped w/ the 28B subscription echoed back
  intact. So the proposed `_detect_topsrv_endianness()` `'>'`
  retry-probe is unnecessary and is NOT implemented.

Note the event carries no *scope* — the name-table doesn't
report one — so the decoded `.addr` echoes the subscription's
own rather than pretending to observe it.

(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
wkt/pr493_review
Gud Boi 2026-08-15 01:37:13 -04:00
parent 7e20585f59
commit e269bbf871
2 changed files with 246 additions and 0 deletions

View File

@ -8,6 +8,7 @@ the pure address-algebra cases run everywhere.
''' '''
from __future__ import annotations from __future__ import annotations
import errno import errno
import struct
from socket import ( from socket import (
SOCK_STREAM, SOCK_STREAM,
SOL_SOCKET, SOL_SOCKET,
@ -627,3 +628,78 @@ def test_duplicate_name_bind_does_not_raise():
second.socket.close() second.socket.close()
trio.run(main) trio.run(main)
# ------------------------------------------------------------------
# layer B — the topology service (`TIPC_TOP_SRV`)
# ------------------------------------------------------------------
def test_topology_struct_layouts():
'''
Pin the `include/uapi/linux/tipc.h` struct sizes.
XXX the event is **48** bytes (`4+4+4+8+28`), NOT the 40 an
earlier revision of plan 01 §5.2 claimed.
'''
assert struct.calcsize(_tipc._SUBSCR_FMT) == 28
assert struct.calcsize(_tipc._EVENT_FMT) == 48
assert _tipc._EVENT_SIZE == 48
# ..and the subscription we actually emit is exactly that
sub: bytes = _tipc._mk_subscr(
stype=TRACTOR_STYPE,
lower=0,
upper=0xFFFF_FFFF,
filt=_tipc.TIPC_SUB_SERVICE,
timeout=_tipc.TIPC_WAIT_FOREVER,
)
assert len(sub) == 28
def test_wait_forever_is_masked_for_packing():
'''
Python exposes `TIPC_WAIT_FOREVER` as `-1`, which `struct`
refuses to pack into an unsigned `'I'`; it MUST be masked.
'''
assert _tipc.TIPC_WAIT_FOREVER == -1
with pytest.raises(struct.error):
struct.pack('=I', _tipc.TIPC_WAIT_FOREVER)
sub: bytes = _tipc._mk_subscr(
stype=TRACTOR_STYPE,
lower=0,
upper=0,
filt=_tipc.TIPC_SUB_SERVICE,
timeout=_tipc.TIPC_WAIT_FOREVER,
)
_, _, _, timeout, _, _ = struct.unpack(_tipc._SUBSCR_FMT, sub)
assert timeout == 0xFFFF_FFFF
def test_decode_name_event_rejects_junk():
'''
Runt frames and unknown event codes are dropped, never raised
a confused kernel must not kill the reader task.
'''
assert _tipc._decode_name_event(
b'\x00' * 12,
stype=TRACTOR_STYPE,
scope=TIPC_CLUSTER_SCOPE,
) is None
bogus: bytes = struct.pack(
_tipc._EVENT_FMT,
99, # not a known event code
1, 1, 0, 0,
0, 0, 0, 0, 0,
b'\0' * 8,
)
assert _tipc._decode_name_event(
bogus,
stype=TRACTOR_STYPE,
scope=TIPC_CLUSTER_SCOPE,
) is None

View File

@ -52,9 +52,11 @@ from hashlib import blake2b
import os import os
import socket import socket
from socket import SOCK_STREAM from socket import SOCK_STREAM
import struct
from typing import ( from typing import (
Callable, Callable,
ClassVar, ClassVar,
Literal,
Type, Type,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -107,6 +109,14 @@ try:
TIPC_IMPORTANCE, TIPC_IMPORTANCE,
TIPC_LOW_IMPORTANCE, TIPC_LOW_IMPORTANCE,
TIPC_NODE_SCOPE, TIPC_NODE_SCOPE,
TIPC_PUBLISHED,
TIPC_SUB_CANCEL,
TIPC_SUB_PORTS,
TIPC_SUB_SERVICE,
TIPC_SUBSCR_TIMEOUT,
TIPC_TOP_SRV,
TIPC_WAIT_FOREVER,
TIPC_WITHDRAWN,
TIPC_ZONE_SCOPE, TIPC_ZONE_SCOPE,
) )
except ImportError: except ImportError:
@ -122,6 +132,14 @@ except ImportError:
TIPC_HIGH_IMPORTANCE: int = 2 TIPC_HIGH_IMPORTANCE: int = 2
TIPC_IMPORTANCE: int = 127 TIPC_IMPORTANCE: int = 127
TIPC_DEST_DROPPABLE: int = 129 TIPC_DEST_DROPPABLE: int = 129
TIPC_TOP_SRV: int = 1
TIPC_SUB_PORTS: int = 1
TIPC_SUB_SERVICE: int = 2
TIPC_SUB_CANCEL: int = 4
TIPC_PUBLISHED: int = 1
TIPC_WITHDRAWN: int = 2
TIPC_SUBSCR_TIMEOUT: int = 3
TIPC_WAIT_FOREVER: int = -1
# `tractor`'s reserved TIPC service-class ("type"), spelling out # `tractor`'s reserved TIPC service-class ("type"), spelling out
@ -775,3 +793,155 @@ def _observed_addr(
maybe_node=node, maybe_node=node,
maybe_ref=ref, maybe_ref=ref,
) )
# ------------------------------------------------------------------
# layer B, the topology service (`TIPC_TOP_SRV`)
#
# The kernel will *push* us name-table `publish`/`withdraw` events,
# i.e. cluster-wide service (de)registration without a registrar
# actor and without polling. This is what makes #378's "end game
# cluster proto" claim real.
#
# Layouts below are from `include/uapi/linux/tipc.h` and were
# verified byte-for-byte against a live kernel (see the §5.2 probe
# notes in `ai/tpt-backends/01_tipc_backend.md`).
# ------------------------------------------------------------------
# struct tipc_subscr {
# struct tipc_name_seq seq; /* 3 * __u32: type, lower, upper */
# __u32 timeout;
# __u32 filter;
# char usr_handle[8];
# }
_SUBSCR_FMT: str = '=5I8s'
# struct tipc_event {
# __u32 event, found_lower, found_upper;
# struct tipc_portid port; /* {__u32 ref; __u32 node;} */
# struct tipc_subscr s; /* the 28B subscription echo */
# }
#
# NOTE, 48B — NOT the 40 an earlier revision of the plan claimed.
_EVENT_FMT: str = '=10I8s'
_EVENT_SIZE: int = struct.calcsize(_EVENT_FMT)
# a `usr_handle[8]` tag so our subs are identifiable in
# `tipc nametable show`-adjacent debugging.
_SUBSCR_HANDLE: bytes = b'tractor\0'
_event_kinds: dict[int, str] = {
TIPC_PUBLISHED: 'published',
TIPC_WITHDRAWN: 'withdrawn',
TIPC_SUBSCR_TIMEOUT: 'timeout',
}
class TIPCNameEvent(
msgspec.Struct,
frozen=True,
):
'''
A kernel name-table transition: some service name was
published or withdrawn somewhere in the cluster.
'''
kind: Literal[
'published',
'withdrawn',
'timeout',
]
addr: TIPCAddress
node: int
ref: int
def __repr__(self) -> str:
return (
f'{type(self).__name__}'
f'['
f'{self.kind}, {self.addr}, @0x{self.node:08x}:{self.ref}'
f']'
)
def _mk_subscr(
stype: int,
lower: int,
upper: int,
filt: int,
timeout: int,
handle: bytes = _SUBSCR_HANDLE,
) -> bytes:
'''
Pack a `struct tipc_subscr` for the topology server.
NOTE, native (`'='`) byte-order is **accepted** by modern
kernels verified on a live box, both `publish` and
`withdraw` events round-tripped w/ the subscription echoed
back intact. An earlier revision of the plan proposed a
`'>'`-retry endianness probe; it isn't needed.
'''
return struct.pack(
_SUBSCR_FMT,
stype,
lower,
upper,
# XXX python exposes `TIPC_WAIT_FOREVER` as -1, so it MUST
# be masked before packing into an unsigned field.
timeout & 0xFFFF_FFFF,
filt,
handle,
)
def _decode_name_event(
raw: bytes,
stype: int,
scope: int,
) -> TIPCNameEvent|None:
'''
Decode one `struct tipc_event`, or `None` if it's a runt/
unrecognized frame.
'''
if len(raw) < _EVENT_SIZE:
log.warning(
f'Runt TIPC topology event, ignoring\n'
f'len: {len(raw)} (want {_EVENT_SIZE})\n'
)
return None
(
event,
found_lower,
found_upper,
ref,
node,
*_, # the 28B subscription echo
) = struct.unpack(_EVENT_FMT, raw[:_EVENT_SIZE])
if (kind := _event_kinds.get(event)) is None:
log.warning(
f'Unknown TIPC topology event code, ignoring\n'
f'event: {event!r}\n'
)
return None
return TIPCNameEvent(
kind=kind,
# NOTE, `tractor` only ever publishes *singleton* ranges
# (`lower == upper`) so the lower bound IS the instance.
#
# XXX the event carries NO scope — the name-table doesn't
# report it — so we echo back the subscription's own. Fine
# for our use (we subscribe per-scope) but don't mistake
# it for observed data.
addr=TIPCAddress(
_stype=stype,
_instance=found_lower,
_scope=scope,
),
node=node,
ref=ref,
)