Skip to content

refactor(pubsub)!: one publish name, with ownership choosing the route - #342

Open
YuanYuYuan wants to merge 12 commits into
feat/payload-poolfrom
feat/publish-api
Open

YuanYuYuan wants to merge 12 commits into
feat/payload-poolfrom
feat/publish-api

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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 a Published from 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 whose sn never advanced. It now sits on publish_ref.

publisher.publish(&msg)?;        // zenoh          -> Published::Wire
publisher.publish(arc)?;         // shared bus     -> Published::Bus(Delivery::Sent(n))
publisher.publish_moved(msg)?;   // gives it away, by name
publisher.publish_ref(&msg)?;    // explicit wire, keeps Result<()>

This is rclcpp's shape — publish(const&) against publish(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:

finding outcome
the impls cannot overlap, because the trait carries the message type confirmed by the compiler — no E0119
a generic parameter does not deref-coerce, so publish(&arc) stops compiling where publish(&self, msg: &T) accepted it a loud compile error, never a silent reroute; publish_ref is the fix
clippy::needless_borrows_for_generic_args suggested dropping the &, which selected a different impl and moved a message from the wire to the bus no impl for T. 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 errors

The third is why publish covers two routes rather than three. The moved route keeps its own name, which says what it does better than publish(msg) did.

Published { Wire, Bus(Delivery), BusAndWire(Delivery) } composes rather than widening Delivery: a Wire variant inside Delivery would have put a value the bus never returns into the type whose contract is that NoTaker and DepthExceeded stay distinct.

Note

Published is 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, so publish and publish_moved cannot disagree about where a message goes.

Breaking changes

change who is affected before → after
publish returns Result<Published>, not Result<()> callers who bind or match the result. publish(..)?; in statement position is unaffected let () = pub.publish(&m)?;pub.publish(&m)?;
publish is generic, so its argument no longer deref-coerces callers passing &Arc<T>, &Box<T> or a newtype reference publish(&arc)publish_ref(&arc)
no Publishable impl for T callers passing an owned value publish(msg)publish_moved(msg)
publish_owned renamed callers of it publish_owned(msg)publish_moved(msg)

Each of the first three is a compile error, never a silent behaviour change.

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. The set now names the argument's fate — borrowed, shared, moved.

Not affected

why
hiroz-py reaches publishing through RawPublisherpublish_serialized; never touches ZPub::publish
rmw-zenoh-rs (the C ABI) one call site pinned to publish_ref, because it is the tail expression of a Result<()> function. Builds clean — verified by compiling it, in the default devshell that carries AMENT_PREFIX_PATH
routing unchanged; a bus route still needs the caller to assert the audience
publish(&T) behaviour unchanged

Three 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:

gate result
clippy -D warnings 0
cargo fmt --all 0
cargo doc unresolved links 0
payload_pool 7 passed
intra_process 18 passed
payload_pool_compat 5 passed
rmw-zenoh-rs build 0 errors

The clippy result is the one that matters, and it is measured rather than argued: with impl Publishable for T present, 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 --check clean, clippy -p hiroz --all-targets -- -D warnings at 0 errors, cargo doc with 0 unresolved links, payload_pool 7/7, intra_process 23/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 at 28f343e6, and the only change since is a comment.

@YuanYuYuan
YuanYuYuan requested a balanced review from Copilot September 2, 2026 07:45
@YuanYuYuan YuanYuYuan changed the title One publish name, with ownership choosing the route refactor(pubsub)!: one publish name, with ownership choosing the route Sep 2, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 Publishable dispatch 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,” but publish_shared can return Wire for a plain publisher and BusAndWire for Locality::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 for publish_shared also makes the publish_shared section “Ordering against publish” incorrect: it says a message sent with publish always travels the wire and arrives later, although this new form performs the same synchronous bus delivery. Qualify that comparison as borrowed publish(&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.

Comment thread crates/hiroz/src/pubsub.rs
Comment thread crates/hiroz/src/pubsub.rs
Comment thread crates/hiroz/src/pubsub.rs
Comment thread crates/rmw-zenoh-rs/src/pubsub.rs Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants