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