feat(rtps_embedded): Phase 4 — limit profiles, dynamic host memory, DATA_FRAG large payloads - #710
feat(rtps_embedded): Phase 4 — limit profiles, dynamic host memory, DATA_FRAG large payloads#710finger563 wants to merge 9 commits into
Conversation
Restructure the compile-time limits into named, build-time-selectable profiles so one codebase serves both tiny MCU nodes and compute hosts, keeping the fully static allocation model: - embedded (config_esp32.hpp, unchanged/byte-identical): tight MCU caps. - host (config_desktop.hpp, relaxed; the default non-ESP profile): sensible capacity for a compute host out of the box (e.g. MAX_NUM_PARTICIPANTS 2->8, stateful writers/readers ->32, unmatched remotes ->256/128). MAX_NUM_UNMATCHED remote writer/reader counts widened from uint8_t to uint16_t (values exceed 255). - host_large (config_host_large.hpp, new): larger caps for big DDS graphs. - Selected via a CMake RTPS_LIMITS_PROFILE option (embedded|host|host_large, default host on non-ESP) that drives the existing RTPS_CONFIG_HEADER switch, plus an ESP Kconfig choice (default embedded). Wire-neutral: limits cap capacity, not encoding. Gates: golden byte-identical under host and host_large; engine + facade + typed loopbacks PASS; interop 8/8; esp32 builds with the default embedded profile (config_esp32.hpp untouched). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…FRAG slice A) Sample-size type DataSize_t was uint16_t, capping a whole sample at 64 KB and silently truncating PayloadBuffer::spaceUsed() above that. Widen it to uint32_t so a sample can exceed 64 KB internally (prerequisite for DATA_FRAG). Wire-format neutral - only the internal type widens; on-the-wire length fields stay 16-bit: - common/types.hpp: DataSize_t uint16_t -> uint32_t (the ~38 references are typed params/returns and widen automatically; spaceUsed() now holds >64 KB). - MessageFactory.hpp: the one place a size feeds a 16-bit wire field (octetsToNextHeader for a non-fragmented DATA) is explicitly narrowed with static_cast<uint16_t> + comment (a single unfragmented DATA is <64 KB, so the narrowing is correct). - rtps_participant: max_payload_size stays 65535 (one unfragmented DATA submessage is still bounded by the 16-bit octetsToNextHeader); the static_assert now checks against uint16_t max (raised when DATA_FRAG lands). Gates: golden byte-identical (goldens never regenerated - proves wire neutrality of the widening); engine + facade + typed loopbacks PASS; docker interop 8/8 PASS vs ROS 2; esp32 example builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ep 3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
✅Static analysis result - no issues found! ✅ |
A `git add -A` fallback in an earlier commit swept up two local-only, generated artifacts that are not part of the repo: - docs/ : the Doxygen/Sphinx documentation OUTPUT (built from doc/); ~785 files. - build_examples.sh : a local example-build helper. Remove both from tracking (files kept on disk) and add them to .gitignore so they cannot be committed again. Neither exists on main; with squash-merge the net result is that they never land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 36 changed files in this pull request and generated 6 comments.
Suppressed comments (5)
components/rtps_embedded/src/entities/StatefulWriter.cpp:350
- This clamps a DATA_FRAG payload using the DATA limit. DATA_FRAG has a 36-byte raw header rather than DATA's 24-byte header, so its maximum fragment payload is 65,439 bytes, not
MAX_UNFRAGMENTED_PAYLOAD(65,451). A configured size in that 12-byte gap creates a UDP payload larger than 65,507 bytes andsendPacketcannot transmit it. Introduce a DATA_FRAG-specific maximum and clamp against it.
const uint16_t fragmentSize = m_fragmentSize > MAX_UNFRAGMENTED_PAYLOAD
? static_cast<uint16_t>(MAX_UNFRAGMENTED_PAYLOAD)
: m_fragmentSize;
components/rtps_embedded/src/entities/StatelessWriter.cpp:225
- This clamps a DATA_FRAG payload using the DATA limit. DATA_FRAG has a 36-byte raw header rather than DATA's 24-byte header, so its maximum fragment payload is 65,439 bytes, not
MAX_UNFRAGMENTED_PAYLOAD(65,451). A configured size in that 12-byte gap creates a UDP payload larger than 65,507 bytes andsendPacketcannot transmit it. Introduce a DATA_FRAG-specific maximum and clamp against it.
const uint16_t fragmentSize = m_fragmentSize > MAX_UNFRAGMENTED_PAYLOAD
? static_cast<uint16_t>(MAX_UNFRAGMENTED_PAYLOAD)
: m_fragmentSize;
components/rtps_embedded/src/entities/Reader.cpp:52
- A delayed fragment from an older sequence number is treated as a new sample and evicts a newer in-progress reassembly. UDP reordering across consecutive fragmented samples can therefore make both samples fail repeatedly, contrary to the stated “newer sample evicts older” policy. Ignore older sequence numbers from the same writer while a newer sample is active.
const bool sameSample =
m_reassembly.active && m_reassembly.writerGuid == writerGuid && m_reassembly.sn == sn;
if (!sameSample) {
m_reassembly.active = true;
components/rtps_embedded/CMakeLists.txt:35
- These ESP Kconfig selections include headers that unconditionally define
RTPS_STORAGE_DYNAMIC(config_desktop.hpp:40andconfig_host_large.hpp:40). Selecting either “host” profile on ESP therefore switches value pools to heap-backedstd::deque, despite the Kconfig promise that all profiles remain fully static and the PR's MCU determinism guarantee. Separate limits selection from storage policy, or prevent these dynamic headers from enabling heap storage on ESP.
if(CONFIG_RTPS_LIMITS_PROFILE_HOST)
target_compile_definitions(${COMPONENT_LIB} PUBLIC RTPS_CONFIG_HEADER="rtps/config_desktop.hpp")
elseif(CONFIG_RTPS_LIMITS_PROFILE_HOST_LARGE)
target_compile_definitions(${COMPONENT_LIB} PUBLIC RTPS_CONFIG_HEADER="rtps/config_host_large.hpp")
components/rtps_embedded/interop/run_interop.sh:134
- This gate does not verify the claimed byte-exact 200 KB payload: any successful YAML output over 150 KB passes, including a truncated or corrupted sample. Replace the size heuristic with a subscriber/check that compares the received string against the deterministic 200,000-byte pattern, as the reverse-direction gate already does.
# Require the echo to succeed AND to have carried a large (fragment-reassembled)
# payload (>150 KB of YAML), proving the 200 KB sample crossed intact.
big1_rc=1
if [ "$echobig_rc" -eq 0 ] && [ "$(wc -c < /tmp/echobig.log)" -gt 150000 ]; then big1_rc=0; fi
- MemoryPool::getSize() only calls the const capacity(), so make it const (cppcheck functionConst). - rtps_facade_frag test: in the else branch `match` is false by construction, so `match ? 1 : 0` is a known-false condition; print byte-exact=0 directly (cppcheck knownConditionTrueFalse). Gates: cppcheck clean on both files; frag loopback byte-exact (no regression); golden byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six correctness/hardening fixes (Copilot): - max_payload_size (no-frag): was 65535, which overflows the UDP datagram and wraps the 16-bit submessage length. Use 65451 = the engine's MAX_UNFRAGMENTED_PAYLOAD (65507 UDP - 20 RTPS hdr - 12 INFO_TS - 24 DATA hdr); static_assert keeps the facade constant in sync with the engine. - Reader::newFragment: reject fragmentsInSubmessage == 0 and require the serialized data to cover all advertised fragments (fragmentSize each, last fragment ends at sampleSize; trailing 4-byte alignment padding allowed) before copying/marking - a short DATA_FRAG must not mark missing ranges complete and deliver zero-filled bytes. - SimpleHistoryCache / HistoryCacheWithDeletion / ThreadSafeCircularBuffer: the dynamic ring indices are 16-bit; refuse to grow past uint16 max (the m_head/m_tail casts would truncate and corrupt the ring). The buffer then behaves as full (history: drop-oldest; queue: grow() returns false, insert fails). - add_writer: reject fragment_size == 0 up front (the fragmented send paths treat it as failure, so publish() would report success while silently dropping every large sample). Gates: golden byte-identical; loopbacks PASS; docker interop 12/12 (incl. 200 KB ROS 2 both directions); esp32 builds frag-off and frag-on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed all review comments in cbc8ed4:
Verified: golden byte-identical; engine + facade + typed + >64KB frag loopbacks PASS; docker interop 12/12 (incl. both 200 KB ROS 2 directions — the reader validation preserves FastDDS's alignment-padded final fragment); esp32 builds frag-off (the static_assert) and frag-on; cppcheck clean on the changed files (no new findings). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 36 changed files in this pull request and generated 2 comments.
Suppressed comments (7)
components/rtps_embedded/include/rtps/storages/MemoryPool.hpp:117
- The new growth path is bypassed for the unmatched-endpoint pools:
SEDPAgent.cpp:109-124returns as soon asisFull()is true, soadd()is never called at the profile cap. Host discovery therefore still drops unmatched writers/readers instead of growing as promised. Remove those caller-side prechecks (and handleadd()'s result), or otherwise make the full check trigger growth.
#ifdef RTPS_STORAGE_DYNAMIC
// Host: grow past the profile cap instead of hard-failing.
grow();
components/rtps_embedded/src/entities/StatelessWriter.cpp:225
MAX_UNFRAGMENTED_PAYLOADaccounts for the 24-byte DATA header, but DATA_FRAG has a 36-byte fixed header. Clamping to this value permits a 65,519-byte UDP payload (20 + 12 + 36 + 65,451), exceeding UDP's 65,507-byte maximum, so large configured fragment sizes are silently unsendable. Clamp using the DATA_FRAG overhead instead.
const uint16_t fragmentSize = m_fragmentSize > MAX_UNFRAGMENTED_PAYLOAD
? static_cast<uint16_t>(MAX_UNFRAGMENTED_PAYLOAD)
: m_fragmentSize;
components/rtps_embedded/src/entities/StatefulWriter.cpp:350
- This uses the unfragmented DATA limit even though DATA_FRAG has 12 more bytes of fixed overhead. A configured size above 65,439 is clamped to 65,451 and produces a 65,519-byte UDP payload, which exceeds the 65,507-byte protocol maximum and cannot be sent. Derive the clamp from the DATA_FRAG header size.
const uint16_t fragmentSize = m_fragmentSize > MAX_UNFRAGMENTED_PAYLOAD
? static_cast<uint16_t>(MAX_UNFRAGMENTED_PAYLOAD)
: m_fragmentSize;
components/rtps_embedded/interop/run_interop.sh:134
- This gate verifies only that ROS produced more than 150 KB of YAML, not that the received 200 KB pattern is byte-exact. A truncated or corrupted payload above that threshold still passes, contradicting the PR's stated byte-exact espp→ROS verification. Use a ROS subscriber that compares
msg.dataagainst the deterministic pattern, as the reverse-direction espp subscriber does.
# Require the echo to succeed AND to have carried a large (fragment-reassembled)
# payload (>150 KB of YAML), proving the 200 KB sample crossed intact.
big1_rc=1
if [ "$echobig_rc" -eq 0 ] && [ "$(wc -c < /tmp/echobig.log)" -gt 150000 ]; then big1_rc=0; fi
components/rtps_embedded/Kconfig:10
- This help text says every profile is fully static, but the host and host_large headers define
RTPS_STORAGE_DYNAMIC, making value pools heap-backed and growable. That is especially misleading in ESP menuconfig, where selecting either profile introduces dynamic allocation. Describe the embedded/static versus host/dynamic distinction explicitly.
Selects the compile-time capacity limits (profile header) used by the
rtps_embedded engine. All profiles use the same fully-static,
deterministic allocation model; only the capacity caps differ. These
caps are pure capacity limits and do NOT change any bytes on the wire.
pc/tests/rtps_embedded_interop_pub.cpp:9
- The usage text omits the newly parsed ninth
fragment_sizeargument, so users cannot discover how to override the fragment size from this CLI's own documentation.
components/rtps_embedded/include/rtps_participant.hpp:152 - The PR describes an additive optional
RtpsParticipant::Config::max_sample_size, but this implementation exposes only a compile-timeRTPS_MAX_SAMPLE_SIZE/max_payload_size; the publicConfighas no such field and callers cannot configure the cap per participant. Either implement and enforce the advertised option on publish/reassembly or update the stated API impact to make the compile-time-only design explicit.
#if defined(RTPS_ENABLE_FRAGMENTATION)
static constexpr std::size_t max_payload_size = RTPS_MAX_SAMPLE_SIZE;
#else
…s (PR #710) Fix a coordination bug the dynamic value pools (Phase 4 Step 2) introduced, and add regression tests for the review-flagged hazards. Bug: StatelessWriter/StatefulWriter::newChange() advance m_nextSequenceNumberToSend on m_history.isFull(), assuming the next addChange drops the oldest sample. That holds for the static ring, but the dynamic (host) storage path GROWS and retains the sample instead - so the writer skipped retained samples and never sent them under backlog. Guard the drop/cursor-advance on the static path (#ifndef RTPS_STORAGE_DYNAMIC); on the dynamic path nothing is dropped, so the cursor must not advance. esp32 (static) behavior is unchanged. Regression tests (docker-free, in pc/tests; the plain loopbacks never backlog): - rtps_facade_backlog: a reliable writer publishes 300 samples (>> history depth) so the history backlogs + grows; the subscriber must receive ALL 300 with no gaps (a skipped/never-sent sample shows as a missing sequence number - the exact symptom of the bug). Also asserts add_writer rejects fragment_size == 0. - rtps_facade_frag_sizes: DATA_FRAG reassembly with a NON-exact final fragment (70003 and 205003 B @ frag 8000 -> short last fragments), which the existing rtps_facade_frag (exact 25x8000) never exercises. Guards Reader::newFragment fragment-length handling. interop harness: builds both new tests (compile guard) and runs backlog as a CI leg (non-fragmented -> robust in the container). Gates: golden byte-identical; docker interop 13/13 (backlog_no_skip included); esp32 builds frag-off/on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the latest review comments (commit c9a33a2). Writer cursor-advance vs dynamic-growth bug (the substantive one). New regression tests (docker-free, in
The interop harness builds both new tests and runs The re-listed older comments (16-bit index overflow guards, Gates: golden wire test byte-identical; docker ROS 2 interop 13/13 (incl. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 38 changed files in this pull request and generated 2 comments.
Suppressed comments (5)
components/rtps_embedded/Kconfig:23
- This ESP-IDF option selects
config_desktop.hpp, which unconditionally definesRTPS_STORAGE_DYNAMIC; the same applies tohost_large. Thus choosing either relaxed profile on ESP compiles deque-backed, growable heap storage, contrary to this Kconfig menu's claim that all profiles remain fully static and the PR's MCU determinism guarantee. Either make these choices unavailable on ESP or separate the limits profile from the storage policy so ESP always keeps static storage.
config RTPS_LIMITS_PROFILE_HOST
bool "host (relaxed static caps)"
help
Relaxed static caps suitable for small-to-medium DDS graphs
(rtps/config_desktop.hpp). Larger footprint than embedded.
components/rtps_embedded/include/rtps/entities/Reader.hpp:162
- A reader may match multiple remote writers, but this single global slot evicts an incomplete sample whenever a fragment from another
(writerGuid, sn)arrives. Interleaved DATA_FRAG streams from two publishers can therefore continually discard each other and never deliver either sample. Keep bounded reassembly state per writer/sample (with an explicit eviction limit) instead of one slot for the whole reader.
// Single best-effort reassembly slot: accumulates the fragments of one sample
// at a time (a newer sample or different writer evicts an older incomplete
// one). Bounded by Config::MAX_SAMPLE_SIZE.
components/rtps_embedded/include/rtps_participant.hpp:81
- The new public fragmentation option and profile behavior are not reflected in the component documentation.
components/rtps_embedded/README.md:86-116still documents only two fixed config headers/old limits, whiledoc/en/protocols/rtps.rst:22-60still says ROS 2 data exchange and reliable endpoints are unsupported. Update those user-facing docs with the new profiles, payload limits, and DATA_FRAG scope.
/// Nominal per-fragment payload size (bytes) used when a published sample is
/// too large for a single DATA submessage and is split into DATA_FRAG
/// submessages. Default 63000 (large: fewer fragments). Lower it toward the
/// path MTU (e.g. ~1400) for lossy links. Only relevant when fragmentation is
/// compiled in (always on host; opt-in on ESP32). Ignored for samples that
/// fit a single DATA submessage.
uint16_t fragment_size{63000};
pc/tests/rtps_embedded_interop_pub.cpp:9
- The publisher now parses a ninth
fragment_sizeargument, but the usage line omits it, so the interop harness interface is incomplete.
components/rtps_embedded/interop/run_interop.sh:142 - This gate only checks that the YAML output exceeds 150 KB; it does not verify the advertised 200 KB length or any byte content. A truncated or corrupted payload can therefore pass, so this does not support the PR's claim of byte-exact large-payload interop in both directions. Parse/compare the echoed
dataagainst the deterministic 200,000-byte pattern (or use a ROS subscriber that performs that comparison) before recording success.
# Require the echo to succeed AND to have carried a large (fragment-reassembled)
# payload (>150 KB of YAML), proving the 200 KB sample crossed intact.
big1_rc=1
if [ "$echobig_rc" -eq 0 ] && [ "$(wc -c < /tmp/echobig.log)" -gt 150000 ]; then big1_rc=0; fi
… (PR #710) DATA_FRAG's fixed submessage header is 36 bytes vs DATA's 24, so a per-fragment payload must be 12 bytes smaller than a single-DATA payload to keep the datagram within UDP's 65507-byte limit. Both writers clamped a configured fragment_size to MAX_UNFRAGMENTED_PAYLOAD (65451); a size in 65440..65451 then built a datagram of up to 65519 bytes (20 RTPS hdr + 12 INFO_TS + 36 DATA_FRAG hdr + 65451), whose send silently fails (sendPacket discards the error) - losing every large sample. Add rtps::MAX_FRAGMENT_SIZE, derived symbolically from SubmessageDataFrag:: getRawSize() the same way MAX_UNFRAGMENTED_PAYLOAD is derived from SubmessageData (= 65439), and clamp both StatelessWriter and StatefulWriter fragment paths to it. Being constexpr-derived, it auto-recomputes if any header layout changes. A maximal single-fragment datagram is now exactly 65507 bytes (fits). Gates: golden byte-identical; docker ROS 2 interop 13/13 (incl. 200KB both directions + backlog_no_skip); esp32 builds frag-on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the DATA_FRAG fragment-size clamp comments (commit 356fa19). Both writers clamped a configured Fix: added Gates: golden wire test byte-identical; docker ROS 2 interop 13/13 (incl. 200KB espp↔ROS 2 both directions, |
Phase 4 of the
components/rtps_embeddedrefactor — seeREFACTOR_PLAN.md. Makes the RTPS component scale from tiny MCU nodes to compute hosts (Jetson/laptop) that interoperate on the same DDS network: per-platform limits, dynamic memory on host, and large-payload (>64KB) fragmentation.What's in it (4 commits, each interop-gated)
embedded/host(relaxed default) /host_large, selected by aRTPS_LIMITS_PROFILECMake option + ESP Kconfig on the existingRTPS_CONFIG_HEADERswitch.config_esp32.hppis byte-identical (MCU determinism preserved).DataSize_t→ uint32 — internal sample-size type widened so a sample can exceed 64KB (fixes a silentPayloadBuffer::spaceUsed()truncation). Wire-neutral: on-wire length fields stay 16-bit; golden byte-identical.StorageArray<T,N>policy (RTPS_STORAGE_DYNAMIC): host builds allocate on the heap and grow past the profile cap (std::deque, chosen becauseCacheChangeisn't move-constructible); esp32 stays fully static (std::array, zero heap,RTPS_STORAGE_DYNAMICabsent from its compile DB). Entity pools stay static (Participantis non-movable).WriterConfig::fragment_size(default 63000, configurable down to <MTU),Config::max_sample_size(host 8MB / esp32 256KB).RTPS_ENABLE_FRAGMENTATIONis always-on for host, opt-in (default off) on esp32 so the MCU pays nothing for the non-frag path it uses today. Reliable fragment recovery (HEARTBEAT_FRAG/NACK_FRAG) is a tracked v2.Verification
data_fragsection pins the fragment encoding.sampleSizerather than rejecting the final fragment.API impact
Additive: existing
RtpsParticipant/typed API unchanged; new optionalWriterConfig::fragment_sizeandConfig::max_sample_sizewith sensible defaults.max_payload_sizerises tomax_sample_sizewhen fragmentation is enabled.🤖 Generated with Claude Code