From b78e47f1c8224b459a7d216f6c608e4ed22cce46 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:21:24 +0300 Subject: [PATCH 01/35] Fix RX-path crash on crafted gossip names; guard CAN reader ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding #1 (High). Two defects combined into a remotely triggerable, permanent DoS on Cyphal/CAN: - The pin-suffix parser accepted any str.isdigit() character as a pin digit, so non-ASCII digits like '²' (U+00B2) reached int() and raised ValueError on the RX dispatch path, before the printable-ASCII check could reject the name. Pin digits are now gated on ASCII '0'..'9' exactly like the reference (cy.c name_consume_pin_suffix), and _is_valid_wire_name runs the ASCII check before the pin parse. - The CAN reader loop invoked _ingest_frame outside its try block, so any exception escaping the session-layer pipeline silently killed that interface's reception permanently. The ingest call is now guarded the same way the UDP RX loops guard their handler calls: log and keep serving. Regression tests: crafted 'x#²' gossip through the session layer, parser totality on Unicode digits, CAN reader survival with a raising handler, and UDP per-datagram fault-boundary containment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 4 +-- src/pycyphal2/can/_transport.py | 6 +++- tests/can/test_transport_internal.py | 31 +++++++++++++++++- tests/test_gossip.py | 48 ++++++++++++++++++++++++++++ tests/test_names.py | 16 ++++++++++ tests/test_udp.py | 20 ++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index f3f19be40..066e25984 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -95,7 +95,7 @@ def _name_consume_pin_suffix(name: str) -> tuple[str, int | None]: if ch == "#": hash_pos = i break - if not ch.isdigit(): + if not "0" <= ch <= "9": # ASCII only: str.isdigit() accepts chars that int() rejects, e.g. '²' return (name, None) if hash_pos < 0: return (name, None) @@ -134,8 +134,8 @@ def _is_valid_wire_name(name: str) -> bool: and "*" not in name and ">" not in name and not _name_is_homeful(name) - and _name_consume_pin_suffix(name)[1] is None and all(33 <= ord(ch) <= 126 for ch in name) + and _name_consume_pin_suffix(name)[1] is None and _name_normalize(name) == name ) diff --git a/src/pycyphal2/can/_transport.py b/src/pycyphal2/can/_transport.py index 30d19c74f..852f72f4c 100644 --- a/src/pycyphal2/can/_transport.py +++ b/src/pycyphal2/can/_transport.py @@ -365,7 +365,11 @@ async def _reader_loop(self, itf: Interface) -> None: iface_index = self._interface_index.get(id(itf)) if iface_index is None: return - self._ingest_frame(iface_index, frame) + try: + self._ingest_frame(iface_index, frame) + except Exception: + # A raising handler must not kill the reader loop; drop the frame and keep serving. + _logger.exception("Frame ingest raised iface=%s", itf.name) def _drop_interface(self, itf: Interface, ex: BaseException) -> None: if itf not in self._interfaces: diff --git a/tests/can/test_transport_internal.py b/tests/can/test_transport_internal.py index 42154c488..8e7b8d523 100644 --- a/tests/can/test_transport_internal.py +++ b/tests/can/test_transport_internal.py @@ -10,7 +10,7 @@ from pycyphal2._transport import SUBJECT_ID_MODULUS_16bit, TransportArrival from pycyphal2.can import CANTransport, TimestampedFrame from pycyphal2.can._transport import _CANTransportImpl, _PinnedSubjectState -from pycyphal2.can._wire import NODE_ID_ANONYMOUS, TransferKind +from pycyphal2.can._wire import NODE_ID_ANONYMOUS, TransferKind, serialize_transfer from tests.can._support import MockCANBus, MockCANInterface, wait_for @@ -220,6 +220,35 @@ async def test_reader_loop_exit_paths() -> None: delayed.close() +async def test_reader_loop_survives_raising_handler() -> None: + """A raising RX handler must not kill the interface's reader loop; the frame is dropped and reception continues.""" + bus = MockCANBus() + pub_if = MockCANInterface(bus, "pub") + sub_if = MockCANInterface(bus, "sub") + sub = CANTransport.new(sub_if) + calls: list[bytes] = [] + + def raising_handler(arrival: TransportArrival) -> None: + calls.append(bytes(arrival.message)) + raise ValueError("simulated handler fault (e.g. malformed gossip name)") + + sub.subject_listen(7, raising_handler) + for tid in (3, 4): + frame_id, frames = serialize_transfer( + kind=TransferKind.MESSAGE_16, + priority=0, + port_id=7, + source_id=55, + payload=b"payload", + transfer_id=tid, + fd=False, + ) + pub_if.enqueue(frame_id, [memoryview(frames[0])], Instant.now() + 1.0) + await wait_for(lambda: len(calls) == 2) + assert sub.interfaces == [sub_if] # The interface must not be dropped by the handler fault. + sub.close() + + async def test_drop_interface_and_node_id_occupancy_edges(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) bus = MockCANBus() diff --git a/tests/test_gossip.py b/tests/test_gossip.py index 8a2c58a80..210c9aa2a 100644 --- a/tests/test_gossip.py +++ b/tests/test_gossip.py @@ -119,6 +119,54 @@ async def test_send_gossip_unicast(): node.close() +async def test_gossip_crafted_unicode_pin_name_does_not_raise(): + """A crafted gossip name like 'x#²' must be silently dropped, never raise, on the RX path.""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + sub = node.subscribe("/sensor/>") + from pycyphal2._hash import rapidhash + + crafted = "x#²" # '²' passes str.isdigit() but is rejected by int() + name_bytes = crafted.encode("utf-8") + gossip_hdr = GossipHeader( + topic_log_age=5, + topic_hash=rapidhash(name_bytes), + topic_evictions=0, + name_len=len(name_bytes), + ) + arrival = TransportArrival( + timestamp=pycyphal2.Instant.now(), + priority=pycyphal2.Priority.NOMINAL, + remote_id=99, + message=gossip_hdr.serialize() + name_bytes, + ) + node.on_subject_arrival(node.broadcast_subject_id, arrival) # Must not raise. + assert crafted not in node.topics_by_name + + # The node must remain fully operational: a subsequent valid gossip is processed normally. + topic_name = "sensor/temp" + valid_hdr = GossipHeader( + topic_log_age=5, + topic_hash=rapidhash(topic_name), + topic_evictions=0, + name_len=len(topic_name), + ) + node.on_subject_arrival( + node.broadcast_subject_id, + TransportArrival( + timestamp=pycyphal2.Instant.now(), + priority=pycyphal2.Priority.NOMINAL, + remote_id=99, + message=valid_hdr.serialize() + topic_name.encode("utf-8"), + ), + ) + assert topic_name in node.topics_by_name + + sub.close() + node.close() + + async def test_gossip_implicit_topic_creation(): """A gossip whose name matches a pattern subscriber creates an implicit topic.""" net = MockNetwork() diff --git a/tests/test_names.py b/tests/test_names.py index c03731044..d347de296 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -5,6 +5,7 @@ from pycyphal2 import SUBJECT_ID_PINNED_MAX from pycyphal2._node import ( TOPIC_NAME_MAX, + _is_valid_wire_name, _name_consume_pin_suffix, _name_normalize, match_pattern, @@ -78,6 +79,21 @@ def test_pin_non_digit_after_hash() -> None: assert _name_consume_pin_suffix("foo#abc") == ("foo#abc", None) +def test_pin_unicode_digit_not_parsed() -> None: + # str.isdigit() is True for characters that int() rejects, e.g. '²' (U+00B2) or '②' (U+2461); + # the parser must treat them as non-digits and never raise. + assert _name_consume_pin_suffix("foo#²") == ("foo#²", None) + assert _name_consume_pin_suffix("foo#1²") == ("foo#1²", None) + assert _name_consume_pin_suffix("foo#²1") == ("foo#²1", None) + assert _name_consume_pin_suffix("x#②") == ("x#②", None) + + +def test_wire_name_unicode_digit_pin_rejected() -> None: + # A crafted gossip name like 'x#²' must be classified invalid without raising. + assert not _is_valid_wire_name("x#²") + assert not _is_valid_wire_name("x#②") + + def test_pin_hash_in_middle() -> None: # Pin is extracted from the rightmost '#' with a trailing digit run. assert _name_consume_pin_suffix("a#b#42") == ("a#b", 42) diff --git a/tests/test_udp.py b/tests/test_udp.py index fb1401f97..b496367dc 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -928,6 +928,26 @@ async def test_malformed_frame_does_not_learn_endpoint(self): finally: t.close() + @pytest.mark.asyncio + async def test_raising_subject_handler_does_not_escape(self): + """A raising subject handler is contained by the per-datagram fault boundary; reception continues.""" + t = UDPTransport.new_loopback() + assert isinstance(t, _UDPTransportImpl) + try: + calls: list[int] = [] + + def handler(arrival: TransportArrival) -> None: + calls.append(arrival.remote_id) + raise ValueError("simulated handler fault (e.g. malformed gossip name)") + + t._subject_handlers[55] = handler + for tid in (1, 2): + frame = _segment_transfer(4, tid, 0xAA, b"hello", mtu=1400)[0] + t._process_subject_datagram(frame, "10.0.0.1", 9000, 55, 0, Instant(ns=tid)) # Must not raise. + assert calls == [0xAA, 0xAA] + finally: + t.close() + @pytest.mark.asyncio async def test_transfer_failure_still_learns_endpoint(self): t = UDPTransport.new_loopback() From ac84d05081575a97dac73069b3ebc2e2d47f437c Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:22:38 +0300 Subject: [PATCH 02/35] =?UTF-8?q?Wrap=20the=20subject-ID=20hash+evictions?= =?UTF-8?q?=C2=B2=20sum=20mod=202^64=20to=20match=20the=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding #14 (Medium). The reference computes (hash + evictions²) % modulus in uint64 arithmetic, so the sum wraps at 2^64 before reduction; Python computed the exact value with big integers. The results differ whenever hash + evictions² >= 2^64, and since the eviction counter is an untrusted uint32 gossip field installed verbatim by a winning known-topic gossip, the divergence is remotely constructible: Python and C nodes sharing such a topic would compute different subject-IDs and silently stop hearing each other. Reference: cy.c topic_subject_id_impl. The test that pinned the non-wrapping behavior now asserts bit-for-bit equality with the reference formula. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 7 ++++--- tests/test_topic.py | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 066e25984..a4f47d4dd 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -222,9 +222,10 @@ def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None: def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int: if evictions >= EVICTIONS_PINNED_MIN: return 0xFFFFFFFF - evictions - h = topic_hash % modulus - e = evictions % modulus - return SUBJECT_ID_PINNED_MAX + 1 + ((h + ((e * e) % modulus)) % modulus) + # The sum wraps mod 2**64 before the reduction, matching the reference uint64 arithmetic bit-for-bit; + # without the wrap, a large hash plus a near-boundary eviction count (an untrusted gossip field) would + # place the same topic on different subject-IDs in Python and C. + return SUBJECT_ID_PINNED_MAX + 1 + (((topic_hash + evictions * evictions) & U64_MASK) % modulus) @dataclass diff --git a/tests/test_topic.py b/tests/test_topic.py index ebc0e1494..cdecd3a22 100644 --- a/tests/test_topic.py +++ b/tests/test_topic.py @@ -41,7 +41,8 @@ def test_compute_subject_id_non_pinned_zero_evictions(): def test_compute_subject_id_non_pinned_with_evictions(): - """Non-pinned formula: offset + ((hash % modulus) + ((evictions % modulus)^2 % modulus)) % modulus.""" + """Non-pinned formula: offset + ((hash + evictions^2) mod 2^64) % modulus; the modular-reduction form + used here for the expectation is equivalent whenever the sum does not overflow 64 bits.""" topic_hash = rapidhash("some/topic") for ev in (1, 2, 5, 100): sid = compute_subject_id(topic_hash, ev, DEFAULT_MODULUS) @@ -56,16 +57,17 @@ def test_compute_subject_id_non_pinned_with_evictions(): assert sid == expected -def test_compute_subject_id_non_pinned_does_not_wrap_uint64_sum(): +def test_compute_subject_id_wraps_uint64_sum(): + """The hash + evictions² sum wraps mod 2^64 before reduction, matching the reference uint64 arithmetic + bit-for-bit. The eviction count is an untrusted uint32 gossip field, so the overflowing case is remotely + constructible; exact big-int arithmetic here would partition Python and C nodes onto different subject-IDs.""" topic_hash = (1 << 64) - 1 evictions = EVICTIONS_PINNED_MIN - 1 sid = compute_subject_id(topic_hash, evictions, DEFAULT_MODULUS) uint64_wrapping = ( SUBJECT_ID_PINNED_MAX + 1 + (((topic_hash + (evictions * evictions)) & ((1 << 64) - 1)) % DEFAULT_MODULUS) ) - assert sid == 49564 - assert uint64_wrapping == 74897 - assert sid != uint64_wrapping + assert sid == uint64_wrapping == 74897 def test_compute_subject_id_evictions_changes_sid(): From d2102a663c45008e5c9f61d4aa0c97c383008aef Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:24:10 +0300 Subject: [PATCH 03/35] Make the UDP fragment-tree right-neighbor lookup inclusive per the reference Review finding #15 (Low). The reference selects the right neighbor with cavl2_predecessor, an inclusive floor (offset <= right), so a fragment beginning exactly at the new fragment's end participates in overlap eviction; Python used a strict '<'. The divergence is observable only when overlapping fragments carry conflicting data (corruption or adversarial injection): the reference evicts the conflicting fragment and delivers the transfer, while Python kept it and dropped the transfer on CRC failure. Reference: udpard.c rx_fragment_tree_update. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/udp.py | 2 +- tests/test_udp.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index ff6088d76..a16001eba 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -236,7 +236,7 @@ def _find_left_neighbor(self, left: int) -> _Fragment | None: def _find_right_neighbor(self, right: int) -> _Fragment | None: candidate: _Fragment | None = None for frag in self.fragments: - if frag.offset < right: + if frag.offset <= right: # Inclusive: a fragment starting exactly at `right` is a neighbor candidate. candidate = frag else: break diff --git a/tests/test_udp.py b/tests/test_udp.py index b496367dc..f7a5ea5aa 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -466,6 +466,30 @@ def test_bridge_fragment_evicts_victim(self): assert slot._accept_fragment(2, b"XXXXXX") assert [(frag.offset, frag.data) for frag in slot.fragments] == [(0, b"AAAA"), (2, b"XXXXXX"), (6, b"CCCC")] + def test_conflicting_overlap_evicted_via_inclusive_right_neighbor(self): + """A fragment starting exactly at the new fragment's end is its right neighbor (the reference's + cavl2_predecessor is an inclusive floor), so a conflicting overlapped fragment between them is + evicted and the transfer is delivered. A strict '<' lookup would keep the stale fragment and + fail the transfer CRC. Only observable when overlapping fragments carry conflicting data.""" + payload = b"ABCDEFGH" + + def hdr(offset: int, crc: int = 0) -> _FrameHeader: + return _FrameHeader( + priority=4, + transfer_id=1, + sender_uid=1, + frame_payload_offset=offset, + transfer_payload_size=len(payload), + prefix_crc=crc, + ) + + slot = _TransferSlot.create(hdr(0), 0) + assert slot.update(0, hdr(2), b"XXXX") is None # Conflicting (corrupt/injected) overlap. + assert slot.update(1, hdr(3, crc32c_full(payload)), b"DEFGH") is None + result = slot.update(2, hdr(0), b"ABC") + assert [(frag.offset, bytes(frag.data)) for frag in slot.fragments] == [(0, b"ABC"), (3, b"DEFGH")] + assert result == payload + def test_furthest_reaching_crc_is_used(self): payload = b"abcdef" slot = _TransferSlot.create( From feef062968417c63d706356c15fbf55bdf4adbb7 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:25:04 +0300 Subject: [PATCH 04/35] Seed the UDP transfer-ID history with the unmasked 64-bit sentinel Review finding #16 (Low). The history seed (transfer_id - 1) was masked to 48 bits, so a first-seen transfer-ID of 0 produced 0xFFFF_FFFF_FFFF - a valid wire transfer-ID - and a genuinely late transfer with that ID was wrongly treated as already ejected. The reference stores the unmasked uint64 value (2^64-1 for ID 0), which no 48-bit wire ID can match. Reference: udpard.c rx_port_push first-frame history initialization. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/udp.py | 5 ++++- tests/test_udp.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index a16001eba..53db784da 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -284,7 +284,10 @@ def is_transfer_ejected(self, transfer_id: int) -> bool: return transfer_id in self.history def initialize_history(self, transfer_id: int) -> None: - value = (transfer_id - 1) & TRANSFER_ID_MASK + # The seed wraps mod 2**64, not 2**48: for a first-seen transfer-ID of 0 it becomes 2**64-1, + # which no 48-bit wire transfer-ID can match (a 48-bit-masked seed would falsely reject a + # genuine transfer with ID 0xFFFF_FFFF_FFFF). Mirrors the reference uint64 arithmetic. + value = (transfer_id - 1) & 0xFFFF_FFFF_FFFF_FFFF self.history = [value] * _RX_TRANSFER_HISTORY_COUNT self.history_current = 0 self.initialized = True diff --git a/tests/test_udp.py b/tests/test_udp.py index f7a5ea5aa..98b9b60a1 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -265,6 +265,18 @@ def test_transfer_id_dedup(self): result2 = reasm.accept(frame_pairs[0][0], frame_pairs[0][1]) assert result2 is None # Dedup + def test_history_seed_sentinel_not_matchable_by_wire_id(self): + """A first-seen transfer-ID of 0 seeds the dedup history with (0 - 1) wrapped to 2^64-1, which no + 48-bit wire transfer-ID can equal (the reference keeps the unmasked uint64). A 48-bit-masked seed + would equal 0xFFFF_FFFF_FFFF — a valid wire value — falsely rejecting a genuine such transfer.""" + reasm = _RxReassembler() + first = self._make_frames(b"first", mtu=1400, transfer_id=0) + assert reasm.accept(first[0][0], first[0][1]) is not None + genuine = self._make_frames(b"genuine", mtu=1400, transfer_id=TRANSFER_ID_MASK) + result = reasm.accept(genuine[0][0], genuine[0][1]) + assert result is not None + assert result.payload == b"genuine" + def test_crc_mismatch_first_frame(self): payload = b"corrupt me" reasm = _RxReassembler() From 43e57de41bd518047ebcaf30dd4d903f10e11cd3 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:25:55 +0300 Subject: [PATCH 05/35] Destroy idle slot-free CAN RX sessions at the transfer-ID timeout Review finding #17 (Low). libcanard destroys a slot-free session once last_admission_ts falls behind the 2 s transfer-ID timeout (canard_poll); the 30 s retention window applies only to the slots themselves (rx_session_cleanup). Python retained slot-free sessions for the full 30 s, so during a redundant-bus failover a transfer reusing the last-admitted transfer-ID+priority from a different interface could be dropped for up to ~28 s longer than in the reference. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/_reassembly.py | 7 ++++++- tests/can/test_reassembly.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/can/_reassembly.py b/src/pycyphal2/can/_reassembly.py index 3e6b5bb3a..7f5586f1b 100644 --- a/src/pycyphal2/can/_reassembly.py +++ b/src/pycyphal2/can/_reassembly.py @@ -63,13 +63,18 @@ class Endpoint: class Reassembler: @staticmethod def cleanup_sessions(endpoints: Iterable[Endpoint], now_ns: int) -> None: + # Slots are retained for RX_SESSION_RETENTION_NS (30 s), but a slot-free session is destroyed as + # soon as the transfer-ID timeout (2 s) elapses since the last admission, as in the reference + # (canard_poll): past that point the admission logic treats the session as stale anyway, so + # retaining it longer only delays cross-interface transfer-ID reuse after a redundant failover. stale_deadline = now_ns - RX_SESSION_RETENTION_NS + idle_deadline = now_ns - TRANSFER_ID_TIMEOUT_NS for endpoint in endpoints: for source_id, session in list(endpoint.sessions.items()): for priority, slot in enumerate(session.slots): if slot is not None and slot.start_ts_ns < stale_deadline: session.slots[priority] = None - if all(slot is None for slot in session.slots) and session.last_admission_ts_ns < stale_deadline: + if all(slot is None for slot in session.slots) and session.last_admission_ts_ns < idle_deadline: endpoint.sessions.pop(source_id, None) @staticmethod diff --git a/tests/can/test_reassembly.py b/tests/can/test_reassembly.py index af62d51bd..66ea7cd6b 100644 --- a/tests/can/test_reassembly.py +++ b/tests/can/test_reassembly.py @@ -4,6 +4,33 @@ from pycyphal2.can._wire import TransferKind +def test_cleanup_drops_slot_free_session_at_transfer_id_timeout() -> None: + """A slot-free session is destroyed once the 2 s transfer-ID timeout elapses since the last + admission (reference: canard_poll), while slots themselves are retained for 30 s. A fresh + slot-free session is kept.""" + endpoint = Endpoint(kind=TransferKind.MESSAGE_16, port_id=7, on_transfer=lambda *_: None) + stale = RxSession.new(0) + stale.last_admission_ts_ns = 0 + fresh = RxSession.new(0) + fresh.last_admission_ts_ns = 2_500_000_000 + endpoint.sessions[42] = stale + endpoint.sessions[43] = fresh + + Reassembler.cleanup_sessions([endpoint], 3_000_000_000) # Stale is 3 s idle, fresh only 0.5 s. + + assert 42 not in endpoint.sessions + assert 43 in endpoint.sessions + + # A session with a live slot is retained regardless of admission staleness for up to 30 s. + occupied = RxSession.new(0) + occupied.last_admission_ts_ns = 0 + occupied.slots[0] = RxSlot(start_ts_ns=0, transfer_id=0, iface_index=0, expected_toggle=False) + endpoint.sessions[44] = occupied + Reassembler.cleanup_sessions([endpoint], 25_000_000_000) # Slot is 25 s old: below the 30 s purge. + assert 44 in endpoint.sessions + assert endpoint.sessions[44].slots[0] is not None + + def test_cleanup_drops_session_after_30_seconds() -> None: received: list[bytes] = [] endpoint = Endpoint( From af3af8523be791dbe91fe629cb5d566d3c1e20fa Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:27:40 +0300 Subject: [PATCH 06/35] Fix CAN FD framing: the frame format follows the interface, never BRS Review finding #18 (Low), with the corrected model: FD/Classic is a property of the interface, fixed at construction - not per transfer and not per frame. Previously both backends decided per frame by payload length (len > 8), so the short/tail frames of an FD transfer went out as Classic frames, and python-can additionally forced bitrate_switch, which the reference never sets and a nominal-bitrate-only FD network may reject. Now every frame on an FD interface is an FD frame (FDF only) and every frame on a Classic interface is a Classic frame, matching the reference (cy_can_socketcan selects the FD/Classic vtable once from the netdev MTU and emits all frames accordingly, no BRS). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/pythoncan.py | 5 ++-- src/pycyphal2/can/socketcan.py | 11 +++++---- tests/can/test_pythoncan.py | 42 ++++++++++++++++++++++++++++++++ tests/can/test_socketcan_unit.py | 5 ++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/pycyphal2/can/pythoncan.py b/src/pycyphal2/can/pythoncan.py index 9597b5d1b..bf98bcd21 100644 --- a/src/pycyphal2/can/pythoncan.py +++ b/src/pycyphal2/can/pythoncan.py @@ -159,12 +159,13 @@ async def _tx_loop(self) -> None: _logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, entry.id) job.abort(SendError(f"PythonCAN interface {self._name} tx deadline expired")) continue + # The FD flag follows the interface mode, not the payload length, and BRS is never set, + # matching the reference (cy_can_socketcan emits every frame of an FD interface with FDF only). msg = can.Message( arbitration_id=entry.id, is_extended_id=True, data=entry.payload, - is_fd=self._fd and len(entry.payload) > 8, - bitrate_switch=self._fd and len(entry.payload) > 8, + is_fd=self._fd, ) try: await asyncio.wait_for(loop.run_in_executor(None, self._bus.send, msg, timeout), timeout=timeout) diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index 4c2cb42b5..9281316dc 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -181,11 +181,10 @@ def _is_transient_tx_error(ex: OSError) -> bool: return ex.errno in _TRANSIENT_TX_ERRNO def _encode(self, identifier: int, data: bytes) -> bytes: - if len(data) > 8: - if not self._fd: - raise ValueError( - f"SocketCAN interface {self._name} cannot send a {len(data)}-byte frame on Classic CAN" - ) + # The frame format is a property of the interface, fixed at construction, not of the payload + # length: every frame on an FD interface is an FD frame (FDF set, BRS never), as in the + # reference (cy_can_socketcan selects the FD/Classic vtable once from the netdev MTU). + if self._fd: return _CANFD_FRAME_STRUCT.pack( socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK), len(data), @@ -194,6 +193,8 @@ def _encode(self, identifier: int, data: bytes) -> bytes: 0, data.ljust(64, b"\x00"), ) + if len(data) > 8: + raise ValueError(f"SocketCAN interface {self._name} cannot send a {len(data)}-byte frame on Classic CAN") return _CAN_FRAME_STRUCT.pack( socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK), len(data), diff --git a/tests/can/test_pythoncan.py b/tests/can/test_pythoncan.py index 67a620a6a..802a2a0d8 100644 --- a/tests/can/test_pythoncan.py +++ b/tests/can/test_pythoncan.py @@ -1185,6 +1185,48 @@ async def test_unit_mixed_fd_and_classic_payloads() -> None: _close_all(a, b) +async def test_unit_fd_flags_follow_interface_mode() -> None: + """Every frame on an FD interface carries is_fd regardless of payload length, and BRS is never set; + a Classic interface never sets is_fd. Matches the reference cy_can_socketcan framing.""" + a, b = _virtual_pair(fd=True) + sent: list[_can.Message] = [] + orig_send = a._bus.send + + def recording_send(msg: _can.Message, timeout: float | None = None) -> None: + sent.append(msg) + orig_send(msg, timeout) + + a._bus.send = recording_send # type: ignore[method-assign] + try: + a.enqueue(0x00030000, [memoryview(b"tiny")], Instant.now() + 2.0) + a.enqueue(0x00030001, [memoryview(bytes(range(32)))], Instant.now() + 2.0) + for _ in range(2): + await asyncio.wait_for(b.receive(), timeout=2.0) + assert len(sent) == 2 + assert all(m.is_fd for m in sent) + assert not any(m.bitrate_switch for m in sent) + finally: + _close_all(a, b) + + c, d = _virtual_pair() + sent_classic: list[_can.Message] = [] + orig_send_c = c._bus.send + + def recording_send_c(msg: _can.Message, timeout: float | None = None) -> None: + sent_classic.append(msg) + orig_send_c(msg, timeout) + + c._bus.send = recording_send_c # type: ignore[method-assign] + try: + c.enqueue(0x00030002, [memoryview(b"tiny")], Instant.now() + 2.0) + await asyncio.wait_for(d.receive(), timeout=2.0) + assert len(sent_classic) == 1 + assert not sent_classic[0].is_fd + assert not sent_classic[0].bitrate_switch + finally: + _close_all(c, d) + + async def test_unit_enqueue_same_id_preserves_order() -> None: a, b = _virtual_pair() try: diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index cfd0e6edf..502e30f47 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -311,6 +311,11 @@ def test_encode_and_decode_branches(monkeypatch: pytest.MonkeyPatch) -> None: encoded_fd = fd_iface._encode(456, b"012345678") assert len(encoded_fd) == module._FD_FRAME_SIZE + # The frame format follows the interface mode, not the payload length: a short payload on an FD + # interface is still emitted as an FD frame, as in the reference. + encoded_fd_short = fd_iface._encode(456, b"abc") + assert len(encoded_fd_short) == module._FD_FRAME_SIZE + assert module.SocketCANInterface._decode(b"\x00") is None non_extended = module._CAN_FRAME_STRUCT.pack(0x123, 1, b"x".ljust(8, b"\x00")) From d5e3e5fe8b14769d3388a0670924c91a01f4f050 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:29:30 +0300 Subject: [PATCH 07/35] Classify wildcards by whole segment, not by character containment Review finding #19 (Low). The reference treats '*'/'>' as substitution tokens only when a whole '/'-segment equals them (wkv_has_substitution_tokens), so a name like 'ab*cd' is a legal verbatim topic in C. Python classified any name containing those characters as a pattern, so such names could be neither advertised nor joined from gossip - a Python-to-C interop gap. Classification is now whole-segment on both the resolve path and gossip wire-name acceptance. The match semantics keep the documented terminal-only '>' REFERENCE PARITY deviation; classification follows the reference exactly, so off-terminal whole-segment '>' names remain patterns. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 18 ++++++++++++------ tests/test_names.py | 27 +++++++++++++++++++++++++++ tests/test_topic.py | 16 ++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index a4f47d4dd..d5ec88fa4 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -122,17 +122,23 @@ def _name_is_homeful(name: str) -> bool: return name == "~" or name.startswith("~/") +def _name_has_pattern_tokens(name: str) -> bool: + """A name is a pattern iff some whole '/'-segment is a substitution token ('*' or '>'). Tokens embedded + within a longer segment (e.g. 'ab*cd') are literal characters, as in the reference classifier + (wkv_has_substitution_tokens), so such names are legal verbatim topics.""" + return any(seg in ("*", ">") for seg in name.split("/")) + + def _is_valid_wire_name(name: str) -> bool: """True if `name` is a well-formed *resolved* wire topic name, as required of names received in gossip: nonempty, length-bounded, printable ASCII (33-126), already normalized (no leading/trailing/duplicate - '/'), verbatim (no '*'/'>' pattern tokens), not homeful ('~'/'~/...'), and pin-free (no '#' suffix). - The last two are stripped/expanded by resolve_name before a name reaches the wire, so their presence - means the gossip is unresolved/non-canonical and must not create a local topic.""" + '/'), verbatim (no whole-segment '*'/'>' pattern tokens), not homeful ('~'/'~/...'), and pin-free + (no '#' suffix). The last two are stripped/expanded by resolve_name before a name reaches the wire, + so their presence means the gossip is unresolved/non-canonical and must not create a local topic.""" return ( bool(name) and len(name) <= TOPIC_NAME_MAX - and "*" not in name - and ">" not in name + and not _name_has_pattern_tokens(name) and not _name_is_homeful(name) and all(33 <= ord(ch) <= 126 for ch in name) and _name_consume_pin_suffix(name)[1] is None @@ -184,7 +190,7 @@ def resolve_name( if o < 33 or o > 126: raise ValueError(f"Invalid character in name: {ch!r}") - verbatim = "*" not in resolved and ">" not in resolved + verbatim = not _name_has_pattern_tokens(resolved) if pin is not None and not verbatim: raise ValueError("Pattern names cannot be pinned") return resolved, pin, verbatim diff --git a/tests/test_names.py b/tests/test_names.py index d347de296..7f9575d8f 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -94,6 +94,33 @@ def test_wire_name_unicode_digit_pin_rejected() -> None: assert not _is_valid_wire_name("x#②") +def test_resolve_embedded_token_is_verbatim() -> None: + # Only a whole segment equal to '*' or '>' is a substitution token (reference: + # wkv_has_substitution_tokens); embedded within a longer segment they are literal characters. + resolved, _, verbatim = resolve_name("/sensor/temp*raw", "home", "ns") + assert resolved == "sensor/temp*raw" + assert verbatim + resolved, _, verbatim = resolve_name("/ab>cd", "home", "ns") + assert resolved == "ab>cd" + assert verbatim + + +def test_resolve_whole_segment_tokens_are_patterns() -> None: + assert resolve_name("/a/*/c", "home", "ns")[2] is False + assert resolve_name("/a/>", "home", "ns")[2] is False + assert resolve_name("/a/>/b", "home", "ns")[2] is False # Classified a pattern even off-terminal. + + +def test_wire_name_embedded_token_is_valid() -> None: + # A legal verbatim C topic like 'ab*cd' must be accepted from gossip for interop. + assert _is_valid_wire_name("ab*cd") + assert _is_valid_wire_name("x/y>z") + assert not _is_valid_wire_name("a/*/c") + assert not _is_valid_wire_name("a/>") + assert not _is_valid_wire_name("*") + assert not _is_valid_wire_name(">") + + def test_pin_hash_in_middle() -> None: # Pin is extracted from the rightmost '#' with a trailing digit run. assert _name_consume_pin_suffix("a#b#42") == ("a#b", 42) diff --git a/tests/test_topic.py b/tests/test_topic.py index cdecd3a22..856244243 100644 --- a/tests/test_topic.py +++ b/tests/test_topic.py @@ -113,6 +113,22 @@ async def test_advertise_creates_topic(): node.close() +async def test_advertise_embedded_wildcard_char_is_verbatim(): + """'sensor/temp*raw' has no whole-segment substitution token, so it is a legal verbatim topic + (reference parity with the wkv classifier) and can be advertised.""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n") + + pub = node.advertise("/sensor/temp*raw") + topic = node.topics_by_name.get("sensor/temp*raw") + assert topic is not None + assert topic.pub_count == 1 + + pub.close() + node.close() + + async def test_advertise_assigns_subject_id(): net = MockNetwork() tr = MockTransport(node_id=1, network=net) From 03f278e70cd1600d54ac7c2f1c94979bc37f301e Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:30:26 +0300 Subject: [PATCH 08/35] Allow CAN unicast to remote node-ID 0 Review finding #12 (Medium). Node-ID 0 is a valid regular node in Cyphal/CAN v1 (only v0/DroneCAN treated 0 as anonymous; libcanard permits a remote 0 and a service destination 0), and the wire encoder already accepts it, but unicast() guarded 1 <= remote_id, so the ACK path could not answer a node-0 peer and reliable delivery from such a peer never completed. The destination range is now 0..127. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/_transport.py | 4 +++- tests/can/test_transport_internal.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pycyphal2/can/_transport.py b/src/pycyphal2/can/_transport.py index 852f72f4c..7ad52921f 100644 --- a/src/pycyphal2/can/_transport.py +++ b/src/pycyphal2/can/_transport.py @@ -250,7 +250,9 @@ def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None: async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None: if self._closed: raise ClosedError("CAN transport closed") - if not (1 <= remote_id <= NODE_ID_MAX): + # Node-ID 0 is a valid regular node in Cyphal/CAN v1 (only v0 treated it as anonymous), so it is + # a legal unicast destination; rejecting it would make the ACK path unable to answer a node-0 peer. + if not (0 <= remote_id <= NODE_ID_MAX): raise ValueError(f"Invalid remote node-ID: {remote_id}") transfer_id = self._unicast_tid[remote_id] self._unicast_tid[remote_id] = (transfer_id + 1) % TRANSFER_ID_MODULO diff --git a/tests/can/test_transport_internal.py b/tests/can/test_transport_internal.py index 8e7b8d523..f0628895f 100644 --- a/tests/can/test_transport_internal.py +++ b/tests/can/test_transport_internal.py @@ -10,7 +10,7 @@ from pycyphal2._transport import SUBJECT_ID_MODULUS_16bit, TransportArrival from pycyphal2.can import CANTransport, TimestampedFrame from pycyphal2.can._transport import _CANTransportImpl, _PinnedSubjectState -from pycyphal2.can._wire import NODE_ID_ANONYMOUS, TransferKind, serialize_transfer +from pycyphal2.can._wire import NODE_ID_ANONYMOUS, TransferKind, parse_frame, serialize_transfer from tests.can._support import MockCANBus, MockCANInterface, wait_for @@ -99,9 +99,18 @@ async def test_writer_unicast_and_send_transfer_error_paths() -> None: with pytest.raises(ClosedError, match="CAN transport closed"): await writer2(Instant.now() + 1.0, Priority.NOMINAL, b"x") - live = CANTransport.new(MockCANInterface(bus, "if1")) - with pytest.raises(ValueError, match="Invalid remote node-ID"): - await live.unicast(Instant.now() + 1.0, Priority.NOMINAL, 0, b"x") + live_if = MockCANInterface(bus, "if1") + live = CANTransport.new(live_if) + # Node-ID 0 is a valid regular Cyphal/CAN v1 node, hence a legal unicast destination; + # the wire encoding must carry destination 0. + await live.unicast(Instant.now() + 1.0, Priority.NOMINAL, 0, b"x") + uni_id, uni_frames, _ = live_if.enqueue_history[-1] + parsed = parse_frame(uni_id, uni_frames[0]) + assert parsed is not None + assert parsed.destination_id == 0 + for bad_remote in (-1, 128): + with pytest.raises(ValueError, match="Invalid remote node-ID"): + await live.unicast(Instant.now() + 1.0, Priority.NOMINAL, bad_remote, b"x") live_impl = cast(_CANTransportImpl, live) with pytest.raises(SendError, match="Deadline exceeded"): From deee1622c29ca4e5892b23bfa14daf12ad531ee5 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:31:32 +0300 Subject: [PATCH 09/35] Drop SLCAN standard-ID data frames instead of aliasing them as extended Review finding #8 (Medium). The SLCAN 't' command (11-bit standard-ID data frame) was decoded into an ordinary Frame, indistinguishable from an extended frame with a small ID because Frame carries no IDE flag - in violation of the extended-only Interface contract that the SocketCAN and python-can backends enforce. On a mixed bus a standard frame could parse as a bogus transfer or even trigger a spurious node-ID collision re-roll. Standard-ID 't' lines are now dropped alongside the already-dropped 'r'/'R' remote frames; the reference consumes only CAN_EFF_FLAG frames. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/_media_slcan.py | 13 +++++-------- tests/can/test_media_slcan.py | 10 ++++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/pycyphal2/can/_media_slcan.py b/src/pycyphal2/can/_media_slcan.py index 9420fac49..7099f1da6 100644 --- a/src/pycyphal2/can/_media_slcan.py +++ b/src/pycyphal2/can/_media_slcan.py @@ -6,7 +6,7 @@ import logging -from ._interface import CAN_EXT_ID_MASK, CAN_STD_ID_MASK, Frame +from ._interface import CAN_EXT_ID_MASK, Frame from ._wire import DLC_TO_LENGTH, MTU_CAN_CLASSIC _logger = logging.getLogger(__name__) @@ -126,12 +126,12 @@ def _parse_line(line: bytes) -> Frame | None: command = line[:1] if command in (b"T", b"x"): return _parse_data_frame(line, id_length=8, max_payload_length=MTU_CAN_CLASSIC) - if command == b"t": - return _parse_data_frame(line, id_length=3, max_payload_length=MTU_CAN_CLASSIC) if command == b"D": return _parse_data_frame(line, id_length=8, max_payload_length=64) - if command in (b"r", b"R"): - _logger.debug("SLCAN drop unsupported frame type cmd=%r", command) + if command in (b"t", b"r", b"R"): + # Standard-ID (11-bit) frames: the Interface contract is extended-only and Frame carries no IDE + # discriminator, so forwarding a 't' data frame would alias an extended frame with a small ID. + _logger.debug("SLCAN drop standard-id frame cmd=%r", command) return None _logger.debug("SLCAN drop unknown line=%r", line) return None @@ -155,9 +155,6 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) - if len(line) < expected: _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected) return None - if id_length == 3 and identifier > CAN_STD_ID_MASK: - _logger.debug("SLCAN drop invalid standard id=%x", identifier) - return None data = _parse_hex_bytes(line[header_length:expected]) if data is None: _logger.debug("SLCAN drop malformed data id=%08x", identifier) diff --git a/tests/can/test_media_slcan.py b/tests/can/test_media_slcan.py index 4578f4614..e9f55ccd3 100644 --- a/tests/can/test_media_slcan.py +++ b/tests/can/test_media_slcan.py @@ -53,9 +53,11 @@ def test_parse_classic_extended_frames() -> None: assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")] assert parser.feed(b"T000001232abCd\r") == [Frame(id=0x123, data=b"\xab\xcd")] - assert parser.feed(b"t1231AA\r") == [Frame(id=0x123, data=b"\xaa")] - assert parser.feed(b"t7FF1AA\r") == [Frame(id=0x7FF, data=b"\xaa")] - assert parser.feed(b"t7FF0\r") == [Frame(id=0x7FF, data=b"")] + # Standard-ID 't' data frames are dropped: the Interface contract is extended-only, and Frame has + # no IDE discriminator, so forwarding them would alias extended frames with small IDs. + assert parser.feed(b"t1231AA\r") == [] + assert parser.feed(b"t7FF1AA\r") == [] + assert parser.feed(b"t7FF0\r") == [] assert parser.feed(b"T000001") == [] assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")] assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xab")] @@ -68,7 +70,7 @@ def test_parse_ignores_optional_frame_suffix() -> None: assert parser.feed(b"T000001232ABCDL\r") == [Frame(id=0x123, data=b"\xab\xcd")] assert parser.feed(b"T000001232ABCD1234L\r") == [Frame(id=0x123, data=b"\xab\xcd")] assert parser.feed(b"T000001232ABCDzzzz\r") == [Frame(id=0x123, data=b"\xab\xcd")] - assert parser.feed(b"t1231AAL\r") == [Frame(id=0x123, data=b"\xaa")] + assert parser.feed(b"t1231AAL\r") == [] # Standard-ID frames are dropped regardless of suffix. assert parser.feed(b"T000001232ABCD1234\x03\r") == [Frame(id=0x123, data=b"\xab\xcd")] assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [ Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xff\x00\x00\x00\xa0"), From fa4003015d61c71611e84696c601a51f42fb1133 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:33:01 +0300 Subject: [PATCH 10/35] Restrict Linux multicast RX sockets to their own group membership Review finding #13 (Medium). The reference validates every received datagram's ingress interface index via recvmsg+IP_PKTINFO and drops mismatches (udp_wrapper.c). Python's RX loops attribute every datagram to the socket's configured interface, and on multi-homed Linux hosts the default IP_MULTICAST_ALL=1 delivers datagrams of groups joined on *other* interfaces to this socket too, mislearning (uid, iface) reverse routes and defeating independent-path failover. asyncio offers no sock_recvmsg (and Windows lacks socket.recvmsg entirely), so the fix uses the kernel-level equivalent: IP_MULTICAST_ALL=0 makes the socket receive only datagrams matching its own explicit (group, interface) membership, which yields the same delivery set as the reference's ipi_ifindex filter. macOS/BSD scope multicast delivery per membership natively; the Windows single-NIC-per-multicast-network assumption is documented at the socket setup site. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/udp.py | 11 +++++++++++ tests/test_udp.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 53db784da..765107f53 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -52,6 +52,7 @@ IPv4_SUBJECT_ID_MAX = 0x7FFFFF TRANSFER_ID_MASK = (1 << 48) - 1 _MULTICAST_TTL = 16 +_IP_MULTICAST_ALL_LINUX = 49 # Linux uapi in.h; not exposed by CPython's socket module. _SIOCGIFMTU = 0x8921 _CYPHAL_OVERHEAD_MAX = 100 _CYPHAL_MTU_LINK_MIN = 576 @@ -684,6 +685,16 @@ def _create_mcast_socket(subject_id: int, iface: Interface) -> socket.socket: sock.bind((mcast_ip, port)) mreq = socket.inet_aton(mcast_ip) + socket.inet_aton(str(iface.address)) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + # REFERENCE PARITY: the reference filters every received datagram by its ingress interface index + # via recvmsg+IP_PKTINFO (udp_wrapper.c). asyncio offers no sock_recvmsg, so on Linux the same + # delivery set is obtained at the kernel level with IP_MULTICAST_ALL=0: with it, this socket only + # receives datagrams matching its own (group, interface) membership above, instead of the default + # any-interface delivery that would mislearn reverse routes on multi-homed hosts. macOS/BSD scope + # multicast delivery per membership natively. On Windows the socket binds INADDR_ANY and Winsock + # may deliver cross-interface traffic; multi-homed Windows hosts should configure at most one + # transport interface per multicast-reachable network. + if sys.platform == "linux": + sock.setsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX, 0) _logger.info("Multicast socket for subject %d on %s (%s:%d)", subject_id, iface.address, mcast_ip, port) return sock diff --git a/tests/test_udp.py b/tests/test_udp.py index 98b9b60a1..d416b450c 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -2,7 +2,9 @@ import asyncio import os +import socket import struct +import sys from ipaddress import IPv4Address from unittest.mock import patch @@ -31,6 +33,7 @@ Interface, UDPTransport, _FrameHeader, + _IP_MULTICAST_ALL_LINUX, _RxReassembler, _SUBJECT_ID_MODULUS_MAX, _TransferSlot, @@ -939,6 +942,17 @@ async def test_operations_after_close_fail(self): with pytest.raises(SendError): await writer(Instant.now() + 1.0, Priority.NOMINAL, b"should fail") + @pytest.mark.skipif(sys.platform != "linux", reason="IP_MULTICAST_ALL is a Linux-only socket option") + def test_mcast_socket_disables_cross_interface_delivery(self, loopback_iface): + """On Linux the multicast RX socket must set IP_MULTICAST_ALL=0 so it only receives datagrams + matching its own (group, interface) membership - the kernel-level equivalent of the reference's + recvmsg+IP_PKTINFO ingress-interface filter (udp_wrapper.c).""" + sock = _UDPTransportImpl._create_mcast_socket(5, loopback_iface) + try: + assert sock.getsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX) == 0 + finally: + sock.close() + @pytest.mark.asyncio async def test_subject_id_modulus(self, loopback_iface): t = UDPTransport.new(interfaces=[loopback_iface], subject_id_modulus=_SUBJECT_ID_MODULUS_MAX) From 7aaae7116b4bcd69226ec0feeab2e73bc3d8ff2f Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:38:50 +0300 Subject: [PATCH 11/35] Validate subject_id_modulus with the reference predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding #2 (Medium). The transports accepted any modulus in range, but the quadratic probe (hash + evictions²) mod m covers the residue space only when the modulus is at least 57203, prime, and congruent to 3 modulo 4 (cy.c is_valid_subject_id_modulus). A degenerate modulus made the synchronous displacement loop in topic_allocate iterate billions of times - a hard event-loop stall that asyncio.wait_for cannot cancel. The predicate is now enforced at node construction, before any resource is acquired, so the three published moduli pass and a degenerate value is rejected cleanly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 22 +++++++++++++++++++++- tests/test_topic.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index d5ec88fa4..8907db84b 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -28,7 +28,7 @@ ScoutHeader, deserialize_header, ) -from ._transport import SubjectWriter, Transport, TransportArrival +from ._transport import SUBJECT_ID_MODULUS_16bit, SubjectWriter, Transport, TransportArrival from ._api import Topic, Node, Publisher, Subscriber, Breadcrumb, Closable, ClosedError, Instant, Priority, SendError from ._api import SUBJECT_ID_PINNED_MAX @@ -225,6 +225,21 @@ def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None: return subs +def is_valid_subject_id_modulus(modulus: int) -> bool: + """The reference predicate (cy.c is_valid_subject_id_modulus): at least 57203, prime, and ≡ 3 (mod 4). + The quadratic probe (hash + evictions²) mod m covers the residue space only under these conditions; + a degenerate modulus would make the synchronous displacement loop in topic_allocate effectively + non-terminating, hard-blocking the event loop.""" + if modulus < SUBJECT_ID_MODULUS_16bit or modulus % 4 != 3: + return False + d = 3 + while d * d <= modulus: + if modulus % d == 0: + return False + d += 2 + return True + + def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int: if evictions >= EVICTIONS_PINNED_MIN: return 0xFFFFFFFF - evictions @@ -521,6 +536,11 @@ def __init__(self, transport: Transport, *, home: str, namespace: str) -> None: self.respond_futures: dict[tuple[int, ...], RespondTracker] = {} modulus = transport.subject_id_modulus + if not is_valid_subject_id_modulus(modulus): + raise ValueError( + f"Invalid subject_id_modulus {modulus}: " + f"must be at least {SUBJECT_ID_MODULUS_16bit}, prime, and congruent to 3 modulo 4" + ) sid_max = SUBJECT_ID_PINNED_MAX + modulus self.broadcast_subject_id = (1 << (int(math.log2(sid_max)) + 1)) - 1 self.gossip_shard_count = self.broadcast_subject_id - (sid_max + 1) diff --git a/tests/test_topic.py b/tests/test_topic.py index 856244243..afe52a61a 100644 --- a/tests/test_topic.py +++ b/tests/test_topic.py @@ -2,6 +2,8 @@ import time +import pytest + from pycyphal2 import SUBJECT_ID_PINNED_MAX from pycyphal2._node import left_wins from pycyphal2._hash import rapidhash @@ -9,6 +11,7 @@ EVICTIONS_PINNED_MIN, GossipScope, compute_subject_id, + is_valid_subject_id_modulus, match_pattern, resolve_name, ) @@ -94,6 +97,31 @@ def test_compute_subject_id_just_below_pinned(): assert sid == expected +def test_is_valid_subject_id_modulus_predicate(): + """Mirror of the reference predicate: >= 57203, prime, and ≡ 3 (mod 4).""" + for good in (57203, 122743, 8378431, 4294954663): + assert is_valid_subject_id_modulus(good) + assert not is_valid_subject_id_modulus(3) # Prime and ≡3 mod 4, but below the minimum. + assert not is_valid_subject_id_modulus(57202) # Below the minimum. + assert not is_valid_subject_id_modulus(57205) # ≡ 1 mod 4. + assert not is_valid_subject_id_modulus(57207) # ≡ 3 mod 4 but composite (3 × 19069). + assert not is_valid_subject_id_modulus(122744) # Even. + + +async def test_degenerate_subject_id_modulus_rejected(): + """A modulus violating the reference predicate must be rejected at node construction: the quadratic + probe (hash + evictions²) mod m does not cover the residue space under a degenerate modulus, so the + synchronous displacement loop in topic_allocate would hard-block the event loop.""" + for bad in (3, 57202, 57205, 57207, 122744): + tr = MockTransport(node_id=1, modulus=bad, network=MockNetwork()) + with pytest.raises(ValueError, match="subject_id_modulus"): + new_node(tr, home="n") + for good in (57203, 122743, 8378431): + tr = MockTransport(node_id=1, modulus=good, network=MockNetwork()) + node = new_node(tr, home="n") + node.close() + + async def test_advertise_creates_topic(): net = MockNetwork() tr = MockTransport(node_id=1, network=net) From bbab7ab38285898660c160ac3173f80fff200a5a Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:38:50 +0300 Subject: [PATCH 12/35] Wake a parked SocketCAN reader on close/fail Review finding #10 (Medium). SocketCAN receive() awaited loop.sock_recv directly, and close()/_fail() closed the socket without waking a reader already parked there - the selector loop does not wake a bare sock_recv on socket.close(), so the transport's reader stayed hung in receive() forever and interface loss (no reception, no interface removal) was never propagated. RX now runs in its own task feeding a queue, and close() pushes a ClosedError sentinel that unblocks a parked receive(), mirroring the python-can and webserial backends. The socket is closed last so the cancelled reader task deregisters cleanly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/socketcan.py | 30 +++++++++++++++--- tests/can/test_socketcan_unit.py | 54 ++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index 9281316dc..458ddac7a 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -47,6 +47,11 @@ def __init__(self, name: str) -> None: self._failure: BaseException | None = None self._tx = TxQueue() self._tx_task: asyncio.Task[None] | None = None + # RX runs in its own task feeding a queue, so close()/fail() can wake a parked reader with a + # sentinel instead of leaving it hung in sock_recv (which the selector loop never wakes on a + # bare socket.close()). This mirrors the python-can and webserial backends. + self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue() + self._rx_task: asyncio.Task[None] | None = None @property def name(self) -> str: @@ -88,24 +93,41 @@ def purge(self) -> None: async def receive(self) -> TimestampedFrame: self._raise_if_closed() + if self._rx_task is None: + self._rx_task = asyncio.get_running_loop().create_task(self._rx_loop()) + self._rx_task.add_done_callback(self._on_task_done) + item = await self._rx_queue.get() + if isinstance(item, BaseException): + self._fail(item) + raise ClosedError(f"SocketCAN interface {self._name} receive failed") from item + return item + + async def _rx_loop(self) -> None: loop = asyncio.get_running_loop() recv_size = _FD_FRAME_SIZE if self._fd else _CLASSIC_FRAME_SIZE - while True: + while not self._closed: try: raw = await loop.sock_recv(self._sock, recv_size) except asyncio.CancelledError: raise except OSError as ex: - self._fail(ex) - raise ClosedError(f"SocketCAN interface {self._name} receive failed") from ex + if not self._closed: + self._rx_queue.put_nowait(ex) + return frame = self._decode(raw) if frame is not None: - return frame + self._rx_queue.put_nowait(frame) def close(self) -> None: if self._closed: return self._closed = True + if self._rx_task is not None and self._rx_task is not asyncio.current_task(): + self._rx_task.cancel() + self._rx_task = None + # Wake a reader parked on the queue; the socket is closed last so the cancelled reader task + # deregisters cleanly before the fd goes away. + self._rx_queue.put_nowait(self._closed_error()) if self._tx_task is not None: self._tx_task.cancel() self._tx_task = None diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index 502e30f47..6fb66c99f 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -169,6 +169,8 @@ def _make_iface( iface._failure = failure iface._tx = TxQueue() iface._tx_task = None + iface._rx_queue = asyncio.Queue() + iface._rx_task = None return iface @@ -252,31 +254,71 @@ async def test_enqueue_purge_and_close_paths(monkeypatch: pytest.MonkeyPatch) -> closed.purge() -async def test_receive_retries_after_decode_drop_and_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_rx_loop_decodes_skips_and_queues_failure(monkeypatch: pytest.MonkeyPatch) -> None: fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) good = module._CAN_FRAME_STRUCT.pack(fake_socket.CAN_EFF_FLAG | 0x123, 2, b"ab".ljust(8, b"\x00")) - loop = _FakeLoop(recv=[b"\x00", good]) + err = OSError("rx failed") + loop = _FakeLoop(recv=[b"\x00", good, err]) # Undecodable, then good, then a socket error. monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: loop) - frame = await iface.receive() + await iface._rx_loop() # Returns when sock_recv raises, having queued the good frame then the error. + frame = iface._rx_queue.get_nowait() + assert isinstance(frame, TimestampedFrame) assert frame.id == 0x123 assert frame.data == b"ab" + assert iface._rx_queue.get_nowait() is err + # receive() surfaces a queued error as a ClosedError and marks the interface failed. failing = _make_iface(module) - failing_loop = _FakeLoop(recv=[OSError("rx failed")]) - monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: failing_loop) + failing._rx_task = _TaskStub() # Already spawned, so receive() only drains the queue. + failing._rx_queue.put_nowait(OSError("rx failed")) with pytest.raises(ClosedError, match="receive failed"): await failing.receive() assert failing._closed is True assert isinstance(failing._failure, OSError) + # A cancellation while parked in sock_recv propagates out of the RX loop. cancelled = _make_iface(module) cancelled_loop = _FakeLoop(recv=[asyncio.CancelledError()]) monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: cancelled_loop) with pytest.raises(asyncio.CancelledError): - await cancelled.receive() + await cancelled._rx_loop() + + +async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: + """close() must wake a reader parked on the RX queue with a ClosedError sentinel, so interface loss + propagates instead of leaving the transport's reader hung (review finding #10).""" + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + iface._rx_task = asyncio.create_task(asyncio.sleep(100)) # Stand-in for the RX loop task. + + recv_task = asyncio.create_task(iface.receive()) + await asyncio.sleep(0) + assert not recv_task.done() # Parked on the empty queue. + + iface.close() + with pytest.raises(ClosedError): + await asyncio.wait_for(recv_task, timeout=1.0) + assert iface._rx_task is None + + +async def test_fail_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: + """A non-transient TX failure (_fail -> close) must likewise unblock a parked reader.""" + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + iface._rx_task = asyncio.create_task(asyncio.sleep(100)) + + recv_task = asyncio.create_task(iface.receive()) + await asyncio.sleep(0) + + iface._fail(OSError("ENETDOWN")) + with pytest.raises(ClosedError): + await asyncio.wait_for(recv_task, timeout=1.0) + assert isinstance(iface._failure, OSError) def test_raise_if_closed_and_transient_error_helpers(monkeypatch: pytest.MonkeyPatch) -> None: From a5c4ef12d5222acc7b78edbb07cf2bfed996ced5 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:43:46 +0300 Subject: [PATCH 13/35] Send UDP redundant interfaces concurrently with per-socket serialization Review findings #6, #7, #9 (one restructure of the send path). - #6: the subject writer and unicast sent to interfaces serially against one shared deadline, so a congested first interface could consume the whole deadline and starve a healthy redundant one. Interfaces are now sent to concurrently via asyncio.gather; each interface's frames still go out in order. - #7: concurrent senders shared one TX socket per interface, but asyncio's selector loop permits only one writer callback per fd, so an overlapping sock_sendto could be displaced and hang. Each socket now has its own asyncio.Lock; a new send_on_iface helper holds it for the transfer. - #9: a send suspended mid-transfer while close() emptied _tx_socks used to index the emptied list and raise IndexError. The send path now snapshots the (iface, socket, lock) elements before the first await, so a racing close leaves the send index-safe; it fails cleanly on the closed socket and is aggregated into a SendError. Aggregation semantics are unchanged: success means all frames delivered on at least one interface; a partial failure warns rather than raising. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/udp.py | 80 +++++++++++++++++++++++++++++------------ tests/test_udp.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 23 deletions(-) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 765107f53..9d25b004e 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -473,17 +473,20 @@ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | self._transfer_id += 1 _logger.debug("Subject tx start sid=%d tid=%d bytes=%d", self._subject_id, transfer_id, len(message)) - errors: list[Exception] = [] - success_count = 0 - for i, iface in enumerate(self._transport.interfaces): - mtu = iface.mtu_cyphal - frames = _segment_transfer(priority, transfer_id, self._transport.uid, message, mtu) - try: - for frame in frames: - await self._transport.async_sendto(self._transport.tx_socks[i], frame, (mcast_ip, port), deadline) - success_count += 1 - except (OSError, SendError) as e: - errors.append(e) + addr = (mcast_ip, port) + # Snapshot (iface, sock, lock) elements before the first await so a concurrent close() clearing + # the socket lists cannot desync indices; a send racing close simply hits a closed socket and + # aggregates as a per-interface error rather than raising IndexError. + targets = list(zip(self._transport.interfaces, self._transport.tx_socks, self._transport.tx_locks)) + coros = [] + for iface, sock, lock in targets: + frames = _segment_transfer(priority, transfer_id, self._transport.uid, message, iface.mtu_cyphal) + coros.append(self._transport.send_on_iface(sock, lock, frames, addr, deadline)) + # Send to all interfaces concurrently so a congested interface cannot starve a healthy one of the + # shared deadline (each interface's frames still go out in order under its own socket lock). + results = await asyncio.gather(*coros) + errors = [r for r in results if r is not None] + success_count = len(results) - len(errors) if errors and success_count == 0: _logger.error("Send failed on all interfaces for subject %d", self._subject_id) @@ -630,10 +633,15 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus raise ValueError("At least one network interface is required") self._tx_socks: list[socket.socket] = [] + # One lock per TX socket. asyncio's selector loop allows only one writer callback per fd, so + # concurrent senders on the same socket (subject writers, unicast, detached ACK sends) must be + # serialized; without it a displaced sock_sendto can hang until its deadline. + self._tx_locks: list[asyncio.Lock] = [] self._self_endpoints: set[tuple[str, int]] = set() for iface in self._interfaces: sock = self._create_tx_socket(iface) self._tx_socks.append(sock) + self._tx_locks.append(asyncio.Lock()) self._self_endpoints.add(sock.getsockname()[:2]) self._subject_handlers: dict[int, Callable[[TransportArrival], None]] = {} @@ -715,6 +723,30 @@ def interfaces(self) -> list[Interface]: def tx_socks(self) -> list[socket.socket]: return self._tx_socks + @property + def tx_locks(self) -> list[asyncio.Lock]: + return self._tx_locks + + async def send_on_iface( + self, + sock: socket.socket, + lock: asyncio.Lock, + frames: list[bytes], + addr: tuple[str, int], + deadline: Instant, + ) -> Exception | None: + """Send every frame of one transfer on one interface, serialized on that socket's lock. Returns + the failure (never raised) so the caller can aggregate per-interface results, or None on success.""" + async with lock: + if self._closed: + return ClosedError("Transport closed") + try: + for frame in frames: + await self.async_sendto(sock, frame, addr, deadline) + return None + except (OSError, SendError) as e: + return e + def __repr__(self) -> str: addrs = ", ".join(str(i.address) for i in self._interfaces) return f"UDPTransport(uid=0x{self._uid:016x}, interfaces=[{addrs}], modulus={self._subject_id_modulus_val})" @@ -782,26 +814,27 @@ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, m self._next_unicast_transfer_id += 1 _logger.debug("Unicast tx start rid=%016x tid=%d bytes=%d", remote_id, transfer_id, len(message)) - errors: list[Exception] = [] - success_count = 0 - for i, iface in enumerate(self._interfaces): + # Snapshot targets (only interfaces with a known endpoint) before the first await, then send + # concurrently, as with the subject writer. + coros = [] + for i, (iface, sock, lock) in enumerate(zip(self._interfaces, self._tx_socks, self._tx_locks)): ep = self._remote_endpoints.get((remote_id, i)) if ep is None: _logger.debug("Unicast tx skip rid=%016x iface=%d reason=no-endpoint", remote_id, i) continue frames = _segment_transfer(priority, transfer_id, self._uid, message, iface.mtu_cyphal) - try: - for frame in frames: - await self.async_sendto(self._tx_socks[i], frame, ep, deadline) - success_count += 1 - except (OSError, SendError) as e: - errors.append(e) + coros.append(self.send_on_iface(sock, lock, frames, ep, deadline)) - if success_count == 0: - if errors: - raise SendError("Unicast failed on all interfaces") from errors[0] + if not coros: _logger.warning("No endpoint known for remote_id=0x%016x", remote_id) raise SendError("No endpoint known for remote_id") + + results = await asyncio.gather(*coros) + errors = [r for r in results if r is not None] + success_count = len(results) - len(errors) + + if success_count == 0: + raise SendError("Unicast failed on all interfaces") from errors[0] if errors: # Redundant transport: delivery via at least one interface is a success. Warn but do not # raise, otherwise a delivered transfer would be reported as failed and retried (mirrors @@ -831,6 +864,7 @@ def close(self) -> None: sock.close() self._mcast_socks.clear() self._tx_socks.clear() + self._tx_locks.clear() self._subject_handlers.clear() self._subject_writers.clear() self._reassemblers.clear() diff --git a/tests/test_udp.py b/tests/test_udp.py index d416b450c..e3a6e99df 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import errno import os import socket import struct @@ -1195,6 +1196,90 @@ async def all_fail(sock, data, addr, deadline): # type: ignore[no-untyped-def] pub.close() +@pytest.mark.asyncio +async def test_redundant_interfaces_send_concurrently() -> None: + """A congested interface must not starve a healthy one of the shared deadline: interfaces are sent + to concurrently, so one transfer's wall-clock is ~max(per-iface), not the sum (finding #6).""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + pub = UDPTransport.new(interfaces=[iface, iface, iface]) + assert isinstance(pub, _UDPTransportImpl) + try: + + async def slow_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] + await asyncio.sleep(0.2) + + with patch.object(pub, "async_sendto", slow_sendto): + writer = pub.subject_advertise(10) + start = asyncio.get_running_loop().time() + await writer(Instant.now() + 5.0, Priority.NOMINAL, b"x") + elapsed = asyncio.get_running_loop().time() - start + assert elapsed < 0.4, f"serial send would take >=0.6s across 3 interfaces, took {elapsed:.3f}s" + finally: + pub.close() + + +@pytest.mark.asyncio +async def test_concurrent_sends_on_shared_socket_are_serialized() -> None: + """Two transfers racing on the same per-interface socket must be serialized by its lock, so they + never overlap inside sock_sendto (the selector loop allows only one writer per fd) (finding #7).""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + pub = UDPTransport.new(interfaces=[iface]) + assert isinstance(pub, _UDPTransportImpl) + try: + in_flight: set[int] = set() + overlap_detected = False + + async def tracking_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] + nonlocal overlap_detected + fd = sock.fileno() + if fd in in_flight: + overlap_detected = True + in_flight.add(fd) + await asyncio.sleep(0.02) + in_flight.discard(fd) + + with patch.object(pub, "async_sendto", tracking_sendto): + w1 = pub.subject_advertise(10) + w2 = pub.subject_advertise(11) + await asyncio.gather( + w1(Instant.now() + 5.0, Priority.NOMINAL, b"a"), + w2(Instant.now() + 5.0, Priority.NOMINAL, b"b"), + w1(Instant.now() + 5.0, Priority.NOMINAL, b"c"), + ) + assert not overlap_detected + finally: + pub.close() + + +@pytest.mark.asyncio +async def test_close_during_send_raises_send_error_not_index_error() -> None: + """A send suspended when close() empties the socket lists must surface a clean SendError, never an + IndexError (finding #9). The snapshotted socket is closed by then, so the resumed send fails on the + closed fd and is aggregated, exactly as a real EBADF would be.""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + pub = UDPTransport.new(interfaces=[iface]) + assert isinstance(pub, _UDPTransportImpl) + started = asyncio.Event() + release = asyncio.Event() + + async def blocking_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] + started.set() + await release.wait() + raise OSError(errno.EBADF, "Bad file descriptor") # What the real closed socket raises. + + with patch.object(pub, "async_sendto", blocking_sendto): + writer = pub.subject_advertise(10) + send_task = asyncio.create_task(writer(Instant.now() + 5.0, Priority.NOMINAL, b"payload")) + await started.wait() # The send is parked inside _send_on_iface. + pub.close() # Clears _tx_socks/_tx_locks; the snapshot keeps the send index-safe. + release.set() + with pytest.raises(SendError) as excinfo: + await send_task + cause = excinfo.value.__cause__ + causes = list(cause.exceptions) if isinstance(cause, BaseExceptionGroup) else [cause] + assert not any(isinstance(c, IndexError) for c in causes) + + def test_interface_rejects_subminimum_mtu() -> None: """A link MTU below the Cyphal minimum is rejected at construction, not via a strippable assert.""" with pytest.raises(ValueError, match="mtu_link must be"): From b56395e99341f597edf1d2f94c8eea060f338212 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:47:14 +0300 Subject: [PATCH 14/35] Use conforming subject-ID moduli in tests after the modulus validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modulus validation added for finding #2 rejects moduli that are not >= 57203, prime, and ≡ 3 mod 4 at node construction. Two existing tests built nodes with test-convenient non-conforming moduli (65521 ≡ 1 mod 4; 11 below the minimum); they now use the 16-bit floor 57203. The gossip reallocation collision search is O(modulus) but breaks on the first hit, so it still completes in a fraction of a second. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- tests/test_parity.py | 4 +++- tests/test_reliable.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_parity.py b/tests/test_parity.py index f229dc4eb..5aa6e5458 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -514,7 +514,9 @@ async def test_gossip_shard_formula(): async def test_broadcast_subject_id_formula(): """Broadcast subject-ID formula: broadcast_sid = (1 << (floor(log2(PINNED_MAX + modulus)) + 1)) - 1.""" - for modulus in [DEFAULT_MODULUS, 8378431, 131071, 65521]: + # All moduli must satisfy the reference predicate (>= 57203, prime, ≡ 3 mod 4); 131071 is a Mersenne + # prime and 57203 is the 16-bit floor. They still span different log2 buckets of the formula. + for modulus in [DEFAULT_MODULUS, 8378431, 131071, 57203]: net = MockNetwork() tr = MockTransport(node_id=1, modulus=modulus, network=net) node = new_node(tr, home="n1") diff --git a/tests/test_reliable.py b/tests/test_reliable.py index 5ce3918d0..66e714f69 100644 --- a/tests/test_reliable.py +++ b/tests/test_reliable.py @@ -202,14 +202,16 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat async def test_gossip_reallocation_to_occupied_subject_preserves_writer(): net = MockNetwork() - tr = MockTransport(node_id=1, modulus=11, network=net) + # Smallest modulus satisfying the reference predicate (>= 57203, prime, ≡ 3 mod 4); the collision + # search below is O(modulus) but breaks on the first hit, so it typically runs in ~modulus iterations. + tr = MockTransport(node_id=1, modulus=57203, network=net) node = new_node(tr, home="n1") pub_a = node.advertise("/topic_a") topic_a = node.topics_by_name["topic_a"] target_sid = compute_subject_id(topic_a.hash, 1, tr.subject_id_modulus) colliding_name: str | None = None - for i in range(128): + for i in range(2_000_000): candidate = f"/topic_b_{i}" if compute_subject_id(rapidhash(candidate.removeprefix("/")), 0, tr.subject_id_modulus) == target_sid: colliding_name = candidate From 93833db61e17f9eb026a56ecd69c4305c4de6d67 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:58:38 +0300 Subject: [PATCH 15/35] Bound subject_id_modulus validation against oversized values Follow-up to finding #2 from the Codex design review: cap the modulus at uint32 before trial division. The reference modulus is a uint32, so a larger value is invalid by definition, and the bound also keeps the primality test below ~65536 iterations so an untrusted custom-transport integer cannot make it hang. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 5 ++++- tests/test_topic.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 8907db84b..5f0746587 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -230,7 +230,10 @@ def is_valid_subject_id_modulus(modulus: int) -> bool: The quadratic probe (hash + evictions²) mod m covers the residue space only under these conditions; a degenerate modulus would make the synchronous displacement loop in topic_allocate effectively non-terminating, hard-blocking the event loop.""" - if modulus < SUBJECT_ID_MODULUS_16bit or modulus % 4 != 3: + # The reference modulus is a uint32, so anything above that is invalid by definition; the bound also + # keeps the trial division below ~65536 iterations, so an untrusted custom-transport value cannot make + # the primality test hang. + if modulus < SUBJECT_ID_MODULUS_16bit or modulus > 0xFFFFFFFF or modulus % 4 != 3: return False d = 3 while d * d <= modulus: diff --git a/tests/test_topic.py b/tests/test_topic.py index afe52a61a..4f5715a8d 100644 --- a/tests/test_topic.py +++ b/tests/test_topic.py @@ -106,6 +106,8 @@ def test_is_valid_subject_id_modulus_predicate(): assert not is_valid_subject_id_modulus(57205) # ≡ 1 mod 4. assert not is_valid_subject_id_modulus(57207) # ≡ 3 mod 4 but composite (3 × 19069). assert not is_valid_subject_id_modulus(122744) # Even. + # Above uint32 is rejected without running a slow primality test on a huge untrusted value. + assert not is_valid_subject_id_modulus((1 << 32) + 3) async def test_degenerate_subject_id_modulus_rejected(): From 8993ad0f25e8749448ed4b9d6777274c622eef92 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:58:38 +0300 Subject: [PATCH 16/35] Harden the UDP concurrent send path Follow-ups to findings #6/#7/#13 from the Codex design review: - Bound each interface's lock acquisition by the transfer deadline, so a short-deadline sender queued behind a long-deadline holder of the same socket lock fails on its own budget instead of waiting the holder out. - gather(return_exceptions=True) so every interface send settles before aggregation, leaving none running in the background. - zip(strict=True) over the parallel interface/socket/lock lists to expose any length desync. - Resolve IP_MULTICAST_ALL via getattr so a CPython build that exposes it is used in preference to the hardcoded Linux uapi value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/udp.py | 40 ++++++++++++++++++++++++++-------------- tests/test_udp.py | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 9d25b004e..507b8d899 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -52,7 +52,8 @@ IPv4_SUBJECT_ID_MAX = 0x7FFFFF TRANSFER_ID_MASK = (1 << 48) - 1 _MULTICAST_TTL = 16 -_IP_MULTICAST_ALL_LINUX = 49 # Linux uapi in.h; not exposed by CPython's socket module. +# Linux uapi in.h value; older CPython does not expose it in the socket module, so fall back to the literal. +_IP_MULTICAST_ALL_LINUX = getattr(socket, "IP_MULTICAST_ALL", 49) _SIOCGIFMTU = 0x8921 _CYPHAL_OVERHEAD_MAX = 100 _CYPHAL_MTU_LINK_MIN = 576 @@ -477,15 +478,16 @@ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | # Snapshot (iface, sock, lock) elements before the first await so a concurrent close() clearing # the socket lists cannot desync indices; a send racing close simply hits a closed socket and # aggregates as a per-interface error rather than raising IndexError. - targets = list(zip(self._transport.interfaces, self._transport.tx_socks, self._transport.tx_locks)) + targets = list(zip(self._transport.interfaces, self._transport.tx_socks, self._transport.tx_locks, strict=True)) coros = [] for iface, sock, lock in targets: frames = _segment_transfer(priority, transfer_id, self._transport.uid, message, iface.mtu_cyphal) coros.append(self._transport.send_on_iface(sock, lock, frames, addr, deadline)) # Send to all interfaces concurrently so a congested interface cannot starve a healthy one of the # shared deadline (each interface's frames still go out in order under its own socket lock). - results = await asyncio.gather(*coros) - errors = [r for r in results if r is not None] + # return_exceptions=True lets every interface settle before we aggregate, so none is left running. + results = await asyncio.gather(*coros, return_exceptions=True) + errors = [r for r in results if isinstance(r, Exception)] success_count = len(results) - len(errors) if errors and success_count == 0: @@ -737,15 +739,25 @@ async def send_on_iface( ) -> Exception | None: """Send every frame of one transfer on one interface, serialized on that socket's lock. Returns the failure (never raised) so the caller can aggregate per-interface results, or None on success.""" - async with lock: + # Bound the lock wait by the same absolute deadline, so a short-deadline sender queued behind a + # long-deadline holder fails on its own budget rather than waiting out the holder's. + remaining_ns = deadline.ns - Instant.now().ns + if remaining_ns <= 0: + return SendError("Deadline exceeded") + try: + await asyncio.wait_for(lock.acquire(), timeout=remaining_ns * 1e-9) + except asyncio.TimeoutError: + return SendError("Deadline exceeded waiting for socket lock") + try: if self._closed: return ClosedError("Transport closed") - try: - for frame in frames: - await self.async_sendto(sock, frame, addr, deadline) - return None - except (OSError, SendError) as e: - return e + for frame in frames: + await self.async_sendto(sock, frame, addr, deadline) + return None + except (OSError, SendError) as e: + return e + finally: + lock.release() def __repr__(self) -> str: addrs = ", ".join(str(i.address) for i in self._interfaces) @@ -817,7 +829,7 @@ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, m # Snapshot targets (only interfaces with a known endpoint) before the first await, then send # concurrently, as with the subject writer. coros = [] - for i, (iface, sock, lock) in enumerate(zip(self._interfaces, self._tx_socks, self._tx_locks)): + for i, (iface, sock, lock) in enumerate(zip(self._interfaces, self._tx_socks, self._tx_locks, strict=True)): ep = self._remote_endpoints.get((remote_id, i)) if ep is None: _logger.debug("Unicast tx skip rid=%016x iface=%d reason=no-endpoint", remote_id, i) @@ -829,8 +841,8 @@ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, m _logger.warning("No endpoint known for remote_id=0x%016x", remote_id) raise SendError("No endpoint known for remote_id") - results = await asyncio.gather(*coros) - errors = [r for r in results if r is not None] + results = await asyncio.gather(*coros, return_exceptions=True) + errors = [r for r in results if isinstance(r, Exception)] success_count = len(results) - len(errors) if success_count == 0: diff --git a/tests/test_udp.py b/tests/test_udp.py index e3a6e99df..184651652 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1251,6 +1251,32 @@ async def tracking_sendto(sock, data, addr, deadline): # type: ignore[no-untype pub.close() +@pytest.mark.asyncio +async def test_short_deadline_send_fails_on_own_budget_behind_long_holder() -> None: + """A short-deadline sender queued behind a long-deadline holder of the same socket lock must fail on + its own deadline rather than waiting out the holder (the lock acquisition is deadline-bounded).""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + pub = UDPTransport.new(interfaces=[iface]) + assert isinstance(pub, _UDPTransportImpl) + holder_entered = asyncio.Event() + holder_release = asyncio.Event() + + async def slow_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] + holder_entered.set() + await holder_release.wait() + + with patch.object(pub, "async_sendto", slow_sendto): + holder = pub.subject_advertise(10) + waiter = pub.subject_advertise(11) # Same interface -> same socket lock. + holder_task = asyncio.create_task(holder(Instant.now() + 100.0, Priority.NOMINAL, b"hold")) + await holder_entered.wait() # The holder now owns the lock and is parked in the send. + with pytest.raises(SendError): + await waiter(Instant.now() + 0.15, Priority.NOMINAL, b"wait") + holder_release.set() + await holder_task # The holder still completes cleanly. + pub.close() + + @pytest.mark.asyncio async def test_close_during_send_raises_send_error_not_index_error() -> None: """A send suspended when close() empties the socket lists must surface a clean SendError, never an From 7d015c1e2fb8390a96f833d827afb06a7b76d230 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 03:58:38 +0300 Subject: [PATCH 17/35] Refine SocketCAN close/fail RX semantics Follow-up to finding #10 from the Codex design review: fail the interface from the RX loop's OSError path (not by enqueueing the error behind pending frames), have receive() raise the terminal sentinel directly so an explicit close is not misrecorded as an interface failure, and drain any queued frames before installing the single terminal sentinel in close(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/can/socketcan.py | 15 +++++---- tests/can/test_socketcan_unit.py | 57 ++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index 458ddac7a..06ca302bc 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -98,8 +98,9 @@ async def receive(self) -> TimestampedFrame: self._rx_task.add_done_callback(self._on_task_done) item = await self._rx_queue.get() if isinstance(item, BaseException): - self._fail(item) - raise ClosedError(f"SocketCAN interface {self._name} receive failed") from item + # Terminal sentinel: a receive-side failure already recorded itself via _fail(); an explicit + # close installs a plain ClosedError. Raise it directly so a clean close is not misrecorded. + raise item return item async def _rx_loop(self) -> None: @@ -112,9 +113,9 @@ async def _rx_loop(self) -> None: raise except OSError as ex: if not self._closed: - self._rx_queue.put_nowait(ex) + self._fail(ex) # Records the failure, closes, and installs the terminal sentinel. return - frame = self._decode(raw) + frame = self._decode(raw) # Malformed frames decode to None and are dropped. if frame is not None: self._rx_queue.put_nowait(frame) @@ -125,8 +126,10 @@ def close(self) -> None: if self._rx_task is not None and self._rx_task is not asyncio.current_task(): self._rx_task.cancel() self._rx_task = None - # Wake a reader parked on the queue; the socket is closed last so the cancelled reader task - # deregisters cleanly before the fd goes away. + # Drop any already-queued frames and install a single terminal sentinel, so a reader parked on + # the queue wakes promptly; the socket is closed last so the cancelled reader deregisters cleanly. + while not self._rx_queue.empty(): + self._rx_queue.get_nowait() self._rx_queue.put_nowait(self._closed_error()) if self._tx_task is not None: self._tx_task.cancel() diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index 6fb66c99f..f38a7bce4 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -254,37 +254,49 @@ async def test_enqueue_purge_and_close_paths(monkeypatch: pytest.MonkeyPatch) -> closed.purge() -async def test_rx_loop_decodes_skips_and_queues_failure(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_rx_loop_decodes_skips_and_drops_cleanly_on_cancel(monkeypatch: pytest.MonkeyPatch) -> None: fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) good = module._CAN_FRAME_STRUCT.pack(fake_socket.CAN_EFF_FLAG | 0x123, 2, b"ab".ljust(8, b"\x00")) - err = OSError("rx failed") - loop = _FakeLoop(recv=[b"\x00", good, err]) # Undecodable, then good, then a socket error. + loop = _FakeLoop(recv=[b"\x00", good, asyncio.CancelledError()]) # Undecodable, good, then cancelled. monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: loop) - await iface._rx_loop() # Returns when sock_recv raises, having queued the good frame then the error. - frame = iface._rx_queue.get_nowait() + with pytest.raises(asyncio.CancelledError): + await iface._rx_loop() + frame = iface._rx_queue.get_nowait() # The good frame was queued; the undecodable one was dropped. assert isinstance(frame, TimestampedFrame) assert frame.id == 0x123 assert frame.data == b"ab" - assert iface._rx_queue.get_nowait() is err - - # receive() surfaces a queued error as a ClosedError and marks the interface failed. - failing = _make_iface(module) - failing._rx_task = _TaskStub() # Already spawned, so receive() only drains the queue. - failing._rx_queue.put_nowait(OSError("rx failed")) - with pytest.raises(ClosedError, match="receive failed"): - await failing.receive() - assert failing._closed is True - assert isinstance(failing._failure, OSError) - - # A cancellation while parked in sock_recv propagates out of the RX loop. - cancelled = _make_iface(module) - cancelled_loop = _FakeLoop(recv=[asyncio.CancelledError()]) - monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: cancelled_loop) - with pytest.raises(asyncio.CancelledError): - await cancelled._rx_loop() + assert iface._rx_queue.empty() + + +async def test_rx_loop_failure_marks_interface_and_installs_sentinel(monkeypatch: pytest.MonkeyPatch) -> None: + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + err = OSError("rx failed") + loop = _FakeLoop(recv=[err]) + monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: loop) + + await iface._rx_loop() # Fails the interface via _fail(), then returns. + assert iface._closed is True + assert iface._failure is err + sentinel = iface._rx_queue.get_nowait() + assert isinstance(sentinel, ClosedError) + + +async def test_receive_raises_clean_close_without_recording_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit close is surfaced to a parked receive() as a plain ClosedError and is not recorded as + an interface failure (only a receive-side error is).""" + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + iface._rx_task = _TaskStub() # Already spawned, so receive() only drains the queue. + iface._rx_queue.put_nowait(ClosedError("SocketCAN interface vcan0 closed")) + with pytest.raises(ClosedError): + await iface.receive() + assert iface._failure is None async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: @@ -303,6 +315,7 @@ async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> N with pytest.raises(ClosedError): await asyncio.wait_for(recv_task, timeout=1.0) assert iface._rx_task is None + assert iface._failure is None # A clean close is not an interface failure. async def test_fail_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: From e414d6b62e9ddd43ac3753c0bf51f608ea5e279f Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:07:02 +0300 Subject: [PATCH 18/35] Make node and transport setup paths transactional Review finding #11 (Medium). Several setup paths mutated registries before their fallible transport acquisitions and never rolled back, leaving unrepairable half-state on failure. Following the reference's three-tier model: - Repair model (enabler): TopicImpl.ensure_listener now catches an acquisition failure, logs it, and leaves the listener unacquired for the next opportunity to retry, mirroring cy.c topic_sync_subject_reader. A retry is also triggered at the start of periodic gossip (cy.c:1819). This makes sync_listener()/sync_implicit() infallible. - Node construction rolls back the broadcast writer if the broadcast listener fails. - ensure_gossip_shard rolls back its just-created writer if the shard listener fails. - topic_ensure commits its index first, then rolls the whole topic back via destroy_topic on any failure in the fallible tail (without masking the original exception); the gossip-driven twin does the same but never raises (untrusted wire input), dropping the gossip instead. - advertise acquires the writer before mutating publish state, so a failure leaves an ordinary implicit topic rather than a phantom publisher. - subscribe constructs the subscriber (fallible reordering-window validation) and, for a verbatim name, its topic before committing any root/subscriber registry state. - UDP subject_listen rolls back the handler and every per-interface socket/task on a mid-loop failure; _create_mcast_socket closes its socket if bind/join fails. MockTransport gains fail_subject_listen/fail_subject_advertise injection sets for the regression tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 103 +++++++++++++++------ src/pycyphal2/udp.py | 64 +++++++------ tests/mock_transport.py | 9 ++ tests/test_transactional_setup.py | 148 ++++++++++++++++++++++++++++++ tests/test_udp.py | 31 +++++++ 5 files changed, 298 insertions(+), 57 deletions(-) create mode 100644 tests/test_transactional_setup.py diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 5f0746587..43a551d6a 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -472,7 +472,14 @@ def ensure_writer(self) -> SubjectWriter: def ensure_listener(self) -> None: if self.sub_listener is None and self.couplings: sid = self.subject_id(self._node.transport.subject_id_modulus) - self.sub_listener = self._node.acquire_subject_listener(self, sid) + # Repair model (cy.c topic_sync_subject_reader): a listener acquisition failure is logged and + # left for the next opportunity (topic sync, periodic gossip) to retry, rather than raising and + # tearing down a partly-built subscription. Keeps sync_listener()/sync_implicit() infallible. + try: + self.sub_listener = self._node.acquire_subject_listener(self, sid) + except Exception as ex: + _logger.error("Listener acquisition failed for '%s' sid=%d; will retry: %s", self._name, sid, ex) + return _logger.info("Listener acquired for '%s' sid=%d", self._name, sid) def sync_listener(self) -> None: @@ -554,7 +561,11 @@ def __init__(self, transport: Transport, *, home: str, namespace: str) -> None: def broadcast_handler(arrival: TransportArrival) -> None: self.on_subject_arrival(self.broadcast_subject_id, arrival) - self.broadcast_listener = transport.subject_listen(self.broadcast_subject_id, broadcast_handler) + try: + self.broadcast_listener = transport.subject_listen(self.broadcast_subject_id, broadcast_handler) + except BaseException: + self.broadcast_writer.close() # Roll back the writer so a retry does not see a duplicate. + raise self.gossip_shard_writers: dict[int, SubjectWriter] = {} self.gossip_shard_listeners: dict[int, Closable] = {} @@ -627,9 +638,11 @@ def advertise(self, name: str) -> Publisher: if not verbatim: raise ValueError("Cannot advertise on a pattern name") topic = self.topic_ensure(resolved, pin) + # Acquire the fallible writer before mutating publish state, so a failure leaves the topic as an + # ordinary implicit topic (GC'd) rather than a phantom publisher with no writer. + topic.ensure_writer() topic.pub_count += 1 topic.sync_implicit() - topic.ensure_writer() _logger.info( "Advertise '%s' -> '%s' sid=%d", name, @@ -646,24 +659,26 @@ def subscribe(self, name: str, *, reordering_window: float | None = None) -> Sub if pin is not None and not verbatim: raise ValueError("Pattern names cannot be pinned") - if verbatim: - root = self.sub_roots_verbatim.get(resolved) - if root is None: - root = SubscriberRoot(name=resolved, is_pattern=False) - self.sub_roots_verbatim[resolved] = root - else: - root = self.sub_roots_pattern.get(resolved) - if root is None: - root = SubscriberRoot(name=resolved, is_pattern=True, needs_scouting=True) - self.sub_roots_pattern[resolved] = root - + # Acquire the two fallible resources first — the subscriber (reordering-window validation) and, for a + # verbatim name, its topic (transactional) — before committing any registry state, so a failure + # leaves no half-registered root or subscriber behind. + registry = self.sub_roots_verbatim if verbatim else self.sub_roots_pattern + root = registry.get(resolved) + new_root = root is None + if root is None: + root = SubscriberRoot(name=resolved, is_pattern=not verbatim, needs_scouting=not verbatim) subscriber = SubscriberImpl(self, root, resolved, verbatim, reordering_window) + verbatim_topic = self.topic_ensure(resolved, pin) if verbatim else None + + # Commit — everything below is infallible. + if new_root: + registry[resolved] = root root.subscribers.append(subscriber) if verbatim: - topic = self.topic_ensure(resolved, pin) - self.couple_topic_root(topic, root) - topic.sync_implicit() + assert verbatim_topic is not None + self.couple_topic_root(verbatim_topic, root) + verbatim_topic.sync_implicit() else: for topic in list(self.topics_by_name.values()): self.couple_topic_root(topic, root) @@ -713,12 +728,18 @@ def topic_ensure(self, name: str, pin: int | None) -> TopicImpl: topic = TopicImpl(self, name, evictions, now) self.topics_by_name[name] = topic self.topics_by_hash[topic.hash] = topic - self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) - self.touch_implicit_topic(topic) - self.topic_allocate(topic, evictions, now) - for root in self.sub_roots_pattern.values(): - self.couple_topic_root(topic, root) - topic.sync_listener() + # Commit the index first so the rollback primitive (destroy_topic) can find and undo the topic; + # the fallible tail (gossip-shard acquisition) rolls the whole topic back on failure. + try: + self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) + self.touch_implicit_topic(topic) + self.topic_allocate(topic, evictions, now) + for root in self.sub_roots_pattern.values(): + self.couple_topic_root(topic, root) + topic.sync_listener() + except BaseException: + self._rollback_topic(name) + raise self.notify_implicit_gc() _logger.info( "Topic created '%s' hash=%016x sid=%d", @@ -728,6 +749,13 @@ def topic_ensure(self, name: str, pin: int | None) -> TopicImpl: ) return topic + def _rollback_topic(self, name: str) -> None: + """Undo a partially-built topic without masking the exception that triggered the rollback.""" + try: + self.destroy_topic(name) + except Exception: + _logger.exception("Rollback of partially-built topic '%s' failed", name) + def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None: """Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c.""" modulus = self.transport.subject_id_modulus @@ -867,7 +895,12 @@ def ensure_gossip_shard(self, shard_sid: int) -> SubjectWriter: def handler(arrival: TransportArrival) -> None: self.on_subject_arrival(shard_sid, arrival) - self.gossip_shard_listeners[shard_sid] = self.transport.subject_listen(shard_sid, handler) + try: + self.gossip_shard_listeners[shard_sid] = self.transport.subject_listen(shard_sid, handler) + except BaseException: + self.gossip_shard_writers.pop(shard_sid, None) # Roll back the just-created writer. + writer.close() + raise _logger.debug("Gossip shard writer/listener for sid=%d", shard_sid) return writer @@ -977,6 +1010,9 @@ async def _gossip_event_urgent(self, topic: TopicImpl) -> None: await self.send_gossip(topic, broadcast=True) async def _gossip_event_periodic(self, topic: TopicImpl) -> None: + # Retry a previously-failed listener acquisition on the gossip cadence (cy.c:1819), so a verbatim + # subscription whose listener failed once eventually recovers. + topic.sync_listener() self._reschedule_gossip_periodic(topic, suppressed=False) broadcast = (topic.gossip_counter < GOSSIP_BROADCAST_RATIO) or ( (topic.gossip_counter % GOSSIP_BROADCAST_RATIO) == 0 @@ -1402,12 +1438,19 @@ def topic_subscribe_if_matching( topic.ts_origin = now - lage_to_seconds(lage) self.topics_by_name[name] = topic self.topics_by_hash[topic_hash] = topic - self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) - self.touch_implicit_topic(topic) - self.topic_allocate(topic, evictions, now) - for root in matches: - self.couple_topic_root(topic, root) - topic.sync_listener() + # This is a wire-driven path: it must never raise (untrusted input). A transport failure mid-setup + # rolls the topic back and drops the gossip; a later gossip retries. + try: + self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) + self.touch_implicit_topic(topic) + self.topic_allocate(topic, evictions, now) + for root in matches: + self.couple_topic_root(topic, root) + topic.sync_listener() + except Exception as ex: + self._rollback_topic(name) + _logger.warning("Implicit topic '%s' setup failed, dropped: %s", name, ex) + return None self.notify_implicit_gc() _logger.info("Implicit topic '%s' created from gossip", name) return topic diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 507b8d899..54d3ad888 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -684,27 +684,31 @@ def _create_tx_socket(iface: Interface) -> socket.socket: def _create_mcast_socket(subject_id: int, iface: Interface) -> socket.socket: mcast_ip, port = _make_subject_endpoint(subject_id) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setblocking(False) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(socket, "SO_REUSEPORT"): - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - # Bind to multicast group address on Linux; INADDR_ANY on Windows - if sys.platform == "win32": - sock.bind(("", port)) - else: - sock.bind((mcast_ip, port)) - mreq = socket.inet_aton(mcast_ip) + socket.inet_aton(str(iface.address)) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - # REFERENCE PARITY: the reference filters every received datagram by its ingress interface index - # via recvmsg+IP_PKTINFO (udp_wrapper.c). asyncio offers no sock_recvmsg, so on Linux the same - # delivery set is obtained at the kernel level with IP_MULTICAST_ALL=0: with it, this socket only - # receives datagrams matching its own (group, interface) membership above, instead of the default - # any-interface delivery that would mislearn reverse routes on multi-homed hosts. macOS/BSD scope - # multicast delivery per membership natively. On Windows the socket binds INADDR_ANY and Winsock - # may deliver cross-interface traffic; multi-homed Windows hosts should configure at most one - # transport interface per multicast-reachable network. - if sys.platform == "linux": - sock.setsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX, 0) + try: + sock.setblocking(False) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + # Bind to multicast group address on Linux; INADDR_ANY on Windows + if sys.platform == "win32": + sock.bind(("", port)) + else: + sock.bind((mcast_ip, port)) + mreq = socket.inet_aton(mcast_ip) + socket.inet_aton(str(iface.address)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + # REFERENCE PARITY: the reference filters every received datagram by its ingress interface index + # via recvmsg+IP_PKTINFO (udp_wrapper.c). asyncio offers no sock_recvmsg, so on Linux the same + # delivery set is obtained at the kernel level with IP_MULTICAST_ALL=0: with it, this socket only + # receives datagrams matching its own (group, interface) membership above, instead of the default + # any-interface delivery that would mislearn reverse routes on multi-homed hosts. macOS/BSD scope + # multicast delivery per membership natively. On Windows the socket binds INADDR_ANY and Winsock + # may deliver cross-interface traffic; multi-homed Windows hosts should configure at most one + # transport interface per multicast-reachable network. + if sys.platform == "linux": + sock.setsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX, 0) + except BaseException: + sock.close() # Do not leak the fd if bind/join fails. + raise _logger.info("Multicast socket for subject %d on %s (%s:%d)", subject_id, iface.address, mcast_ip, port) return sock @@ -799,12 +803,18 @@ def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], raise ValueError(f"Subject {subject_id} already has an active listener") _logger.info("Subscribing to subject %d", subject_id) self._subject_handlers[subject_id] = handler - for i, iface in enumerate(self._interfaces): - key = (subject_id, i) - sock = self._create_mcast_socket(subject_id, iface) - self._mcast_socks[key] = sock - task = self._loop.create_task(self._mcast_rx_loop(sock, subject_id, i)) - self._mcast_rx_tasks[key] = task + try: + for i, iface in enumerate(self._interfaces): + key = (subject_id, i) + sock = self._create_mcast_socket(subject_id, iface) + self._mcast_socks[key] = sock + task = self._loop.create_task(self._mcast_rx_loop(sock, subject_id, i)) + self._mcast_rx_tasks[key] = task + except BaseException: + # Roll back the handler and every per-interface socket/task created so far, so a later + # subject_listen for this subject is not blocked by the duplicate-handler check above. + self.remove_subject_listener(subject_id, handler) + raise return _UDPSubjectListener(self, subject_id, handler) def subject_advertise(self, subject_id: int) -> SubjectWriter: diff --git a/tests/mock_transport.py b/tests/mock_transport.py index ce84b838e..b976a1155 100644 --- a/tests/mock_transport.py +++ b/tests/mock_transport.py @@ -72,6 +72,11 @@ def __init__(self, node_id: int = 0, modulus: int = DEFAULT_MODULUS, network: Mo self.unicast_log: list[tuple[int, bytes]] = [] self.closed = False self.fail_unicast = False + # Setup-path failure injection: a subject-ID present in either set makes the corresponding + # acquisition raise, to exercise transactional rollback. Sets are not auto-cleared, so a test + # controls exactly which retries fail. + self.fail_subject_listen: set[int] = set() + self.fail_subject_advertise: set[int] = set() if network is not None: network.add_transport(self) @@ -86,6 +91,8 @@ def subject_id_modulus(self) -> int: def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> Closable: if subject_id in self.subject_handlers: raise ValueError(f"Subject {subject_id} already has an active listener") + if subject_id in self.fail_subject_listen: + raise RuntimeError(f"Simulated subject_listen failure for {subject_id}") self.subject_handlers[subject_id] = handler self.subject_listener_creations[subject_id] = self.subject_listener_creations.get(subject_id, 0) + 1 return MockSubjectListener(self, subject_id, handler) @@ -93,6 +100,8 @@ def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], def subject_advertise(self, subject_id: int) -> MockSubjectWriter: if subject_id in self.writers: raise ValueError(f"Subject {subject_id} already has an active writer") + if subject_id in self.fail_subject_advertise: + raise RuntimeError(f"Simulated subject_advertise failure for {subject_id}") writer = MockSubjectWriter(self, subject_id) self.writers[subject_id] = writer self.subject_writer_creations[subject_id] = self.subject_writer_creations.get(subject_id, 0) + 1 diff --git a/tests/test_transactional_setup.py b/tests/test_transactional_setup.py new file mode 100644 index 000000000..49427de8c --- /dev/null +++ b/tests/test_transactional_setup.py @@ -0,0 +1,148 @@ +"""Regression tests for finding #11: setup paths must be transactional — a transport failure mid-setup +must not leave unrepairable half-state, and subscribe-path listener failures follow the reference repair +model (logged, retried) rather than raising.""" + +from __future__ import annotations + +import math + +import pytest + +import pycyphal2 +from pycyphal2 import SUBJECT_ID_PINNED_MAX +from pycyphal2._hash import rapidhash +from pycyphal2._header import GossipHeader +from pycyphal2._transport import TransportArrival +from tests.mock_transport import DEFAULT_MODULUS, MockNetwork, MockTransport +from tests.typing_helpers import new_node + + +def _broadcast_sid(modulus: int = DEFAULT_MODULUS) -> int: + sid_max = SUBJECT_ID_PINNED_MAX + modulus + return (1 << (int(math.log2(sid_max)) + 1)) - 1 + + +async def test_node_init_rolls_back_broadcast_writer_on_listen_failure() -> None: + tr = MockTransport(node_id=1, network=MockNetwork()) + tr.fail_subject_listen.add(_broadcast_sid()) + with pytest.raises(RuntimeError, match="Simulated subject_listen"): + new_node(tr, home="n") + assert tr.writers == {} # The broadcast writer was rolled back, so a retry sees no duplicate. + assert tr.subject_handlers == {} + + +async def test_advertise_rolls_back_pub_count_on_writer_failure() -> None: + # Learn the topic's subject-ID from a healthy node (it depends only on name + modulus). + healthy_tr = MockTransport(node_id=1, network=MockNetwork()) + healthy = new_node(healthy_tr, home="n") + pub = healthy.advertise("/topic_a") + topic_sid = healthy.topics_by_name["topic_a"].subject_id(healthy_tr.subject_id_modulus) + pub.close() + healthy.close() + + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n") + tr.fail_subject_advertise.add(topic_sid) + with pytest.raises(RuntimeError, match="Simulated subject_advertise"): + node.advertise("/topic_a") + + topic = node.topics_by_name["topic_a"] # The topic exists but as an ordinary implicit topic. + assert topic.pub_count == 0 + assert topic.is_implicit + assert topic.pub_writer is None + + tr.fail_subject_advertise.clear() # Retry succeeds. + pub2 = node.advertise("/topic_a") + assert node.topics_by_name["topic_a"].pub_count == 1 + pub2.close() + node.close() + + +async def test_subscribe_listener_failure_is_repaired_not_raised() -> None: + healthy_tr = MockTransport(node_id=1, network=MockNetwork()) + healthy = new_node(healthy_tr, home="n") + sub_h = healthy.subscribe("/topic_v") + topic_sid = healthy.topics_by_name["topic_v"].subject_id(healthy_tr.subject_id_modulus) + sub_h.close() + healthy.close() + + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n") + tr.fail_subject_listen.add(topic_sid) + sub = node.subscribe("/topic_v") # Repair model: subscribe does not raise on listener failure. + topic = node.topics_by_name["topic_v"] + assert topic.sub_listener is None # Listener not yet acquired. + creations_before = tr.subject_listener_creations.get(topic_sid, 0) + + tr.fail_subject_listen.clear() # The next sync opportunity repairs it. + topic.sync_listener() + assert topic.sub_listener is not None + assert tr.subject_listener_creations.get(topic_sid, 0) == creations_before + 1 + + sub.close() + node.close() + + +async def test_topic_ensure_rolls_back_on_gossip_shard_failure() -> None: + healthy_tr = MockTransport(node_id=1, network=MockNetwork()) + healthy = new_node(healthy_tr, home="n") + shard_sid = healthy.gossip_shard_subject_id(rapidhash("topic_s")) + healthy.close() + + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n") + tr.fail_subject_advertise.add(shard_sid) + with pytest.raises(RuntimeError, match="Simulated subject_advertise"): + node.advertise("/topic_s") + + assert "topic_s" not in node.topics_by_name # Fully rolled back, both registries clean. + assert rapidhash("topic_s") not in node.topics_by_hash + + tr.fail_subject_advertise.clear() # Retry succeeds. + pub = node.advertise("/topic_s") + assert "topic_s" in node.topics_by_name + pub.close() + node.close() + + +async def test_gossip_driven_topic_creation_failure_never_raises() -> None: + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n") + sub = node.subscribe("/sensor/>") + + name = "sensor/temp" + shard_sid = node.gossip_shard_subject_id(rapidhash(name)) + hdr = GossipHeader(topic_log_age=5, topic_hash=rapidhash(name), topic_evictions=0, name_len=len(name)) + + def deliver() -> None: + node.on_subject_arrival( + node.broadcast_subject_id, + TransportArrival( + timestamp=pycyphal2.Instant.now(), + priority=pycyphal2.Priority.NOMINAL, + remote_id=99, + message=hdr.serialize() + name.encode("utf-8"), + ), + ) + + tr.fail_subject_advertise.add(shard_sid) + deliver() # Wire-driven path must not raise even when transport setup fails. + assert name not in node.topics_by_name + + tr.fail_subject_advertise.clear() # A later gossip creates the topic. + deliver() + assert name in node.topics_by_name + + sub.close() + node.close() + + +async def test_subscribe_bad_reordering_window_leaves_no_state() -> None: + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n") + with pytest.raises(ValueError, match="Reordering window"): + node.subscribe("/topic_r", reordering_window=-1.0) + assert "topic_r" not in node.sub_roots_verbatim + assert "topic_r" not in node.topics_by_name + node.close() diff --git a/tests/test_udp.py b/tests/test_udp.py index 184651652..75a30c533 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1306,6 +1306,37 @@ async def blocking_sendto(sock, data, addr, deadline): # type: ignore[no-untype assert not any(isinstance(c, IndexError) for c in causes) +@pytest.mark.asyncio +async def test_subject_listen_rolls_back_partial_interface_setup() -> None: + """A per-interface socket failure mid-subject_listen must roll back the handler and every socket/task + created so far, so a retry is not blocked by the duplicate-listener check (finding #11).""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + t = UDPTransport.new(interfaces=[iface, iface]) + assert isinstance(t, _UDPTransportImpl) + try: + real_create = t._create_mcast_socket + calls = {"n": 0} + + def flaky_create(subject_id, iface): # type: ignore[no-untyped-def] + calls["n"] += 1 + if calls["n"] == 2: # Fail on the second interface, after the first has been set up. + raise OSError("second interface bind failed") + return real_create(subject_id, iface) + + with patch.object(t, "_create_mcast_socket", flaky_create): + with pytest.raises(OSError): + t.subject_listen(42, lambda _a: None) + + assert 42 not in t._subject_handlers + assert not any(sid == 42 for (sid, _i) in t._mcast_socks) + assert not any(sid == 42 for (sid, _i) in t._mcast_rx_tasks) + + listener = t.subject_listen(42, lambda _a: None) # Retry succeeds. + listener.close() + finally: + t.close() + + def test_interface_rejects_subminimum_mtu() -> None: """A link MTU below the Cyphal minimum is rejected at construction, not via a strippable assert.""" with pytest.raises(ValueError, match="mtu_link must be"): From 4bcbf69efba48c2c58c19fcc3310ff86f98246e5 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:15:32 +0300 Subject: [PATCH 19/35] Keep response streams alive across node close and implicit GC Review findings #21 and #22 (Low). #21: Node.close() never cancelled library-owned request-publish tasks nor woke a pending response-stream iteration, so closing a node with an outstanding stream (especially one with a far-off or infinite response timeout) left the retry task and payload graph alive until the timeout. close() now disposes every outstanding response stream: it cancels the publish task, cancels any zombie-cleanup timer, and stops the iteration. #22: implicit-topic GC ignored request_futures, so it could destroy a topic that still had an open response stream, silently orphaning it after the publisher closed. compute_is_implicit now also treats an open response stream or an in-flight reliable publish as keeping the topic explicit; ResponseStreamImpl.close() and the reliable-publish-tracker release re-sync implicitness so a topic still becomes implicit once that state clears (no gossip-forever inverse leak); and destroy_topic disposes streams before delisting the topic to avoid a GC re-touch, then clears request_futures. Also: request() validates response_timeout (rejects NaN and negatives, allows +inf), a non-finite timeout disables liveness as with subscribers, and a failure after the stream is registered closes it rather than merely popping the tag, so a concurrent publisher close cannot strand the topic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 23 +++++- src/pycyphal2/_publisher.py | 46 +++++++++-- tests/test_close_streams.py | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 tests/test_close_streams.py diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 43a551d6a..c9653dee4 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -501,7 +501,13 @@ def release_transport_handles(self) -> None: def compute_is_implicit(self) -> bool: has_verbatim_sub = any(not c.root.is_pattern for c in self.couplings) - return self.pub_count == 0 and not has_verbatim_sub + # An open response stream or an in-flight reliable publish keeps the topic explicit, so implicit + # GC cannot destroy a topic that still has outstanding request/publish state (closed "zombie" + # streams awaiting their dedup-cleanup timer do not count). Python must gate on these because, + # unlike the C API, it allows a stream/publish to outlive the publisher that issued it. + has_open_stream = any(not s.closed for s in self.request_futures.values()) + has_pending_publish = bool(self.publish_futures) + return self.pub_count == 0 and not has_verbatim_sub and not has_open_stream and not has_pending_publish def sync_implicit(self) -> None: """Sync implicitness and transport state with the reference state machine.""" @@ -1512,6 +1518,11 @@ def destroy_topic(self, name: str) -> None: topic = self.topics_by_name.get(name) if topic is None: return + # Dispose any outstanding response streams first (before discard_implicit_topic): a stream's + # forced teardown cancels its zombie-cleanup timer and stops its iteration, and doing it here + # avoids a re-touch of the implicit list that could otherwise resurrect the topic mid-destroy. + for stream in list(topic.request_futures.values()): + stream.dispose() if topic.gossip_task is not None: self._cancel_gossip(topic) self.discard_implicit_topic(topic) @@ -1526,6 +1537,7 @@ def destroy_topic(self, name: str) -> None: topic.associations.clear() topic.dedup.clear() topic.publish_futures.clear() + topic.request_futures.clear() self.notify_implicit_gc() _logger.info("Topic destroyed '%s'", name) @@ -1535,11 +1547,16 @@ def close(self) -> None: self._closed = True _logger.info("Node closing home='%s'", self._home) # Unblock anything awaiting on a subscriber (`async for`): closing each enqueues StopAsyncIteration, - # otherwise a default (no-liveness-timeout) subscriber would wait on its queue forever. (Reliable - # publishes / response streams are deadline-bounded and resolve on their own.) + # otherwise a default (no-liveness-timeout) subscriber would wait on its queue forever. for root in list(self.sub_roots_verbatim.values()) + list(self.sub_roots_pattern.values()): for sub in list(root.subscribers): sub.close() + # Dispose outstanding response streams: cancel each library-owned request-publish task and stop + # pending iteration, otherwise a stream with a far-off (or infinite) response timeout would keep + # its retry task and payload graph alive against the closed transport until that timeout. + for topic in list(self.topics_by_name.values()): + for stream in list(topic.request_futures.values()): + stream.dispose() self._gc_task.cancel() for root in list(self.sub_roots_pattern.values()): if root.scout_task is not None: diff --git a/src/pycyphal2/_publisher.py b/src/pycyphal2/_publisher.py index 231843820..bcc135082 100644 --- a/src/pycyphal2/_publisher.py +++ b/src/pycyphal2/_publisher.py @@ -112,6 +112,10 @@ async def request( if self.closed: raise SendError("Publisher closed") + response_timeout = float(response_timeout) + if math.isnan(response_timeout) or response_timeout < 0.0: + raise ValueError("response_timeout must be non-negative (or +inf to disable liveness)") + tag = self._topic.next_tag() payload = bytes(message) @@ -124,17 +128,23 @@ async def request( ) self._topic.request_futures[tag] = stream - tracker = self._prepare_reliable_publish_tracker(tag) + # Any failure after the stream is registered must close it (drop it from request_futures and + # re-sync implicitness), not just pop the tag -- otherwise a publisher closing concurrently could + # leave the topic permanently explicit. The tracker prep is inside the guard for the same reason. + tracker: PublishTracker | None = None try: + tracker = self._prepare_reliable_publish_tracker(tag) initial_window = await self._reliable_publish_start(delivery_deadline, tag, payload, tracker) except asyncio.CancelledError: - tracker.compromised = True - self._topic.request_futures.pop(tag, None) - self._release_reliable_publish_tracker(tag, tracker) + if tracker is not None: + tracker.compromised = True + self._release_reliable_publish_tracker(tag, tracker) + stream.close() raise except BaseException: - self._topic.request_futures.pop(tag, None) - self._release_reliable_publish_tracker(tag, tracker) + if tracker is not None: + self._release_reliable_publish_tracker(tag, tracker) + stream.close() raise task = self._node.loop.create_task( @@ -192,6 +202,9 @@ def _prepare_reliable_publish_tracker(self, tag: int) -> PublishTracker: def _release_reliable_publish_tracker(self, tag: int, tracker: PublishTracker) -> None: self._topic.publish_futures.pop(tag, None) self._node.publish_tracker_release(self._topic, tracker) + # Re-evaluate implicitness: once the last pending reliable publish is released, a topic held + # explicit only by it can become implicit and be GC'd (otherwise it would gossip forever). + self._topic.sync_implicit() async def _send_reliable_publish( self, @@ -321,8 +334,10 @@ def __aiter__(self) -> ResponseStreamImpl: async def __anext__(self) -> Response: if self.closed: raise StopAsyncIteration + # A non-finite response timeout disables liveness (waits forever), mirroring Subscriber.timeout. + timeout = self._response_timeout if self._response_timeout != float("inf") else None try: - item = await asyncio.wait_for(self.queue.get(), timeout=self._response_timeout) + item = await asyncio.wait_for(self.queue.get(), timeout=timeout) except asyncio.TimeoutError: raise LivenessError("Response timeout") if isinstance(item, StopAsyncIteration): @@ -404,4 +419,21 @@ def close(self) -> None: else: self._remove_from_topic() self.queue.put_nowait(StopAsyncIteration()) + # Re-evaluate topic implicitness: this stream no longer keeps the topic explicit, so once the + # publisher is also gone the topic can become implicit and be GC'd (otherwise it would gossip + # forever). Safe during node teardown -- notify_implicit_gc() is a no-op when the node is closed. + self._topic.sync_implicit() _logger.debug("Response stream closed for tag=%d", self._message_tag) + + def dispose(self) -> None: + """Force-remove the stream during node/topic teardown: cancel its publish task and any pending + zombie-cleanup timer, drop it from the topic, and stop pending iteration. Unlike close(), this + never retains a zombie and does not re-sync implicitness (the topic is being torn down).""" + was_open = not self.closed + self.closed = True + if self._publish_task is not None: + self._publish_task.cancel() + self._publish_task = None + self._remove_from_topic() # Cancels the cleanup timer and drops from request_futures. + if was_open: + self.queue.put_nowait(StopAsyncIteration()) diff --git a/tests/test_close_streams.py b/tests/test_close_streams.py new file mode 100644 index 000000000..0959b896b --- /dev/null +++ b/tests/test_close_streams.py @@ -0,0 +1,156 @@ +"""Regression tests for findings #21 and #22: node close and implicit-topic GC must not orphan or hang an +outstanding response stream, and an open stream / in-flight reliable publish keeps its topic alive.""" + +from __future__ import annotations + +import asyncio + +import pytest + +import pycyphal2 +from pycyphal2._node import Association +from tests.mock_transport import MockNetwork, MockTransport +from tests.typing_helpers import new_node, request_stream + + +async def test_node_close_unblocks_parked_response_stream() -> None: + """Node.close() must cancel a library-owned request-publish task and stop a parked iteration, even + when the stream has an infinite response timeout (finding #21).""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + pub.ack_timeout = 0.05 + topic = node.topics_by_name["rpc"] + topic.associations[42] = Association(remote_id=42, last_seen=0.0) + + stream = await request_stream(pub, pycyphal2.Instant.now() + 1.0, float("inf"), b"request") + publish_task = stream._publish_task + anext_task = asyncio.create_task(stream.__anext__()) + await asyncio.sleep(0) + assert not anext_task.done() # Parked with no liveness timeout. + + node.close() + with pytest.raises(StopAsyncIteration): + await asyncio.wait_for(anext_task, timeout=1.0) + assert publish_task is not None + await asyncio.sleep(0) + assert publish_task.cancelled() or publish_task.done() + + +async def test_open_stream_keeps_topic_explicit_until_closed() -> None: + """An open response stream keeps its topic explicit after the publisher closes, so implicit GC cannot + destroy it; closing the stream lets the topic become implicit (finding #22).""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + pub.ack_timeout = 0.05 + topic = node.topics_by_name["rpc"] + topic.associations[42] = Association(remote_id=42, last_seen=0.0) + + stream = await request_stream(pub, pycyphal2.Instant.now() + 1.0, 1.0, b"request") + pub.close() + assert topic.is_implicit is False # The open stream keeps it explicit. + + stream.close() # Cancels the publish task; its release re-syncs implicitness on the next loop turn. + for _ in range(50): + if topic.is_implicit: + break + await asyncio.sleep(0.001) + assert topic.is_implicit is True # Now it may be GC'd. + + node.close() + + +async def test_implicit_gc_does_not_destroy_topic_with_open_stream(monkeypatch: pytest.MonkeyPatch) -> None: + """The implicit GC must not reap a topic while a response stream is open, only after it closes.""" + monkeypatch.setattr(pycyphal2._node, "IMPLICIT_TOPIC_TIMEOUT", 0.05) + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + pub.ack_timeout = 0.05 + topic = node.topics_by_name["rpc"] + topic.associations[42] = Association(remote_id=42, last_seen=0.0) + + stream = await request_stream(pub, pycyphal2.Instant.now() + 1.0, 1.0, b"request") + pub.close() + node.notify_implicit_gc() + await asyncio.sleep(0.15) + assert "rpc" in node.topics_by_name # Still alive: the open stream blocks GC. + + stream.close() + node.notify_implicit_gc() + for _ in range(50): + if "rpc" not in node.topics_by_name: + break + await asyncio.sleep(0.01) + assert "rpc" not in node.topics_by_name # Reaped once the stream closed. + + node.close() + + +async def test_zombie_stream_does_not_block_gc() -> None: + """A closed 'zombie' stream awaiting its dedup-cleanup timer must not keep the topic explicit, and + destroy_topic clears request_futures.""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + pub.ack_timeout = 0.05 + topic = node.topics_by_name["rpc"] + topic.associations[42] = Association(remote_id=42, last_seen=0.0) + + stream = await request_stream(pub, pycyphal2.Instant.now() + 1.0, 1.0, b"request") + # Populate a reliable remote so close() retains a zombie (scheduled cleanup rather than immediate). + from pycyphal2._publisher import ResponseRemoteState + + stream._reliable_remote_by_id[7] = ResponseRemoteState(seqno_top=0) + stream.close() # Cancels the publish task; its release runs on the next loop turn. + assert topic.request_futures # The zombie is retained pending its cleanup timer... + pub.close() + for _ in range(50): + if topic.is_implicit: + break + await asyncio.sleep(0.001) + assert topic.is_implicit is True # ...but a closed stream does not keep the topic explicit. + + node.destroy_topic("rpc") + assert topic.request_futures == {} # destroy_topic clears it. + node.close() + + +async def test_request_rejects_nan_response_timeout() -> None: + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + with pytest.raises(ValueError, match="response_timeout"): + await pub.request(pycyphal2.Instant.now() + 1.0, float("nan"), b"request") + with pytest.raises(ValueError, match="response_timeout"): + await pub.request(pycyphal2.Instant.now() + 1.0, -1.0, b"request") + pub.close() + node.close() + + +async def test_pending_reliable_publish_keeps_topic_explicit() -> None: + """An in-flight reliable publish (tracked in publish_futures) keeps the topic explicit even with no + publisher, so implicit GC cannot destroy it mid-delivery (Codex D6 addition to finding #22).""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") + topic = node.topics_by_name["rpc"] + + from pycyphal2._node import PublishTracker + + topic.publish_futures[99] = PublishTracker(tag=99, ack_event=asyncio.Event()) + pub.close() + assert topic.is_implicit is False # The pending publish keeps it explicit. + + topic.publish_futures.clear() + topic.sync_implicit() + assert topic.is_implicit is True + + node.close() From 6c205701f5a379544dd1b8216bc36861229be8f5 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:32:00 +0300 Subject: [PATCH 20/35] Bound per-remote state under untrusted traffic Review finding #3 (Medium). Several internal structures grew without bound under wire traffic (whose source ID is spoofable on Cyphal/UDP), where the C core is bounded by design: - topic.dedup was pruned only for the arriving remote. A new aggregate, time-driven sweep (TopicImpl.drop_stale_dedup) retires entries for departed remotes, mirroring the reference dedup_drop_stale. - Reordering state was swept only when a later message arrived. The sweep (now public drop_stale_reordering) also runs from a periodic tick, so idle streams are retired after traffic stops (reference reordering_drop_stale). - Both sweeps run from an extended implicit_gc_loop on an ABSOLUTE housekeeping schedule, so steady implicit-topic traffic cannot postpone them indefinitely. - UDP reassembly sessions are now retired from a periodic _housekeeping_loop independent of new traffic (reference udpard_rx_poll), and bounded by an LRU capacity cap so a burst of unique spoofed UIDs cannot grow them without bound. - _remote_endpoints is now an LRU cache with a capacity cap (bounding memory under spoofed-UID flooding) and is cleared on close(); it is not time-expired, so a retained breadcrumb's reverse route survives until evicted under sustained flooding rather than expiring during a brief peer silence. - close() also resets the unicast reassembler and clears the unicast handler, which the finding flagged as surviving close(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 45 ++++++++++++++++---- src/pycyphal2/_subscriber.py | 4 +- src/pycyphal2/udp.py | 57 +++++++++++++++++++++++-- tests/test_housekeeping.py | 74 +++++++++++++++++++++++++++++++++ tests/test_udp.py | 80 ++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 tests/test_housekeeping.py diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index c9653dee4..241ac326a 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -48,6 +48,7 @@ ACK_TX_TIMEOUT = 1.0 SESSION_LIFETIME = 60.0 IMPLICIT_TOPIC_TIMEOUT = 600.0 +HOUSEKEEPING_PERIOD = 1.0 # Aggregate stale-state sweep cadence (dedup/reordering), well inside SESSION_LIFETIME. REORDERING_CAPACITY = 16 ASSOC_SLACK_LIMIT = 2 DEDUP_HISTORY = 512 @@ -513,6 +514,14 @@ def sync_implicit(self) -> None: """Sync implicitness and transport state with the reference state machine.""" self._node.sync_topic_lifecycle(self) + def drop_stale_dedup(self, now: float) -> None: + """Aggregate sweep of per-remote dedup state (mirrors the reference dedup_drop_stale). The + per-arrival prune only touches the arriving remote; this retires entries for departed remotes so + the map cannot grow without bound under untrusted traffic.""" + stale = [rid for rid, st in self.dedup.items() if (st.last_active + SESSION_LIFETIME) < now] + for rid in stale: + del self.dedup[rid] + def log_age(origin: float, now: float) -> int: diff = int(now - origin) @@ -1496,21 +1505,41 @@ def _retire_one_expired_implicit_topic(self, now: float) -> bool: _logger.info("GC removed implicit topic '%s'", oldest.name) return True + def sweep_stale_states(self, now: float) -> None: + """Aggregate, time-driven retirement of per-remote dedup and reordering state, so neither grows + without bound after the traffic that created it stops (mirrors the reference poll's round-robin + dedup_drop_stale / reordering_drop_stale, done sweep-all here).""" + from ._subscriber import SubscriberImpl + + for topic in list(self.topics_by_name.values()): + topic.drop_stale_dedup(now) + for registry in (self.sub_roots_verbatim, self.sub_roots_pattern): + for root in list(registry.values()): + for sub in list(root.subscribers): + if isinstance(sub, SubscriberImpl): + sub.drop_stale_reordering(now) + async def implicit_gc_loop(self) -> None: + # The stale-state sweep runs on an ABSOLUTE schedule so steady implicit-topic traffic (which wakes + # this loop via notify_implicit_gc) cannot postpone it indefinitely. + next_sweep = time.monotonic() + HOUSEKEEPING_PERIOD try: while not self._closed: self._implicit_gc_wakeup.clear() - delay = self._next_implicit_gc_delay() - if delay is None: - await self._implicit_gc_wakeup.wait() - continue - if delay > 0: + now = time.monotonic() + gc_delay = self._next_implicit_gc_delay(now) + sweep_delay = max(0.0, next_sweep - now) + timeout = sweep_delay if gc_delay is None else min(gc_delay, sweep_delay) + if timeout > 0: try: - await asyncio.wait_for(self._implicit_gc_wakeup.wait(), timeout=delay) - continue + await asyncio.wait_for(self._implicit_gc_wakeup.wait(), timeout=timeout) except asyncio.TimeoutError: pass - self._retire_one_expired_implicit_topic(time.monotonic()) + now = time.monotonic() + self._retire_one_expired_implicit_topic(now) # No-op when nothing is expired. + if now >= next_sweep: + self.sweep_stale_states(now) + next_sweep = now + HOUSEKEEPING_PERIOD except asyncio.CancelledError: pass diff --git a/src/pycyphal2/_subscriber.py b/src/pycyphal2/_subscriber.py index 519a77c49..ef612514b 100644 --- a/src/pycyphal2/_subscriber.py +++ b/src/pycyphal2/_subscriber.py @@ -114,7 +114,7 @@ def deliver(self, arrival: Arrival, tag: int, remote_id: int) -> bool: if self._reordering_window is None: self.queue.put_nowait(arrival) return True - self._drop_stale_reordering(arrival.timestamp.s) + self.drop_stale_reordering(arrival.timestamp.s) topic_hash = arrival.breadcrumb.topic.hash key = (remote_id, topic_hash) state = self._reordering.get(key) @@ -210,7 +210,7 @@ def on_timeout() -> None: state.timeout_handle = loop.call_later(delay, on_timeout) - def _drop_stale_reordering(self, now: float) -> None: + def drop_stale_reordering(self, now: float) -> None: stale = [key for key, state in self._reordering.items() if (state.last_active_at + SESSION_LIFETIME) < now] for key in stale: state = self._reordering.pop(key) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 54d3ad888..4b8503140 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -58,6 +58,16 @@ _CYPHAL_OVERHEAD_MAX = 100 _CYPHAL_MTU_LINK_MIN = 576 _RX_SESSION_LIFETIME_NS = round(30.0 * 1e9) +_HOUSEKEEPING_PERIOD = 1.0 # Periodic RX-session retirement cadence, independent of new traffic. +# Capacity bound on the learned reverse-route cache. Legitimate deployments use a handful of remotes; the +# cap bounds memory under untrusted traffic with spoofable source UIDs, evicting the least-recently-seen +# entry. Not time-based: a retained breadcrumb's route survives until evicted under sustained flooding +# (then respond() fails cleanly with SendError), rather than expiring while the peer is briefly silent. +_REMOTE_ENDPOINT_CAPACITY = 8192 +# Per-reassembler cap on concurrent sender sessions. TTL retirement alone bounds retention time but not +# cardinality: a burst of many unique (spoofable) source UIDs within the lifetime window would otherwise +# grow unboundedly. The least-recently-active session is evicted past the cap. +_RX_SESSION_CAPACITY = 4096 _RX_SLOT_COUNT = 8 _RX_TRANSFER_HISTORY_COUNT = 32 _SUBJECT_ID_MODULUS_MAX = IPv4_SUBJECT_ID_MAX - SUBJECT_ID_PINNED_MAX @@ -351,6 +361,9 @@ def accept( self._sessions[header.sender_uid] = session session.last_animated_ns = timestamp_ns self._sessions.move_to_end(header.sender_uid, last=False) + if len(self._sessions) > _RX_SESSION_CAPACITY: # Evict the least-recently-active session. + evicted_uid, _ = self._sessions.popitem(last=True) + _logger.debug("UDP reasm session cache full, evicted uid=%016x", evicted_uid) if not session.initialized: session.initialize_history(header.transfer_id) if session.is_transfer_ejected(header.transfer_id): @@ -403,6 +416,17 @@ def _retire_one_stale_session(self, timestamp_ns: int) -> None: self._sessions.pop(oldest_uid) _logger.debug("UDP reasm retire uid=%016x", oldest_uid) + def drop_stale_sessions(self, timestamp_ns: int) -> None: + """Retire every stale session, independent of new traffic (the reference does this from a periodic + poll). Sessions are recency-ordered, so once the oldest is fresh none remain stale.""" + while self._sessions: + oldest_uid = next(reversed(self._sessions)) + if timestamp_ns >= (self._sessions[oldest_uid].last_animated_ns + _RX_SESSION_LIFETIME_NS): + self._sessions.pop(oldest_uid) + _logger.debug("UDP reasm retire uid=%016x", oldest_uid) + else: + break + def _make_subject_endpoint(subject_id: int) -> tuple[str, int]: """Return (multicast_ip, port) for a given subject_id.""" @@ -653,7 +677,7 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus self._unicast_handler: Callable[[TransportArrival], None] | None = None self._unicast_reassembler = _RxReassembler() - self._remote_endpoints: dict[tuple[int, int], tuple[str, int]] = {} + self._remote_endpoints: OrderedDict[tuple[int, int], tuple[str, int]] = OrderedDict() self._next_unicast_transfer_id = int.from_bytes(os.urandom(6), "little") self._unicast_rx_tasks: list[asyncio.Task[None]] = [] @@ -663,6 +687,8 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus task = self._loop.create_task(self._unicast_rx_loop(sock, i)) self._unicast_rx_tasks.append(task) + self._housekeeping_task = self._loop.create_task(self._housekeeping_loop()) + _logger.info( "UDPTransport initialized: uid=0x%016x, interfaces=%s, modulus=%d", self._uid, @@ -874,6 +900,7 @@ def close(self) -> None: return self._closed = True _logger.info("Closing UDPTransport uid=0x%016x", self._uid) + self._housekeeping_task.cancel() for task in self._unicast_rx_tasks: task.cancel() self._unicast_rx_tasks.clear() @@ -890,6 +917,11 @@ def close(self) -> None: self._subject_handlers.clear() self._subject_writers.clear() self._reassemblers.clear() + # Also release the state the finding flagged as surviving close(): the unicast reassembler's + # sessions, the learned reverse-route cache, and the unicast handler reference. + self._unicast_reassembler = _RxReassembler() + self._remote_endpoints.clear() + self._unicast_handler = None async def _mcast_rx_loop(self, sock: socket.socket, subject_id: int, iface_idx: int) -> None: try: @@ -926,9 +958,28 @@ async def _unicast_rx_loop(self, sock: socket.socket, iface_idx: int) -> None: except asyncio.CancelledError: _logger.debug("Unicast rx cancelled iface=%d", iface_idx) + async def _housekeeping_loop(self) -> None: + """Retire stale reassembly sessions periodically, independent of new traffic (the reference does + this from its poll), so a silent remote's session is reclaimed instead of lingering the full + session lifetime past the last frame.""" + try: + while not self._closed: + await asyncio.sleep(_HOUSEKEEPING_PERIOD) + now_ns = Instant.now().ns + self._unicast_reassembler.drop_stale_sessions(now_ns) + for reassembler in list(self._reassemblers.values()): + reassembler.drop_stale_sessions(now_ns) + except asyncio.CancelledError: + pass + def _learn_remote_endpoint(self, remote_id: int, iface_idx: int, src_ip: str, src_port: int) -> None: - existing = self._remote_endpoints.get((remote_id, iface_idx)) - self._remote_endpoints[(remote_id, iface_idx)] = (src_ip, src_port) + key = (remote_id, iface_idx) + existing = self._remote_endpoints.get(key) + self._remote_endpoints[key] = (src_ip, src_port) + self._remote_endpoints.move_to_end(key) # Mark most-recently-seen for LRU eviction. + if len(self._remote_endpoints) > _REMOTE_ENDPOINT_CAPACITY: + evicted, _ = self._remote_endpoints.popitem(last=False) + _logger.debug("Remote endpoint cache full, evicted rid=%016x iface=%d", evicted[0], evicted[1]) if existing != (src_ip, src_port): _logger.info("Remote endpoint rid=%016x iface=%d ep=%s:%d", remote_id, iface_idx, src_ip, src_port) diff --git a/tests/test_housekeeping.py b/tests/test_housekeeping.py new file mode 100644 index 000000000..637bfa3f8 --- /dev/null +++ b/tests/test_housekeeping.py @@ -0,0 +1,74 @@ +"""Regression tests for finding #3: per-remote dedup and reordering state must be swept in aggregate on a +time basis, not only when the arriving remote sends again, so neither grows without bound under untrusted +traffic.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +import pycyphal2 +from pycyphal2._node import SESSION_LIFETIME, DedupState +from pycyphal2._subscriber import ReorderingState, SubscriberImpl +from tests.mock_transport import MockNetwork, MockTransport +from tests.typing_helpers import new_node + + +async def test_sweep_drops_stale_dedup_for_departed_remotes() -> None: + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n1") + sub = node.subscribe("/t") + topic = node.topics_by_name["t"] + + base = 1000.0 + topic.dedup[10] = DedupState(tag_frontier=1, last_active=base) + topic.dedup[11] = DedupState(tag_frontier=1, last_active=base) + topic.dedup[12] = DedupState(tag_frontier=1, last_active=base + 10_000.0) # Recently active. + + node.sweep_stale_states(base + SESSION_LIFETIME + 1.0) + assert 10 not in topic.dedup # Departed remotes are swept in aggregate... + assert 11 not in topic.dedup + assert 12 in topic.dedup # ...while a recently-active remote is retained. + + sub.close() + node.close() + + +async def test_sweep_drops_stale_reordering_states() -> None: + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n1") + sub = node.subscribe("/t", reordering_window=0.2) + assert isinstance(sub, SubscriberImpl) + + base = 1000.0 + sub._reordering[(10, 0xAAAA)] = ReorderingState(last_active_at=base) + sub._reordering[(11, 0xBBBB)] = ReorderingState(last_active_at=base + 10_000.0) + + node.sweep_stale_states(base + SESSION_LIFETIME + 1.0) + assert (10, 0xAAAA) not in sub._reordering # Idle stream swept... + assert (11, 0xBBBB) in sub._reordering # ...recently-active one retained. + + sub.close() + node.close() + + +async def test_housekeeping_loop_sweeps_without_new_traffic(monkeypatch: pytest.MonkeyPatch) -> None: + """The background loop must retire stale state on its own schedule, even if the remote never sends + again (previously the sweep was only triggered by a new arrival).""" + monkeypatch.setattr(pycyphal2._node, "HOUSEKEEPING_PERIOD", 0.02) + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n1") + sub = node.subscribe("/t") + topic = node.topics_by_name["t"] + + topic.dedup[10] = DedupState(tag_frontier=1, last_active=time.monotonic() - SESSION_LIFETIME - 5.0) + for _ in range(100): + if 10 not in topic.dedup: + break + await asyncio.sleep(0.01) + assert 10 not in topic.dedup # The loop swept it with no further traffic. + + sub.close() + node.close() diff --git a/tests/test_udp.py b/tests/test_udp.py index 75a30c533..b637581e8 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -35,7 +35,11 @@ UDPTransport, _FrameHeader, _IP_MULTICAST_ALL_LINUX, + _REMOTE_ENDPOINT_CAPACITY, + _RX_SESSION_CAPACITY, + _RX_SESSION_LIFETIME_NS, _RxReassembler, + _RxSession, _SUBJECT_ID_MODULUS_MAX, _TransferSlot, _header_deserialize, @@ -429,6 +433,32 @@ def test_ninth_concurrent_transfer_sacrifices_oldest_slot(self): slot_transfer_ids = {slot.transfer_id for slot in session.slots if slot is not None} assert slot_transfer_ids == set(range(2, 10)) + def test_drop_stale_sessions_retires_idle_without_new_traffic(self): + """drop_stale_sessions retires every session past its lifetime, independent of new frames, so a + silent remote's session is reclaimed on the periodic tick (finding #3).""" + reasm = _RxReassembler() + old = self._make_frames(b"old", mtu=1400, sender_uid=100, transfer_id=1) + reasm.accept(old[0][0], old[0][1], timestamp_ns=0) + recent = self._make_frames(b"recent", mtu=1400, sender_uid=200, transfer_id=1) + # A timestamp within the lifetime so the built-in per-accept retire does not fire yet. + reasm.accept(recent[0][0], recent[0][1], timestamp_ns=1000) + assert set(reasm._sessions) == {100, 200} + + reasm.drop_stale_sessions(_RX_SESSION_LIFETIME_NS + 1) + assert 100 not in reasm._sessions # Idle past its lifetime -> retired. + assert 200 in reasm._sessions # Still within its lifetime -> retained. + + def test_sessions_bounded_by_lru_capacity(self): + """Session count is capacity-bounded, so a burst of unique source UIDs within the lifetime window + cannot grow the map without bound (finding #3).""" + reasm = _RxReassembler() + for uid in range(_RX_SESSION_CAPACITY + 20): + frames = self._make_frames(b"x", mtu=1400, sender_uid=uid, transfer_id=1) + reasm.accept(frames[0][0], frames[0][1], timestamp_ns=uid) # Distinct, monotonically-advancing ts. + assert len(reasm._sessions) == _RX_SESSION_CAPACITY + assert 0 not in reasm._sessions # Least-recently-active evicted. + assert (_RX_SESSION_CAPACITY + 19) in reasm._sessions # Most recent retained. + def test_duplicate_history_window_is_32(self): reasm = _RxReassembler() for transfer_id in range(1, 34): @@ -1337,6 +1367,56 @@ def flaky_create(subject_id, iface): # type: ignore[no-untyped-def] t.close() +@pytest.mark.asyncio +async def test_remote_endpoints_bounded_by_lru_capacity() -> None: + """The learned reverse-route cache is capacity-bounded with LRU eviction, so it cannot grow without + bound under untrusted traffic with spoofable source UIDs (finding #3).""" + t = UDPTransport.new_loopback() + assert isinstance(t, _UDPTransportImpl) + try: + for uid in range(_REMOTE_ENDPOINT_CAPACITY + 50): + t._learn_remote_endpoint(uid, 0, "10.0.0.1", 9000) + assert len(t._remote_endpoints) == _REMOTE_ENDPOINT_CAPACITY + assert (0, 0) not in t._remote_endpoints # Least-recently-seen evicted. + assert (_REMOTE_ENDPOINT_CAPACITY + 49, 0) in t._remote_endpoints # Most recent retained. + finally: + t.close() + + +@pytest.mark.asyncio +async def test_close_releases_unicast_and_endpoint_state() -> None: + """close() must release the state the finding flagged as surviving it: the unicast reassembler's + sessions, the learned endpoint cache, and the unicast handler reference (finding #3).""" + t = UDPTransport.new_loopback() + assert isinstance(t, _UDPTransportImpl) + t.unicast_listen(lambda _a: None) + t._learn_remote_endpoint(5, 0, "10.0.0.1", 9000) + t._unicast_reassembler._sessions[100] = _RxSession(last_animated_ns=0) + + t.close() + assert t._remote_endpoints == {} + assert t._unicast_handler is None + assert t._unicast_reassembler._sessions == {} + + +@pytest.mark.asyncio +async def test_housekeeping_loop_retires_stale_sessions(monkeypatch: pytest.MonkeyPatch) -> None: + """The periodic housekeeping loop retires stale reassembly sessions with no further traffic.""" + monkeypatch.setattr("pycyphal2.udp._HOUSEKEEPING_PERIOD", 0.02) + monkeypatch.setattr("pycyphal2.udp._RX_SESSION_LIFETIME_NS", 1) + t = UDPTransport.new_loopback() + assert isinstance(t, _UDPTransportImpl) + try: + t._unicast_reassembler._sessions[100] = _RxSession(last_animated_ns=0) + for _ in range(100): + if not t._unicast_reassembler._sessions: + break + await asyncio.sleep(0.01) + assert t._unicast_reassembler._sessions == {} + finally: + t.close() + + def test_interface_rejects_subminimum_mtu() -> None: """A link MTU below the Cyphal minimum is rejected at construction, not via a strippable assert.""" with pytest.raises(ValueError, match="mtu_link must be"): From b5b3551fce8a9b361107e2d8f1ff0e16f4205278 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:34:43 +0300 Subject: [PATCH 21/35] Update docs and changelog for the correctness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct the stale CLAUDE.md/AGENTS.md claim that pycyphal2.can is "coming soon, not yet in the codebase" — the CAN transport now exists. - Document the response_timeout contract on Publisher.request (non-negative; +inf disables liveness; NaN/negative raise). - Summarize the audit fixes in the v2.0 changelog section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- CHANGELOG.rst | 23 +++++++++++++++++++++++ CLAUDE.md | 2 +- src/pycyphal2/_api.py | 3 ++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f1664bde1..acf7e98ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,29 @@ with v1 in the same Python environment. - Add Cyphal/CAN SLCAN media with a browser WebSerial backend. +- Correctness and robustness fixes from a deep audit against the C reference: + + - Malformed wire input can no longer raise on the receive path: a crafted gossip topic name is dropped + rather than crashing the pin-suffix parser, and the Cyphal/CAN reader loop survives a raising handler + instead of going permanently deaf. + - ``subject_id_modulus`` is validated (at least 57203, prime, ≡ 3 mod 4) at node construction, so a + degenerate value is rejected instead of hanging the event loop. + - Cyphal/UDP sends to redundant interfaces concurrently with per-socket serialization, so a congested + interface no longer starves a healthy one and closing mid-send raises a clean error. + - Wire-format parity fixes: subject-ID computation wraps mod 2^64, the fragment-tree neighbor lookup and + transfer-ID history seed match the reference, whole-segment wildcard classification (so names like + ``ab*cd`` are legal verbatim topics), CAN FD framing is fixed per interface with no bit-rate switching, + CAN unicast accepts node-ID 0, SLCAN drops standard-ID frames, and idle CAN RX sessions retire at the + transfer-ID timeout. + - On Linux, multicast RX sockets disable cross-interface delivery so reverse routes are not mislearned on + multi-homed hosts. + - Node and transport setup paths are transactional: a transport failure mid-setup rolls back rather than + leaving unrepairable half-state (subscribe follows the reference repair model and no longer raises on a + listener acquisition failure). + - Internal per-remote state (dedup, reordering, reassembly sessions, learned reverse routes) is bounded + under untrusted traffic and released on close. + - Node close and implicit-topic GC no longer orphan or hang an outstanding response stream. + Changelog v1 ============ diff --git a/CLAUDE.md b/CLAUDE.md index 19c888af4..73eb704c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Source is in `src/pycyphal2/`, tests in `tests/`. The package is extremely compa Concrete transports are in top-level submodules: - `pycyphal2.udp` — Cyphal/UDP transport implementation. -- `pycyphal2.can` (coming soon, not yet in the codebase) — Cyphal/CAN transport implementation. +- `pycyphal2.can` — Cyphal/CAN transport implementation (SocketCAN, python-can, and SLCAN/WebSerial media). The core must be dependency-free. Transports may introduce (optional) dependencies that MUST be kept to the bare minimum. diff --git a/src/pycyphal2/_api.py b/src/pycyphal2/_api.py index a8cdaab39..f6a775f23 100644 --- a/src/pycyphal2/_api.py +++ b/src/pycyphal2/_api.py @@ -282,7 +282,8 @@ async def request( from any subscriber that chooses to answer. ``response_timeout`` is the maximum idle gap (liveness timeout) between accepted responses, - so it applies both to one-off RPC and to streaming. + so it applies both to one-off RPC and to streaming. It must be non-negative; a non-finite value + (``inf``) disables the liveness timeout, and ``NaN`` or a negative value raises :class:`ValueError`. """ raise NotImplementedError From 7d4b57e64d62377b5592255b567292e121c6ecdc Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:49:42 +0300 Subject: [PATCH 22/35] Address review round 1: doc gaps, TX-socket rollback, comment cleanup From the multi-agent review of the change set: - Document the subject_id_modulus predicate on Transport.subject_id_modulus (a custom transport author cannot otherwise discover the constraint). - Close the transactional-setup gap the mcast fix left in its sibling: _create_tx_socket now closes its socket on bind/setsockopt failure, and node construction rolls back already-created TX sockets on a mid-loop failure. Regression test added. - Rewrite the UDP close() comment to state the invariant instead of referencing an (ephemeral) review finding; cite the reference by symbol (topic_sync_subject_reader) instead of a rot-prone line number; fix the self-contradictory "non-finite/NaN" wording in the request() docstring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_api.py | 2 +- src/pycyphal2/_node.py | 4 ++-- src/pycyphal2/_transport.py | 4 +++- src/pycyphal2/udp.py | 32 +++++++++++++++++++++----------- tests/test_udp.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/pycyphal2/_api.py b/src/pycyphal2/_api.py index f6a775f23..602c60a99 100644 --- a/src/pycyphal2/_api.py +++ b/src/pycyphal2/_api.py @@ -282,7 +282,7 @@ async def request( from any subscriber that chooses to answer. ``response_timeout`` is the maximum idle gap (liveness timeout) between accepted responses, - so it applies both to one-off RPC and to streaming. It must be non-negative; a non-finite value + so it applies both to one-off RPC and to streaming. It must be non-negative; positive infinity (``inf``) disables the liveness timeout, and ``NaN`` or a negative value raises :class:`ValueError`. """ raise NotImplementedError diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 241ac326a..324ad34bb 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -1025,8 +1025,8 @@ async def _gossip_event_urgent(self, topic: TopicImpl) -> None: await self.send_gossip(topic, broadcast=True) async def _gossip_event_periodic(self, topic: TopicImpl) -> None: - # Retry a previously-failed listener acquisition on the gossip cadence (cy.c:1819), so a verbatim - # subscription whose listener failed once eventually recovers. + # Retry a previously-failed listener acquisition on the gossip cadence (reference + # topic_sync_subject_reader), so a verbatim subscription whose listener failed once recovers. topic.sync_listener() self._reschedule_gossip_periodic(topic, suppressed=False) broadcast = (topic.gossip_counter < GOSSIP_BROADCAST_RATIO) or ( diff --git a/src/pycyphal2/_transport.py b/src/pycyphal2/_transport.py index 02f2995d9..30fbf5146 100644 --- a/src/pycyphal2/_transport.py +++ b/src/pycyphal2/_transport.py @@ -50,7 +50,9 @@ class Transport(Closable): @abstractmethod def subject_id_modulus(self) -> int: """ - Constant, cannot be changed while the transport is in used because that would invalidate subject allocations. + Constant, cannot be changed while the transport is in use because that would invalidate subject allocations. + The value must satisfy the reference predicate — at least ``SUBJECT_ID_MODULUS_16bit`` (57203), prime, and + congruent to 3 modulo 4 — otherwise :meth:`pycyphal2.Node.new` rejects the transport with ``ValueError``. """ raise NotImplementedError diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 4b8503140..e2b928bf6 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -664,11 +664,16 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus # serialized; without it a displaced sock_sendto can hang until its deadline. self._tx_locks: list[asyncio.Lock] = [] self._self_endpoints: set[tuple[str, int]] = set() - for iface in self._interfaces: - sock = self._create_tx_socket(iface) - self._tx_socks.append(sock) - self._tx_locks.append(asyncio.Lock()) - self._self_endpoints.add(sock.getsockname()[:2]) + try: + for iface in self._interfaces: + sock = self._create_tx_socket(iface) + self._tx_socks.append(sock) + self._tx_locks.append(asyncio.Lock()) + self._self_endpoints.add(sock.getsockname()[:2]) + except BaseException: + for sock in self._tx_socks: # Roll back sockets created before the failure. + sock.close() + raise self._subject_handlers: dict[int, Callable[[TransportArrival], None]] = {} self._subject_writers: dict[int, _UDPSubjectWriter] = {} @@ -699,10 +704,14 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus @staticmethod def _create_tx_socket(iface: Interface) -> socket.socket: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setblocking(False) - sock.bind((str(iface.address), 0)) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, _MULTICAST_TTL) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(str(iface.address))) + try: + sock.setblocking(False) + sock.bind((str(iface.address), 0)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, _MULTICAST_TTL) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(str(iface.address))) + except BaseException: + sock.close() # Do not leak the fd if bind/setsockopt fails. + raise _logger.info("TX socket created on %s, bound to port %d", iface.address, sock.getsockname()[1]) return sock @@ -917,8 +926,9 @@ def close(self) -> None: self._subject_handlers.clear() self._subject_writers.clear() self._reassemblers.clear() - # Also release the state the finding flagged as surviving close(): the unicast reassembler's - # sessions, the learned reverse-route cache, and the unicast handler reference. + # Release all RX-side state so a closed transport retains no session memory, learned reverse + # routes, or handler references: the unicast reassembler's sessions, the endpoint cache, and the + # unicast handler. self._unicast_reassembler = _RxReassembler() self._remote_endpoints.clear() self._unicast_handler = None diff --git a/tests/test_udp.py b/tests/test_udp.py index b637581e8..fcbd388b5 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1304,6 +1304,10 @@ async def slow_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-de await waiter(Instant.now() + 0.15, Priority.NOMINAL, b"wait") holder_release.set() await holder_task # The holder still completes cleanly. + + # The lock-acquisition timeout must not have leaked the socket lock: a later send succeeds. + assert not pub._tx_locks[0].locked() + await waiter(Instant.now() + 2.0, Priority.NOMINAL, b"after") pub.close() @@ -1417,6 +1421,30 @@ async def test_housekeeping_loop_retires_stale_sessions(monkeypatch: pytest.Monk t.close() +@pytest.mark.asyncio +async def test_tx_socket_creation_failure_rolls_back_created_sockets() -> None: + """A TX-socket creation failure mid-construction must roll back the interface sockets created before + it, rather than leaking their file descriptors (finding #11 completeness).""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + real_create = _UDPTransportImpl._create_tx_socket + created: list[socket.socket] = [] + calls = {"n": 0} + + def flaky_create(iface_arg): # type: ignore[no-untyped-def] + calls["n"] += 1 + if calls["n"] == 2: # Fail the second interface, after the first socket exists. + raise OSError("tx socket bind failed") + sock = real_create(iface_arg) + created.append(sock) + return sock + + with patch.object(_UDPTransportImpl, "_create_tx_socket", staticmethod(flaky_create)): + with pytest.raises(OSError): + UDPTransport.new(interfaces=[iface, iface]) + assert len(created) == 1 + assert created[0].fileno() == -1 # The first socket was closed by the rollback (no fd leak). + + def test_interface_rejects_subminimum_mtu() -> None: """A link MTU below the Cyphal minimum is rejected at construction, not via a strippable assert.""" with pytest.raises(ValueError, match="mtu_link must be"): From af9f4c0edb564587b436a84458b440421d15d57b Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:53:58 +0300 Subject: [PATCH 23/35] Simplify per review consensus: dedicated node housekeeping task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviewers flagged the stale-state sweep merged into implicit_gc_loop (with absolute-deadline bookkeeping) as the campaign's most intricate addition. Split it into a dedicated NodeImpl._housekeeping_loop that sleeps HOUSEKEEPING_PERIOD and sweeps — the same non-postponable guarantee for free (nothing can postpone asyncio.sleep in its own task), matching the existing UDP/CAN transport housekeeping loops, and reverting implicit_gc_loop and _next_implicit_gc_delay to their simpler forms. Also collapse the twin except blocks in Publisher.request into one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- src/pycyphal2/_node.py | 39 +++++++++++++++++++++---------------- src/pycyphal2/_publisher.py | 10 +++------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 324ad34bb..3045a0132 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -592,6 +592,7 @@ def broadcast_handler(arrival: TransportArrival) -> None: self._implicit_topics: OrderedDict[TopicImpl, None] = OrderedDict() self._implicit_gc_wakeup = asyncio.Event() self._gc_task = self.loop.create_task(self.implicit_gc_loop()) + self._housekeeping_task = self.loop.create_task(self._housekeeping_loop()) _logger.info( "Node init home='%s' ns='%s' broadcast_sid=%d shards=%d", @@ -1488,12 +1489,11 @@ def notify_implicit_gc(self) -> None: if not self._closed: self._implicit_gc_wakeup.set() - def _next_implicit_gc_delay(self, now: float | None = None) -> float | None: - now = time.monotonic() if now is None else now + def _next_implicit_gc_delay(self) -> float | None: if not self._implicit_topics: return None oldest = next(reversed(self._implicit_topics)) - return max(0.0, (oldest.ts_animated + IMPLICIT_TOPIC_TIMEOUT) - now) + return max(0.0, (oldest.ts_animated + IMPLICIT_TOPIC_TIMEOUT) - time.monotonic()) def _retire_one_expired_implicit_topic(self, now: float) -> bool: if not self._implicit_topics: @@ -1520,26 +1520,30 @@ def sweep_stale_states(self, now: float) -> None: sub.drop_stale_reordering(now) async def implicit_gc_loop(self) -> None: - # The stale-state sweep runs on an ABSOLUTE schedule so steady implicit-topic traffic (which wakes - # this loop via notify_implicit_gc) cannot postpone it indefinitely. - next_sweep = time.monotonic() + HOUSEKEEPING_PERIOD try: while not self._closed: self._implicit_gc_wakeup.clear() - now = time.monotonic() - gc_delay = self._next_implicit_gc_delay(now) - sweep_delay = max(0.0, next_sweep - now) - timeout = sweep_delay if gc_delay is None else min(gc_delay, sweep_delay) - if timeout > 0: + delay = self._next_implicit_gc_delay() + if delay is None: + await self._implicit_gc_wakeup.wait() + continue + if delay > 0: try: - await asyncio.wait_for(self._implicit_gc_wakeup.wait(), timeout=timeout) + await asyncio.wait_for(self._implicit_gc_wakeup.wait(), timeout=delay) + continue except asyncio.TimeoutError: pass - now = time.monotonic() - self._retire_one_expired_implicit_topic(now) # No-op when nothing is expired. - if now >= next_sweep: - self.sweep_stale_states(now) - next_sweep = now + HOUSEKEEPING_PERIOD + self._retire_one_expired_implicit_topic(time.monotonic()) + except asyncio.CancelledError: + pass + + async def _housekeeping_loop(self) -> None: + # A dedicated periodic task: nothing can postpone asyncio.sleep in its own task, so the stale-state + # sweep runs on a steady cadence regardless of implicit-GC activity (matches the UDP/CAN transports). + try: + while not self._closed: + await asyncio.sleep(HOUSEKEEPING_PERIOD) + self.sweep_stale_states(time.monotonic()) except asyncio.CancelledError: pass @@ -1587,6 +1591,7 @@ def close(self) -> None: for stream in list(topic.request_futures.values()): stream.dispose() self._gc_task.cancel() + self._housekeeping_task.cancel() for root in list(self.sub_roots_pattern.values()): if root.scout_task is not None: root.scout_task.cancel() diff --git a/src/pycyphal2/_publisher.py b/src/pycyphal2/_publisher.py index bcc135082..148025c45 100644 --- a/src/pycyphal2/_publisher.py +++ b/src/pycyphal2/_publisher.py @@ -135,14 +135,10 @@ async def request( try: tracker = self._prepare_reliable_publish_tracker(tag) initial_window = await self._reliable_publish_start(delivery_deadline, tag, payload, tracker) - except asyncio.CancelledError: - if tracker is not None: - tracker.compromised = True - self._release_reliable_publish_tracker(tag, tracker) - stream.close() - raise - except BaseException: + except BaseException as ex: if tracker is not None: + if isinstance(ex, asyncio.CancelledError): + tracker.compromised = True # publish_tracker_release reads this, so set it before release. self._release_reliable_publish_tracker(tag, tracker) stream.close() raise From a0439dccddb90223b9ca4b14a4f1d22a0b4689eb Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 04:56:53 +0300 Subject: [PATCH 24/35] Fix Windows CI: avoid a real loopback multicast send in the lock test The lock-not-leaked regression test did a real multicast send to 127.0.0.1 to confirm the socket lock is re-acquirable after an acquisition timeout, which fails on Windows (WinError 1231: loopback has no multicast route). The lock-free assertion already proves no leak; the re-send now uses a mocked async_sendto so it still exercises lock re-acquisition via send_on_iface without touching the network. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CabiCDQ5DNBYq8WDKRzG4Z --- tests/test_udp.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_udp.py b/tests/test_udp.py index fcbd388b5..8749110ec 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1305,9 +1305,14 @@ async def slow_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-de holder_release.set() await holder_task # The holder still completes cleanly. - # The lock-acquisition timeout must not have leaked the socket lock: a later send succeeds. + # The lock-acquisition timeout must not have leaked the socket lock: it is free and re-acquirable. assert not pub._tx_locks[0].locked() - await waiter(Instant.now() + 2.0, Priority.NOMINAL, b"after") + + async def ok_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] + pass # Avoid a real multicast send (unroutable on Windows loopback); still re-acquires the lock. + + with patch.object(pub, "async_sendto", ok_sendto): + await waiter(Instant.now() + 2.0, Priority.NOMINAL, b"after") # Succeeds via send_on_iface. pub.close() From cf148b0ac3597c45bb0826216bcd20e896bc8e8d Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 22:56:08 +0300 Subject: [PATCH 25/35] Bump version and clean the changelog --- CHANGELOG.rst | 23 ----------------------- CLAUDE.md | 2 +- src/pycyphal2/__init__.py | 2 +- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index acf7e98ad..f1664bde1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,29 +18,6 @@ with v1 in the same Python environment. - Add Cyphal/CAN SLCAN media with a browser WebSerial backend. -- Correctness and robustness fixes from a deep audit against the C reference: - - - Malformed wire input can no longer raise on the receive path: a crafted gossip topic name is dropped - rather than crashing the pin-suffix parser, and the Cyphal/CAN reader loop survives a raising handler - instead of going permanently deaf. - - ``subject_id_modulus`` is validated (at least 57203, prime, ≡ 3 mod 4) at node construction, so a - degenerate value is rejected instead of hanging the event loop. - - Cyphal/UDP sends to redundant interfaces concurrently with per-socket serialization, so a congested - interface no longer starves a healthy one and closing mid-send raises a clean error. - - Wire-format parity fixes: subject-ID computation wraps mod 2^64, the fragment-tree neighbor lookup and - transfer-ID history seed match the reference, whole-segment wildcard classification (so names like - ``ab*cd`` are legal verbatim topics), CAN FD framing is fixed per interface with no bit-rate switching, - CAN unicast accepts node-ID 0, SLCAN drops standard-ID frames, and idle CAN RX sessions retire at the - transfer-ID timeout. - - On Linux, multicast RX sockets disable cross-interface delivery so reverse routes are not mislearned on - multi-homed hosts. - - Node and transport setup paths are transactional: a transport failure mid-setup rolls back rather than - leaving unrepairable half-state (subscribe follows the reference repair model and no longer raises on a - listener acquisition failure). - - Internal per-remote state (dedup, reordering, reassembly sessions, learned reverse routes) is bounded - under untrusted traffic and released on close. - - Node close and implicit-topic GC no longer orphan or hang an outstanding response stream. - Changelog v1 ============ diff --git a/CLAUDE.md b/CLAUDE.md index 73eb704c0..051e2c483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Source is in `src/pycyphal2/`, tests in `tests/`. The package is extremely compa Concrete transports are in top-level submodules: - `pycyphal2.udp` — Cyphal/UDP transport implementation. -- `pycyphal2.can` — Cyphal/CAN transport implementation (SocketCAN, python-can, and SLCAN/WebSerial media). +- `pycyphal2.can` — Cyphal/CAN transport implementation. The core must be dependency-free. Transports may introduce (optional) dependencies that MUST be kept to the bare minimum. diff --git a/src/pycyphal2/__init__.py b/src/pycyphal2/__init__.py index 6e4dc0a55..e68812487 100644 --- a/src/pycyphal2/__init__.py +++ b/src/pycyphal2/__init__.py @@ -155,7 +155,7 @@ async def main(): from ._transport import Transport as Transport from ._transport import TransportArrival as TransportArrival -__version__ = "2.0.0.dev8" +__version__ = "2.0.0.dev9" # pdoc needs __all__ to display re-exported members. __all__ = [ From 15daf8aa66c6d08657215057d1816e1ecbc122d5 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:14:32 +0300 Subject: [PATCH 26/35] Update the review-loop skill --- .claude/skills/review-loop/SKILL.md | 49 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/.claude/skills/review-loop/SKILL.md b/.claude/skills/review-loop/SKILL.md index 6bac630ee..cb84b6c6b 100644 --- a/.claude/skills/review-loop/SKILL.md +++ b/.claude/skills/review-loop/SKILL.md @@ -2,39 +2,46 @@ name: review-loop description: >- Multi-agent review/refine loop. Use after a change or milestone, or when asked to review work: - fan out fresh-context, single-focus reviewers across distinct tools, consolidate and fix, add a - regression test for every defect, and repeat until reviews stay clean for three consecutive turns. + dispatch a fresh-context full-spectrum reviewer plus a dissimilar correctness reviewer, + consolidate and fix, add a regression test for every defect, and repeat until a round is clean. --- # Adversarial review/refine loop -After a change or milestone, or when prompted, dispatch a fan-out of fresh-context review agents at +After a change or milestone, or when prompted, dispatch fresh-context review agents at MAXIMUM THINKING EFFORT, then consolidate, fix, and repeat. -The goal is broad coverage from adversarial, diverse, independent perspectives. +The goal is adversarial, diverse, independent coverage. -## Fan out — one focus per agent +The prompts given to the agents shall be extremely terse, at most a few sentences. +Giving excessive detail may constrain their thinking causing the tunnel vision syndrome. +They must be given the opportunity to look at the work without bias or prejudice. -Spawn one agent per concern and run them in parallel. Never give an agent multiple jobs: it dilutes attention -and degrades every answer. Cover at least these angles, one agent each: +## The reviewer pair -- Opportunities for SIMPLIFICATION. -- Functional CORRECTNESS and ROBUSTNESS. -- ARCHITECTURAL CLEANLINESS, DESIGN PRACTICES, CODE QUALITY. -- POLICY and STYLE compliance with the project's own docs. +Run two reviewers in parallel per round: -### Dissimilar agents +- An *ultrathink* Claude agent with the FULL-SPECTRUM remit, in priority order: functional CORRECTNESS and + ROBUSTNESS first, then SIMPLIFICATION opportunities, ARCHITECTURAL CLEANLINESS and CODE QUALITY, + and POLICY/STYLE compliance with the project's own docs. -In addition to the subagents above, dispatch distinct tools focusing on CORRECTNESS only to maximize the diversity -of perspectives and minimize blind spots. Check which tools are available (Codex etc.) and use all of them. - -Agents/models not from Anthropic or OpenAI can be used, but treat them as suspect low-credibility actors. -Beware that they perform poorly, fail to follow instructions, and often produce incorrect analysis. +- Codex running the *most advanced model* in *ultra* effort focusing on CORRECTNESS only, to maximize perspective + diversity and minimize blind spots. ## Reviewers are read-only Review agents must not modify the worktree or run mutating commands. If one needs a mutable environment, it copies the worktree elsewhere. +## Reviewers do not re-run the project test suites + +The tests normally should already be green when the review loop is invoked; re-running them +duplicates work and, for the broad sessions, wastes minutes of compute per round. State this in the +reviewer prompts. Reviewer effort goes instead into adversarial counterexamples for behaviors the +existing tests do NOT cover, executed in a scratch clone. Probes must run under the repo's own test +interpreter (e.g. `.nox/tests/bin/python`, which mutates nothing) rather than whatever is on PATH: +a version-skewed interpreter or dependency set can produce findings that do not apply to the project +or miss ones that do. Reproducing their own findings before reporting remains mandatory. + ## Consolidate and act When all reviewers return, merge their findings, discard the noise, and fix what is real. @@ -42,12 +49,9 @@ For every correctness defect, add a regression test verified to fail before the ## When to stop -Repeat until the reviewers surface only minor feedback (or none) for THREE consecutive turns — this is -non-negotiable, however many iterations it takes. +A round is clean when the reviewers surface only trivial feedback or none; the first clean round ends the loop. Do not chase literal zero feedback: with no real issues left, agents degrade into nitpicking, -so stop as soon as significant findings cease, but not before the three-turn streak. -A blank turn followed by one that digs up a real defect is exactly why the streak must be consecutive; -expect dozens (sometimes over a hundred) of agent sessions per full pass. +so a round is clean as soon as significant findings cease. ## Operational notes @@ -58,3 +62,4 @@ is a common cause of stream-idle timeouts. Some headless agents hang waiting on stdin (like Codex) — redirect from `/dev/null`. Retry agents that fail on a transient or connection error until they succeed. +If an agent gets stuck or hits a security guardrail, try resuming it first instead of restarting its work from scratch. From f5761e317603ebb259f9f20c7042f56dbbb0ab8c Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:48:08 +0300 Subject: [PATCH 27/35] Set FDF and BRS on every CAN FD frame af3af85 correctly made the frame format follow the interface rather than the payload length, but it also dropped BRS, so the data phase ran at the arbitration bit rate -- forfeiting the throughput that is the point of FD. BRS is now set on every frame of an FD interface, with no exceptions; on Classic CAN it does not apply. The FDF flag was broken too, and more quietly: _CANFD_FDF was resolved via getattr(socket, "CANFD_FDF", 0), and CPython exposes no CANFD_* constants on any supported version (verified through 3.14), so it silently evaluated to 0 and every "FD" frame went out with an empty flags byte. Both bits are now hardcoded from linux/can.h. REFERENCE PARITY: this is a deliberate divergence. cy_can_socketcan.c emits `.flags = CANFD_FDF` alone, and BRS appears nowhere in reference/cy or reference/libcanard. The divergence is marked in socketcan._encode and should be reconciled upstream. The defect survived review because nothing unpacked the flags byte -- the SocketCAN test only asserted the encoded frame's length. It now asserts FDF and BRS for both a short and a long payload, and the python-can test asserts bitrate_switch instead of its negation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/can/pythoncan.py | 5 +++-- src/pycyphal2/can/socketcan.py | 16 ++++++++++++---- tests/can/test_pythoncan.py | 7 ++++--- tests/can/test_socketcan_unit.py | 11 +++++++++++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/pycyphal2/can/pythoncan.py b/src/pycyphal2/can/pythoncan.py index bf98bcd21..87a06042b 100644 --- a/src/pycyphal2/can/pythoncan.py +++ b/src/pycyphal2/can/pythoncan.py @@ -159,13 +159,14 @@ async def _tx_loop(self) -> None: _logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, entry.id) job.abort(SendError(f"PythonCAN interface {self._name} tx deadline expired")) continue - # The FD flag follows the interface mode, not the payload length, and BRS is never set, - # matching the reference (cy_can_socketcan emits every frame of an FD interface with FDF only). + # The FD flag follows the interface mode, not the payload length; see the REFERENCE PARITY + # note in socketcan._encode on why BRS is always set on an FD interface. msg = can.Message( arbitration_id=entry.id, is_extended_id=True, data=entry.payload, is_fd=self._fd, + bitrate_switch=self._fd, ) try: await asyncio.wait_for(loop.run_in_executor(None, self._bus.send, msg, timeout), timeout=timeout) diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index 06ca302bc..cc44f98c8 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -22,7 +22,10 @@ _CAN_FILTER_CAPACITY = 64 _CAN_INTERFACE_TYPE = 280 -_CANFD_FDF = getattr(socket, "CANFD_FDF", 0) +# CAN FD flag bits from linux/can.h. CPython's socket module does not expose these on any supported +# version, so they are hardcoded; a getattr() fallback would silently clear them. +_CANFD_BRS = 0x01 # Bit-rate switch: the data phase runs at the higher FD bit rate. +_CANFD_FDF = 0x04 # Marks the frame as CAN FD for the dual-use struct canfd_frame. _CAN_FRAME_STRUCT = struct.Struct("=IB3x8s") _CANFD_FRAME_STRUCT = struct.Struct("=IBBBB64s") _CAN_FILTER_STRUCT = struct.Struct("=II") @@ -207,13 +210,18 @@ def _is_transient_tx_error(ex: OSError) -> bool: def _encode(self, identifier: int, data: bytes) -> bytes: # The frame format is a property of the interface, fixed at construction, not of the payload - # length: every frame on an FD interface is an FD frame (FDF set, BRS never), as in the - # reference (cy_can_socketcan selects the FD/Classic vtable once from the netdev MTU). + # length: every frame on an FD interface is an FD frame, as in the reference + # (cy_can_socketcan selects the FD/Classic vtable once from the netdev MTU). + # + # REFERENCE PARITY: BRS is set on every FD frame, whereas the reference emits + # `.flags = CANFD_FDF` alone (cy_can_socketcan.c). Sending FD without BRS runs the data phase + # at the arbitration bit rate, forfeiting the throughput that is the point of FD, so this + # library always switches. BRS does not apply to Classic CAN. if self._fd: return _CANFD_FRAME_STRUCT.pack( socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK), len(data), - _CANFD_FDF, + _CANFD_FDF | _CANFD_BRS, 0, 0, data.ljust(64, b"\x00"), diff --git a/tests/can/test_pythoncan.py b/tests/can/test_pythoncan.py index 802a2a0d8..6bc59e4fe 100644 --- a/tests/can/test_pythoncan.py +++ b/tests/can/test_pythoncan.py @@ -1186,8 +1186,9 @@ async def test_unit_mixed_fd_and_classic_payloads() -> None: async def test_unit_fd_flags_follow_interface_mode() -> None: - """Every frame on an FD interface carries is_fd regardless of payload length, and BRS is never set; - a Classic interface never sets is_fd. Matches the reference cy_can_socketcan framing.""" + """Every frame on an FD interface carries is_fd AND bitrate_switch regardless of payload length; + a Classic interface sets neither. See the REFERENCE PARITY note in socketcan._encode: BRS is always + set on FD, which is a deliberate divergence from the reference.""" a, b = _virtual_pair(fd=True) sent: list[_can.Message] = [] orig_send = a._bus.send @@ -1204,7 +1205,7 @@ def recording_send(msg: _can.Message, timeout: float | None = None) -> None: await asyncio.wait_for(b.receive(), timeout=2.0) assert len(sent) == 2 assert all(m.is_fd for m in sent) - assert not any(m.bitrate_switch for m in sent) + assert all(m.bitrate_switch for m in sent) # Including the 4-byte payload. finally: _close_all(a, b) diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index f38a7bce4..9ff2aec34 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -371,6 +371,17 @@ def test_encode_and_decode_branches(monkeypatch: pytest.MonkeyPatch) -> None: encoded_fd_short = fd_iface._encode(456, b"abc") assert len(encoded_fd_short) == module._FD_FRAME_SIZE + # The flags byte must carry FDF (so the dual-use struct is unambiguous) and BRS (so the data phase + # actually runs at the FD bit rate) on EVERY FD frame, short payloads included. The literals are + # hardcoded from linux/can.h because CPython's socket module exposes neither; a getattr() fallback + # to 0 would silently emit flagless frames, which is what this assertion guards against. + assert module._CANFD_FDF == 0x04 + assert module._CANFD_BRS == 0x01 + for encoded in (encoded_fd, encoded_fd_short): + _, _, flags, _, _, _ = module._CANFD_FRAME_STRUCT.unpack(encoded) + assert flags & module._CANFD_FDF + assert flags & module._CANFD_BRS + assert module.SocketCANInterface._decode(b"\x00") is None non_extended = module._CAN_FRAME_STRUCT.pack(0x123, 1, b"x".ljust(8, b"\x00")) From 01e8c2348a77915c85106af0d442d67ee0cfc457 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:48:25 +0300 Subject: [PATCH 28/35] Stop acting on a closed node; guard the last constructor acquisition Two holes in the transactional-setup and teardown work. close() disposes every outstanding response stream, which cancels its publish task. That task's finally clause runs on a LATER loop iteration -- after close() returned and after transport.close() -- and re-syncs topic implicitness through sync_topic_lifecycle, which was not guarded by _closed. With a publisher still open the topic stays explicit, so the tail spawned a fresh _gossip_wait task on a dead node (never cancelled, and "Task was destroyed but it is pending" if the loop closed first) and called subject_listen on a closed transport. touch_implicit_topic could also re-populate _implicit_topics after close() had cleared it. Separately, transport.unicast_listen was the one unguarded fallible step in NodeImpl.__init__, one line after the try/except added to protect its predecessor. Nobody closes the transport when the constructor raises, so a failure there stranded the broadcast writer and listener. Both stock transports implement it as a plain assignment that cannot raise, but a custom transport may not. Regression tests pin both: the first reproduced a pending _gossip_wait task on a closed node before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/_node.py | 17 ++++++++++++++++- tests/test_close_streams.py | 26 ++++++++++++++++++++++++++ tests/test_transactional_setup.py | 17 +++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 3045a0132..1634d592a 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -587,7 +587,16 @@ def broadcast_handler(arrival: TransportArrival) -> None: self.shared_subject_writers: dict[int, SharedSubjectWriter] = {} self.shared_subject_listeners: dict[int, SharedSubjectListener] = {} - transport.unicast_listen(self.on_unicast_arrival) + # The last fallible acquisition in the constructor. Both built-in transports implement this as a + # plain assignment that cannot raise, but a third-party one may not, and an unguarded failure here + # would strand the broadcast writer and listener acquired above -- nobody closes the transport on + # constructor failure, so those handles would leak. + try: + transport.unicast_listen(self.on_unicast_arrival) + except BaseException: + self.broadcast_listener.close() + self.broadcast_writer.close() + raise self._implicit_topics: OrderedDict[TopicImpl, None] = OrderedDict() self._implicit_gc_wakeup = asyncio.Event() @@ -817,6 +826,12 @@ def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> No work.append((t, ev + 1)) def sync_topic_lifecycle(self, topic: TopicImpl) -> None: + # Reachable after close(): disposing a response stream cancels its publish task, whose finally + # clause releases the tracker and re-syncs implicitness on a later loop iteration -- by then the + # transport is closed and the gossip tasks are cancelled. Without this guard that tail would + # spawn an uncancellable gossip task on a dead node and call subject_listen on a closed transport. + if self._closed: + return implicit = topic.compute_is_implicit() if implicit != topic.is_implicit: topic.is_implicit = implicit diff --git a/tests/test_close_streams.py b/tests/test_close_streams.py index 0959b896b..9b3819034 100644 --- a/tests/test_close_streams.py +++ b/tests/test_close_streams.py @@ -154,3 +154,29 @@ async def test_pending_reliable_publish_keeps_topic_explicit() -> None: assert topic.is_implicit is True node.close() + + +async def test_close_does_not_resurrect_topic_via_disposed_stream_tail() -> None: + """close() disposes response streams, which cancels each publish task; that task's finally clause runs + on a LATER loop iteration -- after the transport is closed and the gossip tasks are cancelled -- and + re-syncs topic implicitness. sync_topic_lifecycle must refuse to act on a closed node, otherwise it + spawns an uncancellable gossip task on a dead node and calls subject_listen on a closed transport.""" + net = MockNetwork() + tr = MockTransport(node_id=1, network=net) + node = new_node(tr, home="n1") + pub = node.advertise("/rpc") # Left OPEN, so pub_count keeps the topic explicit across close(). + pub.ack_timeout = 0.05 + topic = node.topics_by_name["rpc"] + topic.associations[42] = Association(remote_id=42, last_seen=0.0) + + stream = await request_stream(pub, pycyphal2.Instant.now() + 5.0, float("inf"), b"request") + assert stream._publish_task is not None + listener_creations = dict(tr.subject_listener_creations) + + node.close() + for _ in range(5): # Let the cancelled publish task's finally clause run to completion. + await asyncio.sleep(0) + + assert topic.gossip_task is None # No gossip task resurrected on a dead node. + assert tr.subject_listener_creations == listener_creations # No subject_listen on a closed transport. + assert not node._implicit_topics # close() cleared it and the tail did not re-populate it. diff --git a/tests/test_transactional_setup.py b/tests/test_transactional_setup.py index 49427de8c..87141bf01 100644 --- a/tests/test_transactional_setup.py +++ b/tests/test_transactional_setup.py @@ -31,6 +31,23 @@ async def test_node_init_rolls_back_broadcast_writer_on_listen_failure() -> None assert tr.subject_handlers == {} +async def test_node_init_rolls_back_broadcast_handles_on_unicast_listen_failure() -> None: + """unicast_listen is the LAST fallible acquisition in the constructor. Both built-in transports + implement it as a plain assignment, but a third-party one may not -- and nobody closes the transport + when the constructor raises, so an unguarded failure here strands the broadcast writer and listener + acquired just above it.""" + tr = MockTransport(node_id=1, network=MockNetwork()) + + def failing_unicast_listen(_handler): # type: ignore[no-untyped-def] + raise RuntimeError("Simulated unicast_listen failure") + + tr.unicast_listen = failing_unicast_listen # type: ignore[method-assign] + with pytest.raises(RuntimeError, match="Simulated unicast_listen"): + new_node(tr, home="n") + assert tr.writers == {} # Broadcast writer rolled back... + assert tr.subject_handlers == {} # ...and so was the broadcast listener. + + async def test_advertise_rolls_back_pub_count_on_writer_failure() -> None: # Learn the topic's subject-ID from a healthy node (it depends only on name + modulus). healthy_tr = MockTransport(node_id=1, network=MockNetwork()) From a1fbcd5e5fd57cb9ffbeea1f2b2745c16a24102b Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:48:52 +0300 Subject: [PATCH 29/35] Count a cancelled interface as a failed send, not a delivery The redundant-send aggregation classified per-interface results with isinstance(r, Exception). asyncio.gather(..., return_exceptions=True) reports an individually cancelled child as a CancelledError *instance*, which has derived from BaseException since 3.8, so a cancelled interface was scored as a successful delivery. With every interface cancelled the error list came out empty and the send returned normally, reporting a fully delivered transfer that never put a byte on the wire. Both call sites now share _collect_send_errors, which tests BaseException and documents why. Fixing the predicate exposed a second defect on the all-failed path: ExceptionGroup refuses to nest a BaseException ("Cannot nest BaseExceptions in an ExceptionGroup"), so raising the aggregate turned into a TypeError as soon as a CancelledError reached it. It now builds a BaseExceptionGroup, which downgrades itself to an ExceptionGroup when every member is an Exception, leaving the common case unchanged. Covered by a unit test on the predicate and an end-to-end test asserting that an all-cancelled send raises, while one cancelled interface alongside one delivery still succeeds per redundancy semantics. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/udp.py | 21 ++++++++++++++++++--- tests/test_udp.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index e2b928bf6..6bd5549d6 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -159,6 +159,18 @@ def _frame_is_valid(header: _FrameHeader, payload_chunk: bytes | memoryview) -> return (header.frame_payload_offset + len(payload_chunk)) <= header.transfer_payload_size +def _collect_send_errors(results: list[BaseException | None]) -> list[BaseException]: + """ + Per-interface failures out of a ``gather(..., return_exceptions=True)`` over ``send_on_iface``. + + The predicate is ``BaseException``, not ``Exception``: gather reports an individually cancelled child + as a ``CancelledError`` *instance*, which derives from ``BaseException``, so an ``Exception`` test + would score a cancelled interface as a delivery -- and if every interface were cancelled the caller + would report a fully successful send that never put a byte on the wire. + """ + return [r for r in results if isinstance(r, BaseException)] + + @dataclass(frozen=True) class _Fragment: offset: int @@ -511,12 +523,15 @@ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | # shared deadline (each interface's frames still go out in order under its own socket lock). # return_exceptions=True lets every interface settle before we aggregate, so none is left running. results = await asyncio.gather(*coros, return_exceptions=True) - errors = [r for r in results if isinstance(r, Exception)] + errors = _collect_send_errors(results) success_count = len(results) - len(errors) if errors and success_count == 0: _logger.error("Send failed on all interfaces for subject %d", self._subject_id) - raise SendError("send failed on all interfaces") from ExceptionGroup( + # BaseExceptionGroup, not ExceptionGroup: the latter refuses to nest a BaseException, and + # `errors` may carry a CancelledError. It downgrades itself to an ExceptionGroup when every + # member is an Exception, so the common case is unchanged. + raise SendError("send failed on all interfaces") from BaseExceptionGroup( "send failed on all interfaces", errors ) if errors: @@ -887,7 +902,7 @@ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, m raise SendError("No endpoint known for remote_id") results = await asyncio.gather(*coros, return_exceptions=True) - errors = [r for r in results if isinstance(r, Exception)] + errors = _collect_send_errors(results) success_count = len(results) - len(errors) if success_count == 0: diff --git a/tests/test_udp.py b/tests/test_udp.py index 8749110ec..dab74a2e0 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -42,6 +42,7 @@ _RxSession, _SUBJECT_ID_MODULUS_MAX, _TransferSlot, + _collect_send_errors, _header_deserialize, _header_serialize, _make_subject_endpoint, @@ -1226,6 +1227,47 @@ async def all_fail(sock, data, addr, deadline): # type: ignore[no-untyped-def] pub.close() +def test_collect_send_errors_counts_cancellation_as_a_failure() -> None: + """asyncio.gather(..., return_exceptions=True) reports an individually cancelled child as a + CancelledError INSTANCE, which derives from BaseException, not Exception. The old + isinstance(r, Exception) predicate therefore scored a cancelled interface as a delivery.""" + oserr = OSError("down") + assert _collect_send_errors([None, None]) == [] + assert _collect_send_errors([None, oserr]) == [oserr] + cancelled = asyncio.CancelledError() + assert _collect_send_errors([None, cancelled]) == [cancelled] + assert len(_collect_send_errors([cancelled, asyncio.CancelledError()])) == 2 + + +async def test_send_cancelled_on_every_interface_is_not_reported_as_success() -> None: + """With every interface cancelled the send used to return normally, reporting a fully successful + transfer that never put a byte on the wire.""" + iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) + pub = UDPTransport.new(interfaces=[iface, iface]) + assert isinstance(pub, _UDPTransportImpl) + try: + + async def cancelled_send(sock, lock, frames, addr, deadline): # type: ignore[no-untyped-def] + raise asyncio.CancelledError + + with patch.object(pub, "send_on_iface", cancelled_send): + writer = pub.subject_advertise(10) + with pytest.raises(SendError): + await writer(Instant.now() + 2.0, Priority.NOMINAL, b"nope") + + # Redundancy still holds: one cancelled interface alongside one delivery is a success. + async def cancel_first(sock, lock, frames, addr, deadline): # type: ignore[no-untyped-def] + if sock is pub.tx_socks[0]: + raise asyncio.CancelledError + return None + + with patch.object(pub, "send_on_iface", cancel_first): + writer_partial = pub.subject_advertise(11) + await writer_partial(Instant.now() + 2.0, Priority.NOMINAL, b"ok") + finally: + pub.close() + + @pytest.mark.asyncio async def test_redundant_interfaces_send_concurrently() -> None: """A congested interface must not starve a healthy one of the shared deadline: interfaces are sent From 4b3bbb96dc932f4a19adf3132cb0658404bb0c10 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:49:11 +0300 Subject: [PATCH 30/35] Keep the periodic housekeeping loops alive across a faulty sweep All four long-lived maintenance loops -- NodeImpl._housekeeping_loop and implicit_gc_loop, _UDPTransportImpl._housekeeping_loop, and the CAN transport's _cleanup_loop -- caught only CancelledError. Any other exception killed the task, was never retrieved (nothing awaits these), and permanently disabled the sweep for the life of the node or transport, with no log above an eventual "exception was never retrieved" at GC time. These loops are the only bound on per-remote state growth and on stale RX session retirement, which do not happen on the traffic path, so silently losing one defeats the purpose of 6c20570. Each loop body now has a broad per-iteration boundary that logs and continues. The implicit-GC loop needed more than a catch: an already-expired topic yields a zero delay, so the loop does not await before retrying and a persistent fault would spin at full speed. Its recovery path backs off by one housekeeping period first. Regression tests inject a fault into the first sweep of the node and UDP loops and assert the loop recovers and sweeps on a later tick. Before the fix both reproduced the silent task death. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/_node.py | 17 ++++++++- src/pycyphal2/can/_transport.py | 7 +++- src/pycyphal2/udp.py | 13 +++++-- tests/test_housekeeping.py | 67 +++++++++++++++++++++++++++++++++ tests/test_udp.py | 31 +++++++++++++++ 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 1634d592a..912cde940 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -1548,7 +1548,14 @@ async def implicit_gc_loop(self) -> None: continue except asyncio.TimeoutError: pass - self._retire_one_expired_implicit_topic(time.monotonic()) + try: + self._retire_one_expired_implicit_topic(time.monotonic()) + except Exception: + # Same reasoning as _housekeeping_loop: a faulty retirement must not permanently + # disable implicit-topic GC. Back off before retrying -- an expired topic yields a + # zero delay, so retrying immediately would spin at full speed on a persistent fault. + _logger.exception("Implicit topic retirement failed; backing off") + await asyncio.sleep(HOUSEKEEPING_PERIOD) except asyncio.CancelledError: pass @@ -1558,7 +1565,13 @@ async def _housekeeping_loop(self) -> None: try: while not self._closed: await asyncio.sleep(HOUSEKEEPING_PERIOD) - self.sweep_stale_states(time.monotonic()) + try: + self.sweep_stale_states(time.monotonic()) + except Exception: + # This loop is the only bound on per-remote state growth, so it must outlive a faulty + # sweep. Letting the exception escape would kill the task silently (nothing retrieves + # its result) and leave the node unbounded for the rest of its life. + _logger.exception("Stale-state sweep failed; continuing") except asyncio.CancelledError: pass diff --git a/src/pycyphal2/can/_transport.py b/src/pycyphal2/can/_transport.py index 7ad52921f..3252b44a4 100644 --- a/src/pycyphal2/can/_transport.py +++ b/src/pycyphal2/can/_transport.py @@ -471,7 +471,12 @@ async def _cleanup_loop(self) -> None: try: while not self._closed: await asyncio.sleep(1.0) - Reassembler.cleanup_sessions(self._endpoints.values(), Instant.now().ns) + try: + Reassembler.cleanup_sessions(self._endpoints.values(), Instant.now().ns) + except Exception: + # Session retirement is not traffic-driven, so this loop must outlive a faulty sweep + # rather than dying silently and leaking sessions for the transport's lifetime. + _logger.exception("Session cleanup failed; continuing") except asyncio.CancelledError: raise diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 6bd5549d6..f5cf1fa67 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -990,10 +990,15 @@ async def _housekeeping_loop(self) -> None: try: while not self._closed: await asyncio.sleep(_HOUSEKEEPING_PERIOD) - now_ns = Instant.now().ns - self._unicast_reassembler.drop_stale_sessions(now_ns) - for reassembler in list(self._reassemblers.values()): - reassembler.drop_stale_sessions(now_ns) + try: + now_ns = Instant.now().ns + self._unicast_reassembler.drop_stale_sessions(now_ns) + for reassembler in list(self._reassemblers.values()): + reassembler.drop_stale_sessions(now_ns) + except Exception: + # Traffic-driven retirement alone does not reclaim a silent remote's session, so this + # loop must outlive a faulty sweep rather than dying with an unretrieved exception. + _logger.exception("Stale session sweep failed; continuing") except asyncio.CancelledError: pass diff --git a/tests/test_housekeeping.py b/tests/test_housekeeping.py index 637bfa3f8..ab1cfe443 100644 --- a/tests/test_housekeeping.py +++ b/tests/test_housekeeping.py @@ -72,3 +72,70 @@ async def test_housekeeping_loop_sweeps_without_new_traffic(monkeypatch: pytest. sub.close() node.close() + + +async def test_housekeeping_loop_survives_a_raising_sweep(monkeypatch: pytest.MonkeyPatch) -> None: + """The sweep is the only bound on per-remote state growth, so the loop must outlive a faulty sweep. + Catching only CancelledError let one stray exception kill the task silently -- nothing retrieves its + result -- leaving the node unbounded for the rest of its life.""" + monkeypatch.setattr(pycyphal2._node, "HOUSEKEEPING_PERIOD", 0.02) + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n1") + sub = node.subscribe("/t") + topic = node.topics_by_name["t"] + + calls = 0 + real_sweep = node.sweep_stale_states + + def flaky_sweep(now: float) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("simulated sweep fault") + real_sweep(now) + + monkeypatch.setattr(node, "sweep_stale_states", flaky_sweep) + + topic.dedup[10] = DedupState(tag_frontier=1, last_active=time.monotonic() - SESSION_LIFETIME - 5.0) + for _ in range(200): + if calls >= 2 and 10 not in topic.dedup: + break + await asyncio.sleep(0.01) + assert calls >= 2, "the loop died on the first faulty sweep" + assert 10 not in topic.dedup # It recovered and swept on a later tick. + assert not node._housekeeping_task.done() + + sub.close() + node.close() + + +async def test_implicit_gc_loop_survives_a_raising_retirement(monkeypatch: pytest.MonkeyPatch) -> None: + """A faulty retirement must not permanently disable implicit-topic GC. An expired topic yields a zero + delay, so the recovery path must also back off rather than spin at full speed.""" + monkeypatch.setattr(pycyphal2._node, "HOUSEKEEPING_PERIOD", 0.02) + monkeypatch.setattr(pycyphal2._node, "IMPLICIT_TOPIC_TIMEOUT", 0.02) + tr = MockTransport(node_id=1, network=MockNetwork()) + node = new_node(tr, home="n1") + + calls = 0 + real_retire = node._retire_one_expired_implicit_topic + + def flaky_retire(now: float) -> bool: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("simulated retirement fault") + return real_retire(now) + + monkeypatch.setattr(node, "_retire_one_expired_implicit_topic", flaky_retire) + + node.topic_ensure("gc-me", None) # Implicit: no publisher, no subscriber. + for _ in range(200): + if calls >= 2 and "gc-me" not in node.topics_by_name: + break + await asyncio.sleep(0.01) + assert calls >= 2, "the loop died on the first faulty retirement" + assert "gc-me" not in node.topics_by_name # It recovered and retired the topic. + assert not node._gc_task.done() + + node.close() diff --git a/tests/test_udp.py b/tests/test_udp.py index dab74a2e0..d31462a61 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -1468,6 +1468,37 @@ async def test_housekeeping_loop_retires_stale_sessions(monkeypatch: pytest.Monk t.close() +async def test_housekeeping_loop_survives_a_raising_sweep(monkeypatch: pytest.MonkeyPatch) -> None: + """Session retirement here is not traffic-driven, so the loop must outlive a faulty sweep. Catching + only CancelledError let one stray exception kill the task silently, leaking sessions for the rest of + the transport's life.""" + monkeypatch.setattr("pycyphal2.udp._HOUSEKEEPING_PERIOD", 0.02) + monkeypatch.setattr("pycyphal2.udp._RX_SESSION_LIFETIME_NS", 1) + t = UDPTransport.new_loopback() + assert isinstance(t, _UDPTransportImpl) + try: + calls = {"n": 0} + real_drop = _RxReassembler.drop_stale_sessions + + def flaky_drop(self, now_ns): # type: ignore[no-untyped-def] + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("simulated sweep fault") + real_drop(self, now_ns) + + with patch.object(_RxReassembler, "drop_stale_sessions", flaky_drop): + t._unicast_reassembler._sessions[100] = _RxSession(last_animated_ns=0) + for _ in range(200): + if calls["n"] >= 2 and not t._unicast_reassembler._sessions: + break + await asyncio.sleep(0.01) + assert calls["n"] >= 2, "the loop died on the first faulty sweep" + assert t._unicast_reassembler._sessions == {} + assert not t._housekeeping_task.done() + finally: + t.close() + + @pytest.mark.asyncio async def test_tx_socket_creation_failure_rolls_back_created_sockets() -> None: """A TX-socket creation failure mid-construction must roll back the interface sockets created before From 797096484783f2edd70691a25a5bc49843f5ffba Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:53:33 +0300 Subject: [PATCH 31/35] Fix media teardown: deregister before close, and don't misreport a clean close Three related teardown defects. python-can's receive() fed EVERY terminal sentinel through _fail(), including the plain ClosedError that close() installs, so an explicit shutdown was recorded as an interface failure and relabelled "receive failed". SocketCAN already got this right, and its comment claimed to "mirror the python-can and webserial backends" while python-can carried the bug. The failure is now recorded at its source: the RX thread hands the exception to _fail() via call_soon_threadsafe, so it propagates even with nobody parked in receive(), and receive() raises the sentinel verbatim. close() installs _closed_error(), which carries the underlying failure as __cause__ instead of discarding it. The bare `except Exception: pass` around that put_nowait now logs -- without the sentinel a parked reader hangs forever, which must never be silent. SocketCAN's close() claimed "the socket is closed last so the cancelled reader deregisters cleanly", but Task.cancel() is deferred to a later loop iteration while socket.close() takes effect immediately, so the selector callbacks were torn down against an already-closed fd -- and against an unrelated socket if that fd number had been recycled meanwhile. Both SocketCAN and the UDP transport now deregister explicitly while the descriptor is still open. A send already parked inside sock_sendto still unblocks on its own deadline, which is the caller's budget. The UDP constructor's create_task calls sat outside the TX-socket rollback guard, so a failure there would leak every socket and orphan the RX tasks already spawned; a half-built transport is never returned and so is never closed. The SocketCAN fake socket now owns a real fd rather than lacking fileno(), which is what let the close path go untested here in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/can/pythoncan.py | 25 ++++++++++------- src/pycyphal2/can/socketcan.py | 14 ++++++++++ src/pycyphal2/udp.py | 40 +++++++++++++++++++++++---- tests/can/test_pythoncan.py | 26 ++++++++++++++++-- tests/can/test_socketcan_unit.py | 46 ++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 16 deletions(-) diff --git a/src/pycyphal2/can/pythoncan.py b/src/pycyphal2/can/pythoncan.py index 87a06042b..3651935c1 100644 --- a/src/pycyphal2/can/pythoncan.py +++ b/src/pycyphal2/can/pythoncan.py @@ -112,12 +112,13 @@ def purge(self) -> None: async def receive(self) -> TimestampedFrame: self._raise_if_closed() - while True: - item = await self._rx_queue.get() - if isinstance(item, BaseException): - self._fail(item) - raise ClosedError(f"PythonCAN interface {self._name} receive failed") from item - return item + item = await self._rx_queue.get() + if isinstance(item, BaseException): + # Terminal sentinel: a receive-side failure already recorded itself via _fail(), which + # folds the cause into the sentinel. Raise it directly -- feeding it back through _fail() + # here would misrecord a clean close as an interface failure. Mirrors SocketCANInterface. + raise item + return item def close(self) -> None: with self._admin_lock: @@ -129,9 +130,12 @@ def close(self) -> None: self._tx_task.cancel() self._tx_task = None try: - self._rx_queue.put_nowait(ClosedError(f"PythonCAN interface {self._name} closed")) + # Carries self._failure as the cause when close() was reached via _fail(), so a parked + # reader learns why the interface died rather than just that it closed. + self._rx_queue.put_nowait(self._closed_error()) except Exception: - pass + # Never silent: without the sentinel a parked reader hangs forever. + _logger.exception("PythonCAN could not install the terminal RX sentinel on %s", self._name) try: self._bus.shutdown() except Exception as ex: @@ -197,7 +201,10 @@ def _rx_thread_func(self) -> None: except Exception as ex: if not self._closed: try: - self._loop.call_soon_threadsafe(self._rx_queue.put_nowait, ex) + # Record the failure at its source rather than at the reader: _fail() stores + # it and closes, which installs the terminal sentinel carrying it as cause. + # This also propagates the failure when nobody is parked in receive(). + self._loop.call_soon_threadsafe(self._fail, ex) except RuntimeError: pass return diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index cc44f98c8..3bbaf0c52 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -138,6 +138,20 @@ def close(self) -> None: self._tx_task.cancel() self._tx_task = None self._tx.abort_all(self._closed_error) + # Deregister explicitly BEFORE closing the fd. Task.cancel() above is deferred to a later loop + # iteration, whereas socket.close() takes effect now, so the selector callbacks installed by + # sock_recv/sock_sendto would otherwise be torn down against an already-closed fd -- and if that + # number were meanwhile reused by another socket, the deferred removal would deregister ITS + # callbacks instead. Both removals are no-ops when nothing is registered. + try: + loop = asyncio.get_running_loop() + except RuntimeError: # Closed outside a running loop; nothing can be registered either. + pass + else: + fd = self._sock.fileno() + if fd >= 0: + loop.remove_reader(fd) + loop.remove_writer(fd) self._sock.close() def __repr__(self) -> str: diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index f5cf1fa67..1c8076af1 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -703,11 +703,20 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus self._unicast_rx_tasks: list[asyncio.Task[None]] = [] self._mcast_rx_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} - for i, sock in enumerate(self._tx_socks): - task = self._loop.create_task(self._unicast_rx_loop(sock, i)) - self._unicast_rx_tasks.append(task) - - self._housekeeping_task = self._loop.create_task(self._housekeeping_loop()) + # Task creation is the remaining fallible step; a failure here would otherwise leak every TX + # socket and orphan the RX tasks already spawned, since a half-built transport is never returned + # to the caller and so is never close()d. + try: + for i, sock in enumerate(self._tx_socks): + task = self._loop.create_task(self._unicast_rx_loop(sock, i)) + self._unicast_rx_tasks.append(task) + self._housekeeping_task = self._loop.create_task(self._housekeeping_loop()) + except BaseException: + for task in self._unicast_rx_tasks: + task.cancel() + for sock in self._tx_socks: + sock.close() + raise _logger.info( "UDPTransport initialized: uid=0x%016x, interfaces=%s, modulus=%d", @@ -829,8 +838,23 @@ def remove_subject_listener(self, subject_id: int, handler: Callable[[TransportA task.cancel() sock = self._mcast_socks.pop(key, None) if sock is not None: + self._deregister_socket(sock) # Before close(); see the note in close(). sock.close() + def _deregister_socket(self, sock: socket.socket) -> None: + """ + Drop any selector callbacks for this socket while its descriptor is still open. + + Cancelling the task that owns a ``sock_recv``/``sock_sendto`` only schedules the teardown, so + without this the removal would run after the descriptor is closed -- and would hit an unrelated + socket if the number had been recycled by then. Both removals no-op when nothing is registered. + """ + fd = sock.fileno() + if fd < 0: + return + self._loop.remove_reader(fd) + self._loop.remove_writer(fd) + def remove_subject_writer(self, subject_id: int, writer: _UDPSubjectWriter) -> None: if self._subject_writers.get(subject_id) is writer: self._subject_writers.pop(subject_id, None) @@ -931,6 +955,12 @@ def close(self) -> None: for task in self._mcast_rx_tasks.values(): task.cancel() self._mcast_rx_tasks.clear() + # Deregister before closing: the cancellations above land on a later loop iteration while + # sock.close() takes effect now, so the selector callbacks would otherwise be torn down against + # a closed fd -- or against whatever socket has since inherited that fd number. A send already + # parked inside sock_sendto still unblocks on its own deadline, which is the caller's budget. + for sock in [*self._tx_socks, *self._mcast_socks.values()]: + self._deregister_socket(sock) for sock in self._tx_socks: sock.close() for sock in self._mcast_socks.values(): diff --git a/tests/can/test_pythoncan.py b/tests/can/test_pythoncan.py index 6bc59e4fe..e28324843 100644 --- a/tests/can/test_pythoncan.py +++ b/tests/can/test_pythoncan.py @@ -1297,15 +1297,37 @@ def flaky_send(msg, timeout=None): async def test_unit_rx_bus_error_propagates() -> None: + """An RX failure is recorded at its source and surfaces to the reader with the cause attached.""" mock_bus = MagicMock(spec=_can.BusABC) - mock_bus.recv.side_effect = OSError("hardware gone") + err = OSError("hardware gone") + mock_bus.recv.side_effect = err mock_bus.channel_info = "mock:err" itf = PythonCANInterface(mock_bus) - with pytest.raises(ClosedError, match="receive failed"): + with pytest.raises(ClosedError) as caught: await asyncio.wait_for(itf.receive(), timeout=2.0) + assert itf._failure is err # Recorded by _fail() in the RX thread's handoff, not by receive(). + assert caught.value.__cause__ is err # The sentinel carries the underlying cause. itf.close() +async def test_unit_clean_close_is_not_recorded_as_a_failure() -> None: + """close() must not be misrecorded as an interface failure. receive() used to feed EVERY sentinel -- + including the plain ClosedError installed by an explicit close -- back through _fail(), so a clean + shutdown ended up reported as 'receive failed'. SocketCANInterface already got this right.""" + mock_bus = MagicMock(spec=_can.BusABC) + mock_bus.recv.return_value = None # Idle bus: the reader parks on the queue. + mock_bus.channel_info = "mock:cleanclose" + itf = PythonCANInterface(mock_bus) + receiver = asyncio.create_task(itf.receive()) + await asyncio.sleep(0.05) + assert not receiver.done() # Parked. + + itf.close() + with pytest.raises(ClosedError): + await asyncio.wait_for(receiver, timeout=2.0) + assert itf._failure is None # A clean close leaves no failure recorded. + + async def test_unit_multiple_close_with_failure() -> None: mock_bus = MagicMock(spec=_can.BusABC) mock_bus.recv.side_effect = _can.CanError("fail") diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index 9ff2aec34..872eea549 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -2,6 +2,7 @@ import asyncio import errno +import os from pathlib import Path import sys import types @@ -19,6 +20,9 @@ class _FakeRawSocket: def __init__(self) -> None: self.calls: list[tuple[object, ...]] = [] + # A real fd, so close() can deregister it from the selector exactly as it would a real CAN + # socket. Fabricating a number here would risk deregistering an unrelated fd in this process. + self._rfd, self._wfd = os.pipe() def setblocking(self, enabled: bool) -> None: self.calls.append(("setblocking", enabled)) @@ -29,8 +33,17 @@ def setsockopt(self, level: int, option: int, value: object) -> None: def bind(self, address: tuple[str]) -> None: self.calls.append(("bind", address)) + def fileno(self) -> int: + return self._rfd + def close(self) -> None: self.calls.append(("close",)) + for fd in (self._rfd, self._wfd): + try: + os.close(fd) + except OSError: + pass + self._rfd = self._wfd = -1 class _TaskStub: @@ -50,6 +63,17 @@ def __init__(self, *, recv: list[object] | None = None, send: list[object] | Non self.send = list(send or []) self.sent_frames: list[bytes] = [] self.created_tasks: list[object] = [] + # close() deregisters the fd from the selector before closing it; record the order so a test can + # assert the deregistration precedes the close rather than racing a deferred task cancellation. + self.deregistered: list[tuple[str, int]] = [] + + def remove_reader(self, fd: int) -> bool: + self.deregistered.append(("reader", fd)) + return False + + def remove_writer(self, fd: int) -> bool: + self.deregistered.append(("writer", fd)) + return False def create_task(self, coro: object) -> _TaskStub: if hasattr(coro, "close"): @@ -318,6 +342,28 @@ async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> N assert iface._failure is None # A clean close is not an interface failure. +async def test_close_deregisters_the_fd_before_closing_it(monkeypatch: pytest.MonkeyPatch) -> None: + """Task.cancel() is deferred to a later loop iteration but socket.close() takes effect immediately, + so relying on the cancelled reader to deregister itself tears down selector callbacks against an + already-closed fd -- and against a DIFFERENT socket if that fd number has been reused meanwhile. + close() must therefore deregister explicitly, before closing.""" + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + loop = _FakeLoop() + monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: loop) + + sock = iface._sock + fd = sock.fileno() + assert fd >= 0 + iface.close() + + assert loop.deregistered == [("reader", fd), ("writer", fd)] + # Ordering is the whole point: the fd must still be open when it is deregistered. + assert ("close",) in sock.calls + assert loop.deregistered, "deregistration must not be left to the deferred task cancellation" + + async def test_fail_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: """A non-transient TX failure (_fail -> close) must likewise unblock a parked reader.""" fake_socket, _ = _make_socket_module() From 51c01ba3ec4cc0b5c5583202e9b20effd72fa65c Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:54:40 +0300 Subject: [PATCH 32/35] Record why three unbounded-looking structures are correct as written An audit of this branch flagged three sites as unbounded per-remote state or as incomplete rollback. Checking each against the pinned reference submodule showed all three already match it, so the "fixes" would have been divergences. Documenting them so the next reader does not repeat the analysis -- or act on it. - Association.last_seen is written and never read. That is deliberate: cy.c says "Not used for eviction, only for diagnostics and possibly API exposure". Eviction is driven solely by `slack`. It is not a half-finished timeout. - topic.associations is unbounded and keyed by a spoofable remote-ID, but the reference is too, and carries an explicit TODO for exactly this DoS ("there should be a limit ... ~500 might be a reasonable default"). Bounding it here would diverge on reliable-delivery accounting, so the fix belongs upstream first. - ResponseStreamImpl._reliable_remote_by_id is never pruned during the stream's life, matching request_future_t.remote_by_id: "States are never removed assuming that futures are short-lived and/or the responder set is mostly constant". Its bound is the stream's lifetime. dispose() now clears it along with the rest of teardown; close() still retains it deliberately, to re-ack late duplicates via the zombie timer. Also notes why the writer acquisition in topic_allocate's displacement branch cannot fail midway: the collider still holds the writer for that subject-ID, so the refcounted registry returns the existing entry without touching the transport. The reference achieves the same by moving the writer pointer outright. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/_node.py | 16 ++++++++++++++++ src/pycyphal2/_publisher.py | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 912cde940..55758af68 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -258,9 +258,14 @@ class Association: """Tracks a known remote subscriber for reliable delivery ACK tracking.""" remote_id: int + # Diagnostics only -- deliberately NOT an eviction input, matching the reference association_t + # ("Not used for eviction, only for diagnostics and possibly API exposure", cy.c). Eviction is + # driven solely by `slack`; do not "fix" this field into a timeout. last_seen: float slack: int = 0 seqno_witness: int = 0 + # An association cannot be dropped while a publish tracker still references it (reference parity: + # "The association cannot be removed unless zero to avoid dangly pointers", cy.c). pending_count: int = 0 @@ -409,6 +414,11 @@ def __init__(self, node: NodeImpl, name: str, evictions: int, now: float) -> Non self.sub_listener: Closable | None = None self.couplings: list[Coupling] = [] self.is_implicit = True + # Unbounded by design, matching the reference, which carries an explicit TODO for the same gap + # ("there should be a limit on the number of associations to prevent DoS ... ~500 might be a + # reasonable default", cy.c). Entries are created only by POSITIVE acks and retired via `slack`. + # Bounding this ahead of the reference would diverge on reliable-delivery accounting, so the fix + # belongs upstream first. self.associations: dict[int, Association] = {} self.dedup: dict[int, DedupState] = {} self.publish_futures: dict[int, PublishTracker] = {} @@ -817,6 +827,12 @@ def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> No del self.topics_by_subject_id[new_sid] self.topics_by_subject_id[new_sid] = t if collider.pub_writer is not None: + # Winner acquires BEFORE the loser releases below, so the shared handle survives the + # handover on its refcount -- the reference does the same thing by moving the writer + # pointer outright ("the winner acquires first, then the loser releases"). Since the + # collider still holds the writer for new_sid, this hits the existing registry entry + # and is a pure refcount bump: no transport call, so the cascade cannot fail midway + # and leave a displaced topic unreachable from topics_by_subject_id. t.pub_writer = self.acquire_subject_writer(t, new_sid) t.sync_listener() self.schedule_gossip_urgent(t) diff --git a/src/pycyphal2/_publisher.py b/src/pycyphal2/_publisher.py index 148025c45..b5390379b 100644 --- a/src/pycyphal2/_publisher.py +++ b/src/pycyphal2/_publisher.py @@ -320,6 +320,10 @@ def __init__( self._response_timeout = response_timeout self.queue: asyncio.Queue[Response | BaseException] = asyncio.Queue() self.closed = False + # Never pruned during the stream's life, matching the reference request_future_t.remote_by_id + # ("States are never removed assuming that futures are short-lived and/or the responder set is + # mostly constant", cy.c). The bound is the stream's own lifetime. close() deliberately retains + # it to re-ack late duplicates via the zombie timer; dispose() drops it with the rest of teardown. self._reliable_remote_by_id: dict[int, ResponseRemoteState] = {} self._publish_task: asyncio.Task[None] | None = None self._cleanup_handle: asyncio.TimerHandle | None = None @@ -431,5 +435,6 @@ def dispose(self) -> None: self._publish_task.cancel() self._publish_task = None self._remove_from_topic() # Cancels the cleanup timer and drops from request_futures. + self._reliable_remote_by_id.clear() # No zombie is retained here, so the per-remote state goes too. if was_open: self.queue.put_nowait(StopAsyncIteration()) From ba5316a56993aa43ff359aacfd038a27d8399f3a Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sat, 18 Jul 2026 23:54:40 +0300 Subject: [PATCH 33/35] Run ruff in CI, test on Python 3.14, and tidy loose ends CI ran test, mypy and format but never lint, so ruff had never gated this branch even though bare `nox` -- the stated acceptance criterion -- includes it. Added a lint job alongside format. noxfile PYTHONS stopped at 3.13 while 3.14 is the current stable, which CLAUDE.md says is supported. That also made the whole suite unrunnable on a machine with only 3.14 installed. Added to PYTHONS and the CI matrix; the suite passes on it unchanged. Smaller items: - Node.new now documents the ValueError it raises for a transport whose subject_id_modulus fails the reference predicate; Transport already pointed at Node.new as the thing that raises it. UDPTransport.new notes that its own check is only the transport-level range, so an otherwise valid modulus can still be rejected a layer up. - Dropped CAN_STD_ID_MASK, dead since standard-ID SLCAN frames started being rejected. - The SLCAN drop message called 'R' a standard-ID frame; it is an extended-ID RTR frame. Both are unusable here, but for different reasons, and the log said the wrong one. - Node.close() now clears gossip_shard_writers/listeners after closing them, matching the adjacent shared_subject_* handling. - The two OrderedDict LRUs in udp.py use mirror-image conventions -- _sessions keeps the newest FIRST because its retirement scans want the oldest at a fixed end, _remote_endpoints keeps the newest LAST. Both are correct; each now says so and points at the other, since silently inverting one would flip an eviction policy. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- .github/workflows/test.yml | 14 +++++++++++++- noxfile.py | 2 +- src/pycyphal2/_api.py | 4 ++++ src/pycyphal2/_node.py | 2 ++ src/pycyphal2/can/_interface.py | 1 - src/pycyphal2/can/_media_slcan.py | 7 ++++--- src/pycyphal2/can/socketcan.py | 5 +++-- src/pycyphal2/udp.py | 14 +++++++++++++- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef11d1a4c..dd6b7b4f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python: ["3.11", "3.12", "3.13"] + python: ["3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -61,6 +61,18 @@ jobs: - run: pip install nox - run: nox -s mypy + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_OLDEST }} + - run: pip install nox + - run: nox -s lint + format: runs-on: ubuntu-latest steps: diff --git a/noxfile.py b/noxfile.py index 18003ec7e..cc5e500fd 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,7 +7,7 @@ nox.options.sessions = ["test", "mypy", "lint", "format"] -PYTHONS = ["3.11", "3.12", "3.13"] +PYTHONS = ["3.11", "3.12", "3.13", "3.14"] @nox.session(python=False, default=False) diff --git a/src/pycyphal2/_api.py b/src/pycyphal2/_api.py index 602c60a99..864579244 100644 --- a/src/pycyphal2/_api.py +++ b/src/pycyphal2/_api.py @@ -553,6 +553,10 @@ def new(transport: Transport, home: str = "", namespace: str = "") -> Node: If the namespace is not set, it is read from the CYPHAL_NAMESPACE environment variable, which is the main intended use case. Direct assignment might be considered an anti-pattern in most cases. + + Raises :class:`ValueError` if ``transport.subject_id_modulus`` does not satisfy the reference + predicate (at least 57203, prime, and congruent to 3 modulo 4). The stock transports always do; + this only concerns custom ones. See :meth:`Transport.subject_id_modulus`. """ from ._node import NodeImpl diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index 55758af68..fd3e6b931 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -1654,8 +1654,10 @@ def close(self) -> None: self.shared_subject_listeners.clear() for w in self.gossip_shard_writers.values(): w.close() + self.gossip_shard_writers.clear() for gossip_listener in self.gossip_shard_listeners.values(): gossip_listener.close() + self.gossip_shard_listeners.clear() self._monitor_callbacks.clear() self._implicit_topics.clear() self.transport.close() diff --git a/src/pycyphal2/can/_interface.py b/src/pycyphal2/can/_interface.py index a4f461dee..d0e737d75 100644 --- a/src/pycyphal2/can/_interface.py +++ b/src/pycyphal2/can/_interface.py @@ -9,7 +9,6 @@ from .. import Closable, ClosedError, Instant CAN_EXT_ID_MASK = (1 << 29) - 1 -CAN_STD_ID_MASK = (1 << 11) - 1 def closed_error(interface: str, failure: BaseException | None) -> ClosedError: diff --git a/src/pycyphal2/can/_media_slcan.py b/src/pycyphal2/can/_media_slcan.py index 7099f1da6..6f41b5111 100644 --- a/src/pycyphal2/can/_media_slcan.py +++ b/src/pycyphal2/can/_media_slcan.py @@ -129,9 +129,10 @@ def _parse_line(line: bytes) -> Frame | None: if command == b"D": return _parse_data_frame(line, id_length=8, max_payload_length=64) if command in (b"t", b"r", b"R"): - # Standard-ID (11-bit) frames: the Interface contract is extended-only and Frame carries no IDE - # discriminator, so forwarding a 't' data frame would alias an extended frame with a small ID. - _logger.debug("SLCAN drop standard-id frame cmd=%r", command) + # 't'/'r' are standard-ID (11-bit) frames, 'R' is an extended-ID RTR frame; none are usable here. + # The Interface contract is extended-data-only and Frame carries no IDE or RTR discriminator, so + # forwarding a 't' would alias an extended frame with a small ID, and RTR has no Cyphal meaning. + _logger.debug("SLCAN drop unusable frame type cmd=%r", command) return None _logger.debug("SLCAN drop unknown line=%r", line) return None diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index 3bbaf0c52..d9b413493 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -129,8 +129,9 @@ def close(self) -> None: if self._rx_task is not None and self._rx_task is not asyncio.current_task(): self._rx_task.cancel() self._rx_task = None - # Drop any already-queued frames and install a single terminal sentinel, so a reader parked on - # the queue wakes promptly; the socket is closed last so the cancelled reader deregisters cleanly. + # Drop any already-queued frames and install a single terminal sentinel so a reader parked on the + # queue wakes promptly. A frame decoded before the cancellation lands may still be appended behind + # the sentinel; that is harmless because _raise_if_closed() short-circuits every later receive(). while not self._rx_queue.empty(): self._rx_queue.get_nowait() self._rx_queue.put_nowait(self._closed_error()) diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 1c8076af1..5e72b38b7 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -349,6 +349,11 @@ def get_slot(self, timestamp_ns: int, header: _FrameHeader) -> tuple[int, _Trans class _RxReassembler: def __init__(self) -> None: + # LRU ordered MOST-recently-active FIRST, so the oldest session is the LAST item and is reached + # with next(reversed(...)). Note this is the mirror image of _UDPTransportImpl._remote_endpoints, + # which orders most-recent LAST; the difference is deliberate -- the stale-retirement scans here + # want the oldest, so keeping it at a fixed end avoids re-sorting. Flip one and you must flip + # every popitem()/reversed() that reads it. self._sessions: OrderedDict[int, _RxSession] = OrderedDict() def accept( @@ -604,6 +609,11 @@ def new( detected. You can also use ``UDPTransport.list_interfaces()`` for a semi-automatic approach. The UID is a globally unique 64-bit identifier of the local node. If not given, one will be generated randomly. + + The default ``subject_id_modulus`` is always valid. Overriding it only makes sense for a + deliberately reduced subject-ID space, and the value must satisfy the reference predicate -- + at least 57203, prime, and congruent to 3 modulo 4 -- because :meth:`pycyphal2.Node.new` rejects + anything else with ``ValueError``. This constructor only enforces the transport-level range. """ if not interfaces: ifaces = UDPTransport.list_interfaces() @@ -1036,7 +1046,9 @@ def _learn_remote_endpoint(self, remote_id: int, iface_idx: int, src_ip: str, sr key = (remote_id, iface_idx) existing = self._remote_endpoints.get(key) self._remote_endpoints[key] = (src_ip, src_port) - self._remote_endpoints.move_to_end(key) # Mark most-recently-seen for LRU eviction. + # Ordered most-recently-seen LAST, so eviction pops the FRONT. This is the mirror image of + # _RxReassembler._sessions -- see the note there before changing either. + self._remote_endpoints.move_to_end(key) if len(self._remote_endpoints) > _REMOTE_ENDPOINT_CAPACITY: evicted, _ = self._remote_endpoints.popitem(last=False) _logger.debug("Remote endpoint cache full, evicted rid=%016x iface=%d", evicted[0], evicted[1]) From aca2eec561168a80844f3ab4fb1a099372ead7b9 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sun, 19 Jul 2026 00:00:37 +0300 Subject: [PATCH 34/35] Tolerate event loops without reader/writer registration The teardown fix in 7970964 called loop.remove_reader/remove_writer unconditionally before closing a socket. Windows' ProactorEventLoop drives sockets through overlapped I/O rather than selector callbacks and implements neither method, so both raise NotImplementedError -- which took out every Windows job across all four Python versions while Linux and macOS stayed green. There is nothing to deregister on such a loop, so both call sites now treat NotImplementedError as "no registration exists". SocketCAN is Linux-only in production, but its unit tests exercise close() against whatever loop the host provides, which is how it failed there too. Pinned by a test that closes an interface against a loop raising NotImplementedError from both methods and asserts close() still completes and still closes the socket. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/can/socketcan.py | 9 +++++++-- src/pycyphal2/udp.py | 9 +++++++-- tests/can/test_socketcan_unit.py | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index d9b413493..eae206f08 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -151,8 +151,13 @@ def close(self) -> None: else: fd = self._sock.fileno() if fd >= 0: - loop.remove_reader(fd) - loop.remove_writer(fd) + try: + loop.remove_reader(fd) + loop.remove_writer(fd) + except NotImplementedError: + # Windows' ProactorEventLoop implements neither call. SocketCAN itself is Linux-only, + # but the unit tests exercise close() against whatever loop the host provides. + pass self._sock.close() def __repr__(self) -> str: diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 5e72b38b7..267565d9b 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -862,8 +862,13 @@ def _deregister_socket(self, sock: socket.socket) -> None: fd = sock.fileno() if fd < 0: return - self._loop.remove_reader(fd) - self._loop.remove_writer(fd) + try: + self._loop.remove_reader(fd) + self._loop.remove_writer(fd) + except NotImplementedError: + # Windows' ProactorEventLoop drives sockets through overlapped I/O instead of selector + # callbacks, so it implements neither call and there is no registration to clean up. + pass def remove_subject_writer(self, subject_id: int, writer: _UDPSubjectWriter) -> None: if self._subject_writers.get(subject_id) is writer: diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index 872eea549..1bfdc9c0a 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -364,6 +364,28 @@ async def test_close_deregisters_the_fd_before_closing_it(monkeypatch: pytest.Mo assert loop.deregistered, "deregistration must not be left to the deferred task cancellation" +async def test_close_tolerates_a_loop_without_reader_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows' ProactorEventLoop drives sockets through overlapped I/O and raises NotImplementedError + from remove_reader/remove_writer. close() must treat that as "nothing to deregister" rather than + propagating it -- an unguarded call broke every Windows job while Linux and macOS stayed green.""" + fake_socket, _ = _make_socket_module() + module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) + iface = _make_iface(module) + + class _ProactorishLoop(_FakeLoop): + def remove_reader(self, fd: int) -> bool: + raise NotImplementedError + + def remove_writer(self, fd: int) -> bool: + raise NotImplementedError + + monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: _ProactorishLoop()) + + iface.close() # Must not raise. + assert iface._closed + assert ("close",) in iface._sock.calls # The socket was still closed. + + async def test_fail_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: """A non-transient TX failure (_fail -> close) must likewise unblock a parked reader.""" fake_socket, _ = _make_socket_module() From 6e3b369ce2fc13d1f0b4af3196021c8710834bd8 Mon Sep 17 00:00:00 2001 From: Pavel Kirienko Date: Sun, 19 Jul 2026 00:11:05 +0300 Subject: [PATCH 35/35] Downsize the comments and docs added by this branch The correctness work on this branch explained itself at length -- multi-line blocks that restated the adjacent code, re-derived the same conclusion across successive sentences, or narrated control flow. Cut the narration and kept the parts a reader cannot reconstruct from the code. 25 files touched; the comment lines this branch adds over master drop by about a quarter, and most surviving blocks are half their previous length. Comments and docstrings only -- no code changed. Kept deliberately, since none of it is recoverable from the source: every C reference citation and quoted reference text (cy.c, canard_poll, cavl2_predecessor, wkv_has_substitution_tokens, udp_wrapper.c, request_future_t, association_t), all ten REFERENCE PARITY markers, the kernel and protocol facts (linux/can.h flag values and the getattr() fallback hazard, IP_MULTICAST_ALL semantics, ProactorEventLoop lacking remove_reader, one-writer-per-fd in the selector loop), the correctness traps (BaseException vs Exception in gather results, BaseExceptionGroup nesting, the mod-2**64 history seed, the two mirror-image LRU orderings), and the defect provenance in each regression test's docstring. Verified mechanically rather than by eye: each file's syntax tree, with docstrings stripped and empty bodies normalized, is identical before and after, which is only possible if nothing but comments changed. The script was checked against a known-code-change control first. Black, ruff, mypy and the full suite (750 passed) all still pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015RGRSKsWKELSLLPNr3FVpH --- src/pycyphal2/_api.py | 8 +- src/pycyphal2/_node.py | 115 ++++++++++-------------- src/pycyphal2/_publisher.py | 28 +++--- src/pycyphal2/_transport.py | 4 +- src/pycyphal2/can/_media_slcan.py | 5 +- src/pycyphal2/can/_reassembly.py | 7 +- src/pycyphal2/can/_transport.py | 7 +- src/pycyphal2/can/pythoncan.py | 14 ++- src/pycyphal2/can/socketcan.py | 45 ++++------ src/pycyphal2/udp.py | 128 +++++++++++---------------- tests/can/test_media_slcan.py | 4 +- tests/can/test_pythoncan.py | 14 ++- tests/can/test_reassembly.py | 7 +- tests/can/test_socketcan_unit.py | 42 ++++----- tests/can/test_transport_internal.py | 3 +- tests/mock_transport.py | 5 +- tests/test_close_streams.py | 23 +++-- tests/test_gossip.py | 2 +- tests/test_housekeeping.py | 27 +++--- tests/test_names.py | 9 +- tests/test_parity.py | 4 +- tests/test_reliable.py | 2 +- tests/test_topic.py | 18 ++-- tests/test_transactional_setup.py | 25 +++--- tests/test_udp.py | 74 +++++++--------- 25 files changed, 270 insertions(+), 350 deletions(-) diff --git a/src/pycyphal2/_api.py b/src/pycyphal2/_api.py index 864579244..9bd977c2c 100644 --- a/src/pycyphal2/_api.py +++ b/src/pycyphal2/_api.py @@ -282,8 +282,8 @@ async def request( from any subscriber that chooses to answer. ``response_timeout`` is the maximum idle gap (liveness timeout) between accepted responses, - so it applies both to one-off RPC and to streaming. It must be non-negative; positive infinity - (``inf``) disables the liveness timeout, and ``NaN`` or a negative value raises :class:`ValueError`. + so it applies both to one-off RPC and to streaming. Must be non-negative; ``inf`` disables the + liveness timeout; ``NaN`` or a negative value raises :class:`ValueError`. """ raise NotImplementedError @@ -555,8 +555,8 @@ def new(transport: Transport, home: str = "", namespace: str = "") -> Node: which is the main intended use case. Direct assignment might be considered an anti-pattern in most cases. Raises :class:`ValueError` if ``transport.subject_id_modulus`` does not satisfy the reference - predicate (at least 57203, prime, and congruent to 3 modulo 4). The stock transports always do; - this only concerns custom ones. See :meth:`Transport.subject_id_modulus`. + predicate (at least 57203, prime, congruent to 3 modulo 4); only custom transports are affected. + See :meth:`Transport.subject_id_modulus`. """ from ._node import NodeImpl diff --git a/src/pycyphal2/_node.py b/src/pycyphal2/_node.py index fd3e6b931..ed73f7ba4 100644 --- a/src/pycyphal2/_node.py +++ b/src/pycyphal2/_node.py @@ -48,7 +48,7 @@ ACK_TX_TIMEOUT = 1.0 SESSION_LIFETIME = 60.0 IMPLICIT_TOPIC_TIMEOUT = 600.0 -HOUSEKEEPING_PERIOD = 1.0 # Aggregate stale-state sweep cadence (dedup/reordering), well inside SESSION_LIFETIME. +HOUSEKEEPING_PERIOD = 1.0 # Stale-state sweep cadence; well inside SESSION_LIFETIME. REORDERING_CAPACITY = 16 ASSOC_SLACK_LIMIT = 2 DEDUP_HISTORY = 512 @@ -124,18 +124,15 @@ def _name_is_homeful(name: str) -> bool: def _name_has_pattern_tokens(name: str) -> bool: - """A name is a pattern iff some whole '/'-segment is a substitution token ('*' or '>'). Tokens embedded - within a longer segment (e.g. 'ab*cd') are literal characters, as in the reference classifier - (wkv_has_substitution_tokens), so such names are legal verbatim topics.""" + """A name is a pattern iff a whole '/'-segment is a substitution token ('*' or '>'). Tokens inside a longer + segment (e.g. 'ab*cd') are literal, as in the reference classifier (wkv_has_substitution_tokens).""" return any(seg in ("*", ">") for seg in name.split("/")) def _is_valid_wire_name(name: str) -> bool: - """True if `name` is a well-formed *resolved* wire topic name, as required of names received in gossip: - nonempty, length-bounded, printable ASCII (33-126), already normalized (no leading/trailing/duplicate - '/'), verbatim (no whole-segment '*'/'>' pattern tokens), not homeful ('~'/'~/...'), and pin-free - (no '#' suffix). The last two are stripped/expanded by resolve_name before a name reaches the wire, - so their presence means the gossip is unresolved/non-canonical and must not create a local topic.""" + """True if `name` is a well-formed *resolved* wire topic name, as required of names received in gossip. + Homeful ('~'/'~/...') and pinned ('#') forms are stripped/expanded by resolve_name before a name + reaches the wire, so their presence means the gossip is unresolved and must not create a local topic.""" return ( bool(name) and len(name) <= TOPIC_NAME_MAX @@ -231,9 +228,8 @@ def is_valid_subject_id_modulus(modulus: int) -> bool: The quadratic probe (hash + evictions²) mod m covers the residue space only under these conditions; a degenerate modulus would make the synchronous displacement loop in topic_allocate effectively non-terminating, hard-blocking the event loop.""" - # The reference modulus is a uint32, so anything above that is invalid by definition; the bound also - # keeps the trial division below ~65536 iterations, so an untrusted custom-transport value cannot make - # the primality test hang. + # The reference modulus is a uint32; that bound also caps the trial division at ~65536 iterations, so an + # untrusted custom-transport value cannot hang the primality test. if modulus < SUBJECT_ID_MODULUS_16bit or modulus > 0xFFFFFFFF or modulus % 4 != 3: return False d = 3 @@ -247,9 +243,8 @@ def is_valid_subject_id_modulus(modulus: int) -> bool: def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int: if evictions >= EVICTIONS_PINNED_MIN: return 0xFFFFFFFF - evictions - # The sum wraps mod 2**64 before the reduction, matching the reference uint64 arithmetic bit-for-bit; - # without the wrap, a large hash plus a near-boundary eviction count (an untrusted gossip field) would - # place the same topic on different subject-IDs in Python and C. + # The sum wraps mod 2**64 before the reduction, matching the reference uint64 arithmetic; without the + # wrap, a large hash plus a near-boundary eviction count would diverge from C on the subject-ID. return SUBJECT_ID_PINNED_MAX + 1 + (((topic_hash + evictions * evictions) & U64_MASK) % modulus) @@ -258,9 +253,8 @@ class Association: """Tracks a known remote subscriber for reliable delivery ACK tracking.""" remote_id: int - # Diagnostics only -- deliberately NOT an eviction input, matching the reference association_t - # ("Not used for eviction, only for diagnostics and possibly API exposure", cy.c). Eviction is - # driven solely by `slack`; do not "fix" this field into a timeout. + # Diagnostics only, deliberately NOT an eviction input, matching the reference association_t ("Not used + # for eviction, only for diagnostics and possibly API exposure", cy.c). Eviction is driven by `slack`. last_seen: float slack: int = 0 seqno_witness: int = 0 @@ -416,9 +410,8 @@ def __init__(self, node: NodeImpl, name: str, evictions: int, now: float) -> Non self.is_implicit = True # Unbounded by design, matching the reference, which carries an explicit TODO for the same gap # ("there should be a limit on the number of associations to prevent DoS ... ~500 might be a - # reasonable default", cy.c). Entries are created only by POSITIVE acks and retired via `slack`. - # Bounding this ahead of the reference would diverge on reliable-delivery accounting, so the fix - # belongs upstream first. + # reasonable default", cy.c). Bounding it ahead of the reference would diverge on reliable-delivery + # accounting, so the fix belongs upstream first. self.associations: dict[int, Association] = {} self.dedup: dict[int, DedupState] = {} self.publish_futures: dict[int, PublishTracker] = {} @@ -483,9 +476,8 @@ def ensure_writer(self) -> SubjectWriter: def ensure_listener(self) -> None: if self.sub_listener is None and self.couplings: sid = self.subject_id(self._node.transport.subject_id_modulus) - # Repair model (cy.c topic_sync_subject_reader): a listener acquisition failure is logged and - # left for the next opportunity (topic sync, periodic gossip) to retry, rather than raising and - # tearing down a partly-built subscription. Keeps sync_listener()/sync_implicit() infallible. + # Repair model (cy.c topic_sync_subject_reader): an acquisition failure is left for the next + # opportunity (topic sync, periodic gossip) to retry, which keeps sync_listener() infallible. try: self.sub_listener = self._node.acquire_subject_listener(self, sid) except Exception as ex: @@ -512,10 +504,9 @@ def release_transport_handles(self) -> None: def compute_is_implicit(self) -> bool: has_verbatim_sub = any(not c.root.is_pattern for c in self.couplings) - # An open response stream or an in-flight reliable publish keeps the topic explicit, so implicit - # GC cannot destroy a topic that still has outstanding request/publish state (closed "zombie" - # streams awaiting their dedup-cleanup timer do not count). Python must gate on these because, - # unlike the C API, it allows a stream/publish to outlive the publisher that issued it. + # An open response stream or in-flight reliable publish keeps the topic explicit so implicit GC + # cannot destroy it (closed "zombie" streams awaiting their dedup-cleanup timer do not count). + # Needed because, unlike the C API, Python lets a stream/publish outlive its publisher. has_open_stream = any(not s.closed for s in self.request_futures.values()) has_pending_publish = bool(self.publish_futures) return self.pub_count == 0 and not has_verbatim_sub and not has_open_stream and not has_pending_publish @@ -525,9 +516,8 @@ def sync_implicit(self) -> None: self._node.sync_topic_lifecycle(self) def drop_stale_dedup(self, now: float) -> None: - """Aggregate sweep of per-remote dedup state (mirrors the reference dedup_drop_stale). The - per-arrival prune only touches the arriving remote; this retires entries for departed remotes so - the map cannot grow without bound under untrusted traffic.""" + """Aggregate sweep of per-remote dedup state (reference dedup_drop_stale). The per-arrival prune only + touches the arriving remote; this retires departed ones so the map cannot grow without bound.""" stale = [rid for rid, st in self.dedup.items() if (st.last_active + SESSION_LIFETIME) < now] for rid in stale: del self.dedup[rid] @@ -597,10 +587,8 @@ def broadcast_handler(arrival: TransportArrival) -> None: self.shared_subject_writers: dict[int, SharedSubjectWriter] = {} self.shared_subject_listeners: dict[int, SharedSubjectListener] = {} - # The last fallible acquisition in the constructor. Both built-in transports implement this as a - # plain assignment that cannot raise, but a third-party one may not, and an unguarded failure here - # would strand the broadcast writer and listener acquired above -- nobody closes the transport on - # constructor failure, so those handles would leak. + # Nobody closes the transport on constructor failure, so an unguarded raise here would leak the + # broadcast writer and listener. Only third-party transports can raise here; the built-in ones cannot. try: transport.unicast_listen(self.on_unicast_arrival) except BaseException: @@ -694,9 +682,8 @@ def subscribe(self, name: str, *, reordering_window: float | None = None) -> Sub if pin is not None and not verbatim: raise ValueError("Pattern names cannot be pinned") - # Acquire the two fallible resources first — the subscriber (reordering-window validation) and, for a - # verbatim name, its topic (transactional) — before committing any registry state, so a failure - # leaves no half-registered root or subscriber behind. + # Acquire the fallible resources first — the subscriber (reordering-window validation) and, for a + # verbatim name, its topic — so a failure leaves no half-registered root or subscriber behind. registry = self.sub_roots_verbatim if verbatim else self.sub_roots_pattern root = registry.get(resolved) new_root = root is None @@ -763,8 +750,7 @@ def topic_ensure(self, name: str, pin: int | None) -> TopicImpl: topic = TopicImpl(self, name, evictions, now) self.topics_by_name[name] = topic self.topics_by_hash[topic.hash] = topic - # Commit the index first so the rollback primitive (destroy_topic) can find and undo the topic; - # the fallible tail (gossip-shard acquisition) rolls the whole topic back on failure. + # Index first so that the rollback primitive (destroy_topic) can find and undo the topic. try: self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) self.touch_implicit_topic(topic) @@ -828,11 +814,9 @@ def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> No self.topics_by_subject_id[new_sid] = t if collider.pub_writer is not None: # Winner acquires BEFORE the loser releases below, so the shared handle survives the - # handover on its refcount -- the reference does the same thing by moving the writer - # pointer outright ("the winner acquires first, then the loser releases"). Since the - # collider still holds the writer for new_sid, this hits the existing registry entry - # and is a pure refcount bump: no transport call, so the cascade cannot fail midway - # and leave a displaced topic unreachable from topics_by_subject_id. + # handover on its refcount ("the winner acquires first, then the loser releases" in the + # reference). It is therefore a pure refcount bump on the existing registry entry: no + # transport call, so the cascade cannot fail midway and strand a displaced topic. t.pub_writer = self.acquire_subject_writer(t, new_sid) t.sync_listener() self.schedule_gossip_urgent(t) @@ -842,10 +826,9 @@ def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> No work.append((t, ev + 1)) def sync_topic_lifecycle(self, topic: TopicImpl) -> None: - # Reachable after close(): disposing a response stream cancels its publish task, whose finally - # clause releases the tracker and re-syncs implicitness on a later loop iteration -- by then the - # transport is closed and the gossip tasks are cancelled. Without this guard that tail would - # spawn an uncancellable gossip task on a dead node and call subject_listen on a closed transport. + # Reachable after close(): a disposed response stream's cancelled publish task re-syncs implicitness + # from its finally clause on a later loop iteration. Without this guard that tail would spawn an + # uncancellable gossip task on a dead node and call subject_listen on a closed transport. if self._closed: return implicit = topic.compute_is_implicit() @@ -1057,8 +1040,7 @@ async def _gossip_event_urgent(self, topic: TopicImpl) -> None: await self.send_gossip(topic, broadcast=True) async def _gossip_event_periodic(self, topic: TopicImpl) -> None: - # Retry a previously-failed listener acquisition on the gossip cadence (reference - # topic_sync_subject_reader), so a verbatim subscription whose listener failed once recovers. + # Retry a previously-failed listener acquisition on the gossip cadence (reference topic_sync_subject_reader). topic.sync_listener() self._reschedule_gossip_periodic(topic, suppressed=False) broadcast = (topic.gossip_counter < GOSSIP_BROADCAST_RATIO) or ( @@ -1485,8 +1467,8 @@ def topic_subscribe_if_matching( topic.ts_origin = now - lage_to_seconds(lage) self.topics_by_name[name] = topic self.topics_by_hash[topic_hash] = topic - # This is a wire-driven path: it must never raise (untrusted input). A transport failure mid-setup - # rolls the topic back and drops the gossip; a later gossip retries. + # Wire-driven path: must never raise (untrusted input). A mid-setup failure rolls the topic back + # and drops the gossip; a later gossip retries. try: self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash)) self.touch_implicit_topic(topic) @@ -1537,9 +1519,9 @@ def _retire_one_expired_implicit_topic(self, now: float) -> bool: return True def sweep_stale_states(self, now: float) -> None: - """Aggregate, time-driven retirement of per-remote dedup and reordering state, so neither grows - without bound after the traffic that created it stops (mirrors the reference poll's round-robin - dedup_drop_stale / reordering_drop_stale, done sweep-all here).""" + """Time-driven retirement of per-remote dedup and reordering state, so neither grows without bound + after the traffic that created it stops (reference poll's round-robin dedup_drop_stale / + reordering_drop_stale, done sweep-all here).""" from ._subscriber import SubscriberImpl for topic in list(self.topics_by_name.values()): @@ -1567,17 +1549,15 @@ async def implicit_gc_loop(self) -> None: try: self._retire_one_expired_implicit_topic(time.monotonic()) except Exception: - # Same reasoning as _housekeeping_loop: a faulty retirement must not permanently - # disable implicit-topic GC. Back off before retrying -- an expired topic yields a - # zero delay, so retrying immediately would spin at full speed on a persistent fault. + # A faulty retirement must not permanently disable implicit-topic GC. Back off first: + # an expired topic yields a zero delay, so an immediate retry would spin on a hard fault. _logger.exception("Implicit topic retirement failed; backing off") await asyncio.sleep(HOUSEKEEPING_PERIOD) except asyncio.CancelledError: pass async def _housekeeping_loop(self) -> None: - # A dedicated periodic task: nothing can postpone asyncio.sleep in its own task, so the stale-state - # sweep runs on a steady cadence regardless of implicit-GC activity (matches the UDP/CAN transports). + # Dedicated task so the sweep keeps a steady cadence regardless of implicit-GC activity. try: while not self._closed: await asyncio.sleep(HOUSEKEEPING_PERIOD) @@ -1585,8 +1565,7 @@ async def _housekeeping_loop(self) -> None: self.sweep_stale_states(time.monotonic()) except Exception: # This loop is the only bound on per-remote state growth, so it must outlive a faulty - # sweep. Letting the exception escape would kill the task silently (nothing retrieves - # its result) and leave the node unbounded for the rest of its life. + # sweep; an escaping exception would kill the task silently and leave the node unbounded. _logger.exception("Stale-state sweep failed; continuing") except asyncio.CancelledError: pass @@ -1595,9 +1574,8 @@ def destroy_topic(self, name: str) -> None: topic = self.topics_by_name.get(name) if topic is None: return - # Dispose any outstanding response streams first (before discard_implicit_topic): a stream's - # forced teardown cancels its zombie-cleanup timer and stops its iteration, and doing it here - # avoids a re-touch of the implicit list that could otherwise resurrect the topic mid-destroy. + # Dispose response streams before discard_implicit_topic, else a re-touch of the implicit list + # could resurrect the topic mid-destroy. for stream in list(topic.request_futures.values()): stream.dispose() if topic.gossip_task is not None: @@ -1628,9 +1606,8 @@ def close(self) -> None: for root in list(self.sub_roots_verbatim.values()) + list(self.sub_roots_pattern.values()): for sub in list(root.subscribers): sub.close() - # Dispose outstanding response streams: cancel each library-owned request-publish task and stop - # pending iteration, otherwise a stream with a far-off (or infinite) response timeout would keep - # its retry task and payload graph alive against the closed transport until that timeout. + # Likewise for response streams: a far-off (or infinite) response timeout would otherwise keep the + # retry task and payload graph alive against the closed transport until that timeout. for topic in list(self.topics_by_name.values()): for stream in list(topic.request_futures.values()): stream.dispose() diff --git a/src/pycyphal2/_publisher.py b/src/pycyphal2/_publisher.py index b5390379b..34d10a1d4 100644 --- a/src/pycyphal2/_publisher.py +++ b/src/pycyphal2/_publisher.py @@ -128,9 +128,8 @@ async def request( ) self._topic.request_futures[tag] = stream - # Any failure after the stream is registered must close it (drop it from request_futures and - # re-sync implicitness), not just pop the tag -- otherwise a publisher closing concurrently could - # leave the topic permanently explicit. The tracker prep is inside the guard for the same reason. + # Any failure past registration must close() the stream, not just pop the tag; otherwise a + # concurrently closing publisher could leave the topic permanently explicit. tracker: PublishTracker | None = None try: tracker = self._prepare_reliable_publish_tracker(tag) @@ -198,8 +197,8 @@ def _prepare_reliable_publish_tracker(self, tag: int) -> PublishTracker: def _release_reliable_publish_tracker(self, tag: int, tracker: PublishTracker) -> None: self._topic.publish_futures.pop(tag, None) self._node.publish_tracker_release(self._topic, tracker) - # Re-evaluate implicitness: once the last pending reliable publish is released, a topic held - # explicit only by it can become implicit and be GC'd (otherwise it would gossip forever). + # A topic held explicit only by this publish can now become implicit and be GC'd; without this + # it would gossip forever. self._topic.sync_implicit() async def _send_reliable_publish( @@ -322,8 +321,7 @@ def __init__( self.closed = False # Never pruned during the stream's life, matching the reference request_future_t.remote_by_id # ("States are never removed assuming that futures are short-lived and/or the responder set is - # mostly constant", cy.c). The bound is the stream's own lifetime. close() deliberately retains - # it to re-ack late duplicates via the zombie timer; dispose() drops it with the rest of teardown. + # mostly constant", cy.c). close() retains it to re-ack late duplicates via the zombie timer. self._reliable_remote_by_id: dict[int, ResponseRemoteState] = {} self._publish_task: asyncio.Task[None] | None = None self._cleanup_handle: asyncio.TimerHandle | None = None @@ -334,7 +332,7 @@ def __aiter__(self) -> ResponseStreamImpl: async def __anext__(self) -> Response: if self.closed: raise StopAsyncIteration - # A non-finite response timeout disables liveness (waits forever), mirroring Subscriber.timeout. + # inf disables liveness (waits forever), mirroring Subscriber.timeout. timeout = self._response_timeout if self._response_timeout != float("inf") else None try: item = await asyncio.wait_for(self.queue.get(), timeout=timeout) @@ -419,22 +417,20 @@ def close(self) -> None: else: self._remove_from_topic() self.queue.put_nowait(StopAsyncIteration()) - # Re-evaluate topic implicitness: this stream no longer keeps the topic explicit, so once the - # publisher is also gone the topic can become implicit and be GC'd (otherwise it would gossip - # forever). Safe during node teardown -- notify_implicit_gc() is a no-op when the node is closed. + # The stream no longer keeps the topic explicit, so it can now be GC'd instead of gossiping + # forever. Safe during node teardown -- notify_implicit_gc() is a no-op when the node is closed. self._topic.sync_implicit() _logger.debug("Response stream closed for tag=%d", self._message_tag) def dispose(self) -> None: - """Force-remove the stream during node/topic teardown: cancel its publish task and any pending - zombie-cleanup timer, drop it from the topic, and stop pending iteration. Unlike close(), this - never retains a zombie and does not re-sync implicitness (the topic is being torn down).""" + """Force-remove the stream during node/topic teardown. Unlike close(), retains no zombie and does + not re-sync implicitness, the topic being torn down anyway.""" was_open = not self.closed self.closed = True if self._publish_task is not None: self._publish_task.cancel() self._publish_task = None - self._remove_from_topic() # Cancels the cleanup timer and drops from request_futures. - self._reliable_remote_by_id.clear() # No zombie is retained here, so the per-remote state goes too. + self._remove_from_topic() + self._reliable_remote_by_id.clear() if was_open: self.queue.put_nowait(StopAsyncIteration()) diff --git a/src/pycyphal2/_transport.py b/src/pycyphal2/_transport.py index 30fbf5146..cf8aa45fa 100644 --- a/src/pycyphal2/_transport.py +++ b/src/pycyphal2/_transport.py @@ -51,8 +51,8 @@ class Transport(Closable): def subject_id_modulus(self) -> int: """ Constant, cannot be changed while the transport is in use because that would invalidate subject allocations. - The value must satisfy the reference predicate — at least ``SUBJECT_ID_MODULUS_16bit`` (57203), prime, and - congruent to 3 modulo 4 — otherwise :meth:`pycyphal2.Node.new` rejects the transport with ``ValueError``. + Must satisfy the reference predicate — at least ``SUBJECT_ID_MODULUS_16bit`` (57203), prime, congruent to 3 + modulo 4 — otherwise :meth:`pycyphal2.Node.new` rejects the transport with ``ValueError``. """ raise NotImplementedError diff --git a/src/pycyphal2/can/_media_slcan.py b/src/pycyphal2/can/_media_slcan.py index 6f41b5111..6daaa52c6 100644 --- a/src/pycyphal2/can/_media_slcan.py +++ b/src/pycyphal2/can/_media_slcan.py @@ -129,9 +129,8 @@ def _parse_line(line: bytes) -> Frame | None: if command == b"D": return _parse_data_frame(line, id_length=8, max_payload_length=64) if command in (b"t", b"r", b"R"): - # 't'/'r' are standard-ID (11-bit) frames, 'R' is an extended-ID RTR frame; none are usable here. - # The Interface contract is extended-data-only and Frame carries no IDE or RTR discriminator, so - # forwarding a 't' would alias an extended frame with a small ID, and RTR has no Cyphal meaning. + # The Interface contract is extended-data-only and Frame carries no IDE/RTR discriminator, so a + # standard-ID 't'/'r' would alias an extended frame with a small ID; RTR has no Cyphal meaning. _logger.debug("SLCAN drop unusable frame type cmd=%r", command) return None _logger.debug("SLCAN drop unknown line=%r", line) diff --git a/src/pycyphal2/can/_reassembly.py b/src/pycyphal2/can/_reassembly.py index 7f5586f1b..adca64f06 100644 --- a/src/pycyphal2/can/_reassembly.py +++ b/src/pycyphal2/can/_reassembly.py @@ -63,10 +63,9 @@ class Endpoint: class Reassembler: @staticmethod def cleanup_sessions(endpoints: Iterable[Endpoint], now_ns: int) -> None: - # Slots are retained for RX_SESSION_RETENTION_NS (30 s), but a slot-free session is destroyed as - # soon as the transfer-ID timeout (2 s) elapses since the last admission, as in the reference - # (canard_poll): past that point the admission logic treats the session as stale anyway, so - # retaining it longer only delays cross-interface transfer-ID reuse after a redundant failover. + # A slot-free session dies at the transfer-ID timeout, not the slot retention timeout, as in the + # reference (canard_poll): the admission logic already treats it as stale, and retaining it longer + # only delays cross-interface transfer-ID reuse after a redundant failover. stale_deadline = now_ns - RX_SESSION_RETENTION_NS idle_deadline = now_ns - TRANSFER_ID_TIMEOUT_NS for endpoint in endpoints: diff --git a/src/pycyphal2/can/_transport.py b/src/pycyphal2/can/_transport.py index 3252b44a4..f02099863 100644 --- a/src/pycyphal2/can/_transport.py +++ b/src/pycyphal2/can/_transport.py @@ -250,8 +250,8 @@ def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None: async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None: if self._closed: raise ClosedError("CAN transport closed") - # Node-ID 0 is a valid regular node in Cyphal/CAN v1 (only v0 treated it as anonymous), so it is - # a legal unicast destination; rejecting it would make the ACK path unable to answer a node-0 peer. + # Node-ID 0 is a regular node in Cyphal/CAN v1 (only v0 treated it as anonymous); rejecting it + # would leave the ACK path unable to answer a node-0 peer. if not (0 <= remote_id <= NODE_ID_MAX): raise ValueError(f"Invalid remote node-ID: {remote_id}") transfer_id = self._unicast_tid[remote_id] @@ -474,8 +474,7 @@ async def _cleanup_loop(self) -> None: try: Reassembler.cleanup_sessions(self._endpoints.values(), Instant.now().ns) except Exception: - # Session retirement is not traffic-driven, so this loop must outlive a faulty sweep - # rather than dying silently and leaking sessions for the transport's lifetime. + # Retirement is not traffic-driven: a faulty sweep must not kill the loop and leak sessions. _logger.exception("Session cleanup failed; continuing") except asyncio.CancelledError: raise diff --git a/src/pycyphal2/can/pythoncan.py b/src/pycyphal2/can/pythoncan.py index 3651935c1..5a59bda92 100644 --- a/src/pycyphal2/can/pythoncan.py +++ b/src/pycyphal2/can/pythoncan.py @@ -114,9 +114,8 @@ async def receive(self) -> TimestampedFrame: self._raise_if_closed() item = await self._rx_queue.get() if isinstance(item, BaseException): - # Terminal sentinel: a receive-side failure already recorded itself via _fail(), which - # folds the cause into the sentinel. Raise it directly -- feeding it back through _fail() - # here would misrecord a clean close as an interface failure. Mirrors SocketCANInterface. + # The failure was already recorded by _fail() and folded into this sentinel; feeding it back + # through _fail() here would misrecord a clean close as an interface failure. As in SocketCAN. raise item return item @@ -130,8 +129,8 @@ def close(self) -> None: self._tx_task.cancel() self._tx_task = None try: - # Carries self._failure as the cause when close() was reached via _fail(), so a parked - # reader learns why the interface died rather than just that it closed. + # Carries self._failure as the cause when close() came via _fail(), so a parked reader + # learns why the interface died rather than just that it closed. self._rx_queue.put_nowait(self._closed_error()) except Exception: # Never silent: without the sentinel a parked reader hangs forever. @@ -201,9 +200,8 @@ def _rx_thread_func(self) -> None: except Exception as ex: if not self._closed: try: - # Record the failure at its source rather than at the reader: _fail() stores - # it and closes, which installs the terminal sentinel carrying it as cause. - # This also propagates the failure when nobody is parked in receive(). + # _fail() stores the cause and closes, installing the terminal sentinel; this + # also propagates the failure when nobody is parked in receive(). self._loop.call_soon_threadsafe(self._fail, ex) except RuntimeError: pass diff --git a/src/pycyphal2/can/socketcan.py b/src/pycyphal2/can/socketcan.py index eae206f08..cdd395e80 100644 --- a/src/pycyphal2/can/socketcan.py +++ b/src/pycyphal2/can/socketcan.py @@ -22,10 +22,10 @@ _CAN_FILTER_CAPACITY = 64 _CAN_INTERFACE_TYPE = 280 -# CAN FD flag bits from linux/can.h. CPython's socket module does not expose these on any supported -# version, so they are hardcoded; a getattr() fallback would silently clear them. +# CAN FD flag bits from linux/can.h, hardcoded because CPython's socket module does not expose them on +# any supported version and a getattr() fallback would silently clear them. _CANFD_BRS = 0x01 # Bit-rate switch: the data phase runs at the higher FD bit rate. -_CANFD_FDF = 0x04 # Marks the frame as CAN FD for the dual-use struct canfd_frame. +_CANFD_FDF = 0x04 # Marks the frame as CAN FD in the dual-use struct canfd_frame. _CAN_FRAME_STRUCT = struct.Struct("=IB3x8s") _CANFD_FRAME_STRUCT = struct.Struct("=IBBBB64s") _CAN_FILTER_STRUCT = struct.Struct("=II") @@ -50,9 +50,9 @@ def __init__(self, name: str) -> None: self._failure: BaseException | None = None self._tx = TxQueue() self._tx_task: asyncio.Task[None] | None = None - # RX runs in its own task feeding a queue, so close()/fail() can wake a parked reader with a - # sentinel instead of leaving it hung in sock_recv (which the selector loop never wakes on a - # bare socket.close()). This mirrors the python-can and webserial backends. + # RX runs in its own task feeding a queue so close()/fail() can wake a parked reader with a + # sentinel; the selector loop never wakes a pending sock_recv on a bare socket.close(). + # Mirrors the python-can and webserial backends. self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue() self._rx_task: asyncio.Task[None] | None = None @@ -101,8 +101,8 @@ async def receive(self) -> TimestampedFrame: self._rx_task.add_done_callback(self._on_task_done) item = await self._rx_queue.get() if isinstance(item, BaseException): - # Terminal sentinel: a receive-side failure already recorded itself via _fail(); an explicit - # close installs a plain ClosedError. Raise it directly so a clean close is not misrecorded. + # The sentinel is either the failure recorded by _fail() or a plain ClosedError from an + # explicit close; raise it as is so a clean close is not misrecorded as a failure. raise item return item @@ -118,7 +118,7 @@ async def _rx_loop(self) -> None: if not self._closed: self._fail(ex) # Records the failure, closes, and installs the terminal sentinel. return - frame = self._decode(raw) # Malformed frames decode to None and are dropped. + frame = self._decode(raw) if frame is not None: self._rx_queue.put_nowait(frame) @@ -129,9 +129,8 @@ def close(self) -> None: if self._rx_task is not None and self._rx_task is not asyncio.current_task(): self._rx_task.cancel() self._rx_task = None - # Drop any already-queued frames and install a single terminal sentinel so a reader parked on the - # queue wakes promptly. A frame decoded before the cancellation lands may still be appended behind - # the sentinel; that is harmless because _raise_if_closed() short-circuits every later receive(). + # Install a terminal sentinel so a parked reader wakes promptly. A frame decoded before the + # cancellation lands may still queue behind it; harmless, as _raise_if_closed() gates later reads. while not self._rx_queue.empty(): self._rx_queue.get_nowait() self._rx_queue.put_nowait(self._closed_error()) @@ -139,11 +138,8 @@ def close(self) -> None: self._tx_task.cancel() self._tx_task = None self._tx.abort_all(self._closed_error) - # Deregister explicitly BEFORE closing the fd. Task.cancel() above is deferred to a later loop - # iteration, whereas socket.close() takes effect now, so the selector callbacks installed by - # sock_recv/sock_sendto would otherwise be torn down against an already-closed fd -- and if that - # number were meanwhile reused by another socket, the deferred removal would deregister ITS - # callbacks instead. Both removals are no-ops when nothing is registered. + # Deregister before closing: cancel() is deferred but close() is immediate, so the callbacks would + # otherwise be torn down against a closed -- possibly recycled -- fd. try: loop = asyncio.get_running_loop() except RuntimeError: # Closed outside a running loop; nothing can be registered either. @@ -155,8 +151,7 @@ def close(self) -> None: loop.remove_reader(fd) loop.remove_writer(fd) except NotImplementedError: - # Windows' ProactorEventLoop implements neither call. SocketCAN itself is Linux-only, - # but the unit tests exercise close() against whatever loop the host provides. + # ProactorEventLoop implements neither; SocketCAN is Linux-only but tests run on any loop. pass self._sock.close() @@ -229,14 +224,12 @@ def _is_transient_tx_error(ex: OSError) -> bool: return ex.errno in _TRANSIENT_TX_ERRNO def _encode(self, identifier: int, data: bytes) -> bytes: - # The frame format is a property of the interface, fixed at construction, not of the payload - # length: every frame on an FD interface is an FD frame, as in the reference - # (cy_can_socketcan selects the FD/Classic vtable once from the netdev MTU). + # The frame format follows the interface, not the payload length: every frame on an FD interface is + # an FD frame, as in the reference (cy_can_socketcan picks the FD/Classic vtable from the netdev MTU). # - # REFERENCE PARITY: BRS is set on every FD frame, whereas the reference emits - # `.flags = CANFD_FDF` alone (cy_can_socketcan.c). Sending FD without BRS runs the data phase - # at the arbitration bit rate, forfeiting the throughput that is the point of FD, so this - # library always switches. BRS does not apply to Classic CAN. + # REFERENCE PARITY: BRS is set on every FD frame, whereas the reference emits `.flags = CANFD_FDF` + # alone (cy_can_socketcan.c). Without BRS the data phase runs at the arbitration bit rate, + # forfeiting the throughput that is the point of FD. BRS does not apply to Classic CAN. if self._fd: return _CANFD_FRAME_STRUCT.pack( socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK), diff --git a/src/pycyphal2/udp.py b/src/pycyphal2/udp.py index 267565d9b..6055d7018 100644 --- a/src/pycyphal2/udp.py +++ b/src/pycyphal2/udp.py @@ -58,15 +58,13 @@ _CYPHAL_OVERHEAD_MAX = 100 _CYPHAL_MTU_LINK_MIN = 576 _RX_SESSION_LIFETIME_NS = round(30.0 * 1e9) -_HOUSEKEEPING_PERIOD = 1.0 # Periodic RX-session retirement cadence, independent of new traffic. -# Capacity bound on the learned reverse-route cache. Legitimate deployments use a handful of remotes; the -# cap bounds memory under untrusted traffic with spoofable source UIDs, evicting the least-recently-seen -# entry. Not time-based: a retained breadcrumb's route survives until evicted under sustained flooding -# (then respond() fails cleanly with SendError), rather than expiring while the peer is briefly silent. +_HOUSEKEEPING_PERIOD = 1.0 # RX-session retirement cadence, independent of new traffic. +# Bounds the learned reverse-route cache against untrusted traffic with spoofable source UIDs, evicting the +# least-recently-seen entry. Deliberately not time-based: a route must not expire while its peer is briefly +# silent; under flooding an evicted route merely makes respond() fail cleanly with SendError. _REMOTE_ENDPOINT_CAPACITY = 8192 -# Per-reassembler cap on concurrent sender sessions. TTL retirement alone bounds retention time but not -# cardinality: a burst of many unique (spoofable) source UIDs within the lifetime window would otherwise -# grow unboundedly. The least-recently-active session is evicted past the cap. +# Per-reassembler cap on concurrent sender sessions: TTL retirement bounds retention time but not +# cardinality, so a burst of unique (spoofable) source UIDs would otherwise grow unboundedly. _RX_SESSION_CAPACITY = 4096 _RX_SLOT_COUNT = 8 _RX_TRANSFER_HISTORY_COUNT = 32 @@ -163,10 +161,8 @@ def _collect_send_errors(results: list[BaseException | None]) -> list[BaseExcept """ Per-interface failures out of a ``gather(..., return_exceptions=True)`` over ``send_on_iface``. - The predicate is ``BaseException``, not ``Exception``: gather reports an individually cancelled child - as a ``CancelledError`` *instance*, which derives from ``BaseException``, so an ``Exception`` test - would score a cancelled interface as a delivery -- and if every interface were cancelled the caller - would report a fully successful send that never put a byte on the wire. + Tests ``BaseException``, not ``Exception``: gather returns an individually cancelled child as a + ``CancelledError`` instance, which an ``Exception`` test would score as a successful delivery. """ return [r for r in results if isinstance(r, BaseException)] @@ -260,7 +256,7 @@ def _find_left_neighbor(self, left: int) -> _Fragment | None: def _find_right_neighbor(self, right: int) -> _Fragment | None: candidate: _Fragment | None = None for frag in self.fragments: - if frag.offset <= right: # Inclusive: a fragment starting exactly at `right` is a neighbor candidate. + if frag.offset <= right: # Inclusive: a fragment starting exactly at `right` is adjacent. candidate = frag else: break @@ -308,9 +304,9 @@ def is_transfer_ejected(self, transfer_id: int) -> bool: return transfer_id in self.history def initialize_history(self, transfer_id: int) -> None: - # The seed wraps mod 2**64, not 2**48: for a first-seen transfer-ID of 0 it becomes 2**64-1, - # which no 48-bit wire transfer-ID can match (a 48-bit-masked seed would falsely reject a - # genuine transfer with ID 0xFFFF_FFFF_FFFF). Mirrors the reference uint64 arithmetic. + # Seed wraps mod 2**64, not 2**48, mirroring the reference uint64 arithmetic: a 48-bit-masked seed + # for a first-seen transfer-ID of 0 would be 0xFFFF_FFFF_FFFF and falsely reject a genuine transfer + # bearing that ID. value = (transfer_id - 1) & 0xFFFF_FFFF_FFFF_FFFF self.history = [value] * _RX_TRANSFER_HISTORY_COUNT self.history_current = 0 @@ -349,11 +345,9 @@ def get_slot(self, timestamp_ns: int, header: _FrameHeader) -> tuple[int, _Trans class _RxReassembler: def __init__(self) -> None: - # LRU ordered MOST-recently-active FIRST, so the oldest session is the LAST item and is reached - # with next(reversed(...)). Note this is the mirror image of _UDPTransportImpl._remote_endpoints, - # which orders most-recent LAST; the difference is deliberate -- the stale-retirement scans here - # want the oldest, so keeping it at a fixed end avoids re-sorting. Flip one and you must flip - # every popitem()/reversed() that reads it. + # LRU ordered most-recently-active FIRST, so the oldest is last: next(reversed(...)). + # This is the mirror image of _UDPTransportImpl._remote_endpoints (most-recent LAST); flipping + # either requires flipping every popitem()/reversed() that reads it. self._sessions: OrderedDict[int, _RxSession] = OrderedDict() def accept( @@ -378,7 +372,7 @@ def accept( self._sessions[header.sender_uid] = session session.last_animated_ns = timestamp_ns self._sessions.move_to_end(header.sender_uid, last=False) - if len(self._sessions) > _RX_SESSION_CAPACITY: # Evict the least-recently-active session. + if len(self._sessions) > _RX_SESSION_CAPACITY: evicted_uid, _ = self._sessions.popitem(last=True) _logger.debug("UDP reasm session cache full, evicted uid=%016x", evicted_uid) if not session.initialized: @@ -434,8 +428,8 @@ def _retire_one_stale_session(self, timestamp_ns: int) -> None: _logger.debug("UDP reasm retire uid=%016x", oldest_uid) def drop_stale_sessions(self, timestamp_ns: int) -> None: - """Retire every stale session, independent of new traffic (the reference does this from a periodic - poll). Sessions are recency-ordered, so once the oldest is fresh none remain stale.""" + """Retire stale sessions independent of traffic (the reference does this from a periodic poll). + Sessions are recency-ordered, so the scan stops at the first fresh one.""" while self._sessions: oldest_uid = next(reversed(self._sessions)) if timestamp_ns >= (self._sessions[oldest_uid].last_animated_ns + _RX_SESSION_LIFETIME_NS): @@ -516,26 +510,23 @@ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | _logger.debug("Subject tx start sid=%d tid=%d bytes=%d", self._subject_id, transfer_id, len(message)) addr = (mcast_ip, port) - # Snapshot (iface, sock, lock) elements before the first await so a concurrent close() clearing - # the socket lists cannot desync indices; a send racing close simply hits a closed socket and - # aggregates as a per-interface error rather than raising IndexError. + # Snapshot before the first await: a concurrent close() clearing the socket lists would otherwise + # desync the indices; a send racing close just hits a closed socket and aggregates as an error. targets = list(zip(self._transport.interfaces, self._transport.tx_socks, self._transport.tx_locks, strict=True)) coros = [] for iface, sock, lock in targets: frames = _segment_transfer(priority, transfer_id, self._transport.uid, message, iface.mtu_cyphal) coros.append(self._transport.send_on_iface(sock, lock, frames, addr, deadline)) - # Send to all interfaces concurrently so a congested interface cannot starve a healthy one of the - # shared deadline (each interface's frames still go out in order under its own socket lock). - # return_exceptions=True lets every interface settle before we aggregate, so none is left running. + # Concurrent so a congested interface cannot starve a healthy one of the shared deadline; frames + # still go out in order per interface. return_exceptions lets every send settle before aggregating. results = await asyncio.gather(*coros, return_exceptions=True) errors = _collect_send_errors(results) success_count = len(results) - len(errors) if errors and success_count == 0: _logger.error("Send failed on all interfaces for subject %d", self._subject_id) - # BaseExceptionGroup, not ExceptionGroup: the latter refuses to nest a BaseException, and - # `errors` may carry a CancelledError. It downgrades itself to an ExceptionGroup when every - # member is an Exception, so the common case is unchanged. + # BaseExceptionGroup because `errors` may carry a CancelledError, which ExceptionGroup rejects; + # it degrades to an ExceptionGroup when every member is an Exception. raise SendError("send failed on all interfaces") from BaseExceptionGroup( "send failed on all interfaces", errors ) @@ -610,10 +601,9 @@ def new( The UID is a globally unique 64-bit identifier of the local node. If not given, one will be generated randomly. - The default ``subject_id_modulus`` is always valid. Overriding it only makes sense for a - deliberately reduced subject-ID space, and the value must satisfy the reference predicate -- - at least 57203, prime, and congruent to 3 modulo 4 -- because :meth:`pycyphal2.Node.new` rejects - anything else with ``ValueError``. This constructor only enforces the transport-level range. + Overriding ``subject_id_modulus`` only makes sense for a deliberately reduced subject-ID space; the + value must be at least 57203, prime, and congruent to 3 modulo 4, else :meth:`pycyphal2.Node.new` + rejects it with ``ValueError``. This constructor only enforces the transport-level range. """ if not interfaces: ifaces = UDPTransport.list_interfaces() @@ -684,9 +674,8 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus raise ValueError("At least one network interface is required") self._tx_socks: list[socket.socket] = [] - # One lock per TX socket. asyncio's selector loop allows only one writer callback per fd, so - # concurrent senders on the same socket (subject writers, unicast, detached ACK sends) must be - # serialized; without it a displaced sock_sendto can hang until its deadline. + # asyncio's selector loop allows only one writer callback per fd, so concurrent senders on one + # socket must be serialized; otherwise a displaced sock_sendto hangs until its deadline. self._tx_locks: list[asyncio.Lock] = [] self._self_endpoints: set[tuple[str, int]] = set() try: @@ -713,9 +702,8 @@ def __init__(self, interfaces: Iterable[Interface], uid: int, subject_id_modulus self._unicast_rx_tasks: list[asyncio.Task[None]] = [] self._mcast_rx_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} - # Task creation is the remaining fallible step; a failure here would otherwise leak every TX - # socket and orphan the RX tasks already spawned, since a half-built transport is never returned - # to the caller and so is never close()d. + # A half-built transport is never returned to the caller and hence never close()d, so a failure + # here must not leak the TX sockets or orphan the RX tasks already spawned. try: for i, sock in enumerate(self._tx_socks): task = self._loop.create_task(self._unicast_rx_loop(sock, i)) @@ -765,14 +753,13 @@ def _create_mcast_socket(subject_id: int, iface: Interface) -> socket.socket: sock.bind((mcast_ip, port)) mreq = socket.inet_aton(mcast_ip) + socket.inet_aton(str(iface.address)) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - # REFERENCE PARITY: the reference filters every received datagram by its ingress interface index - # via recvmsg+IP_PKTINFO (udp_wrapper.c). asyncio offers no sock_recvmsg, so on Linux the same - # delivery set is obtained at the kernel level with IP_MULTICAST_ALL=0: with it, this socket only - # receives datagrams matching its own (group, interface) membership above, instead of the default - # any-interface delivery that would mislearn reverse routes on multi-homed hosts. macOS/BSD scope - # multicast delivery per membership natively. On Windows the socket binds INADDR_ANY and Winsock - # may deliver cross-interface traffic; multi-homed Windows hosts should configure at most one - # transport interface per multicast-reachable network. + # REFERENCE PARITY: the reference filters received datagrams by ingress interface index via + # recvmsg+IP_PKTINFO (udp_wrapper.c). asyncio has no sock_recvmsg, so on Linux the same delivery + # set comes from IP_MULTICAST_ALL=0, which restricts this socket to its own (group, interface) + # membership instead of the default any-interface delivery that would mislearn reverse routes on + # multi-homed hosts. macOS/BSD scope delivery per membership natively; on Windows the socket binds + # INADDR_ANY and Winsock may deliver cross-interface traffic, so multi-homed Windows hosts should + # configure at most one transport interface per multicast-reachable network. if sys.platform == "linux": sock.setsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX, 0) except BaseException: @@ -810,8 +797,8 @@ async def send_on_iface( addr: tuple[str, int], deadline: Instant, ) -> Exception | None: - """Send every frame of one transfer on one interface, serialized on that socket's lock. Returns - the failure (never raised) so the caller can aggregate per-interface results, or None on success.""" + """Send one transfer's frames on one interface under that socket's lock; returns the failure + instead of raising, so the caller can aggregate per-interface results.""" # Bound the lock wait by the same absolute deadline, so a short-deadline sender queued behind a # long-deadline holder fails on its own budget rather than waiting out the holder's. remaining_ns = deadline.ns - Instant.now().ns @@ -855,9 +842,8 @@ def _deregister_socket(self, sock: socket.socket) -> None: """ Drop any selector callbacks for this socket while its descriptor is still open. - Cancelling the task that owns a ``sock_recv``/``sock_sendto`` only schedules the teardown, so - without this the removal would run after the descriptor is closed -- and would hit an unrelated - socket if the number had been recycled by then. Both removals no-op when nothing is registered. + Cancelling the owning task only schedules the teardown, so otherwise the removal lands after the + fd is closed -- possibly hitting an unrelated socket that has recycled the number. """ fd = sock.fileno() if fd < 0: @@ -866,8 +852,7 @@ def _deregister_socket(self, sock: socket.socket) -> None: self._loop.remove_reader(fd) self._loop.remove_writer(fd) except NotImplementedError: - # Windows' ProactorEventLoop drives sockets through overlapped I/O instead of selector - # callbacks, so it implements neither call and there is no registration to clean up. + # ProactorEventLoop uses overlapped I/O, not selector callbacks: nothing to deregister. pass def remove_subject_writer(self, subject_id: int, writer: _UDPSubjectWriter) -> None: @@ -900,8 +885,8 @@ def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], task = self._loop.create_task(self._mcast_rx_loop(sock, subject_id, i)) self._mcast_rx_tasks[key] = task except BaseException: - # Roll back the handler and every per-interface socket/task created so far, so a later - # subject_listen for this subject is not blocked by the duplicate-handler check above. + # Roll back so a later subject_listen for this subject is not blocked by the duplicate-handler + # check above. self.remove_subject_listener(subject_id, handler) raise return _UDPSubjectListener(self, subject_id, handler) @@ -925,8 +910,8 @@ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, m self._next_unicast_transfer_id += 1 _logger.debug("Unicast tx start rid=%016x tid=%d bytes=%d", remote_id, transfer_id, len(message)) - # Snapshot targets (only interfaces with a known endpoint) before the first await, then send - # concurrently, as with the subject writer. + # Snapshot targets (only interfaces with a known endpoint) before the first await, as in the + # subject writer. coros = [] for i, (iface, sock, lock) in enumerate(zip(self._interfaces, self._tx_socks, self._tx_locks, strict=True)): ep = self._remote_endpoints.get((remote_id, i)) @@ -970,10 +955,9 @@ def close(self) -> None: for task in self._mcast_rx_tasks.values(): task.cancel() self._mcast_rx_tasks.clear() - # Deregister before closing: the cancellations above land on a later loop iteration while - # sock.close() takes effect now, so the selector callbacks would otherwise be torn down against - # a closed fd -- or against whatever socket has since inherited that fd number. A send already - # parked inside sock_sendto still unblocks on its own deadline, which is the caller's budget. + # Deregister before closing: the cancellations above land on a later loop iteration while close() + # takes effect now, so the callbacks would otherwise be torn down against a closed -- possibly + # recycled -- fd. A send already parked in sock_sendto still unblocks on its own deadline. for sock in [*self._tx_socks, *self._mcast_socks.values()]: self._deregister_socket(sock) for sock in self._tx_socks: @@ -986,9 +970,7 @@ def close(self) -> None: self._subject_handlers.clear() self._subject_writers.clear() self._reassemblers.clear() - # Release all RX-side state so a closed transport retains no session memory, learned reverse - # routes, or handler references: the unicast reassembler's sessions, the endpoint cache, and the - # unicast handler. + # Release RX-side state so a closed transport retains no sessions, learned routes, or handler refs. self._unicast_reassembler = _RxReassembler() self._remote_endpoints.clear() self._unicast_handler = None @@ -1029,9 +1011,8 @@ async def _unicast_rx_loop(self, sock: socket.socket, iface_idx: int) -> None: _logger.debug("Unicast rx cancelled iface=%d", iface_idx) async def _housekeeping_loop(self) -> None: - """Retire stale reassembly sessions periodically, independent of new traffic (the reference does - this from its poll), so a silent remote's session is reclaimed instead of lingering the full - session lifetime past the last frame.""" + """Retire stale reassembly sessions periodically (the reference does this from its poll), so a + silent remote's session is reclaimed instead of lingering long past its last frame.""" try: while not self._closed: await asyncio.sleep(_HOUSEKEEPING_PERIOD) @@ -1041,8 +1022,7 @@ async def _housekeeping_loop(self) -> None: for reassembler in list(self._reassemblers.values()): reassembler.drop_stale_sessions(now_ns) except Exception: - # Traffic-driven retirement alone does not reclaim a silent remote's session, so this - # loop must outlive a faulty sweep rather than dying with an unretrieved exception. + # Retirement is not traffic-driven: a faulty sweep must not kill the loop. _logger.exception("Stale session sweep failed; continuing") except asyncio.CancelledError: pass diff --git a/tests/can/test_media_slcan.py b/tests/can/test_media_slcan.py index e9f55ccd3..33915eb5b 100644 --- a/tests/can/test_media_slcan.py +++ b/tests/can/test_media_slcan.py @@ -53,8 +53,8 @@ def test_parse_classic_extended_frames() -> None: assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")] assert parser.feed(b"T000001232abCd\r") == [Frame(id=0x123, data=b"\xab\xcd")] - # Standard-ID 't' data frames are dropped: the Interface contract is extended-only, and Frame has - # no IDE discriminator, so forwarding them would alias extended frames with small IDs. + # Standard-ID 't' frames are dropped: Frame has no IDE discriminator, so forwarding them would alias + # extended frames with small IDs. assert parser.feed(b"t1231AA\r") == [] assert parser.feed(b"t7FF1AA\r") == [] assert parser.feed(b"t7FF0\r") == [] diff --git a/tests/can/test_pythoncan.py b/tests/can/test_pythoncan.py index e28324843..34421783e 100644 --- a/tests/can/test_pythoncan.py +++ b/tests/can/test_pythoncan.py @@ -1186,9 +1186,8 @@ async def test_unit_mixed_fd_and_classic_payloads() -> None: async def test_unit_fd_flags_follow_interface_mode() -> None: - """Every frame on an FD interface carries is_fd AND bitrate_switch regardless of payload length; - a Classic interface sets neither. See the REFERENCE PARITY note in socketcan._encode: BRS is always - set on FD, which is a deliberate divergence from the reference.""" + """Every FD-interface frame carries is_fd and bitrate_switch regardless of payload length; a Classic + interface sets neither. Always-on BRS is the deliberate divergence noted in socketcan._encode.""" a, b = _virtual_pair(fd=True) sent: list[_can.Message] = [] orig_send = a._bus.send @@ -1306,14 +1305,13 @@ async def test_unit_rx_bus_error_propagates() -> None: with pytest.raises(ClosedError) as caught: await asyncio.wait_for(itf.receive(), timeout=2.0) assert itf._failure is err # Recorded by _fail() in the RX thread's handoff, not by receive(). - assert caught.value.__cause__ is err # The sentinel carries the underlying cause. + assert caught.value.__cause__ is err itf.close() async def test_unit_clean_close_is_not_recorded_as_a_failure() -> None: - """close() must not be misrecorded as an interface failure. receive() used to feed EVERY sentinel -- - including the plain ClosedError installed by an explicit close -- back through _fail(), so a clean - shutdown ended up reported as 'receive failed'. SocketCANInterface already got this right.""" + """receive() used to feed EVERY sentinel back through _fail(), including the plain ClosedError from an + explicit close, so a clean shutdown got reported as 'receive failed'.""" mock_bus = MagicMock(spec=_can.BusABC) mock_bus.recv.return_value = None # Idle bus: the reader parks on the queue. mock_bus.channel_info = "mock:cleanclose" @@ -1325,7 +1323,7 @@ async def test_unit_clean_close_is_not_recorded_as_a_failure() -> None: itf.close() with pytest.raises(ClosedError): await asyncio.wait_for(receiver, timeout=2.0) - assert itf._failure is None # A clean close leaves no failure recorded. + assert itf._failure is None async def test_unit_multiple_close_with_failure() -> None: diff --git a/tests/can/test_reassembly.py b/tests/can/test_reassembly.py index 66ea7cd6b..482ff2a5a 100644 --- a/tests/can/test_reassembly.py +++ b/tests/can/test_reassembly.py @@ -5,9 +5,8 @@ def test_cleanup_drops_slot_free_session_at_transfer_id_timeout() -> None: - """A slot-free session is destroyed once the 2 s transfer-ID timeout elapses since the last - admission (reference: canard_poll), while slots themselves are retained for 30 s. A fresh - slot-free session is kept.""" + """A slot-free session dies at the 2 s transfer-ID timeout since last admission (reference: + canard_poll); a session holding a slot lives for 30 s.""" endpoint = Endpoint(kind=TransferKind.MESSAGE_16, port_id=7, on_transfer=lambda *_: None) stale = RxSession.new(0) stale.last_admission_ts_ns = 0 @@ -21,7 +20,7 @@ def test_cleanup_drops_slot_free_session_at_transfer_id_timeout() -> None: assert 42 not in endpoint.sessions assert 43 in endpoint.sessions - # A session with a live slot is retained regardless of admission staleness for up to 30 s. + # A live slot keeps the session alive despite admission staleness, up to 30 s. occupied = RxSession.new(0) occupied.last_admission_ts_ns = 0 occupied.slots[0] = RxSlot(start_ts_ns=0, transfer_id=0, iface_index=0, expected_toggle=False) diff --git a/tests/can/test_socketcan_unit.py b/tests/can/test_socketcan_unit.py index 1bfdc9c0a..18b8e22ff 100644 --- a/tests/can/test_socketcan_unit.py +++ b/tests/can/test_socketcan_unit.py @@ -20,8 +20,7 @@ class _FakeRawSocket: def __init__(self) -> None: self.calls: list[tuple[object, ...]] = [] - # A real fd, so close() can deregister it from the selector exactly as it would a real CAN - # socket. Fabricating a number here would risk deregistering an unrelated fd in this process. + # A real fd: a fabricated number would risk deregistering an unrelated fd in this process. self._rfd, self._wfd = os.pipe() def setblocking(self, enabled: bool) -> None: @@ -63,8 +62,7 @@ def __init__(self, *, recv: list[object] | None = None, send: list[object] | Non self.send = list(send or []) self.sent_frames: list[bytes] = [] self.created_tasks: list[object] = [] - # close() deregisters the fd from the selector before closing it; record the order so a test can - # assert the deregistration precedes the close rather than racing a deferred task cancellation. + # Records selector deregistration so a test can assert it happens before the fd is closed. self.deregistered: list[tuple[str, int]] = [] def remove_reader(self, fd: int) -> bool: @@ -288,7 +286,7 @@ async def test_rx_loop_decodes_skips_and_drops_cleanly_on_cancel(monkeypatch: py with pytest.raises(asyncio.CancelledError): await iface._rx_loop() - frame = iface._rx_queue.get_nowait() # The good frame was queued; the undecodable one was dropped. + frame = iface._rx_queue.get_nowait() # The undecodable frame was dropped, not queued. assert isinstance(frame, TimestampedFrame) assert frame.id == 0x123 assert frame.data == b"ab" @@ -303,7 +301,7 @@ async def test_rx_loop_failure_marks_interface_and_installs_sentinel(monkeypatch loop = _FakeLoop(recv=[err]) monkeypatch.setattr(module.asyncio, "get_running_loop", lambda: loop) - await iface._rx_loop() # Fails the interface via _fail(), then returns. + await iface._rx_loop() # _fail()s the interface, then returns rather than raising. assert iface._closed is True assert iface._failure is err sentinel = iface._rx_queue.get_nowait() @@ -311,8 +309,8 @@ async def test_rx_loop_failure_marks_interface_and_installs_sentinel(monkeypatch async def test_receive_raises_clean_close_without_recording_failure(monkeypatch: pytest.MonkeyPatch) -> None: - """An explicit close is surfaced to a parked receive() as a plain ClosedError and is not recorded as - an interface failure (only a receive-side error is).""" + """An explicit close reaches receive() as a plain ClosedError; only a receive-side error is recorded + as an interface failure.""" fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) @@ -324,8 +322,8 @@ async def test_receive_raises_clean_close_without_recording_failure(monkeypatch: async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> None: - """close() must wake a reader parked on the RX queue with a ClosedError sentinel, so interface loss - propagates instead of leaving the transport's reader hung (review finding #10).""" + """close() must wake a parked reader with a ClosedError sentinel; otherwise interface loss leaves the + transport's reader hung forever.""" fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) @@ -343,10 +341,9 @@ async def test_close_wakes_parked_receiver(monkeypatch: pytest.MonkeyPatch) -> N async def test_close_deregisters_the_fd_before_closing_it(monkeypatch: pytest.MonkeyPatch) -> None: - """Task.cancel() is deferred to a later loop iteration but socket.close() takes effect immediately, - so relying on the cancelled reader to deregister itself tears down selector callbacks against an - already-closed fd -- and against a DIFFERENT socket if that fd number has been reused meanwhile. - close() must therefore deregister explicitly, before closing.""" + """Task.cancel() is deferred while socket.close() is immediate, so leaving deregistration to the + cancelled reader tears down callbacks against a closed -- possibly already reused -- fd. close() must + deregister explicitly, first.""" fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) @@ -359,15 +356,14 @@ async def test_close_deregisters_the_fd_before_closing_it(monkeypatch: pytest.Mo iface.close() assert loop.deregistered == [("reader", fd), ("writer", fd)] - # Ordering is the whole point: the fd must still be open when it is deregistered. + # The fd must still have been open at deregistration time. assert ("close",) in sock.calls assert loop.deregistered, "deregistration must not be left to the deferred task cancellation" async def test_close_tolerates_a_loop_without_reader_registration(monkeypatch: pytest.MonkeyPatch) -> None: - """Windows' ProactorEventLoop drives sockets through overlapped I/O and raises NotImplementedError - from remove_reader/remove_writer. close() must treat that as "nothing to deregister" rather than - propagating it -- an unguarded call broke every Windows job while Linux and macOS stayed green.""" + """ProactorEventLoop raises NotImplementedError from remove_reader/writer; close() must treat that + as "nothing to deregister". An unguarded call broke every Windows job.""" fake_socket, _ = _make_socket_module() module = _load_socketcan_module(monkeypatch, socket_module=fake_socket) iface = _make_iface(module) @@ -434,15 +430,13 @@ def test_encode_and_decode_branches(monkeypatch: pytest.MonkeyPatch) -> None: encoded_fd = fd_iface._encode(456, b"012345678") assert len(encoded_fd) == module._FD_FRAME_SIZE - # The frame format follows the interface mode, not the payload length: a short payload on an FD - # interface is still emitted as an FD frame, as in the reference. + # Format follows the interface mode, not the payload length, as in the reference. encoded_fd_short = fd_iface._encode(456, b"abc") assert len(encoded_fd_short) == module._FD_FRAME_SIZE - # The flags byte must carry FDF (so the dual-use struct is unambiguous) and BRS (so the data phase - # actually runs at the FD bit rate) on EVERY FD frame, short payloads included. The literals are - # hardcoded from linux/can.h because CPython's socket module exposes neither; a getattr() fallback - # to 0 would silently emit flagless frames, which is what this assertion guards against. + # Every FD frame must carry FDF and BRS, short payloads included. The literals are hardcoded from + # linux/can.h (CPython's socket module exposes neither); a getattr() fallback to 0 would silently + # emit flagless frames. assert module._CANFD_FDF == 0x04 assert module._CANFD_BRS == 0x01 for encoded in (encoded_fd, encoded_fd_short): diff --git a/tests/can/test_transport_internal.py b/tests/can/test_transport_internal.py index f0628895f..019fc194b 100644 --- a/tests/can/test_transport_internal.py +++ b/tests/can/test_transport_internal.py @@ -101,8 +101,7 @@ async def test_writer_unicast_and_send_transfer_error_paths() -> None: live_if = MockCANInterface(bus, "if1") live = CANTransport.new(live_if) - # Node-ID 0 is a valid regular Cyphal/CAN v1 node, hence a legal unicast destination; - # the wire encoding must carry destination 0. + # Node-ID 0 is a legal unicast destination; the wire encoding must carry destination 0. await live.unicast(Instant.now() + 1.0, Priority.NOMINAL, 0, b"x") uni_id, uni_frames, _ = live_if.enqueue_history[-1] parsed = parse_frame(uni_id, uni_frames[0]) diff --git a/tests/mock_transport.py b/tests/mock_transport.py index b976a1155..ec655c886 100644 --- a/tests/mock_transport.py +++ b/tests/mock_transport.py @@ -72,9 +72,8 @@ def __init__(self, node_id: int = 0, modulus: int = DEFAULT_MODULUS, network: Mo self.unicast_log: list[tuple[int, bytes]] = [] self.closed = False self.fail_unicast = False - # Setup-path failure injection: a subject-ID present in either set makes the corresponding - # acquisition raise, to exercise transactional rollback. Sets are not auto-cleared, so a test - # controls exactly which retries fail. + # Setup-path failure injection for transactional-rollback tests: a subject-ID in either set makes + # that acquisition raise. Never auto-cleared, so a test controls exactly which retries fail. self.fail_subject_listen: set[int] = set() self.fail_subject_advertise: set[int] = set() diff --git a/tests/test_close_streams.py b/tests/test_close_streams.py index 9b3819034..53c45ee78 100644 --- a/tests/test_close_streams.py +++ b/tests/test_close_streams.py @@ -51,14 +51,14 @@ async def test_open_stream_keeps_topic_explicit_until_closed() -> None: stream = await request_stream(pub, pycyphal2.Instant.now() + 1.0, 1.0, b"request") pub.close() - assert topic.is_implicit is False # The open stream keeps it explicit. + assert topic.is_implicit is False - stream.close() # Cancels the publish task; its release re-syncs implicitness on the next loop turn. + stream.close() # Its release re-syncs implicitness only on the next loop turn, hence the poll below. for _ in range(50): if topic.is_implicit: break await asyncio.sleep(0.001) - assert topic.is_implicit is True # Now it may be GC'd. + assert topic.is_implicit is True node.close() @@ -86,7 +86,7 @@ async def test_implicit_gc_does_not_destroy_topic_with_open_stream(monkeypatch: if "rpc" not in node.topics_by_name: break await asyncio.sleep(0.01) - assert "rpc" not in node.topics_by_name # Reaped once the stream closed. + assert "rpc" not in node.topics_by_name node.close() @@ -107,7 +107,7 @@ async def test_zombie_stream_does_not_block_gc() -> None: from pycyphal2._publisher import ResponseRemoteState stream._reliable_remote_by_id[7] = ResponseRemoteState(seqno_top=0) - stream.close() # Cancels the publish task; its release runs on the next loop turn. + stream.close() assert topic.request_futures # The zombie is retained pending its cleanup timer... pub.close() for _ in range(50): @@ -117,7 +117,7 @@ async def test_zombie_stream_does_not_block_gc() -> None: assert topic.is_implicit is True # ...but a closed stream does not keep the topic explicit. node.destroy_topic("rpc") - assert topic.request_futures == {} # destroy_topic clears it. + assert topic.request_futures == {} node.close() @@ -136,7 +136,7 @@ async def test_request_rejects_nan_response_timeout() -> None: async def test_pending_reliable_publish_keeps_topic_explicit() -> None: """An in-flight reliable publish (tracked in publish_futures) keeps the topic explicit even with no - publisher, so implicit GC cannot destroy it mid-delivery (Codex D6 addition to finding #22).""" + publisher, so implicit GC cannot destroy it mid-delivery (finding #22).""" net = MockNetwork() tr = MockTransport(node_id=1, network=net) node = new_node(tr, home="n1") @@ -147,7 +147,7 @@ async def test_pending_reliable_publish_keeps_topic_explicit() -> None: topic.publish_futures[99] = PublishTracker(tag=99, ack_event=asyncio.Event()) pub.close() - assert topic.is_implicit is False # The pending publish keeps it explicit. + assert topic.is_implicit is False topic.publish_futures.clear() topic.sync_implicit() @@ -157,10 +157,9 @@ async def test_pending_reliable_publish_keeps_topic_explicit() -> None: async def test_close_does_not_resurrect_topic_via_disposed_stream_tail() -> None: - """close() disposes response streams, which cancels each publish task; that task's finally clause runs - on a LATER loop iteration -- after the transport is closed and the gossip tasks are cancelled -- and - re-syncs topic implicitness. sync_topic_lifecycle must refuse to act on a closed node, otherwise it - spawns an uncancellable gossip task on a dead node and calls subject_listen on a closed transport.""" + """A cancelled publish task's finally clause runs a loop iteration AFTER close() tore down the transport + and the gossip tasks, and it re-syncs topic implicitness; sync_topic_lifecycle must refuse to act on a + closed node, else it spawns an uncancellable gossip task and calls subject_listen on a dead transport.""" net = MockNetwork() tr = MockTransport(node_id=1, network=net) node = new_node(tr, home="n1") diff --git a/tests/test_gossip.py b/tests/test_gossip.py index 210c9aa2a..ce35def5a 100644 --- a/tests/test_gossip.py +++ b/tests/test_gossip.py @@ -144,7 +144,7 @@ async def test_gossip_crafted_unicode_pin_name_does_not_raise(): node.on_subject_arrival(node.broadcast_subject_id, arrival) # Must not raise. assert crafted not in node.topics_by_name - # The node must remain fully operational: a subsequent valid gossip is processed normally. + # The node must remain operational afterwards. topic_name = "sensor/temp" valid_hdr = GossipHeader( topic_log_age=5, diff --git a/tests/test_housekeeping.py b/tests/test_housekeeping.py index ab1cfe443..f25fec519 100644 --- a/tests/test_housekeeping.py +++ b/tests/test_housekeeping.py @@ -1,6 +1,5 @@ """Regression tests for finding #3: per-remote dedup and reordering state must be swept in aggregate on a -time basis, not only when the arriving remote sends again, so neither grows without bound under untrusted -traffic.""" +time basis, not only when the arriving remote sends again, so neither grows without bound.""" from __future__ import annotations @@ -28,9 +27,9 @@ async def test_sweep_drops_stale_dedup_for_departed_remotes() -> None: topic.dedup[12] = DedupState(tag_frontier=1, last_active=base + 10_000.0) # Recently active. node.sweep_stale_states(base + SESSION_LIFETIME + 1.0) - assert 10 not in topic.dedup # Departed remotes are swept in aggregate... + assert 10 not in topic.dedup assert 11 not in topic.dedup - assert 12 in topic.dedup # ...while a recently-active remote is retained. + assert 12 in topic.dedup # Recently active -> retained. sub.close() node.close() @@ -47,16 +46,16 @@ async def test_sweep_drops_stale_reordering_states() -> None: sub._reordering[(11, 0xBBBB)] = ReorderingState(last_active_at=base + 10_000.0) node.sweep_stale_states(base + SESSION_LIFETIME + 1.0) - assert (10, 0xAAAA) not in sub._reordering # Idle stream swept... - assert (11, 0xBBBB) in sub._reordering # ...recently-active one retained. + assert (10, 0xAAAA) not in sub._reordering + assert (11, 0xBBBB) in sub._reordering # Recently active -> retained. sub.close() node.close() async def test_housekeeping_loop_sweeps_without_new_traffic(monkeypatch: pytest.MonkeyPatch) -> None: - """The background loop must retire stale state on its own schedule, even if the remote never sends - again (previously the sweep was only triggered by a new arrival).""" + """The background loop retires stale state on its own schedule even if the remote never sends again; + the sweep used to be triggered only by a new arrival.""" monkeypatch.setattr(pycyphal2._node, "HOUSEKEEPING_PERIOD", 0.02) tr = MockTransport(node_id=1, network=MockNetwork()) node = new_node(tr, home="n1") @@ -68,16 +67,16 @@ async def test_housekeeping_loop_sweeps_without_new_traffic(monkeypatch: pytest. if 10 not in topic.dedup: break await asyncio.sleep(0.01) - assert 10 not in topic.dedup # The loop swept it with no further traffic. + assert 10 not in topic.dedup sub.close() node.close() async def test_housekeeping_loop_survives_a_raising_sweep(monkeypatch: pytest.MonkeyPatch) -> None: - """The sweep is the only bound on per-remote state growth, so the loop must outlive a faulty sweep. - Catching only CancelledError let one stray exception kill the task silently -- nothing retrieves its - result -- leaving the node unbounded for the rest of its life.""" + """The sweep is the only bound on per-remote state growth, so the loop must outlive a faulty sweep: + catching only CancelledError let one stray exception kill the task silently (nothing retrieves its + result), leaving the node unbounded thereafter.""" monkeypatch.setattr(pycyphal2._node, "HOUSEKEEPING_PERIOD", 0.02) tr = MockTransport(node_id=1, network=MockNetwork()) node = new_node(tr, home="n1") @@ -102,7 +101,7 @@ def flaky_sweep(now: float) -> None: break await asyncio.sleep(0.01) assert calls >= 2, "the loop died on the first faulty sweep" - assert 10 not in topic.dedup # It recovered and swept on a later tick. + assert 10 not in topic.dedup # Recovered and swept on a later tick. assert not node._housekeeping_task.done() sub.close() @@ -135,7 +134,7 @@ def flaky_retire(now: float) -> bool: break await asyncio.sleep(0.01) assert calls >= 2, "the loop died on the first faulty retirement" - assert "gc-me" not in node.topics_by_name # It recovered and retired the topic. + assert "gc-me" not in node.topics_by_name # Recovered and retired the topic. assert not node._gc_task.done() node.close() diff --git a/tests/test_names.py b/tests/test_names.py index 7f9575d8f..d9db10298 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -80,8 +80,7 @@ def test_pin_non_digit_after_hash() -> None: def test_pin_unicode_digit_not_parsed() -> None: - # str.isdigit() is True for characters that int() rejects, e.g. '²' (U+00B2) or '②' (U+2461); - # the parser must treat them as non-digits and never raise. + # str.isdigit() is True for chars int() rejects, e.g. '²' (U+00B2) or '②' (U+2461); must not raise. assert _name_consume_pin_suffix("foo#²") == ("foo#²", None) assert _name_consume_pin_suffix("foo#1²") == ("foo#1²", None) assert _name_consume_pin_suffix("foo#²1") == ("foo#²1", None) @@ -95,8 +94,8 @@ def test_wire_name_unicode_digit_pin_rejected() -> None: def test_resolve_embedded_token_is_verbatim() -> None: - # Only a whole segment equal to '*' or '>' is a substitution token (reference: - # wkv_has_substitution_tokens); embedded within a longer segment they are literal characters. + # Only a whole segment '*' or '>' is a substitution token (reference: wkv_has_substitution_tokens); + # embedded in a longer segment they are literal characters. resolved, _, verbatim = resolve_name("/sensor/temp*raw", "home", "ns") assert resolved == "sensor/temp*raw" assert verbatim @@ -112,7 +111,7 @@ def test_resolve_whole_segment_tokens_are_patterns() -> None: def test_wire_name_embedded_token_is_valid() -> None: - # A legal verbatim C topic like 'ab*cd' must be accepted from gossip for interop. + # A verbatim C topic like 'ab*cd' must be accepted from gossip for interop. assert _is_valid_wire_name("ab*cd") assert _is_valid_wire_name("x/y>z") assert not _is_valid_wire_name("a/*/c") diff --git a/tests/test_parity.py b/tests/test_parity.py index 5aa6e5458..0680ce47a 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -514,8 +514,8 @@ async def test_gossip_shard_formula(): async def test_broadcast_subject_id_formula(): """Broadcast subject-ID formula: broadcast_sid = (1 << (floor(log2(PINNED_MAX + modulus)) + 1)) - 1.""" - # All moduli must satisfy the reference predicate (>= 57203, prime, ≡ 3 mod 4); 131071 is a Mersenne - # prime and 57203 is the 16-bit floor. They still span different log2 buckets of the formula. + # All moduli satisfy the reference predicate (>= 57203, prime, ≡ 3 mod 4) yet span different log2 + # buckets of the formula. for modulus in [DEFAULT_MODULUS, 8378431, 131071, 57203]: net = MockNetwork() tr = MockTransport(node_id=1, modulus=modulus, network=net) diff --git a/tests/test_reliable.py b/tests/test_reliable.py index 66e714f69..d35dc12ef 100644 --- a/tests/test_reliable.py +++ b/tests/test_reliable.py @@ -203,7 +203,7 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat async def test_gossip_reallocation_to_occupied_subject_preserves_writer(): net = MockNetwork() # Smallest modulus satisfying the reference predicate (>= 57203, prime, ≡ 3 mod 4); the collision - # search below is O(modulus) but breaks on the first hit, so it typically runs in ~modulus iterations. + # search below breaks on the first hit, so it runs in ~modulus iterations. tr = MockTransport(node_id=1, modulus=57203, network=net) node = new_node(tr, home="n1") pub_a = node.advertise("/topic_a") diff --git a/tests/test_topic.py b/tests/test_topic.py index 4f5715a8d..ae85eb189 100644 --- a/tests/test_topic.py +++ b/tests/test_topic.py @@ -44,8 +44,8 @@ def test_compute_subject_id_non_pinned_zero_evictions(): def test_compute_subject_id_non_pinned_with_evictions(): - """Non-pinned formula: offset + ((hash + evictions^2) mod 2^64) % modulus; the modular-reduction form - used here for the expectation is equivalent whenever the sum does not overflow 64 bits.""" + """Non-pinned formula: offset + ((hash + evictions^2) mod 2^64) % modulus; the reduced form used for + the expectation below is equivalent absent 64-bit overflow.""" topic_hash = rapidhash("some/topic") for ev in (1, 2, 5, 100): sid = compute_subject_id(topic_hash, ev, DEFAULT_MODULUS) @@ -61,9 +61,9 @@ def test_compute_subject_id_non_pinned_with_evictions(): def test_compute_subject_id_wraps_uint64_sum(): - """The hash + evictions² sum wraps mod 2^64 before reduction, matching the reference uint64 arithmetic - bit-for-bit. The eviction count is an untrusted uint32 gossip field, so the overflowing case is remotely - constructible; exact big-int arithmetic here would partition Python and C nodes onto different subject-IDs.""" + """hash + evictions² must wrap mod 2^64 as the reference uint64 arithmetic does. Evictions is an + untrusted gossip field, so the overflow is remotely constructible; big-int arithmetic here would put + Python and C nodes on different subject-IDs.""" topic_hash = (1 << 64) - 1 evictions = EVICTIONS_PINNED_MIN - 1 sid = compute_subject_id(topic_hash, evictions, DEFAULT_MODULUS) @@ -111,9 +111,9 @@ def test_is_valid_subject_id_modulus_predicate(): async def test_degenerate_subject_id_modulus_rejected(): - """A modulus violating the reference predicate must be rejected at node construction: the quadratic - probe (hash + evictions²) mod m does not cover the residue space under a degenerate modulus, so the - synchronous displacement loop in topic_allocate would hard-block the event loop.""" + """A degenerate modulus must be rejected at node construction: the quadratic probe (hash + evictions²) + mod m would not cover the residue space, so topic_allocate's synchronous displacement loop would + hard-block the event loop.""" for bad in (3, 57202, 57205, 57207, 122744): tr = MockTransport(node_id=1, modulus=bad, network=MockNetwork()) with pytest.raises(ValueError, match="subject_id_modulus"): @@ -145,7 +145,7 @@ async def test_advertise_creates_topic(): async def test_advertise_embedded_wildcard_char_is_verbatim(): """'sensor/temp*raw' has no whole-segment substitution token, so it is a legal verbatim topic - (reference parity with the wkv classifier) and can be advertised.""" + (parity with the wkv classifier).""" net = MockNetwork() tr = MockTransport(node_id=1, network=net) node = new_node(tr, home="n") diff --git a/tests/test_transactional_setup.py b/tests/test_transactional_setup.py index 87141bf01..661effdf7 100644 --- a/tests/test_transactional_setup.py +++ b/tests/test_transactional_setup.py @@ -1,6 +1,6 @@ -"""Regression tests for finding #11: setup paths must be transactional — a transport failure mid-setup -must not leave unrepairable half-state, and subscribe-path listener failures follow the reference repair -model (logged, retried) rather than raising.""" +"""Regression tests for finding #11: setup paths are transactional (a mid-setup transport failure leaves +no unrepairable half-state), and subscribe-path listener failures follow the reference repair model +(logged, retried) rather than raising.""" from __future__ import annotations @@ -27,15 +27,14 @@ async def test_node_init_rolls_back_broadcast_writer_on_listen_failure() -> None tr.fail_subject_listen.add(_broadcast_sid()) with pytest.raises(RuntimeError, match="Simulated subject_listen"): new_node(tr, home="n") - assert tr.writers == {} # The broadcast writer was rolled back, so a retry sees no duplicate. + assert tr.writers == {} # Rolled back, so a retry sees no duplicate. assert tr.subject_handlers == {} async def test_node_init_rolls_back_broadcast_handles_on_unicast_listen_failure() -> None: - """unicast_listen is the LAST fallible acquisition in the constructor. Both built-in transports - implement it as a plain assignment, but a third-party one may not -- and nobody closes the transport - when the constructor raises, so an unguarded failure here strands the broadcast writer and listener - acquired just above it.""" + """unicast_listen is the LAST fallible acquisition in the constructor -- a plain assignment in both + built-in transports but not necessarily in a third-party one -- and nobody closes the transport when + the constructor raises, so an unguarded failure here strands the broadcast writer and listener.""" tr = MockTransport(node_id=1, network=MockNetwork()) def failing_unicast_listen(_handler): # type: ignore[no-untyped-def] @@ -44,8 +43,8 @@ def failing_unicast_listen(_handler): # type: ignore[no-untyped-def] tr.unicast_listen = failing_unicast_listen # type: ignore[method-assign] with pytest.raises(RuntimeError, match="Simulated unicast_listen"): new_node(tr, home="n") - assert tr.writers == {} # Broadcast writer rolled back... - assert tr.subject_handlers == {} # ...and so was the broadcast listener. + assert tr.writers == {} + assert tr.subject_handlers == {} async def test_advertise_rolls_back_pub_count_on_writer_failure() -> None: @@ -63,7 +62,7 @@ async def test_advertise_rolls_back_pub_count_on_writer_failure() -> None: with pytest.raises(RuntimeError, match="Simulated subject_advertise"): node.advertise("/topic_a") - topic = node.topics_by_name["topic_a"] # The topic exists but as an ordinary implicit topic. + topic = node.topics_by_name["topic_a"] # Survives, but as an ordinary implicit topic. assert topic.pub_count == 0 assert topic.is_implicit assert topic.pub_writer is None @@ -88,7 +87,7 @@ async def test_subscribe_listener_failure_is_repaired_not_raised() -> None: tr.fail_subject_listen.add(topic_sid) sub = node.subscribe("/topic_v") # Repair model: subscribe does not raise on listener failure. topic = node.topics_by_name["topic_v"] - assert topic.sub_listener is None # Listener not yet acquired. + assert topic.sub_listener is None creations_before = tr.subject_listener_creations.get(topic_sid, 0) tr.fail_subject_listen.clear() # The next sync opportunity repairs it. @@ -112,7 +111,7 @@ async def test_topic_ensure_rolls_back_on_gossip_shard_failure() -> None: with pytest.raises(RuntimeError, match="Simulated subject_advertise"): node.advertise("/topic_s") - assert "topic_s" not in node.topics_by_name # Fully rolled back, both registries clean. + assert "topic_s" not in node.topics_by_name # Both registries rolled back. assert rapidhash("topic_s") not in node.topics_by_hash tr.fail_subject_advertise.clear() # Retry succeeds. diff --git a/tests/test_udp.py b/tests/test_udp.py index d31462a61..f6f8ddade 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -275,9 +275,9 @@ def test_transfer_id_dedup(self): assert result2 is None # Dedup def test_history_seed_sentinel_not_matchable_by_wire_id(self): - """A first-seen transfer-ID of 0 seeds the dedup history with (0 - 1) wrapped to 2^64-1, which no - 48-bit wire transfer-ID can equal (the reference keeps the unmasked uint64). A 48-bit-masked seed - would equal 0xFFFF_FFFF_FFFF — a valid wire value — falsely rejecting a genuine such transfer.""" + """A first-seen transfer-ID of 0 seeds the dedup history with (0 - 1) wrapped to 2^64-1, unmatchable + by any 48-bit wire ID (the reference keeps the unmasked uint64); a 48-bit-masked seed would equal + the valid wire value 0xFFFF_FFFF_FFFF and falsely reject it.""" reasm = _RxReassembler() first = self._make_frames(b"first", mtu=1400, transfer_id=0) assert reasm.accept(first[0][0], first[0][1]) is not None @@ -435,8 +435,8 @@ def test_ninth_concurrent_transfer_sacrifices_oldest_slot(self): assert slot_transfer_ids == set(range(2, 10)) def test_drop_stale_sessions_retires_idle_without_new_traffic(self): - """drop_stale_sessions retires every session past its lifetime, independent of new frames, so a - silent remote's session is reclaimed on the periodic tick (finding #3).""" + """Sessions past their lifetime are retired independently of new frames, so a silent remote is + reclaimed on the periodic tick (finding #3).""" reasm = _RxReassembler() old = self._make_frames(b"old", mtu=1400, sender_uid=100, transfer_id=1) reasm.accept(old[0][0], old[0][1], timestamp_ns=0) @@ -450,7 +450,7 @@ def test_drop_stale_sessions_retires_idle_without_new_traffic(self): assert 200 in reasm._sessions # Still within its lifetime -> retained. def test_sessions_bounded_by_lru_capacity(self): - """Session count is capacity-bounded, so a burst of unique source UIDs within the lifetime window + """Session count is capacity-bounded: a burst of unique source UIDs within the lifetime window cannot grow the map without bound (finding #3).""" reasm = _RxReassembler() for uid in range(_RX_SESSION_CAPACITY + 20): @@ -514,10 +514,9 @@ def test_bridge_fragment_evicts_victim(self): assert [(frag.offset, frag.data) for frag in slot.fragments] == [(0, b"AAAA"), (2, b"XXXXXX"), (6, b"CCCC")] def test_conflicting_overlap_evicted_via_inclusive_right_neighbor(self): - """A fragment starting exactly at the new fragment's end is its right neighbor (the reference's - cavl2_predecessor is an inclusive floor), so a conflicting overlapped fragment between them is - evicted and the transfer is delivered. A strict '<' lookup would keep the stale fragment and - fail the transfer CRC. Only observable when overlapping fragments carry conflicting data.""" + """cavl2_predecessor is an inclusive floor, so a fragment starting exactly at the new one's end is + its right neighbor and the conflicting fragment between them is evicted; a strict '<' lookup would + keep the stale fragment and fail the transfer CRC.""" payload = b"ABCDEFGH" def hdr(offset: int, crc: int = 0) -> _FrameHeader: @@ -976,9 +975,8 @@ async def test_operations_after_close_fail(self): @pytest.mark.skipif(sys.platform != "linux", reason="IP_MULTICAST_ALL is a Linux-only socket option") def test_mcast_socket_disables_cross_interface_delivery(self, loopback_iface): - """On Linux the multicast RX socket must set IP_MULTICAST_ALL=0 so it only receives datagrams - matching its own (group, interface) membership - the kernel-level equivalent of the reference's - recvmsg+IP_PKTINFO ingress-interface filter (udp_wrapper.c).""" + """IP_MULTICAST_ALL=0 confines the RX socket to its own (group, interface) membership -- the kernel + equivalent of the reference's recvmsg+IP_PKTINFO ingress-interface filter (udp_wrapper.c).""" sock = _UDPTransportImpl._create_mcast_socket(5, loopback_iface) try: assert sock.getsockopt(socket.IPPROTO_IP, _IP_MULTICAST_ALL_LINUX) == 0 @@ -1228,9 +1226,8 @@ async def all_fail(sock, data, addr, deadline): # type: ignore[no-untyped-def] def test_collect_send_errors_counts_cancellation_as_a_failure() -> None: - """asyncio.gather(..., return_exceptions=True) reports an individually cancelled child as a - CancelledError INSTANCE, which derives from BaseException, not Exception. The old - isinstance(r, Exception) predicate therefore scored a cancelled interface as a delivery.""" + """gather(return_exceptions=True) reports a cancelled child as a CancelledError instance (a + BaseException), so the old isinstance(r, Exception) test scored a cancelled interface as a delivery.""" oserr = OSError("down") assert _collect_send_errors([None, None]) == [] assert _collect_send_errors([None, oserr]) == [oserr] @@ -1240,8 +1237,7 @@ def test_collect_send_errors_counts_cancellation_as_a_failure() -> None: async def test_send_cancelled_on_every_interface_is_not_reported_as_success() -> None: - """With every interface cancelled the send used to return normally, reporting a fully successful - transfer that never put a byte on the wire.""" + """An all-cancelled send used to return normally, reporting a transfer that never hit the wire.""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) pub = UDPTransport.new(interfaces=[iface, iface]) assert isinstance(pub, _UDPTransportImpl) @@ -1270,8 +1266,8 @@ async def cancel_first(sock, lock, frames, addr, deadline): # type: ignore[no-u @pytest.mark.asyncio async def test_redundant_interfaces_send_concurrently() -> None: - """A congested interface must not starve a healthy one of the shared deadline: interfaces are sent - to concurrently, so one transfer's wall-clock is ~max(per-iface), not the sum (finding #6).""" + """A congested interface must not starve a healthy one of the shared deadline: sends run concurrently, + so a transfer's wall-clock is ~max(per-iface), not the sum (finding #6).""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) pub = UDPTransport.new(interfaces=[iface, iface, iface]) assert isinstance(pub, _UDPTransportImpl) @@ -1325,8 +1321,8 @@ async def tracking_sendto(sock, data, addr, deadline): # type: ignore[no-untype @pytest.mark.asyncio async def test_short_deadline_send_fails_on_own_budget_behind_long_holder() -> None: - """A short-deadline sender queued behind a long-deadline holder of the same socket lock must fail on - its own deadline rather than waiting out the holder (the lock acquisition is deadline-bounded).""" + """A short-deadline sender queued behind a long-deadline holder of the same socket lock fails on its + own deadline rather than waiting out the holder: lock acquisition is deadline-bounded.""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) pub = UDPTransport.new(interfaces=[iface]) assert isinstance(pub, _UDPTransportImpl) @@ -1341,28 +1337,27 @@ async def slow_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-de holder = pub.subject_advertise(10) waiter = pub.subject_advertise(11) # Same interface -> same socket lock. holder_task = asyncio.create_task(holder(Instant.now() + 100.0, Priority.NOMINAL, b"hold")) - await holder_entered.wait() # The holder now owns the lock and is parked in the send. + await holder_entered.wait() with pytest.raises(SendError): await waiter(Instant.now() + 0.15, Priority.NOMINAL, b"wait") holder_release.set() - await holder_task # The holder still completes cleanly. + await holder_task - # The lock-acquisition timeout must not have leaked the socket lock: it is free and re-acquirable. + # The lock-acquisition timeout must not have leaked the socket lock. assert not pub._tx_locks[0].locked() async def ok_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def] pass # Avoid a real multicast send (unroutable on Windows loopback); still re-acquires the lock. with patch.object(pub, "async_sendto", ok_sendto): - await waiter(Instant.now() + 2.0, Priority.NOMINAL, b"after") # Succeeds via send_on_iface. + await waiter(Instant.now() + 2.0, Priority.NOMINAL, b"after") pub.close() @pytest.mark.asyncio async def test_close_during_send_raises_send_error_not_index_error() -> None: """A send suspended when close() empties the socket lists must surface a clean SendError, never an - IndexError (finding #9). The snapshotted socket is closed by then, so the resumed send fails on the - closed fd and is aggregated, exactly as a real EBADF would be.""" + IndexError (finding #9); the resumed send fails on the by-then-closed fd, as a real EBADF would.""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) pub = UDPTransport.new(interfaces=[iface]) assert isinstance(pub, _UDPTransportImpl) @@ -1377,8 +1372,8 @@ async def blocking_sendto(sock, data, addr, deadline): # type: ignore[no-untype with patch.object(pub, "async_sendto", blocking_sendto): writer = pub.subject_advertise(10) send_task = asyncio.create_task(writer(Instant.now() + 5.0, Priority.NOMINAL, b"payload")) - await started.wait() # The send is parked inside _send_on_iface. - pub.close() # Clears _tx_socks/_tx_locks; the snapshot keeps the send index-safe. + await started.wait() + pub.close() # Clears _tx_socks/_tx_locks; the snapshot keeps the parked send index-safe. release.set() with pytest.raises(SendError) as excinfo: await send_task @@ -1389,7 +1384,7 @@ async def blocking_sendto(sock, data, addr, deadline): # type: ignore[no-untype @pytest.mark.asyncio async def test_subject_listen_rolls_back_partial_interface_setup() -> None: - """A per-interface socket failure mid-subject_listen must roll back the handler and every socket/task + """A per-interface socket failure mid-subject_listen rolls back the handler and every socket/task created so far, so a retry is not blocked by the duplicate-listener check (finding #11).""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) t = UDPTransport.new(interfaces=[iface, iface]) @@ -1420,8 +1415,8 @@ def flaky_create(subject_id, iface): # type: ignore[no-untyped-def] @pytest.mark.asyncio async def test_remote_endpoints_bounded_by_lru_capacity() -> None: - """The learned reverse-route cache is capacity-bounded with LRU eviction, so it cannot grow without - bound under untrusted traffic with spoofable source UIDs (finding #3).""" + """The learned reverse-route cache is LRU-bounded, so spoofable source UIDs cannot grow it without + bound (finding #3).""" t = UDPTransport.new_loopback() assert isinstance(t, _UDPTransportImpl) try: @@ -1436,8 +1431,8 @@ async def test_remote_endpoints_bounded_by_lru_capacity() -> None: @pytest.mark.asyncio async def test_close_releases_unicast_and_endpoint_state() -> None: - """close() must release the state the finding flagged as surviving it: the unicast reassembler's - sessions, the learned endpoint cache, and the unicast handler reference (finding #3).""" + """close() must release the state that used to survive it: the unicast reassembler's sessions, the + learned endpoint cache, and the unicast handler reference (finding #3).""" t = UDPTransport.new_loopback() assert isinstance(t, _UDPTransportImpl) t.unicast_listen(lambda _a: None) @@ -1469,9 +1464,8 @@ async def test_housekeeping_loop_retires_stale_sessions(monkeypatch: pytest.Monk async def test_housekeeping_loop_survives_a_raising_sweep(monkeypatch: pytest.MonkeyPatch) -> None: - """Session retirement here is not traffic-driven, so the loop must outlive a faulty sweep. Catching - only CancelledError let one stray exception kill the task silently, leaking sessions for the rest of - the transport's life.""" + """Session retirement is not traffic-driven, so the loop must outlive a faulty sweep: catching only + CancelledError let one stray exception kill the task silently, leaking sessions thereafter.""" monkeypatch.setattr("pycyphal2.udp._HOUSEKEEPING_PERIOD", 0.02) monkeypatch.setattr("pycyphal2.udp._RX_SESSION_LIFETIME_NS", 1) t = UDPTransport.new_loopback() @@ -1501,8 +1495,8 @@ def flaky_drop(self, now_ns): # type: ignore[no-untyped-def] @pytest.mark.asyncio async def test_tx_socket_creation_failure_rolls_back_created_sockets() -> None: - """A TX-socket creation failure mid-construction must roll back the interface sockets created before - it, rather than leaking their file descriptors (finding #11 completeness).""" + """A TX-socket creation failure mid-construction rolls back the sockets created before it instead of + leaking their file descriptors (finding #11 completeness).""" iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500) real_create = _UDPTransportImpl._create_tx_socket created: list[socket.socket] = []