Skip to content

fix(rtps): deliver SPDP DATA with readerId ENTITYID_UNKNOWN to the SPDP reader - #871

Merged
finger563 merged 2 commits into
esp-cpp:mainfrom
ewang360:rtps-spdp-unknown-reader
Nov 2, 2026
Merged

finger563 merged 2 commits into
esp-cpp:mainfrom
ewang360:rtps-spdp-unknown-reader

Conversation

@ewang360

@ewang360 ewang360 commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A DATA submessage may be addressed to the destination participant instead of to one reader (readerId ENTITYID_UNKNOWN, RTPS 2.5 §8.3.7.2). MessageReceiver::processDataSubmessage() then resolves the reader through the writer it is matched to. That works for SEDP and user data, whose readers hold a proxy per remote writer, but not for SPDP: the SPDP reader is best-effort and never matches remote SPDP writers, so such a DATA found no reader and was dropped.

CycloneDDS sends its periodic participant announcements exactly this way; only its first message to a newly seen peer names the SPDP reader (0:0:0:100c7), and the repeats reuse sequence number 1. So discovery succeeded, but no later announcement reached SPDPAgent, the peer's liveliness was never refreshed, and checkAndResetHeartbeats() removed it once its advertised lease (10 s for Cyclone) had passed, dropping every matched endpoint with it. Cyclone did not know it had been forgotten and never resent its SEDP, so the peer never came back. A longer lease on the Cyclone side only delayed it.

  • MessageReceiver: a DATA from ENTITYID_SPDP_BUILTIN_PARTICIPANT_WRITER with an unknown reader is delivered to ENTITYID_SPDP_BUILTIN_PARTICIPANT_READER. Everything else keeps the matched-writer lookup.

  • Regression test (review follow-up, 2d5d1c0): pc/tests/rtps_spdp_unknown_reader.cpp, standalone, no peer. It hands crafted datagrams to a participant through Participant::newMessage(): an announcement naming the SPDP reader adds the peer; a re-announcement with readerId ENTITYID_UNKNOWN and the same sequence number must refresh its liveliness (fails on main at exactly that check); a first announcement with an unnamed reader is discovered too; an unnamed-reader DATA from a non-SPDP writer is still dropped. Runs under host_sanitizers.yml with the other rtps_* tests. test(rtps): interop cases for lease liveliness, many participants, and the CycloneDDS addressing fixes #874 covers the same class against a real CycloneDDS peer.

No wire-format change. Independent of #870 (the participant-pool knob); the two were found together on the same bench.

Test plan

  • RAMMP MIB (ESP32-P4, IDF 6.1) against a ROS 2 Humble host with 16 rmw_cyclonedds participants: before, every Cyclone peer's MibStatus stream stopped 10–16 s after it started (seq frozen, Cyclone trace showed the MIB's SEDP going unanswered afterwards); after, a peer on the default 10 s lease stayed matched for the whole 90 s test run, with 2 Hz status in and DriveCommand/TwistSetpoint out
  • A CycloneDDS trace confirmed the periodic SPDP DATA arrive with reader 0:0:0:0 and seq #1; the initial ones with …:100c7
  • pc/tests/rtps_spdp_unknown_reader passes with the fix and fails on main's MessageReceiver.cpp (FAIL: re-announcement with readerId ENTITYID_UNKNOWN refreshes liveliness); rtps_pubsub, rtps_locator_subnet and rtps_sedp_dedicated_locator still pass on the host build
  • Host interop tests (pc/tests/rtps_interop_*) not run here
  • CI

🤖 Generated with Claude Code

…DP reader

A DATA submessage may be addressed to the destination participant instead of
to one reader (readerId ENTITYID_UNKNOWN, RTPS 2.5 8.3.7.2). The receiver then
finds the reader through the writer it is matched to. That works for SEDP and
user data, whose readers hold a proxy per remote writer, but not for SPDP: the
SPDP reader is best-effort and never matches remote SPDP writers, so such a
DATA found no reader and was dropped.

CycloneDDS sends its periodic participant announcements exactly this way; only
its first message to a newly seen peer names the SPDP reader. So discovery
succeeded, but no later announcement reached SPDPAgent, the peer's liveliness
was never refreshed, and checkAndResetHeartbeats() removed it once its lease
(10 s for Cyclone) had passed, dropping every matched endpoint with it. On a
RAMMP MIB (ESP32-P4) against a ROS 2 Humble host, every rmw_cyclonedds peer
died 10 to 16 s after discovery and never came back, since Cyclone did not know
it had been forgotten and never resent its SEDP. A longer lease on the Cyclone
side only delayed it.

Route a DATA from ENTITYID_SPDP_BUILTIN_PARTICIPANT_WRITER with an unknown
reader to ENTITYID_SPDP_BUILTIN_PARTICIPANT_READER. No wire change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

ewang360 added a commit to rammp-org/RAMMP-mib-fw that referenced this pull request Nov 1, 2026
Every reader and writer, the two SEDP discovery endpoints included, holds a pool of
remote proxies whose depth on the esp32 profile is 6. Each remote participant takes one
slot in each SEDP writer and reader, so the MIB completed discovery with six peers and
silently skipped the rest. On the bench, the seventh ROS 2 node never saw the MIB's
endpoints. 24 covers the Jetson's graph with room; about 60 bytes a proxy.

The two remaining limits are espp's: the remote-participant pool of 10
(esp-cpp/espp#870 adds the knob) and CycloneDDS peers expiring after their lease because
their repeated SPDP is dropped (esp-cpp/espp#871). Until those ship, a Jetson with more
than ten DDS participants loses the MIB after about ten seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@finger563
finger563 requested a balanced review from Copilot November 2, 2026 01:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

2 open issues
What changed in this PR

Fixes RTPS discovery interop with CycloneDDS by correctly delivering SPDP DATA submessages addressed to the destination participant (readerId ENTITYID_UNKNOWN) to the SPDP reader, preventing peers from expiring due to missed periodic announcements.

Changes:

  • Special-cases SPDP participant writer DATA with unknown readerId to route directly to the SPDP builtin participant reader.
  • Retains the existing matched-writer lookup path for all other writers.
File Description
components/​rtps/​src/​messages/​MessageReceiver.cpp Routes SPDP DATA with ENTITYID_UNKNOWN readerId to the SPDP reader instead of relying on matched-writer proxy lookup.

Review effort: Lite

Comment on lines +234 to +246
if (dataSubmsg.writerId == ENTITYID_SPDP_BUILTIN_PARTICIPANT_WRITER) {
// SPDP DATA addressed to the participant rather than to our SPDP reader
// (readerId ENTITYID_UNKNOWN, RTPS 2.5 8.3.7.2): CycloneDDS sends its
// periodic announcements this way, and only its first message to a newly
// seen peer names the reader. The SPDP reader is best-effort and matches
// no remote writer, so the proxy lookup below never finds it and the
// repeat was dropped: the peer's liveliness was never refreshed and it
// expired after its lease, taking every matched endpoint with it.
reader = mp_part->getReader(ENTITYID_SPDP_BUILTIN_PARTICIPANT_READER, readerGen);
} else {
reader = mp_part->getReaderByWriterId(
Guid_t{sourceState.sourceGuidPrefix, dataSubmsg.writerId}, readerGen);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2d5d1c0: pc/tests/rtps_spdp_unknown_reader.cpp, a standalone host test (picked up by the pc/tests glob and by host_sanitizers.yml, no Docker or peer). It hands crafted datagrams to a participant through Participant::newMessage(): (1) an announcement naming the SPDP reader adds the peer, the control; (2) a re-announcement with readerId ENTITYID_UNKNOWN and the same sequence number, what CycloneDDS sends, must refresh its liveliness; (3) a first announcement with an unnamed reader is discovered too; (4) an unnamed-reader DATA from a non-SPDP writer is still dropped, so the routing stays scoped to the SPDP writer. Verified it fails on upstream's MessageReceiver.cpp at check 2 (FAIL: re-announcement with readerId ENTITYID_UNKNOWN refreshes liveliness) and passes with the fix. #874 covers the same class end to end against a real CycloneDDS peer.

Comment on lines +235 to +241
// SPDP DATA addressed to the participant rather than to our SPDP reader
// (readerId ENTITYID_UNKNOWN, RTPS 2.5 8.3.7.2): CycloneDDS sends its
// periodic announcements this way, and only its first message to a newly
// seen peer names the reader. The SPDP reader is best-effort and matches
// no remote writer, so the proxy lookup below never finds it and the
// repeat was dropped: the peer's liveliness was never refreshed and it
// expired after its lease, taking every matched endpoint with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed in 2d5d1c0 to the rationale and the spec citation (RTPS 2.5 8.3.7.2), plus a pointer to the regression test. The CycloneDDS behaviour and the lease-expiry chain stay in the PR description and the commit message.

…OWN; trim the receiver comment

pc/tests/rtps_spdp_unknown_reader.cpp hands crafted datagrams to a participant
through Participant::newMessage(), no network: an announcement naming the SPDP
reader adds the peer; a re-announcement with readerId ENTITYID_UNKNOWN and the
same sequence number (what CycloneDDS sends) must refresh its liveliness, which
fails without the fix; a first announcement with an unnamed reader is discovered
too; and an unnamed-reader DATA from a non-SPDP writer is still dropped, so the
routing stays scoped to the SPDP writer. It is picked up by the pc/tests glob
and runs standalone in host_sanitizers.yml.

The inline comment in MessageReceiver.cpp now carries only the rationale and
the spec citation and points at the test; the failure story stays in the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@finger563
finger563 merged commit df173f4 into esp-cpp:main Nov 2, 2026
178 checks passed
finger563 added a commit that referenced this pull request Nov 2, 2026
…d the CycloneDDS addressing fixes (#874)

* test(rtps): interop cases for lease liveliness and a busy domain, on FastDDS and CycloneDDS

Two defect classes found against CycloneDDS peers had no coverage because
every ROS 2 pair in the matrix proves a single sample within a few
seconds:

- Liveliness past the remote participant lease. A peer whose SPDP
  re-announcements are not delivered to the SPDP reader has its lease
  expire (10 s for CycloneDDS, which addresses the repeats to readerId
  ENTITYID_UNKNOWN) and loses every matched endpoint - discovery looked
  fine, data stopped after the lease. Both legs now run ~30 s and count
  only samples that arrive after 16 s: `rtps_interop_sub` gains a
  backward-compatible `count_after_s` argument, and a small rclpy
  `ros2_count_subscriber.py` does the same on the ROS 2 side. Run on the
  FastDDS pass as a control and on the CycloneDDS pass, where it fails
  until the SPDP-reader delivery fix (#871) is in.
- A busy domain. Twelve unrelated ROS 2 participants are up before espp
  starts, then the publisher espp needs joins last and must still be
  matched: a participant beyond the remote-participant pool is ignored
  silently. The harness builds the host profile (32 slots) so this
  passes; the embedded profile's 10 slots need the pool raised for such a
  domain (#870's knob), which the note and README say.

A live multi-homed-peer case (the #865 class) is described in the README
as follow-up work: it needs a peer address espp cannot reach, which one
shared network namespace cannot provide.

Workflow timeout 30 -> 40 min for the ~3 min the four new runs add.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* test(rtps): interop liveliness window past the worst-case expiry; lift CycloneDDS's participant-index cap

Count from 22 s in, not 16: discovery, the 10 s lease and the liveliness
check cadence all fit before that, so a peer dropped at its lease cannot
contribute to the count. CycloneDDS allows ten participants per host per
domain by default (MaxAutoParticipantIndex 9); the busy-domain case
creates thirteen, so the CycloneDDS pass raises it through CYCLONEDDS_URI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* test(rtps): interop review follow-ups; note why only the espp-publisher liveliness leg detects a dropped peer

- cleanup `wait`s in the new cases tolerate an already-reaped PID
- rtps_interop_sub: count_after_s < 0 is a usage error (exit 1); early
  samples are verified byte-exact and reported like counted ones, only the
  count differs
- ros2_count_subscriber.py: argparse with typed, non-negative arguments
- README: CI showed only `espp_pub->ros2_sub_liveliness` failing on
  CycloneDDS. A HEARTBEAT reaching a matched espp reader refreshes the
  sending participant's liveliness, so a CycloneDDS writer keeps its
  participant alive through its heartbeats even with its SPDP
  re-announcements lost; an espp writer only gets ACKNACKs, which do not,
  and the peer expires.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ewang360 added a commit to rammp-org/RAMMP-mib-fw that referenced this pull request Nov 3, 2026
espp 1.4.0 ships esp-cpp/espp#871, so a CycloneDDS peer's repeated announcements reach the
MIB and it no longer drops the Jetson's nodes 10 s after discovery, and #870, so the
remote-participant pool is a knob: 32 here, beside the history and proxy caps from #61.
The manifest's floor moves to 1.4.0 and the other espp components follow it. docs/ros2.md
drops the "until the release" wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants