Fix reader-thread wedge: decouple republish onto a bounded publish-worker queue#28
Merged
Conversation
added 9 commits
May 25, 2026 18:29
Decouple destination publish from the socket-drain thread via a bounded publish-worker queue, so a RELIABLE publish() stalling on an uncleanly-dead subscriber can no longer wedge recvfrom / back up the kernel Recv-Q. QoS knobs are ruled out by qos_design.md (BEST_EFFORT breaks CAMP, BEST_AVAILABLE blocked by rmw_zenoh_cpp 0.2.9); the fix is transport-level and rmw-independent.
Move all first-arrival rmw work (create_generic_publisher + sendBridgeInfo + publish) into the worker sink, not just publish(); byte-bounded queue with injected sink for testability; construct/join worker in on_configure/on_cleanup (non-lifecycle publishers — join gates teardown); update udp_bridge_node.cpp invariant comment + qos_design unrecoverable-drop note.
Header-only single-worker queue that decouples republishing from the caller. push() never blocks; bound is in bytes with drop-oldest (KEEP_LAST-consistent) and an atomic drop counter; the publish work is an injected sink so the queue unit-tests with a fake sink. test_publish_queue covers the non-stall property (blocking sink), FIFO, byte-budget drop-oldest, oversize-item handling, push-after-stop/before-start no-op, and stop-while-blocked join.
decodeData now only deserializes the MessageInternal and enqueues a PublishItem; the rmw-touching tail — find-or-create the destination publisher, first-arrival sendBridgeInfo, and publish() — runs on the publish worker (publishItem). A RELIABLE publish stalling on an uncleanly-dead subscriber, or a first-arrival create_generic_publisher blocking on rmw discovery, now blocks only the worker; recvfrom keeps draining and the kernel Recv-Q no longer backs up. The fix is transport-level and independent of the destination publisher's reliability (RELIABLE workaround / BEST_AVAILABLE intent) — see qos_design.md. Worker starts in on_configure, stops+joins in on_cleanup (dtor backstop); publish_queue_max_bytes is a declareIfMissing parameter; a publish-queue diagnostic WARNs on recent drops. Updates the socket_drain_group_ invariant comment that predicted this change.
Local /review-code (Deep; Claude + Copilot adversarial agreed): - (must-fix) The publish worker had no exception barrier. The sink can throw (create_generic_publisher on unknown/cross-version datatype, publish, and sendBridgeInfo's ConnectionException) — and before #10 this ran inside decode()'s catch-all on the executor thread. On a plain std::thread an uncaught throw is std::terminate: a single bad packet would crash the bridge. publishItem now try/catches + logs (RCLCPP_ERROR, mirroring decode's catch-all); PublishQueue::run adds a last-resort catch(...) backstop. New test ThrowingSinkDoesNotKillWorker covers it. - Run the worker only while ACTIVE: start() in on_activate, stop()+join in on_deactivate (was start in on_configure). Stops it publishing onto the lifecycle-gated bridge_info/topic_statistics publishers while INACTIVE and matches the state-guard discipline of the other publish paths. Backstop stop() retained in on_cleanup + the destructor. - Document the new concurrent outbound-send caller in the callback-group audit comment, and the shutdown-join bound (one in-flight publish max_blocking_time).
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #10 by preventing the UDP socket-drain (reader) thread from wedging when destination-side republishing blocks (e.g., a RELIABLE publisher stalled by a dead-but-still-matched subscriber). It does this by moving all rmw-touching republish work off the drain thread onto a bounded, single-worker publish queue, with observability via diagnostics and unit tests that exercise the non-stall contract.
Changes:
- Introduce
udp_bridge::PublishQueue(byte-bounded, drop-oldest, single worker) and wiredecodeData()to enqueuePublishItems instead of publishing inline. - Add lifecycle-managed worker start/stop, a publish-queue diagnostic, and a configurable
publish_queue_max_bytesparameter. - Add focused unit tests for queue behavior (non-blocking push under blocking sink, FIFO, dropping semantics, stop/join behavior, exception containment) and document the design in
qos_design.md.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
udp_bridge/include/udp_bridge/publish_queue.h |
New bounded worker queue abstraction with injected sink and drop accounting. |
udp_bridge/src/udp_bridge.cpp |
Rewire decodeData() → enqueue; add publishItem() worker sink; lifecycle start/stop; parameter + diagnostic. |
udp_bridge/include/udp_bridge/udp_bridge.h |
Declare publish-queue members, parameter storage, and diagnostics helper. |
udp_bridge/src/udp_bridge_node.cpp |
Update callback-group/threading architecture comments to reflect the new worker. |
udp_bridge/test/test_publish_queue.cpp |
New unit tests covering non-stall, ordering, drops, shutdown, and exception survival. |
udp_bridge/doc/qos_design.md |
Document transport-level decoupling rationale and unrecoverable post-wire-drop semantics. |
udp_bridge/CMakeLists.txt |
Register the new gtest target for the publish-queue tests. |
.agent/work-plans/issue-10/plan.md |
Planning document for the change. |
.agent/work-plans/issue-10/progress.md |
Progress/status tracking for the plan. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+70
to
+75
| // Spin until the worker is parked inside the sink (has popped an item). | ||
| void wait_until_blocked() | ||
| { | ||
| while(calls.load() == 0) | ||
| std::this_thread::yield(); | ||
| } |
Comment on lines
+180
to
+193
| declareIfMissing("publish_queue_max_bytes", | ||
| static_cast<int64_t>(kDefaultPublishQueueMaxBytes)); | ||
| { | ||
| int64_t configured = get_parameter("publish_queue_max_bytes").as_int(); | ||
| if(configured < static_cast<int64_t>(kMinPublishQueueMaxBytes)) | ||
| { | ||
| RCLCPP_WARN_STREAM(get_logger(), | ||
| "publish_queue_max_bytes " << configured << " is below " | ||
| << kMinPublishQueueMaxBytes << "; clamping to " | ||
| << kMinPublishQueueMaxBytes); | ||
| configured = static_cast<int64_t>(kMinPublishQueueMaxBytes); | ||
| } | ||
| publish_queue_max_bytes_ = static_cast<size_t>(configured); | ||
| } |
added 3 commits
May 25, 2026 20:16
- test_publish_queue: BlockingSink::wait_until_blocked() now waits against a 2s deadline with ASSERT_LT instead of an unbounded spin, so a worker that never starts fails fast with a clear message rather than wedging until the colcon test timeout. - on_configure: publish_queue_max_bytes now clamps an upper bound (2 GiB) with a warning too, matching the both-bounds discipline of port / maximum_packet_size / history_depth and removing the int64->size_t truncation edge on 32-bit size_t platforms.
Comment on lines
+83
to
+85
| stop_requested_ = false; | ||
| running_ = true; | ||
| worker_ = std::thread(&PublishQueue::run, this); |
added 3 commits
May 25, 2026 21:36
…ueue::start() Set running_=true only after the std::thread is constructed. If the thread ctor throws (std::system_error on resource exhaustion), running_ stays false so the queue is cleanly not-running (push() drops, start() retryable) instead of stuck running_=true with no worker to drain. run() never reads running_, so the reorder is behaviour-neutral on the success path.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #10
Problem
The bridge's UDP reader wedges — reader thread blocked, kernel
Recv-Qbacks up, forwarded topics go silent — when a local subscriber dies during active forwarding. Observed 4× in one field day (2026-04-27); one wedge masked an RTK/NTRIP alert by freezing/diagnosticsstale on the operator side.Root cause:
decodeData'spublisher->publish()runs in the MutuallyExclusivesocket_drain_group_(spin_once → decode → decodeData). The destination publisher is deliberatelyRELIABLE(anrmw_zenoh_cpp0.2.9 graph-visibility workaround — seeqos_design.md). When a local reliable subscriber dies uncleanly, DDS keeps it matched and the publish blocks on a full history slot, which stallsspin_once→recvfrom→ Recv-Q backup. The first-arrivalcreate_generic_publisher()andsendBridgeInfo()are on the same thread and can block on rmw discovery too.QoS can't be the fix (
qos_design.mdrules out all three knobs:BEST_EFFORTbreaks CAMP's reliable subscribers,BEST_AVAILABLEis blocked by the rmw bug and still behaves reliable on the local hop). So the fix is transport-level and rmw-independent.Fix
Decouple the rmw-touching tail of
decodeDataonto a dedicated single-workerPublishQueue(include/udp_bridge/publish_queue.h):decodeDatanow only deserializes theMessageInternal, builds the innerSerializedMessage, and enqueues aPublishItem.publishItem) does find-or-create publisher + first-arrivalsendBridgeInfo+publish(). A stalled publish blocks only the worker;recvfromkeeps draining.publish_queue_max_bytes,declareIfMissing), drop-oldest (KEEP_LAST-consistent), with an atomic drop counter surfaced via apublish queuediagnostic (WARN on recent drops). Queue drops are unrecoverable loss after wire delivery — documented inqos_design.mdas the local-hop expression of the bridge's best-effort-with-loss-reduction model.startinon_activate,stop+join inon_deactivate, backstop inon_cleanup+ destructor).publishItemcatches+logs (mirroringdecode()'s catch-all) andrun()has a last-resort barrier, so a bad packet can'tstd::terminatethe node.Test plan
colcon buildclean (only pre-existing-Wpedantic/sign-compare warnings).test_publish_queue.cpp(8 cases): non-stall under a blocking sink (the bug, via fake sink), FIFO, byte-budget drop-oldest, oversize-item enqueue-then-evict, push-after-stop / push-before-start no-op, stop-while-blocked join, throwing-sink survival.Notes
/review-code(Deep; Claude + Copilot adversarial). Findings (missing worker exception barrier; ACTIVE-only lifecycle) addressed before this description. Plan + review timeline in.agent/work-plans/issue-10/.Authored-By:
Claude Code AgentModel:
Claude Opus 4.7 (1M context)