Skip to main content

nros_node/executor/
node.rs

1//! Node — borrows the session to create typed entities.
2
3use core::marker::PhantomData;
4
5use nros_core::{RosAction, RosMessage, RosService};
6use nros_rmw::{ActionInfo, QoSProfile, ServiceInfo, Session as _, TopicInfo, TransportError};
7
8use crate::{
9    rmw_type_registry::{MessageForRmw, register_type},
10    session,
11};
12
13use super::{
14    handles::{
15        ActionClient, ActionClientCallback, ActionServer, EmbeddedPublisher, EmbeddedServiceClient,
16        EmbeddedServiceServer, ServiceClientCallback, Subscription,
17    },
18    types::NodeError,
19};
20
21/// Validate a profile against what the backend honours, and SAY WHICH POLICY
22/// when it does not — phase-417 G7.
23///
24/// Six `create_*` paths ran `qos.validate_against(…).map_err(NodeError::Transport)`
25/// and that was the whole diagnostic: the C caller saw `RCLC_RET_REJECTED`, the
26/// C++ caller `NROS_CPP_RET_NOT_ALLOWED`, and neither could learn WHICH policy
27/// the backend declined — while `rmw_vtable.h`'s `supported_qos_policies` block
28/// told the reader those creates fail "naming a policy". This is the one place
29/// that sentence becomes true for a caller who only gets a return code.
30///
31/// ONE helper rather than a log line per site, for the reason CLAUDE.md's "fix
32/// the CLASS" rule gives: six copies of a diagnostic are six places for the
33/// wording, the logger name and the fallback to drift, and this family has
34/// already paid that twice (the sizes-header mirror, the Zephyr unset-variable
35/// guard).
36///
37/// The policy NAME comes from [`nros_rmw::QoSPolicyMask::NAMED`] by way of
38/// `TransportError::qos_policy_name`, so this crate states no policy vocabulary
39/// of its own, and the token a C++ caller reads out of
40/// `nros::qos_policy_kind_to_cstr` is the same byte string that appears here.
41pub(crate) fn validate_qos_or_report(
42    qos: &QoSProfile,
43    supported: nros_rmw::QoSPolicyMask,
44    kind: &str,
45    name: &str,
46) -> Result<(), NodeError> {
47    match qos.validate_against(supported) {
48        Ok(()) => Ok(()),
49        Err(err) => {
50            nros_log::log_error!(
51                nros_log::get_logger("nros_node"),
52                "qos: {} '{}' refused — the backend does not honour {}",
53                kind,
54                name,
55                // `None` is a real answer, not a missing one: a refusal
56                // relayed from a C backend crosses `rmw_ret_t`, which has no
57                // room for a mask, so no policy was identified. Naming a
58                // neighbouring one would be the plausible wrong answer.
59                err.qos_policy_name()
60                    .unwrap_or("a QoS policy it did not name")
61            );
62            Err(NodeError::Transport(err))
63        }
64    }
65}
66
67// ============================================================================
68// Node
69// ============================================================================
70
71/// Backend-agnostic node — borrows the session to create typed entities.
72pub struct NodeHandle<'a> {
73    name: heapless::String<64>,
74    namespace: heapless::String<64>,
75    session: &'a mut session::ConcreteSession,
76    /// The executor's remap table, both tiers. Consulted only to SUBSTITUTE a
77    /// name some rule matches — see `NodeHandle::remapped`.
78    remaps: &'a [Option<super::spin::RemapRule>],
79    domain_id: u32,
80    /// Phase 211.H — per-node QoS overrides lowered from the launch
81    /// `qos_overrides.<topic>.<role>.<policy>` params and baked into a
82    /// `&'static` table by the entry codegen. Folded into each entity's
83    /// `QoSProfile` at `create_publisher`/`create_subscription` time
84    /// (setup-time, no alloc). Empty (`&[]`) by default → zero cost for
85    /// systems without overrides.
86    qos_overrides: &'static [nros_rmw::QoSOverride],
87    /// RFC-0052 W3b.4 — baked contract-monitor table (mirror of
88    /// `qos_overrides`): `create_publisher` attaches the matching
89    /// endpoint's counter cell so `publish` can bump it lock-free.
90    monitors: &'static [crate::executor::monitor::MonitorSpec],
91    /// W3b.5 — baked subscriber age-contract table + epoch clock;
92    /// `create_subscription` attaches the matching endpoint's age cell.
93    age_monitors: &'static [crate::executor::monitor::AgeMonitorSpec],
94    epoch_us_fn: Option<fn() -> u64>,
95}
96
97impl<'a> NodeHandle<'a> {
98    /// Create a new node (called by Executor::create_node).
99    pub(crate) fn new(
100        name: heapless::String<64>,
101        namespace: heapless::String<64>,
102        session: &'a mut session::ConcreteSession,
103        remaps: &'a [Option<super::spin::RemapRule>],
104        domain_id: u32,
105    ) -> Self {
106        Self {
107            name,
108            namespace,
109            session,
110            remaps,
111            domain_id,
112            qos_overrides: &[],
113            monitors: &[],
114            age_monitors: &[],
115            epoch_us_fn: None,
116        }
117    }
118
119    /// Phase 211.H — install the plan's QoS-override table on this node. Called
120    /// by the generated entry BEFORE the component constructs its entities, so
121    /// `create_publisher`/`create_subscription` fold the matching overrides in.
122    /// The table is `&'static` (codegen bakes it as a `static`), so there is no
123    /// lifetime to thread and no runtime allocation. Plan = authority: an
124    /// override for a topic the entity creates is applied transparently (the
125    /// user's `create_publisher(topic)` call is unchanged, matching rclcpp).
126    pub fn set_qos_overrides(&mut self, overrides: &'static [nros_rmw::QoSOverride]) {
127        self.qos_overrides = overrides;
128    }
129
130    /// RFC-0052 W3b.4 — install the executor's monitor table on this node
131    /// (called by the entry glue / fixture alongside `set_qos_overrides`).
132    pub fn set_monitors(&mut self, monitors: &'static [crate::executor::monitor::MonitorSpec]) {
133        self.monitors = monitors;
134    }
135
136    /// W3b.5 — install the subscriber age-contract table + epoch clock
137    /// (auto-seeded from the executor's `set_age_table` / config epoch).
138    pub fn set_age_monitors(
139        &mut self,
140        table: &'static [crate::executor::monitor::AgeMonitorSpec],
141        epoch_us: Option<fn() -> u64>,
142    ) {
143        self.age_monitors = table;
144        self.epoch_us_fn = epoch_us;
145    }
146
147    /// The installed QoS-override table (empty unless the entry set one).
148    #[must_use]
149    pub fn qos_overrides(&self) -> &'static [nros_rmw::QoSOverride] {
150        self.qos_overrides
151    }
152
153    /// Get the node name.
154    pub fn name(&self) -> &str {
155        &self.name
156    }
157
158    /// RFC-0088 — the serialization format this node's entities speak.
159    ///
160    /// The counterpart to ROS 2's `rmw_get_serialization_format()`, asked of
161    /// the node rather than the process: an `Executor::open_multi` image holds
162    /// two sessions and has no single answer, which is the case the reserved
163    /// vtable slot was cut for.
164    ///
165    /// A single-backend image already knows this at compile time — see
166    /// [`crate::session::IMAGE_SERIALIZATION_FORMAT`], which is what the
167    /// entity-creation assertions compare against. Use this accessor when the
168    /// answer must be a value: a bridge, a diagnostic, a tool.
169    ///
170    /// **`None` means the backend has not said** — the same discipline the C
171    /// entry point `rclc_node_get_serialization_format` has always kept by
172    /// returning NULL, and the one `rmw_vtable.h` states for the slot
173    /// underneath. It is not a synonym for `"cdr"`.
174    #[must_use]
175    pub fn serialization_format(&self) -> Option<&'static str> {
176        nros_rmw::Session::serialization_format(&*self.session)
177    }
178
179    /// Phase 88.12 — return the [`nros_log::Logger`] NAMED FOR THIS NODE.
180    ///
181    /// Loggers are interned in nros-log's bounded global table
182    /// ([`nros_log::MAX_LOGGERS`] slots). If the caller has
183    /// pre-registered a `'static Logger` whose name matches this
184    /// node's name (via [`nros_log::register_logger`]), this method
185    /// returns that exact reference — so subsequent `nros_*!` calls
186    /// share per-logger runtime threshold state with any other call
187    /// site that resolves the same name.
188    ///
189    /// phase-427 W5 — otherwise one is CREATED under the node's name, in
190    /// nros-log's bounded arena. It used to answer
191    /// [`nros_log::DEFAULT_LOGGER`] instead, which kept the API total by
192    /// making every node in an image share the name `"nros"`: no record could
193    /// say which node emitted it, and `set_level` on one node's handle moved
194    /// every other node's bar with it. [`nros_log::resolve_logger`] is the one
195    /// spelling of this resolution; the catch-all remains the fallback for an
196    /// exhausted arena, and it reports itself when it fires.
197    ///
198    /// ```ignore
199    /// // Pre-register if you want a `'static` logger you can also name
200    /// // from elsewhere in the image:
201    /// static MY_NODE_LOGGER: nros_log::Logger =
202    ///     nros_log::Logger::new("my_node");
203    /// nros_log::register_logger(&MY_NODE_LOGGER);
204    ///
205    /// // Inside any node-creating code:
206    /// let logger = node.logger();
207    /// nros_log::log_info!(logger, "started; domain = {}", node.domain_id());
208    /// ```
209    #[must_use]
210    pub fn logger(&self) -> &'static nros_log::Logger {
211        nros_log::resolve_node_logger(self.name())
212    }
213
214    /// Get the domain ID.
215    pub fn domain_id(&self) -> u32 {
216        self.domain_id
217    }
218
219    /// Set the domain ID.
220    pub fn set_domain_id(&mut self, domain_id: u32) {
221        self.domain_id = domain_id;
222    }
223
224    /// Get a mutable reference to the underlying session.
225    pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
226        self.session
227    }
228
229    // ------------------------------------------------------------------
230    // Routing-info builders (Phase 91.F)
231    //
232    // Every `create_*` below threads the same node identity (domain_id +
233    // name + namespace) into a TopicInfo / ServiceInfo / ActionInfo. The
234    // shape repeats verbatim ~12 times across this file. Centralised
235    // here so a future change to the routing-info shape (e.g. adding a
236    // `with_security_context`) updates one site instead of twelve, and
237    // so the per-`create_*` function bodies focus on the parts that
238    // actually differ between them.
239    // ------------------------------------------------------------------
240
241    // Associated fns (NOT `&self` methods) so the returned `*Info`
242    // value's borrow tracks only the explicit `&str` arguments, not
243    // the whole `Node`. A `&self` form would block the immediately-
244    // following `self.session.create_*(&info, …)` mut borrow on the
245    // `name` / `namespace` reborrow held inside the returned `*Info`,
246    // because going through a method call hides the field-disjoint
247    // path that lets `&self.name` + `&mut self.session` coexist.
248    fn topic_info<'b>(
249        domain_id: u32,
250        node_name: &'b str,
251        namespace: &'b str,
252        topic_name: &'b str,
253        type_name: &'b str,
254        type_hash: &'b str,
255    ) -> TopicInfo<'b> {
256        TopicInfo::new(topic_name, type_name, type_hash)
257            .with_domain(domain_id)
258            .with_node_name(node_name)
259            .with_namespace(namespace)
260    }
261
262    fn service_info<'b>(
263        domain_id: u32,
264        node_name: &'b str,
265        namespace: &'b str,
266        service_name: &'b str,
267        type_name: &'b str,
268        type_hash: &'b str,
269    ) -> ServiceInfo<'b> {
270        ServiceInfo::new(service_name, type_name, type_hash)
271            .with_domain(domain_id)
272            .with_node_name(node_name)
273            .with_namespace(namespace)
274    }
275
276    fn action_info<'b>(
277        domain_id: u32,
278        action_name: &'b str,
279        type_name: &'b str,
280        type_hash: &'b str,
281    ) -> ActionInfo<'b> {
282        // Action root only needs the domain — per-channel ServiceInfo /
283        // TopicInfo derived from action_info.{send_goal,cancel_goal,...}_key()
284        // carry the full node identity via service_info() / topic_info().
285        ActionInfo::new(action_name, type_name, type_hash).with_domain(domain_id)
286    }
287
288    /// The name a rule in either tier sends `source` to, or `None` when no
289    /// rule matches — in which case the caller passes `source` through
290    /// UNCHANGED, exactly as this handle always has.
291    ///
292    /// Launch rules (`Executor::declare_remap`) are authoritative and
293    /// `--ros-args` rules are the fallback, through
294    /// [`crate::names::remap_layered`] — the same two tiers
295    /// `Executor::resolve_entity_name_for` gives the C and C++ roads and
296    /// `ExecutorSink` gives Rust components.
297    ///
298    /// Substitution-only, deliberately. This handle has never expanded a name
299    /// (`~`/relative) before handing it to the backend, and doing so now for
300    /// every entity would move wire names nobody asked to move. A matched rule
301    /// yields a fully-qualified name, which is what every other road hands the
302    /// backend too.
303    ///
304    /// Before this the handle consulted no remap at all: a rule declared on
305    /// the executor reached C, C++ and component entities and silently missed
306    /// every entity created through `Executor::create_node`'s handle.
307    fn remapped(&self, source: &str) -> Option<crate::names::ResolvedName> {
308        use super::spin::{RemapTier, tier_rules};
309        crate::names::remap_layered(
310            source,
311            &self.name,
312            &self.namespace,
313            tier_rules(self.remaps, RemapTier::Launch, &self.name, &self.namespace),
314            tier_rules(self.remaps, RemapTier::Argv, &self.name, &self.namespace),
315        )
316    }
317
318    // -- Publishers --
319
320    /// Create a publisher for the given topic.
321    pub fn create_publisher<M: MessageForRmw>(
322        &mut self,
323        topic_name: &str,
324    ) -> Result<EmbeddedPublisher<M>, NodeError> {
325        self.create_publisher_with_qos::<M>(topic_name, QoSProfile::default())
326    }
327
328    /// Create a publisher with custom QoS settings.
329    pub fn create_publisher_with_qos<M: MessageForRmw>(
330        &mut self,
331        topic_name: &str,
332        qos: QoSProfile,
333    ) -> Result<EmbeddedPublisher<M>, NodeError> {
334        // RFC-0088 / phase-421 W1 — the message's declared format must be the
335        // one the linked backend speaks. Universal: the const lives on
336        // `RosMessage`, which `MessageForRmw` requires under every backend.
337        crate::format_check::assert_message_format::<M>();
338        // Phase 212.K.7.6.b — under `rmw-cyclonedds`, ensure the runtime
339        // type-descriptor exists before the cffi vtable creates the
340        // entity. No-op for other RMWs.
341        register_type::<M>()?;
342        // Phase 211.H — fold any plan qos_overrides for this topic+publisher
343        // into the profile (setup-time, no alloc) BEFORE validation, so an
344        // override the backend can't honour still errors loudly below.
345        let qos = qos.apply_overrides(
346            topic_name,
347            nros_rmw::QoSOverrideRole::Publisher,
348            self.qos_overrides,
349        );
350        // Phase 108.B — synchronous QoS validation against backend's
351        // `supported_qos_policies()` mask. No silent downgrade.
352        let remapped = self.remapped(topic_name);
353        let topic = Self::topic_info(
354            self.domain_id,
355            &self.name,
356            &self.namespace,
357            remapped.as_deref().unwrap_or(topic_name),
358            <M as RosMessage>::TYPE_NAME,
359            <M as RosMessage>::TYPE_HASH,
360        );
361        // issue 1608 -- the publisher half of the declared-QoS check. BEFORE
362        // validation, so a policy the contract raises this publisher to is
363        // one the backend is asked whether it can honour.
364        let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
365        validate_qos_or_report(
366            &qos,
367            nros_rmw::Session::supported_qos_policies(self.session),
368            "publisher",
369            topic_name,
370        )?;
371        let handle = self
372            .session
373            .create_publisher(&topic, qos)
374            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))?;
375        // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell
376        // (exact topic-name match against the baked table; None = free).
377        let monitor = self
378            .monitors
379            .iter()
380            .find(|m| m.topic == topic_name)
381            .map(|m| m.cell);
382        Ok(EmbeddedPublisher {
383            handle,
384            event_regs: crate::executor::handles::empty_event_regs(),
385            monitor,
386            epoch: self.epoch_us_fn,
387            _phantom: PhantomData,
388        })
389    }
390
391    /// Create a typeless publisher for non-ROS wire formats (e.g. PX4 uORB
392    /// raw POD bytes, custom binary protocols). The caller supplies the
393    /// `type_name` and `type_hash` strings used by backends that need them
394    /// for liveliness/discovery; backends that don't (uORB) can pass any
395    /// stable string.
396    pub fn create_publisher_raw(
397        &mut self,
398        topic_name: &str,
399        type_name: &str,
400        type_hash: &str,
401    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
402        self.create_publisher_raw_with_qos(topic_name, type_name, type_hash, QoSProfile::default())
403    }
404
405    /// Typeless publisher with custom QoS.
406    pub fn create_publisher_raw_with_qos(
407        &mut self,
408        topic_name: &str,
409        type_name: &str,
410        type_hash: &str,
411        qos: QoSProfile,
412    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
413        // Phase 211.H — apply plan qos_overrides (publisher side) before validate.
414        let qos = qos.apply_overrides(
415            topic_name,
416            nros_rmw::QoSOverrideRole::Publisher,
417            self.qos_overrides,
418        );
419        let remapped = self.remapped(topic_name);
420        let topic = Self::topic_info(
421            self.domain_id,
422            &self.name,
423            &self.namespace,
424            remapped.as_deref().unwrap_or(topic_name),
425            type_name,
426            type_hash,
427        );
428        // issue 1608 -- see `create_publisher_with_qos`.
429        let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
430        validate_qos_or_report(
431            &qos,
432            nros_rmw::Session::supported_qos_policies(self.session),
433            "publisher",
434            topic_name,
435        )?;
436        let handle = self
437            .session
438            .create_publisher(&topic, qos)
439            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))?;
440        Ok(crate::executor::handles::EmbeddedRawPublisher {
441            handle,
442            arena: crate::executor::handles::TxArena::new(),
443            event_regs: crate::executor::handles::empty_event_regs(),
444            // Issue 1676 — the typed path's rule, verbatim.
445            monitor: self
446                .monitors
447                .iter()
448                .find(|m| m.topic == topic_name)
449                .map(|m| m.cell),
450        })
451    }
452
453    /// Phase 189.M1 — the customizable publisher **builder** (the `clone` tier;
454    /// see `docs/design/0022-entity-api-tiers.md`). Pick a mode with `.typed::<M>()`
455    /// or `.generic(type, hash)`, set knobs (`.qos`), then `.build()`. The
456    /// convenient `create_publisher` / `create_publisher_raw` are the `fork`
457    /// tier — sugar over this with defaults.
458    pub fn publisher<'t>(&mut self, topic: &'t str) -> PublisherBuilder<'_, 'a, 't> {
459        PublisherBuilder {
460            node: self,
461            topic,
462            qos: QoSProfile::default(),
463        }
464    }
465
466    // -- Subscriptions --
467
468    /// Create a subscription for the given topic.
469    pub fn create_subscription<M: MessageForRmw>(
470        &mut self,
471        topic_name: &str,
472    ) -> Result<Subscription<M>, NodeError> {
473        self.create_subscription_sized::<M, { crate::config::DEFAULT_RX_BUF_SIZE }>(topic_name)
474    }
475
476    /// Create a subscription with custom buffer size.
477    pub fn create_subscription_sized<M: MessageForRmw, const RX_BUF: usize>(
478        &mut self,
479        topic_name: &str,
480    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
481        self.create_subscription_with_qos::<M, RX_BUF>(topic_name, QoSProfile::default())
482    }
483
484    /// Create a subscription with custom QoS and buffer size.
485    pub fn create_subscription_with_qos<M: MessageForRmw, const RX_BUF: usize>(
486        &mut self,
487        topic_name: &str,
488        qos: QoSProfile,
489    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
490        // RFC-0088 / phase-421 W1 — the message's declared format must be the
491        // one the linked backend speaks. Universal: the const lives on
492        // `RosMessage`, which `MessageForRmw` requires under every backend.
493        crate::format_check::assert_message_format::<M>();
494        // Phase 212.K.7.6.b — see `create_publisher_with_qos`.
495        register_type::<M>()?;
496        // Phase 211.H — apply plan qos_overrides (subscription side) before validate.
497        let qos = qos.apply_overrides(
498            topic_name,
499            nros_rmw::QoSOverrideRole::Subscription,
500            self.qos_overrides,
501        );
502        validate_qos_or_report(
503            &qos,
504            nros_rmw::Session::supported_qos_policies(self.session),
505            "subscription",
506            topic_name,
507        )?;
508        let remapped = self.remapped(topic_name);
509        let topic = Self::topic_info(
510            self.domain_id,
511            &self.name,
512            &self.namespace,
513            remapped.as_deref().unwrap_or(topic_name),
514            <M as RosMessage>::TYPE_NAME,
515            <M as RosMessage>::TYPE_HASH,
516        );
517        // phase-454 W10/W13 — see the same line in `executor/spin.rs`.
518        let qos = crate::declared_qos::honour(topic.type_name, topic.name, qos)?;
519        let handle = self
520            .session
521            .create_subscription(&topic, qos)
522            .map_err(NodeError::Transport)?;
523        // W3b.5 — attach the contracted endpoint's age cell (stamped
524        // types only; needs an epoch source).
525        let age_mon = match (<M as RosMessage>::STAMP_OFFSET, self.epoch_us_fn) {
526            (Some(_), Some(epoch)) => self
527                .age_monitors
528                .iter()
529                .find(|a| a.topic == topic_name)
530                .map(|a| (a.cell, epoch)),
531            _ => None,
532        };
533        Ok(Subscription {
534            handle,
535            buffer: [0u8; RX_BUF],
536            event_regs: crate::executor::handles::empty_event_regs(),
537            age_mon,
538            _phantom: PhantomData,
539        })
540    }
541
542    /// Create a typeless subscription. Caller decodes raw bytes themselves.
543    pub fn create_subscription_raw(
544        &mut self,
545        topic_name: &str,
546        type_name: &str,
547        type_hash: &str,
548    ) -> Result<crate::executor::handles::RawSubscription, NodeError> {
549        self.create_subscription_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
550            topic_name, type_name, type_hash,
551        )
552    }
553
554    /// Typeless subscription with custom buffer size.
555    pub fn create_subscription_raw_sized<const RX_BUF: usize>(
556        &mut self,
557        topic_name: &str,
558        type_name: &str,
559        type_hash: &str,
560    ) -> Result<crate::executor::handles::RawSubscription<RX_BUF>, NodeError> {
561        // Phase 211.H — apply plan qos_overrides (subscription side) before
562        // validate, mirroring `create_publisher_raw_with_qos`. The raw entity
563        // paths honour node overrides exactly like the typed ones — an
564        // override the active RMW can't meet errors loudly, never silently.
565        let qos = QoSProfile::default().apply_overrides(
566            topic_name,
567            nros_rmw::QoSOverrideRole::Subscription,
568            self.qos_overrides,
569        );
570        validate_qos_or_report(
571            &qos,
572            nros_rmw::Session::supported_qos_policies(self.session),
573            "subscription",
574            topic_name,
575        )?;
576        let remapped = self.remapped(topic_name);
577        let topic = Self::topic_info(
578            self.domain_id,
579            &self.name,
580            &self.namespace,
581            remapped.as_deref().unwrap_or(topic_name),
582            type_name,
583            type_hash,
584        );
585        // phase-454 W10/W13 — the raw path costs the arena exactly what
586        // the typed one does, so leaving it out would make the declared depth
587        // enforceable everywhere except in the images that decode bytes
588        // themselves.
589        let qos = crate::declared_qos::honour(topic.type_name, topic.name, qos)?;
590        let handle = self
591            .session
592            .create_subscription(&topic, qos)
593            .map_err(NodeError::Transport)?;
594        Ok(crate::executor::handles::RawSubscription {
595            handle,
596            buffer: [0u8; RX_BUF],
597            event_regs: crate::executor::handles::empty_event_regs(),
598        })
599    }
600
601    // -- Services --
602
603    /// Create a service server.
604    pub fn create_service<Svc: RosService>(
605        &mut self,
606        service_name: &str,
607    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
608    where
609        Svc::Request: MessageForRmw,
610        Svc::Reply: MessageForRmw,
611    {
612        self.create_service_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, QoSProfile::services_default())
613    }
614
615    /// Phase 193.2b — service server with an explicit QoS profile (applied to
616    /// both the request + reply endpoints; rclcpp's `create_service(name, qos)`).
617    pub fn create_service_with_qos<Svc: RosService>(
618        &mut self,
619        service_name: &str,
620        qos: QoSProfile,
621    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
622    where
623        Svc::Request: MessageForRmw,
624        Svc::Reply: MessageForRmw,
625    {
626        self.create_service_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, qos)
627    }
628
629    /// Create a service server with custom buffer sizes + QoS.
630    pub fn create_service_sized<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>(
631        &mut self,
632        service_name: &str,
633        qos: QoSProfile,
634    ) -> Result<EmbeddedServiceServer<Svc, REQ_BUF, REPLY_BUF>, NodeError>
635    where
636        Svc::Request: MessageForRmw,
637        Svc::Reply: MessageForRmw,
638    {
639        // Phase 212.K.7.6.b — register both halves of the service round-trip
640        // under cyclonedds. No-op for other RMWs.
641        register_type::<Svc::Request>()?;
642        register_type::<Svc::Reply>()?;
643        // Phase 193.5 — validate the service profile against the backend's
644        // supported policies (mirrors pub/sub); no silent downgrade. RELIABLE is
645        // effectively required for request/reply, so a backend that only honours
646        // a fixed profile rejects an incompatible request here.
647        validate_qos_or_report(
648            &qos,
649            nros_rmw::Session::supported_qos_policies(self.session),
650            "service",
651            service_name,
652        )?;
653        let remapped = self.remapped(service_name);
654        let info = Self::service_info(
655            self.domain_id,
656            &self.name,
657            &self.namespace,
658            remapped.as_deref().unwrap_or(service_name),
659            Svc::SERVICE_NAME,
660            Svc::SERVICE_HASH,
661        );
662        let handle = self
663            .session
664            .create_service(&info, qos)
665            .map_err(NodeError::Transport)?;
666        Ok(EmbeddedServiceServer {
667            handle,
668            req_buffer: [0u8; REQ_BUF],
669            reply_buffer: [0u8; REPLY_BUF],
670            _phantom: PhantomData,
671        })
672    }
673
674    /// Create a service client.
675    pub fn create_client<Svc: RosService>(
676        &mut self,
677        service_name: &str,
678    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
679    where
680        Svc::Request: MessageForRmw,
681        Svc::Reply: MessageForRmw,
682    {
683        self.create_client_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, QoSProfile::services_default())
684    }
685
686    /// Phase 193.2b — service client with an explicit QoS profile.
687    pub fn create_client_with_qos<Svc: RosService>(
688        &mut self,
689        service_name: &str,
690        qos: QoSProfile,
691    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
692    where
693        Svc::Request: MessageForRmw,
694        Svc::Reply: MessageForRmw,
695    {
696        self.create_client_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, qos)
697    }
698
699    /// Create a service client with custom buffer sizes + QoS.
700    pub fn create_client_sized<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>(
701        &mut self,
702        service_name: &str,
703        qos: QoSProfile,
704    ) -> Result<EmbeddedServiceClient<Svc, REQ_BUF, REPLY_BUF>, NodeError>
705    where
706        Svc::Request: MessageForRmw,
707        Svc::Reply: MessageForRmw,
708    {
709        // Phase 212.K.7.6.b — see `create_service_sized`.
710        register_type::<Svc::Request>()?;
711        register_type::<Svc::Reply>()?;
712        // Phase 193.5 — validate against the backend's supported policies (no
713        // silent downgrade); request/reply effectively requires RELIABLE.
714        validate_qos_or_report(
715            &qos,
716            nros_rmw::Session::supported_qos_policies(self.session),
717            "client",
718            service_name,
719        )?;
720        let remapped = self.remapped(service_name);
721        let info = Self::service_info(
722            self.domain_id,
723            &self.name,
724            &self.namespace,
725            remapped.as_deref().unwrap_or(service_name),
726            Svc::SERVICE_NAME,
727            Svc::SERVICE_HASH,
728        );
729        let handle = self
730            .session
731            .create_client(&info, qos)
732            .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?;
733        Ok(EmbeddedServiceClient {
734            handle,
735            req_buffer: [0u8; REQ_BUF],
736            reply_buffer: [0u8; REPLY_BUF],
737            in_flight: false,
738            _phantom: PhantomData,
739        })
740    }
741
742    /// Typeless service server. L1 counterpart of [`Self::create_service`]
743    /// for the C / C++ FFI shims and callers that own their own
744    /// scheduler. Returns a [`crate::executor::handles::RawServiceServer`]
745    /// which polls request bytes directly.
746    pub fn create_service_raw(
747        &mut self,
748        service_name: &str,
749        type_name: &str,
750        type_hash: &str,
751    ) -> Result<crate::executor::handles::RawServiceServer, NodeError> {
752        self.create_service_raw_sized::<
753            { crate::config::DEFAULT_RX_BUF_SIZE },
754            { crate::config::DEFAULT_RX_BUF_SIZE },
755        >(service_name, type_name, type_hash)
756    }
757
758    /// Typeless service server with custom buffer sizes.
759    pub fn create_service_raw_sized<const REQ_BUF: usize, const RESP_BUF: usize>(
760        &mut self,
761        service_name: &str,
762        type_name: &str,
763        type_hash: &str,
764    ) -> Result<crate::executor::handles::RawServiceServer<REQ_BUF, RESP_BUF>, NodeError> {
765        let remapped = self.remapped(service_name);
766        let info = Self::service_info(
767            self.domain_id,
768            &self.name,
769            &self.namespace,
770            remapped.as_deref().unwrap_or(service_name),
771            type_name,
772            type_hash,
773        );
774        let handle = self
775            .session
776            .create_service(&info, QoSProfile::services_default())
777            .map_err(NodeError::Transport)?;
778        Ok(crate::executor::handles::RawServiceServer::new(handle))
779    }
780
781    /// Typeless service client. L1 counterpart of [`Self::create_client`].
782    pub fn create_client_raw(
783        &mut self,
784        service_name: &str,
785        type_name: &str,
786        type_hash: &str,
787    ) -> Result<crate::executor::handles::RawServiceClient, NodeError> {
788        self.create_client_raw_sized::<
789            { crate::config::DEFAULT_RX_BUF_SIZE },
790            { crate::config::DEFAULT_RX_BUF_SIZE },
791        >(service_name, type_name, type_hash)
792    }
793
794    /// Typeless service client with custom buffer sizes.
795    pub fn create_client_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
796        &mut self,
797        service_name: &str,
798        type_name: &str,
799        type_hash: &str,
800    ) -> Result<crate::executor::handles::RawServiceClient<REQ_BUF, REPLY_BUF>, NodeError> {
801        let remapped = self.remapped(service_name);
802        let info = Self::service_info(
803            self.domain_id,
804            &self.name,
805            &self.namespace,
806            remapped.as_deref().unwrap_or(service_name),
807            type_name,
808            type_hash,
809        );
810        let handle = self
811            .session
812            .create_client(&info, QoSProfile::services_default())
813            .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?;
814        Ok(crate::executor::handles::RawServiceClient::new(handle))
815    }
816
817    // -- Actions --
818
819    /// Phase 122.3.c.6 — typeless action server. Builds the 5
820    /// transport channels (`send_goal` / `cancel_goal` / `get_result`
821    /// services + `feedback` / `status` publishers) and returns the
822    /// raw `ActionServerCore` directly. Caller owns scheduling —
823    /// drives `try_recv_goal_request` / `publish_feedback_raw` /
824    /// `complete_goal_raw` / `try_handle_cancel` /
825    /// `try_handle_get_result_raw` on the returned core.
826    pub fn create_action_server_raw(
827        &mut self,
828        action_name: &str,
829        type_name: &str,
830        type_hash: &str,
831    ) -> Result<
832        super::action_core::ActionServerCore<
833            { crate::config::DEFAULT_RX_BUF_SIZE },
834            { crate::config::DEFAULT_RX_BUF_SIZE },
835            { crate::config::DEFAULT_RX_BUF_SIZE },
836            4,
837        >,
838        NodeError,
839    > {
840        self.create_action_server_raw_sized::<
841            { crate::config::DEFAULT_RX_BUF_SIZE },
842            { crate::config::DEFAULT_RX_BUF_SIZE },
843            { crate::config::DEFAULT_RX_BUF_SIZE },
844            4,
845        >(action_name, type_name, type_hash)
846    }
847
848    /// Typeless action server with custom buffer + goal-slot sizes.
849    pub fn create_action_server_raw_sized<
850        const GOAL_BUF: usize,
851        const RESULT_BUF: usize,
852        const FEEDBACK_BUF: usize,
853        const MAX_GOALS: usize,
854    >(
855        &mut self,
856        action_name: &str,
857        type_name: &str,
858        type_hash: &str,
859    ) -> Result<
860        super::action_core::ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
861        NodeError,
862    > {
863        let remapped = self.remapped(action_name);
864        let action_name = remapped.as_deref().unwrap_or(action_name);
865        let action_info = Self::action_info(self.domain_id, action_name, type_name, type_hash);
866
867        // Issue 0454 / phase-354 W3 — per-CHANNEL DDS type, not the bare
868        // action type (see `action_channel_type`). These two `_raw_sized`
869        // constructors are the last holders of the phase-338 W3 defect: the
870        // type name is baked into the keyexpr, so `…Fibonacci_` never matches a
871        // peer advertising `…Fibonacci_SendGoal_`.
872        let send_goal_type: heapless::String<256> =
873            super::action_core::action_channel_type(type_name, "SendGoal");
874        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
875        let send_goal_info = Self::service_info(
876            self.domain_id,
877            &self.name,
878            &self.namespace,
879            &send_goal_keyexpr,
880            &send_goal_type,
881            type_hash,
882        );
883        let send_goal_server = self
884            .session
885            .create_service(&send_goal_info, QoSProfile::services_default())
886            .map_err(|_| NodeError::ActionCreationFailed)?;
887
888        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
889        let cancel_goal_info = Self::service_info(
890            self.domain_id,
891            &self.name,
892            &self.namespace,
893            &cancel_goal_keyexpr,
894            "action_msgs::srv::dds_::CancelGoal_",
895            type_hash,
896        );
897        let cancel_goal_server = self
898            .session
899            .create_service(&cancel_goal_info, QoSProfile::services_default())
900            .map_err(|_| NodeError::ActionCreationFailed)?;
901
902        let get_result_type: heapless::String<256> =
903            super::action_core::action_channel_type(type_name, "GetResult");
904        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
905        let get_result_info = Self::service_info(
906            self.domain_id,
907            &self.name,
908            &self.namespace,
909            &get_result_keyexpr,
910            &get_result_type,
911            type_hash,
912        );
913        let get_result_server = self
914            .session
915            .create_service(&get_result_info, QoSProfile::services_default())
916            .map_err(|_| NodeError::ActionCreationFailed)?;
917
918        let feedback_type: heapless::String<256> =
919            super::action_core::action_channel_type(type_name, "FeedbackMessage");
920        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
921        let feedback_topic = Self::topic_info(
922            self.domain_id,
923            &self.name,
924            &self.namespace,
925            &feedback_keyexpr,
926            &feedback_type,
927            type_hash,
928        );
929        let feedback_publisher = self
930            .session
931            .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
932            .map_err(|_| NodeError::ActionCreationFailed)?;
933
934        let status_keyexpr: heapless::String<256> = action_info.status_key();
935        let status_topic = Self::topic_info(
936            self.domain_id,
937            &self.name,
938            &self.namespace,
939            &status_keyexpr,
940            "action_msgs::msg::dds_::GoalStatusArray_",
941            type_hash,
942        );
943        let status_publisher = self
944            .session
945            .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
946            .map_err(|_| NodeError::ActionCreationFailed)?;
947
948        Ok(super::action_core::ActionServerCore {
949            send_goal_server,
950            cancel_goal_server,
951            get_result_server,
952            feedback_publisher,
953            status_publisher,
954            active_goals: heapless::Vec::new(),
955            completed_results: heapless::Vec::new(),
956            pending_get_results: heapless::Vec::new(),
957            result_slab: [0u8; RESULT_BUF],
958            result_slab_used: 0,
959            goal_buffer: [0u8; GOAL_BUF],
960            feedback_buffer: [0u8; FEEDBACK_BUF],
961            cancel_buffer: [0u8; 256],
962        })
963    }
964
965    /// Phase 122.3.c.6 — typeless action client. Same shape as
966    /// `create_action_server_raw` but builds the 3 service clients
967    /// + 1 feedback subscriber, returns the raw `ActionClientCore`.
968    pub fn create_action_client_raw(
969        &mut self,
970        action_name: &str,
971        type_name: &str,
972        type_hash: &str,
973    ) -> Result<
974        super::action_core::ActionClientCore<
975            { crate::config::DEFAULT_RX_BUF_SIZE },
976            { crate::config::DEFAULT_RX_BUF_SIZE },
977            { crate::config::DEFAULT_RX_BUF_SIZE },
978        >,
979        NodeError,
980    > {
981        self.create_action_client_raw_sized::<
982            { crate::config::DEFAULT_RX_BUF_SIZE },
983            { crate::config::DEFAULT_RX_BUF_SIZE },
984            { crate::config::DEFAULT_RX_BUF_SIZE },
985        >(action_name, type_name, type_hash)
986    }
987
988    /// Typeless action client with custom buffer sizes.
989    pub fn create_action_client_raw_sized<
990        const GOAL_BUF: usize,
991        const RESULT_BUF: usize,
992        const FEEDBACK_BUF: usize,
993    >(
994        &mut self,
995        action_name: &str,
996        type_name: &str,
997        type_hash: &str,
998    ) -> Result<super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
999    {
1000        let remapped = self.remapped(action_name);
1001        let action_name = remapped.as_deref().unwrap_or(action_name);
1002        let action_info = Self::action_info(self.domain_id, action_name, type_name, type_hash);
1003
1004        // Issue 0454 / phase-354 W3 — per-CHANNEL DDS type, not the bare
1005        // action type (see `action_channel_type`). These two `_raw_sized`
1006        // constructors are the last holders of the phase-338 W3 defect: the
1007        // type name is baked into the keyexpr, so `…Fibonacci_` never matches a
1008        // peer advertising `…Fibonacci_SendGoal_`.
1009        let send_goal_type: heapless::String<256> =
1010            super::action_core::action_channel_type(type_name, "SendGoal");
1011        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1012        let send_goal_info = Self::service_info(
1013            self.domain_id,
1014            &self.name,
1015            &self.namespace,
1016            &send_goal_keyexpr,
1017            &send_goal_type,
1018            type_hash,
1019        );
1020        let send_goal_client = self
1021            .session
1022            .create_client(&send_goal_info, QoSProfile::services_default())
1023            .map_err(|_| NodeError::ActionCreationFailed)?;
1024
1025        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1026        let cancel_goal_info = Self::service_info(
1027            self.domain_id,
1028            &self.name,
1029            &self.namespace,
1030            &cancel_goal_keyexpr,
1031            "action_msgs::srv::dds_::CancelGoal_",
1032            type_hash,
1033        );
1034        let cancel_goal_client = self
1035            .session
1036            .create_client(&cancel_goal_info, QoSProfile::services_default())
1037            .map_err(|_| NodeError::ActionCreationFailed)?;
1038
1039        let get_result_type: heapless::String<256> =
1040            super::action_core::action_channel_type(type_name, "GetResult");
1041        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1042        let get_result_info = Self::service_info(
1043            self.domain_id,
1044            &self.name,
1045            &self.namespace,
1046            &get_result_keyexpr,
1047            &get_result_type,
1048            type_hash,
1049        );
1050        let get_result_client = self
1051            .session
1052            .create_client(&get_result_info, QoSProfile::services_default())
1053            .map_err(|_| NodeError::ActionCreationFailed)?;
1054
1055        let feedback_type: heapless::String<256> =
1056            super::action_core::action_channel_type(type_name, "FeedbackMessage");
1057        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1058        let feedback_topic = Self::topic_info(
1059            self.domain_id,
1060            &self.name,
1061            &self.namespace,
1062            &feedback_keyexpr,
1063            &feedback_type,
1064            type_hash,
1065        );
1066        let feedback_subscriber = self
1067            .session
1068            .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
1069            .map_err(|_| NodeError::ActionCreationFailed)?;
1070
1071        Ok(super::action_core::ActionClientCore::new(
1072            send_goal_client,
1073            cancel_goal_client,
1074            get_result_client,
1075            feedback_subscriber,
1076        ))
1077    }
1078
1079    /// Create an action server.
1080    pub fn create_action_server<A: RosAction>(
1081        &mut self,
1082        action_name: &str,
1083    ) -> Result<ActionServer<A>, NodeError>
1084    where
1085        A::Goal: MessageForRmw,
1086        A::Result: MessageForRmw,
1087        A::Feedback: MessageForRmw,
1088        A::SendGoalRequest: MessageForRmw,
1089        A::SendGoalResponse: MessageForRmw,
1090        A::GetResultRequest: MessageForRmw,
1091        A::GetResultResponse: MessageForRmw,
1092        A::FeedbackMessage: MessageForRmw,
1093    {
1094        self.create_action_server_sized::<A, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, 4>(action_name)
1095    }
1096
1097    /// Create an action server with custom buffer sizes.
1098    pub fn create_action_server_sized<
1099        A: RosAction,
1100        const GOAL_BUF: usize,
1101        const RESULT_BUF: usize,
1102        const FEEDBACK_BUF: usize,
1103        const MAX_GOALS: usize,
1104    >(
1105        &mut self,
1106        action_name: &str,
1107    ) -> Result<ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>, NodeError>
1108    where
1109        A::Goal: MessageForRmw,
1110        A::Result: MessageForRmw,
1111        A::Feedback: MessageForRmw,
1112        A::SendGoalRequest: MessageForRmw,
1113        A::SendGoalResponse: MessageForRmw,
1114        A::GetResultRequest: MessageForRmw,
1115        A::GetResultResponse: MessageForRmw,
1116        A::FeedbackMessage: MessageForRmw,
1117    {
1118        // Phase 212.K.7.6.b + K.7.7.c — register the three user-facing
1119        // message types AND the five action-protocol envelope types under
1120        // cyclonedds. No-op for other RMWs. The envelopes are needed
1121        // because the action service shapes (`*_SendGoal_Request`,
1122        // `*_GetResult_Response`, …) are the actual on-wire CDR types,
1123        // and the C++ Cyclone bridge auto-prepends a cdds_request_header_t
1124        // for any TYPE_NAME ending `_Request`/`_Response`/`_Reply`.
1125        register_type::<A::Goal>()?;
1126        register_type::<A::Result>()?;
1127        register_type::<A::Feedback>()?;
1128        register_type::<A::SendGoalRequest>()?;
1129        register_type::<A::SendGoalResponse>()?;
1130        register_type::<A::GetResultRequest>()?;
1131        register_type::<A::GetResultResponse>()?;
1132        register_type::<A::FeedbackMessage>()?;
1133        // issue #234 — also register the fixed `action_msgs` protocol types
1134        // (`CancelGoal_{Request,Response}`, `GoalStatusArray`) the cancel service
1135        // + status publisher created below serialize. They are not `RosAction`
1136        // associated types (they live in `action_msgs`, which `nros-core` cannot
1137        // name), so the generated `impl RosAction::register_protocol_types` — which
1138        // routes them through the generic `nros_rmw::register_type_descriptor` seam —
1139        // registers them. Without this the cancel_goal service + status publisher
1140        // have no Cyclone descriptor → `ActionCreationFailed`. The callback executor
1141        // path (`executor/action.rs`) already did this; this node.rs path — the one
1142        // `create_action_server` materialises through — did not.
1143        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
1144        let remapped = self.remapped(action_name);
1145        let action_name = remapped.as_deref().unwrap_or(action_name);
1146        let action_info =
1147            Self::action_info(self.domain_id, action_name, A::ACTION_NAME, A::ACTION_HASH);
1148
1149        // Each underlying ServiceInfo / TopicInfo also carries the
1150        // node identity so the Zenoh shim declares a liveliness token
1151        // for it. Without `with_node_name` the shim's
1152        // `declare_entity_liveliness` short-circuits (`node_name.and_then`
1153        // → None) and `wait_for_action_server` has nothing to find.
1154        // Advertise the per-channel service / topic types ROS 2 matches on
1155        // (`<Action>_SendGoal` / `<Action>_GetResult` / `<Action>_FeedbackMessage`),
1156        // not the bare action type — see `action_core::action_service_base_type`.
1157        let send_goal_type = super::action_core::action_service_base_type(
1158            <A::SendGoalRequest as RosMessage>::TYPE_NAME,
1159            A::ACTION_NAME,
1160        );
1161        let get_result_type = super::action_core::action_service_base_type(
1162            <A::GetResultRequest as RosMessage>::TYPE_NAME,
1163            A::ACTION_NAME,
1164        );
1165        let feedback_type = <A::FeedbackMessage as RosMessage>::TYPE_NAME;
1166
1167        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1168        let send_goal_info = Self::service_info(
1169            self.domain_id,
1170            &self.name,
1171            &self.namespace,
1172            &send_goal_keyexpr,
1173            send_goal_type,
1174            A::SEND_GOAL_SERVICE_HASH,
1175        );
1176        let send_goal_server = self
1177            .session
1178            .create_service(&send_goal_info, QoSProfile::services_default())
1179            .map_err(|_| NodeError::ActionCreationFailed)?;
1180
1181        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1182        let cancel_goal_info = Self::service_info(
1183            self.domain_id,
1184            &self.name,
1185            &self.namespace,
1186            &cancel_goal_keyexpr,
1187            "action_msgs::srv::dds_::CancelGoal_",
1188            A::CANCEL_GOAL_SERVICE_HASH,
1189        );
1190        let cancel_goal_server = self
1191            .session
1192            .create_service(&cancel_goal_info, QoSProfile::services_default())
1193            .map_err(|_| NodeError::ActionCreationFailed)?;
1194
1195        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1196        let get_result_info = Self::service_info(
1197            self.domain_id,
1198            &self.name,
1199            &self.namespace,
1200            &get_result_keyexpr,
1201            get_result_type,
1202            A::GET_RESULT_SERVICE_HASH,
1203        );
1204        let get_result_server = self
1205            .session
1206            .create_service(&get_result_info, QoSProfile::services_default())
1207            .map_err(|_| NodeError::ActionCreationFailed)?;
1208
1209        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1210        let feedback_topic = Self::topic_info(
1211            self.domain_id,
1212            &self.name,
1213            &self.namespace,
1214            &feedback_keyexpr,
1215            feedback_type,
1216            <A::FeedbackMessage as RosMessage>::TYPE_HASH,
1217        );
1218        let feedback_publisher = self
1219            .session
1220            .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
1221            .map_err(|_| NodeError::ActionCreationFailed)?;
1222
1223        let status_keyexpr: heapless::String<256> = action_info.status_key();
1224        let status_topic = Self::topic_info(
1225            self.domain_id,
1226            &self.name,
1227            &self.namespace,
1228            &status_keyexpr,
1229            "action_msgs::msg::dds_::GoalStatusArray_",
1230            A::STATUS_MESSAGE_HASH,
1231        );
1232        let status_publisher = self
1233            .session
1234            .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
1235            .map_err(|_| NodeError::ActionCreationFailed)?;
1236
1237        Ok(ActionServer {
1238            core: super::action_core::ActionServerCore {
1239                send_goal_server,
1240                cancel_goal_server,
1241                get_result_server,
1242                feedback_publisher,
1243                status_publisher,
1244                active_goals: heapless::Vec::new(),
1245                completed_results: heapless::Vec::new(),
1246                pending_get_results: heapless::Vec::new(),
1247                result_slab: [0u8; RESULT_BUF],
1248                result_slab_used: 0,
1249                goal_buffer: [0u8; GOAL_BUF],
1250                feedback_buffer: [0u8; FEEDBACK_BUF],
1251                cancel_buffer: [0u8; 256],
1252            },
1253            typed_goals: heapless::Vec::new(),
1254            completed_goals: heapless::Vec::new(),
1255        })
1256    }
1257
1258    /// Create an action client.
1259    pub fn create_action_client<A: RosAction>(
1260        &mut self,
1261        action_name: &str,
1262    ) -> Result<ActionClient<A>, NodeError>
1263    where
1264        A::Goal: MessageForRmw,
1265        A::Result: MessageForRmw,
1266        A::Feedback: MessageForRmw,
1267        A::SendGoalRequest: MessageForRmw,
1268        A::SendGoalResponse: MessageForRmw,
1269        A::GetResultRequest: MessageForRmw,
1270        A::GetResultResponse: MessageForRmw,
1271        A::FeedbackMessage: MessageForRmw,
1272    {
1273        self.create_action_client_sized::<A, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(action_name)
1274    }
1275
1276    /// Create an action client with custom buffer sizes.
1277    pub fn create_action_client_sized<
1278        A: RosAction,
1279        const GOAL_BUF: usize,
1280        const RESULT_BUF: usize,
1281        const FEEDBACK_BUF: usize,
1282    >(
1283        &mut self,
1284        action_name: &str,
1285    ) -> Result<ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
1286    where
1287        A::Goal: MessageForRmw,
1288        A::Result: MessageForRmw,
1289        A::Feedback: MessageForRmw,
1290        A::SendGoalRequest: MessageForRmw,
1291        A::SendGoalResponse: MessageForRmw,
1292        A::GetResultRequest: MessageForRmw,
1293        A::GetResultResponse: MessageForRmw,
1294        A::FeedbackMessage: MessageForRmw,
1295    {
1296        // Phase 212.K.7.6.b + K.7.7.c — see `create_action_server_sized`.
1297        register_type::<A::Goal>()?;
1298        register_type::<A::Result>()?;
1299        register_type::<A::Feedback>()?;
1300        register_type::<A::SendGoalRequest>()?;
1301        register_type::<A::SendGoalResponse>()?;
1302        register_type::<A::GetResultRequest>()?;
1303        register_type::<A::GetResultResponse>()?;
1304        register_type::<A::FeedbackMessage>()?;
1305        // issue #234 — register the `action_msgs` protocol types the cancel-goal
1306        // service client below serializes (`CancelGoal_{Request,Response}`; the impl
1307        // also registers `GoalStatusArray`, harmlessly unused client-side) via the
1308        // generic seam. Without it the cancel_goal client has no Cyclone descriptor
1309        // → `ActionCreationFailed`. Mirrors the server path.
1310        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
1311        let remapped = self.remapped(action_name);
1312        let action_name = remapped.as_deref().unwrap_or(action_name);
1313        let action_info =
1314            Self::action_info(self.domain_id, action_name, A::ACTION_NAME, A::ACTION_HASH);
1315
1316        // Mirror `create_action_server_sized`: thread node identity through
1317        // each underlying ServiceInfo / TopicInfo so the Zenoh shim
1318        // declares the matching client-side liveliness tokens (and so the
1319        // discovery wildcard built from `send_goal_info` ends up in the
1320        // same domain as the server's tokens).
1321        // Same per-channel typing as the server side so the client's requesters
1322        // and feedback reader match a real ROS 2 action server over DDS.
1323        let send_goal_type = super::action_core::action_service_base_type(
1324            <A::SendGoalRequest as RosMessage>::TYPE_NAME,
1325            A::ACTION_NAME,
1326        );
1327        let get_result_type = super::action_core::action_service_base_type(
1328            <A::GetResultRequest as RosMessage>::TYPE_NAME,
1329            A::ACTION_NAME,
1330        );
1331        let feedback_type = <A::FeedbackMessage as RosMessage>::TYPE_NAME;
1332
1333        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1334        let send_goal_info = Self::service_info(
1335            self.domain_id,
1336            &self.name,
1337            &self.namespace,
1338            &send_goal_keyexpr,
1339            send_goal_type,
1340            A::SEND_GOAL_SERVICE_HASH,
1341        );
1342        let send_goal_client = self
1343            .session
1344            .create_client(&send_goal_info, QoSProfile::services_default())
1345            .map_err(|_| NodeError::ActionCreationFailed)?;
1346
1347        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1348        let cancel_goal_info = Self::service_info(
1349            self.domain_id,
1350            &self.name,
1351            &self.namespace,
1352            &cancel_goal_keyexpr,
1353            "action_msgs::srv::dds_::CancelGoal_",
1354            A::CANCEL_GOAL_SERVICE_HASH,
1355        );
1356        let cancel_goal_client = self
1357            .session
1358            .create_client(&cancel_goal_info, QoSProfile::services_default())
1359            .map_err(|_| NodeError::ActionCreationFailed)?;
1360
1361        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1362        let get_result_info = Self::service_info(
1363            self.domain_id,
1364            &self.name,
1365            &self.namespace,
1366            &get_result_keyexpr,
1367            get_result_type,
1368            A::GET_RESULT_SERVICE_HASH,
1369        );
1370        let get_result_client = self
1371            .session
1372            .create_client(&get_result_info, QoSProfile::services_default())
1373            .map_err(|_| NodeError::ActionCreationFailed)?;
1374
1375        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1376        let feedback_topic = Self::topic_info(
1377            self.domain_id,
1378            &self.name,
1379            &self.namespace,
1380            &feedback_keyexpr,
1381            feedback_type,
1382            <A::FeedbackMessage as RosMessage>::TYPE_HASH,
1383        );
1384        let feedback_subscriber = self
1385            .session
1386            .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
1387            .map_err(|_| NodeError::ActionCreationFailed)?;
1388
1389        Ok(ActionClient {
1390            core: super::action_core::ActionClientCore {
1391                send_goal_client,
1392                cancel_goal_client,
1393                get_result_client,
1394                feedback_subscriber,
1395                goal_buffer: [0u8; GOAL_BUF],
1396                result_buffer: [0u8; RESULT_BUF],
1397                feedback_buffer: [0u8; FEEDBACK_BUF],
1398                goal_counter: 0,
1399                in_flight_send_goal: false,
1400                in_flight_cancel: false,
1401                in_flight_get_result: false,
1402            },
1403            _phantom: PhantomData,
1404        })
1405    }
1406}
1407
1408// ===================================================================
1409// Phase 189.M1 — entity builders (the `clone` tier)
1410// ===================================================================
1411
1412/// Publisher builder — `node.publisher(topic)`. Choose `.typed::<M>()` or
1413/// `.generic(type, hash)`, optionally `.qos(..)`, then `.build()`.
1414pub struct PublisherBuilder<'n, 'a, 't> {
1415    node: &'n mut NodeHandle<'a>,
1416    topic: &'t str,
1417    qos: QoSProfile,
1418}
1419
1420impl<'n, 'a, 't> PublisherBuilder<'n, 'a, 't> {
1421    /// Set the QoS (also settable on the typed/generic builder).
1422    pub fn qos(mut self, qos: QoSProfile) -> Self {
1423        self.qos = qos;
1424        self
1425    }
1426
1427    /// Phase 282 (#145) — mark this publisher "express": its samples bypass
1428    /// transport tx batching (sent immediately even when the batching knob is
1429    /// on). For control-tier / latency-sensitive topics.
1430    pub fn tx_express(mut self, express: bool) -> Self {
1431        self.qos.tx_express = express;
1432        self
1433    }
1434
1435    /// Typed publisher for a ROS message `M` (mirrors rclcpp/rclrs).
1436    pub fn typed<M: MessageForRmw>(self) -> TypedPublisherBuilder<'n, 'a, 't, M> {
1437        TypedPublisherBuilder {
1438            node: self.node,
1439            topic: self.topic,
1440            qos: self.qos,
1441            _phantom: PhantomData,
1442        }
1443    }
1444
1445    /// Generic (type-erased) publisher — the rclcpp `create_generic_publisher`
1446    /// form; raw CDR bytes via `publish_raw`.
1447    pub fn generic(
1448        self,
1449        type_name: &'t str,
1450        type_hash: &'t str,
1451    ) -> GenericPublisherBuilder<'n, 'a, 't> {
1452        GenericPublisherBuilder {
1453            node: self.node,
1454            topic: self.topic,
1455            type_name,
1456            type_hash,
1457            qos: self.qos,
1458        }
1459    }
1460}
1461
1462/// Typed publisher builder (`.typed::<M>()`).
1463pub struct TypedPublisherBuilder<'n, 'a, 't, M> {
1464    node: &'n mut NodeHandle<'a>,
1465    topic: &'t str,
1466    qos: QoSProfile,
1467    _phantom: PhantomData<M>,
1468}
1469
1470impl<'n, 'a, 't, M: MessageForRmw> TypedPublisherBuilder<'n, 'a, 't, M> {
1471    pub fn qos(mut self, qos: QoSProfile) -> Self {
1472        self.qos = qos;
1473        self
1474    }
1475
1476    /// Phase 282 (#145) — see [`PublisherBuilder::tx_express`].
1477    pub fn tx_express(mut self, express: bool) -> Self {
1478        self.qos.tx_express = express;
1479        self
1480    }
1481
1482    pub fn build(self) -> Result<EmbeddedPublisher<M>, NodeError> {
1483        self.node
1484            .create_publisher_with_qos::<M>(self.topic, self.qos)
1485    }
1486}
1487
1488/// Generic (type-erased) publisher builder (`.generic(type, hash)`).
1489pub struct GenericPublisherBuilder<'n, 'a, 't> {
1490    node: &'n mut NodeHandle<'a>,
1491    topic: &'t str,
1492    type_name: &'t str,
1493    type_hash: &'t str,
1494    qos: QoSProfile,
1495}
1496
1497impl<'n, 'a, 't> GenericPublisherBuilder<'n, 'a, 't> {
1498    pub fn qos(mut self, qos: QoSProfile) -> Self {
1499        self.qos = qos;
1500        self
1501    }
1502
1503    /// Phase 282 (#145) — see [`PublisherBuilder::tx_express`].
1504    pub fn tx_express(mut self, express: bool) -> Self {
1505        self.qos.tx_express = express;
1506        self
1507    }
1508
1509    pub fn build(self) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1510        self.node.create_publisher_raw_with_qos(
1511            self.topic,
1512            self.type_name,
1513            self.type_hash,
1514            self.qos,
1515        )
1516    }
1517}
1518
1519// ============================================================================
1520// Phase 273 (RFC-0047) — CallbackGroup token (rclcpp/rclrs shape)
1521// ============================================================================
1522
1523/// A first-class callback group — a **name-only token** (rclcpp/rclrs shape).
1524///
1525/// Created via [`Node::create_callback_group`].  Passed to the `_in`
1526/// entity-create variants (`create_timer_in_group`, `create_subscription_in_group`,
1527/// `create_publisher_in_group`) to label entities with a group name.
1528///
1529/// The group is just a name — the actual `SchedContext` binding is seeded at
1530/// boot by `Executor::bind_group_sched` (from `system.toml group_tiers`, phase
1531/// 273 W2) and resolved at entity-registration time by
1532/// `apply_node_default_sched` (phase 273 W1).  No concurrency type (Mutually-
1533/// Exclusive vs Reentrant) is stored here — that is RFC-0047 OQ1 follow-up.
1534pub struct CallbackGroup {
1535    name: heapless::String<32>,
1536}
1537
1538impl CallbackGroup {
1539    /// The group name (e.g. `"ctrl"`, `"telem"`).
1540    pub fn name(&self) -> &str {
1541        &self.name
1542    }
1543}
1544
1545/// A ROS 2 node — rclrs's `Node`, and the ONE node type (phase-483 W2).
1546///
1547/// `Executor::create_node(name)` returns it, and so does
1548/// `Executor::node_mut(id)`; a component and a standalone program hold the
1549/// same type. It carries rclrs's methods — `create_publisher`,
1550/// `create_subscription(topic, callback)`, `create_service`, timers, the
1551/// clock, `name` / `namespace` / `logger`, the graph queries — plus RTOS
1552/// extensions with names of their own (`create_polling_*`, the `_sized` and
1553/// `_raw` constructors).
1554///
1555/// ADOPT-BOUNDED: a node is a short-lived `&mut Executor` borrow, not rclrs's `Arc<Node>`.
1556/// Create entities, then drop it before acquiring the next node handle; entity
1557/// handles (`HandleId`, publishers) are owned and outlive it (no allocator —
1558/// see `docs/design/0022-entity-api-tiers.md` §Borrow model).
1559pub struct Node<'e, 's> {
1560    executor: &'e mut super::spin::Executor<'s>,
1561    node_id: super::node_record::NodeId,
1562}
1563
1564impl<'e, 's> Node<'e, 's> {
1565    pub(crate) fn new(
1566        executor: &'e mut super::spin::Executor<'s>,
1567        node_id: super::node_record::NodeId,
1568    ) -> Self {
1569        Self { executor, node_id }
1570    }
1571
1572    /// The node's name — rclrs's `Node::name()`.
1573    ///
1574    /// phase-483 W2. Read from the executor's node table, where the name the
1575    /// node was created with (or the launch file's override of it) lives.
1576    #[must_use]
1577    pub fn name(&self) -> &str {
1578        self.executor
1579            .node(self.node_id)
1580            .map(|r| r.name.as_str())
1581            .unwrap_or("")
1582    }
1583
1584    /// The node's namespace — rclrs's `Node::namespace()`. `""` is the root.
1585    #[must_use]
1586    pub fn namespace(&self) -> &str {
1587        self.executor
1588            .node(self.node_id)
1589            .map(|r| r.namespace.as_str())
1590            .unwrap_or("")
1591    }
1592
1593    /// `/<namespace>/<name>` — rclrs's `Node::fully_qualified_name()`.
1594    ///
1595    /// Bounded, where rclrs returns a `String`: no allocator here, and the
1596    /// two halves are each at most 64 bytes.
1597    pub fn fully_qualified_name(&self) -> Result<crate::names::ResolvedName, NodeError> {
1598        crate::names::fully_qualified_name(self.name(), self.namespace())
1599            .map_err(|()| NodeError::NameTooLong)
1600    }
1601
1602    /// The node's ROS domain — rclrs's `Node::domain_id()`. Every node of an
1603    /// executor shares its session, so this is the executor's domain.
1604    #[must_use]
1605    pub fn domain_id(&self) -> u32 {
1606        self.executor.domain_id
1607    }
1608
1609    /// The logger named for this node — rclrs's `Node::logger()`. See
1610    /// [`NodeHandle::logger`] for how the name is resolved.
1611    #[must_use]
1612    pub fn logger(&self) -> &'static nros_log::Logger {
1613        nros_log::resolve_node_logger(self.name())
1614    }
1615
1616    /// The executor this node borrows — for the `nros` facade's declarative
1617    /// constructors, which register into it. Not a user API.
1618    #[doc(hidden)]
1619    pub fn __executor(&mut self) -> &mut super::spin::Executor<'s> {
1620        self.executor
1621    }
1622
1623    /// This node's id in the executor's node table — an RTOS extension, for
1624    /// the APIs that address a node by id (`Executor::node_mut`, the
1625    /// per-node QoS override table).
1626    #[must_use]
1627    pub fn id(&self) -> super::node_record::NodeId {
1628        self.node_id
1629    }
1630
1631    /// The node's clock — rclrs's `Node::get_clock()`, and rclcpp's.
1632    ///
1633    /// phase-417 G6. `cpp:Node::get_clock` has shipped since issue 0789 and
1634    /// only the Rust node lacked the accessor: a component reached the clock
1635    /// through the executor, or constructed one, and the ported line
1636    /// `node.get_clock().now()` had nothing to bind.
1637    ///
1638    /// **A delegation, not a second clock.** [`nros_core::clock::Clock`] is a
1639    /// one-field value naming a time SOURCE — it holds no state of its own, and
1640    /// `now()` on a ROS-time clock reads the process-global override that
1641    /// `install_ros_time_source` installs. So
1642    /// this returns the same answer as the executor's clock by construction
1643    /// rather than by agreement, which is the property the graph forwarders in
1644    /// this family are shaped for.
1645    ///
1646    /// ROS TIME, as rclcpp's and rclrs's node clocks are: with a `/clock`
1647    /// source installed it follows the simulator, and with none it reads the
1648    /// same wall clock `Clock::system()` does (issue 1334 put those two on one
1649    /// expression). A component that specifically wants the monotonic source
1650    /// asks for `Clock::steady()`.
1651    ///
1652    /// By VALUE, where rclcpp hands back a `Clock::SharedPtr`: there is no
1653    /// allocator here (RFC-0022) and the type is `Copy`, so a handle would be
1654    /// a pointer to one `u8`.
1655    pub fn get_clock(&self) -> nros_core::clock::Clock {
1656        nros_core::clock::Clock::ros_time()
1657    }
1658
1659    /// Subscription builder (the `clone` tier). Pick a mode with `.typed::<M>()`
1660    /// or `.generic(type, hash)`, set knobs (`.qos`), then `.build(callback)`.
1661    pub fn subscription<'t>(&mut self, topic: &'t str) -> SubscriptionBuilder<'_, 'e, 't, 's> {
1662        SubscriptionBuilder {
1663            ctx: self,
1664            topic,
1665            qos: QoSProfile::default(),
1666        }
1667    }
1668
1669    /// Publisher builder (the `clone` tier), symmetric with
1670    /// [`subscription`](Self::subscription). Pick `.typed::<M>()` or
1671    /// `.generic(type, hash)`, set `.qos()`, then `.build()`. The returned
1672    /// publisher handle is owned and outlives this `Node` — the bridge
1673    /// builds the dest publisher on one ctx, drops it, then registers the
1674    /// source subscription on another (see `0022-entity-api-tiers.md`).
1675    pub fn publisher<'t>(&mut self, topic: &'t str) -> CtxPublisherBuilder<'_, 'e, 't, 's> {
1676        CtxPublisherBuilder {
1677            ctx: self,
1678            topic,
1679            qos: QoSProfile::default(),
1680        }
1681    }
1682
1683    /// Convenient typed publisher (the `fork` tier — rclcpp/rclrs shape).
1684    pub fn create_publisher<M: MessageForRmw>(
1685        &mut self,
1686        topic: &str,
1687    ) -> Result<EmbeddedPublisher<M>, NodeError> {
1688        self.create_publisher_with_qos::<M>(topic, QoSProfile::default())
1689    }
1690
1691    /// Convenient generic (type-erased) publisher — rclcpp `create_generic_*`.
1692    pub fn create_generic_publisher(
1693        &mut self,
1694        topic: &str,
1695        type_name: &str,
1696        type_hash: &str,
1697    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1698        self.create_generic_publisher_with_qos(topic, type_name, type_hash, QoSProfile::default())
1699    }
1700
1701    /// Issue 0306 — the same, with an explicit profile. The declarative
1702    /// component path needs it: a node that declares
1703    /// `create_publisher_for_topic_with_qos(...)` carries its profile in
1704    /// `EntityMetadata::qos`, and the runtime used to drop it on the floor by
1705    /// calling the default-QoS constructor here.
1706    pub fn create_generic_publisher_with_qos(
1707        &mut self,
1708        topic: &str,
1709        type_name: &str,
1710        type_hash: &str,
1711        qos: QoSProfile,
1712    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1713        self.executor
1714            .create_publisher_raw_on(self.node_id, topic, type_name, type_hash, qos)
1715    }
1716
1717    /// Convenient typed subscription (the `fork` tier — rclcpp/rclrs shape).
1718    /// Sugar over the builder with default QoS + buffer.
1719    pub fn create_subscription<M, F>(
1720        &mut self,
1721        topic: &str,
1722        callback: F,
1723    ) -> Result<super::types::HandleId, NodeError>
1724    where
1725        M: MessageForRmw + 'static,
1726        F: FnMut(&M) + 'static,
1727    {
1728        self.executor
1729            .register_subscription_buffered_on::<M, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1730                self.node_id,
1731                topic,
1732                QoSProfile::default(),
1733                callback,
1734                None, // no group — node default
1735                None, // phase-403 W2: the configured default, unchanged
1736            )
1737    }
1738
1739    /// Subscribe `/clock` and install every sample as this image's ROS time —
1740    /// phase-425 W3, the thing `rclcpp`'s `use_sim_time` parameter switches on.
1741    ///
1742    /// After this, `Clock::ros_time().now()` is the simulator's or the bag
1743    /// player's time, and a timer registered with `TimerClockSource::Ros`
1744    /// follows it: it stops while the simulation is paused and tracks the replay
1745    /// rate. Nothing else changes — a wall timer on the same executor keeps its
1746    /// own cadence, which is what a watchdog needs.
1747    ///
1748    /// QoS is `QoSProfile::clock_default()`, i.e. `rclcpp::ClockQoS`: best
1749    /// effort, keep-last 1, volatile. A late subscriber wants the NEXT sample,
1750    /// not a replay of the simulation's history.
1751    ///
1752    /// Returns the subscription handle. Cancelling it stops the source but does
1753    /// NOT clear the override: a node that unsubscribes mid-run keeps the last
1754    /// simulated time rather than jumping back to the wall clock, which every
1755    /// ROS-time timer would otherwise have to absorb as a jump.
1756    ///
1757    /// The override is process-global — one simulated clock per image — so
1758    /// calling this on a second node in the same image adds a second subscriber
1759    /// to the same global, which is redundant rather than wrong.
1760    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1761    pub fn install_ros_time_source(&mut self) -> Result<super::types::HandleId, NodeError> {
1762        self.install_ros_time_source_on(crate::time_source::CLOCK_TOPIC)
1763    }
1764
1765    /// [`install_ros_time_source`](Self::install_ros_time_source) against a
1766    /// topic other than `/clock` — a remapped or namespaced clock, which is what
1767    /// a launch file creates when two simulations share a graph.
1768    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1769    pub fn install_ros_time_source_on(
1770        &mut self,
1771        topic: &str,
1772    ) -> Result<super::types::HandleId, NodeError> {
1773        // One spelling of the QoS and the sample conversion, on the executor:
1774        // the `use_sim_time` reconciliation (phase-425 W3b) installs the same
1775        // subscription, and two copies would be two places for the QoS to drift.
1776        self.executor.install_ros_time_source(self.node_id, topic)
1777    }
1778
1779    // -----------------------------------------------------------------------
1780    // Phase 273 (RFC-0047) — callback-group API (rclcpp/rclrs shape)
1781    // -----------------------------------------------------------------------
1782
1783    /// Create a named callback group — a thin token wrapping the group name.
1784    ///
1785    /// Pass the returned [`CallbackGroup`] to the `_in` create variants
1786    /// (`create_timer_in_group`, `create_subscription_in_group`, `create_publisher_in_group`)
1787    /// to label entities with this group. The executor binds the entity's
1788    /// callback to the `SchedContext` seeded via `bind_group_sched` for
1789    /// `(node_name, namespace, group_name)` (phase 273 W1/W2).
1790    ///
1791    /// ```ignore
1792    /// let ctrl = node.create_callback_group("ctrl");
1793    /// node.create_timer_in_group(&ctrl, TimerDuration::from_millis(10), || { /* … */ })?;
1794    /// ```
1795    ///
1796    /// Names longer than 32 bytes are silently truncated.
1797    pub fn create_callback_group(&self, name: &str) -> CallbackGroup {
1798        let mut s = heapless::String::<32>::new();
1799        // Silently truncate if the name is too long (defensive; caller
1800        // should use short ASCII group names like "ctrl"/"telem").
1801        for ch in name.chars() {
1802            if s.push(ch).is_err() {
1803                break;
1804            }
1805        }
1806        CallbackGroup { name: s }
1807    }
1808
1809    /// Create a repeating timer **in** a callback group (phase 273, rclcpp shape).
1810    ///
1811    /// The timer's callback is bound to the `SchedContext` associated with
1812    /// `group` in the executor's `group_sched_table` for this node. If no
1813    /// entry was seeded for the group, the node's default `SchedContext` applies
1814    /// (same as `register_timer`). `period` fires the callback repeatedly.
1815    pub fn create_timer_in_group<F>(
1816        &mut self,
1817        group: &CallbackGroup,
1818        period: crate::timer::TimerDuration,
1819        callback: F,
1820    ) -> Result<super::types::HandleId, NodeError>
1821    where
1822        F: FnMut() + 'static,
1823    {
1824        self.executor
1825            .register_timer_on(Some(self.node_id), period, callback, Some(group.name()))
1826    }
1827
1828    /// Create a guard condition on this node — phase-417 W4.e, the Rust half of
1829    /// the one creation shape.
1830    ///
1831    /// A guard condition is the cross-thread / ISR wake source: any thread may
1832    /// call [`GuardCondition::trigger`](super::types::GuardCondition::trigger)
1833    /// and the `callback` runs on the executor's next `spin_once`. The flag
1834    /// itself lives in the executor's arena, which is what makes the returned
1835    /// handle `Send + Sync` with no allocation — so the NODE is the owner a
1836    /// caller names and the EXECUTOR is where the entity lands, exactly as for
1837    /// a timer or a subscription.
1838    ///
1839    /// Until 2026-09-18 the only Rust spelling was
1840    /// [`Executor::register_guard_condition`](crate::Executor::register_guard_condition),
1841    /// while C++ created one on the node and C needed three calls. That was the
1842    /// three-owner state RFC-0089 stage 4 exists to remove; the executor form
1843    /// survives as the ours-only primitive this calls.
1844    ///
1845    /// The returned [`HandleId`](super::types::HandleId) is the slot, for
1846    /// `trigger_one` / `bind_handle_to_sched_context`; the
1847    /// [`GuardCondition`](super::types::GuardCondition) is the trigger handle to
1848    /// hand to the other thread.
1849    ///
1850    /// ```ignore
1851    /// let (_id, guard) = node.create_guard_condition(|| { /* woken */ })?;
1852    /// // from an ISR or another task:
1853    /// guard.trigger();
1854    /// ```
1855    pub fn create_guard_condition<F>(
1856        &mut self,
1857        callback: F,
1858    ) -> Result<(super::types::HandleId, super::types::GuardCondition), NodeError>
1859    where
1860        F: FnMut() + 'static,
1861    {
1862        self.executor
1863            .register_guard_condition_on(Some(self.node_id), callback)
1864    }
1865
1866    /// Create a repeating timer on a chosen CLOCK — phase-430 W4, the
1867    /// node-level spelling of
1868    /// [`Executor::register_timer_on_clock`](crate::Executor::register_timer_on_clock)
1869    /// and the Rust counterpart of C++'s `create_timer(clock, period, cb)`.
1870    ///
1871    /// [`create_timer_in_group`](Self::create_timer_in_group) and the executor's
1872    /// `register_timer` are the WALL case: they consume the spin delta the
1873    /// executor already measured, and no simulator can slow them down. A
1874    /// [`TimerClockSource::Ros`](crate::executor::TimerClockSource::Ros) timer instead follows `/clock`, so it stops
1875    /// while the simulator is paused and tracks a bag's replay rate; with no
1876    /// `/clock` source installed it reads system time, the same fallback
1877    /// `rclcpp::Clock` has.
1878    ///
1879    /// The timer is bound to this node, so the node's default `SchedContext`
1880    /// applies — [`create_timer_on_clock_in_group`](Self::create_timer_on_clock_in_group)
1881    /// is the callback-group form.
1882    ///
1883    /// ```ignore
1884    /// node.create_timer_on_clock(
1885    ///     TimerDuration::from_millis(100),
1886    ///     TimerClockSource::Ros,
1887    ///     || { /* fires once per 100 ms of SIMULATED time */ },
1888    /// )?;
1889    /// ```
1890    pub fn create_timer_on_clock<F>(
1891        &mut self,
1892        period: crate::timer::TimerDuration,
1893        source: crate::executor::TimerClockSource,
1894        callback: F,
1895    ) -> Result<super::types::HandleId, NodeError>
1896    where
1897        F: FnMut() + 'static,
1898    {
1899        self.executor.register_timer_entry(
1900            Some(self.node_id),
1901            period,
1902            source,
1903            false,
1904            None,
1905            callback,
1906        )
1907    }
1908
1909    /// [`create_timer_on_clock`](Self::create_timer_on_clock) **in** a callback
1910    /// group — the clock axis on [`create_timer_in_group`](Self::create_timer_in_group)
1911    /// (phase-430 W4). The two axes are independent: the group decides which
1912    /// `SchedContext` the callback runs under, the clock decides what advances
1913    /// the timer towards its next activation.
1914    pub fn create_timer_on_clock_in_group<F>(
1915        &mut self,
1916        group: &CallbackGroup,
1917        period: crate::timer::TimerDuration,
1918        source: crate::executor::TimerClockSource,
1919        callback: F,
1920    ) -> Result<super::types::HandleId, NodeError>
1921    where
1922        F: FnMut() + 'static,
1923    {
1924        self.executor.register_timer_entry(
1925            Some(self.node_id),
1926            period,
1927            source,
1928            false,
1929            Some(group.name()),
1930            callback,
1931        )
1932    }
1933
1934    /// Create a typed subscription **in** a callback group (phase 273, rclcpp shape).
1935    ///
1936    /// The subscription's callback is bound to the `SchedContext` associated
1937    /// with `group` in the `group_sched_table` for this node. If no entry was
1938    /// seeded, the node default applies.
1939    pub fn create_subscription_in_group<M, F>(
1940        &mut self,
1941        group: &CallbackGroup,
1942        topic: &str,
1943        callback: F,
1944    ) -> Result<super::types::HandleId, NodeError>
1945    where
1946        M: MessageForRmw + 'static,
1947        F: FnMut(&M) + 'static,
1948    {
1949        self.executor
1950            .register_subscription_buffered_on::<M, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1951                self.node_id,
1952                topic,
1953                QoSProfile::default(),
1954                callback,
1955                Some(group.name()),
1956                None, // phase-403 W2: the configured default, unchanged
1957            )
1958    }
1959
1960    /// Create a typed publisher **in** a callback group (phase 273, rclcpp shape).
1961    ///
1962    /// Publishers do not have an executor-dispatched callback, so the group
1963    /// name has no scheduling effect today (publishers are explicitly driven by
1964    /// the user via `publish()`). The API is provided for symmetry and
1965    /// forward-compatibility (intra-process / loaned-message knobs may use
1966    /// it in the future).
1967    pub fn create_publisher_in_group<M: MessageForRmw>(
1968        &mut self,
1969        _group: &CallbackGroup,
1970        topic: &str,
1971    ) -> Result<EmbeddedPublisher<M>, NodeError> {
1972        // Publishers carry no executor callback slot; group is forward-compat.
1973        self.create_publisher::<M>(topic)
1974    }
1975
1976    /// RFC-0041 / Phase 239.1 — callback-based service client (rclcpp
1977    /// `async_send_request(req, cb)` analogue). The reply is delivered to
1978    /// `callback` at `spin_once` (no `Promise` poll). Returns a
1979    /// [`ServiceClientCallback`] send handle; dual-mode — the `Promise`-based
1980    /// `create_client` is unchanged.
1981    pub fn create_client_with_callback<Svc, F>(
1982        &mut self,
1983        service_name: &str,
1984        callback: F,
1985    ) -> Result<ServiceClientCallback<Svc>, NodeError>
1986    where
1987        Svc: RosService + 'static,
1988        Svc::Request: MessageForRmw,
1989        Svc::Reply: MessageForRmw,
1990        F: FnMut(&Svc::Reply) + 'static,
1991    {
1992        self.create_client_with_callback_sized::<
1993            Svc,
1994            F,
1995            { crate::config::DEFAULT_RX_BUF_SIZE },
1996            { crate::config::DEFAULT_RX_BUF_SIZE },
1997        >(service_name, callback)
1998    }
1999
2000    /// Callback-based service client with custom buffer sizes (Phase 239.1).
2001    pub fn create_client_with_callback_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
2002        &mut self,
2003        service_name: &str,
2004        callback: F,
2005    ) -> Result<ServiceClientCallback<Svc, REQ_BUF, REPLY_BUF>, NodeError>
2006    where
2007        Svc: RosService + 'static,
2008        Svc::Request: MessageForRmw,
2009        Svc::Reply: MessageForRmw,
2010        F: FnMut(&Svc::Reply) + 'static,
2011    {
2012        register_type::<Svc::Request>()?;
2013        register_type::<Svc::Reply>()?;
2014        let (_id, hdr) = self
2015            .executor
2016            .register_service_client_callback::<Svc, F, REPLY_BUF>(
2017                Some(self.node_id),
2018                service_name,
2019                Svc::SERVICE_NAME,
2020                Svc::SERVICE_HASH,
2021                QoSProfile::services_default(),
2022                callback,
2023            )?;
2024        Ok(ServiceClientCallback::new(hdr))
2025    }
2026
2027    /// RFC-0041 / Phase 239.2 — callback-based action client (rclcpp
2028    /// `SendGoalOptions{goal_response_callback, feedback_callback,
2029    /// result_callback}` analogue). Goal-response / feedback / result are
2030    /// delivered to the closures at `spin_once`. Returns an
2031    /// [`ActionClientCallback`] send handle (`send_goal` / `get_result`);
2032    /// dual-mode — the `Promise`-based `create_action_client`
2033    /// is unchanged.
2034    #[allow(clippy::type_complexity)]
2035    pub fn create_action_client_with_callbacks<A, GRespF, FbF, ResF>(
2036        &mut self,
2037        action_name: &str,
2038        on_goal_response: GRespF,
2039        on_feedback: FbF,
2040        on_result: ResF,
2041    ) -> Result<ActionClientCallback<A>, NodeError>
2042    where
2043        A: RosAction + 'static,
2044        A::Goal: MessageForRmw,
2045        A::Result: MessageForRmw,
2046        A::Feedback: MessageForRmw,
2047        GRespF: FnMut(&nros_core::GoalId, bool) + 'static,
2048        FbF: FnMut(&nros_core::GoalId, &A::Feedback) + 'static,
2049        ResF: FnMut(&nros_core::GoalId, nros_core::GoalStatus, &A::Result) + 'static,
2050    {
2051        self.create_action_client_with_callbacks_sized::<
2052            A,
2053            GRespF,
2054            FbF,
2055            ResF,
2056            { crate::config::DEFAULT_RX_BUF_SIZE },
2057            { crate::config::DEFAULT_RX_BUF_SIZE },
2058            { crate::config::DEFAULT_RX_BUF_SIZE },
2059        >(action_name, on_goal_response, on_feedback, on_result)
2060    }
2061
2062    /// Callback-based action client with custom buffer sizes (Phase 239.2).
2063    #[allow(clippy::type_complexity)]
2064    pub fn create_action_client_with_callbacks_sized<
2065        A,
2066        GRespF,
2067        FbF,
2068        ResF,
2069        const GOAL_BUF: usize,
2070        const RESULT_BUF: usize,
2071        const FEEDBACK_BUF: usize,
2072    >(
2073        &mut self,
2074        action_name: &str,
2075        on_goal_response: GRespF,
2076        on_feedback: FbF,
2077        on_result: ResF,
2078    ) -> Result<ActionClientCallback<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
2079    where
2080        A: RosAction + 'static,
2081        A::Goal: MessageForRmw,
2082        A::Result: MessageForRmw,
2083        A::Feedback: MessageForRmw,
2084        GRespF: FnMut(&nros_core::GoalId, bool) + 'static,
2085        FbF: FnMut(&nros_core::GoalId, &A::Feedback) + 'static,
2086        ResF: FnMut(&nros_core::GoalId, nros_core::GoalStatus, &A::Result) + 'static,
2087    {
2088        register_type::<A::Goal>()?;
2089        register_type::<A::Result>()?;
2090        register_type::<A::Feedback>()?;
2091        let (_id, core) = self
2092            .executor
2093            .register_action_client_callback::<A, GRespF, FbF, ResF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>(
2094                Some(self.node_id),
2095                action_name,
2096                A::ACTION_NAME,
2097                A::ACTION_HASH,
2098                // Feedback is a stream → buffer a short QoS-depth history (Phase
2099                // 239.5). Goal-response / result are single-outstanding (gated).
2100                super::arena::DEFAULT_ACTION_FEEDBACK_DEPTH,
2101                on_goal_response,
2102                on_feedback,
2103                on_result,
2104            )?;
2105        Ok(ActionClientCallback::new(core))
2106    }
2107
2108    /// Convenient generic (type-erased) subscription — rclcpp `create_generic_*`.
2109    pub fn create_generic_subscription<F>(
2110        &mut self,
2111        topic: &str,
2112        type_name: &str,
2113        type_hash: &str,
2114        callback: F,
2115    ) -> Result<super::types::HandleId, NodeError>
2116    where
2117        F: FnMut(&[u8]) + 'static,
2118    {
2119        self.create_generic_subscription_with_qos(
2120            topic,
2121            type_name,
2122            type_hash,
2123            QoSProfile::default(),
2124            callback,
2125        )
2126    }
2127
2128    /// Issue 0306 — the same, with an explicit profile (see
2129    /// [`Self::create_generic_publisher_with_qos`] for why).
2130    pub fn create_generic_subscription_with_qos<F>(
2131        &mut self,
2132        topic: &str,
2133        type_name: &str,
2134        type_hash: &str,
2135        qos: QoSProfile,
2136        callback: F,
2137    ) -> Result<super::types::HandleId, NodeError>
2138    where
2139        F: FnMut(&[u8]) + 'static,
2140    {
2141        self.executor
2142            .register_subscription_buffered_raw_on::<F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
2143                self.node_id,
2144                topic,
2145                type_name,
2146                type_hash,
2147                qos,
2148                callback,
2149            )
2150    }
2151
2152    /// Phase 250 (Wave 2) — generic (type-erased) subscription that surfaces E2E
2153    /// [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC + sequence gap/dup) to
2154    /// the callback (`FnMut(&[u8], &IntegrityStatus)`). The declarative-`Node`
2155    /// analog of the typed `.typed::<M>().safety()` builder: the validator lives
2156    /// in the `RmwSubscriber`, so the raw bytes + status arrive together without
2157    /// a typed `M`. Wired by the declarative runtime's `.safety()` opt-in.
2158    #[cfg(feature = "safety-e2e")]
2159    pub fn create_generic_subscription_with_integrity<F>(
2160        &mut self,
2161        topic: &str,
2162        type_name: &str,
2163        type_hash: &str,
2164        callback: F,
2165    ) -> Result<super::types::HandleId, NodeError>
2166    where
2167        F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
2168    {
2169        self.executor
2170            .register_subscription_buffered_raw_safety_on::<F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
2171                self.node_id,
2172                topic,
2173                type_name,
2174                type_hash,
2175                QoSProfile::default(),
2176                callback,
2177            )
2178    }
2179
2180    /// Convenient zero-copy subscription (Phase 229.6, issue 0007 / RFC-0033
2181    /// `view` mode).
2182    ///
2183    /// `B` is the code-generated VIEWABLE marker (e.g. `ImageViewable`, emitted
2184    /// alongside the `inline` `Image` for a `.msg` with a `view`-mode field).
2185    /// The marker is zero-sized and carries NO lifetime, which is the whole
2186    /// reason it exists: `B::View<'a>` does, and a generic parameter cannot be
2187    /// the lifetime-carrying type itself. That is also why phase-390 could not
2188    /// simply rename it to `{Msg}View` — the view struct already owns that
2189    /// name, and the marker points AT it.
2190    ///
2191    /// The callback receives `&B::View<'a>` — a message whose unbounded
2192    /// sequence/string fields point directly into the receive buffer (no
2193    /// `heapless::Vec` copy); valid only for the callback's duration.
2194    ///
2195    /// Uses `KEEP_LAST(1)` QoS → triple buffer, as view subscriptions require
2196    /// (a single well-defined slot for the callback). For an explicit deeper
2197    /// queue use the `inline` [`create_subscription`](Self::create_subscription);
2198    /// a view subscription registered with `KEEP_LAST(N>1)` is rejected.
2199    pub fn create_subscription_viewable<B, F>(
2200        &mut self,
2201        topic: &str,
2202        callback: F,
2203    ) -> Result<super::types::HandleId, NodeError>
2204    where
2205        B: nros_core::ViewableMessage + 'static,
2206        F: for<'a> FnMut(&B::View<'a>) + 'static,
2207    {
2208        self.executor
2209            .register_subscription_buffered_borrowed_on::<B, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
2210                self.node_id,
2211                topic,
2212                QoSProfile::default().keep_last(1),
2213                callback,
2214            )
2215    }
2216
2217    /// Service-server builder (the `clone` tier) — `node.service(name)`.
2218    /// Set `.qos()` (defaults to the services profile = RELIABLE+VOLATILE+
2219    /// KEEP_LAST(10)), then `.build::<Svc, _>(callback)` (Phase 193.2).
2220    pub fn service<'t>(&mut self, name: &'t str) -> CtxServiceBuilder<'_, 'e, 't, 's> {
2221        CtxServiceBuilder {
2222            ctx: self,
2223            name,
2224            qos: QoSProfile::services_default(),
2225        }
2226    }
2227
2228    /// Convenient service server (the `fork` tier — rclrs/rclcpp shape), default
2229    /// services QoS. Mirror of `create_subscription`.
2230    pub fn create_service<Svc, F>(
2231        &mut self,
2232        name: &str,
2233        callback: F,
2234    ) -> Result<super::types::HandleId, NodeError>
2235    where
2236        Svc: RosService + 'static,
2237        Svc::Request: crate::rmw_type_registry::MessageForRmw,
2238        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
2239        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
2240    {
2241        self.executor.register_service_sized_on::<
2242            Svc,
2243            F,
2244            { crate::config::DEFAULT_RX_BUF_SIZE },
2245            { crate::config::DEFAULT_RX_BUF_SIZE },
2246        >(self.node_id, name, QoSProfile::services_default(), callback)
2247    }
2248
2249    // ====================================================================
2250    // Graph introspection — RFC-0036, phase-444.
2251    //
2252    // Upstream hangs the graph family off the NODE (`rclrs::Node::
2253    // get_node_names`, `rcl_get_node_names(node, ...)`). We open ONE transport
2254    // session per image and the Executor owns it, so the capability lives on
2255    // [`Executor`](super::spin::Executor) — see phase-381 W4. These are the
2256    // forwarders that let a ported node keep upstream's receiver and upstream's
2257    // verb, the same forwarders phase-417 stage 2b wrote for C++ `nros::Node`.
2258    //
2259    // They add no behaviour: each is a one-line delegation to the executor
2260    // this handle already borrows.
2261    //
2262    // THE CONTRACT EVERY ONE OF THEM KEEPS (RFC-0036 §"Static CONNECTION,
2263    // dynamic GRAPH"), stated once here and referenced from each:
2264    //
2265    //  * It reports what has been DISCOVERED, and NEVER BLOCKS. The backend
2266    //    keeps a standing view fed by `spin`, so a first call legitimately
2267    //    returns a partial graph and the view fills in over successive calls.
2268    //    Code waiting for a peer must poll, not call once and conclude.
2269    //  * An empty answer is "nobody seen yet", NEVER "nobody exists".
2270    //  * `Err(Transport(Unsupported))` — a backend that cannot tell you, such
2271    //    as XRCE — is a DIFFERENT answer from empty, and must not be collapsed
2272    //    into zero or into an empty enumeration.
2273    //
2274    // SHAPE DIVERGENCE from rclrs, and why: rclrs returns owned heap
2275    // containers (`Vec<NodeNameInfo>`, `TopicNamesAndTypes`). The graph has no
2276    // bound the caller can know, so a caller-sized buffer is an unanswerable
2277    // question and an owned container needs an allocator a 128 KiB target does
2278    // not have. We stream through a visitor instead, which costs one entry of
2279    // peak RAM. Every `&str` handed to a visitor is BORROWED for the duration
2280    // of that call — copy anything you keep. Returning `false` stops the walk.
2281    // ====================================================================
2282
2283    /// Every node on the graph, with its namespace — rclrs's
2284    /// `Node::get_node_names`.
2285    ///
2286    /// `visit(name, namespace)` once per node. For the enclave as well, use
2287    /// [`get_node_names_with_enclaves`](Self::get_node_names_with_enclaves):
2288    /// one backend slot answers both, exactly as `rmw_get_node_names` and
2289    /// `rmw_get_node_names_with_enclaves` are one query upstream.
2290    ///
2291    /// Reports what has been DISCOVERED and never blocks, so an empty
2292    /// enumeration means "nobody seen yet", not "nobody exists";
2293    /// `Err(Transport(Unsupported))` from a backend with no graph is a
2294    /// different answer from empty. See the module note above.
2295    pub fn get_node_names(
2296        &mut self,
2297        visit: &mut dyn FnMut(&str, &str) -> bool,
2298    ) -> Result<(), NodeError> {
2299        self.executor
2300            .get_node_names(&mut |name, ns, _enclave| visit(name, ns))
2301    }
2302
2303    /// Every node on the graph with its namespace AND its enclave — rclrs's
2304    /// `Node::get_node_names_with_enclaves`.
2305    ///
2306    /// `visit(name, namespace, enclave)`. `enclave` is `None` when the backend
2307    /// reports the node but not its enclave, which is a partial answer rather
2308    /// than an error — reporting the node without the enclave beats dropping
2309    /// it. Same discovery envelope as [`get_node_names`](Self::get_node_names).
2310    pub fn get_node_names_with_enclaves(
2311        &mut self,
2312        visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
2313    ) -> Result<(), NodeError> {
2314        self.executor.get_node_names(visit)
2315    }
2316
2317    /// Every topic on the graph, with the types on it — rclrs's
2318    /// `Node::get_topic_names_and_types`.
2319    ///
2320    /// `visit(topic_name, types)` once per distinct TOPIC: a topic carrying
2321    /// two types is one call with two entries, not two calls. `types` may
2322    /// legitimately be empty on a partially discovered graph. Same discovery
2323    /// envelope as [`get_node_names`](Self::get_node_names).
2324    pub fn get_topic_names_and_types(
2325        &mut self,
2326        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2327    ) -> Result<(), NodeError> {
2328        self.executor.get_topic_names_and_types(visit)
2329    }
2330
2331    /// Every service on the graph, with its types — rclrs's
2332    /// `Node::get_service_names_and_types`. As
2333    /// [`get_topic_names_and_types`](Self::get_topic_names_and_types), over
2334    /// servers and clients.
2335    pub fn get_service_names_and_types(
2336        &mut self,
2337        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2338    ) -> Result<(), NodeError> {
2339        self.executor.get_service_names_and_types(visit)
2340    }
2341
2342    /// How many publishers are visible on `topic_name` — rclrs's
2343    /// `Node::count_publishers`.
2344    ///
2345    /// **This is the GRAPH's count, not this node's.** The number of
2346    /// publishers this node itself DECLARED is
2347    /// [`StandaloneNode::publisher_count`](crate::StandaloneNode), a different
2348    /// question with a different answer.
2349    ///
2350    /// A count reflects what has been DISCOVERED, so it can be low right after
2351    /// startup and is never a proof of absence. See
2352    /// [`get_node_names`](Self::get_node_names).
2353    pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
2354        self.executor.count_publishers(topic_name)
2355    }
2356
2357    /// How many subscriptions are visible on `topic_name` — rclrs's
2358    /// `Node::count_subscriptions`.
2359    ///
2360    /// **`subscriptions`, not `subscribers`** — this is rclrs's spelling, and
2361    /// the Rust surface takes its vocabulary from rclrs so a user porting Rust
2362    /// ROS 2 code types what they already know. The C surface says
2363    /// `subscribers` because rcl does, and the vtable slot says `subscribers`
2364    /// because upstream rmw does. Three layers, three upstreams, one word each
2365    /// — not drift.
2366    ///
2367    /// The graph's count, not this node's; see
2368    /// [`count_publishers`](Self::count_publishers) for that distinction and
2369    /// for the discovery caveats.
2370    pub fn count_subscriptions(&mut self, topic_name: &str) -> Result<usize, NodeError> {
2371        self.executor.count_subscribers(topic_name)
2372    }
2373
2374    /// What one named node PUBLISHES, with the types — rclrs's
2375    /// `Node::get_publisher_names_and_types_by_node`.
2376    ///
2377    /// A node the graph has not discovered yields no visits, which is not an
2378    /// error — see [`get_node_names`](Self::get_node_names) for why an empty
2379    /// answer means "not seen yet".
2380    pub fn get_publisher_names_and_types_by_node(
2381        &mut self,
2382        node_name: &str,
2383        node_namespace: &str,
2384        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2385    ) -> Result<(), NodeError> {
2386        self.executor
2387            .get_publisher_names_and_types_by_node(node_name, node_namespace, visit)
2388    }
2389
2390    /// What one named node SUBSCRIBES to, with the types — rclrs's
2391    /// `Node::get_subscription_names_and_types_by_node`. See
2392    /// [`count_subscriptions`](Self::count_subscriptions) on the spelling.
2393    pub fn get_subscription_names_and_types_by_node(
2394        &mut self,
2395        node_name: &str,
2396        node_namespace: &str,
2397        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2398    ) -> Result<(), NodeError> {
2399        self.executor
2400            .get_subscription_names_and_types_by_node(node_name, node_namespace, visit)
2401    }
2402
2403    /// What services one named node SERVES, with the types — rclrs's
2404    /// `Node::get_service_names_and_types_by_node`.
2405    pub fn get_service_names_and_types_by_node(
2406        &mut self,
2407        node_name: &str,
2408        node_namespace: &str,
2409        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2410    ) -> Result<(), NodeError> {
2411        self.executor
2412            .get_service_names_and_types_by_node(node_name, node_namespace, visit)
2413    }
2414
2415    /// What services one named node CALLS, with the types — rclrs's
2416    /// `Node::get_client_names_and_types_by_node`.
2417    pub fn get_client_names_and_types_by_node(
2418        &mut self,
2419        node_name: &str,
2420        node_namespace: &str,
2421        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2422    ) -> Result<(), NodeError> {
2423        self.executor
2424            .get_client_names_and_types_by_node(node_name, node_namespace, visit)
2425    }
2426
2427    /// The PUBLISHERS on `topic_name`, one visit each — rclrs's
2428    /// `Node::get_publishers_info_by_topic`.
2429    ///
2430    /// The last two members of the graph family to reach upstream's receiver.
2431    /// `visit(info)` per discovered endpoint; every `&str` on the
2432    /// [`GraphEndpointInfo`](nros_rmw::GraphEndpointInfo) is BORROWED for the
2433    /// duration of that call, so copy anything you keep, and returning `false`
2434    /// stops the walk.
2435    ///
2436    /// ADOPT-BOUNDED: rclrs returns an owned `Vec<TopicEndpointInfo>`; here the
2437    /// answer is STREAMED to `visit`, because the graph has no bound a caller could
2438    /// size a buffer to and there is no allocator for a `Vec`. It reports what has
2439    /// been DISCOVERED and never blocks, so an empty walk means "nobody seen yet";
2440    /// a backend with no graph answers `Err(Transport(Unsupported))`, never an
2441    /// empty `Ok`.
2442    ///
2443    /// It carries NO QoS, and that absence is the point: the GRANTED profile is
2444    /// what answers "why is nothing arriving", no backend can read a remote's
2445    /// back yet, and reporting the remote's DECLARED profile instead would be
2446    /// the confident wrong answer the field exists to avoid.
2447    pub fn get_publishers_info_by_topic(
2448        &mut self,
2449        topic_name: &str,
2450        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
2451    ) -> Result<(), NodeError> {
2452        self.executor
2453            .get_publishers_info_by_topic(topic_name, visit)
2454    }
2455
2456    /// The SUBSCRIPTIONS on `topic_name`, one visit each — rclrs's
2457    /// `Node::get_subscriptions_info_by_topic`. See
2458    /// [`get_publishers_info_by_topic`](Self::get_publishers_info_by_topic).
2459    ///
2460    /// Same envelope: STREAMED to `visit` rather than returned as a `Vec`, every
2461    /// `&str` borrowed for the visit, discovered-only and non-blocking, and
2462    /// `Err(Transport(Unsupported))` from a backend with no graph.
2463    pub fn get_subscriptions_info_by_topic(
2464        &mut self,
2465        topic_name: &str,
2466        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
2467    ) -> Result<(), NodeError> {
2468        self.executor
2469            .get_subscriptions_info_by_topic(topic_name, visit)
2470    }
2471
2472    // ------------------------------------------------------------------
2473    // phase-483 W2 — the constructors that only `NodeHandle` had. `Node`
2474    // is the ONE node type, so it carries them too; each forwards through
2475    // `Executor::with_node_try`, which opens a `NodeHandle` on this node's
2476    // session for the duration of the call. The polled subscription and
2477    // service constructors are `create_polling_*`: rclrs's
2478    // `create_subscription` / `create_service` take a callback, and this type
2479    // already has those under exactly those names.
2480    // ------------------------------------------------------------------
2481
2482    /// [`create_publisher`](Self::create_publisher) with an explicit profile (phase-483 W2).
2483    ///
2484    /// Through [`NodeHandle::create_publisher_with_qos`], so a publisher made
2485    /// here gets what one made on the handle `Executor::create_node` used to
2486    /// return: the node's remap rules (launch, then `--ros-args`), the plan's
2487    /// QoS overrides and the declared-QoS check. `Executor::create_publisher_on`
2488    /// takes a name its caller already resolved, which is right for the C and
2489    /// component roads and wrong for a user's source name.
2490    pub fn create_publisher_with_qos<M: MessageForRmw>(
2491        &mut self,
2492        topic_name: &str,
2493        qos: QoSProfile,
2494    ) -> Result<EmbeddedPublisher<M>, NodeError> {
2495        self.executor.with_node_try(self.node_id, |h| {
2496            h.create_publisher_with_qos::<M>(topic_name, qos)
2497        })
2498    }
2499
2500    /// [`NodeHandle::create_publisher_raw`] on this node (phase-483 W2).
2501    pub fn create_publisher_raw(
2502        &mut self,
2503        topic_name: &str,
2504        type_name: &str,
2505        type_hash: &str,
2506    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
2507        self.executor.with_node_try(self.node_id, |h| {
2508            h.create_publisher_raw(topic_name, type_name, type_hash)
2509        })
2510    }
2511
2512    /// [`NodeHandle::create_publisher_raw_with_qos`] on this node (phase-483 W2).
2513    pub fn create_publisher_raw_with_qos(
2514        &mut self,
2515        topic_name: &str,
2516        type_name: &str,
2517        type_hash: &str,
2518        qos: QoSProfile,
2519    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
2520        self.executor.with_node_try(self.node_id, |h| {
2521            h.create_publisher_raw_with_qos(topic_name, type_name, type_hash, qos)
2522        })
2523    }
2524
2525    /// The POLLED form of [`NodeHandle::create_subscription`] (phase-483 W2): the caller
2526    /// takes from it, where rclrs's `create_subscription` takes a callback.
2527    pub fn create_polling_subscription<M: MessageForRmw>(
2528        &mut self,
2529        topic_name: &str,
2530    ) -> Result<Subscription<M>, NodeError> {
2531        self.executor
2532            .with_node_try(self.node_id, |h| h.create_subscription::<M>(topic_name))
2533    }
2534
2535    /// The POLLED form of [`NodeHandle::create_subscription_sized`] (phase-483 W2): the caller
2536    /// takes from it, where rclrs's `create_subscription` takes a callback.
2537    pub fn create_polling_subscription_sized<M: MessageForRmw, const RX_BUF: usize>(
2538        &mut self,
2539        topic_name: &str,
2540    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
2541        self.executor.with_node_try(self.node_id, |h| {
2542            h.create_subscription_sized::<M, RX_BUF>(topic_name)
2543        })
2544    }
2545
2546    /// The POLLED form of [`NodeHandle::create_subscription_with_qos`] (phase-483 W2): the caller
2547    /// takes from it, where rclrs's `create_subscription` takes a callback.
2548    pub fn create_polling_subscription_with_qos<M: MessageForRmw, const RX_BUF: usize>(
2549        &mut self,
2550        topic_name: &str,
2551        qos: QoSProfile,
2552    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
2553        self.executor.with_node_try(self.node_id, |h| {
2554            h.create_subscription_with_qos::<M, RX_BUF>(topic_name, qos)
2555        })
2556    }
2557
2558    /// The POLLED form of [`NodeHandle::create_subscription_raw`] (phase-483 W2): the caller
2559    /// takes from it, where rclrs's `create_subscription` takes a callback.
2560    pub fn create_polling_subscription_raw(
2561        &mut self,
2562        topic_name: &str,
2563        type_name: &str,
2564        type_hash: &str,
2565    ) -> Result<crate::executor::handles::RawSubscription, NodeError> {
2566        self.executor.with_node_try(self.node_id, |h| {
2567            h.create_subscription_raw(topic_name, type_name, type_hash)
2568        })
2569    }
2570
2571    /// The POLLED form of [`NodeHandle::create_subscription_raw_sized`] (phase-483 W2): the caller
2572    /// takes from it, where rclrs's `create_subscription` takes a callback.
2573    pub fn create_polling_subscription_raw_sized<const RX_BUF: usize>(
2574        &mut self,
2575        topic_name: &str,
2576        type_name: &str,
2577        type_hash: &str,
2578    ) -> Result<crate::executor::handles::RawSubscription<RX_BUF>, NodeError> {
2579        self.executor.with_node_try(self.node_id, |h| {
2580            h.create_subscription_raw_sized::<RX_BUF>(topic_name, type_name, type_hash)
2581        })
2582    }
2583
2584    /// The POLLED form of [`NodeHandle::create_service`] (phase-483 W2): the caller
2585    /// takes from it, where rclrs's `create_service` takes a callback.
2586    pub fn create_polling_service<Svc: RosService>(
2587        &mut self,
2588        service_name: &str,
2589    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
2590    where
2591        Svc::Request: MessageForRmw,
2592        Svc::Reply: MessageForRmw,
2593    {
2594        self.executor
2595            .with_node_try(self.node_id, |h| h.create_service::<Svc>(service_name))
2596    }
2597
2598    /// The POLLED form of [`NodeHandle::create_service_with_qos`] (phase-483 W2): the caller
2599    /// takes from it, where rclrs's `create_service` takes a callback.
2600    pub fn create_polling_service_with_qos<Svc: RosService>(
2601        &mut self,
2602        service_name: &str,
2603        qos: QoSProfile,
2604    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
2605    where
2606        Svc::Request: MessageForRmw,
2607        Svc::Reply: MessageForRmw,
2608    {
2609        self.executor.with_node_try(self.node_id, |h| {
2610            h.create_service_with_qos::<Svc>(service_name, qos)
2611        })
2612    }
2613
2614    /// The POLLED form of [`NodeHandle::create_service_sized`] (phase-483 W2): the caller
2615    /// takes from it, where rclrs's `create_service` takes a callback.
2616    pub fn create_polling_service_sized<
2617        Svc: RosService,
2618        const REQ_BUF: usize,
2619        const REPLY_BUF: usize,
2620    >(
2621        &mut self,
2622        service_name: &str,
2623        qos: QoSProfile,
2624    ) -> Result<EmbeddedServiceServer<Svc, REQ_BUF, REPLY_BUF>, NodeError>
2625    where
2626        Svc::Request: MessageForRmw,
2627        Svc::Reply: MessageForRmw,
2628    {
2629        self.executor.with_node_try(self.node_id, |h| {
2630            h.create_service_sized::<Svc, REQ_BUF, REPLY_BUF>(service_name, qos)
2631        })
2632    }
2633
2634    /// [`NodeHandle::create_client`] on this node (phase-483 W2).
2635    pub fn create_client<Svc: RosService>(
2636        &mut self,
2637        service_name: &str,
2638    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
2639    where
2640        Svc::Request: MessageForRmw,
2641        Svc::Reply: MessageForRmw,
2642    {
2643        self.executor
2644            .with_node_try(self.node_id, |h| h.create_client::<Svc>(service_name))
2645    }
2646
2647    /// [`NodeHandle::create_client_with_qos`] on this node (phase-483 W2).
2648    pub fn create_client_with_qos<Svc: RosService>(
2649        &mut self,
2650        service_name: &str,
2651        qos: QoSProfile,
2652    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
2653    where
2654        Svc::Request: MessageForRmw,
2655        Svc::Reply: MessageForRmw,
2656    {
2657        self.executor.with_node_try(self.node_id, |h| {
2658            h.create_client_with_qos::<Svc>(service_name, qos)
2659        })
2660    }
2661
2662    /// [`NodeHandle::create_client_sized`] on this node (phase-483 W2).
2663    pub fn create_client_sized<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>(
2664        &mut self,
2665        service_name: &str,
2666        qos: QoSProfile,
2667    ) -> Result<EmbeddedServiceClient<Svc, REQ_BUF, REPLY_BUF>, NodeError>
2668    where
2669        Svc::Request: MessageForRmw,
2670        Svc::Reply: MessageForRmw,
2671    {
2672        self.executor.with_node_try(self.node_id, |h| {
2673            h.create_client_sized::<Svc, REQ_BUF, REPLY_BUF>(service_name, qos)
2674        })
2675    }
2676
2677    /// The POLLED form of [`NodeHandle::create_service_raw`] (phase-483 W2): the caller
2678    /// takes from it, where rclrs's `create_service` takes a callback.
2679    pub fn create_polling_service_raw(
2680        &mut self,
2681        service_name: &str,
2682        type_name: &str,
2683        type_hash: &str,
2684    ) -> Result<crate::executor::handles::RawServiceServer, NodeError> {
2685        self.executor.with_node_try(self.node_id, |h| {
2686            h.create_service_raw(service_name, type_name, type_hash)
2687        })
2688    }
2689
2690    /// The POLLED form of [`NodeHandle::create_service_raw_sized`] (phase-483 W2): the caller
2691    /// takes from it, where rclrs's `create_service` takes a callback.
2692    pub fn create_polling_service_raw_sized<const REQ_BUF: usize, const RESP_BUF: usize>(
2693        &mut self,
2694        service_name: &str,
2695        type_name: &str,
2696        type_hash: &str,
2697    ) -> Result<crate::executor::handles::RawServiceServer<REQ_BUF, RESP_BUF>, NodeError> {
2698        self.executor.with_node_try(self.node_id, |h| {
2699            h.create_service_raw_sized::<REQ_BUF, RESP_BUF>(service_name, type_name, type_hash)
2700        })
2701    }
2702
2703    /// [`NodeHandle::create_client_raw`] on this node (phase-483 W2).
2704    pub fn create_client_raw(
2705        &mut self,
2706        service_name: &str,
2707        type_name: &str,
2708        type_hash: &str,
2709    ) -> Result<crate::executor::handles::RawServiceClient, NodeError> {
2710        self.executor.with_node_try(self.node_id, |h| {
2711            h.create_client_raw(service_name, type_name, type_hash)
2712        })
2713    }
2714
2715    /// [`NodeHandle::create_client_raw_sized`] on this node (phase-483 W2).
2716    pub fn create_client_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
2717        &mut self,
2718        service_name: &str,
2719        type_name: &str,
2720        type_hash: &str,
2721    ) -> Result<crate::executor::handles::RawServiceClient<REQ_BUF, REPLY_BUF>, NodeError> {
2722        self.executor.with_node_try(self.node_id, |h| {
2723            h.create_client_raw_sized::<REQ_BUF, REPLY_BUF>(service_name, type_name, type_hash)
2724        })
2725    }
2726
2727    /// [`NodeHandle::create_action_server_raw`] on this node (phase-483 W2).
2728    pub fn create_action_server_raw(
2729        &mut self,
2730        action_name: &str,
2731        type_name: &str,
2732        type_hash: &str,
2733    ) -> Result<
2734        super::action_core::ActionServerCore<
2735            { crate::config::DEFAULT_RX_BUF_SIZE },
2736            { crate::config::DEFAULT_RX_BUF_SIZE },
2737            { crate::config::DEFAULT_RX_BUF_SIZE },
2738            4,
2739        >,
2740        NodeError,
2741    > {
2742        self.executor.with_node_try(self.node_id, |h| {
2743            h.create_action_server_raw(action_name, type_name, type_hash)
2744        })
2745    }
2746
2747    /// [`NodeHandle::create_action_server_raw_sized`] on this node (phase-483 W2).
2748    pub fn create_action_server_raw_sized<
2749        const GOAL_BUF: usize,
2750        const RESULT_BUF: usize,
2751        const FEEDBACK_BUF: usize,
2752        const MAX_GOALS: usize,
2753    >(
2754        &mut self,
2755        action_name: &str,
2756        type_name: &str,
2757        type_hash: &str,
2758    ) -> Result<
2759        super::action_core::ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
2760        NodeError,
2761    > {
2762        self.executor.with_node_try(self.node_id, |h| {
2763            h.create_action_server_raw_sized::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>(
2764                action_name,
2765                type_name,
2766                type_hash,
2767            )
2768        })
2769    }
2770
2771    /// [`NodeHandle::create_action_client_raw`] on this node (phase-483 W2).
2772    pub fn create_action_client_raw(
2773        &mut self,
2774        action_name: &str,
2775        type_name: &str,
2776        type_hash: &str,
2777    ) -> Result<
2778        super::action_core::ActionClientCore<
2779            { crate::config::DEFAULT_RX_BUF_SIZE },
2780            { crate::config::DEFAULT_RX_BUF_SIZE },
2781            { crate::config::DEFAULT_RX_BUF_SIZE },
2782        >,
2783        NodeError,
2784    > {
2785        self.executor.with_node_try(self.node_id, |h| {
2786            h.create_action_client_raw(action_name, type_name, type_hash)
2787        })
2788    }
2789
2790    /// [`NodeHandle::create_action_client_raw_sized`] on this node (phase-483 W2).
2791    pub fn create_action_client_raw_sized<
2792        const GOAL_BUF: usize,
2793        const RESULT_BUF: usize,
2794        const FEEDBACK_BUF: usize,
2795    >(
2796        &mut self,
2797        action_name: &str,
2798        type_name: &str,
2799        type_hash: &str,
2800    ) -> Result<super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
2801    {
2802        self.executor.with_node_try(self.node_id, |h| {
2803            h.create_action_client_raw_sized::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>(
2804                action_name,
2805                type_name,
2806                type_hash,
2807            )
2808        })
2809    }
2810
2811    /// [`NodeHandle::create_action_server`] on this node (phase-483 W2).
2812    pub fn create_action_server<A: RosAction>(
2813        &mut self,
2814        action_name: &str,
2815    ) -> Result<ActionServer<A>, NodeError>
2816    where
2817        A::Goal: MessageForRmw,
2818        A::Result: MessageForRmw,
2819        A::Feedback: MessageForRmw,
2820        A::SendGoalRequest: MessageForRmw,
2821        A::SendGoalResponse: MessageForRmw,
2822        A::GetResultRequest: MessageForRmw,
2823        A::GetResultResponse: MessageForRmw,
2824        A::FeedbackMessage: MessageForRmw,
2825    {
2826        self.executor
2827            .with_node_try(self.node_id, |h| h.create_action_server::<A>(action_name))
2828    }
2829
2830    /// [`NodeHandle::create_action_server_sized`] on this node (phase-483 W2).
2831    pub fn create_action_server_sized<
2832        A: RosAction,
2833        const GOAL_BUF: usize,
2834        const RESULT_BUF: usize,
2835        const FEEDBACK_BUF: usize,
2836        const MAX_GOALS: usize,
2837    >(
2838        &mut self,
2839        action_name: &str,
2840    ) -> Result<ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>, NodeError>
2841    where
2842        A::Goal: MessageForRmw,
2843        A::Result: MessageForRmw,
2844        A::Feedback: MessageForRmw,
2845        A::SendGoalRequest: MessageForRmw,
2846        A::SendGoalResponse: MessageForRmw,
2847        A::GetResultRequest: MessageForRmw,
2848        A::GetResultResponse: MessageForRmw,
2849        A::FeedbackMessage: MessageForRmw,
2850    {
2851        self.executor.with_node_try(self.node_id, |h| {
2852            h.create_action_server_sized::<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>(
2853                action_name,
2854            )
2855        })
2856    }
2857
2858    /// [`NodeHandle::create_action_client`] on this node (phase-483 W2).
2859    pub fn create_action_client<A: RosAction>(
2860        &mut self,
2861        action_name: &str,
2862    ) -> Result<ActionClient<A>, NodeError>
2863    where
2864        A::Goal: MessageForRmw,
2865        A::Result: MessageForRmw,
2866        A::Feedback: MessageForRmw,
2867        A::SendGoalRequest: MessageForRmw,
2868        A::SendGoalResponse: MessageForRmw,
2869        A::GetResultRequest: MessageForRmw,
2870        A::GetResultResponse: MessageForRmw,
2871        A::FeedbackMessage: MessageForRmw,
2872    {
2873        self.executor
2874            .with_node_try(self.node_id, |h| h.create_action_client::<A>(action_name))
2875    }
2876
2877    /// [`NodeHandle::create_action_client_sized`] on this node (phase-483 W2).
2878    pub fn create_action_client_sized<
2879        A: RosAction,
2880        const GOAL_BUF: usize,
2881        const RESULT_BUF: usize,
2882        const FEEDBACK_BUF: usize,
2883    >(
2884        &mut self,
2885        action_name: &str,
2886    ) -> Result<ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
2887    where
2888        A::Goal: MessageForRmw,
2889        A::Result: MessageForRmw,
2890        A::Feedback: MessageForRmw,
2891        A::SendGoalRequest: MessageForRmw,
2892        A::SendGoalResponse: MessageForRmw,
2893        A::GetResultRequest: MessageForRmw,
2894        A::GetResultResponse: MessageForRmw,
2895        A::FeedbackMessage: MessageForRmw,
2896    {
2897        self.executor.with_node_try(self.node_id, |h| {
2898            h.create_action_client_sized::<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>(action_name)
2899        })
2900    }
2901}
2902
2903/// Service-server builder on a [`Node`] — `node.service(name)`.
2904pub struct CtxServiceBuilder<'c, 'e, 't, 's> {
2905    ctx: &'c mut Node<'e, 's>,
2906    name: &'t str,
2907    qos: QoSProfile,
2908}
2909
2910impl<'c, 'e, 't, 's> CtxServiceBuilder<'c, 'e, 't, 's> {
2911    /// Service QoS (applies to both the request + reply endpoints). Defaults to
2912    /// `QoSProfile::services_default()`.
2913    pub fn qos(mut self, qos: QoSProfile) -> Self {
2914        self.qos = qos;
2915        self
2916    }
2917
2918    pub fn build<Svc, F>(self, callback: F) -> Result<super::types::HandleId, NodeError>
2919    where
2920        Svc: RosService + 'static,
2921        Svc::Request: crate::rmw_type_registry::MessageForRmw,
2922        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
2923        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
2924    {
2925        self.ctx.executor.register_service_sized_on::<
2926            Svc,
2927            F,
2928            { crate::config::DEFAULT_RX_BUF_SIZE },
2929            { crate::config::DEFAULT_RX_BUF_SIZE },
2930        >(self.ctx.node_id, self.name, self.qos, callback)
2931    }
2932}
2933
2934/// Publisher builder on a [`Node`] — `node.publisher(topic)`.
2935pub struct CtxPublisherBuilder<'c, 'e, 't, 's> {
2936    ctx: &'c mut Node<'e, 's>,
2937    topic: &'t str,
2938    qos: QoSProfile,
2939}
2940
2941impl<'c, 'e, 't, 's> CtxPublisherBuilder<'c, 'e, 't, 's> {
2942    pub fn qos(mut self, qos: QoSProfile) -> Self {
2943        self.qos = qos;
2944        self
2945    }
2946
2947    /// Typed publisher for a ROS message `M`.
2948    pub fn typed<M: MessageForRmw>(self) -> CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
2949        CtxTypedPublisherBuilder {
2950            ctx: self.ctx,
2951            topic: self.topic,
2952            qos: self.qos,
2953            _phantom: PhantomData,
2954        }
2955    }
2956
2957    /// Generic (type-erased) publisher.
2958    pub fn generic(
2959        self,
2960        type_name: &'t str,
2961        type_hash: &'t str,
2962    ) -> CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
2963        CtxGenericPublisherBuilder {
2964            ctx: self.ctx,
2965            topic: self.topic,
2966            type_name,
2967            type_hash,
2968            qos: self.qos,
2969        }
2970    }
2971}
2972
2973/// Typed publisher builder on a `Node` (`.typed::<M>()`).
2974pub struct CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
2975    ctx: &'c mut Node<'e, 's>,
2976    topic: &'t str,
2977    qos: QoSProfile,
2978    _phantom: PhantomData<M>,
2979}
2980
2981impl<'c, 'e, 't, 's, M: MessageForRmw> CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
2982    pub fn qos(mut self, qos: QoSProfile) -> Self {
2983        self.qos = qos;
2984        self
2985    }
2986
2987    pub fn build(self) -> Result<EmbeddedPublisher<M>, NodeError> {
2988        self.ctx
2989            .executor
2990            .create_publisher_on::<M>(self.ctx.node_id, self.topic, self.qos)
2991    }
2992}
2993
2994/// Generic publisher builder on a `Node` (`.generic(type, hash)`).
2995pub struct CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
2996    ctx: &'c mut Node<'e, 's>,
2997    topic: &'t str,
2998    type_name: &'t str,
2999    type_hash: &'t str,
3000    qos: QoSProfile,
3001}
3002
3003impl<'c, 'e, 't, 's> CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
3004    pub fn qos(mut self, qos: QoSProfile) -> Self {
3005        self.qos = qos;
3006        self
3007    }
3008
3009    pub fn build(self) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
3010        self.ctx.executor.create_publisher_raw_on(
3011            self.ctx.node_id,
3012            self.topic,
3013            self.type_name,
3014            self.type_hash,
3015            self.qos,
3016        )
3017    }
3018}
3019
3020/// Subscription builder — `node.subscription(topic)`.
3021pub struct SubscriptionBuilder<'c, 'e, 't, 's> {
3022    ctx: &'c mut Node<'e, 's>,
3023    topic: &'t str,
3024    qos: QoSProfile,
3025}
3026
3027impl<'c, 'e, 't, 's> SubscriptionBuilder<'c, 'e, 't, 's> {
3028    pub fn qos(mut self, qos: QoSProfile) -> Self {
3029        self.qos = qos;
3030        self
3031    }
3032
3033    /// Typed subscription for a ROS message `M`.
3034    pub fn typed<M: MessageForRmw + 'static>(self) -> TypedSubscriptionBuilder<'c, 'e, 't, 's, M> {
3035        TypedSubscriptionBuilder {
3036            ctx: self.ctx,
3037            topic: self.topic,
3038            qos: self.qos,
3039            sched: None,
3040            rx_explicit: false,
3041            _phantom: PhantomData,
3042        }
3043    }
3044
3045    /// Generic (type-erased) subscription — raw CDR bytes to the callback.
3046    pub fn generic(
3047        self,
3048        type_name: &'t str,
3049        type_hash: &'t str,
3050    ) -> GenericSubscriptionBuilder<'c, 'e, 't, 's> {
3051        GenericSubscriptionBuilder {
3052            ctx: self.ctx,
3053            topic: self.topic,
3054            type_name,
3055            type_hash,
3056            qos: self.qos,
3057            sched: None,
3058        }
3059    }
3060}
3061
3062/// Typed subscription builder (`.typed::<M>()`). `RX` is the staging-buffer
3063/// size, set via `.rx_buffer::<N>()` (defaults to `DEFAULT_RX_BUF_SIZE`).
3064pub struct TypedSubscriptionBuilder<
3065    'c,
3066    'e,
3067    't,
3068    's,
3069    M,
3070    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3071> {
3072    ctx: &'c mut Node<'e, 's>,
3073    topic: &'t str,
3074    qos: QoSProfile,
3075    sched: Option<super::sched_context::SchedContextId>,
3076    /// phase-392 W3c -- did the CONSUMER name `RX`, or is it just the default?
3077    ///
3078    /// The two are the same const generic and mean opposite things now: an
3079    /// unnamed `RX` is a ceiling the derivation clamps to, a named one is the
3080    /// number the consumer asked for. `.rx_buffer::<N>()` is the only thing that
3081    /// sets this, so the flag cannot drift from "somebody wrote a number".
3082    rx_explicit: bool,
3083    _phantom: PhantomData<M>,
3084}
3085
3086impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
3087    TypedSubscriptionBuilder<'c, 'e, 't, 's, M, RX>
3088{
3089    pub fn qos(mut self, qos: QoSProfile) -> Self {
3090        self.qos = qos;
3091        self
3092    }
3093
3094    /// Bind the subscription's callback to a scheduling context.
3095    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3096        self.sched = Some(sc);
3097        self
3098    }
3099
3100    /// Set the staging-buffer size (const-generic).
3101    ///
3102    /// phase-392 W3c -- **this is the opt-out.** A subscription that does not
3103    /// call it is sized from `M`'s own serialized bound; calling it says "give
3104    /// me exactly `N` bytes per slot" and the derivation is skipped. Naming a
3105    /// number is the only way to spend more arena than the type needs, which is
3106    /// the direction that should require saying so.
3107    ///
3108    /// It stays the way to raise a buffer above the type's bound -- for a peer
3109    /// that pads, or a type whose `.msg` bound is looser than what it really
3110    /// sends -- and `.rx_buffer_from_type()` after it re-enters the derivation
3111    /// with `N` as the ceiling.
3112    pub fn rx_buffer<const N: usize>(self) -> TypedSubscriptionBuilder<'c, 'e, 't, 's, M, N> {
3113        TypedSubscriptionBuilder {
3114            ctx: self.ctx,
3115            topic: self.topic,
3116            qos: self.qos,
3117            sched: self.sched,
3118            rx_explicit: true,
3119            _phantom: PhantomData,
3120        }
3121    }
3122
3123    /// Phase 403 W2 -- size the receive buffer from `M`'s OWN serialized bound
3124    /// instead of the configured default.
3125    ///
3126    /// ```ignore
3127    /// node.subscription("/chatter")
3128    ///     .typed::<Int32>()
3129    ///     .rx_buffer_from_type()   // 12 bytes per slot, not 1024
3130    ///     .build(on_msg)?;
3131    /// ```
3132    ///
3133    /// **No longer the only way to get it (phase-392 W3c).** This was opt-in,
3134    /// and its own doc said making it the default "would move every image's
3135    /// arena occupancy at once, which is a decision, not a refactor". W3c is
3136    /// that decision: a plain `.typed::<M>().build()` now derives too, wherever
3137    /// the bound is reachable, and `.rx_buffer::<N>()` is the opt-out.
3138    ///
3139    /// Two jobs are left, and they are why this method stays:
3140    ///
3141    /// * **It REFUSES an unbounded `M` at build time.** The default path cannot
3142    ///   -- erroring there would make a bound mandatory for every message type
3143    ///   in every image at once -- so it keeps `RX` for a type with no bound.
3144    ///   Calling this says the bound must exist, and the `const` assert below
3145    ///   is the error that says so.
3146    /// * **It reaches the derivation where `MessageForRmw` carries no schema.**
3147    ///   Only a backend declaring `type-descriptors` makes the type-erased
3148    ///   default site able to read a bound at all (see
3149    ///   [`default_subscription_rx_bytes`](crate::rmw_type_registry::default_subscription_rx_bytes));
3150    ///   this method names `M: nros_serdes::schema::Message` itself, so it works
3151    ///   on every backend.
3152    ///
3153    /// **What it costs and what it buys.** The buffered path stores `depth <= 1
3154    /// ? 3 : depth+1` slots of this size in the executor arena, so a bounded
3155    /// type recovers `slots * (default - bound)` bytes per subscription. The
3156    /// number is the LARGER of the type's XCDR1 and XCDR2 bounds, because the
3157    /// stack writes XCDR1 but a peer may send XCDR2 (see
3158    /// [`subscription_rx_bytes`](crate::rmw_type_registry::subscription_rx_bytes)).
3159    ///
3160    /// **An unbounded `M` fails the BUILD.** Every message type is required to
3161    /// carry a bound -- stated in the `.msg` (`string<=64`) or capped in
3162    /// `nros-codegen.toml` -- so "this type has no bound" is a defect to report,
3163    /// not a case to absorb. The alternative, quietly keeping the configured
3164    /// default, is precisely the substitution phase 380 forbids: `None` means
3165    /// "no bound EXISTS", never "unknown", and a buffer sized from a fallback is
3166    /// the failure that rule was written to prevent. The C header already refuses
3167    /// the same thing the same way -- naming an unbounded type's
3168    /// `{PREFIX}_RX_MAX_SERIALIZED_SIZE` expands to a deliberate compile error
3169    /// carrying the member that costs the bound (`unbounded_token`) -- and this
3170    /// is that refusal on the Rust path. rustc names `M` in the instantiation
3171    /// note; the member comes from the codegen diagnostic for the same type.
3172    ///
3173    /// **The bound never GROWS the buffer.** A type larger than `RX` keeps `RX`;
3174    /// spending unbudgeted arena silently is the worse failure, and the
3175    /// too-small case already reports itself (`report_dropped_take`).
3176    ///
3177    /// Returns a builder without `.message_info()` / `.safety()` on purpose:
3178    /// those entries hold a real `[u8; RX]` array, so their size can only come
3179    /// from a const generic -- `nros::rx_buffer_for!(M)` at the call site -- and
3180    /// carrying the flag into them would silently drop it.
3181    pub fn rx_buffer_from_type(self) -> TypedSubBoundBuilder<'c, 'e, 't, 's, M, RX>
3182    where
3183        M: nros_serdes::schema::Message,
3184    {
3185        // The diagnostic lives HERE, at the opt-in, rather than in `build()`:
3186        // this is the call that asserts the type has a bound, so it is the call
3187        // the error should point at.
3188        const {
3189            assert!(
3190                crate::rmw_type_registry::subscription_rx_bytes::<M>(RX).is_some(),
3191                "this message type has NO maximum serialized size, so its receive \
3192                 buffer cannot be sized from it. Every message type must carry a \
3193                 bound: give the unbounded member one in the `.msg` \
3194                 (`string<=64`, `int32[<=8]`) or a `cap` in `nros-codegen.toml`. \
3195                 The generated C header for this type names the member that costs \
3196                 it the bound (`NROS_UNBOUNDED__<type>__field_<member>`)."
3197            )
3198        }
3199        TypedSubBoundBuilder {
3200            ctx: self.ctx,
3201            topic: self.topic,
3202            qos: self.qos,
3203            sched: self.sched,
3204            _phantom: PhantomData,
3205        }
3206    }
3207
3208    /// Surface per-message [`MessageInfo`](nros_core::MessageInfo) (seq,
3209    /// publisher GID, timestamps) to the callback — `FnMut(&M, Option<&MessageInfo>)`,
3210    /// the rclrs shape. Distinct from the generic builder's `.message_info()`
3211    /// (which yields a `RawMessageInfo` with the wire attachment).
3212    pub fn message_info(self) -> TypedSubInfoBuilder<'c, 'e, 't, 's, M, RX> {
3213        TypedSubInfoBuilder {
3214            ctx: self.ctx,
3215            topic: self.topic,
3216            qos: self.qos,
3217            sched: self.sched,
3218            rx_explicit: self.rx_explicit,
3219            _phantom: PhantomData,
3220        }
3221    }
3222
3223    /// Surface E2E-safety validation (CRC + sequence gap/duplicate) to the
3224    /// callback — `FnMut(&M, &IntegrityStatus)`.
3225    #[cfg(feature = "safety-e2e")]
3226    pub fn safety(self) -> TypedSubSafetyBuilder<'c, 'e, 't, 's, M, RX> {
3227        TypedSubSafetyBuilder {
3228            ctx: self.ctx,
3229            topic: self.topic,
3230            qos: self.qos,
3231            sched: self.sched,
3232            rx_explicit: self.rx_explicit,
3233            _phantom: PhantomData,
3234        }
3235    }
3236
3237    pub fn build<F: FnMut(&M) + 'static>(
3238        self,
3239        callback: F,
3240    ) -> Result<super::types::HandleId, NodeError> {
3241        let handle = self
3242            .ctx
3243            .executor
3244            .register_subscription_buffered_on::<M, F, RX>(
3245                self.ctx.node_id,
3246                self.topic,
3247                self.qos,
3248                callback,
3249                None, // group threaded via create_subscription_in_group; builder uses sched override
3250                // phase-392 W3c -- `None` asks the choke point to DERIVE from
3251                // `M`; `Some(RX)` is the consumer's own number, and only
3252                // `.rx_buffer::<N>()` sets that flag.
3253                if self.rx_explicit { Some(RX) } else { None },
3254            )?;
3255        if let Some(sc) = self.sched {
3256            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3257        }
3258        Ok(handle)
3259    }
3260}
3261
3262/// Phase 403 W2 -- a typed subscription whose buffer is sized by `M`'s own
3263/// serialized bound (`.typed::<M>().rx_buffer_from_type()`).
3264///
3265/// Terminal by construction: `.message_info()` and `.safety()` are absent
3266/// because their entries store `[u8; RX]` and cannot take a runtime size. `RX`
3267/// survives as the CEILING -- an unbounded `M`, or one whose bound exceeds `RX`,
3268/// gets `RX` unchanged.
3269pub struct TypedSubBoundBuilder<
3270    'c,
3271    'e,
3272    't,
3273    's,
3274    M,
3275    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3276> {
3277    ctx: &'c mut Node<'e, 's>,
3278    topic: &'t str,
3279    qos: QoSProfile,
3280    sched: Option<super::sched_context::SchedContextId>,
3281    _phantom: PhantomData<M>,
3282}
3283
3284impl<'c, 'e, 't, 's, M: MessageForRmw + nros_serdes::schema::Message + 'static, const RX: usize>
3285    TypedSubBoundBuilder<'c, 'e, 't, 's, M, RX>
3286{
3287    pub fn qos(mut self, qos: QoSProfile) -> Self {
3288        self.qos = qos;
3289        self
3290    }
3291
3292    /// Bind the subscription's callback to a scheduling context.
3293    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3294        self.sched = Some(sc);
3295        self
3296    }
3297
3298    pub fn build<F: FnMut(&M) + 'static>(
3299        self,
3300        callback: F,
3301    ) -> Result<super::types::HandleId, NodeError> {
3302        // Unreachable: `rx_buffer_from_type` is the only constructor of this
3303        // builder and its `const` assert already refused an unbounded `M`.
3304        let rx_bytes = crate::rmw_type_registry::subscription_rx_bytes::<M>(RX)
3305            .expect("rx_buffer_from_type asserts the bound exists at build time");
3306        let handle = self
3307            .ctx
3308            .executor
3309            .register_subscription_buffered_on::<M, F, RX>(
3310                self.ctx.node_id,
3311                self.topic,
3312                self.qos,
3313                callback,
3314                None,
3315                Some(rx_bytes),
3316            )?;
3317        if let Some(sc) = self.sched {
3318            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3319        }
3320        Ok(handle)
3321    }
3322}
3323
3324/// Typed subscription builder with `MessageInfo` (`.typed::<M>().message_info()`).
3325/// Callback is `FnMut(&M, Option<&MessageInfo>)`.
3326pub struct TypedSubInfoBuilder<
3327    'c,
3328    'e,
3329    't,
3330    's,
3331    M,
3332    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3333> {
3334    ctx: &'c mut Node<'e, 's>,
3335    topic: &'t str,
3336    qos: QoSProfile,
3337    sched: Option<super::sched_context::SchedContextId>,
3338    /// Issue 1860 -- did the consumer NAME `RX` (`.rx_buffer::<N>()`), or is it
3339    /// the default the type's bound is clamped to? As `TypedSubscriptionBuilder`.
3340    rx_explicit: bool,
3341    _phantom: PhantomData<M>,
3342}
3343
3344impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
3345    TypedSubInfoBuilder<'c, 'e, 't, 's, M, RX>
3346{
3347    pub fn qos(mut self, qos: QoSProfile) -> Self {
3348        self.qos = qos;
3349        self
3350    }
3351
3352    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3353        self.sched = Some(sc);
3354        self
3355    }
3356
3357    pub fn rx_buffer<const N: usize>(self) -> TypedSubInfoBuilder<'c, 'e, 't, 's, M, N> {
3358        TypedSubInfoBuilder {
3359            ctx: self.ctx,
3360            topic: self.topic,
3361            qos: self.qos,
3362            sched: self.sched,
3363            rx_explicit: true,
3364            _phantom: PhantomData,
3365        }
3366    }
3367
3368    pub fn build<F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static>(
3369        self,
3370        callback: F,
3371    ) -> Result<super::types::HandleId, NodeError> {
3372        let handle = self
3373            .ctx
3374            .executor
3375            .register_subscription_with_info_sized_inner::<M, F, RX>(
3376                Some(self.ctx.node_id),
3377                self.topic,
3378                self.qos,
3379                callback,
3380                if self.rx_explicit { Some(RX) } else { None },
3381            )?;
3382        if let Some(sc) = self.sched {
3383            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3384        }
3385        Ok(handle)
3386    }
3387}
3388
3389/// Typed subscription builder with E2E-safety validation
3390/// (`.typed::<M>().safety()`). Callback is `FnMut(&M, &IntegrityStatus)`.
3391#[cfg(feature = "safety-e2e")]
3392pub struct TypedSubSafetyBuilder<
3393    'c,
3394    'e,
3395    't,
3396    's,
3397    M,
3398    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3399> {
3400    ctx: &'c mut Node<'e, 's>,
3401    topic: &'t str,
3402    qos: QoSProfile,
3403    sched: Option<super::sched_context::SchedContextId>,
3404    /// Issue 1860 -- did the consumer NAME `RX` (`.rx_buffer::<N>()`), or is it
3405    /// the default the type's bound is clamped to? As `TypedSubscriptionBuilder`.
3406    rx_explicit: bool,
3407    _phantom: PhantomData<M>,
3408}
3409
3410#[cfg(feature = "safety-e2e")]
3411impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
3412    TypedSubSafetyBuilder<'c, 'e, 't, 's, M, RX>
3413{
3414    pub fn qos(mut self, qos: QoSProfile) -> Self {
3415        self.qos = qos;
3416        self
3417    }
3418
3419    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3420        self.sched = Some(sc);
3421        self
3422    }
3423
3424    pub fn rx_buffer<const N: usize>(self) -> TypedSubSafetyBuilder<'c, 'e, 't, 's, M, N> {
3425        TypedSubSafetyBuilder {
3426            ctx: self.ctx,
3427            topic: self.topic,
3428            qos: self.qos,
3429            sched: self.sched,
3430            rx_explicit: true,
3431            _phantom: PhantomData,
3432        }
3433    }
3434
3435    pub fn build<F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static>(
3436        self,
3437        callback: F,
3438    ) -> Result<super::types::HandleId, NodeError> {
3439        let handle = self
3440            .ctx
3441            .executor
3442            .register_subscription_with_safety_sized_inner::<M, F, RX>(
3443                Some(self.ctx.node_id),
3444                self.topic,
3445                self.qos,
3446                callback,
3447                if self.rx_explicit { Some(RX) } else { None },
3448            )?;
3449        if let Some(sc) = self.sched {
3450            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3451        }
3452        Ok(handle)
3453    }
3454}
3455
3456/// Generic (type-erased) subscription builder (`.generic(type, hash)`).
3457pub struct GenericSubscriptionBuilder<
3458    'c,
3459    'e,
3460    't,
3461    's,
3462    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3463> {
3464    ctx: &'c mut Node<'e, 's>,
3465    topic: &'t str,
3466    type_name: &'t str,
3467    type_hash: &'t str,
3468    qos: QoSProfile,
3469    sched: Option<super::sched_context::SchedContextId>,
3470}
3471
3472impl<'c, 'e, 't, 's, const RX: usize> GenericSubscriptionBuilder<'c, 'e, 't, 's, RX> {
3473    pub fn qos(mut self, qos: QoSProfile) -> Self {
3474        self.qos = qos;
3475        self
3476    }
3477
3478    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3479        self.sched = Some(sc);
3480        self
3481    }
3482
3483    pub fn rx_buffer<const N: usize>(self) -> GenericSubscriptionBuilder<'c, 'e, 't, 's, N> {
3484        GenericSubscriptionBuilder {
3485            ctx: self.ctx,
3486            topic: self.topic,
3487            type_name: self.type_name,
3488            type_hash: self.type_hash,
3489            qos: self.qos,
3490            sched: self.sched,
3491        }
3492    }
3493
3494    /// Surface the sample's wire attachment + metadata to the callback
3495    /// (`FnMut(&[u8], &RawMessageInfo)`). The cross-RMW bridge reads the
3496    /// `bridge_origin` tag from `info.attachment()` for echo suppression.
3497    pub fn message_info(self) -> GenericSubInfoBuilder<'c, 'e, 't, 's, RX> {
3498        GenericSubInfoBuilder {
3499            ctx: self.ctx,
3500            topic: self.topic,
3501            type_name: self.type_name,
3502            type_hash: self.type_hash,
3503            qos: self.qos,
3504            sched: self.sched,
3505        }
3506    }
3507
3508    pub fn build<F: FnMut(&[u8]) + 'static>(
3509        self,
3510        callback: F,
3511    ) -> Result<super::types::HandleId, NodeError> {
3512        let handle = self
3513            .ctx
3514            .executor
3515            .register_subscription_buffered_raw_on::<F, RX>(
3516                self.ctx.node_id,
3517                self.topic,
3518                self.type_name,
3519                self.type_hash,
3520                self.qos,
3521                callback,
3522            )?;
3523        if let Some(sc) = self.sched {
3524            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3525        }
3526        Ok(handle)
3527    }
3528}
3529
3530/// Generic subscription builder with `MessageInfo` surfaced
3531/// (`.message_info()`). Callback is `FnMut(&[u8], &RawMessageInfo)`.
3532pub struct GenericSubInfoBuilder<
3533    'c,
3534    'e,
3535    't,
3536    's,
3537    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
3538> {
3539    ctx: &'c mut Node<'e, 's>,
3540    topic: &'t str,
3541    type_name: &'t str,
3542    type_hash: &'t str,
3543    qos: QoSProfile,
3544    sched: Option<super::sched_context::SchedContextId>,
3545}
3546
3547impl<'c, 'e, 't, 's, const RX: usize> GenericSubInfoBuilder<'c, 'e, 't, 's, RX> {
3548    pub fn qos(mut self, qos: QoSProfile) -> Self {
3549        self.qos = qos;
3550        self
3551    }
3552
3553    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
3554        self.sched = Some(sc);
3555        self
3556    }
3557
3558    pub fn rx_buffer<const N: usize>(self) -> GenericSubInfoBuilder<'c, 'e, 't, 's, N> {
3559        GenericSubInfoBuilder {
3560            ctx: self.ctx,
3561            topic: self.topic,
3562            type_name: self.type_name,
3563            type_hash: self.type_hash,
3564            qos: self.qos,
3565            sched: self.sched,
3566        }
3567    }
3568
3569    pub fn build<F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static>(
3570        self,
3571        callback: F,
3572    ) -> Result<super::types::HandleId, NodeError> {
3573        let handle = self
3574            .ctx
3575            .executor
3576            .register_subscription_buffered_raw_info_on::<F, RX>(
3577                self.ctx.node_id,
3578                self.topic,
3579                self.type_name,
3580                self.type_hash,
3581                self.qos,
3582                callback,
3583            )?;
3584        if let Some(sc) = self.sched {
3585            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
3586        }
3587        Ok(handle)
3588    }
3589}
3590
3591// `not(feature = "rmw-cffi")` — these tests use the `mock` backend
3592// (`crate::mock`, itself `cfg(all(test, not(rmw-cffi)))`); a workspace test
3593// build that unifies `rmw-cffi` on swaps `ConcreteSession` to the cffi session
3594// and drops `mock`, so the module must drop with it (matches the
3595// `mock_integration` gate in lifecycle_services.rs).
3596#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
3597mod builder_tests {
3598    use super::*;
3599    use crate::{executor::Executor, mock::MockSession};
3600    use nros_core::{CdrReader, CdrWriter, DeserError, Deserialize, SerError, Serialize};
3601
3602    struct TestMsg;
3603    impl RosMessage for TestMsg {
3604        const TYPE_NAME: &'static str = "test/msg/TestMsg";
3605        const TYPE_HASH: &'static str = "test_hash";
3606    }
3607    impl Serialize for TestMsg {
3608        fn serialize(&self, _w: &mut CdrWriter) -> Result<(), SerError> {
3609            Ok(())
3610        }
3611    }
3612    impl Deserialize for TestMsg {
3613        fn deserialize(_r: &mut CdrReader) -> Result<Self, DeserError> {
3614            Ok(Self)
3615        }
3616    }
3617    // Phase 212.K.7.6.b — minimal single-field `Message` impl so
3618    // `TypedPublisherBuilder::build` resolves under the cyclonedds-tightened
3619    // bound AND the runtime register call succeeds. `DescriptorBuilder`
3620    // rejects empty `FIELDS` with `BuildError::EmptySchema`; pretend
3621    // there's one byte so the bridge stub returns a non-NULL pointer.
3622    // phase-380 W4 — was `#[cfg(rmw_needs_type_descriptors)]`: the schema is
3623    // now required by `MessageForRmw` on EVERY backend, because that is where
3624    // a subscription's build-time size bound comes from.
3625    impl nros_serdes::schema::Message for TestMsg {
3626        const TYPE_NAME: &'static str = "test/msg/TestMsg";
3627        const FIELDS: &'static [nros_serdes::schema::Field] = &[nros_serdes::schema::Field {
3628            name: "data",
3629            ty: nros_serdes::schema::FieldType::Uint8,
3630            offset: 0,
3631        }];
3632    }
3633
3634    fn s(v: &str) -> heapless::String<64> {
3635        heapless::String::try_from(v).unwrap()
3636    }
3637
3638    #[test]
3639    fn publisher_builder_typed_and_generic() {
3640        let mut session = MockSession::new();
3641        let mut node = NodeHandle::new(s("n"), s("/"), &mut session, &[], 0);
3642
3643        // typed: node.publisher(t).typed::<M>().qos(..).build()
3644        let _typed = node
3645            .publisher("/chatter")
3646            .typed::<TestMsg>()
3647            .qos(QoSProfile::default().keep_last(5))
3648            .build()
3649            .expect("typed publisher builds");
3650
3651        // generic: node.publisher(t).qos(..).generic(type, hash).build()
3652        let _generic = node
3653            .publisher("/chatter")
3654            .qos(QoSProfile::default())
3655            .generic("std_msgs/msg/Int32", "hash")
3656            .build()
3657            .expect("generic publisher builds");
3658    }
3659
3660    // The handle `Executor::create_node` returns resolves through the same two
3661    // tiers. Asserted on the name the BACKEND was handed (`MockPublisher`
3662    // records it), not on a helper's return value.
3663
3664    fn argv_rule(from: &'static str, to: &'static str) -> crate::ros_args::RemapArg<'static> {
3665        crate::ros_args::RemapArg {
3666            node: None,
3667            from,
3668            to,
3669        }
3670    }
3671
3672    #[test]
3673    fn create_node_handle_applies_an_argv_remap() {
3674        let mut exec: Executor = Executor::from_session(MockSession::new());
3675        crate::ros_args::install_argv_remaps(&mut exec, [argv_rule("chatter", "/from_argv")])
3676            .unwrap();
3677        let mut node = exec.create_node("talker").expect("node");
3678        let p = node
3679            .create_publisher::<TestMsg>("chatter")
3680            .expect("publisher");
3681        assert_eq!(p.handle.topic_name(), "/from_argv");
3682    }
3683
3684    #[test]
3685    fn create_node_handle_prefers_a_launch_rule_over_argv() {
3686        let mut exec: Executor = Executor::from_session(MockSession::new());
3687        crate::ros_args::install_argv_remaps(&mut exec, [argv_rule("chatter", "/from_argv")])
3688            .unwrap();
3689        exec.declare_remap("talker", "/", "chatter", "/from_launch")
3690            .unwrap();
3691        let mut node = exec.create_node("talker").expect("node");
3692        let p = node
3693            .create_publisher::<TestMsg>("chatter")
3694            .expect("publisher");
3695        assert_eq!(p.handle.topic_name(), "/from_launch");
3696    }
3697
3698    #[test]
3699    fn create_node_handle_passes_an_unmatched_name_through_unchanged() {
3700        // No rule matches, so the handle hands the backend exactly what it was
3701        // given — NOT the expanded `/talker_ns/chatter`. This handle has never
3702        // expanded names, and a remap that does not fire must not start.
3703        let mut exec: Executor = Executor::from_session(MockSession::new());
3704        crate::ros_args::install_argv_remaps(&mut exec, [argv_rule("other", "/x")]).unwrap();
3705        let mut node = exec.create_node("talker").expect("node");
3706        let p = node
3707            .create_publisher::<TestMsg>("chatter")
3708            .expect("publisher");
3709        assert_eq!(p.handle.topic_name(), "chatter");
3710    }
3711
3712    #[test]
3713    fn subscription_builder_and_convenient() {
3714        let mut exec: Executor = Executor::from_session(MockSession::new());
3715        let id = exec.node_builder("n").build().expect("node");
3716
3717        // builder: typed
3718        let _h = exec
3719            .node_mut(id)
3720            .subscription("/chatter")
3721            .typed::<TestMsg>()
3722            .qos(QoSProfile::default().keep_last(5))
3723            .build(|_m: &TestMsg| {})
3724            .expect("typed subscription builds");
3725
3726        // builder: generic (raw bytes)
3727        let _g = exec
3728            .node_mut(id)
3729            .subscription("/raw")
3730            .generic("std_msgs/msg/Int32", "hash")
3731            .build(|_b: &[u8]| {})
3732            .expect("generic subscription builds");
3733
3734        // builder: sized + sched-context (slice 3 knobs)
3735        let sc = exec.default_sched_context_id();
3736        let _s = exec
3737            .node_mut(id)
3738            .subscription("/sized")
3739            .typed::<TestMsg>()
3740            .rx_buffer::<64>()
3741            .sched_context(sc)
3742            .build(|_m: &TestMsg| {})
3743            .expect("sized + sched subscription builds");
3744
3745        // convenient (fork tier) — one node-ctx at a time, re-acquired
3746        let _c = exec
3747            .node_mut(id)
3748            .create_subscription::<TestMsg, _>("/conv", |_m: &TestMsg| {})
3749            .expect("convenient typed subscription builds");
3750    }
3751
3752    #[test]
3753    fn generic_message_info_builder() {
3754        // slice 3b — the bridge echo path: generic sub whose callback
3755        // receives the wire attachment via RawMessageInfo.
3756        let mut exec: Executor = Executor::from_session(MockSession::new());
3757        let id = exec.node_builder("n").build().expect("node");
3758
3759        let _i = exec
3760            .node_mut(id)
3761            .subscription("/info")
3762            .generic("std_msgs/msg/Int32", "hash")
3763            .message_info()
3764            .rx_buffer::<256>()
3765            .build(|_payload: &[u8], info: &nros_core::RawMessageInfo| {
3766                let _ = info.attachment();
3767            })
3768            .expect("generic + message_info subscription builds");
3769    }
3770
3771    #[test]
3772    fn typed_message_info_builder() {
3773        // M2.a — typed .message_info() (rclrs shape FnMut(&M, Option<&MessageInfo>)),
3774        // replacing register_subscription_with_info.
3775        let mut exec: Executor = Executor::from_session(MockSession::new());
3776        let id = exec.node_builder("n").build().expect("node");
3777        let _h = exec
3778            .node_mut(id)
3779            .subscription("/chatter")
3780            .typed::<TestMsg>()
3781            .qos(QoSProfile::default().keep_last(5))
3782            .message_info()
3783            .build(|_m: &TestMsg, _info: Option<&nros_core::MessageInfo>| {})
3784            .expect("typed + message_info subscription builds");
3785    }
3786
3787    #[cfg(feature = "safety-e2e")]
3788    #[test]
3789    fn typed_safety_builder() {
3790        // M2.a — typed .safety(), replacing register_subscription_with_safety.
3791        let mut exec: Executor = Executor::from_session(MockSession::new());
3792        let id = exec.node_builder("n").build().expect("node");
3793        let _h = exec
3794            .node_mut(id)
3795            .subscription("/chatter")
3796            .typed::<TestMsg>()
3797            .safety()
3798            .build(|_m: &TestMsg, _status: &nros_rmw::IntegrityStatus| {})
3799            .expect("typed + safety subscription builds");
3800    }
3801
3802    #[test]
3803    fn generator_emitted_chain_compiles() {
3804        // Locks the exact builder chain the orchestration generator emits
3805        // for a subscriber (replaces register_subscription_raw_with_qos_sized_on).
3806        let mut exec: Executor = Executor::from_session(MockSession::new());
3807        let id = exec.node_builder("n").build().expect("node");
3808        let _h = exec
3809            .node_mut(id)
3810            .subscription("/topic")
3811            .generic("std_msgs/msg/Int32", "hash")
3812            .qos(QoSProfile::default().keep_last(1))
3813            .rx_buffer::<1024>()
3814            .build(|_data: &[u8]| {})
3815            .expect("generator-shape subscription builds");
3816    }
3817
3818    #[test]
3819    fn nodectx_publisher_and_bridge_shape() {
3820        // Node publisher symmetry + the bridge two-ctx borrow pattern:
3821        // build the dest publisher on one Node (dropped), then register
3822        // the source subscription on another — the owned publisher outlives.
3823        let mut exec: Executor = Executor::from_session(MockSession::new());
3824        let id = exec.node_builder("n").build().expect("node");
3825
3826        // convenient + builder publisher on Node
3827        let _p = exec
3828            .node_mut(id)
3829            .create_publisher::<TestMsg>("/p")
3830            .expect("ctx convenient publisher");
3831        let dest_pub = exec
3832            .node_mut(id)
3833            .publisher("/fwd")
3834            .generic("std_msgs/msg/Int32", "hash")
3835            .build()
3836            .expect("ctx generic publisher builds"); // Node dropped here
3837
3838        // re-borrow exec for the source sub; closure owns dest_pub
3839        let _s = exec
3840            .node_mut(id)
3841            .subscription("/src")
3842            .generic("std_msgs/msg/Int32", "hash")
3843            .message_info()
3844            .build(move |payload: &[u8], _info: &nros_core::RawMessageInfo| {
3845                let _ = dest_pub.publish_raw(payload);
3846            })
3847            .expect("bridge-shape source subscription builds");
3848    }
3849}