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}