refactor(pubsub)!: one publish name, with ownership choosing the route - #342
Open
YuanYuYuan wants to merge 12 commits into
Open
YuanYuYuan wants to merge 12 commits into
YuanYuYuan wants to merge 12 commits into
Conversation
This was referenced Sep 2, 2026
There was a problem hiding this comment.
🟡 Changes recommended
The new shared dispatch lacks direct coverage, and several public-facing descriptions contradict its routing behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Unifies publishing under ownership-based routing while retaining explicit wire and moved APIs.
Changes:
- Adds
Publishabledispatch for borrowed and shared messages. - Renames owned publishing to
publish_moved. - Updates internal callers, documentation, exports, and tests.
File summaries
| File | Description |
|---|---|
crates/hiroz/src/pubsub.rs |
Implements ownership-based publishing. |
crates/hiroz/src/local_bus.rs |
Renames moved-message bus publishing. |
crates/hiroz/src/prelude.rs |
Re-exports Publishable. |
crates/hiroz/src/payload_pool.rs |
Updates API references. |
crates/hiroz/src/action/server.rs |
Pins feedback to wire publishing. |
crates/hiroz/src/lifecycle/publisher.rs |
Preserves Result<()> publishing. |
crates/hiroz/src/lifecycle/node.rs |
Uses explicit wire publishing. |
crates/rmw-zenoh-rs/src/pubsub.rs |
Preserves the C ABI publish contract. |
crates/hiroz-tests/tests/intra_process.rs |
Updates moved-route tests and callers. |
Review details
Suppressed comments (2)
crates/hiroz/src/pubsub.rs:651
- The
Arc<T>row says this always takes “the bus,” butpublish_sharedcan returnWirefor a plain publisher andBusAndWireforLocality::Remote. Describe this as the configured shared route so the table agrees with the routing rules immediately below.
/// | `Arc<T>` | the bus, every subscriber sharing one allocation | [`ZPub::publish_shared`] |
crates/hiroz/src/pubsub.rs:666
- Making
publish(Arc<T>)an alias forpublish_sharedalso makes thepublish_sharedsection “Ordering against publish” incorrect: it says a message sent withpublishalways travels the wire and arrives later, although this new form performs the same synchronous bus delivery. Qualify that comparison as borrowedpublish(&msg)/publish_ref.
pub fn publish(&self, msg: impl Publishable<T, S>) -> Result<Published> {
msg.publish_to(self)
- Files reviewed: 9/9 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
YuanYuYuan
force-pushed
the
feat/payload-pool
branch
from
September 2, 2026 07:51
1511f16 to
d1e9881
Compare
YuanYuYuan
force-pushed
the
feat/publish-api
branch
from
September 2, 2026 07:51
1e1bc3e to
e70b07d
Compare
YuanYuYuan
force-pushed
the
feat/payload-pool
branch
from
September 2, 2026 09:16
d1e9881 to
fa0544f
Compare
YuanYuYuan
force-pushed
the
feat/publish-api
branch
from
September 2, 2026 09:16
e70b07d to
0b7042f
Compare
YuanYuYuan
force-pushed
the
feat/payload-pool
branch
from
September 2, 2026 09:29
fa0544f to
c1ec1f1
Compare
YuanYuYuan
force-pushed
the
feat/publish-api
branch
from
September 2, 2026 09:29
0b7042f to
9ea6142
Compare
publish now takes impl Publishable<T, S>: &T goes to zenoh, Arc<T> to the shared bus, T to the owned bus. That is rclcpp's shape - publish(const&) against publish(unique_ptr) - carried by a trait, since Rust has no overloading. Routing is unchanged. A bus route still needs the caller to assert the audience; without one an Arc<T> takes the wire exactly as before. The concrete wire method is now publish_ref, keeping Result<()>. The rmw C-ABI path uses it explicitly: that call is the tail expression of a Result<()> function, which is the one shape the new return type breaks. hiroz-py is unaffected - it goes through RawPublisher to publish_serialized and never touches this method. Delivery gains Wire and is now non_exhaustive, so a later variant is not a breaking change.
The trait's S bound was invented; it is the same higher-ranked bound the existing ZPub impl block carries. Delivery was imported inside one function and the trait signature needs it at module scope.
Adding a Wire variant to Delivery broke two internal matches and, worse, put a variant the bus never returns into the type whose whole contract is that NoTaker and DepthExceeded stay distinct (#36). Published { Wire, Bus(Delivery) } keeps that contract and says which route ran. The route is asked of the publisher rather than inferred from a count, because publish_shared returns 0 both for an empty bus and for a wire send. Two more tail-position callers, which the compiler found and my grep did not: action::server::publish_feedback and lifecycle::publisher::publish. Both pinned to publish_ref.
A generic parameter does not deref-coerce, so publish(&arc) no longer reaches the &T impl the way publish(&self, msg: &T) did. Recorded on #153 as the largest cost of the trait approach; the break is loud and the fix reads better than what it replaces.
clippy::needless_borrows_for_generic_args fires on publish(&expr) now that publish is generic, and its suggested fix selects the owned impl - moving the message from the wire to the intra-process bus. The lint is right about the borrow and wrong about the consequence, and this repo gates on -D warnings. Recorded on #153 as a fourth cost, with the option of dropping the owned impl from the trait to remove the footgun entirely.
publish_owned named the wrong party. Read literally it describes the publisher's relationship to the message, which is equally true of publish_shared, so it distinguished nothing. What the method actually does is give the message away. publish_moved completes the set by describing the argument's fate, as publish_ref and publish_shared already do: borrowed, shared, moved. It also maps onto publish(std::move(msg)), where rclcpp makes the move visible at the call site for the same reason.
… publish With an impl on the bare value, clippy::needless_borrows_for_generic_args fires on publish(&expr) and suggests dropping the &. That suggestion selects a different impl and moves the message from the wire to the intra-process bus - no error, no failing test - and this repo gates on -D warnings, so contributors are told to make the change. Measured both ways at the same call site: with the impl, the lint fires twice and clippy fails; without it, the call compiles and clippy passes with zero errors. Removing the impl deletes the hazard rather than documenting around it. publish now covers the two commonest routes. The moved route keeps its own name, publish_moved, which says what it does better than publish(msg) did.
A module-level //! doc is attributed to the pub mod line, so a link that reads fine in the file can still fail to resolve. cargo doc warned; nothing else did, and a dead link in a public API is the kind of defect this branch has been fixing elsewhere.
The Arc arm of Publishable reconstructed a Published from a count and a second copy of the routing condition, both of which predate the base reporting the route itself. Removing takes_the_bus takes the routing decision back to one place, and reattaches publish_ref's documentation, which had been left on it. The tracing span moves to publish_ref too: it records the wire sequence number, so on the forwarder a bus publish would have emitted a span whose sn never advanced.
It warned that clippy's suggestion would select the owned impl and reroute the message silently. That impl was removed for exactly this reason, so the suggestion now fails to compile instead.
…g call Nothing exercised publish(Arc<T>) - every test reached publish_shared directly, so the headline dispatch could regress silently. The durable error also recommended publish(), which is the call that just failed; it names publish_ref now. And an rmw comment still said Result<Delivery> where the type is Result<Published>.
The lead was inherited from when publish was the wire method: it promised serialization and pointed at async_publish. Neither holds for the Arc route, which never serializes and whose blocking is synchronous inline delivery - something async_publish cannot escape, being wire-only.
YuanYuYuan
force-pushed
the
feat/payload-pool
branch
from
September 2, 2026 09:55
c1ec1f1 to
9a43a5b
Compare
YuanYuYuan
force-pushed
the
feat/publish-api
branch
from
September 2, 2026 10:22
9ea6142 to
9ec3761
Compare
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 the publish-API design issue. Part of the intra-process meta issue.
Note
Stacked on #341, which is stacked on #340. The base is
feat/payload-pool, so the diff here is the publish API alone.Rebased onto the hardened base. Three things the rebase removed:
Publishable for Arc<T>had been reconstructing aPublishedfrom a count,takes_the_bus()was a third copy of the routing condition, and#[instrument(name = "publish")]— which records the wire sequence number — had been left on the generic forwarder, where a bus publish would have emitted a span whosesnnever advanced. It now sits onpublish_ref.This is rclcpp's shape —
publish(const&)againstpublish(std::move(msg))— carried by a trait, since Rust has no overloading. Routing rules are unchanged: a bus route still needs the caller to assert the audience.Three findings came out of building it, and two of them cost the design something:
E0119publish(&arc)stops compiling wherepublish(&self, msg: &T)accepted itpublish_refis the fixclippy::needless_borrows_for_generic_argssuggested dropping the&, which selected a different impl and moved a message from the wire to the busT. Measured at one call site: with the impl the lint fires twice and clippy fails; without it the call compiles and clippy passes with zero errorsThe third is why
publishcovers two routes rather than three. The moved route keeps its own name, which says what it does better thanpublish(msg)did.Published { Wire, Bus(Delivery), BusAndWire(Delivery) }composes rather than wideningDelivery: aWirevariant insideDeliverywould have put a value the bus never returns into the type whose contract is thatNoTakerandDepthExceededstay distinct.Note
Publishedis defined in #340, not here. It moved down once the base needed to report the same three routes; this PR consumes it. The base also resolves the route in one place, sopublishandpublish_movedcannot disagree about where a message goes.Breaking changes
publishreturnsResult<Published>, notResult<()>publish(..)?;in statement position is unaffectedlet () = pub.publish(&m)?;→pub.publish(&m)?;publishis generic, so its argument no longer deref-coerces&Arc<T>,&Box<T>or a newtype referencepublish(&arc)→publish_ref(&arc)Publishableimpl forTpublish(msg)→publish_moved(msg)publish_ownedrenamedpublish_owned(msg)→publish_moved(msg)Each of the first three is a compile error, never a silent behaviour change.
publish_ownednamed the wrong party: read literally it describes the publisher's relationship to the message, which is equally true ofpublish_shared, so it distinguished nothing. The set now names the argument's fate — borrowed, shared, moved.Not affected
hiroz-pyRawPublisher→publish_serialized; never touchesZPub::publishrmw-zenoh-rs(the C ABI)publish_ref, because it is the tail expression of aResult<()>function. Builds clean — verified by compiling it, in thedefaultdevshell that carriesAMENT_PREFIX_PATHpublish(&T)behaviourThree tail-position call sites needed pinning. A grep found one; the compiler found the other two (
action::server::publish_feedback,lifecycle::publisher::publish) and a third later. For a signature change the compiler is the detector and a grep is a hypothesis.Evidence
Full gate at
dd3aec9f8, in a devshell with ROS headers:-D warningscargo fmt --allcargo docunresolved linkspayload_poolintra_processpayload_pool_compatrmw-zenoh-rsbuildThe clippy result is the one that matters, and it is measured rather than argued: with
impl Publishable for Tpresent, the lint fires twice and clippy fails; with it removed, the same call site compiles and clippy passes at zero errors. The positive control is what makes the second half meaningful.What fails without this
Nothing. No failing baseline: this is ergonomics. The justification is that hiroz's publish surface should read like the ROS API its users come from, and that Rust's type system already carries the distinction three names spelled out.
Evidence
At
32b3934f, on the worker:cargo fmt --all --checkclean,clippy -p hiroz --all-targets -- -D warningsat 0 errors,cargo docwith 0 unresolved links,payload_pool7/7,intra_process23/23 with the executed-test count printed (ran=23) so an empty binary cannot read as a pass.rmw-zenoh-rs— the C ABI consumer — builds clean; that was measured at28f343e6, and the only change since is a comment.