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