Skip to main content

nros/
contract.rs

1//! Contract monitors on target, and the `/diagnostics` reporter they publish
2//! through — issues 1635 and 1676, ONE implementation for every language.
3//!
4//! RFC-0052 / phase-296 W3b: a contracted image bakes monitor tables
5//! (`min_rate_hz` / `max_latency_ms` per publisher, `max_age_ms` per
6//! subscriber), the executor checks them every spin and logs each violation at
7//! detection (issue 0514's floor). Issue 1635 made a C or C++ image also
8//! PUBLISH each one as a `DiagnosticArray` on `/diagnostics`, through a
9//! reporter `nros-cpp` armed when the tables were installed. The Rust entry
10//! (`nros::main!`) installed no table at all (issue 1676), so it neither
11//! monitored nor reported.
12//!
13//! This module is that reporter, moved up to the umbrella so the three
14//! languages share it: [`DiagSink`] (one `/diagnostics` publisher and its two
15//! counters) and its violation sink, used by `nros-cpp`'s
16//! `nros_cpp_install_monitors` and by [`install_contract_monitors`], which the
17//! `nros::main!` expansion calls before the first node is created. The mapping
18//! from a violation to a report is `nros-diagnostics`'
19//! (`DiagnosticReporter::report_violation`, streamed by
20//! `write_violation_reports` so the report never sits on the spin thread's
21//! stack as a value), so a violation reads the same from every road. The
22//! executor hands the reporter its queued verdicts off the judged dispatch
23//! (phase-474 I7), and one report carries as many as fit one message.
24
25use core::{cell::UnsafeCell, ffi::c_void};
26
27use nros_core::RosMessage;
28use nros_diagnostics::DiagnosticArray;
29use nros_node::executor::{
30    Executor,
31    monitor::{AgeMonitorSpec, MonitorSpec, Violation},
32};
33use nros_rmw::{Publisher as _, Session as _, TopicInfo, TransportError};
34
35use crate::internals::RmwPublisher;
36
37/// The topic ROS 2's diagnostics tooling reads.
38pub const DIAG_TOPIC: &str = "/diagnostics";
39
40/// One report's CDR: one `DiagnosticStatus` (name <= 64, message <= 128,
41/// hardware id <= 96) with one key/value (<= 32 + 64) and the array's header.
42/// 512 covers that with room; a report that does not fit is counted, not sent
43/// truncated.
44const REPORT_BUF: usize = 512;
45
46/// One executor's `/diagnostics` reporter: the publisher and two counters.
47///
48/// No rate-limit state: the rules are windowed and fire on transitions, so the
49/// reporter is built per call with no interval (and this struct's size, which
50/// `nros-build-helpers` adds to `CPP_EXECUTOR_OPAQUE_U64S`, stays the
51/// publisher plus two words).
52pub struct DiagSink {
53    publisher: RmwPublisher,
54    /// Reports published.
55    pub published: u32,
56    /// Reports that could not be (serialise or publish failed).
57    pub failed: u32,
58}
59
60impl DiagSink {
61    /// Create a `/diagnostics` publisher on `executor`'s session, in
62    /// `domain_id` (the executor's own for a Rust entry; the C++ context keeps
63    /// its domain beside the executor).
64    pub fn create(executor: &mut Executor<'_>, domain_id: u32) -> Result<Self, TransportError> {
65        let info = TopicInfo::new(
66            DIAG_TOPIC,
67            <DiagnosticArray as RosMessage>::TYPE_NAME,
68            <DiagnosticArray as RosMessage>::TYPE_HASH,
69        )
70        .with_domain(domain_id);
71        let publisher = executor
72            .session_mut()
73            .create_publisher(&info, nros_rmw::QoSProfile::default())?;
74        Ok(Self {
75            publisher,
76            published: 0,
77            failed: 0,
78        })
79    }
80
81    /// Point `executor`'s violation sink at this reporter.
82    ///
83    /// # Safety
84    /// `self` must not move, and must outlive every spin of `executor`, until
85    /// the sink is removed (`set_violation_sink(None)`) or the executor is
86    /// dropped — the executor keeps its address.
87    pub unsafe fn hook(&mut self, executor: &mut Executor<'_>) {
88        let ctx = self as *mut DiagSink as *mut c_void;
89        unsafe { executor.set_violation_sink(Some((publish_violations as _, ctx))) };
90    }
91}
92
93/// The executor's violation sink: the pending violations, coalesced into one
94/// `DiagnosticArray` -- as many of them, oldest first, as fit [`REPORT_BUF`].
95/// Returns how many it took (0 when even one could not be sent, which it
96/// counts in `failed`).
97///
98/// phase-474 I7 -- the executor calls this off the judged dispatch, at the end
99/// of a spin with slack, with every verdict queued since the last call. Each
100/// status is the one a single report would carry; on the safety island's
101/// 921,600-baud serial link one report cost 15-20 ms of spin, so one message
102/// for several verdicts is the difference between one tick and several.
103///
104/// # Safety
105/// `ctx` is the [`DiagSink`] [`DiagSink::hook`] installed.
106unsafe fn publish_violations(ctx: *mut c_void, pending: &[Violation]) -> usize {
107    let sink = unsafe { &mut *(ctx as *mut DiagSink) };
108    // No rate limit: the rules themselves are windowed and fire on
109    // transitions. phase-474 I7 -- the report is STREAMED into the buffer
110    // (`write_violation_reports`, byte-identical to serializing the value
111    // form). The value is a 5 KB `DiagnosticArray`, and building it on the
112    // spin thread ran the safety island's 16 KiB main stack into the idle
113    // thread's on its first stored violation. What stays on this frame is the
114    // 512 B buffer and four borrowed tuples.
115    let mut buf = [0u8; REPORT_BUF];
116    let mut rows: [(&str, &str, u32, u32); nros_node::executor::monitor::REPORT_QUEUE] =
117        [("", "", 0, 0); nros_node::executor::monitor::REPORT_QUEUE];
118    let n_max = pending.len().min(rows.len());
119    for (row, v) in rows.iter_mut().zip(pending.iter()) {
120        *row = (v.rule, v.fqn, v.measured, v.declared);
121    }
122    // The most that fit one buffer: a status is 100-300 B, so two or three
123    // usually do; whatever does not fit waits for the next slot.
124    let mut n = n_max;
125    while n > 0 {
126        let Ok(mut w) = nros_core::CdrWriter::new_with_header(&mut buf) else {
127            break;
128        };
129        if nros_diagnostics::write_violation_reports(&mut w, &rows[..n]).is_ok() {
130            let len = w.position();
131            return match sink.publisher.publish_raw(&buf[..len]) {
132                Ok(()) => {
133                    sink.published = sink.published.saturating_add(1);
134                    n
135                }
136                Err(_) => {
137                    sink.failed = sink.failed.saturating_add(1);
138                    0
139                }
140            };
141        }
142        n -= 1;
143    }
144    sink.failed = sink.failed.saturating_add(1);
145    0
146}
147
148/// Static storage for one executor's [`DiagSink`] — what a Rust entry owns in
149/// place of the C++ context's `diag` field.
150///
151/// `nros::main!` emits one per executor that installs a non-empty table, as a
152/// `static`. Filled once, by [`install_contract_monitors`], on the thread that
153/// registers that executor's nodes; read afterwards only through the
154/// executor's sink, on the thread that spins it.
155pub struct ContractReporter {
156    sink: UnsafeCell<Option<DiagSink>>,
157}
158
159// SAFETY: one `ContractReporter` serves one executor. It is written once,
160// before that executor spins, and afterwards reached only from that
161// executor's spin (through the sink pointer) — never from two threads at once.
162unsafe impl Sync for ContractReporter {}
163
164impl ContractReporter {
165    /// An empty reporter.
166    #[must_use]
167    pub const fn new() -> Self {
168        Self {
169            sink: UnsafeCell::new(None),
170        }
171    }
172}
173
174impl Default for ContractReporter {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180/// Install a contracted Rust image's monitor tables on one executor, and arm
181/// its `/diagnostics` reporter — issue 1676.
182///
183/// The Rust spelling of `nros_cpp_install_monitors`. Call BEFORE any node is
184/// created on the executor: a publisher attaches its counter cell by exact
185/// topic match at create time, so a table installed later monitors nothing.
186/// `nros::main!` emits this ahead of the parameter services and the node
187/// registrations on every executor it builds (the single one, and each
188/// tier's).
189///
190/// The tables are refused, not truncated, when they exceed the executor's
191/// `MAX_MONITORS` / `MAX_AGE_MONITORS` (the baked tables also carry a
192/// compile-time assert naming the knob). Empty tables install nothing and arm
193/// no reporter — an uncontracted image carries none of this. A reporter is
194/// armed once per `reporter`; a second install keeps the first.
195///
196/// If the `/diagnostics` publisher cannot be created the tables stay
197/// installed and violations reach the log only — reported, not fatal, which
198/// is what `nros_cpp_install_monitors` does too (both through
199/// [`arm_reporter`]; a census run arms none). Returns `Err` only for a
200/// refused table.
201///
202/// # Safety
203/// `executor` must be the live `*mut Executor<'static>` handle a
204/// `RuntimeCtx` hands out (`runtime.executor_handle()`), or NULL, and nothing
205/// else may hold a reference into that executor for the call.
206pub unsafe fn install_contract_monitors(
207    executor: *mut c_void,
208    monitors: &'static [MonitorSpec],
209    ages: &'static [AgeMonitorSpec],
210    reporter: &'static ContractReporter,
211) -> Result<(), &'static str> {
212    // Issue 1535 — the one handle check every seam in this crate makes.
213    let executor = match unsafe {
214        crate::executor_handle::executor_from_handle(executor, "install_contract_monitors")
215    } {
216        Ok(executor) => executor,
217        Err(crate::executor_handle::HandleRefusal::Null) => {
218            return Err("the runtime hands out no executor to install the contract monitors on");
219        }
220        Err(crate::executor_handle::HandleRefusal::CppContext) => {
221            return Err("the contract monitors were handed an nros-cpp context, not an executor");
222        }
223    };
224    if let Err(full) = executor.try_set_monitor_tables(monitors, ages) {
225        nros_log::log_error!(
226            nros_log::get_logger("nros.contract"),
227            "install_contract_monitors: {}",
228            full
229        );
230        return Err("the contract's monitor tables exceed the executor's monitor capacity");
231    }
232    if monitors.is_empty() && ages.is_empty() {
233        return Ok(());
234    }
235    // SAFETY: see `ContractReporter`'s `Sync` — this is the one write, made
236    // before the executor spins.
237    let slot = unsafe { &mut *reporter.sink.get() };
238    let domain_id = executor.domain_id();
239    // SAFETY: `slot` lives in a `'static` reporter and never moves.
240    unsafe { arm_reporter(executor, domain_id, slot) };
241    Ok(())
242}
243
244/// phase-462 W3b -- install a contracted Rust image's hazard table on one
245/// executor, after [`install_contract_monitors`] (each hazard's guards index
246/// the two tables that call installs). `nros::main!` emits this only for an
247/// executor whose tables observe a hazard; the observer then reports
248/// `hazard-detected`, `hazard-reaction` and `hazard-recovered` through the
249/// reporter that call armed.
250///
251/// A NULL or non-executor handle installs nothing: the monitor install it
252/// follows has already refused that handle, and said so.
253///
254/// # Safety
255/// As for [`install_contract_monitors`].
256pub unsafe fn install_contract_hazards(
257    executor: *mut c_void,
258    hazards: &'static [nros_node::executor::monitor::HazardSpec],
259) {
260    if let Ok(executor) = unsafe {
261        crate::executor_handle::executor_from_handle(executor, "install_contract_hazards")
262    } {
263        executor.set_hazard_table(hazards);
264    }
265}
266
267/// Arm one executor's `/diagnostics` reporter in `slot`, and point the
268/// executor's violation sink at it — THE arming step, shared by
269/// [`install_contract_monitors`] (Rust) and `nros-cpp`'s
270/// `nros_cpp_install_monitors` (C and C++), issue 1693.
271///
272/// * Idempotent: a slot already holding a reporter keeps it.
273/// * A CENSUS run arms nothing ([`crate::census_hooks::recording_run`]): it
274///   never spins, so the reporter could never publish, and the recorder has no
275///   node open yet to attribute it to — it refused the publisher, and the
276///   C/C++ road turned that refusal into a failed setup that recorded nothing.
277/// * A publisher that cannot be created is REPORTED, never fatal: the tables
278///   stay installed and every violation still reaches the log (issue 0514's
279///   floor). Both roads say so in the one message below; before this the C/C++
280///   road logged "violations stay in the log only" and then failed the setup.
281///
282/// Returns whether a reporter is armed after the call.
283///
284/// # Safety
285/// `slot` must not move, and must outlive every spin of `executor`, until the
286/// sink is removed (`set_violation_sink(None)`) or the executor is dropped —
287/// [`DiagSink::hook`]'s contract.
288pub unsafe fn arm_reporter(
289    executor: &mut Executor<'_>,
290    domain_id: u32,
291    slot: &mut Option<DiagSink>,
292) -> bool {
293    if slot.is_some() {
294        return true;
295    }
296    if crate::census_hooks::recording_run() {
297        return false;
298    }
299    match DiagSink::create(executor, domain_id) {
300        Ok(sink) => {
301            let sink = slot.insert(sink);
302            // SAFETY: forwarded from this fn's contract.
303            unsafe { sink.hook(executor) };
304            true
305        }
306        Err(e) => {
307            nros_log::log_error!(
308                nros_log::get_logger("nros.contract"),
309                "contract monitors installed, but the {} publisher could not be created \
310                 ({:?}); violations stay in the log only (issues 1635/1676)",
311                DIAG_TOPIC,
312                e
313            );
314            false
315        }
316    }
317}