feat(rtps): endpoint priority — banded channels, dedicated SEDP-advertised ports, deferred dispatch (phase 2) - #737
feat(rtps): endpoint priority — banded channels, dedicated SEDP-advertised ports, deferred dispatch (phase 2)#737finger563 wants to merge 26 commits into
Conversation
…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>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
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
QosBandscheduling to RTPS transport receive ports, with metatraffic defaulting toHigh. - 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.
…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>
|
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:
Defense-in-depth: the reactor's UDP receive is bounded with a 1 s Evidence: new |
There was a problem hiding this comment.
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).
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>
There was a problem hiding this comment.
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);
…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>
…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>
|
Also addressed the two "suppressed" Copilot comments from the latest review (no threads to resolve):
Full regression drill on the final binaries: |
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>
There was a problem hiding this comment.
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 beforedeleteReader()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_trampolinewithimpl_.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 rawNativeServiceServerContext*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 beforeremove_reader()is confirmed creates a use-after-free if that removal fails. Retain the client innative_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);
…-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>
|
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):
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). |
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>
Description
Phase 2 of priority-aware scheduling (#735 was phase 1): wires
espp::QosBandinto 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 onRtpsParticipant::Config. Metatraffic (SPDP/SEDP) now dispatches atQosBand::Highby 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 adscp) now gets: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);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_srcPortfollows the locator);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+ optionaldscpappended 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). Pythonrtps_bindings.cpp: participant config fields +band/dscpkwargs on all creation methods.Testing
rtps_goldenbyte-identical; all 18 pre-existing rtps host suites + socket_reactor + thread_pool pass — 66/66 runs (22 binaries × 3), independently re-verified.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).ros2_pub→espp_banded_subentry — aQosBand::Highespp reader on a dedicated port receiving from a live ROS 2 Jazzy / rmw_fastrtps publisher.rtpsexample builds; python config surface smoke-tested; cppcheck (CI flags) clean.Reviewer notes
shared_ptr<vector>payloads to work around a GCC 15 xtensa-Werror=free-nonheap-objectfalse positive on moved-vector captures.Phase 3 (flood-latency p99 acceptance test) remains as follow-up.
🤖 Generated with Claude Code