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
parent
7e20585f59
commit
e269bbf871
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue