Skip to content

feat(rtps): endpoint priority — banded channels, dedicated SEDP-advertised ports, deferred dispatch (phase 2) - #737

Open
finger563 wants to merge 26 commits into
mainfrom
feat/rtps-endpoint-priority
Open

feat(rtps): endpoint priority — banded channels, dedicated SEDP-advertised ports, deferred dispatch (phase 2)#737
finger563 wants to merge 26 commits into
mainfrom
feat/rtps-endpoint-priority

Conversation

@finger563

@finger563 finger563 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Description

Phase 2 of priority-aware scheduling (#735 was phase 1): wires espp::QosBand into the RTPS stack so a high-priority subscriber/service actually preempts lower-priority traffic, end to end — from the socket to the user callback. Wire format is untouched (goldens byte-identical); the one intentional default-behavior change is called out below.

Per-channel transport bands

rtps::ChannelOptions{band, dscp} on the transport's receive ports; DomainConfig{metatraffic_band, user_traffic_band, enable_dedicated_endpoint_ports, max_prioritized_endpoint_ports} exposed on RtpsParticipant::Config. Metatraffic (SPDP/SEDP) now dispatches at QosBand::High by default so discovery stays responsive under user-traffic floods — the only default-behavior change; everything else is opt-in.

Per-endpoint priority via dedicated ports (the real enabler)

All user traffic for a participant normally shares ONE unicast port (demux happens in-engine), so socket-level priority alone can't distinguish endpoints. An endpoint configured with a non-default band (or a dscp) now gets:

  • its own UDP port at 7400 + 250·domain + 100 + n (linear-probed, reuse-disabled bind; standard ports stay below the +100 offset for participant ids 0–44, and the range stays inside the domain's port block), registered on the reactor at the endpoint's band and optionally DSCP-marked (Socket::set_dscp);
  • its SEDP announcement carrying that port via the standard PID_UNICAST_LOCATOR (already parsed/serialized by the engine — no new wire construct), which FastDDS/ROS 2 honor, so peers send that endpoint's traffic straight to the banded socket; outbound traffic also leaves from it (m_srcPort follows the locator);
  • fd-budget rationing: max_prioritized_endpoint_ports (default 4, lwIP has ~10 sockets) — on exhaustion, warn + fall back to deferred dispatch below. Ports are released on endpoint deletion.

Deferred banded dispatch (shared-port fallback)

Banded endpoints without a dedicated port get their user callbacks re-submitted to the pool at their band instead of running inline on the receive worker: bounded queue (32/endpoint, newest-dropped with warn), single in-flight drain job so per-endpoint ordering is preserved (mirrors the reactor's one-shot pattern). Applies to reader on_sample, service-server handlers, and service-client reply delivery; default (Normal) endpoints keep today's inline path byte-for-byte.

Facade + python

band + optional dscp appended to Writer/Reader/Service/Action configs and all typed facades (a service applies them to request+reply; an action inherits to all sub-endpoints — a ROS action is ~8 endpoints, more than the default ration, so most defer). Python rtps_bindings.cpp: participant config fields + band/dscp kwargs on all creation methods.

Testing

  • Wire safety: rtps_golden byte-identical; all 18 pre-existing rtps host suites + socket_reactor + thread_pool pass — 66/66 runs (22 binaries × 3), independently re-verified.
  • New suites (×3 each): rtps_sedp_dedicated_locator (byte-exact locator param, port range, ration, release/reuse, disabled mode), rtps_banded_pubsub (delivery proves the dedicated socket received — the announcement carries only that locator), rtps_banded_deferred (30/30 strictly in order), rtps_banded_ration (cap=1, both readers deliver).
  • Docker interop matrix: 32/32 PASS, including a new ros2_pub→espp_banded_sub entry — a QosBand::High espp reader on a dedicated port receiving from a live ROS 2 Jazzy / rmw_fastrtps publisher.
  • esp32 rtps example builds; python config surface smoke-tested; cppcheck (CI flags) clean.

Reviewer notes

  • Dedicated-port offsets only walk forward (released ports free the ration slot, not the offset) — ~150 banded-endpoint creations per Domain lifetime exhaust the range; documented.
  • Deferred-queue drops are post-ack (not recovered by RTPS reliability) and only occur if a user callback stalls past 32 queued samples; warned on drop.
  • Deferred closures capture shared_ptr<vector> payloads to work around a GCC 15 xtensa -Werror=free-nonheap-object false positive on moved-vector captures.

Phase 3 (flood-latency p99 acceptance test) remains as follow-up.

🤖 Generated with Claude Code

finger563 and others added 8 commits August 24, 2026 11:45
…t ports in the engine

- EsppTransport: every receive channel takes ChannelOptions{band, dscp} -
  the reactor dispatches the socket at the band (espp::QosBand) and marks
  the socket's outgoing traffic with the optional DSCP; submit() takes a band.
- Domain: DomainConfig{metatraffic_band=High, user_traffic_band=Normal,
  enable_dedicated_endpoint_ports, max_prioritized_endpoint_ports=4}.
  SPDP/SEDP channels register at the metatraffic band (High by default) so
  discovery dispatch overtakes queued user traffic; user channels at Normal.
- Per-endpoint priority: createWriter/createReader take EndpointOptions
  {band, dscp}. A non-Normal band (or a dscp) requests a dedicated unicast
  port, allocated deterministically at offset 100+ of the domain's RTPS port
  block (7400+250*domain+100+n, linear probe with reuse-disabled bind) and
  rationed by max_prioritized_endpoint_ports (each port is one fd; lwIP has
  ~10). The endpoint's SEDP announcement then carries the dedicated port in
  its standard PID_UNICAST_LOCATOR (wire-format unchanged - only the port
  value differs), so FastDDS/ROS 2 peers send that endpoint's traffic there,
  and the endpoint sends FROM the dedicated (DSCP-marked) socket since
  m_srcPort follows the unicast locator. Received datagrams on dedicated
  ports route by a port->participant registry; entity demux is unchanged.
  Ports are released on endpoint deletion and on creation failure.
- TopicData: local-only band/dscp/hasDedicatedPort attributes (never
  serialized; SEDP encoding is byte-identical - golden tests unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ispatch fallback

- RtpsParticipant::Config: metatraffic_band (High default), user_traffic_band,
  enable_dedicated_endpoint_ports, max_prioritized_endpoint_ports (4) - passed
  through to the engine DomainConfig.
- WriterConfig/ReaderConfig: band (espp::QosBand) + optional dscp (espp::Dscp).
  Banded endpoints request a dedicated port; when none is granted (ration
  exhausted or disabled), banded READERS fall back to deferred banded dispatch:
  each sample is queued (bounded, 32/reader, newest dropped with a warning) and
  delivered by a single in-flight job re-submitted to the transport pool at the
  reader's band - one delivery per job, mirroring the reactor's one-shot
  arming, so per-reader ordering is preserved. Default-path readers keep the
  exact inline delivery.
- ServiceConfig (band/dscp on both request+reply endpoints) and ActionConfig
  (inherited by all underlying service/topic endpoints) likewise; banded
  shared-port service servers run their handler deferred at the band, banded
  shared-port service clients defer the user-facing reply delivery. Native
  services/actions inherit via their pub/sub readers.
- Typed facades (Publisher/Subscriber, ServiceServer/Client, ActionServer/
  Client) expose the same band/dscp config fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Config gains metatraffic_band / user_traffic_band /
enable_dedicated_endpoint_ports / max_prioritized_endpoint_ports;
add_writer / add_reader / service + action (ROS and native) creation
methods gain band (espp.QosBand, default Normal) and dscp (espp.Dscp | None)
keyword arguments. The committed .pyi stub has no RtpsParticipant surface,
so no stub update is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ispatch, ration exhaustion

- rtps_sedp_dedicated_locator: engine-level - banded reader/writer get a
  dedicated port from the documented range; the SEDP announcement carries it
  in a byte-exact PID_UNICAST_LOCATOR parameter (plus round-trip parse);
  default endpoints keep the shared port unchanged; ration cap enforced;
  deleted endpoints return their port; disabled dedicated ports honored.
- rtps_banded_pubsub: facade publisher -> engine-level banded subscriber on a
  dedicated port; end-to-end delivery proves the traffic flows through the
  dedicated socket (the announcement carries only that locator).
- rtps_banded_deferred: banded reader with dedicated ports disabled receives
  all 30 sequence-numbered samples strictly in order via deferred dispatch.
- rtps_banded_ration: cap=1 with two banded readers - the over-cap reader
  logs, falls back, and both still receive everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Deferred-dispatch closures capture shared_ptr payloads instead of moved
  vectors: keeps them cheaply copyable inside std::function and sidesteps a
  GCC 15 (xtensa, -O2 -Werror) -Wfree-nonheap-object false positive that
  broke the ESP32 build.
- Domain: drop the always-true seed in initializeTransport's success chain,
  use std::find_if for the dedicated-port lookup (cppcheck).
- Tests: pointer-to-const where cppcheck asked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fd ration

- README: new 'Priority scheduling (bands, dedicated ports, DSCP)' section;
  architecture diagram notes the QosBand-priority reactor/pool.
- doc/en/protocols/rtps.rst: 'Ports and Channels' gains the dedicated-port
  row (7400 + 250*domain + 100 + n) and a 'Per-endpoint priority (dedicated
  ports)' subsection covering the deterministic allocation, SEDP
  PID_UNICAST_LOCATOR announcement (wire-format unchanged), DSCP marking,
  the fd-budget rationing, and the deferred banded dispatch fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rtps_interop_sub gains a band argument (0=Critical..3=Low, default Normal);
the matrix adds ros2_pub->espp_banded_sub: a QosBand::High espp reader on a
dedicated unicast port receiving from a ROS 2 publisher, proving FastDDS
honors the announced per-endpoint unicast locator. The banded loopback tests
also run in the container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stale phase-2 port-collision note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 17:10
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

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.

Pull request overview

This PR implements Phase 2 of priority-aware RTPS scheduling by threading espp::QosBand (and optional DSCP) through the RTPS transport, adding per-endpoint dedicated unicast ports (announced via standard SEDP PID_UNICAST_LOCATOR), and providing a shared-port fallback via deferred banded dispatch for user callbacks.

Changes:

  • Add per-channel QosBand scheduling to RTPS transport receive ports, with metatraffic defaulting to High.
  • Implement per-endpoint dedicated unicast ports (rationed) and route inbound dedicated-port traffic to the owning participant.
  • Add deferred banded dispatch for banded shared-port readers / service handlers / client replies, plus Python/docs/tests/interop coverage.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pc/tests/rtps_sedp_dedicated_locator.cpp New engine-level test validating dedicated-port allocation + SEDP locator encoding + ration + release.
pc/tests/rtps_interop_sub.cpp Extend interop subscriber tool to accept a band argument and pass it into the reader config.
pc/tests/rtps_banded_ration.cpp New end-to-end test proving ration exhaustion falls back while still delivering to both readers.
pc/tests/rtps_banded_pubsub.cpp New loopback test proving traffic flows via the dedicated port announced in SEDP.
pc/tests/rtps_banded_deferred.cpp New loopback test proving deferred shared-port dispatch preserves per-reader ordering.
lib/python_bindings/rtps_bindings.cpp Expose participant config fields + band/dscp on endpoint creation APIs in Python.
doc/en/protocols/rtps.rst Document banded channels, dedicated endpoint ports, and deferred dispatch behavior.
components/rtps/src/rtps_participant.cpp Wire participant config into rtps::DomainConfig, pass endpoint options, and implement deferred dispatch helper + usage.
components/rtps/src/entities/Domain.cpp Add DomainConfig, per-channel banding, dedicated-port allocation/release, and dedicated-port receive routing.
components/rtps/src/communication/EsppTransport.cpp Add per-channel ChannelOptions (band/dscp) and band-aware submit() to the transport worker pool.
components/rtps/README.md Add a “Priority scheduling” section describing bands, dedicated ports, DSCP, and deferred dispatch.
components/rtps/interop/run_interop.sh Build and run new priority/dedicated-port tests; add ROS2→banded-espp interop scenario.
components/rtps/include/rtps/entities/Domain.hpp Define DomainConfig + EndpointOptions, extend constructors/create APIs, expose getTransport().
components/rtps/include/rtps/discovery/TopicData.hpp Add local-only band/dscp/hasDedicatedPort attributes (not serialized).
components/rtps/include/rtps/communication/EsppTransport.hpp Define ChannelOptions; extend transport APIs for band/DSCP-aware channels and submissions.
components/rtps/include/rtps_service.hpp Extend typed service facade configs to carry band/dscp into underlying endpoints.
components/rtps/include/rtps_pubsub.hpp Extend typed publisher/subscriber facade configs to carry band/dscp.
components/rtps/include/rtps_participant.hpp Extend public configs (participant/writer/reader/service/action) and define deferred dispatch helper.
components/rtps/include/rtps_action.hpp Extend typed action facade configs to carry band/dscp into underlying endpoints.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/python_bindings/rtps_bindings.cpp Outdated
Comment thread pc/tests/rtps_interop_sub.cpp
@finger563 finger563 added enhancement New feature or request rtps real time publish subscribe labels Aug 24, 2026
finger563 and others added 4 commits August 24, 2026 13:38
…iness can never wedge stop()

select() readiness can be stale or spurious for UDP (Linux documents that a
subsequent read may still block, e.g. a checksum-failed datagram discarded
in between; a just-closed fd's readiness can also alias onto a reused fd
number). add_udp_receiver()'s handler used an unbounded blocking recvfrom,
so one such dispatch never finished and SocketReactor::stop()'s in-flight
wait hung forever. Set a 1 s receive timeout on registration: invisible on
the data path (reads only follow readiness) and guarantees every dispatch -
and therefore stop() - makes progress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hang)

Runtime endpoint deletion (new in the per-endpoint-priority work: dedicated
ports are released by deleteReader/deleteWriter) exposed one regression and a
family of latent engine races, reproduced on Linux with a dedicated-port
churn-under-flood stress and diagnosed from gdb stack dumps of the hung/
crashed processes:

1. releaseReceivePort() destroyed the channel's UdpSocket immediately after a
   NON-blocking SocketReactor::remove() - but remove() defers unregistration
   while a dispatch is in flight, and that dispatch's handler references the
   socket. The handler was observed blocked forever acquiring the FREED
   object's internal logger mutex, which wedged SocketReactor::stop()'s
   in-flight wait: the exact CI hang ("Still waiting for 1 in-flight
   handler(s)" repeating for 24 min). Fix: RETIRE the socket (park it in
   m_retiredSockets, fd stays open so stale readiness can't alias onto a
   reused fd) and close it in stop() after the reactor and pool have
   quiesced. Shutdown never waits on the released socket.

2. Lock-order inversion, Participant vs SEDPAgent: add/deleteReader/Writer
   held Participant::m_mutex while calling into the SEDP agent (which locks
   SEDPAgent::m_mutex), while the agent's receive handlers lock
   SEDPAgent::m_mutex and then call back into the participant - a classic
   ABBA deadlock under concurrent discovery traffic (second reproduced hang:
   deleteReader vs handlePublisherReaderMessage). Fix: the slot bookkeeping
   stays under m_mutex, the SEDP call moves OUTSIDE it; the global order is
   SEDPAgent::m_mutex -> Participant::m_mutex, never the reverse. The
   deletion loops now also match by pointer identity, fixing a null-slot
   dereference (the old sequence-number comparison dereferenced empty
   slots).

3. Domain's dedicated-port registry gets its own small mutex: the port ->
   participant lookup runs on the receive workers, and routing it through
   Domain::m_mutex let an API caller stall every receive worker (observed as
   part of the deadlocked state).

4. Unlocked proxy-pool accesses that race SEDP (un)matching under endpoint
   churn - StatefulWriter::sendHeartBeat (reproduced SIGSEGV iterating
   m_proxies from the protocol task while a receive worker mutated them),
   StatelessWriter::progress, StatefulReader/StatelessReader::
   addNewMatchedWriter, Reader::isProxy/getProxy - now take the designated
   mutex (Writer::m_mutex / Reader::m_proxies_mutex) that every other
   accessor already used.

Verified: the churn reproducer (rtps_banded_churn, next commit) hung at
iter 5 and crashed at iter 6/26 before these fixes; afterwards 100/100
runs pass on Linux (docker, DDS noise) plus 20x with the final binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…entry

The reproducer for the CI shutdown hang, kept as a regression test.
Phase 1: while a publisher floods a reliable topic, the subscriber domain
repeatedly (25x) creates a banded dedicated-port reader, receives live
traffic, and deletes it - exercising releaseReceivePort() with dispatches in
flight, SEDP (un)announcements racing the API, and immediate port/slot
reuse. Phase 2: a banded SHARED-port reader with deferred banded dispatch
and a slow callback is stopped WHILE deliveries are in flight and queued.
Any shutdown hang trips the harness timeout. Before the teardown fixes this
hung at iteration 5 and segfaulted at iteration 6; it now passes 100/100 on
Linux (docker) and 3/3 on macOS. Also run (with a 120 s timeout) in the
docker interop matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erop band arg

- python add_reader(): the docstring claimed only a non-Normal band requests
  a dedicated receive port; a set dscp does too (matching add_writer's
  wording and the C++ ReaderConfig docs).
- rtps_interop_sub: validate the band argument (0..3) before casting to
  espp::QosBand instead of propagating an unchecked value into band-indexed
  code; fail fast with a clear message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

The CI interop hang is fixed in c573c39..ef323f4 — the 30-minute timeout kill turned out to be a real shutdown deadlock, and reproducing it on Linux (gdb watchdog inside the interop image) surfaced three distinct concurrency bugs, two of them pre-existing in the engine and merely exposed by runtime endpoint deletion:

  1. Use-after-free wedging stop() (the CI signature): releaseReceivePort() destroyed the UdpSocket right after the non-blocking reactor remove() while an in-flight dispatch still referenced it — the handler blocked forever on the freed object's mutex, and SocketReactor::stop() spun on "Still waiting for 1 in-flight handler(s)". Fixed by retiring released sockets (fd stays open, so stale select() readiness can't alias a reused fd) and closing them in stop() after the reactor/pool quiesce.
  2. Pre-existing Participant↔SEDPAgent ABBA deadlock (exposed by runtime deletion): endpoint add/delete held Participant::m_mutex while calling into the agent; agent receive handlers take the locks in the opposite order. The SEDP call now happens outside Participant::m_mutex (global order SEDP→Participant), and deletion loops match by pointer identity (also fixing a null-slot deref).
  3. Pre-existing unlocked proxy-pool iteration (reproduced as a SIGSEGV under churn): StatefulWriter::sendHeartBeat and several reader/writer accessors iterated m_proxies without the designated mutex while SEDP workers mutated it — all guarded now.

Defense-in-depth: the reactor's UDP receive is bounded with a 1 s SO_RCVTIMEO (Linux-documented spurious readiness + unbounded recvfrom could wedge stop even without the UAF), and the dedicated-port registry got its own mutex off the Domain::m_mutex hot path.

Evidence: new rtps_banded_churn stress test (create/receive/delete churn under flood + stop-under-deferred-load) reproduced hang@iter5 / deadlock@iter8 / segv@iter6 before the fixes and now passes 100/100 on Linux; the four banded suites pass 30/30 each on Linux; the full docker interop matrix is 33/33 PASS (including a new banded_churn entry); local sweep 75/75; esp32 rtps + socket examples build. Note for review: the engine lock-order and proxy-guard fixes touch shared code paths used by all traffic — the interop matrix and goldens are the regression evidence.

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.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

pc/tests/rtps_banded_deferred.cpp:76

  • The comment says each sample is sent until it is "acknowledged by observation" via paced resends of the head, but the loop actually sends each sequence number at most once (next_to_send is always incremented on a successful publish and never retried). Either adjust the comment to match the current send strategy, or implement the described resend behavior.
  // Wait for discovery/matching, then send the numbered sequence. Reliable
  // writers retransmit on the wire, but a sample must reach the reader at least
  // once for the deferred queue to see it - send each one until acknowledged by
  // observation (simple paced resend of the not-yet-seen head).

Comment thread components/rtps/src/communication/EsppTransport.cpp Outdated
finger563 and others added 2 commits August 24, 2026 15:44
New overload remove(id, on_removed): the callback fires EXACTLY ONCE when
the registration is fully gone AND any handler that was running or pending
for it has finished - i.e. when no reactor code can reference the socket/fd
anymore - so callers finally have a non-blocking way to know when destroying
the socket is safe (remove() itself never blocks and defers erasure while a
dispatch is in flight). Covers all three completion paths: immediate erase
(idle at remove() time; callback runs synchronously on the caller), deferred
erase (fires on the pool worker right after the in-flight handler returns),
and the pool-saturated dispatch revert (fires on the reactor loop). Invoked
without reactor locks (may re-enter the reactor; must return promptly; must
not call stop() from worker/loop context); repeated remove() for a pending
id chains the callbacks; the erase paths wake the loop so the fd leaves the
select interest set promptly. Doxygen documents the guarantees, threading,
and the residual one-iteration stale-select caveat (bounded by the
add_udp_receiver receive timeout). Python remove(id) binding unchanged
(cast disambiguates the overload).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… completion

Addresses the PR review on the retire strategy: parking released sockets
until stop() kept each fd open (and the port bound) for the transport
lifetime, so endpoint churn could accumulate fds and defeat the
dedicated-port ration on small-socket platforms (lwIP ~10).

releaseReceivePort() still parks the socket in m_retiredSockets (the object
must outlive any in-flight handler), but now passes an on_removed completion
to SocketReactor::remove() that destroys the retired socket - closing the fd
and unbinding the port - the moment the reactor confirms the registration is
fully gone and no handler can reference it. For an idle registration (the
common case) that is synchronous within releaseReceivePort(); with a handler
in flight it fires right after that handler finishes. destroyRetiredSocket()
erases by pointer under m_mutex, so racing stop()'s clearing of the retired
list is a benign no-op (the pointer is only used to find the entry, never
dereferenced), and the recursive mutex makes the synchronous-callback path
(caller already holds m_mutex) safe. stop() remains the backstop for any
socket whose completion never fired (e.g. the pool died first).
retiredSocketCount() is exposed for tests/diagnostics.

Tests now prove the prompt release: rtps_sedp_dedicated_locator binds a
fresh reuse-disabled socket to the released dedicated port (and sees zero
retired sockets) well before the domain stops; rtps_banded_churn asserts
that after 25 delete/create cycles under flood the retired list drains to
zero and the FIRST iteration's port is bindable again before stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

components/rtps/src/rtps_participant.cpp:673

  • These two endpoint creations are not transactional. If the writer succeeds (and consumes a dedicated-port slot) but the reader pool is exhausted, the method returns false while leaving the writer announced and its dedicated socket allocated; the inverse case similarly leaves a reader behind. Roll back whichever endpoint was created before returning failure so failed service registration cannot permanently consume endpoint and fd budgets.

This issue also appears on line 1172 of the same file.

      domain_->createWriter(*participant_, rep_topic.c_str(), rep_type.c_str(), /*reliable=*/true,
                            /*enforceUnicast=*/false, endpoint_options);
  rtps::Reader *request_reader =
      domain_->createReader(*participant_, req_topic.c_str(), req_type.c_str(), /*reliable=*/true,
                            /*mcastaddress=*/{0, 0, 0, 0}, endpoint_options);

components/rtps/src/entities/Domain.cpp:413

  • A failed probe window never advances m_nextDedicatedPortOffset; it is updated only on a successful bind. If offsets 0–15 are occupied while offset 16 is free, every later endpoint retries the same 16 ports and dedicated allocation remains permanently disabled despite the documented 100–249 range. Advance past the failed window (bounded by the domain block) before returning fallback.
  for (uint16_t probe = 0; probe < DEDICATED_PORT_PROBE_LIMIT; ++probe) {
    const uint16_t offset = m_nextDedicatedPortOffset + probe;
    if (DEDICATED_PORT_OFFSET + offset > 249) {
      break; // stay inside this domain's 250-port block

components/rtps/src/rtps_participant.cpp:1176

  • As in the server path, partial creation leaks the successful endpoint. For example, a successful dedicated reply reader followed by request-writer exhaustion makes add_service_client() fail while retaining the reader/socket and ration slot. Delete whichever endpoint succeeded before returning failure.
      domain_->createReader(*participant_, rep_topic.c_str(), rep_type.c_str(), /*reliable=*/true,
                            /*mcastaddress=*/{0, 0, 0, 0}, endpoint_options);
  rtps::Writer *request_writer =
      domain_->createWriter(*participant_, req_topic.c_str(), req_type.c_str(), /*reliable=*/true,
                            /*enforceUnicast=*/false, endpoint_options);

Comment thread components/rtps/src/entities/Domain.cpp Outdated
Comment thread components/socket/src/socket_reactor.cpp
Comment thread pc/tests/rtps_banded_churn.cpp Outdated
Comment thread components/rtps/src/rtps_participant.cpp
…stration

If the SO_RCVTIMEO install fails, the unbounded-recvfrom hang guard is void,
so registration now fails with a clear error instead of proceeding.
Evaluated O_NONBLOCK as the alternative: it would also bound reads, but this
fd is used for SENDS too (the owner and the echo path), and non-blocking
mode changes send semantics under buffer pressure (EWOULDBLOCK instead of a
brief block). SO_RCVTIMEO bounds only receives and is supported on POSIX,
lwIP (LWIP_SO_RCVTIMEO), and Windows, so it remains the mechanism - now
mandatory. (PR #737 review.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
finger563 and others added 4 commits August 24, 2026 16:33
…dow advances

- The ration check now counts active dedicated ports PLUS retired sockets
  whose fd is still open awaiting the reactor's removal completion, so
  delete/create churn with a stalled completion can never push real fd usage
  past max_prioritized_endpoint_ports. Docs updated (Domain + facade Config).
- A fully-occupied probe window now advances m_nextDedicatedPortOffset past
  the failed window before falling back; previously only a successful bind
  advanced it, so an occupied window (e.g. ports taken by another process)
  was retried forever and dedicated allocation was permanently stuck even
  with free ports later in the 100..249 block.
- rtps_sedp_dedicated_locator gains both proofs: (6) with the transport pool
  saturated so a dispatch defers the removal completion, a cap-1 domain
  refuses a new dedicated port while the retired fd is open and grants one
  after it closes; (7) with the entire first window externally occupied, the
  first allocation falls back but the next succeeds beyond the window.
  (PR #737 review.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ervice/action creation

Two PR #737 review items in the facade:

- Deferred deliveries run as bare ThreadPool jobs, so a throwing user
  callback would kill the worker AND leave the dispatcher's in_flight flag
  set, permanently wedging that endpoint's deferred queue (the inline path is
  protected by SocketReactor::dispatch()'s boundary). DeferredDispatch::
  drain() now mirrors dispatch(): try/catch + log under __cpp_exceptions,
  direct call on no-exceptions builds (ESP-IDF default).

- Composite endpoint creation is now transactional: a partial failure rolls
  back every endpoint that DID build, so nothing stays announced via SEDP and
  no engine pool slot or dedicated-port ration slot (fd) leaks.
  - ROS service server/client: the surviving half of the writer+reader pair
    (and both, when callback registration fails) is deleted before returning
    failure.
  - Native service server/client: the request/reply writer is removed when
    the paired reader fails (new remove_writer/remove_reader helpers; the
    ReaderContext now records its topic + engine reader for this).
  - Actions (ROS and native, server and client): container marks are taken
    before the multi-endpoint build and everything added past the mark is
    unwound on a later step's failure (rollback_service_servers/_clients and
    the native equivalents; contexts now retain the fields needed to find
    their endpoints).

rtps_service_rollback proves it: a service name whose request topic exceeds
MAX_TOPICNAME_LENGTH (reply fits) induces the partial failure; the rolled-
back banded endpoint's dedicated port becomes externally bindable again
(fd + ration released), and an action built against a writer budget of
exactly 3 free slots fails partway yet returns all 3 slots (verified by
refilling them, then hitting the exhausted budget).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port

An iteration that never observed a sample had not exercised the
delete-with-traffic-in-flight race the test exists for, yet was allowed to
proceed - the whole churn phase could pass idle. Each iteration now fails
(with a proper flood-thread stop/join) unless at least 2 samples arrive on
its dedicated port; the first iteration gets a longer (15 s) deadline for
discovery/matching, later ones 5 s. (PR #737 review.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

Also addressed the two "suppressed" Copilot comments from the latest review (no threads to resolve):

  • Non-transactional service/action creation (rtps_participant.cpp:673 / 1176): fixed in 2663af3 — the ROS service server (writer→reader) and client (reader→writer) paths now delete the surviving endpoint on partial failure (and both on callback-registration failure), native service pairs roll back via new remove_writer/remove_reader helpers, and all four action composites (ROS/native × server/client) unwind every endpoint created past a container mark when a later step fails — so a failed registration can no longer leak an SEDP announcement, a dedicated-port ration slot, or an endpoint pool slot. Proven by the new rtps_service_rollback suite: after an induced partial failure, the rolled-back banded endpoint's dedicated port becomes externally bindable (fd + ration released), and an action failed partway through releases exactly its consumed writer-budget slots (verified by refilling them).
  • Stuck probe window (Domain.cpp:413): fixed in 46a01fd — a fully-failed probe window now advances m_nextDedicatedPortOffset past the probed range (bounded by the domain block) before falling back, so an occupied window no longer permanently disables dedicated allocation; proven by the occupied-window scope in rtps_sedp_dedicated_locator (16 external squatters → first allocation falls back, second succeeds past the window).

Full regression drill on the final binaries: rtps_banded_churn 100/100 on Linux docker (now with the live-sample requirement), the five banded/rollback suites 30/30 each, local sweep 78/78, docker interop matrix 34/34 PASS (now includes service_rollback), esp32 rtps + socket examples build, cppcheck clean.

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.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.

Comment thread components/rtps/src/rtps_participant.cpp Outdated
Comment thread components/rtps/src/rtps_participant.cpp Outdated
Comment thread components/rtps/src/entities/StatefulWriter.cpp
Comment thread components/rtps/src/rtps_participant.cpp Outdated
Comment thread components/rtps/src/rtps_participant.cpp Outdated
Comment thread components/rtps/src/rtps_participant.cpp Outdated
Comment thread components/rtps/src/rtps_participant.cpp
finger563 and others added 4 commits August 25, 2026 09:36
sendHeartBeat() locked its proxy iteration, but the unconfirmed-changes
find_if right after it still scanned m_proxies unlocked from the protocol
task, racing SEDP-worker proxy mutations. The whole tick now runs under
m_mutex (recursive, so the nested guards stay harmless). (PR #737 review.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…recise composite rollback

One coherent ownership model for all deferred work (PR #737 review, cluster A):

- SHARED OWNERSHIP: every context embedding a DeferredDispatch is shared_ptr
  owned (ReaderContext - reader_contexts_ is now a vector of shared_ptr -,
  ServiceServerContext, ServiceClient::Impl - now shared and
  enable_shared_from_this). Every deferred delivery closure and every drain
  job captures its owning context, so queued work can never outlive the
  context regardless of when it is removed or rolled back - the raw-pointer
  UAF class is structurally gone.
- QUIESCE ON REMOVAL: DeferredDispatch::close() drops the queue, refuses new
  work, and cancels the retry timer synchronously. Every removal path
  (remove_reader, remove_service_server/_client, the native removals, and
  stop()) closes the dispatcher BEFORE deleting engine endpoints or releasing
  context references - mirroring the reactor's removal-completion discipline.
  The retry timer captures the owner WEAKLY (no cycle) and close() guarantees
  no timer callback can drop the last context reference on its own thread.
- GUARANTEED ARM RECOVERY: a rejected drain arm (pool saturated/stopped) can
  no longer strand a queued - possibly lone/last, already-acked - delivery:
  the dispatcher flags needs_arm and a lazy 20 ms retry timer re-arms the
  drain until it succeeds; the post-drain re-arm uses the same path. The
  transport pool queue is now bounded (64) so rejection is real backpressure
  rather than an unbounded backlog (and the reactor's saturation path is
  live).

Precise composite rollback (cluster B):

- add_service_server_deferred / add_native_service_server gain internal
  variants returning the EXACT context created; actions track those handles
  (and created-writer flags) and roll back exactly what THIS invocation
  built via remove_service_server/_client and the native equivalents (erase
  by pointer identity). The size-watermark helpers are gone - a duplicate
  add_action_server can no longer delete the first instance's writers, and
  an endpoint added concurrently by another thread can never be rolled back
  as collateral.

Facade deletion ordering (items 6/7): remove_writer deletes the engine
writer FIRST and drops the map entry only on success; remove_reader closes
the deferred dispatcher, deletes the ENGINE reader (clearing its callback
registration), and only then drops the context - a failed deletion leaves
both handles in place for retry, never a dangling callback.

rtps component now depends on espp/timer (retry timer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ck-under-concurrency

- rtps_deferred_recovery: unit-level, deterministic - both transport workers
  latched, the bounded queue filled until submit() rejects, ONE deferred
  delivery enqueued (arm rejected), workers released: the lone delivery must
  arrive with NO further traffic (retry-timer recovery). Exposes the
  protected DeferredDispatch via a test subclass.
- rtps_service_rollback: (5) duplicate add_action_server fails but the FIRST
  action's feedback/status writers still accept samples - precise rollback
  removed nothing it did not create; (6) writers added by a concurrent
  thread survive failing composites running real rollbacks (partially-built
  banded services) in parallel. Header documents why deletion-failure
  ordering is not cheaply testable (needs engine-internal corruption).
- Both run in the docker interop matrix; churn's live-sample requirement is
  unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flake (1/30 on Linux): the two worker-blocking jobs could still be
QUEUED when the bounded-queue fill completed; a late-waking worker then
popped the HIGH-band drain job ahead of the Normal-band fillers
(band-priority pop) and ran the delivery while the test believed the pool
was saturated. The test now waits until both blockers are actually RUNNING
(latched counter) before filling, so the drain arm is genuinely rejected
every run. Verified 60/60 on Linux docker after the guard (previously
failed by iteration 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 4 comments.

Suppressed comments (4)

components/rtps/src/rtps_participant.cpp:310

  • close() is irreversible, but it runs before deleteReader() is known to have succeeded. If SEDP deletion returns false, this method keeps the still-active reader/context for retry while permanently dropping all future deferred deliveries. Delete the engine reader first, then close the dispatcher only after deletion succeeds.
      (*it)->deferred.close();
      if (!domain_->deleteReader(*participant_, (*it)->reader)) {
        return false;
      }

components/rtps/src/rtps_participant.cpp:1068

  • This removes the client's owning registry entry before checking whether its reply reader was actually deleted. On a deletion failure, that live reader still invokes service_reply_trampoline with impl_.get(), but rollback soon destroys the client/Impl, leaving a dangling callback. Keep the client registered until both endpoint deletions succeed, with partial-deletion state retained for retry.
  {
    std::lock_guard<std::mutex> lock(mutex_);
    std::erase(service_clients_, client);
  }
  client->impl_->deferred.close();

components/rtps/src/rtps_participant.cpp:1587

  • Erasing this context before remove_reader() succeeds can leave the facade reader alive with its callback lambda still capturing the raw NativeServiceServerContext* created at line 1639. When the helper returns and the last shared pointer is released, later requests use freed memory. Only erase the context after the reader and writer removals have succeeded, retaining it on failure.
  {
    // Remove exactly THIS handle - a concurrently added server is untouched.
    std::lock_guard<std::mutex> lock(mutex_);
    std::erase(native_service_servers_, server);
  }

components/rtps/src/rtps_participant.cpp:1603

  • The reply-reader callback captures NativeServiceClient::Impl* by raw pointer, so erasing the owning client before remove_reader() is confirmed creates a use-after-free if that removal fails. Retain the client in native_service_clients_ until both endpoint removals succeed and preserve partial-cleanup state for retry.
  {
    std::lock_guard<std::mutex> lock(mutex_);
    std::erase(native_service_clients_, client);
  }
  remove_reader(client->impl_->reply_topic);

Comment thread components/rtps/src/communication/EsppTransport.cpp
Comment thread components/rtps/src/communication/EsppTransport.cpp
Comment thread components/rtps/src/rtps_participant.cpp
Comment thread pc/tests/rtps_banded_deferred.cpp
…-first removal ordering

Round-5 review fixes (PR #737):

- Writer progress is now banded + guaranteed. StatefulWriter/StatelessWriter
  progress() pokes went through submit() at Normal, so a Critical/High writer's
  outbound DATA (incl. service replies) was queued as Normal - priority did not
  apply end-to-end. New EsppTransport::submitGuaranteed(job, band) passes the
  writer's m_attributes.band AND, since the pool queue is bounded (64), parks a
  rejected submission and re-submits it via a lazy 20ms retry timer: a lone
  best-effort DATA (no heartbeat/acknack recovery) can no longer be stranded
  unsent. The retry timer is cancelled synchronously in stop() before pool
  teardown.

- Engine-first removal ordering everywhere. remove_reader and all four service
  removals (ROS + native, server + client) now delete the ENGINE endpoint(s)
  first and mutate facade state (registry entry, deferred close(), handle
  fields) only on confirmed deletion; a failed deletion leaves every handle and
  the callbacks/dispatchers they anchor intact for retry, with per-endpoint
  bool markers recording partial progress. Fixes the erase-before-delete UAF
  class (a failed deleteReader left an engine reader calling into a freed
  context) and the irreversible-close-before-delete drop.

- Tests: rtps_banded_deferred gains a deterministic queue-jump phase (two
  DeferredDispatch bands, both drains queued behind blocked workers, a single
  freed worker services High before Low - fails if the drain were resubmitted
  at Normal); new rtps_guaranteed_submit proves a pool-rejected guaranteed job
  still runs via the retry timer with no further submissions.

Verified: host sweep 75/75 (25 binaries x3), new tests 10/10 each, cppcheck
clean, esp32 rtps example builds, docker interop 35/35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

Also addressed the four "suppressed" Copilot comments from the latest review — all part of the same engine-first removal-ordering fix in 0b63854 (no threads to resolve):

  • rtps_participant.cpp:310 (remove_reader closed the dispatcher before deleteReader confirmed): now deletes the engine reader FIRST and calls deferred.close() (irreversible) only after deletion succeeds; a failed deletion leaves reader + dispatcher fully functional for retry.
  • rtps_participant.cpp:1068 (ROS service client — registry erase before reply-reader deletion): reply reader deleted first, impl_->reply_reader nulled, registry entry erased only on confirmed deletion of both endpoints.
  • rtps_participant.cpp:1587 (native service server — context erased before remove_reader success): remove_reader/remove_writer run first (they themselves only mutate facade state on confirmed engine deletion), registry erased only after both succeed, with request_removed/reply_removed markers for retry.
  • rtps_participant.cpp:1603 (native service client — same for Impl*): reply reader removed and confirmed before the client is unregistered.

The invariant is now uniform across every removal/rollback path: delete the engine endpoint, then mutate facade state only on confirmed success; on failure every handle and the callbacks/dispatchers it anchors stay alive for retry. Verified: host sweep 75/75 (25 binaries x3), the two new tests 10/10 each, cppcheck clean, esp32 rtps example builds, and the full docker interop matrix 35/35 (all service/action paths exercise these removal orderings).

finger563 and others added 2 commits August 25, 2026 16:09
The queue-jump phase in rtps_banded_deferred relied on a single freed worker
sequentially draining BOTH queued drain jobs within one 5s deadline, which
flaked on a loaded runner (~1/200 locally, and failed once in CI: 'banded
drains did not both run'). Restructure to a two-phase free that asserts the
same band-priority property without the timing dependency: free one worker and
assert the FIRST delivery is High (the queue-jump - Low was enqueued first yet
High runs first), then free the second worker and assert Low follows. Generous
10s per-phase waits. 0/500 locally; interop banded_deferred green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rueFalse)

The two-phase queue-jump rewrite already asserts order[0]=='H' in phase 1, so
the final order[0]!='H' branch is always false - cppcheck flagged it and the
CI static_analysis failed. Check only order[1]=='L' at the end.

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

Labels

enhancement New feature or request rtps real time publish subscribe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants