Skip to main content

cu29_runtime/
monitoring.rs

1//! Some basic internal monitoring tooling Copper uses to monitor itself and the components it runs.
2//!
3
4use crate::config::CuConfig;
5use crate::config::{
6    BridgeChannelConfigRepresentation, BridgeConfig, ComponentConfig, CuGraph, Flavor, NodeId,
7    TaskKind, resolve_task_kind_for_id,
8};
9use crate::context::CuContext;
10use crate::cutask::CuMsgMetadata;
11#[cfg(any(not(feature = "std"), not(target_has_atomic = "64")))]
12use crate::sync_compat::Mutex as SyncMutex;
13#[cfg(not(target_has_atomic = "64"))]
14use crate::sync_compat::MutexGuard as SyncMutexGuard;
15use bincode::Encode;
16use bincode::config::standard;
17use bincode::enc::EncoderImpl;
18use bincode::enc::write::SizeWriter;
19use compact_str::CompactString;
20use cu29_clock::CuDuration;
21#[allow(unused_imports)]
22use cu29_log::CuLogLevel;
23#[cfg(all(feature = "std", debug_assertions))]
24use cu29_log_runtime::{LiveLogListenerGuard, format_message_only, scoped_live_log_listener};
25use cu29_traits::{
26    CuError, CuResult, ObservedWriter, abort_observed_encode, begin_observed_encode,
27    finish_observed_encode,
28};
29use portable_atomic::{
30    AtomicBool as PortableAtomicBool, AtomicU64 as PortableAtomicU64, Ordering as PortableOrdering,
31};
32use serde_derive::{Deserialize, Serialize};
33
34#[cfg(not(feature = "std"))]
35extern crate alloc;
36
37#[cfg(feature = "std")]
38use core::cell::Cell;
39#[cfg(feature = "std")]
40use std::backtrace::Backtrace;
41#[cfg(feature = "std")]
42use std::fs::File;
43#[cfg(feature = "std")]
44use std::io::Write;
45#[cfg(feature = "std")]
46use std::panic::PanicHookInfo;
47#[cfg(feature = "std")]
48use std::sync::{Arc, Mutex as StdMutex, OnceLock};
49#[cfg(feature = "std")]
50use std::thread_local;
51#[cfg(feature = "std")]
52use std::time::{SystemTime, UNIX_EPOCH};
53#[cfg(feature = "std")]
54use std::{collections::HashMap as Map, string::String, string::ToString, vec::Vec};
55
56#[cfg(not(feature = "std"))]
57use alloc::{collections::BTreeMap as Map, string::String, string::ToString, vec::Vec};
58
59#[cfg(not(feature = "std"))]
60mod imp {
61    pub use alloc::alloc::{GlobalAlloc, Layout};
62    #[cfg(target_has_atomic = "64")]
63    pub use core::sync::atomic::AtomicU64;
64    pub use core::sync::atomic::{AtomicUsize, Ordering};
65    pub use libm::sqrt;
66}
67
68#[cfg(feature = "std")]
69mod imp {
70    #[cfg(feature = "memory_monitoring")]
71    use super::CountingAlloc;
72    #[cfg(feature = "memory_monitoring")]
73    pub use std::alloc::System;
74    pub use std::alloc::{GlobalAlloc, Layout};
75    #[cfg(target_has_atomic = "64")]
76    pub use std::sync::atomic::AtomicU64;
77    pub use std::sync::atomic::{AtomicUsize, Ordering};
78    #[cfg(feature = "memory_monitoring")]
79    #[global_allocator]
80    pub static GLOBAL: CountingAlloc<System> = CountingAlloc::new(System);
81}
82
83use imp::*;
84
85#[cfg(not(target_has_atomic = "64"))]
86fn lock_sync_mutex<T>(mutex: &SyncMutex<T>) -> SyncMutexGuard<'_, T> {
87    crate::sync_compat::lock(mutex)
88}
89
90#[cfg(all(feature = "std", debug_assertions))]
91fn format_timestamp(time: CuDuration) -> String {
92    // Render CuTime/CuDuration as HH:mm:ss.xxxx (4 fractional digits of a second).
93    let nanos = time.as_nanos();
94    let total_seconds = nanos / 1_000_000_000;
95    let hours = total_seconds / 3600;
96    let minutes = (total_seconds / 60) % 60;
97    let seconds = total_seconds % 60;
98    let fractional_1e4 = (nanos % 1_000_000_000) / 100_000;
99    format!("{hours:02}:{minutes:02}:{seconds:02}.{fractional_1e4:04}")
100}
101
102/// Lifecycle state of a monitored component.
103#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
104pub enum CuComponentState {
105    Start,
106    Preprocess,
107    Process,
108    Postprocess,
109    Stop,
110}
111
112/// Strongly-typed index into [`CuMonitoringMetadata::components`].
113#[repr(transparent)]
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115pub struct ComponentId(usize);
116
117impl ComponentId {
118    pub const INVALID: Self = Self(usize::MAX);
119
120    #[inline]
121    pub const fn new(index: usize) -> Self {
122        Self(index)
123    }
124
125    #[inline]
126    pub const fn index(self) -> usize {
127        self.0
128    }
129}
130
131impl core::fmt::Display for ComponentId {
132    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
133        self.0.fmt(f)
134    }
135}
136
137impl From<ComponentId> for usize {
138    fn from(value: ComponentId) -> Self {
139        value.index()
140    }
141}
142
143/// Strongly-typed CopperList slot index.
144#[repr(transparent)]
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct CuListSlot(usize);
147
148impl CuListSlot {
149    #[inline]
150    pub const fn new(index: usize) -> Self {
151        Self(index)
152    }
153
154    #[inline]
155    pub const fn index(self) -> usize {
156        self.0
157    }
158}
159
160impl From<CuListSlot> for usize {
161    fn from(value: CuListSlot) -> Self {
162        value.index()
163    }
164}
165
166/// Static monitor-side CopperList indexing layout.
167///
168/// This layout is mission/runtime scoped and remains constant after monitor construction.
169#[derive(Debug, Clone, Copy)]
170pub struct CopperListLayout {
171    components: &'static [MonitorComponentMetadata],
172    slot_to_component: &'static [ComponentId],
173}
174
175impl CopperListLayout {
176    #[inline]
177    pub const fn new(
178        components: &'static [MonitorComponentMetadata],
179        slot_to_component: &'static [ComponentId],
180    ) -> Self {
181        Self {
182            components,
183            slot_to_component,
184        }
185    }
186
187    #[inline]
188    pub const fn components(self) -> &'static [MonitorComponentMetadata] {
189        self.components
190    }
191
192    #[inline]
193    pub const fn component_count(self) -> usize {
194        self.components.len()
195    }
196
197    #[inline]
198    pub const fn culist_slot_count(self) -> usize {
199        self.slot_to_component.len()
200    }
201
202    #[inline]
203    pub fn component(self, id: ComponentId) -> &'static MonitorComponentMetadata {
204        &self.components[id.index()]
205    }
206
207    #[inline]
208    pub fn component_for_slot(self, culist_slot: CuListSlot) -> ComponentId {
209        self.slot_to_component[culist_slot.index()]
210    }
211
212    #[inline]
213    pub const fn slot_to_component(self) -> &'static [ComponentId] {
214        self.slot_to_component
215    }
216
217    #[inline]
218    pub fn view<'a>(self, msgs: &'a [&'a CuMsgMetadata]) -> CopperListView<'a> {
219        CopperListView::new(self, msgs)
220    }
221}
222
223/// Per-loop monitor view over CopperList metadata paired with static component mapping.
224#[derive(Debug, Clone, Copy)]
225pub struct CopperListView<'a> {
226    layout: CopperListLayout,
227    msgs: &'a [&'a CuMsgMetadata],
228}
229
230impl<'a> CopperListView<'a> {
231    #[inline]
232    pub fn new(layout: CopperListLayout, msgs: &'a [&'a CuMsgMetadata]) -> Self {
233        assert_eq!(
234            msgs.len(),
235            layout.culist_slot_count(),
236            "invalid monitor CopperList view: msgs len {} != slot mapping len {}",
237            msgs.len(),
238            layout.culist_slot_count()
239        );
240        Self { layout, msgs }
241    }
242
243    #[inline]
244    pub const fn layout(self) -> CopperListLayout {
245        self.layout
246    }
247
248    #[inline]
249    pub const fn msgs(self) -> &'a [&'a CuMsgMetadata] {
250        self.msgs
251    }
252
253    #[inline]
254    pub const fn len(self) -> usize {
255        self.msgs.len()
256    }
257
258    #[inline]
259    pub const fn is_empty(self) -> bool {
260        self.msgs.is_empty()
261    }
262
263    #[inline]
264    pub fn entry(self, culist_slot: CuListSlot) -> CopperListEntry<'a> {
265        let index = culist_slot.index();
266        CopperListEntry {
267            culist_slot,
268            component_id: self.layout.component_for_slot(culist_slot),
269            msg: self.msgs[index],
270        }
271    }
272
273    pub fn entries(self) -> impl Iterator<Item = CopperListEntry<'a>> + 'a {
274        self.msgs.iter().enumerate().map(move |(idx, msg)| {
275            let culist_slot = CuListSlot::new(idx);
276            CopperListEntry {
277                culist_slot,
278                component_id: self.layout.component_for_slot(culist_slot),
279                msg,
280            }
281        })
282    }
283}
284
285/// One message entry in CopperList slot order with resolved component identity.
286#[derive(Debug, Clone, Copy)]
287pub struct CopperListEntry<'a> {
288    pub culist_slot: CuListSlot,
289    pub component_id: ComponentId,
290    pub msg: &'a CuMsgMetadata,
291}
292
293impl<'a> CopperListEntry<'a> {
294    #[inline]
295    pub fn component(self, layout: CopperListLayout) -> &'static MonitorComponentMetadata {
296        layout.component(self.component_id)
297    }
298
299    #[inline]
300    pub fn component_type(self, layout: CopperListLayout) -> ComponentType {
301        layout.component(self.component_id).kind()
302    }
303}
304
305/// Execution progress marker emitted by the runtime before running a component step.
306#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
307pub struct ExecutionMarker {
308    /// Index into `CuMonitoringMetadata::components()`.
309    pub component_id: ComponentId,
310    /// Lifecycle phase currently entered.
311    pub step: CuComponentState,
312    /// CopperList id when available (runtime loop), None during start/stop.
313    pub culistid: Option<u64>,
314}
315
316/// Lock-free runtime-side progress probe.
317///
318/// The runtime writes execution markers directly into this probe from the hot path
319/// (without calling monitor fan-out callbacks), and monitors can read a coherent
320/// snapshot from watchdog threads when diagnosing stalls.
321#[derive(Debug)]
322pub struct RuntimeExecutionProbe {
323    component_id: AtomicUsize,
324    step: AtomicUsize,
325    #[cfg(target_has_atomic = "64")]
326    culistid: AtomicU64,
327    #[cfg(target_has_atomic = "64")]
328    culistid_present: AtomicUsize,
329    #[cfg(not(target_has_atomic = "64"))]
330    culistid: SyncMutex<Option<u64>>,
331    sequence: AtomicUsize,
332}
333
334impl Default for RuntimeExecutionProbe {
335    fn default() -> Self {
336        Self {
337            component_id: AtomicUsize::new(ComponentId::INVALID.index()),
338            step: AtomicUsize::new(0),
339            #[cfg(target_has_atomic = "64")]
340            culistid: AtomicU64::new(0),
341            #[cfg(target_has_atomic = "64")]
342            culistid_present: AtomicUsize::new(0),
343            #[cfg(not(target_has_atomic = "64"))]
344            culistid: SyncMutex::new(None),
345            sequence: AtomicUsize::new(0),
346        }
347    }
348}
349
350impl RuntimeExecutionProbe {
351    #[inline]
352    pub fn record(&self, marker: ExecutionMarker) {
353        self.component_id
354            .store(marker.component_id.index(), Ordering::Relaxed);
355        self.step
356            .store(component_state_to_usize(marker.step), Ordering::Relaxed);
357        #[cfg(target_has_atomic = "64")]
358        match marker.culistid {
359            Some(culistid) => {
360                self.culistid.store(culistid, Ordering::Relaxed);
361                self.culistid_present.store(1, Ordering::Relaxed);
362            }
363            None => {
364                self.culistid_present.store(0, Ordering::Relaxed);
365            }
366        }
367        #[cfg(not(target_has_atomic = "64"))]
368        {
369            *lock_sync_mutex(&self.culistid) = marker.culistid;
370        }
371        self.sequence.fetch_add(1, Ordering::Release);
372    }
373
374    #[inline]
375    pub fn sequence(&self) -> usize {
376        self.sequence.load(Ordering::Acquire)
377    }
378
379    #[inline]
380    pub fn marker(&self) -> Option<ExecutionMarker> {
381        // Read a coherent snapshot. A concurrent writer may change values between reads;
382        // in that case we retry to keep the marker and sequence aligned.
383        loop {
384            let seq_before = self.sequence.load(Ordering::Acquire);
385            let component_id = self.component_id.load(Ordering::Relaxed);
386            let step = self.step.load(Ordering::Relaxed);
387            #[cfg(target_has_atomic = "64")]
388            let culistid_present = self.culistid_present.load(Ordering::Relaxed);
389            #[cfg(target_has_atomic = "64")]
390            let culistid_value = self.culistid.load(Ordering::Relaxed);
391            #[cfg(not(target_has_atomic = "64"))]
392            let culistid = *lock_sync_mutex(&self.culistid);
393            let seq_after = self.sequence.load(Ordering::Acquire);
394            if seq_before == seq_after {
395                if component_id == ComponentId::INVALID.index() {
396                    return None;
397                }
398                let step = usize_to_component_state(step);
399                #[cfg(target_has_atomic = "64")]
400                let culistid = if culistid_present == 0 {
401                    None
402                } else {
403                    Some(culistid_value)
404                };
405                return Some(ExecutionMarker {
406                    component_id: ComponentId::new(component_id),
407                    step,
408                    culistid,
409                });
410            }
411        }
412    }
413}
414
415#[inline]
416const fn component_state_to_usize(step: CuComponentState) -> usize {
417    match step {
418        CuComponentState::Start => 0,
419        CuComponentState::Preprocess => 1,
420        CuComponentState::Process => 2,
421        CuComponentState::Postprocess => 3,
422        CuComponentState::Stop => 4,
423    }
424}
425
426#[inline]
427const fn usize_to_component_state(step: usize) -> CuComponentState {
428    match step {
429        0 => CuComponentState::Start,
430        1 => CuComponentState::Preprocess,
431        2 => CuComponentState::Process,
432        3 => CuComponentState::Postprocess,
433        _ => CuComponentState::Stop,
434    }
435}
436
437#[cfg(feature = "std")]
438pub type ExecutionProbeHandle = Arc<RuntimeExecutionProbe>;
439
440/// Platform-neutral monitor view of runtime execution progress.
441///
442/// In `std` builds this can wrap a shared runtime probe. In `no_std` builds it is currently
443/// unavailable and helper methods return `None`/`false`.
444#[derive(Debug, Clone)]
445pub struct MonitorExecutionProbe {
446    #[cfg(feature = "std")]
447    inner: Option<ExecutionProbeHandle>,
448}
449
450impl Default for MonitorExecutionProbe {
451    fn default() -> Self {
452        Self::unavailable()
453    }
454}
455
456impl MonitorExecutionProbe {
457    #[cfg(feature = "std")]
458    pub fn from_shared(handle: ExecutionProbeHandle) -> Self {
459        Self {
460            inner: Some(handle),
461        }
462    }
463
464    pub const fn unavailable() -> Self {
465        Self {
466            #[cfg(feature = "std")]
467            inner: None,
468        }
469    }
470
471    pub fn is_available(&self) -> bool {
472        #[cfg(feature = "std")]
473        {
474            self.inner.is_some()
475        }
476        #[cfg(not(feature = "std"))]
477        {
478            false
479        }
480    }
481
482    pub fn marker(&self) -> Option<ExecutionMarker> {
483        #[cfg(feature = "std")]
484        {
485            self.inner.as_ref().and_then(|probe| probe.marker())
486        }
487        #[cfg(not(feature = "std"))]
488        {
489            None
490        }
491    }
492
493    pub fn sequence(&self) -> Option<usize> {
494        #[cfg(feature = "std")]
495        {
496            self.inner.as_ref().map(|probe| probe.sequence())
497        }
498        #[cfg(not(feature = "std"))]
499        {
500            None
501        }
502    }
503}
504
505/// Runtime component category used by monitoring metadata and topology.
506///
507/// A "task" is a regular Copper task (lifecycle callbacks + payload processing). A "bridge"
508/// is a monitored bridge-side execution component (bridge nodes and channel endpoints).
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510#[non_exhaustive]
511pub enum ComponentType {
512    Source,
513    Task,
514    Sink,
515    Bridge,
516}
517
518impl ComponentType {
519    pub const fn is_task(self) -> bool {
520        !matches!(self, Self::Bridge)
521    }
522}
523
524/// Static identity entry for one monitored runtime component.
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub struct MonitorComponentMetadata {
527    id: &'static str,
528    kind: ComponentType,
529    type_name: Option<&'static str>,
530}
531
532impl MonitorComponentMetadata {
533    pub const fn new(
534        id: &'static str,
535        kind: ComponentType,
536        type_name: Option<&'static str>,
537    ) -> Self {
538        Self {
539            id,
540            kind,
541            type_name,
542        }
543    }
544
545    /// Stable monitor component id (for logs/debug and joins with runtime markers).
546    pub const fn id(&self) -> &'static str {
547        self.id
548    }
549
550    pub const fn kind(&self) -> ComponentType {
551        self.kind
552    }
553
554    /// Rust type label when available (typically tasks); `None` for synthetic bridge entries.
555    pub const fn type_name(&self) -> Option<&'static str> {
556        self.type_name
557    }
558}
559
560/// Immutable runtime-provided metadata passed once to [`CuMonitor::new`].
561///
562/// This bundles identifiers, deterministic component layout, and monitor-specific config so monitor
563/// construction is explicit and does not need ad-hoc late setters.
564#[derive(Debug, Clone)]
565pub struct CuMonitoringMetadata {
566    mission_id: CompactString,
567    subsystem_id: Option<CompactString>,
568    instance_id: u32,
569    layout: CopperListLayout,
570    copperlist_info: CopperListInfo,
571    topology: MonitorTopology,
572    monitor_config: Option<ComponentConfig>,
573}
574
575impl CuMonitoringMetadata {
576    pub fn new(
577        mission_id: CompactString,
578        components: &'static [MonitorComponentMetadata],
579        culist_component_mapping: &'static [ComponentId],
580        copperlist_info: CopperListInfo,
581        topology: MonitorTopology,
582        monitor_config: Option<ComponentConfig>,
583    ) -> CuResult<Self> {
584        Self::validate_components(components)?;
585        Self::validate_culist_mapping(components.len(), culist_component_mapping)?;
586        Ok(Self {
587            mission_id,
588            subsystem_id: None,
589            instance_id: 0,
590            layout: CopperListLayout::new(components, culist_component_mapping),
591            copperlist_info,
592            topology,
593            monitor_config,
594        })
595    }
596
597    fn validate_components(components: &'static [MonitorComponentMetadata]) -> CuResult<()> {
598        let mut seen_bridge = false;
599        for component in components {
600            match component.kind() {
601                component_type if component_type.is_task() && seen_bridge => {
602                    return Err(CuError::from(
603                        "invalid monitor metadata: task-family components must appear before bridges",
604                    ));
605                }
606                ComponentType::Bridge => seen_bridge = true,
607                _ => {}
608            }
609        }
610        Ok(())
611    }
612
613    fn validate_culist_mapping(
614        components_len: usize,
615        culist_component_mapping: &'static [ComponentId],
616    ) -> CuResult<()> {
617        for component_idx in culist_component_mapping {
618            if component_idx.index() >= components_len {
619                return Err(CuError::from(
620                    "invalid monitor metadata: culist mapping points past components table",
621                ));
622            }
623        }
624        Ok(())
625    }
626
627    /// Active mission identifier for this runtime instance.
628    pub fn mission_id(&self) -> &str {
629        self.mission_id.as_str()
630    }
631
632    /// Compile-time subsystem identifier for this runtime instance when running in a
633    /// multi-Copper deployment.
634    pub fn subsystem_id(&self) -> Option<&str> {
635        self.subsystem_id.as_deref()
636    }
637
638    /// Deployment/runtime instance identity for this runtime instance.
639    pub fn instance_id(&self) -> u32 {
640        self.instance_id
641    }
642
643    /// Canonical table of monitored runtime components.
644    ///
645    /// Ordering is deterministic and mission-scoped: tasks first, then bridge-side components.
646    pub fn components(&self) -> &'static [MonitorComponentMetadata] {
647        self.layout.components()
648    }
649
650    /// Total number of monitored components.
651    pub const fn component_count(&self) -> usize {
652        self.layout.component_count()
653    }
654
655    /// Static runtime layout used to map CopperList slots to components.
656    pub const fn layout(&self) -> CopperListLayout {
657        self.layout
658    }
659
660    pub fn component(&self, component_id: ComponentId) -> &'static MonitorComponentMetadata {
661        self.layout.component(component_id)
662    }
663
664    pub fn component_id(&self, component_id: ComponentId) -> &'static str {
665        self.component(component_id).id()
666    }
667
668    pub fn component_kind(&self, component_id: ComponentId) -> ComponentType {
669        self.component(component_id).kind()
670    }
671
672    pub fn component_index_by_id(&self, component_id: &str) -> Option<ComponentId> {
673        self.layout
674            .components()
675            .iter()
676            .position(|component| component.id() == component_id)
677            .map(ComponentId::new)
678    }
679
680    /// CopperList slot -> monitored component index mapping.
681    ///
682    /// This table maps each CopperList slot index to the producing component index.
683    pub fn culist_component_mapping(&self) -> &'static [ComponentId] {
684        self.layout.slot_to_component()
685    }
686
687    pub fn component_for_culist_slot(&self, culist_slot: CuListSlot) -> ComponentId {
688        self.layout.component_for_slot(culist_slot)
689    }
690
691    pub fn copperlist_view<'a>(&self, msgs: &'a [&'a CuMsgMetadata]) -> CopperListView<'a> {
692        self.layout.view(msgs)
693    }
694
695    pub const fn copperlist_info(&self) -> CopperListInfo {
696        self.copperlist_info
697    }
698
699    /// Resolved graph topology for the active mission.
700    ///
701    /// This is always available. Nodes represent config graph nodes, not every synthetic bridge
702    /// channel entry in `components()`.
703    pub fn topology(&self) -> &MonitorTopology {
704        &self.topology
705    }
706
707    pub fn monitor_config(&self) -> Option<&ComponentConfig> {
708        self.monitor_config.as_ref()
709    }
710
711    pub fn with_monitor_config(mut self, monitor_config: Option<ComponentConfig>) -> Self {
712        self.monitor_config = monitor_config;
713        self
714    }
715
716    pub fn with_subsystem_id(mut self, subsystem_id: Option<&str>) -> Self {
717        self.subsystem_id = subsystem_id.map(CompactString::from);
718        self
719    }
720
721    pub fn with_instance_id(mut self, instance_id: u32) -> Self {
722        self.instance_id = instance_id;
723        self
724    }
725}
726
727/// Runtime-provided dynamic monitoring handles passed once to [`CuMonitor::new`].
728///
729/// This context may expose live runtime state (for example execution progress probes).
730#[derive(Debug, Clone, Default)]
731pub struct CuMonitoringRuntime {
732    execution_probe: MonitorExecutionProbe,
733}
734
735impl CuMonitoringRuntime {
736    #[cfg(feature = "std")]
737    pub fn new(execution_probe: MonitorExecutionProbe) -> Self {
738        ensure_runtime_panic_hook_installed();
739        Self { execution_probe }
740    }
741
742    #[cfg(not(feature = "std"))]
743    pub const fn new(execution_probe: MonitorExecutionProbe) -> Self {
744        Self { execution_probe }
745    }
746
747    #[cfg(feature = "std")]
748    pub fn unavailable() -> Self {
749        Self::new(MonitorExecutionProbe::unavailable())
750    }
751
752    #[cfg(not(feature = "std"))]
753    pub const fn unavailable() -> Self {
754        Self::new(MonitorExecutionProbe::unavailable())
755    }
756
757    pub fn execution_probe(&self) -> &MonitorExecutionProbe {
758        &self.execution_probe
759    }
760
761    #[cfg(feature = "std")]
762    pub fn register_panic_cleanup<F>(&self, callback: F) -> PanicHookRegistration
763    where
764        F: Fn(&PanicReport) + Send + Sync + 'static,
765    {
766        ensure_runtime_panic_hook_installed();
767        register_panic_cleanup(callback)
768    }
769
770    #[cfg(feature = "std")]
771    pub fn register_panic_action<F>(&self, callback: F) -> PanicHookRegistration
772    where
773        F: Fn(&PanicReport) -> Option<i32> + Send + Sync + 'static,
774    {
775        ensure_runtime_panic_hook_installed();
776        register_panic_action(callback)
777    }
778}
779
780#[cfg(feature = "std")]
781type PanicCleanupCallback = Arc<dyn Fn(&PanicReport) + Send + Sync + 'static>;
782#[cfg(feature = "std")]
783type PanicActionCallback = Arc<dyn Fn(&PanicReport) -> Option<i32> + Send + Sync + 'static>;
784
785#[cfg(feature = "std")]
786#[derive(Debug, Clone)]
787pub struct PanicReport {
788    message: String,
789    location: Option<String>,
790    thread_name: Option<String>,
791    backtrace: String,
792    timestamp_unix_ms: u128,
793    crash_report_path: Option<String>,
794}
795
796#[cfg(feature = "std")]
797impl PanicReport {
798    fn capture(info: &PanicHookInfo<'_>) -> Self {
799        let location = info
800            .location()
801            .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()));
802        let thread_name = std::thread::current().name().map(|name| name.to_string());
803        let timestamp_unix_ms = SystemTime::now()
804            .duration_since(UNIX_EPOCH)
805            .map(|dur| dur.as_millis())
806            .unwrap_or(0);
807
808        Self {
809            message: panic_hook_payload_to_string(info),
810            location,
811            thread_name,
812            backtrace: Backtrace::force_capture().to_string(),
813            timestamp_unix_ms,
814            crash_report_path: None,
815        }
816    }
817
818    pub fn message(&self) -> &str {
819        &self.message
820    }
821
822    pub fn location(&self) -> Option<&str> {
823        self.location.as_deref()
824    }
825
826    pub fn thread_name(&self) -> Option<&str> {
827        self.thread_name.as_deref()
828    }
829
830    pub fn backtrace(&self) -> &str {
831        &self.backtrace
832    }
833
834    pub fn timestamp_unix_ms(&self) -> u128 {
835        self.timestamp_unix_ms
836    }
837
838    pub fn crash_report_path(&self) -> Option<&str> {
839        self.crash_report_path.as_deref()
840    }
841
842    pub fn summary(&self) -> String {
843        match self.location() {
844            Some(location) => format!("panic at {location}: {}", self.message()),
845            None => format!("panic: {}", self.message()),
846        }
847    }
848}
849
850#[cfg(feature = "std")]
851#[derive(Clone, Copy, Debug, PartialEq, Eq)]
852enum PanicHookRegistrationKind {
853    Cleanup,
854    Action,
855}
856
857#[cfg(feature = "std")]
858#[derive(Clone)]
859struct RegisteredPanicCleanup {
860    id: usize,
861    callback: PanicCleanupCallback,
862}
863
864#[cfg(feature = "std")]
865#[derive(Clone)]
866struct RegisteredPanicAction {
867    id: usize,
868    callback: PanicActionCallback,
869}
870
871#[cfg(feature = "std")]
872#[derive(Default)]
873struct PanicHookRegistry {
874    cleanup_callbacks: StdMutex<Vec<RegisteredPanicCleanup>>,
875    action_callbacks: StdMutex<Vec<RegisteredPanicAction>>,
876}
877
878#[cfg(feature = "std")]
879#[derive(Debug)]
880pub struct PanicHookRegistration {
881    id: usize,
882    kind: PanicHookRegistrationKind,
883}
884
885#[cfg(feature = "std")]
886impl Drop for PanicHookRegistration {
887    fn drop(&mut self) {
888        unregister_panic_hook(self.kind, self.id);
889    }
890}
891
892#[cfg(feature = "std")]
893static PANIC_HOOK_REGISTRY: OnceLock<PanicHookRegistry> = OnceLock::new();
894#[cfg(feature = "std")]
895static PANIC_HOOK_INSTALL_ONCE: OnceLock<()> = OnceLock::new();
896#[cfg(feature = "std")]
897static PANIC_HOOK_REGISTRATION_ID: AtomicUsize = AtomicUsize::new(1);
898#[cfg(feature = "std")]
899static PANIC_HOOK_ACTIVE_COUNT: AtomicUsize = AtomicUsize::new(0);
900
901#[cfg(feature = "std")]
902fn panic_hook_registry() -> &'static PanicHookRegistry {
903    PANIC_HOOK_REGISTRY.get_or_init(PanicHookRegistry::default)
904}
905
906#[cfg(feature = "std")]
907fn ensure_runtime_panic_hook_installed() {
908    let _ = PANIC_HOOK_INSTALL_ONCE.get_or_init(|| {
909        std::panic::set_hook(Box::new(move |info| {
910            let _guard = PanicHookActiveGuard::new();
911            let mut report = PanicReport::capture(info);
912            run_panic_cleanup_callbacks(&report);
913            report.crash_report_path = write_panic_report_to_file(&report);
914            emit_panic_report(&report);
915
916            if let Some(exit_code) = run_panic_action_callbacks(&report) {
917                std::process::exit(exit_code);
918            }
919        }));
920    });
921}
922
923#[cfg(feature = "std")]
924struct PanicHookActiveGuard;
925
926#[cfg(feature = "std")]
927impl PanicHookActiveGuard {
928    fn new() -> Self {
929        PANIC_HOOK_ACTIVE_COUNT.fetch_add(1, Ordering::SeqCst);
930        Self
931    }
932}
933
934#[cfg(feature = "std")]
935impl Drop for PanicHookActiveGuard {
936    fn drop(&mut self) {
937        PANIC_HOOK_ACTIVE_COUNT.fetch_sub(1, Ordering::SeqCst);
938    }
939}
940
941#[cfg(feature = "std")]
942pub fn runtime_panic_hook_active() -> bool {
943    PANIC_HOOK_ACTIVE_COUNT.load(Ordering::SeqCst) > 0
944}
945
946#[cfg(not(feature = "std"))]
947pub const fn runtime_panic_hook_active() -> bool {
948    false
949}
950
951#[cfg(feature = "std")]
952fn register_panic_cleanup<F>(callback: F) -> PanicHookRegistration
953where
954    F: Fn(&PanicReport) + Send + Sync + 'static,
955{
956    let id = PANIC_HOOK_REGISTRATION_ID.fetch_add(1, Ordering::Relaxed);
957    let callback = Arc::new(callback) as PanicCleanupCallback;
958    let mut callbacks = panic_hook_registry()
959        .cleanup_callbacks
960        .lock()
961        .unwrap_or_else(|poison| poison.into_inner());
962    callbacks.push(RegisteredPanicCleanup { id, callback });
963    PanicHookRegistration {
964        id,
965        kind: PanicHookRegistrationKind::Cleanup,
966    }
967}
968
969#[cfg(feature = "std")]
970fn register_panic_action<F>(callback: F) -> PanicHookRegistration
971where
972    F: Fn(&PanicReport) -> Option<i32> + Send + Sync + 'static,
973{
974    let id = PANIC_HOOK_REGISTRATION_ID.fetch_add(1, Ordering::Relaxed);
975    let callback = Arc::new(callback) as PanicActionCallback;
976    let mut callbacks = panic_hook_registry()
977        .action_callbacks
978        .lock()
979        .unwrap_or_else(|poison| poison.into_inner());
980    callbacks.push(RegisteredPanicAction { id, callback });
981    PanicHookRegistration {
982        id,
983        kind: PanicHookRegistrationKind::Action,
984    }
985}
986
987#[cfg(feature = "std")]
988fn unregister_panic_hook(kind: PanicHookRegistrationKind, id: usize) {
989    let registry = panic_hook_registry();
990    match kind {
991        PanicHookRegistrationKind::Cleanup => {
992            let mut callbacks = registry
993                .cleanup_callbacks
994                .lock()
995                .unwrap_or_else(|poison| poison.into_inner());
996            callbacks.retain(|entry| entry.id != id);
997        }
998        PanicHookRegistrationKind::Action => {
999            let mut callbacks = registry
1000                .action_callbacks
1001                .lock()
1002                .unwrap_or_else(|poison| poison.into_inner());
1003            callbacks.retain(|entry| entry.id != id);
1004        }
1005    }
1006}
1007
1008#[cfg(feature = "std")]
1009fn run_panic_cleanup_callbacks(report: &PanicReport) {
1010    let callbacks = panic_hook_registry()
1011        .cleanup_callbacks
1012        .lock()
1013        .unwrap_or_else(|poison| poison.into_inner())
1014        .clone();
1015    for entry in callbacks {
1016        (entry.callback)(report);
1017    }
1018}
1019
1020#[cfg(feature = "std")]
1021fn run_panic_action_callbacks(report: &PanicReport) -> Option<i32> {
1022    let callbacks = panic_hook_registry()
1023        .action_callbacks
1024        .lock()
1025        .unwrap_or_else(|poison| poison.into_inner())
1026        .clone();
1027    let mut exit_code = None;
1028    for entry in callbacks {
1029        if exit_code.is_none() {
1030            exit_code = (entry.callback)(report);
1031        } else {
1032            let _ = (entry.callback)(report);
1033        }
1034    }
1035    exit_code
1036}
1037
1038#[cfg(feature = "std")]
1039fn panic_hook_payload_to_string(info: &PanicHookInfo<'_>) -> String {
1040    if let Some(msg) = info.payload().downcast_ref::<&str>() {
1041        (*msg).to_string()
1042    } else if let Some(msg) = info.payload().downcast_ref::<String>() {
1043        msg.clone()
1044    } else {
1045        "panic with non-string payload".to_string()
1046    }
1047}
1048
1049#[cfg(feature = "std")]
1050fn render_panic_report(report: &PanicReport) -> String {
1051    let mut rendered = String::from("Copper panic\n");
1052    rendered.push_str(&format!("time_unix_ms: {}\n", report.timestamp_unix_ms()));
1053    rendered.push_str(&format!(
1054        "thread: {}\n",
1055        report.thread_name().unwrap_or("<unnamed>")
1056    ));
1057    if let Some(location) = report.location() {
1058        rendered.push_str(&format!("location: {location}\n"));
1059    }
1060    rendered.push_str(&format!("message: {}\n", report.message()));
1061    if let Some(path) = report.crash_report_path() {
1062        rendered.push_str(&format!("crash_report: {path}\n"));
1063    }
1064    rendered.push_str("\nBacktrace:\n");
1065    rendered.push_str(report.backtrace());
1066    if !report.backtrace().ends_with('\n') {
1067        rendered.push('\n');
1068    }
1069    rendered
1070}
1071
1072#[cfg(feature = "std")]
1073fn emit_panic_report(report: &PanicReport) {
1074    let mut stderr = std::io::stderr().lock();
1075    let _ = stderr.write_all(render_panic_report(report).as_bytes());
1076    let _ = stderr.flush();
1077}
1078
1079#[cfg(feature = "std")]
1080fn write_panic_report_to_file(report: &PanicReport) -> Option<String> {
1081    let cwd = std::env::current_dir().ok()?;
1082    let file_name = format!(
1083        "copper-crash-{}-{}.txt",
1084        report.timestamp_unix_ms(),
1085        std::process::id()
1086    );
1087    let path = cwd.join(file_name);
1088    let path_string = path.to_string_lossy().to_string();
1089    let mut file = File::create(&path).ok()?;
1090    let mut report_with_path = report.clone();
1091    report_with_path.crash_report_path = Some(path_string.clone());
1092    file.write_all(render_panic_report(&report_with_path).as_bytes())
1093        .ok()?;
1094    file.flush().ok()?;
1095    Some(path_string)
1096}
1097
1098/// Monitor decision to be taken when a component step errored out.
1099#[derive(Debug)]
1100pub enum Decision {
1101    Abort,    // for a step (stop, start) or a copperlist, just stop trying to process it.
1102    Ignore, // Ignore this error and try to continue, ie calling the other component steps, setting a None return value and continue a copperlist.
1103    Shutdown, // This is a fatal error, shutdown the copper as cleanly as possible.
1104}
1105
1106fn merge_decision(lhs: Decision, rhs: Decision) -> Decision {
1107    use Decision::{Abort, Ignore, Shutdown};
1108    // Pick the strictest monitor decision when multiple monitors disagree.
1109    // Shutdown dominates Abort, which dominates Ignore.
1110    match (lhs, rhs) {
1111        (Shutdown, _) | (_, Shutdown) => Shutdown,
1112        (Abort, _) | (_, Abort) => Abort,
1113        _ => Ignore,
1114    }
1115}
1116
1117#[derive(Debug, Clone)]
1118pub struct MonitorNode {
1119    pub id: String,
1120    pub type_name: Option<String>,
1121    pub kind: ComponentType,
1122    /// Ordered list of input port identifiers.
1123    pub inputs: Vec<String>,
1124    /// Ordered list of output port identifiers.
1125    pub outputs: Vec<String>,
1126}
1127
1128#[derive(Debug, Clone)]
1129pub struct MonitorConnection {
1130    pub src: String,
1131    pub src_port: Option<String>,
1132    pub dst: String,
1133    pub dst_port: Option<String>,
1134    pub msg: String,
1135}
1136
1137#[derive(Debug, Clone, Default)]
1138pub struct MonitorTopology {
1139    pub nodes: Vec<MonitorNode>,
1140    pub connections: Vec<MonitorConnection>,
1141}
1142
1143#[derive(Debug, Clone, Copy, Default)]
1144pub struct CopperListInfo {
1145    pub size_bytes: usize,
1146    pub count: usize,
1147}
1148
1149impl CopperListInfo {
1150    pub const fn new(size_bytes: usize, count: usize) -> Self {
1151        Self { size_bytes, count }
1152    }
1153}
1154
1155/// Reported data about CopperList IO for a single iteration.
1156#[derive(Debug, Clone, Copy, Default)]
1157pub struct CopperListIoStats {
1158    /// CopperList bytes resident in RAM for this iteration.
1159    ///
1160    /// This includes the fixed CopperList struct size plus any pooled or
1161    /// handle-backed payload bytes observed on the real encode path.
1162    pub raw_culist_bytes: u64,
1163    /// Bytes attributed to handle-backed storage while measuring payload IO.
1164    ///
1165    /// This is surfaced separately so monitors can show how much of the runtime
1166    /// footprint lives in pooled payload buffers rather than inside the fixed
1167    /// CopperList struct.
1168    pub handle_bytes: u64,
1169    /// Bytes produced by bincode serialization of the CopperList
1170    pub encoded_culist_bytes: u64,
1171    /// Bytes produced by bincode serialization of the KeyFrame (0 if none)
1172    pub keyframe_bytes: u64,
1173    /// Cumulative bytes written to the structured log stream so far
1174    pub structured_log_bytes_total: u64,
1175    /// CopperList identifier for reference in monitors
1176    pub culistid: u64,
1177}
1178
1179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1180pub struct PayloadIoStats {
1181    pub resident_bytes: usize,
1182    pub encoded_bytes: usize,
1183    pub handle_bytes: usize,
1184}
1185
1186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1187pub struct CuMsgIoStats {
1188    pub present: bool,
1189    pub resident_bytes: u64,
1190    pub encoded_bytes: u64,
1191    pub handle_bytes: u64,
1192}
1193
1194struct CuMsgIoEntry {
1195    present: PortableAtomicBool,
1196    resident_bytes: PortableAtomicU64,
1197    encoded_bytes: PortableAtomicU64,
1198    handle_bytes: PortableAtomicU64,
1199}
1200
1201impl CuMsgIoEntry {
1202    fn clear(&self) {
1203        self.present.store(false, PortableOrdering::Release);
1204        self.resident_bytes.store(0, PortableOrdering::Relaxed);
1205        self.encoded_bytes.store(0, PortableOrdering::Relaxed);
1206        self.handle_bytes.store(0, PortableOrdering::Relaxed);
1207    }
1208
1209    fn get(&self) -> CuMsgIoStats {
1210        if !self.present.load(PortableOrdering::Acquire) {
1211            return CuMsgIoStats::default();
1212        }
1213
1214        CuMsgIoStats {
1215            present: true,
1216            resident_bytes: self.resident_bytes.load(PortableOrdering::Relaxed),
1217            encoded_bytes: self.encoded_bytes.load(PortableOrdering::Relaxed),
1218            handle_bytes: self.handle_bytes.load(PortableOrdering::Relaxed),
1219        }
1220    }
1221
1222    fn set(&self, stats: CuMsgIoStats) {
1223        self.resident_bytes
1224            .store(stats.resident_bytes, PortableOrdering::Relaxed);
1225        self.encoded_bytes
1226            .store(stats.encoded_bytes, PortableOrdering::Relaxed);
1227        self.handle_bytes
1228            .store(stats.handle_bytes, PortableOrdering::Relaxed);
1229        self.present.store(stats.present, PortableOrdering::Release);
1230    }
1231}
1232
1233impl Default for CuMsgIoEntry {
1234    fn default() -> Self {
1235        Self {
1236            present: PortableAtomicBool::new(false),
1237            resident_bytes: PortableAtomicU64::new(0),
1238            encoded_bytes: PortableAtomicU64::new(0),
1239            handle_bytes: PortableAtomicU64::new(0),
1240        }
1241    }
1242}
1243
1244pub struct CuMsgIoCache<const N: usize> {
1245    entries: [CuMsgIoEntry; N],
1246}
1247
1248impl<const N: usize> CuMsgIoCache<N> {
1249    pub fn clear(&self) {
1250        for entry in &self.entries {
1251            entry.clear();
1252        }
1253    }
1254
1255    pub fn get(&self, idx: usize) -> CuMsgIoStats {
1256        self.entries[idx].get()
1257    }
1258
1259    fn raw_parts(&self) -> (usize, usize) {
1260        (self.entries.as_ptr() as usize, N)
1261    }
1262}
1263
1264impl<const N: usize> Default for CuMsgIoCache<N> {
1265    fn default() -> Self {
1266        Self {
1267            entries: core::array::from_fn(|_| CuMsgIoEntry::default()),
1268        }
1269    }
1270}
1271
1272#[derive(Clone, Copy)]
1273struct ActiveCuMsgIoCapture {
1274    cache_addr: usize,
1275    cache_len: usize,
1276    current_slot: Option<usize>,
1277}
1278
1279#[cfg(feature = "std")]
1280thread_local! {
1281    static PAYLOAD_HANDLE_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
1282    static ACTIVE_COPPERLIST_CAPTURE: Cell<Option<ActiveCuMsgIoCapture>> = const { Cell::new(None) };
1283    static LAST_COMPLETED_HANDLE_BYTES: Cell<u64> = const { Cell::new(0) };
1284}
1285
1286#[cfg(not(feature = "std"))]
1287static PAYLOAD_HANDLE_BYTES: SyncMutex<Option<usize>> = SyncMutex::new(None);
1288#[cfg(not(feature = "std"))]
1289static ACTIVE_COPPERLIST_CAPTURE: SyncMutex<Option<ActiveCuMsgIoCapture>> = SyncMutex::new(None);
1290#[cfg(not(feature = "std"))]
1291static LAST_COMPLETED_HANDLE_BYTES: SyncMutex<u64> = SyncMutex::new(0);
1292
1293fn begin_payload_io_measurement() {
1294    #[cfg(feature = "std")]
1295    PAYLOAD_HANDLE_BYTES.with(|bytes| {
1296        debug_assert!(
1297            bytes.get().is_none(),
1298            "payload IO byte measurement must not be nested"
1299        );
1300        bytes.set(Some(0));
1301    });
1302
1303    #[cfg(not(feature = "std"))]
1304    {
1305        let mut bytes = PAYLOAD_HANDLE_BYTES.lock();
1306        debug_assert!(
1307            bytes.is_none(),
1308            "payload IO byte measurement must not be nested"
1309        );
1310        *bytes = Some(0);
1311    }
1312}
1313
1314fn finish_payload_io_measurement() -> usize {
1315    #[cfg(feature = "std")]
1316    {
1317        PAYLOAD_HANDLE_BYTES.with(|bytes| bytes.replace(None).unwrap_or(0))
1318    }
1319
1320    #[cfg(not(feature = "std"))]
1321    {
1322        PAYLOAD_HANDLE_BYTES.lock().take().unwrap_or(0)
1323    }
1324}
1325
1326fn abort_payload_io_measurement() {
1327    #[cfg(feature = "std")]
1328    PAYLOAD_HANDLE_BYTES.with(|bytes| bytes.set(None));
1329
1330    #[cfg(not(feature = "std"))]
1331    {
1332        *PAYLOAD_HANDLE_BYTES.lock() = None;
1333    }
1334}
1335
1336fn current_payload_io_measurement() -> usize {
1337    #[cfg(feature = "std")]
1338    {
1339        PAYLOAD_HANDLE_BYTES.with(|bytes| bytes.get().unwrap_or(0))
1340    }
1341
1342    #[cfg(not(feature = "std"))]
1343    {
1344        PAYLOAD_HANDLE_BYTES.lock().as_ref().copied().unwrap_or(0)
1345    }
1346}
1347
1348/// Records handle-backed payload bytes for the active CopperList IO capture.
1349///
1350/// This is called automatically by handle encoders such as `CuHandle::encode()`.
1351/// Custom log codecs report equivalent bytes via `CuLogCodec::source_payload_handle_bytes()`.
1352pub(crate) fn record_payload_handle_bytes(bytes: usize) {
1353    #[cfg(feature = "std")]
1354    PAYLOAD_HANDLE_BYTES.with(|total| {
1355        if let Some(current) = total.get() {
1356            total.set(Some(current.saturating_add(bytes)));
1357        }
1358    });
1359
1360    #[cfg(not(feature = "std"))]
1361    {
1362        let mut total = PAYLOAD_HANDLE_BYTES.lock();
1363        if let Some(current) = *total {
1364            *total = Some(current.saturating_add(bytes));
1365        }
1366    }
1367}
1368
1369fn set_last_completed_handle_bytes(bytes: u64) {
1370    #[cfg(feature = "std")]
1371    LAST_COMPLETED_HANDLE_BYTES.with(|total| total.set(bytes));
1372
1373    #[cfg(not(feature = "std"))]
1374    {
1375        *LAST_COMPLETED_HANDLE_BYTES.lock() = bytes;
1376    }
1377}
1378
1379pub fn take_last_completed_handle_bytes() -> u64 {
1380    #[cfg(feature = "std")]
1381    {
1382        LAST_COMPLETED_HANDLE_BYTES.with(|total| total.replace(0))
1383    }
1384
1385    #[cfg(not(feature = "std"))]
1386    {
1387        let mut total = LAST_COMPLETED_HANDLE_BYTES.lock();
1388        let value = *total;
1389        *total = 0;
1390        value
1391    }
1392}
1393
1394fn with_active_capture_mut<R>(f: impl FnOnce(&mut ActiveCuMsgIoCapture) -> R) -> Option<R> {
1395    #[cfg(feature = "std")]
1396    {
1397        ACTIVE_COPPERLIST_CAPTURE.with(|capture| {
1398            let mut state = capture.get()?;
1399            let result = f(&mut state);
1400            capture.set(Some(state));
1401            Some(result)
1402        })
1403    }
1404
1405    #[cfg(not(feature = "std"))]
1406    {
1407        let mut capture = ACTIVE_COPPERLIST_CAPTURE.lock();
1408        let state = capture.as_mut()?;
1409        Some(f(state))
1410    }
1411}
1412
1413pub struct CuMsgIoCaptureGuard;
1414
1415impl CuMsgIoCaptureGuard {
1416    pub fn select_slot(&self, slot: usize) {
1417        let _ = with_active_capture_mut(|capture| {
1418            debug_assert!(slot < capture.cache_len, "payload IO slot out of range");
1419            capture.current_slot = Some(slot);
1420        });
1421    }
1422}
1423
1424impl Drop for CuMsgIoCaptureGuard {
1425    fn drop(&mut self) {
1426        set_last_completed_handle_bytes(finish_payload_io_measurement() as u64);
1427
1428        #[cfg(feature = "std")]
1429        ACTIVE_COPPERLIST_CAPTURE.with(|capture| capture.set(None));
1430
1431        #[cfg(not(feature = "std"))]
1432        {
1433            *ACTIVE_COPPERLIST_CAPTURE.lock() = None;
1434        }
1435    }
1436}
1437
1438pub fn start_copperlist_io_capture<const N: usize>(cache: &CuMsgIoCache<N>) -> CuMsgIoCaptureGuard {
1439    cache.clear();
1440    set_last_completed_handle_bytes(0);
1441    begin_payload_io_measurement();
1442    let (cache_addr, cache_len) = cache.raw_parts();
1443    let capture = ActiveCuMsgIoCapture {
1444        cache_addr,
1445        cache_len,
1446        current_slot: None,
1447    };
1448
1449    #[cfg(feature = "std")]
1450    ACTIVE_COPPERLIST_CAPTURE.with(|state| {
1451        debug_assert!(
1452            state.get().is_none(),
1453            "CopperList payload IO capture must not be nested"
1454        );
1455        state.set(Some(capture));
1456    });
1457
1458    #[cfg(not(feature = "std"))]
1459    {
1460        let mut state = ACTIVE_COPPERLIST_CAPTURE.lock();
1461        debug_assert!(
1462            state.is_none(),
1463            "CopperList payload IO capture must not be nested"
1464        );
1465        *state = Some(capture);
1466    }
1467
1468    CuMsgIoCaptureGuard
1469}
1470
1471pub(crate) fn current_payload_handle_bytes() -> usize {
1472    current_payload_io_measurement()
1473}
1474
1475pub(crate) fn record_current_slot_payload_io_stats(
1476    fixed_bytes: usize,
1477    encoded_bytes: usize,
1478    handle_bytes: usize,
1479) {
1480    let _ = with_active_capture_mut(|capture| {
1481        let Some(slot) = capture.current_slot else {
1482            return;
1483        };
1484        if slot >= capture.cache_len {
1485            return;
1486        }
1487        // SAFETY: the capture guard holds the cache alive for the duration of the encode pass.
1488        let cache_ptr = capture.cache_addr as *const CuMsgIoEntry;
1489        let entry = unsafe { &*cache_ptr.add(slot) };
1490        entry.set(CuMsgIoStats {
1491            present: true,
1492            resident_bytes: (fixed_bytes.saturating_add(handle_bytes)) as u64,
1493            encoded_bytes: encoded_bytes as u64,
1494            handle_bytes: handle_bytes as u64,
1495        });
1496    });
1497}
1498
1499/// Measures payload bytes using the same encode path Copper uses for
1500/// logging/export.
1501///
1502/// `resident_bytes` is the payload's in-memory fixed footprint plus any
1503/// handle-backed dynamic storage reported during encoding. `encoded_bytes` is
1504/// the exact bincode payload size.
1505pub fn payload_io_stats<T>(payload: &T) -> CuResult<PayloadIoStats>
1506where
1507    T: Encode,
1508{
1509    begin_payload_io_measurement();
1510    begin_observed_encode();
1511
1512    let result = (|| {
1513        let mut encoder =
1514            EncoderImpl::<_, _>::new(ObservedWriter::new(SizeWriter::default()), standard());
1515        payload.encode(&mut encoder).map_err(|e| {
1516            CuError::from("Failed to measure payload IO bytes").add_cause(&e.to_string())
1517        })?;
1518        let encoded_bytes = encoder.into_writer().into_inner().bytes_written;
1519        debug_assert_eq!(encoded_bytes, finish_observed_encode());
1520        let handle_bytes = finish_payload_io_measurement();
1521        Ok(PayloadIoStats {
1522            resident_bytes: core::mem::size_of::<T>().saturating_add(handle_bytes),
1523            encoded_bytes,
1524            handle_bytes,
1525        })
1526    })();
1527
1528    if result.is_err() {
1529        abort_payload_io_measurement();
1530        abort_observed_encode();
1531    }
1532
1533    result
1534}
1535
1536fn collect_output_ports(graph: &CuGraph, node_id: NodeId) -> Vec<(String, String)> {
1537    let Ok(msg_types) = graph.get_node_output_msg_types_by_id(node_id) else {
1538        return Vec::new();
1539    };
1540
1541    let mut outputs = Vec::new();
1542    for (port_idx, msg) in msg_types.into_iter().enumerate() {
1543        let mut port_label = String::from("out");
1544        port_label.push_str(&port_idx.to_string());
1545        port_label.push_str(": ");
1546        port_label.push_str(msg.as_str());
1547        outputs.push((msg, port_label));
1548    }
1549    outputs
1550}
1551
1552/// Derive a monitor-friendly topology from the runtime configuration.
1553pub fn build_monitor_topology(config: &CuConfig, mission: &str) -> CuResult<MonitorTopology> {
1554    let graph = config.get_graph(Some(mission))?;
1555    let mut nodes: Map<String, MonitorNode> = Map::new();
1556    let mut output_port_lookup: Map<String, Map<String, String>> = Map::new();
1557
1558    let mut bridge_lookup: Map<&str, &BridgeConfig> = Map::new();
1559    for bridge in &config.bridges {
1560        bridge_lookup.insert(bridge.id.as_str(), bridge);
1561    }
1562
1563    for (node_idx, node) in graph.get_all_nodes() {
1564        let node_id = node.get_id();
1565        let task_kind = match node.get_flavor() {
1566            Flavor::Bridge => ComponentType::Bridge,
1567            Flavor::Task => match resolve_task_kind_for_id(graph, node_idx)? {
1568                TaskKind::Source => ComponentType::Source,
1569                TaskKind::Regular => ComponentType::Task,
1570                TaskKind::Sink => ComponentType::Sink,
1571            },
1572        };
1573
1574        let mut inputs = Vec::new();
1575        let mut outputs = Vec::new();
1576        if task_kind == ComponentType::Bridge
1577            && let Some(bridge) = bridge_lookup.get(node_id.as_str())
1578        {
1579            for ch in &bridge.channels {
1580                match ch {
1581                    BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
1582                    BridgeChannelConfigRepresentation::Tx { id, .. } => inputs.push(id.clone()),
1583                }
1584            }
1585        } else {
1586            match task_kind {
1587                ComponentType::Source => {
1588                    let ports = collect_output_ports(graph, node_idx);
1589                    let mut port_map: Map<String, String> = Map::new();
1590                    for (msg_type, label) in ports {
1591                        port_map.insert(msg_type, label.clone());
1592                        outputs.push(label);
1593                    }
1594                    output_port_lookup.insert(node_id.clone(), port_map);
1595                }
1596                ComponentType::Task => {
1597                    inputs.push("in".to_string());
1598                    let ports = collect_output_ports(graph, node_idx);
1599                    let mut port_map: Map<String, String> = Map::new();
1600                    for (msg_type, label) in ports {
1601                        port_map.insert(msg_type, label.clone());
1602                        outputs.push(label);
1603                    }
1604                    output_port_lookup.insert(node_id.clone(), port_map);
1605                }
1606                ComponentType::Sink => {
1607                    inputs.push("in".to_string());
1608                }
1609                ComponentType::Bridge => unreachable!("handled above"),
1610            }
1611        }
1612
1613        nodes.insert(
1614            node_id.clone(),
1615            MonitorNode {
1616                id: node_id,
1617                type_name: Some(node.get_type().to_string()),
1618                kind: task_kind,
1619                inputs,
1620                outputs,
1621            },
1622        );
1623    }
1624
1625    let mut connections = Vec::new();
1626    for cnx in graph.edges() {
1627        let src = cnx.src.clone();
1628        let dst = cnx.dst.clone();
1629
1630        let src_port = cnx.src_channel.clone().or_else(|| {
1631            output_port_lookup
1632                .get(&src)
1633                .and_then(|ports| ports.get(&cnx.msg).cloned())
1634                .or_else(|| {
1635                    nodes
1636                        .get(&src)
1637                        .and_then(|node| node.outputs.first().cloned())
1638                })
1639        });
1640        let dst_port = cnx.dst_channel.clone().or_else(|| {
1641            nodes
1642                .get(&dst)
1643                .and_then(|node| node.inputs.first().cloned())
1644        });
1645
1646        connections.push(MonitorConnection {
1647            src,
1648            src_port,
1649            dst,
1650            dst_port,
1651            msg: cnx.msg.clone(),
1652        });
1653    }
1654
1655    Ok(MonitorTopology {
1656        nodes: nodes.into_values().collect(),
1657        connections,
1658    })
1659}
1660
1661/// Runtime monitoring contract implemented by monitor components.
1662///
1663/// Lifecycle:
1664/// 1. [`CuMonitor::new`] is called once at runtime construction time.
1665/// 2. [`CuMonitor::start`] is called once before the first runtime iteration.
1666/// 3. For each iteration, [`CuMonitor::process_copperlist`] is called after component execution,
1667///    then [`CuMonitor::observe_copperlist_io`] after serialization accounting.
1668/// 4. [`CuMonitor::process_error`] is called synchronously when a monitored component step fails.
1669/// 5. [`CuMonitor::process_panic`] is called when the runtime catches a panic (`std` builds).
1670/// 6. [`CuMonitor::stop`] is called once during runtime shutdown.
1671///
1672/// Indexing model:
1673/// - `process_error(component_id, ..)` uses component indices into `metadata.components()`.
1674/// - `process_copperlist(..., view)` iterates CopperList slots with resolved component identity.
1675///
1676/// Error policy:
1677/// - [`Decision::Ignore`] continues execution.
1678/// - [`Decision::Abort`] aborts the current operation (step/copperlist scope).
1679/// - [`Decision::Shutdown`] triggers runtime shutdown.
1680pub trait CuMonitor: Sized {
1681    /// Construct the monitor once, before component execution starts.
1682    ///
1683    /// `metadata` contains mission/config/topology/static mapping information.
1684    /// `runtime` exposes dynamic runtime handles (for example execution probes).
1685    /// Use `metadata.monitor_config()` to decode monitor-specific parameters.
1686    fn new(metadata: CuMonitoringMetadata, runtime: CuMonitoringRuntime) -> CuResult<Self>
1687    where
1688        Self: Sized;
1689
1690    /// Called once before processing the first CopperList.
1691    fn start(&mut self, _ctx: &CuContext) -> CuResult<()> {
1692        Ok(())
1693    }
1694
1695    /// Called once per processed CopperList after component execution.
1696    fn process_copperlist(&self, _ctx: &CuContext, view: CopperListView<'_>) -> CuResult<()>;
1697
1698    /// Called when runtime finishes CopperList serialization/IO accounting.
1699    fn observe_copperlist_io(&self, _stats: CopperListIoStats) {}
1700
1701    /// Called after each monitored component step with the heap allocation delta
1702    /// observed by the runtime around the call.
1703    ///
1704    /// `allocated_bytes` / `deallocated_bytes` are computed from the global
1705    /// `CountingAlloc` counters when the runtime is built with
1706    /// `feature = "memory_monitoring"`. Without that feature the runtime still
1707    /// invokes this hook but always reports `0` — implementations can treat
1708    /// that as "monitoring disabled at link time" and degrade gracefully (e.g.
1709    /// log a one-shot warning).
1710    ///
1711    /// `component_id` is an index into [`CuMonitoringMetadata::components`].
1712    fn observe_alloc(
1713        &self,
1714        _component_id: ComponentId,
1715        _step: CuComponentState,
1716        _allocated_bytes: usize,
1717        _deallocated_bytes: usize,
1718    ) {
1719    }
1720
1721    /// Called when a monitored component step fails; must return an immediate runtime decision.
1722    ///
1723    /// `component_id` is an index into [`CuMonitoringMetadata::components`].
1724    fn process_error(
1725        &self,
1726        component_id: ComponentId,
1727        step: CuComponentState,
1728        error: &CuError,
1729    ) -> Decision;
1730
1731    /// Called when the runtime catches a panic (`std` builds).
1732    fn process_panic(&self, _panic_message: &str) {}
1733
1734    /// Called once during runtime shutdown.
1735    fn stop(&mut self, _ctx: &CuContext) -> CuResult<()> {
1736        Ok(())
1737    }
1738}
1739
1740/// A do nothing monitor if no monitor is provided.
1741/// This is basically defining the default behavior of Copper in case of error.
1742pub struct NoMonitor {
1743    #[cfg(all(feature = "std", debug_assertions))]
1744    live_log_listener: Option<LiveLogListenerGuard>,
1745}
1746impl CuMonitor for NoMonitor {
1747    fn new(_metadata: CuMonitoringMetadata, _runtime: CuMonitoringRuntime) -> CuResult<Self> {
1748        Ok(NoMonitor {
1749            #[cfg(all(feature = "std", debug_assertions))]
1750            live_log_listener: None,
1751        })
1752    }
1753
1754    fn start(&mut self, _ctx: &CuContext) -> CuResult<()> {
1755        #[cfg(all(feature = "std", debug_assertions))]
1756        {
1757            self.live_log_listener = Some(scoped_live_log_listener(
1758                |entry, format_str, param_names| {
1759                    let params: Vec<String> = entry.params.iter().map(|v| v.to_string()).collect();
1760                    let named: Map<String, String> = param_names
1761                        .iter()
1762                        .zip(params.iter())
1763                        .map(|(k, v)| (k.to_string(), v.clone()))
1764                        .collect();
1765
1766                    if let Ok(msg) = format_message_only(format_str, params.as_slice(), &named) {
1767                        let ts = format_timestamp(entry.time.into());
1768                        println!("{} [{:?}] {}", ts, entry.level, msg);
1769                    }
1770                },
1771            ));
1772        }
1773        Ok(())
1774    }
1775
1776    fn process_copperlist(&self, _ctx: &CuContext, _view: CopperListView<'_>) -> CuResult<()> {
1777        // By default, do nothing.
1778        Ok(())
1779    }
1780
1781    fn process_error(
1782        &self,
1783        _component_id: ComponentId,
1784        _step: CuComponentState,
1785        _error: &CuError,
1786    ) -> Decision {
1787        // By default, just try to continue.
1788        Decision::Ignore
1789    }
1790
1791    fn stop(&mut self, _ctx: &CuContext) -> CuResult<()> {
1792        #[cfg(all(feature = "std", debug_assertions))]
1793        {
1794            self.live_log_listener = None;
1795        }
1796        Ok(())
1797    }
1798}
1799
1800macro_rules! impl_monitor_tuple {
1801    ($($idx:tt => $name:ident),+) => {
1802        impl<$($name: CuMonitor),+> CuMonitor for ($($name,)+) {
1803            fn new(metadata: CuMonitoringMetadata, runtime: CuMonitoringRuntime) -> CuResult<Self>
1804            where
1805                Self: Sized,
1806            {
1807                Ok(($($name::new(metadata.clone(), runtime.clone())?,)+))
1808            }
1809
1810            fn start(&mut self, ctx: &CuContext) -> CuResult<()> {
1811                $(self.$idx.start(ctx)?;)+
1812                Ok(())
1813            }
1814
1815            fn process_copperlist(&self, ctx: &CuContext, view: CopperListView<'_>) -> CuResult<()> {
1816                $(self.$idx.process_copperlist(ctx, view)?;)+
1817                Ok(())
1818            }
1819
1820            fn observe_copperlist_io(&self, stats: CopperListIoStats) {
1821                $(self.$idx.observe_copperlist_io(stats);)+
1822            }
1823
1824            fn observe_alloc(
1825                &self,
1826                component_id: ComponentId,
1827                step: CuComponentState,
1828                allocated_bytes: usize,
1829                deallocated_bytes: usize,
1830            ) {
1831                $(self.$idx.observe_alloc(component_id, step, allocated_bytes, deallocated_bytes);)+
1832            }
1833
1834            fn process_error(
1835                &self,
1836                component_id: ComponentId,
1837                step: CuComponentState,
1838                error: &CuError,
1839            ) -> Decision {
1840                let mut decision = Decision::Ignore;
1841                $(decision = merge_decision(decision, self.$idx.process_error(component_id, step, error));)+
1842                decision
1843            }
1844
1845            fn process_panic(&self, panic_message: &str) {
1846                $(self.$idx.process_panic(panic_message);)+
1847            }
1848
1849            fn stop(&mut self, ctx: &CuContext) -> CuResult<()> {
1850                $(self.$idx.stop(ctx)?;)+
1851                Ok(())
1852            }
1853        }
1854    };
1855}
1856
1857impl_monitor_tuple!(0 => M0, 1 => M1);
1858impl_monitor_tuple!(0 => M0, 1 => M1, 2 => M2);
1859impl_monitor_tuple!(0 => M0, 1 => M1, 2 => M2, 3 => M3);
1860impl_monitor_tuple!(0 => M0, 1 => M1, 2 => M2, 3 => M3, 4 => M4);
1861impl_monitor_tuple!(0 => M0, 1 => M1, 2 => M2, 3 => M3, 4 => M4, 5 => M5);
1862
1863#[cfg(feature = "std")]
1864pub fn panic_payload_to_string(payload: &(dyn core::any::Any + Send)) -> String {
1865    if let Some(msg) = payload.downcast_ref::<&str>() {
1866        (*msg).to_string()
1867    } else if let Some(msg) = payload.downcast_ref::<String>() {
1868        msg.clone()
1869    } else {
1870        "panic with non-string payload".to_string()
1871    }
1872}
1873
1874/// A simple allocator that counts the number of bytes allocated and deallocated.
1875///
1876/// Tracks two views of the same activity:
1877/// - Process-wide atomic counters (`allocated()`/`deallocated()`), suitable for
1878///   probes like [`global_allocated_bytes`].
1879/// - Per-thread `Cell<usize>` counters (when both `std` and `memory_monitoring`
1880///   are enabled), used by [`ScopedAllocCounter`] so deltas under `parallel-rt`
1881///   attribute only the calling thread's allocations to the calling thread's
1882///   open scope.
1883pub struct CountingAlloc<A: GlobalAlloc> {
1884    inner: A,
1885    allocated: AtomicUsize,
1886    deallocated: AtomicUsize,
1887}
1888
1889#[cfg(all(feature = "std", feature = "memory_monitoring"))]
1890thread_local! {
1891    static THREAD_ALLOCATED: Cell<usize> = const { Cell::new(0) };
1892    static THREAD_DEALLOCATED: Cell<usize> = const { Cell::new(0) };
1893}
1894
1895#[cfg(all(feature = "std", feature = "memory_monitoring"))]
1896#[inline]
1897fn bump_thread_allocated(bytes: usize) {
1898    // try_with is no-op during TLS teardown — we attribute nothing in that
1899    // window, which is fine: monitored steps don't run while a worker thread is
1900    // being torn down.
1901    let _ = THREAD_ALLOCATED.try_with(|c| c.set(c.get().wrapping_add(bytes)));
1902}
1903
1904#[cfg(all(feature = "std", feature = "memory_monitoring"))]
1905#[inline]
1906fn bump_thread_deallocated(bytes: usize) {
1907    let _ = THREAD_DEALLOCATED.try_with(|c| c.set(c.get().wrapping_add(bytes)));
1908}
1909
1910#[cfg(all(feature = "std", feature = "memory_monitoring"))]
1911#[inline]
1912fn read_thread_allocated() -> usize {
1913    THREAD_ALLOCATED.try_with(|c| c.get()).unwrap_or(0)
1914}
1915
1916#[cfg(all(feature = "std", feature = "memory_monitoring"))]
1917#[inline]
1918fn read_thread_deallocated() -> usize {
1919    THREAD_DEALLOCATED.try_with(|c| c.get()).unwrap_or(0)
1920}
1921
1922impl<A: GlobalAlloc> CountingAlloc<A> {
1923    pub const fn new(inner: A) -> Self {
1924        CountingAlloc {
1925            inner,
1926            allocated: AtomicUsize::new(0),
1927            deallocated: AtomicUsize::new(0),
1928        }
1929    }
1930
1931    pub fn allocated(&self) -> usize {
1932        self.allocated.load(Ordering::SeqCst)
1933    }
1934
1935    pub fn deallocated(&self) -> usize {
1936        self.deallocated.load(Ordering::SeqCst)
1937    }
1938
1939    pub fn reset(&self) {
1940        self.allocated.store(0, Ordering::SeqCst);
1941        self.deallocated.store(0, Ordering::SeqCst);
1942    }
1943}
1944
1945// SAFETY: Delegates allocation/deallocation to the inner allocator while tracking sizes.
1946unsafe impl<A: GlobalAlloc> GlobalAlloc for CountingAlloc<A> {
1947    // SAFETY: Callers uphold the GlobalAlloc contract; we delegate to the inner allocator.
1948    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
1949        // SAFETY: Forwarding to the inner allocator preserves GlobalAlloc invariants.
1950        let p = unsafe { self.inner.alloc(layout) };
1951        if !p.is_null() {
1952            self.allocated.fetch_add(layout.size(), Ordering::SeqCst);
1953            #[cfg(all(feature = "std", feature = "memory_monitoring"))]
1954            bump_thread_allocated(layout.size());
1955        }
1956        p
1957    }
1958
1959    // SAFETY: Callers uphold the GlobalAlloc contract; we delegate to the inner allocator.
1960    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
1961        // SAFETY: Forwarding to the inner allocator preserves GlobalAlloc invariants.
1962        unsafe { self.inner.dealloc(ptr, layout) }
1963        self.deallocated.fetch_add(layout.size(), Ordering::SeqCst);
1964        #[cfg(all(feature = "std", feature = "memory_monitoring"))]
1965        bump_thread_deallocated(layout.size());
1966    }
1967}
1968
1969/// Total bytes ever allocated through the `CountingAlloc` global allocator.
1970///
1971/// Returns `None` when the runtime was not built with `feature = "memory_monitoring"`
1972/// (no counting allocator installed). Returns `Some(_)` otherwise.
1973///
1974/// Counters are monotonic and saturate at `usize::MAX`; subtract two snapshots
1975/// to get a delta over a window.
1976#[inline]
1977pub fn global_allocated_bytes() -> Option<usize> {
1978    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
1979    {
1980        Some(GLOBAL.allocated())
1981    }
1982    #[cfg(not(all(feature = "std", feature = "memory_monitoring")))]
1983    {
1984        None
1985    }
1986}
1987
1988/// Total bytes ever returned to the `CountingAlloc` global allocator.
1989///
1990/// See [`global_allocated_bytes`] for availability semantics.
1991#[inline]
1992pub fn global_deallocated_bytes() -> Option<usize> {
1993    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
1994    {
1995        Some(GLOBAL.deallocated())
1996    }
1997    #[cfg(not(all(feature = "std", feature = "memory_monitoring")))]
1998    {
1999        None
2000    }
2001}
2002
2003/// Scoped helper that captures the calling thread's allocation counters on
2004/// construction and exposes the delta accumulated by the time you query it.
2005///
2006/// Per-thread accounting matters under `parallel-rt`: multiple worker threads
2007/// run monitored steps concurrently, and a delta computed from the
2008/// process-wide counters would attribute every other thread's allocations to
2009/// the local step. The counters this type snapshots are thread-local, so
2010/// `allocated()` only reflects allocations performed on the same thread that
2011/// constructed the counter.
2012///
2013/// When the runtime is built without `feature = "memory_monitoring"`, this
2014/// becomes a zero-sized no-op: `allocated()`/`deallocated()` return `0`, the
2015/// constructor performs no TLS load, and the type is fully eliminated by the
2016/// optimizer. This lets the runtime macro emit the same code in both builds.
2017///
2018/// `ScopedAllocCounter` is intentionally pinned to the constructing thread —
2019/// the macro always uses it as a stack-local within a single step, so this
2020/// matches the only realistic usage pattern.
2021///
2022/// # Example
2023/// ```
2024/// use cu29_runtime::monitoring::ScopedAllocCounter;
2025///
2026/// let counter = ScopedAllocCounter::new();
2027/// let _vec = vec![0u8; 1024];
2028/// // With `memory_monitoring`: `counter.allocated() >= 1024`.
2029/// // Without: `counter.allocated() == 0`.
2030/// let _ = counter.allocated();
2031/// ```
2032#[must_use]
2033pub struct ScopedAllocCounter {
2034    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2035    bf_allocated: usize,
2036    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2037    bf_deallocated: usize,
2038    // Marker that pins the counter to the constructing thread: under
2039    // `parallel-rt` it's a logic error to read the delta from another thread,
2040    // since the TLS counters being read belong to the reading thread, not the
2041    // thread the snapshot was taken on. `PhantomData<*const ()>` is a ZST so
2042    // this doesn't change the type's footprint.
2043    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2044    _not_send_sync: core::marker::PhantomData<*const ()>,
2045}
2046
2047impl Default for ScopedAllocCounter {
2048    fn default() -> Self {
2049        Self::new()
2050    }
2051}
2052
2053impl ScopedAllocCounter {
2054    #[inline]
2055    pub fn new() -> Self {
2056        #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2057        {
2058            ScopedAllocCounter {
2059                bf_allocated: read_thread_allocated(),
2060                bf_deallocated: read_thread_deallocated(),
2061                _not_send_sync: core::marker::PhantomData,
2062            }
2063        }
2064        #[cfg(not(all(feature = "std", feature = "memory_monitoring")))]
2065        {
2066            ScopedAllocCounter {}
2067        }
2068    }
2069
2070    /// Bytes allocated by the constructing thread since this counter was
2071    /// created. Always `0` when `memory_monitoring` is not enabled.
2072    #[inline]
2073    pub fn allocated(&self) -> usize {
2074        #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2075        {
2076            read_thread_allocated().wrapping_sub(self.bf_allocated)
2077        }
2078        #[cfg(not(all(feature = "std", feature = "memory_monitoring")))]
2079        {
2080            0
2081        }
2082    }
2083
2084    /// Bytes deallocated by the constructing thread since this counter was
2085    /// created. Always `0` when `memory_monitoring` is not enabled.
2086    #[inline]
2087    pub fn deallocated(&self) -> usize {
2088        #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2089        {
2090            read_thread_deallocated().wrapping_sub(self.bf_deallocated)
2091        }
2092        #[cfg(not(all(feature = "std", feature = "memory_monitoring")))]
2093        {
2094            0
2095        }
2096    }
2097}
2098
2099#[cfg(feature = "std")]
2100const BUCKET_COUNT: usize = 1024;
2101#[cfg(not(feature = "std"))]
2102const BUCKET_COUNT: usize = 256;
2103
2104/// Accumulative stat object that can give your some real time statistics.
2105/// Uses a fixed-size bucketed histogram for accurate percentile calculations.
2106#[derive(Debug, Clone)]
2107pub struct LiveStatistics {
2108    buckets: [u64; BUCKET_COUNT],
2109    min_val: u64,
2110    max_val: u64,
2111    sum: u128,
2112    sum_sq: u128,
2113    count: u64,
2114    max_value: u64,
2115}
2116
2117impl LiveStatistics {
2118    /// Creates a new `LiveStatistics` instance with a specified maximum value.
2119    ///
2120    /// This function initializes a `LiveStatistics` structure with default values
2121    /// for tracking statistical data, while setting an upper limit for the data
2122    /// points that the structure tracks.
2123    ///
2124    /// # Parameters
2125    /// - `max_value` (`u64`): The maximum value that can be recorded or tracked.
2126    ///
2127    /// # Returns
2128    /// A new instance of `LiveStatistics` with:
2129    /// - `buckets`: An array pre-filled with zeros to categorize data points.
2130    /// - `min_val`: Initialized to the maximum possible `u64` value to track the minimum correctly.
2131    /// - `max_val`: Initialized to zero.
2132    /// - `sum`: The sum of all data points, initialized to zero.
2133    /// - `sum_sq`: The sum of squares of all data points, initialized to zero.
2134    /// - `count`: The total number of data points, initialized to zero.
2135    /// - `max_value`: The maximum allowable value for data points, set to the provided `max_value`.
2136    ///
2137    pub fn new_with_max(max_value: u64) -> Self {
2138        LiveStatistics {
2139            buckets: [0; BUCKET_COUNT],
2140            min_val: u64::MAX,
2141            max_val: 0,
2142            sum: 0,
2143            sum_sq: 0,
2144            count: 0,
2145            max_value,
2146        }
2147    }
2148
2149    #[inline]
2150    fn value_to_bucket(&self, value: u64) -> usize {
2151        if value >= self.max_value {
2152            BUCKET_COUNT - 1
2153        } else {
2154            ((value as u128 * BUCKET_COUNT as u128) / self.max_value as u128) as usize
2155        }
2156    }
2157
2158    #[inline]
2159    pub fn min(&self) -> u64 {
2160        if self.count == 0 { 0 } else { self.min_val }
2161    }
2162
2163    #[inline]
2164    pub fn max(&self) -> u64 {
2165        self.max_val
2166    }
2167
2168    #[inline]
2169    pub fn mean(&self) -> f64 {
2170        if self.count == 0 {
2171            0.0
2172        } else {
2173            self.sum as f64 / self.count as f64
2174        }
2175    }
2176
2177    #[inline]
2178    pub fn stdev(&self) -> f64 {
2179        if self.count == 0 {
2180            return 0.0;
2181        }
2182        let mean = self.mean();
2183        let variance = (self.sum_sq as f64 / self.count as f64) - (mean * mean);
2184        if variance < 0.0 {
2185            return 0.0;
2186        }
2187        #[cfg(feature = "std")]
2188        return variance.sqrt();
2189        #[cfg(not(feature = "std"))]
2190        return sqrt(variance);
2191    }
2192
2193    #[inline]
2194    pub fn percentile(&self, percentile: f64) -> u64 {
2195        if self.count == 0 {
2196            return 0;
2197        }
2198
2199        let target_count = (self.count as f64 * percentile) as u64;
2200        let mut accumulated = 0u64;
2201
2202        for (bucket_idx, &bucket_count) in self.buckets.iter().enumerate() {
2203            accumulated += bucket_count;
2204            if accumulated >= target_count {
2205                // Linear interpolation within the bucket
2206                let bucket_start = (bucket_idx as u64 * self.max_value) / BUCKET_COUNT as u64;
2207                let bucket_end = ((bucket_idx + 1) as u64 * self.max_value) / BUCKET_COUNT as u64;
2208                let bucket_fraction = if bucket_count > 0 {
2209                    (target_count - (accumulated - bucket_count)) as f64 / bucket_count as f64
2210                } else {
2211                    0.5
2212                };
2213                return bucket_start
2214                    + ((bucket_end - bucket_start) as f64 * bucket_fraction) as u64;
2215            }
2216        }
2217
2218        self.max_val
2219    }
2220
2221    /// Adds a value to the statistics.
2222    #[inline]
2223    pub fn record(&mut self, value: u64) {
2224        if value < self.min_val {
2225            self.min_val = value;
2226        }
2227        if value > self.max_val {
2228            self.max_val = value;
2229        }
2230        let value_u128 = value as u128;
2231        self.sum += value_u128;
2232        self.sum_sq += value_u128 * value_u128;
2233        self.count += 1;
2234
2235        let bucket = self.value_to_bucket(value);
2236        self.buckets[bucket] += 1;
2237    }
2238
2239    #[inline]
2240    pub fn len(&self) -> u64 {
2241        self.count
2242    }
2243
2244    #[inline]
2245    pub fn is_empty(&self) -> bool {
2246        self.count == 0
2247    }
2248
2249    #[inline]
2250    pub fn reset(&mut self) {
2251        self.buckets.fill(0);
2252        self.min_val = u64::MAX;
2253        self.max_val = 0;
2254        self.sum = 0;
2255        self.sum_sq = 0;
2256        self.count = 0;
2257    }
2258}
2259
2260/// A Specialized statistics object for CuDuration.
2261/// It will also keep track of the jitter between the values.
2262#[derive(Debug, Clone)]
2263pub struct CuDurationStatistics {
2264    bare: LiveStatistics,
2265    jitter: LiveStatistics,
2266    last_value: CuDuration,
2267}
2268
2269impl CuDurationStatistics {
2270    pub fn new(max: CuDuration) -> Self {
2271        let CuDuration(max) = max;
2272        CuDurationStatistics {
2273            bare: LiveStatistics::new_with_max(max),
2274            jitter: LiveStatistics::new_with_max(max),
2275            last_value: CuDuration::default(),
2276        }
2277    }
2278
2279    #[inline]
2280    pub fn min(&self) -> CuDuration {
2281        CuDuration(self.bare.min())
2282    }
2283
2284    #[inline]
2285    pub fn max(&self) -> CuDuration {
2286        CuDuration(self.bare.max())
2287    }
2288
2289    #[inline]
2290    pub fn mean(&self) -> CuDuration {
2291        CuDuration(self.bare.mean() as u64) // CuDuration is in ns, it is ok.
2292    }
2293
2294    #[inline]
2295    pub fn percentile(&self, percentile: f64) -> CuDuration {
2296        CuDuration(self.bare.percentile(percentile))
2297    }
2298
2299    #[inline]
2300    pub fn stddev(&self) -> CuDuration {
2301        CuDuration(self.bare.stdev() as u64)
2302    }
2303
2304    #[inline]
2305    pub fn len(&self) -> u64 {
2306        self.bare.len()
2307    }
2308
2309    #[inline]
2310    pub fn is_empty(&self) -> bool {
2311        self.bare.len() == 0
2312    }
2313
2314    #[inline]
2315    pub fn jitter_min(&self) -> CuDuration {
2316        CuDuration(self.jitter.min())
2317    }
2318
2319    #[inline]
2320    pub fn jitter_max(&self) -> CuDuration {
2321        CuDuration(self.jitter.max())
2322    }
2323
2324    #[inline]
2325    pub fn jitter_mean(&self) -> CuDuration {
2326        CuDuration(self.jitter.mean() as u64)
2327    }
2328
2329    #[inline]
2330    pub fn jitter_stddev(&self) -> CuDuration {
2331        CuDuration(self.jitter.stdev() as u64)
2332    }
2333
2334    #[inline]
2335    pub fn jitter_percentile(&self, percentile: f64) -> CuDuration {
2336        CuDuration(self.jitter.percentile(percentile))
2337    }
2338
2339    #[inline]
2340    pub fn record(&mut self, value: CuDuration) {
2341        let CuDuration(nanos) = value;
2342        if self.bare.is_empty() {
2343            self.bare.record(nanos);
2344            self.last_value = value;
2345            return;
2346        }
2347        self.bare.record(nanos);
2348        let CuDuration(last_nanos) = self.last_value;
2349        self.jitter.record(nanos.abs_diff(last_nanos));
2350        self.last_value = value;
2351    }
2352
2353    #[inline]
2354    pub fn reset(&mut self) {
2355        self.bare.reset();
2356        self.jitter.reset();
2357    }
2358}
2359
2360#[cfg(test)]
2361mod tests {
2362    use super::*;
2363    use core::sync::atomic::{AtomicUsize, Ordering};
2364
2365    #[derive(Clone, Copy)]
2366    enum TestDecision {
2367        Ignore,
2368        Abort,
2369        Shutdown,
2370    }
2371
2372    struct TestMonitor {
2373        decision: TestDecision,
2374        copperlist_calls: AtomicUsize,
2375        panic_calls: AtomicUsize,
2376        alloc_calls: AtomicUsize,
2377        last_alloc_bytes: AtomicUsize,
2378        last_dealloc_bytes: AtomicUsize,
2379    }
2380
2381    impl TestMonitor {
2382        fn new_with(decision: TestDecision) -> Self {
2383            Self {
2384                decision,
2385                copperlist_calls: AtomicUsize::new(0),
2386                panic_calls: AtomicUsize::new(0),
2387                alloc_calls: AtomicUsize::new(0),
2388                last_alloc_bytes: AtomicUsize::new(0),
2389                last_dealloc_bytes: AtomicUsize::new(0),
2390            }
2391        }
2392    }
2393
2394    fn test_metadata() -> CuMonitoringMetadata {
2395        const COMPONENTS: &[MonitorComponentMetadata] = &[
2396            MonitorComponentMetadata::new("a", ComponentType::Task, None),
2397            MonitorComponentMetadata::new("b", ComponentType::Task, None),
2398        ];
2399        CuMonitoringMetadata::new(
2400            CompactString::from(crate::config::DEFAULT_MISSION_ID),
2401            COMPONENTS,
2402            &[],
2403            CopperListInfo::new(0, 0),
2404            MonitorTopology::default(),
2405            None,
2406        )
2407        .expect("test metadata should be valid")
2408    }
2409
2410    impl CuMonitor for TestMonitor {
2411        fn new(_metadata: CuMonitoringMetadata, runtime: CuMonitoringRuntime) -> CuResult<Self> {
2412            let monitor = Self::new_with(TestDecision::Ignore);
2413            #[cfg(feature = "std")]
2414            let _ = runtime.execution_probe();
2415            Ok(monitor)
2416        }
2417
2418        fn process_copperlist(&self, _ctx: &CuContext, _view: CopperListView<'_>) -> CuResult<()> {
2419            self.copperlist_calls.fetch_add(1, Ordering::SeqCst);
2420            Ok(())
2421        }
2422
2423        fn process_error(
2424            &self,
2425            _component_id: ComponentId,
2426            _step: CuComponentState,
2427            _error: &CuError,
2428        ) -> Decision {
2429            match self.decision {
2430                TestDecision::Ignore => Decision::Ignore,
2431                TestDecision::Abort => Decision::Abort,
2432                TestDecision::Shutdown => Decision::Shutdown,
2433            }
2434        }
2435
2436        fn process_panic(&self, _panic_message: &str) {
2437            self.panic_calls.fetch_add(1, Ordering::SeqCst);
2438        }
2439
2440        fn observe_alloc(
2441            &self,
2442            _component_id: ComponentId,
2443            _step: CuComponentState,
2444            allocated_bytes: usize,
2445            deallocated_bytes: usize,
2446        ) {
2447            self.alloc_calls.fetch_add(1, Ordering::SeqCst);
2448            self.last_alloc_bytes
2449                .store(allocated_bytes, Ordering::SeqCst);
2450            self.last_dealloc_bytes
2451                .store(deallocated_bytes, Ordering::SeqCst);
2452        }
2453    }
2454
2455    #[test]
2456    fn test_live_statistics_percentiles() {
2457        let mut stats = LiveStatistics::new_with_max(1000);
2458
2459        // Record 100 values from 0 to 99
2460        for i in 0..100 {
2461            stats.record(i);
2462        }
2463
2464        assert_eq!(stats.len(), 100);
2465        assert_eq!(stats.min(), 0);
2466        assert_eq!(stats.max(), 99);
2467        assert_eq!(stats.mean() as u64, 49); // Average of 0..99
2468
2469        // Test percentiles - should be approximately correct
2470        let p50 = stats.percentile(0.5);
2471        let p90 = stats.percentile(0.90);
2472        let p95 = stats.percentile(0.95);
2473        let p99 = stats.percentile(0.99);
2474
2475        // With 100 samples from 0-99, percentiles should be close to their index
2476        assert!((p50 as i64 - 49).abs() < 5, "p50={} expected ~49", p50);
2477        assert!((p90 as i64 - 89).abs() < 5, "p90={} expected ~89", p90);
2478        assert!((p95 as i64 - 94).abs() < 5, "p95={} expected ~94", p95);
2479        assert!((p99 as i64 - 98).abs() < 5, "p99={} expected ~98", p99);
2480    }
2481
2482    #[test]
2483    fn test_duration_stats() {
2484        let mut stats = CuDurationStatistics::new(CuDuration(1000));
2485        stats.record(CuDuration(100));
2486        stats.record(CuDuration(200));
2487        stats.record(CuDuration(500));
2488        stats.record(CuDuration(400));
2489        assert_eq!(stats.min(), CuDuration(100));
2490        assert_eq!(stats.max(), CuDuration(500));
2491        assert_eq!(stats.mean(), CuDuration(300));
2492        assert_eq!(stats.len(), 4);
2493        assert_eq!(stats.jitter.len(), 3);
2494        assert_eq!(stats.jitter_min(), CuDuration(100));
2495        assert_eq!(stats.jitter_max(), CuDuration(300));
2496        assert_eq!(stats.jitter_mean(), CuDuration((100 + 300 + 100) / 3));
2497        stats.reset();
2498        assert_eq!(stats.len(), 0);
2499    }
2500
2501    #[test]
2502    fn test_duration_stats_large_samples_do_not_overflow() {
2503        let mut stats = CuDurationStatistics::new(CuDuration(10_000_000_000));
2504        stats.record(CuDuration(5_000_000_000));
2505        stats.record(CuDuration(8_000_000_000));
2506
2507        assert_eq!(stats.min(), CuDuration(5_000_000_000));
2508        assert_eq!(stats.max(), CuDuration(8_000_000_000));
2509        assert_eq!(stats.mean(), CuDuration(6_500_000_000));
2510        assert!(stats.stddev().as_nanos().abs_diff(1_500_000_000) <= 1);
2511        assert_eq!(stats.jitter_mean(), CuDuration(3_000_000_000));
2512    }
2513
2514    #[test]
2515    fn tuple_monitor_merges_contradictory_decisions_with_strictest_wins() {
2516        let err = CuError::from("boom");
2517
2518        let two = (
2519            TestMonitor::new_with(TestDecision::Ignore),
2520            TestMonitor::new_with(TestDecision::Shutdown),
2521        );
2522        assert!(matches!(
2523            two.process_error(ComponentId::new(0), CuComponentState::Process, &err),
2524            Decision::Shutdown
2525        ));
2526
2527        let two = (
2528            TestMonitor::new_with(TestDecision::Ignore),
2529            TestMonitor::new_with(TestDecision::Abort),
2530        );
2531        assert!(matches!(
2532            two.process_error(ComponentId::new(0), CuComponentState::Process, &err),
2533            Decision::Abort
2534        ));
2535    }
2536
2537    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2538    #[test]
2539    fn scoped_alloc_counter_attributes_only_calling_threads_allocations() {
2540        // Under parallel-rt, monitored steps run on different worker threads
2541        // concurrently. ScopedAllocCounter must read per-thread counters so
2542        // thread A's open scope is not contaminated by allocations made by
2543        // thread B in the same wall-clock window.
2544        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2545        use std::sync::{Arc, Barrier};
2546
2547        let barrier = Arc::new(Barrier::new(2));
2548        let other_running = Arc::new(AtomicBool::new(true));
2549
2550        let b = barrier.clone();
2551        let flag = other_running.clone();
2552        let other = std::thread::spawn(move || {
2553            // Wait until the main thread has opened its scope, then churn
2554            // through allocations the entire time it is sampling.
2555            b.wait();
2556            let mut total: usize = 0;
2557            while flag.load(AtomicOrdering::Relaxed) {
2558                let v: Vec<u8> = vec![0u8; 1024 * 16];
2559                total = total.wrapping_add(v.len());
2560            }
2561            total
2562        });
2563
2564        let counter = ScopedAllocCounter::new();
2565        barrier.wait();
2566        // Sample for long enough that the other thread definitely allocates a
2567        // lot more than the 1 KiB we deliberately allocate here.
2568        let local_alloc_size = 1024;
2569        let local = vec![0u8; local_alloc_size];
2570        std::thread::sleep(std::time::Duration::from_millis(50));
2571        let observed = counter.allocated();
2572        other_running.store(false, AtomicOrdering::Relaxed);
2573        drop(local);
2574        let _ = other.join();
2575
2576        // The other thread will have allocated megabytes during the sleep —
2577        // an upper bound of ~1 MiB on this thread's delta is comfortably
2578        // below that and well above the local allocation.
2579        assert!(
2580            observed >= local_alloc_size,
2581            "expected at least {local_alloc_size} B, got {observed}"
2582        );
2583        assert!(
2584            observed < 1024 * 1024,
2585            "thread-local scope leaked across threads: observed {observed} B"
2586        );
2587    }
2588
2589    #[test]
2590    fn scoped_alloc_counter_reports_zero_when_feature_off_and_nonzero_when_on() {
2591        // We can't change link-time feature flags from a test, so we exercise
2592        // both halves of the API contract regardless of feature state:
2593        //
2594        // * `global_allocated_bytes()` returns `Some` iff the feature is on.
2595        // * `ScopedAllocCounter::allocated()` returns a sensible value either way
2596        //   (0 when off, monotonically non-decreasing across a Vec alloc when on).
2597        let availability_matches = match global_allocated_bytes() {
2598            Some(_) => cfg!(all(feature = "std", feature = "memory_monitoring")),
2599            None => !cfg!(all(feature = "std", feature = "memory_monitoring")),
2600        };
2601        assert!(availability_matches, "feature-gate cfg mismatch");
2602
2603        let counter = ScopedAllocCounter::new();
2604        let _v = vec![0u8; 4096];
2605        let allocated = counter.allocated();
2606        if cfg!(all(feature = "std", feature = "memory_monitoring")) {
2607            assert!(
2608                allocated >= 4096,
2609                "expected at least 4096 B allocated, got {allocated}"
2610            );
2611        } else {
2612            assert_eq!(allocated, 0, "feature off must report 0");
2613        }
2614    }
2615
2616    #[test]
2617    fn tuple_monitor_fans_out_observe_alloc() {
2618        let monitors = <(TestMonitor, TestMonitor) as CuMonitor>::new(
2619            test_metadata(),
2620            CuMonitoringRuntime::unavailable(),
2621        )
2622        .expect("tuple new");
2623        monitors.observe_alloc(ComponentId::new(0), CuComponentState::Process, 128, 64);
2624
2625        assert_eq!(monitors.0.alloc_calls.load(Ordering::SeqCst), 1);
2626        assert_eq!(monitors.1.alloc_calls.load(Ordering::SeqCst), 1);
2627        assert_eq!(monitors.0.last_alloc_bytes.load(Ordering::SeqCst), 128);
2628        assert_eq!(monitors.1.last_dealloc_bytes.load(Ordering::SeqCst), 64);
2629    }
2630
2631    #[test]
2632    fn tuple_monitor_fans_out_callbacks() {
2633        let monitors = <(TestMonitor, TestMonitor) as CuMonitor>::new(
2634            test_metadata(),
2635            CuMonitoringRuntime::unavailable(),
2636        )
2637        .expect("tuple new");
2638        let (ctx, _clock_control) = CuContext::new_mock_clock();
2639        let empty_view = test_metadata().layout().view(&[]);
2640        monitors
2641            .process_copperlist(&ctx, empty_view)
2642            .expect("process_copperlist should fan out");
2643        monitors.process_panic("panic marker");
2644
2645        assert_eq!(monitors.0.copperlist_calls.load(Ordering::SeqCst), 1);
2646        assert_eq!(monitors.1.copperlist_calls.load(Ordering::SeqCst), 1);
2647        assert_eq!(monitors.0.panic_calls.load(Ordering::SeqCst), 1);
2648        assert_eq!(monitors.1.panic_calls.load(Ordering::SeqCst), 1);
2649    }
2650
2651    fn encoded_size<E: Encode>(value: &E) -> usize {
2652        let mut encoder = EncoderImpl::<_, _>::new(SizeWriter::default(), standard());
2653        value
2654            .encode(&mut encoder)
2655            .expect("size measurement encoder should not fail");
2656        encoder.into_writer().bytes_written
2657    }
2658
2659    #[test]
2660    fn payload_io_stats_tracks_encode_path_size_for_plain_payloads() {
2661        let payload = vec![1u8, 2, 3, 4];
2662        let io = payload_io_stats(&payload).expect("payload IO measurement should succeed");
2663
2664        assert_eq!(io.encoded_bytes, encoded_size(&payload));
2665        assert_eq!(io.resident_bytes, core::mem::size_of::<Vec<u8>>());
2666        assert_eq!(io.handle_bytes, 0);
2667    }
2668
2669    #[test]
2670    fn payload_io_stats_tracks_handle_backed_storage() {
2671        let payload = crate::pool::CuHandle::new_detached(vec![0u8; 32]);
2672        let io = payload_io_stats(&payload).expect("payload IO measurement should succeed");
2673
2674        assert_eq!(io.encoded_bytes, encoded_size(&payload));
2675        assert_eq!(
2676            io.resident_bytes,
2677            core::mem::size_of::<crate::pool::CuHandle<Vec<u8>>>() + 32
2678        );
2679        assert_eq!(io.handle_bytes, 32);
2680    }
2681
2682    #[test]
2683    fn runtime_execution_probe_roundtrip_marker() {
2684        let probe = RuntimeExecutionProbe::default();
2685        assert!(probe.marker().is_none());
2686        assert_eq!(probe.sequence(), 0);
2687
2688        probe.record(ExecutionMarker {
2689            component_id: ComponentId::new(7),
2690            step: CuComponentState::Process,
2691            culistid: Some(42),
2692        });
2693
2694        let marker = probe.marker().expect("marker should be available");
2695        assert_eq!(marker.component_id, ComponentId::new(7));
2696        assert!(matches!(marker.step, CuComponentState::Process));
2697        assert_eq!(marker.culistid, Some(42));
2698        assert_eq!(probe.sequence(), 1);
2699    }
2700}